标签 OOP 下的文章

想象一下,你坐在河边,河岸上如茵绿草,不远处湍急河流;午后的阳光慵懒惬意,使人陷入冥想哲思,不觉开始思考眼前的河流是否真实存在。诚然,几米外确实有河水奔流而下。不过,我们所称为“河流”的存在究竟是什么呢?毕竟,河水奔流不息,一直处于变化之中。似乎,“河流”这个词无法指代任何固定不变的事物。

2009 年,Clojure 的创始人 里奇·希基 Rich Hickey 发表了 一场精彩的演讲,探讨了为什么上文那样的哲学窘境会给面向对象程序的编程范式带来难题。他认为,人们看待计算机程序中的对象与看待河流的逻辑是一样的:我们想象对象是固定不变的,即使对象的许多或者说全部的属性都无时无刻不处于变化之中。所以,这种逻辑并不正确,我们无法区分在不同状态下同一对象实例的不同之处。程序中没有明确的时间的概念。人们只是单纯地用着同一个名字,以期在引用对象时,对象能够处于预期的状态中。这样,我们也就难免会遇到 故障 bug

希基总结道,这一难题的应对办法就是人们应该将世界建模成作用于不可变数据的 进程 process 的集合,而不是可变的对象的集合。换句话说,我们应把每个对象看作一条“河流”,因果相连。总结说来,你应该使用 Clojure 等函数式语言。

作者在远足途中思考面向对象程序设计的本体论问题。

自从希基发表演讲之后,人们对函数式编程语言的兴趣不断提升,主流的面向对象编程语言也大多都采用了函数式编程语言。尽管如此,大多数程序员依旧沿用自己的老一套,继续将对象实例化,不断改变其状态。这些人长此以往,很难做到用不同的视角看待编程。

我曾经想写一篇关于 Simula 的文章,大概会写到我们今天所熟知的面向对象的理念是何时又是如何应用到程序语言之中的。但是,我觉得写当初的 Simula 与如今的面向对象程序设计的 迥然不同之处,会更有趣一些,这我敢打包票。毕竟,我们现在熟知的面向对象程序设计还未完全成型。Simula 有两个主要版本:Simula I 和 Simula 67。Simula 67 为世界带来了 class 类的继承 class hierarchy 以及 虚拟方法 virtual method ;但 Simula I 是一个初稿,它实验了如何能够将数据和进程捆绑起来的其他设想。Simula I 的模型不是希基提出的函数式模型,不过这一模型关注的是随时间展开的 进程,而非有着隐藏状态的对象之间的相互作用。如果 Simula 67 采用了 Simula I 的理念,那么我们如今所知的面向对象程序设计可能会大有不同——这类偶然性启示我们,不要想着现在的程序设计范式会一直占据主导地位。

从 Simula 0 到 Simula 67

Simula 是由两位挪威人 克里斯汀·尼加德 Kristen Nygaard 奥利-约翰·达尔 Ole-Johan Dahl 创建的。

20 世纪 50 年代末,尼加德受雇于 挪威防务科学研究中心 Norwegian Defense Research Establishment (NDRE),该研究中心隶属于挪威军方。在那里,他负责设计 蒙特卡洛模拟方法 Monte Carlo simulations ,用于核反应堆设计与操作研究。最初,那些模拟实验是由人工完成的;后来,实验在 Ferranti Mercury 电脑 [1] 上编入程序运行。尼加德随后发现,将这些模拟实验输入电脑需要一种更有效的方式。

尼加德设计的这种模拟实验就是人们所知的“ 离散事件模型 discrete event model ”,这种模拟记录了一系列事件随着时间改变系统状态的进程。但是问题的关键在于模拟可以从一个事件跳跃到另一个事件中,因为事件是离散的,事件之间的系统不存在任何变化。根据尼加德和达尔在 1966 年发表的一篇关于 Simula 的论文,这种模型被迅速应用于“神经网络、通信系统、交通流量、生产系统、管理系统、社会系统等” [2] 领域的分析。因此,尼加德认为,其他人描述模拟实验时,可能也需要更高层级的模型。于是他开始物色人才,帮助他完成他称之为“ 模拟语言 Simulation Language ”或者“ 蒙特卡洛编译器 Monte Carlo Compiler ”的项目 [3]

达尔当时也受雇于挪威防务科学研究中心,专攻语言设计,此时也加入了尼加德的项目,扮演“沃兹尼亚克”的角色(LCTT 译注:指苹果公司联合创始人斯蒂夫·盖瑞·沃兹尼亚克)。在接下来一年左右的时间,尼加德和达尔携手开发了 Simula 0 语言。 [4] 这一语言的早期版本仅仅是在 ALGOL 60 基础上进行的较小拓展,当时也只是打算将其用作预处理程序而已。当时的语言要比后来的编程语言抽象得多,其基本语言结构是“ 车站 stations ”与“ 乘客 customers ”,这些结构可以用于针对具体某些离散事件网络建立模型。尼加德和达尔给出了一个模拟飞机离港的例子。 [5] 但是尼加德和达尔最后想出了一个更加通用的语言结构,可以同时表示“车站”和“乘客”,也可以为更广泛的模拟建立模型。这是两个主要的概括,它改变了 Simula 作为 ALGOL 专属包的定位,使其转变为通用编程语言。

Simula I 没有“ 车站 stations ”和“ 乘客 customers ”的语言结构,但它可以通过使用“ 进程 process ”再现这些结构。(LCTT 译注:此处使用的“进程”,与当前计算机中用来指代一个已执行程序的实体的概念不同,大致上,你可以将本文中所说的“进程”理解为一种“对象”。)一个进程包含大量数据属性,这些属性与作为进程的 操作规程 的单个行为相联系。你可能会把进程当作是只有单个方法的对象,比如 run() 之类的。不过,这种类比并不全面,因为每个进程的操作规程都可以随时暂停、随时恢复,因为这种操作规程属于 协程 coroutine 的一种。Simula I 程序会将系统建立为一套进程的模型,在概念上这些进程并行运行。实际上,一个时间点上能称为“当前进程”的只有一个进程。但是,一旦某个进程暂停运行,那么下一个进程就会自动接替它的位置。随着模拟的运行,Simula 会保持一个 “ 事件通知 event notices ” 的时间线,跟踪记录每个进程恢复的时间。为了恢复暂停运行的进程,Simula 需要记录多个 调用栈 call stacks 的情况。这就意味着 Simula 无法再作为 ALGOL 的预处理程序了,因为 ALGOL 只有一个 调用栈 call stacks 。于是,尼加德和达尔下定决心,开始编写自己的编译器。

尼加德和达尔在介绍该系统的论文中,借助图示,通过模拟一个可用机器数量有限的工厂,阐明了其用法。 [6] 在该案例中,进程就好比订单:通过寻找可用的机器,订单得以发出;如果没有可用的机器,订单就会搁置;而一旦有机器空出来,订单就会执行下去。有一个订单进程的定义,用来实例化若干种不同的订单实例,不过这些实例并未调用任何方法。该程序的主体仅仅是创建进程,并使其运行。

历史上第一个 Simula I 编译器发布于 1965 年。尼加德和达尔在离开挪威防务科学研究中心之后,就进入了 挪威计算机中心 Norwegian Computer Center 工作,Simula I 也是在这里日渐流行起来的。当时,Simula I 在 UNIVAC 公司的计算机和 Burroughs 公司的 B5500 计算机上均可执行。 [7] 尼加德和达尔两人与一家名为 ASEA 的瑞典公司达成了咨询协议,运用 Simula 模拟加工车间。但是,尼加德和达尔随后就意识到 Simula 也可以写一些和模拟完全不搭边的程序。

奥斯陆大学 University of Oslo 教授 斯坦因·克罗达尔 Stein Krogdahl 曾写过关于 Simula 的发展史,称“真正能够促使新开发的通用语言快速发展的催化剂”就是 一篇题为 《记录处理》 Record Handling 的论文,作者是英国计算机科学家 查尔斯·安东尼·理查德·霍尔 C.A.R. Hoare [8] 假如你现在读霍尔的这篇论文,你就不会怀疑这句话。当人们谈及面向对象语言的发展史时,一定会经常提起霍尔的大名。以下内容摘自霍尔的《记录处理》一文:

该方案设想,在程序执行期间,计算机内部存在任意数量的记录,每条记录都代表着程序员在过去、现在或未来所需的某个对象。程序对现有记录的数量保持动态控制,并可以根据当前任务的要求创建新的记录或删除现有记录。

计算机中的每条记录都必须属于数量有限但互不重合的记录类型中的一类;程序员可以根据需要声明尽可能多的记录类型,并借助标识符为各个类型命名。记录类型的命名可能是普通词汇,比如“牛”、“桌子”以及“房子”,同时,归属于这些类型的记录分别代表一头“牛”、一张“桌子”以及一座“房子”。

霍尔在这片论文中并未提到子类的概念,但是达尔由衷地感谢霍尔,是他引导了两人发现了这一概念。 [9] 尼加德和达尔注意到 Simula I 的进程通常具有相同的元素,所以引入父类来执行共同元素就会非常方便。这也强化了“进程”这一概念本身可以用作父类的可能性,也就是说,并非每种类型都必须用作只有单个操作规程的进程。这就是 Simula 语言迈向通用化的第二次飞跃,此时,Simula 67 真正成为了通用编程语言。正是如此变化让尼加德和达尔短暂地萌生了给 Simula 改名的想法,想让人们意识到 Simula 不仅仅可以用作模拟。 [10] 不过,考虑到 “Simula”这个名字的知名度已经很高了,另取名字恐怕会带来不小的麻烦。

1967 年,尼加德和达尔与 控制数据公司 Control Data 签署协议,着手开发Simula 的新版本:Simula 67。同年六月份的一场会议中,来自控制数据公司、奥斯陆大学以及挪威计算机中心的代表与尼加德和达尔两人会面,意在为这门新语言制定标准与规范。最终,会议发布了 《Simula 67 通用基础语言》,确定了该语言的发展方向。

Simula 67 编译器的开发由若干家供应商负责。 Simula 用户协会 The Association of Simula Users (ASU)也随后成立,并于每年举办年会。不久,Simula 67 的用户就遍及了 23 个国家。 [11]

21 世纪的 Simula 语言

人们至今还记得 Simula,是因为后来那些取代它的编程语言都受到了它的巨大影响。到了今天,你很难找到还在使用 Simula 写程序的人,但是这并不意味着 Simula 已经从这个世界上消失了。得益于 GNU cim,人们在今天依然能够编写和运行 Simula 程序。

cim 编译器遵循 1986 年修订后的 Simula 标准,基本上也就是 Simula 67 版本。你可以用它编写类、子类以及虚拟方法,就像是在使用 Simula 67 一样。所以,用 Python 或 Ruby 轻松写出短短几行面向对象的程序,你照样也可以用 cim 写出来:

! dogs.sim ;
Begin
    Class Dog;
        ! The cim compiler requires virtual procedures to be fully specified ;
        Virtual: Procedure bark Is Procedure bark;;
    Begin
        Procedure bark;
        Begin
            OutText("Woof!");
            OutImage;           ! Outputs a newline ;
        End;
    End;

    Dog Class Chihuahua;        ! Chihuahua is "prefixed" by Dog ;
    Begin
        Procedure bark;
        Begin
            OutText("Yap yap yap yap yap yap");
            OutImage;
        End;
    End;

    Ref (Dog) d;
    d :- new Chihuahua;         ! :- is the reference assignment operator ;
    d.bark;
End;

你可以按照下面代码执行程序的编译与运行:

$ cim dogs.sim
Compiling dogs.sim:
gcc -g -O2 -c dogs.c
gcc -g -O2 -o dogs dogs.o -L/usr/local/lib -lcim
$ ./dogs
Yap yap yap yap yap yap

(你可能会注意到,cim 先将 Simula 语言编译为 C 语言,然后传递给 C 语言编译器。)

这就是 1967 年的面向对象程序设计,除了语法方面的不同,和 2019 年的面向对象程序设计并无本质区别。如果你同意我的这一观点,你也就懂得了为什么人们会认为 Simula 在历史上是那么的重要。

不过,我更想介绍一下 Simula I 的核心概念——进程模型。Simula 67 保留了进程模型,不过只有在使用 Process 类 和 Simulation 块的时候才能调用。

为了表现出进程是如何运行的,我决定模拟下述场景。想象一下,有这么一座住满了村民的村庄,村庄的旁边有条小河边,小河里有很多的鱼。但是,村里的村民却只有一条鱼竿。村民们胃口很大,每隔一个小时就饿了。他们一饿,就会拿着鱼竿去钓鱼。如果一位村民正在等鱼竿,另一位村民自然也用不了。这样一来,村民们就会为了钓鱼排起长长的队伍。假如村民要等五、六分钟才能钓到一条鱼,那么这样等下去,村民们的身体状况就会变得越来越差。再假如,一位村民已经到了骨瘦如柴的地步,最后他可能就会饿死。

这个例子多少有些奇怪,虽然我也不说不出来为什么我脑袋里最先想到的是这样的故事,但是就这样吧。我们把村民们当作 Simula 的各个进程,观察在有着四个村民的村庄里,一天的模拟时间内会发生什么。

完整程序可以通过此处 GitHub Gist 的链接获取。

我把输出结果的最后几行放在了下面。我们来看看一天里最后几个小时发生了什么:

1299.45: 王五饿了,要了鱼竿。
1299.45: 王五正在钓鱼。
1311.39: 王五钓到了一条鱼。
1328.96: 赵六饿了,要了鱼竿。
1328.96: 赵六正在钓鱼。
1331.25: 李四饿了,要了鱼竿。
1340.44: 赵六钓到了一条鱼。
1340.44: 李四饿着肚子等着鱼竿。
1340.44: 李四在等鱼竿的时候饿死了。
1369.21: 王五饿了,要了鱼竿。
1369.21: 王五正在钓鱼。
1379.33: 王五钓到了一条鱼。
1409.59: 赵六饿了,要了鱼竿。
1409.59: 赵六正在钓鱼。
1419.98: 赵六钓到了一条鱼。
1427.53: 王五饿了,要了鱼竿。
1427.53: 王五正在钓鱼。
1437.52: 王五钓到了一条鱼。

可怜的李四最后饿死了,但是他比张三要长寿,因为张三还没到上午 7 点就饿死了。赵六和王五现在一定过得很好,因为需要鱼竿的就只剩下他们两个了。

这里,我要说明,这个程序最重要的部分只是创建了进程(四个村民),并让它们运行下去。各个进程操作对象(鱼竿)的方式与我们今天对对象的操作方式相同。但是程序的主体部分并没有调用任何方法,也没有修改进程的任何属性。进程本身具有内部状态,但是这种内部状态的改变只有进程自身才能做到。

在这个程序中,仍然有一些字段发生了变化,这类程序设计无法直接解决纯函数式编程所能解决的问题。但是正如克罗达尔所注意到的那样,“这一机制引导进行模拟的程序员为底层系统建立模型,生成一系列进程,每个进程表示了系统内的自然事件顺序。” [12] 我们不是主要从名词或行动者(对其他对象做事的对象)的角度来思考正在进行的进程。我们可以将程序的总控制权交予 Simula 的事件通知系统,克罗达尔称其为 “ 时间管理器 time manager ”。因此,尽管我们仍然在适当地改变进程,但是没有任何进程可以假设其他进程的状态。每个进程只能间接地与其他进程进行交互。

这种模式如何用以编写编译器、HTTP 服务器以及其他内容,尚且无法确定。(另外,如果你在 Unity 游戏引擎上编写过游戏,就会发现两者十分相似。)我也承认,尽管我们有了“时间管理器”,但这可能并不完全是希基的意思,他说我们在程序中需要一个明确的时间概念。(我认为,希基想要的类似于 阿达·洛芙莱斯 Ada Lovelace 用于区分一个变量随时间变化产生的不同数值的上标符号。)尽管如此,我们可以发现,面向对象程序设计前期的设计方式与我们今天所习惯的面向对象程序设计并非完全一致,我觉得这一点很有意思。我们可能会理所当然地认为,面向对象程序设计的方式千篇一律,即程序就是对事件的一长串记录:某个对象以特定顺序对其他对象产生作用。Simula I 的进程系统表明,面向对象程序设计的方式不止一种。仔细想一下,函数式语言或许是更好的设计方式,但是 Simula I 的发展告诉我们,现代面向对象程序设计被取代也很正常。

如果你喜欢这篇文章,欢迎关注推特 @TwoBitHistory,也可通过 RSS feed 订阅,获取最新文章(每四周更新一篇)。


  1. Jan Rune Holmevik, “The History of Simula,” accessed January 31, 2019, http://campus.hesge.ch/daehne/2004-2005/langages/simula.htm. ↩︎
  2. Ole-Johan Dahl and Kristen Nygaard, “SIMULA—An ALGOL-Based Simulation Langauge,” Communications of the ACM 9, no. 9 (September 1966): 671, accessed January 31, 2019, http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.95.384&rep=rep1&type=pdf. ↩︎
  3. Stein Krogdahl, “The Birth of Simula,” 2, accessed January 31, 2019, http://heim.ifi.uio.no/~steinkr/papers/HiNC1-webversion-simula.pdf. ↩︎
  4. 出处同上。 ↩︎
  5. Ole-Johan Dahl and Kristen Nygaard, “The Development of the Simula Languages,” ACM SIGPLAN Notices 13, no. 8 (August 1978): 248, accessed January 31, 2019, https://hannemyr.com/cache/knojd_acm78.pdf. ↩︎
  6. Dahl and Nygaard (1966), 676. ↩︎
  7. Dahl and Nygaard (1978), 257. ↩︎
  8. Krogdahl, 3. ↩︎
  9. Ole-Johan Dahl, “The Birth of Object-Orientation: The Simula Languages,” 3, accessed January 31, 2019, http://www.olejohandahl.info/old/birth-of-oo.pdf. ↩︎
  10. Dahl and Nygaard (1978), 265. ↩︎
  11. Holmevik. ↩︎
  12. Krogdahl, 4. ↩︎

via: https://twobithistory.org/2019/01/31/simula.html

作者:Two-Bit History 选题:lujun9972 译者:aREversez 校对:校对者ID

本文由 LCTT 原创编译,Linux中国 荣誉推出

早在 2015 年,Brian Will 撰写了一篇有挑衅性的博客:面向对象编程:一个灾难故事。他随后发布了一个名为面向对象编程很糟糕的视频,该视频更加详细。

我建议你花些时间观看视频,下面是我的一段总结:

OOP 的柏拉图式理想是一堆相互解耦的对象,它们彼此之间发送无状态消息。没有人真的像这样制作软件,Brian 指出这甚至没有意义:对象需要知道向哪个对象发送消息,这意味着它们需要相互引用。该视频大部分讲述的是这样一个痛点:人们试图将对象耦合以实现控制流,同时假装它们是通过设计解耦的。

总的来说,他的想法与我自己的 OOP 经验产生了共鸣:对象没有问题,但是我一直不满意的是面向对象建模程序控制流,并且试图使代码“正确地”面向对象似乎总是在创建不必要的复杂性。

有一件事我认为他无法完全解释。他直截了当地说“封装没有作用”,但在脚注后面加上“在细粒度的代码级别”,并继续承认对象有时可以奏效,并且在库和文件级别封装是可以的。但是他没有确切解释为什么有时会奏效,有时却没有奏效,以及如何和在何处划清界限。有人可能会说这使他的 “OOP 不好”的说法有缺陷,但是我认为他的观点是正确的,并且可以在根本状态和偶发状态之间划清界限。

如果你以前从未听说过“ 根本 essential ”和“ 偶发 accidental ”这两个术语的使用,那么你应该阅读 Fred Brooks 的经典文章《没有银弹》。(顺便说一句,他写了许多很棒的有关构建软件系统的文章。)我以前曾写过关于根本和偶发的复杂性的文章,这里有一个简短的摘要:软件是复杂的。部分原因是因为我们希望软件能够解决混乱的现实世界问题,因此我们将其称为“根本复杂性”。“偶发复杂性”是所有其它的复杂性,因为我们正尝试使用硅和金属来解决与硅和金属无关的问题。例如,对于大多数程序而言,用于内存管理或在内存与磁盘之间传输数据或解析文本格式的代码都是“偶发的复杂性”。

假设你正在构建一个支持多个频道的聊天应用。消息可以随时到达任何频道。有些频道特别有趣,当有新消息传入时,用户希望得到通知。而其他频道静音:消息被存储,但用户不会受到打扰。你需要跟踪每个频道的用户首选设置。

一种实现方法是在频道和频道设置之间使用 映射 map (也称为哈希表、字典或关联数组)。注意,映射是 Brian Will 所说的可以用作对象的抽象数据类型(ADT)。

如果我们有一个调试器并查看内存中的映射对象,我们将看到什么?我们当然会找到频道 ID 和频道设置数据(或至少指向它们的指针)。但是我们还会找到其它数据。如果该映射是使用红黑树实现的,我们将看到带有红/黑标签和指向其他节点的指针的树节点对象。与频道相关的数据是根本状态,而树节点是偶发状态。不过,请注意以下几点:该映射有效地封装了它的偶发状态 —— 你可以用 AVL 树实现的另一个映射替换该映射,并且你的聊天程序仍然可以使用。另一方面,映射没有封装根本状态(仅使用 get()set() 方法访问数据并不是封装)。事实上,映射与根本状态是尽可能不可知的,你可以使用基本相同的映射数据结构来存储与频道或通知无关的其他映射。

这就是映射 ADT 如此成功的原因:它封装了偶发状态,并与根本状态解耦。如果你思考一下,Brian 用封装描述的问题就是尝试封装根本状态。其他描述的好处是封装偶发状态的好处。

要使整个软件系统都达到这一理想状况相当困难,但扩展开来,我认为它看起来像这样:

  • 没有全局的可变状态
  • 封装了偶发状态(在对象或模块或以其他任何形式)
  • 无状态偶发复杂性封装在单独函数中,与数据解耦
  • 使用诸如依赖注入之类的技巧使输入和输出变得明确
  • 组件可由易于识别的位置完全拥有和控制

其中有些违反了我很久以来的直觉。例如,如果你有一个数据库查询函数,如果数据库连接处理隐藏在该函数内部,并且唯一的参数是查询参数,那么接口会看起来会更简单。但是,当你使用这样的函数构建软件系统时,协调数据库的使用实际上变得更加复杂。组件不仅以自己的方式做事,而且还试图将自己所做的事情隐藏为“实现细节”。数据库查询需要数据库连接这一事实从来都不是实现细节。如果无法隐藏某些内容,那么显露它是更合理的。

我对将面向对象编程和函数式编程放在对立的两极非常警惕,但我认为从函数式编程进入面向对象编程的另一极端是很有趣的:OOP 试图封装事物,包括无法封装的根本复杂性,而纯函数式编程往往会使事情变得明确,包括一些偶发复杂性。在大多数时候,这没什么问题,但有时候(比如在纯函数式语言中构建自我指称的数据结构)设计更多的是为了函数编程,而不是为了简便(这就是为什么 Haskell 包含了一些“ 逃生出口 escape hatches )。我之前写过一篇所谓“ 弱纯性 weak purity ”的中间立场

Brian 发现封装对更大规模有效,原因有几个。一个是,由于大小的原因,较大的组件更可能包含偶发状态。另一个是“偶发”与你要解决的问题有关。从聊天程序用户的角度来看,“偶发的复杂性”是与消息、频道和用户等无关的任何事物。但是,当你将问题分解为子问题时,更多的事情就变得“根本”。例如,在解决“构建聊天应用”问题时,可以说频道名称和频道 ID 之间的映射是偶发的复杂性,而在解决“实现 getChannelIdByName() 函数”子问题时,这是根本复杂性。因此,封装对于子组件的作用比对父组件的作用要小。

顺便说一句,在视频的结尾,Brian Will 想知道是否有任何语言支持无法访问它们所作用的范围的匿名函数。D 语言可以。 D 中的匿名 Lambda 通常是闭包,但是如果你想要的话,也可以声明匿名无状态函数:

import std.stdio;

void main()
{
    int x = 41;

    // Value from immediately executed lambda
    auto v1 = () {
        return x + 1;
    }();
    writeln(v1);

    // Same thing
    auto v2 = delegate() {
        return x + 1;
    }();
    writeln(v2);

    // Plain functions aren't closures
    auto v3 = function() {
        // Can't access x
        // Can't access any mutable global state either if also marked pure
        return 42;
    }();
    writeln(v3);
}

via: https://theartofmachinery.com/2019/10/13/oop_and_essential_state.html

作者:Simon Arneaud 选题:lujun9972 译者:geekpi 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出