程序的组成部分
我觉得程序可以分成四个部分:
Program = Input + State + Process + Output也就是说,用户输入(Input)会被程序结合状态(State),通过规定的逻辑运算处理(Process),最终得到输出结果(Output)。
所以说,程序员主要写的,其实是两个部分,即逻辑运算具体是怎么样的(本质上是 Dataflow DAG 图),另一个是状态的更新(状态机部分)。
图灵机的诅咒
图灵机本质上就是状态机,其实和我上面说的差不多,也是一些状态(那个纸袋)不断被更新的过程,这本身没有问题。现在的硬件也是在图灵机的指导思想下设计的,内存用于存储状态,CPU 用于计算逻辑。
但是问题在于,贴近硬件的编程语言,比如说 C 和 C++ ,它们也采用了图灵机的模型,把一个个变量,理解成了一个个状态(内存格子),然后再用他们去编程。就比如说我们实现一个状态更新程序:
// state
int state = 1;
// process
int tmp1 = state + 1;
int tmp2 = tmp1 * tmp1;
tmp2 = tmp2 + 2;
// update state
state = tmp2我们可以看到,为了描述计算流程,我们新声明了两个变量 tmp1, tmp2 ,可以看作它们是我们的新的状态,然后你就会发现这两个状态毫无必要,正如它们的名字一样,是临时的。
换句话说,使用 C 语言这样的语言,就很容易引入一些不必要的状态,而这些不必要的状态,也不利于程序员和编译器,可能我们要花很多事件才能分辨出来,这只是一个临时变量,而不是真正的状态。
Dataflow
那么解决方案是什么?其实就是将那些临时变量,写得更像 SSA 一样。更进一步,也就是将 Process 写得更像是 Dataflow 一样。
之前我觉得数据流只是一种说法,但是我决定其实它隐含了一个意思,那就是它是一个无状态也无副作用的函数,我们考虑的,只是数据如何通过一个操作,变成新的数据,而不会考虑将这个数据,存到某个中间节点上。
我觉得这点 Rust 就做的非常好,它用两种不同的语法区分了状态和临时变量(或者说,数据流中间节点):
// state
let mut state = 1;
// process
let tmp1 = state + 1;
let tmp2 = tmp1 * tmp1;
let tmp2 = tmp2 + 2;
// update state
state = tmp2;可以看到,我们用 let mut 来表达可更新的状态,而用 let 来表达这个变量只是一个 dataflow 中的中间变量。两次的 let tmp2 并不是修改了 tmp2 内存格子的内容,而是将 tmp2 这个名字,从 (+ state 1) 这个节点的绑定(binding)中解开,重新绑定到 (* (+ state 1) (+ state 1)) 这个节点上。
诚然我们可以将程序写成下面这个样子,同样也能得到正确的结果:
// state
let state = 1;
// process
let mut tmp1 = state + 1;
let mut tmp2 = tmp1 * tmp1;
tmp2 = tmp2 + 2;
// update state
let state = tmp2;但是在语义方面,就完全错误了,相当于我们没有弄清楚程序的语义。