一些用函数式编程重新理解的观念

我刚写顺手 CoffeeScript 的时候对程序的理解当然不一样,
coffee 当中思路还算清晰, 全局变量和局部变量, 然后有函数,
从而形成大大小小的对象以及闭包, 然后之间的数据发生相互作用,
而这些关联和互作用足够复杂, 可以模拟我们业务所需的逻辑,
作为脚本语言来说, 非常灵活的一套方法了.

虽然 JavaScript 本身花样挺多, 但 coffee 裁剪的核心非常小,
可以看做是个 good parts 的精简版. 而这些并不足够,
后来 ES6 不断增加功能, 这个事情大家也看到了, 编程语言会很复杂.
而这期间我开始深入挖 Clojure 方向的技术, 特别是 ClojureScript,
从 React 方向上走, cljs 是非常深思熟虑的语言, 也很自然而然的.

就我而言, cljs 对我的思维方式产生了巨大的影响.
而且随着我尝试去接触前端之外的一些内容, 想法也也在改变着,
对于很多人来说, 我现在反思的内容, 也许不曾被疑问过,
对于我自己来说, 这些思维上的转变非常重要, 影响我的思考和工作.
当然整理出来, 也更能明确表示我对于 js 和 cljs 的态度.

一切皆是表达式

使用 coffee 的时期, 一切皆是表达式的观念已经根植在脑海里了,
由于都是表达式, 代码的可组合性非常高, 几乎是任意组合,
比如说 if 在 js 里是 statement, 在 cljs 中是 expression,
那么 cljs 中 if 可以用在代码的任何位置作为参数使用,
就像是 HTML 当中, <div> 的结构可以非常灵活地组合使用.

我原以为 coffee 的表达式已经足够灵活, 但 cljs 还胜过 coffee,
当然这是 Lisp 风格语法的原因, 所有基于 S 表达式的语言都能做到,
其实在 coffee 当中还有很多缩进的顾虑, 组合会语法麻烦,
而在 js 的 C 风格语法, 甚至在 ts 和 flow 中, 这些问题会更明显,
语言本身的语法制约了其灵活性, 虽然不影响强大, 但总归啰嗦了很多.
我现在觉得 S 表达式对于组合能力是相当重要的提升.

面向对象

我对 Java 语言不熟悉, 而 js 的面向对象又是花样百出的,
当然我理解的 OOP 显然是有偏颇的, 想法并不准确.
我更愿意接纳 Alan Kay 说的那种基于消息传递的理解,
假设有很多的细胞各自工作, 之间通过信号来协调状态,
这也是整个互联网巨大的生态所展示的形态, 大量的联网的机器,
机器之间通过收发消息来沟通, 从而形成巨大的程序集合体.

然而具体到编程语言当中实现这样的模型, 比如 js, 获就很怪异,
首先代码是单线程执行的, 编程模型其实还是单线程,
OOP 在 js 中只是将代码进一步结构化了, 算是好维护一点.
然后由于对象实例是 js Object, 可以用 js 代码随意操作,
结果就是对方拿到某个引用, 就能任意修改数据, 这就邪门了.
如果别人的计算机能直接修改你计算机上的数据, 不是乱套了吗.

我觉得这是编程语言具体实现而带来的错觉, 这不属于 OOP,
OOP 可以帮助分隔职能以便于代码能更好地组织,
但是没必要搞成对弈共享内存的不可靠的程度.
当然这可能只在 js 社区早一点的时候比较严重, 现在并不清楚.
当 Clojure 社区批评面向对象是 place oriented Programming,
可能就是批评错了, 那些概念真的属于 OOP 吗, 我很怀疑.

并发编程问题

其实收发消息的模型更像是并发编程, 而不是单线程,
当你有大量的 goroutine 独立做自己的功能, 这种模式就清晰起来,
每一个轻量级进程有自己的内部状态, 然后收发消息:

Do not communicate by sharing memory;
instead, share memory by communicating.

这样也就避开了直接拿到引用去别改别人的数据的问题.
而且也更自然, 就像 HTTP 服务发送的字符串数据一样.
消息就是不可变的, 如果不一样, 那就是一份新的数据了.
而这样的机制也保证了巨大的互联网能够正常地运转.

说到并发编程, 我大致觉得应该分成两种, 比如两个进程之间,
一种方式是两个进程相互有依赖, 要等到对方的行为,
另一种方式是两者基本上无关, 一起启动就好了.
第一种, 也就是进程之间相关依赖的情况, 是我很关心的,
而 Go 的 CSP 模型, 当中的 channel, 就致力于解决这类问题.
有多个进程, 他们之间需要相互协作, 常常要等待, 那就用管道.

对世界的模拟和计算

那我认为的编程语言两个功能, 一个是模拟, 一个是计算,
真实的物理世界, 或者说具体的业务, 有巨大的复杂性,
当你要用编程语言解决问题, 首先语言应该有足够的灵活性去描述问题,
然后是计算, 比说你能描述字符串文件, 也能描述 zip 文件,
那两种形态之间的转化过程, 语言就要能进行拆解由 CPU 完成计算.
更重要的例子当然是多个任务之间相互协作, 需要能模拟和计算.

纯数据当然足够明确了, 还有讨论一下可变状态和时间的问题,
由于内存和磁盘是可变的, 其实编程语言内建就有可变状态,
只是说从 Clojure 和 Haskell 的角度, 直接这么做是容易失控的,
所以抽象出了 reference 的概念, 值不可以修改, 但可以修改引用.
时间指的是异步任务的等待, 或者是事件流这种情况.
我觉得是说, 编程语言应该给出对应的明确的抽象, 来说明它们是什么?
然后才有清晰的方案说遇到这种情况怎么处理.

js 作为脚本语言而生, Java 和 C# 已解决的问题它却没有解决.
而现在 js 又忙不迭地要加上这些那些功能..
这种做法在 Clojure 看来真的是太混乱了, 想到什么加什么.
我不觉得修补问题是坏事, 只是说很难避免很多次生的问题,
比如说社区大量风格不一致的类库, 难以轻松使用.
原本希望语言本身做好模拟和计算, 结果光是模拟就费好大的劲.

怎样理解可变状态

由于 Flux 的原因, 我们前端开始关心 Single Source of Truth,
数据是怎么来的, 最原始的形态是怎样, 如何分割?
如果你拿到一个数据 1, 你当然也可以说这就是 1, 毫无疑问,
但如果是一个不断变化的数字, 或者说 React 应用的 Store, 就不简单了,
这个 Store 当前的内容并非 Source, 而是一个结果,
是一个出事状态和每个后续的操作, 最终形成的结果,
就像是现在的 Git 仓库, 是从空仓库加上全部的 commits 才得到的.

这个问题到了数据库, 以及做备份和同步策略的时候更加明显,
而每个原子性的操作成了 Source, 才是最真实最小的单元.
可变状态是什么? 就是这些原子性的操作进行计算的中间状态,
因而当面对一个数据库的数据时, 数据库可以认为是可变状态,
而实际上数据库是这些原子性操作的集合, 并且随着时间改变.
回到程序当中的局部变量, 实质上也是这样, 一些操作在时间上的集合.
基于这样的角度, 时间这种外部条件变化, 程序的可靠性就存疑了.

时间的抽象

那么说到时间, js 社区近些年才开始集中精力去解决,
比如说开头的 Promise, 然后是 Generator 和 async 函数, 以及 RxJS.
而我会说 CSP, 也就是 Go 的 channel, 就是前几篇文章的内容.
我们要模拟这个世界当中的具体业务, 离不开时间因素,
因此对于时间相关的代码的抽象也就成了相当有分量的工作.
那我就觉得, 能给出基础的操作时间的方案, 那应该是最可行的.

比如 CSP 当中有 timeout, 在 put!take! 过程都有 wait 机制,
通过这些函数, 或者说指令, 时间成了编程语言执行的一部分,
js 需要用回调解决掉问题, 这里显得比较清晰了, 也就是等待.
还有 alts! 之类的更强大的机制, 能做更多的控制.
也可以说 Future, Promise, async, Reactive Programming, 也是办法,
那我的意思就是我认为 CSP 是其中最为明确和自然的方案.
Rx 当然很强大, 但那是类库, 像是黑盒, 而不是单纯语言提供的抽象.

小结

C 风格的语言往往从硬件角度, 提供 mutable 的数据结构,
而 Clojure 跟 Haskell 当中, 这一点是非常谨慎的,
Clojure 认为数据就是数据, 怎么能随意更改数据, 只能更改引用,
对应到真实世界, 苹果就是苹果, 怎么可能变成梨?
只会发生的是, 盒子里原来放苹果, 现在放梨, 关系随着时间改变了.
所以这才是更准确的用代码模拟世界的方式, 苹果不能变梨.

同样地, 当代码复杂到跨越大量的机器, 在不同的时间节交换状态,
事情本身是会越来越复杂的, 没法避免, 编程语言还是要模拟和运算,
可是我们有机会找到更准确的概念去描述他们, 而且更准确,
单纯的脚本语言表达能力当然不够, 结果就需要不断增加新的概念,
比如说造一个些类库和语法, 加一些新的概念, 说能解决这个问题,
问题在于, 这些概念本身也可能过于复杂, 超出文本本身的复杂.
这种时候某些语言表达能力足够强, 把问题弄透彻了, 那就赞了.

阅读 3k

推荐阅读
题叶
用户专栏

ClojureScript 爱好者.

630 人关注
252 篇文章
专栏主页