“回调”这个东西,说小了是个 trick,说大了是一种控制流,再大了是一种“计算的组织形式”
你在使用其他代码时,可以看做是对一个“ad-hoc, informally-specified, bug-ridden”的 EDSL 进行编程。比如说一个数组有 count、find、remove 等操作,这是操作数组的 EDSL,但是我需要一个 max 或者 sum 之类的操作,这超过了这个 EDSL 本身的能力,你就得用数组 EDSL 里面一个类似 fold 的操作再结合宿主语言的原语糊上去。不一定是回调函数,在古典时代的 C++ 可能是个 “functor”,在古典时代的 Java 是个 class,但是在这个层面上没有区别。
从程序组织的角度,需要回调(或类似回调的机制)的原因就是你所要解决的问题超出了这个函数 /库(也就是这个 EDSL )本身的能力或范围——比如数组库本身是不关心它的元素是如何 sum 到一块的。再举个例子,GUI 库里面有个很常见的组件叫 Button (按钮),Button 有几个属性可以自定义,比如(按 CSS 的体系) background, font, box-shadow, text-shadow, border-radius, opacity,这是针对 Button 的 EDSL。但是你说这些不够,我想要 Material Design 里面那种 Ripple Effect (
https://codepen.io/Craigtut/pen/dIfzv ),这你就得使用图灵完全的宿主语言来扩展 GUI 库的能力,而扩展的方式就是回调函数或者类似回调函数的某种机制。
古典时代的 GUI 框架一般有上面那些属性,但是表达力不如现在的 CSS,开发者对程序的控制上限更高但也更麻烦,一般会重写一个什么 OnPaint 函数之类的。就算现在的 “H5” 不使用图灵完全的 JS 的话好像最多也只能做成这样:
https://codeburst.io/create-a-material-design-ripple-effect-without-js-9d3cbee25b3e当然如果是 Google 的库,Google 也许会把这个 Ripple 内嵌到 Button 组件里面,这样你不需要写回调也可以用,但是又有新需求:鼠标划过时需要一个 hover light (
https://docs.microsoft.com/en-us/windows/uwp/design/style/reveal )。这个和 Ripple 其实没啥区别,但是组件不提供这个功能,你还得写回调。
围绕 Button 的渲染举例子其实很难,因为 V 站的环境是在讨论 GUI 时首先会扯到 Web 前端,然而现在的 HTML+CSS 已经可以直接实现很多效果。也就是说 HTML 和 CSS 这套 DSL 在 UI 图形这方面已经非常强大——但是就算是如此强大的 DSL,还是没有办法实现所有图形效果,还是需要依赖更加完善的 JS。
但是另一方面,所有的 Button 几乎都会提供一个 OnClick 回调,用户也几乎必然会去用它——用来实现你自己的应用逻辑。GUI 库只关心界面,不关心你的逻辑,所以这个压根不在人家的 scope 里面。
不过这也不是必然的,UI 里面有一个设计模式,就是弹一个模态界面向用户请求一些数据,界面里面一般会有“确定”和“取消”按钮,点了之后就会关掉这个界面。WinForms 直接把这个模式 encode 到了框架中(
https://docs.microsoft.com/en-us/dotnet/api/system.windows.forms.form.cancelbutton?view=netframework-4.8 ),这里创建了一个 Button,但是不用设置 OnClick,它就会自动关闭对话框,这个行为甚至给 StackOverflow 上的这个开发者造成了困惑(
https://stackoverflow.com/questions/1769951/c-sharp-cancelbutton-closes-dialog )。HTML 里面的表单也遵循了类似的思路。但是在写更复杂的逻辑的时候,还是得手动写回调。
总之,人类 ... 哦不 DSL 的能力是有极限的。这并不能怪库,因为第一他们本来并没有完全按照 DSL 的标准去设计,第二 DSL 能力受限是很正常的事情,第三回调机制为这个 DSL 提供了良好的扩展性以及可组合性,这是一个 feature 不是缺陷。
有没有办法避免掉回调函数(或类似于回调函数的机制),又不影响代码的功能呢?当然是可以的,我需要在 A 函数里面调 X 函数,这个 X 我没法确定,所以我给你个回调函数。解决方法:删掉 A 函数,在所有调 A 函数的地方都手动写一遍 A 函数并且把 X 写死在里面,如此递归进行——如果一直保持这样的逻辑滑坡的话,就相当于抛弃掉所有的抽象、模块化和可组合性,项目从一堆 API 的调用,变成看上去像是 BullshitGenerator 生成的超长官样函数的集合,再变成一个巨大的 main 函数 ... 最后到一堆机器码,然后你整天就对着一个十六进制数的矩阵干活
还有一种方法,就是把这个 DSL 做成更完善的形式,这个”方法“的荒谬之处(也是有趣之处)就在于,你做的这个新 DSL 如果具备基本的可用性,那必然会存在类似”函数“和”回调函数“之类的抽象,然后这个 DSL 中 Domain Specific 的那部分又构成新的蹩脚的 EDSL,最后又回到”回调函数“的老路来 ... 并且无论怎么做都不如直接用宿主语言来的方便
---
更广泛地说,回调为程序的组件本身提供了可编程性。这个”可编程性“的必要性我个人深有体会,因为我最开始是通过做游戏 mod 学习编程的,可以参见
https://zhihu.com/question/37994622/answer/79839802。不过和 RFX 不同的是,我修改的游戏并不具备可编程性——是的就是上面链接里面提到的“Westwood 系的 RTS”,RFX 提到可以修改 “rules.ini 和 arts.ini"。这两个文件其实就是正常的 Windows INI 文件(只不过有点长)。INI 文件里面无非就是 Section, Key 和 Value,也就是说它只是针对特定引擎的一种“ad-hoc, informally-specified, bug-ridden DSL”。虽然也可以玩出很多花样,但是都和简单的”修改生命值“没有本质区别。或者说只能改数据,不能改逻辑。
一般来说,游戏开发者的工作只要把游戏推上市场就行了。moddability 并不是大多数人评判游戏的指标。游戏中的大多数复杂逻辑都会以引擎中的二进制代码的形式存在,外部文件仅仅起一个提供数据的作用。虽然数据也可以是程序,但是在 moddability 对游戏开发者无益甚至会起负面作用(
https://forums.totalwar.com/discussion/142609/ca-please-release-map-editing-tools )的情况下,这么做对比传统手段并没有明显优势。(比如理论上 INI 里面可以用各种方式嵌进通用编程语言都没有问题,但是这超出了 Westwood 的技术能力,并且除了玩票之外完全没有必要)
P 社玩出了一点花样,P 社游戏的数据使用 Lua 来存储,但是看起来他们大多数情况下只是把 Lua 当 JSON 来用。值得一提的是 P 社在 Lua 中实现了一个稍微像样的 DSL,云风有一篇 blog 谈过这个问题:
https://blog.codingnow.com/2017/07/paradox_data_format.html HOI4 前两年还更新了类似”循环“的功能:
https://forum.paradoxplaza.com/forum/index.php?threads/hoi4-dev-diary-modding-and-traits.1117516。现在这个 DSL 不只是个 JSON 了,它有变量,有条件,有循环,理论上能力很强了。但是就算不讨论这个 ad-hoc 的解决方案可能的 bug,这个事情从根上就是有问题的——也许是出于内部工作流程的原因,他们明明使用了 Lua 这个完整的编程语言,但是只用它来存储数据,然后在数据里面糊出了一个蹩脚的 DSL,这个事情本来就是挺难以理解的。
同为 P 社发行的 Cities: Skylines 使用 Unity 开发,并且暴露了 C# 的 Modding API。Minecraft 因为使用 Java 开发所以直接被逆向。这些游戏的可编程性和上面的完全不在一个等级,mod 的空间也大了许多。
但是由于游戏并不是完全开源的,依然会存在各种各样的限制。
以”在缺乏可编程性的平台上实现基本的定制逻辑“为出发点,modder 们尝试过的方案包括但不限于:
* 通过组合引擎已有的功能,以及避开已知和未知的 bug 来实现新逻辑,随便找了个例子:
https://www.bilibili.com/read/cv747528 但是由于引擎本身不是可编程的,具有可组合性的逻辑并不多。相当于我给你一个已经放硬了的馒头和一份咸菜,你看看怎么做能好吃一点。
* 通过修改引擎汇编代码来实现新逻辑:
https://www.modenc.renegadeprojects.com/RockPatch 这确实能在有较高技术门槛的前提下实现一些”突破“,但是需要手动改 exe,不好实操,更不好长期维护,无法 scale。
* 用 C++ 写逻辑,然后编译成汇编代码,再注入进引擎中:
https://github.com/Ares-Developers/Ares/blob/master/src/Ext/Building/Hooks.Prism.cpp 这样维护性比上面的好了很多,出来的成果也丰富得多(文档都写这么长了:
https://ares-developers.github.io/Ares-docs/index.html ),但是从源码可以看出来只是不用写汇编,依然需要手动去定位二进制代码注入点和上下文信息
* 和上面的类似,但是目标不是扩展原有引擎,而是渐进式地重写整个引擎——把原来引擎里的函数一个一个地从汇编翻译成 C++,每写一个就用类似的注入方式把引擎里对原函数的调用指向新函数,最后得到重写后的所有源码,这时才可以不依赖原来的文件执行:
https://github.com/TheAssemblyArmada/Thyme (顺便,ZH 引擎光是二进制大小就成倍增长,这个项目存在的大前提是这游戏在发布 Mac 版时(好像外包给了 Aspyr ),忘记 strip 掉 symbol 了,导致只要拉一个 dmg 就能看到所有 mangle 过后的函数名以及 RTTI 信息 ...)
* 换用其他引擎:
https://www.moddb.com/mods/mideast-crisis-2/news/final-version-released3* 直接从头重写,GitHub 上一个 Star 数前 30 的 C# 项目就是这么来的:
https://github.com/OpenRA/OpenRA如果一开始这东西具有基本的可编程性的话,所有这些幺蛾子都不会存在。
比如星际 2 就搞得挺开心的,甚至被调侃”卖编辑器的“(
http://bbs.islga.org/read-htm-tid-837191-page-1-fpage-1.html )。当然从
https://us.forums.blizzard.com/en/warcraft3/t/for-all-of-you-aspiring-map-makers/14508 这个来看,应该是玻璃渣老传统了。另外星际 2 的竞技被当作人工智能研究对象应该不是什么新闻,这天然要求一套完全可编程的 API (
https://github.com/BurnySc2/python-sc2 ),不过早在这之前好多年,星际 1 就已经使用类似的手段搞过了(
https://bwapi.github.io )。
不过就算是开源项目,也会遇到各种历史包袱造成的定制上的限制。这种部分开放只能说最好情况下只能满足大多数人的需求。但是我混这么多年总结出来的就是:最好的可定制性就是可编程性,而最好的可编程性就是直接开源。很遗憾在开源这个事情上,游戏和 Web 是两个极端。所以我在发现了 modder ”要么一直吃屎,要么成为恶龙“的几乎是被诅咒的命运之后就退坑了。
虽然大家更喜欢使用开源项目的重要原因之一就是开源项目方便定制,但是在一般人是不会去定制大多数用到的开源项目的。(我在
https://v2ex.com/t/627912#r_8326554 这里就讨论过”定制的可能性“”定制的可行性”“可编程性是最好的可定制性”和“开源是最好的可编程性”的问题)所以就算是开源项目中,可编程性也是通过某种形式的“回调”来实现——比如你要扩展 nginx 的功能,一般做法不是直接改 nginx 源码,而是写一个 nginx 插件(其实 Apache 和 nginx 等前端服务器所遵循的“根据配置的位置(‘函数指针’)访问你的静态资源和数据服务”这一模式本来就是一种广义的回调机制)。
https://v2ex.com/t/646906 这个帖子讨论了插件机制的实现,哪个方案都离不开回调。
我折腾了这么长,提了几个“避免回调”的方法(全部 inline,完全的 DSL 和完全开源),每一个最后都归于 absurdum。可见回调是非常必要,并且存在非常广泛的一种模式。下面我尝试从更底层的角度说明这一点。
从控制流的角度来看,在“组件 A 回调组件 B”的过程中,控制流先从 A 转移到 B,再从 B 转移回 A。如果我们把自己当作 CPU 来看这个程序的话,相当于 A 和 B 两个组件的代码在交错运行(如果是多个组件相互回调那就是多个组件交错运行,但是这里只考虑两个组件的 base case,多个组件用数学归纳法结论是一样的)。如果就像我们刚才所说的,用某种方式干掉回调机制——也就是说控制流不能 ABA 交叉了。那么控制流会是什么样子?
没有中间的 B 了,那就是完全执行 A 呗?你这个 A 总得有执行完的时候吧。执行完了怎么办?
来看 Node.js 里面著名的回调使用模式:{ someStatement1; someFunction(someArgument, someCallback); }
假设这个 block 位于组件 A 中,而 someFunction 属于组件 B。控制流先从 A 的 someStatement1 转到 B 的 someFunction,在此之后某个时刻可能会转到 A 的 someCallback。著名的“回调地狱”则指一般情况下大量逻辑位于 someCallback 中,并且 someCallback 会递归地嵌套类似的 pattern。
光从控制流来看,这就是上面说的“A 到 B 再到 A”的过程,而我上面没有提到的是这个“A 组件”中的“组件”到底是什么,哪个粒度的。编程语言允许开发者组合很多东西,值,变量,类型。但是函数是特殊的——这体现在当讨论“控制流”时(尤其是上面两段),大概率同时也在讨论“函数”,在讨论“模块”和“组件”时,一般指的是函数的集合——你不会写一个 100 行的函数,然后在这个函数里面分出 6 个“组件”画在架构图上,函数是模块划分的最细粒度。所以不管这个“组件”指的是什么,都绕不过函数。
那么可以尝试把“从 A 组件到 B 组件再到 A 组件”中的“组件”替换成”函数“,就是”从 A 函数到 B 函数再到 A 函数“,而这个可以用更简练的语言表达,就是:”A 函数调用 B 函数“ ...
上面那个代码从控制流角度可以”简化“为:{ someStatement1; var t = someFunction(someArgument); someCallback(t); }
所以楼主要问怎么理解”回调函数“,我觉得不需要理解”回调“,只需要理解”函数“。
如果觉得哪里不对,考虑函数调用在实现上的具体过程:正确实现函数调用需要一个 activation record (可以没有,但是无法处理非 tail-recursive 的函数),activation record 可以在 hardware stack 上,也可以是某种数据结构(或者 hardware stack、寄存器和内存数据结构的某种组合)。这里可能会存储参数、局部变量等,但最重要的是一定会有一个返回地址。
换句话说,在调用函数时,”这个调用返回到什么地方“这个信息被隐式地当作一个参数传进去了。如果没有这个信息,函数执行完之后不知道该怎么返回。函数返回的过程依赖于回调机制。
但是我调用的时候放进去的是个返回地址,并不是个函数啊
那就把它变成一个函数就得了呗
好的现在我们不会传”返回地址“了,而是传”返回函数“,函数的返回相当于调用了一个新函数
恭喜,现在不仅干掉了”回调“的概念,还成功把”函数返回“这个概念也干掉了——”函数返回“和”函数调用“统一了。这就是所谓 Continuation 的概念——这里不太可能完全让你”理解“,我只能说你研究回调最后都会落在这上面
在一个 Continuation-passing Style 的程序中,”函数“永远不会”返回“,而只会在完成自己的任务之后调用”返回函数“
不仅函数可以这么干,条件和循环等控制流语句,甚至一个 primitive 的指令也是同样的道理——x86 中的 EIP 寄存器( arch 书好像更喜欢叫 PC )就相当于当前指令的 continuation,告诉 CPU 这个指令执行完该执行什么,条件跳转则相当于指令参数和 EIP 两个 continuation 挑一个。
在高层,Continuation 也可用于抽象异常、异步、生成器等较高级的控制流。回调函数的一大用途是用来实现”异步控制流“(如上的 Node.js 例子),当前的趋势是通过”协程“来解决类似的问题——再回到底层你会发现”协程“的实现有两种主流模式,”stackless”和“stacked”,但是放在这里来观察,两者的区别不过是做 CPS 变换的抽象层不同罢了。
简单来说,我认为“回调”作为一种机制,是组合不同组件的基础——注意我上面已经把“组件”的概念从模糊的泛指细化到了函数最后又细化到了指令——也就是说“组合性”和“回调”同样广泛。如果没有回调,一个程序只能有一个组件,只能运行一个函数,函数只能运行一个语句,一个语句只能运行一条指令 ... 而“组合”(或者说是分治)是人类解决一切问题的基本方法——先把大问题分解成小问题,小问题再分解成更小的问题,再去解决那些小小的问题 ...