前两章构造数据抽象和构造过程抽象讲述了如何组合基础函数和基础数据,这些很好展现了抽象是如何解决大型系统的复杂性问题。抽象是一个强大的工具,它可以帮助我们解决构建大型系统中的复杂度的问题。此外还需要一些设计策略,因为它能帮助我们使其模块化。
所谓模块化就是能让系统“自然地”分割成一些具有内聚力的,可以独立开发和维护的部分,而高内聚和低耦合一直是架构师的崇高目标。
通过模拟现实世界是一种设计策略,比如要做一个银行系统,在程序中不可避免创造出“账户”“交易”“转账”等结构性对象,这些对象在各个时间段发生变化,正如现实世界中的实体一样。更好的期望是:当随着时间的流逝,现实生活中的系统拥有了更多的概念,比如出现“移动支付”等新新概念时,只需要在系统中更改某个局部的部分,那么就可以为系统添加新特性。
SICP中展示了两种组织策略,这也反应两种编程思想:
1.将系统视为由互相作用的对象组成。在对象视角下,我们引入赋值来更改对象局部状态,并采用新的环境模型理解程序运行顺序。
2.将系统看作信息流的处理。在信息流视角下,流这一惰性求值来实现序列的按需计算。
赋值和局部状态
局部状态指的是对象私有的,描述当前状态的变量。比如一个balance对象,在银行系统中反映的是账户余额。在没有引入赋值前,balance 这个变量只是某一个值的名字,它没有“变量”的概念。根据函数式编程的特性,相同输入的函数总是会产生相同输出。但引入赋值后,变量变为指向某个存储位置,这个位置的值可以随着时间改变。
这样对象就可以随着时间而变化其内部的值。
没有赋值前,这种对象像是一个常量,它的值是固定的。当有了赋值后,变量成为同一种类型不断变化的存在。赋值使我们能够模拟现实世界中会变的事物,例如银行账户的余额、游戏角色的血量等。
在之前文章的例子中,通过闭包可以在环境中捕获局部变量。在闭包内使用赋值改变了捕获的变量值时,就形成了一个拥有“记忆”的对象。
1 | func makeWithdraw(initialBalance float64) func(amount float64) interface{} { |
在上述代码中,makeWithdraw 返回的匿名函数形成闭包,捕获了局部变量 balance。每次调用闭包都会修改并保留 balance的新值,实现了状态随调用更新。在这个过程中,变量balance对外是不可见的,它被封装在函数内部。这点很符合对象封装的思想:通过过程隐藏内部状态,只暴露操作接口。
SICP中还介绍了另外一种消息传递风格来模拟对象:让对象作为一个“消息处理器”,通过不同消息来触发不同操作。
1 | // Account对象,使用闭包和消息传递模拟银行账户 |
在这个例子中,NewAccount 返回一个闭包 dispatch,根据不同字符串消息执行不同分支。通过这种消息传递机制,模拟了一个拥有内部状态(balance)且提供多种操作(取款、存款)的对象。当对象的状态被封装,只有通过特定消息或方法才能访问和修改,这点也满足满足对象封装的思想。
赋值的优点在于可以更好“模块化”,并提高系统的抽象能力。赋值能让对象记住历史的状态,在现实例子中可以很好记录账户余额随着交易更新等业务。如果没有赋值,对于开发者来说不得不使用显式传递并返回状态:func changeBalance(oldBalance,balance float64)initialBalance float64{...},这样的方式会很繁琐,让代码变得复杂难度。当状态被隐藏在对象内部时,外部使用会更简洁,每次调用会自动更新而无需调用者来维护。
当对象内部的状态对其他部分不可见时,就已经减少了模块之间的耦合,更改该对象不会影响它的使用者。例如将 balance 封装在取款函数内部,可防止外部代码随意改动余额。
赋值也不是一点缺点都没有,引入赋值后变量的值会随时间或事件发生改变,等式不在稳定可靠。引入赋值后表达式不再满足引用透明性,同一段代码在不同时间执行可能产生不同结果,因此等式推理与重排优化不再安全。一个有状态的对象中,“相同”变得模糊起来,两个账户不能引用同一个有状态的变量,就像两个存款相同的人不能共用一个账户,在程序中“余额”这种带有状态和身份属性的对象需要开发者投入额外的精力去思考它们的变化。
赋值引入了时间的因素,程序的结果可能依赖执行步骤的先后顺序,正确性取决于顺序,在复杂程序中时序错误会变得更加隐蔽。此外对象共享状态还可能引发并发问题(所以才引入锁的概念,这又是一个新的难题,在本文的后续章节中我们会提到)。
赋值使程序更接近现实模型,但是也牺牲了部分简单性和可靠性。
赋值构造模型
赋值能让开发者实现诸如链表的就地修改、队列、表(哈希表)等数据结构。比如,在不使用赋值时,连接两个列表需要创建新列表;但有了赋值,可以原地修改指针来改变列表结构。这带来了效率提升,但也要注意可能出现的共享结构问题。
1 | type Node struct { |
这种通过更改指针来改变队列状态的方式,在实际上的编程中十分常见。为了避免创建新队列所产生的开销,每个队列对象封装了自己的内部状态,对外只提供Enqueue和 Dequeue操作。这也进一步解释了赋值是如何支持封装和抽象数据类型:可以把队列看作一个独立对象,通过公共方法来操作它,而不需要关心内部是如何实现的。
赋值配合良好的数据抽象可以让程序结构直观对应所模拟的问题结构,提高扩展性和模块化。当需求新增时,只需要添加对象类型或动作,而无需推翻原有的设计。
环境模型和顺序执行
SICP中的环境(Environment)可以理解为可叠加的字典。每一层被视为一个**Frame(框架)**,本质上就是存放一组“名字-值”的字典,这些字典按层级连成链。检索时就会一层一层寻址,当前层找不到时就会去外层寻找,一直找到“全局层”。可以想象站在迷宫的最中心,从内一层一层向外探寻。
框架的产生:在环境模型中,定义操作会在当前环境框架创建新的名字绑定,而函数调用会产生一个新的环境框架用于函数的局部变量。
在SICP中这段话比较晦涩,但是转换成Go语言的概念中可以理解成:
1 | 1.“创建新框架:形参绑定实参” = 调用时创建一次“调用栈帧”,让局部变量,参数都放置在这一帧 |
当闭包捕获了其创建时环境中的变量绑定,并且该闭包仍可达时,这些绑定不会被回收,这解释了闭包为什么能保存状态:因为闭包携带来指向其创建时环境的指针,当函数返回后,它内部定义的局部变量框架只要存在闭包引用它,就不会消失,因此后续调用闭包时还能访问和修改这些保存在环境框架中的局部变量。
用环境模型来看之前的取款器例子:makeWithdraw 被调用时创建了框架 E1,其中绑定了 balance 初始值100。返回的 λ函数闭包携带了指向该环境 E1 的引用。每次调用闭包时,都在 E1 中找到并更新 balance。如果创建两个取款器 W1 和 W2,它们各自携带不同的环境(各自的balance绑定),因此互不影响 。这就解释了如何通过环境模型理解对象的独立状态:每个对象其实就是一个过程和一个指向自己私有环境的指针。
顺序执行与时间
当有了环境模型,工程师才能准确描述赋值带来的效果,以及程序是如何顺序执行的。
顺序执行指程序按照指定的先后次序来进行求值,并且实现各种子表达式。在SICP中这点通过Scheme的序列结构 begin来体现:(begin
顺序很重要,因为早期表达式可能产生对后续表达式可见的状态变化。例如:
1 | (begin (set! x (* 2 x)) |
这里第二步用到了第一步计算更新后的 x。如果顺序颠倒,结果就会不同甚至错误。环境模型能很好地解释这一过程:set! x 操作不是创造新绑定,而是查找已有变量 x 的绑定位置并就地修改其值 。当第一步完成后,环境中 x 的值改变了;执行第二步时,从环境取出新值计算并赋给 y。整个过程严格按时间顺序更新环境中的值。
在没有赋值的纯函数模型中,由于无副作用(也就是无状态),执行顺序并不影响最终结果。这点可以将表达式看作数据公式自由重排。但在有状态的模型中,时间是程序语义的关键组成。必须按照正确顺序进行操作,否则程序含义就不同。
SICP中特别强调时间顺序带来的问题:如果两个操作必须按某顺序发生(如先后更新同一变量),我们称它们存在顺序依赖;若顺序错乱可能导致错误。
内部定义和局部状态
环境模型还帮助理解块结构和内部定义。当我们在一个过程内部用 define 定义子过程或局部变量时,其实是向当前环境框架添加绑定(类似于局部“静态”变量)。这些内部定义在整个过程执行期间都存在于该过程的环境中。SICP将框架形象地比喻成“展台”(repository)来存放这些局部状态 。因此,一个过程可以有自己的环境框架,里面保存了该过程的静态局部子过程定义和变量,供过程内部任意地方使用甚至返回闭包使用。这进一步强化了过程 = 代码 + 环境的观念。
通过环境模型,我们重新获得对赋值程序的理解能力:我们不再把程序看作纯粹的数学代换,而是跟踪“哪块内存里的值在什么时候被改成了什么”。环境模型为后续讨论并发等问题奠定了基础,因为并发正是涉及同一环境中的状态被多个进程交织地读取和修改。
并发和共享状态
当多个进程(线程)同时操作共享状态时,就会出现并发的问题。在SICP中本小节讨论的是:“时间是一个本质的问题”,它强调的是并发程序中交错时间顺序会带来错误。经典的例子是两个人(相当于两个进程)在不同的ATM机上同时操作同一个银行账户。如果没有做好同步,其中一个人的取款过程可能覆盖另外一个的更新,造成竞态条件(race condition)
在顺序程序中,赋值的顺序由程序文本决定;而在并发程序中,由调度决定的交错顺序可能千变万化,所以无法简单假定每次运行结果是相同的,因为不同的交错顺序可能产生不同结果。
临界区和同步:为保证并发程序的正确性,我们需要控制对共享状态的访问。关键是识别临界区(即对共享变量进行读写的一段代码),确保同一时间只有一个进程进入临界区(如同一时刻只有一个人使用ATM)。经典的方法是使用锁或信号量来序列化访问 。当一个进程进入临界区时,加锁使其他进程必须等待它退出临界区再进入。这样可以避免交错执行导致的冲突。
1 | var balance = 100 |
上面的代码由于两个goroutine交错运行,可能出现丢失更新的问题。如果在不恰当的时序下两个线程都读取到初始余额100,各自计算并写回50,则最终余额将错误地为50。现在我们使用互斥锁确保原子性:
1 | var balance = 100 |
通过使用sync.Mutex 来保证同一时刻只有一个goroutine修改 balance。这样无论调度如何交替,结果都和串行执行两次取款一样,最终余额正确为0。这个Go例子对应了SICP的序列化器思想:将并发操作串行化来维护不变性。
不同于主流的编程语言,Go语言提供了另外一种并发模型:通信顺序进程(Communicating sequential processes,CSP)1。Goroutine 和 Channel 分别对应 CSP 中的实体和传递信息的媒介,Goroutine 之间会通过 Channel 传递数据。
1 | type WithdrawReq struct { |
避免共享变量可以简化并发问题,这是SICP中提到的一种思路,将共享变量转为消息传递,即:不同线程各自处理自己的数据,然后通过消息交换,从根本上杜绝竞争。这正是Go并发模型在工程实践中的重要原型之一。
不要通过共享内存的方式进行通信,而是应该通过通信的方式共享内存。
这种方式的优点是:共享状态被隔离在单 goroutine 中,外部无法直接并发读写,从结构上降低了数据竞争的可能性;并且业务语义通过“消息协议”显式表达。它与 SICP 的思想是一致的:通过受控的序列化来保护状态不变式。
无论是利用锁还是使用Channel,这两者并没有什么优劣之分,只是面对并发难题的不同解法而已,Go语言本身也提供锁的方式。面对不同问题,可以灵活使用两种方式来适应不同场景。比如利用锁的方式很适用于共享数据结构已经存在,并且需要同时维护多个变量。基于这样的前提,改造成本高,那可以直接使用锁。
避免共享变量可以简化并发问题,但是也会造成Channel堵塞,goroutine也可能会被滥用,容易出现泄漏等问题。
在本小节中强调的是时间和状态交织所产生的复杂性。在设计并发程序时需要权衡其中的利弊:一方面希望并行来提升性能,另一方面必须保护共享状态的完整性。这是在设计并发程序时需要权衡的利弊:一方面希望并行来提升性能,另一方面必须保护共享状态的完整性。
流(Stream)
流(Stream)是一种惰性序列,它是一种特殊的序列数据结构,与列表类似,是一系列按照顺序排列的元素,但是最大区别在于:流中的元素不是在流创建时一次性全部生成的,而是按需计算逐步生成,这意味着流可以表示一个潜在无限长的序列。从某种程度上可以将流理解为一个描述序列生成规则的函数,而不是一个完全展开的具体序列。
将流想象成一张卷轴,里面包含很多信息但不会全部展开。每次查看或取出其中的元素时才展开一小段,未展开的部分可以等待以后使用。
引入流的主要动机在于处理无限或大型的数据序列以及提高程序的模块化和效率。例如:可以使用一个流代表“所有自然数”并对其计算,在传统的编程中会陷入无限循环或OOM,但使用流可以优雅处理,因为只有用到的部分才会真正计算。
流通过惰性求值,实现了“按需生产”数据,能够显著减少不必要的计算工作,这是它最大的特点。如果想查询某个范围内的第二个素数,传统做法可能需要先生成该范围内的所有数、筛选出所有素数,最后取第二个;这过程中大量生成和筛选的计算都是无谓的浪费。而使用流可以让程序一边生成数一边筛选,当找到第二个素数时就停止,无需处理更多数据 。这样既节省了时间,也节省了内存,因为根本不会保存整个大量的中间列表。
涉及“随时间变化的数据”时,流提供了一种替代使用可变状态的思路。避免使用循环和变量来逐步更新状态,利用流可以把时间视为另一个维度,直接处理整个随时间变化的序列,从而避免显式的状态修改。
延迟求值
延迟求值是流背后的关键机制。它会推迟表达式的计算,直到真正需要其值时才进行计算。大部分编程语言中采用的是及早求值策略:在函数调用时会先把所有参数表达式都计算完,再进入函数。所以如果想构造一个列表,列表中的每个元素都会在列表构造时立即求值。
SICP中 Scheme 例子使用特殊形式 delay 和 force 来实现:
1.delay 会返回一个延迟对象,这个延迟对象内部封装了给定的表达式,但并不对其求值。可以将其看作是一个不含参数的 lambda(),它只是被包装起来等待将来调用。
2.force 用于强制求值,触发之前延迟表达式真正执行。如果对某个通过delay产生的对象调用force,就会执行其中封装的表达式,并得到实际计算结果。
Go语言中没有内置的惰性求值,但是可以通过闭包或通道来模拟它
1 | // 定义一个整数流的数据结构 |
From 建立了一个从一开始的无限整数流,但是实际上不会一次生成无限多的数,只有调用Take取前十个元素时,Tail()方法才逐步计算流的后继。这个例子模拟了SICP中cons-stream 行为:每次取 stream-cdr 时才计算下一个元素。由于实现了缓存,每个元素只会计算一次,后续再访问不会重复工作。
SICP在引入流时强调了它与程序的模块化设计以及时间抽象之间的关系。流使注意力从对象随时间的状态变化,转移到系统中的信息流动。这对模拟带有时间行为的系统,有着独特的价值。比如银行账户的状态变化被表示为输入流到输出流的映射,而账户自身的状态不需要用可变变量来保存,它被隐含地包含在余额流之中。更方便的时可以将“余额随时间变化”的流当作一个整体来处理,比如年终总结,某个时间段的账单变化等。
流在这里扮演了模块化“胶水”的角色:将发生在不同时刻的事件串联在一起,工程师可以用函数式的思维一次性地映射整个序列,而不用编写繁琐的循环逻辑。这种基于流的设计非常类似于Unix/Linux中的管道机制:每个模块或程序只关注处理流过的数据,然后将结果交给下一个模块。各模块之间通过数据流松散耦合,大大增强了系统结构的清晰度和灵活性。
当然,在并发场景下多个流的合并和同步会引入新的复杂性,比如两个独立产生事件的流要合并时,就涉及到事件顺序问题。
总结
SICP 第三章中讲述了“状态的引入”,这进一步丰富了工程师描述现实世界的能力,但也使程序行为与“时间”纠缠在一起。模块化设计在有状态的环境下,需要同时关注数据的封装和随时间演化的行为控制。
在这一章节中还花费不少篇章来描述流这一概念,通过它能以一种全新的方式看待数据序列和时间变化。SICP第三章通过流展示的正是:即使不依赖赋值和可变状态,依然能够巧妙地模拟现实世界随时间的演变,同时获得良好的模块化和清晰的程序逻辑。