What is a program?

设想您编写了一个程序但不产生任何 I/O 操作,显然此程序在运行时和不运行时对我们而言没有任何区别。这样的程序除占用系统资源以外不能完成任何工作。

一个简单的程序的功能可以是将 I/O 操作映射到 I/O 操作上,这样的计算模型可以简单地表达为:

$$ {\rm Program}:{\rm IO}\Rightarrow{\rm IO} $$

I/O 操作是 Input / Output 的简写。程序对一切外部资源的访问都是 I/O 操作,包括控制台输入输出、文件读写、网络请求、驱动硬件等。

但是,不难发现,上述抽象似乎存在有一个问题:程序似乎没有“记忆”。只要输入的 I/O 操作相同,输出的 I/O 操作也相同。

和数学中的“函数”很像?没错!在计算机编程中,这类“输入相同,输出总是相同,无状态”的函数被称为纯函数,有别于您可能编写过的,用以执行代码而修改外部状态的副作用函数过程

如果我们想要让程序“记住”之前的结果,就需要引入状态。在很多程序设计情景中,您可以粗暴地认为变量就是状态,除:

  • 不可变变量

  • 函数参数

以外。

每次程序处理一个输入,都对应地读取并修改状态,以此实现更普通意义上的功能。

有限状态机 (Finite State Machine, FSM)

有限状态机是一种常用于工业控制和游戏开发的计算模型。该模型定义机器可能处于的有限个状态,并根据当前状态处理输入和转移到下一个状态。

$$ {\rm Program}:({\rm State},{\rm IO})\Rightarrow({\rm State},{\rm IO}) $$

设想您有一台自动售货机来贩售可乐。我们使用有限状态机对此程序进行建模:

  • 程序带有两个状态 $S_1$ 和 $S_2$ , 分别表示未投币和已投币;

  • $S_1$ 在投币时转移为 $S_2$ , 其他时候保持不变;

  • $S_2$ 在取货时吐出一瓶可乐,同时转移为 $S_1$ , 其他时候保持不变。

上述逻辑表达为以下伪代码(JavaScript):

let state = "未投币"

const 吐出可乐 = () => { /* some implementation */ }

const 未投币 = (evt) => {
    switch (evt) {
        case "投币": state = "已投币"
    }
}

const 已投币 = (evt) => {
    switch (evt) {
        case "取货": {
            state = "未投币"
            吐出可乐()
        }
    }
}

const 售货机 = (evt) => {
    switch (state) {
        case "未投币": 未投币(evt)
        case "已投币": 已投币(evt)
    }
}

状态依赖地狱

我们通过引入状态成功解决了问题。您现在可以通过不断添加状态来构建更加复杂的程序了。

这看上去很完美,直到您可能发现状态的数量肆意增长——显然,您难以在人脑中建模和管理以 MiB 计内存中的不同状态和它们的依赖关系。这使得您在编程时更容易疏忽犯错。

在解决此问题前,我们不妨重新审视程序中所有的状态,并不难注意到在绝大多数情况下,对于每个状态而言,其依赖的状态仅占程序所有状态的一小部分,大部分状态均与其无关。

我们常用「正交」这个时髦的名词来表达无关。

Unix 设计

一种解决状态依赖地狱的方式是采用 Unix 设计。Unix 设计的核心思想可以概括为构建多个小而正交的程序,并尝试让它们共同工作来产生更复杂的程序。

Unix 是一种操作系统。此设计由 Unix 首创,故名。

例如,在 FRC 编程中,控制底盘的程序和控制上层建筑的程序可以视为正交。此时,我们便可以分别编写两个子程序,用于控制底盘和上层建筑,并在最终的控制程序中调用这两个模块。

通过这样操作,您在编写每个程序时只需考虑程序自身的状态,而无需考虑一同工作的其它程序的状态。同时,您可以自由选择一个适合的程序体积,并在必要时分拆或合并。

Unix 设计还有助于您少写重复代码,而是对某个独立的程序进行复用。

面向对象编程

另一种常见的解决方案是面向对象编程。面向对象编程试图将与当前上下文无关的状态从您的视线中隐藏,从而减少您实际需要考虑的状态数目。

在 C++/Java 中,面向对象编程是通过实现的。您可以通过 class 关键字声明一个类,并将其字段设为 private 来将状态对外部隐藏。

例如,在 Java 语言中,对于上述售货机代码,您可以将其状态对外界隐藏——如果您在其它地方只想使用售货机,而不关心售货机内部的具体状态的话。

class 售货机 {
    private String state = "未投币";
    /* some logic */
    public void handle(Event evt) {
        // visit internal state
        switch(this.state) {
            /* some logic */
        }
    }
}

事实上,面向对象编程的作用不局限于此。您或许知道其作用是「封装」、「继承」和「多态」。此章节,我们仅讨论「封装」性——即隐藏状态。

思考题

预计花费您30min

使用 C++/Java 等价实现上文中自动售货机程序。您的代码中假设预先定义了:

enum Event {
    投币,
    取货,
    /* some more events */
};

void 吐出可乐() { /* some implementation */ }

// you may refactor this with object-oriented programming

void 售货机(Event evt) { /* your implementation */ }

浏览官网教程中「Command Based Robot」一节。