我刚写顺手 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 认为数据就是数据, 怎么能随意更改数据, 只能更改引用,
对应到真实世界, 苹果就是苹果, 怎么可能变成梨?
只会发生的是, 盒子里原来放苹果, 现在放梨, 关系随着时间改变了.
所以这才是更准确的用代码模拟世界的方式, 苹果不能变梨.

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

如果觉得我的文章对你有用,请随意赞赏

你可能感兴趣的文章

uglee · 2016年09月19日

另外JS在语言定义和引擎实现一级就是Event Model的,Event Model天生优势是run-to-completion避免抢先带来的race condition,以及栈计算对内存友好;

JS具有传统的栈计算模型,而不是象Go那样链表实现栈;从这个意义上说CSP不是适合JS的技术,JS有自己的并行方式,H5的worker,或者干脆自己spawn;

在JS里Promise作为异步解决方案是OK的,不改变其Event Model本质;async/generator可以对付一些流程繁琐的东西但尽量少用;async优于其他乱七八糟的框架的主要原因是它是个inversion of control的设计,否则你还是需要自己写框架去管理所有的执行体,但async等于是把控制权交出去了;

使用async/generator会付出昂贵的内存代价,因为在执行结束之前都需要保留Scope内的变量,本质上Coroutine或者Generator是使用了私有的全局变量;

node里还有一个把event model贯彻到底的设计,就是event emitter,很好用,基本上是万能的;

reactive用在js上属于重新发明轮子,observable之类也没什么意义。

+1 回复

uglee · 2016年09月19日

这篇写的概念太。。。。。都不好说了。

函数式编程指的是以Lambda Calculus作为出发点的编程实践。

OO是基于状态的建模和编程方式。

并发是一个问题域的问题,不是解法域的;并行是算法问题,如何构建算法过程。

事件模型和线程模型是执行体问题。

协程,Generator,是不抢先的线程模型。

CSP解决的问题和多线程是一样的,但是没有用锁机制同步,改用流机制;更大范围的说是私有数据模型加Actor模式。

你对系统和runtime实现,语言编译器,及其基础数学模型的了解太少了。

回复

题叶 作者 · 2016年09月19日

这个奇怪的账户名是梨叔么,, 为什么我觉得这个名字也好奇怪 - -!

底子是 js, 底下的编译原理都没学好.. 写得不准确.. 虽然也有故意不按概念走的的意图..
js 跟 Clojure 都是动态语言, 思维方式上往往是绕着编译器走的.

ES6 加入 yield 之后, Node 社区大量的库都在改变, 尽量往 generator 方向走,
基于事件的异步编程已经证明带来越来越多的混乱和不可控, 所以大量在改变,
当然性能上, 不过对于 js 来说即便内存浪费一点, 已经比我们预期的要快了.
毕竟前端的场景, 对语言性能要求极高, 只是少数代码而已.

我没有写到"并行", 这里都是"并发"(concurrent).

我更多是希望讨论思维上如何理解编程和如何构建程序的问题,
考虑到我那点可怜的后端经验, 我也不指望能把概念全写清楚了.

回复

garfileo · 2016年09月19日

有苹果梨 :)

回复

uglee · 2016年09月19日

你没有根本理解的地方是两个关于计算的古老模型,event vs thread,和stack-based vs stackless。在Donald Knuth写下一切函数都是Coroutine的时候,他用的计算机还没有栈指针;这意味着写函数时即使是局部变量也要手工分配,至于是使用栈的方式分配还是其他方式没有约定。

Yield概念与实践诞生于这个年代,所谓Yield就是交出CPU。使用Yield改变了内存分配和管理方式,收益是代码逻辑的连续性。在脚本语言里内存分配不是语言要考虑的问题了,由引擎负责考虑如何实现Continuation Passing Style。

但是Yield在JS里的必要性是没有的,因为Node的一切IO都是AIO,代码并不会因为调用什么函数导致进程被block。

这类探索的问题本质是:你是否希望执行序等同于代码书写序,这是thread model追求的multitasking方式,但是event model天生就不是如此,而JavaScript天生就是event model的,既然你选了JS,而不是其他thread model的语言,事实上绝大多数语言都是主力支持thread model的,那么最好是适应它的编程模型,而不是在底层用event然后拼命的去封装一个translation层在之上用thread model。

~~~~~~~~~~~

在所有追求代码连续性的努力中,只有一种情况是应该去严肃对待的,就是代码中的流程控制和异步操作混杂的情况,如果不是这样的情况,并发无论是caolan的async库还是Promise.all都是很好的,用forEach裸写也不是问题。

如果出现复杂的流程控制和异步操作混杂的情况,用es7/async是一个最简单的办法,代码容易维护,虽然效率不高(因为通常在仔细思考问题之后就能写出更加并发的逻辑)。这通常发生在一些模块的初始化中,如果仅仅是连续异步动作,用闭包或者Bluebird/bind + Class,做出来的效率往往更好)。

回复

题叶 作者 · 2016年09月19日

除了第一段 stackless 的问题没看懂, 后面都是同意的, 用 Node 写就是事件模型直接写性能高, yield 有额外开销(虽然我对具体的开销不够明确).

至于在 event 模型之上强行封装 yield 来改流程控制, 这个是现状, 并不是我搞出来的. 当年牛哄哄的 Express 框架现在某种程度上已经是 deprecated 状态, 逐渐被基于 generator 实现的 Koa 替代掉, 各种异步 API 也逐渐有了基于 generator 的接口, 比如 co-fs, 比如 co-redis, MongoDB 也有, fetch API 也有. 整个事情已经在发生.

我已经被 Clojure 洗脑成功, event 模型写业务对我来说简直鸡肋, 考虑到 js 代码对性能没有足够敏感, 我宁愿只看到 generator 而不去看各种 callback 干扰视线.

回复

uglee · 2016年09月19日

coroutine/generator是stackless function。他们的scope之内的变量的生命周期超过了执行体的单次执行过程。

回复

载入中...
题叶 题叶

15.4k 声望

发布于专栏

题叶

ClojureScript 爱好者.

401 人关注