|
【CSDN 编者按】面向对象编程的流行是计算机科学领域的不幸,它对现代经济造成了极大的破坏,造成了数万亿美元的间接损失。在过去的三十年中,几乎所有行业都因潜在的面向对象编程危机而受到影响。
作者 | Ilya Suzdalnitski编译 | 弯月 责编 | 张文
出品 | CSDN(ID:CSDNnews)C++和 Java 可能是计算机科学领域最大的错误。就连面向对象的创建者 Alan Kay 都曾对这两门语言提出了严厉的批评。然而,C++和 Java 都是比较主流的面向对象语言。面向对象编程的流行是计算机科学领域的不幸,它对现代经济造成了极大的破坏,造成了数万亿美元的间接损失。在过去的三十年中,几乎所有行业都因潜在的面向对象编程危机而受到影响。为什么面向对象编程如此危险?下面我们一起来寻找答案。2007 年 9 月,美国 Jean Bookout 驾驶的 2005 款凯美瑞突然失控,Bookout 尝试刹车但是失败,最终发生了碰撞事故,导致车内另一人身亡,Bookout 受伤。然而,此案只是丰田在美上百起车辆意外加速投诉的其中之一。在 Bookout 事件调查的过程中,原告方聘请了两位软件专家,他们花了将近 18 个月的时间来研究丰田代码。最终,他们都形容丰田代码库为“面条式代码”(Spaghetticode),程序的流向就像一盘面条一样扭曲纠结在一起。软件专家演示了大量丰田软件可能导致意外加速的情况。最终,丰田被迫召回 900 多万辆汽车,赔付款项高达 30 多亿美元。
面条式代码有什么问题?
然而,丰田并不是唯一一家有面条式代码问题的公司。曾经有两架波音 737 Max 飞机坠毁,造成 346 人死亡,损失超过 600 亿美元。这两起事件的原因也出在了软件 bug 上,而且都是由面条式代码引起的。面条式代码困扰着全世界上许许多多的代码库,包括飞机、医疗设备以及核电站上运行的代码。程序代码不是为机器编写的,而是为人类编写的。Martin Fowler 曾说过:“任何傻瓜都可以编写计算机能够理解的代码。但只有优秀的程序员可以编写人类能够理解的代码。”如果代码不能正常运行,那说明出了问题。但是,如果人们不理解代码,那么它肯定会出问题。迟早的事儿。此处,我们来谈论一下人类的大脑。人脑是世界上最强大的机器。但是,它有其自身的局限性。我们的工作记忆是有限的,人脑一次最多只能思考 5 件事。这意味着,程序代码的编写方式不应该超出人脑的局限。然而,面条式代码导致人类无法理解代码库。这就会埋下深远的祸根,因为我们不清楚某些代码变动是否会引发问题。我们无法运行详尽的测试,找出所有缺陷,甚至没有人知道这样的系统是否能正常工作。即便系统能够正常工作,我们也不明白为什么。
为什么经过一段时间的发展之后,代码库会出现面条式代码?因为熵。宇宙中的一切都变得混乱无序。就像电缆终将乱如一团麻,我们的代码最终也将变得混乱不堪。除非我们施加足够的约束。为什么高速公路有时速限制?这是为了防止我们撞车。为什么道路上有交通信号?为了防止人们走错路,为了防止事故发生。编程也一样。这样的约束不应让程序员来决定,应该通过工具自动实现,或者理想情况下通过编程范例本身来实现。
我们怎样才能施加足够的约束,防止面条式代码的出现?两个办法:手动或自动。手动很容易出错,人类难免会犯错。因此,我们理应自动执行此类约束。然而,面向对象编程并不是我们一直在寻找的解决方案。它没有提供任何约束来帮忙解决代码扭曲纠缠的问题。一个人可以精通各种面向对象编程的最佳实践,例如依赖注入、测试驱动的开发、领域驱动的设计等(这些实践确实有帮助)。但是,这些都不是由编程范例本身来强制执行的(而且也没有相应的工具来强制执行最佳实践)。面向对象编程内部没有任何功能可以帮助我们预防面条式代码,封装只是隐藏和打乱了程序的状态,只会让情况变得更糟。继承带来了更多的混乱。面向对象编程的多态性更是火上浇油,我们根本不知道程序运行时会采用哪种确切的执行路径。特别是在涉及多个继承级别时。面向对象进一步加剧了面条式代码的问题
然而,面向对象的缺点可不止缺乏适当的约束。在大多数面向对象编程语言中,默认情况下一切都是通过引用共享的。这实际上将一个程序变成了一个庞大的全局状态。这与面向对象原本的思想背道而驰。面向对象的创建者 Alan Kay 拥有生物学的背景。他想到了一种语言(Simula),可以让我们按照生物细胞的组织方式编写计算机程序。他希望有独立的程序(细胞)通过相互发送消息进行通信。独立程序的状态永远不会与外界共享(封装)。AlanKay 从来也没想过让“细胞”直接进入其他细胞的内部做任何修改。但现代面向对象编程就这么干了,因为在现代面向对象编程中,默认情况下,一切都是通过引用共享的。这也意味着破坏正常功能的错误无法避免。修改程序的某一部分就会破坏其他功能(这在函数式编程等其他编程范例中很少见。)我们可以清楚地看到,现代面向对象编程本质上就存在很大的缺陷。它不仅会让你在日常工作中痛苦不堪,而且还会让你夜不成寐。
面条式代码是一个重大的问题。面向对象的代码特别容易形成面条式。面条式代码导致软件无法维护,但这只是问题的一部分。此外,我们还希望软件具有可靠性,以及可预测性。任何系统的用户都应该享受相同的、可预测的体验。踩下油门,汽车就会加速;相反,踩刹车,汽车就会减速。用计算机科学术语来说,我们希望汽车的行为是确定的。我们非常不希望汽车表现出随机行为,例如加速器无法加速,或制动器不能减速(丰田的问题)。即使此类问题发生的概率非常低。然而,大多数软件工程师的心态都是“我们的软件要足够好,才能让客户继续使用。”我们能做的只有这么多吗?当然不是,我们应该做得更好!然而,首先最起码应该解决程序的不确定性。
不确定性
在计算机科学中,确定性算法指的是针对相同的输入,算法始终能够表现出相同的行为。而不确定性算法恰恰相反,即便输入相同,每次运行算法也会表现出不同的行为。举个例子:console.log('result', computea(2) );console.log('result', computea(2) );console.log('result', computea(2) );// output:// result 4// result 4// result 4无需在意上述函数的具体功能,你只需要知道对于相同的输入,它总是会返回相同的输出。下面,我们看一看另一个函数 computeb:console.log('result', computeb(2) );console.log('result', computeb(2) );console.log('result', computeb(2) );console.log('result', computeb(2) );// output:// result 4// result 4// result 4// result 2 <= not good这一次,这个函数在面对相同的输入时,却给了不同的输出。这两个函数之间有什么区别?前者针对相同的输入,总是能给出相同的输出,就像数学函数一样。换句话说,这个函数是确定的。而后者则不一定会输出预期的值,换句话说,这个函数是不确定的。如何判断某个函数是确定的,还是不确定的?
[list,
[*,不依赖外部状态的函数百分百都是确定的。
[*,只调用其他确定的函数的函数也是确定的。
[/list,functioncomputea(x){returnx * x;}functioncomputeb(x){returnMath.random<0.9? x * x: x;}在上述示例中,computea 是确定的,它总是能够针对相同的输入给出相同的输出。因为它的输入只取决于参数x。而 computeb 是不确定的,因为它调用了另一个不确定的函数Math.random。我们怎么知道 Math.random是不确定的?因为这个函数会根据系统时间(外部状态)来计算随机值。而且,它也没有参数,只取决于外部状态。确定性与可预测性之间有什么联系?确定的代码就是可预测的代码。不确定的代码就是不可预测的代码。
我们再来看一个函数。functionadd(a, b){我们可以确定,输入(2,2)的结果总是等于 4。我们为什么能确定?在大多数编程语言中,加法操作都是通过硬件实现的,换句话说,CPU 会负责计算结果始终保持不变。除非我们需要处理浮点数的比较(但这是另一个话题,与不确定性问题无关)。这里,我们只讨论整数。硬件非常可靠,因此我们可以放心加法的结果正确无误。下面,我们给 2 加一个处理:constbox =value=>({ value });consttwo = box(2);consttwoPrime = box(2);returna.value +b.value;}console.log("2 + 2' == "+ add(two, twoPrime));console.log("2 + 2' == "console.log("2 + 2' == "// output:// 2 + 2' == 4// 2 + 2' == 4// 2 + 2' == 4到这里,这个函数依然是确定的!a.value+= b.value;returna.value;}console.log("2 + 2' == "+add(two, twoPrime));console.log("2 + 2' == "+add(two, twoPrime));console.log("2 + 2' == "+add(two, twoPrime));// output:// 2 + 2' == 4// 2 + 2' == 6// 2 + 2' == 8发生了什么?突然间,函数的结果就不可预测了!第一次运行没有问题,但是后面每次运行得到的结果都是不可预测的。换句话说,这个函数不再具备确定性。为什么突然变成不确定的?这是因为我们修改了函数作用域之外的一个值,函数出现了副作用。总结一下
确定的程序可确保 2 + 2 == 4。换句话说,给定输入(2, 2),函数 add 必然会输出 4。无论这个函数被调用多少次,无论是否并行调用该函数,也无论函数外部是什么状况,它必然会输出 4。不确定的程序则恰好相反,在大多数情况下,add(2, 2)将返回 4。但有时,该函数可能会返回 3、5,甚至 1004。程序中万万不能出现不确定性,我希望你明白为什么。不确定的代码有什么后果?它们会引发软件缺陷,也就是常说的 bug。遇到 bug,开发人员需要浪费大量宝贵的时间来调试代码,如果将这类代码投入生产,则会大大降低客户体验。为了让我们的程序更可靠,首先应该解决不确定性的问题。
这里,我们不得不谈一谈副作用。什么是副作用?通常意义的副作用是指如果你因头痛而服用药物,但这种药物让你感到恶心,那么恶心就是一种副作用。简而言之,副作用就是不良反应。想象一下,你买了一台计算器。带回家后却发现这不是一台简单的计算器。你输入 10 * 11,而它却输出了 110,同时在你耳边大叫“一百一十”。这就是副作用。接着,你输入 41+ 1,它输出了 42,但又喊道“四十二,死掉。”这也是副作用。你带着满脸的疑惑,打电话叫外卖,结果这台计算器偷听到了你的电话,大声说“好的”,然后还帮你下了订单。这也是副作用。returna.value;}这个函数执行了预期的操作,即 a 加 b。但是,它也有副作用。因为在执行a.value+ = b.value 后,对象 a 会发生变化。假设刚开始的是 a.value=1,则第一次调用完成后,a.value=2。而且第二次调用后,它的值会再次变化。
纯粹
在讨论了确定性和副作用后,我们再来看一看纯粹。纯函数是确定的,而且没有副作用。纯函数有什么优点?它们是可预测的。因此,非常易于测试(无需编写模拟函数和桩函数)。理解纯函数非常容易,不需要将整个应用程序状态都装入大脑。你只需要考虑眼前的函数。编写纯函数也很容易(因为它们不会修改任何范围之外的数据)。纯函数非常适合并发,因为函数之间没有共享状态。另外,重构纯函数也很简单,只需复制和粘贴,无需复杂的 IDE 工具。简而言之,纯函数可以让我们快乐地编程。面向对象编程是否是纯粹的?
为了举例说明,我们看两个面向对象的特性:getter和setter。getter 的结果取决于外部状态,也就是对象状态。每次调用 getter,得到的结果都不相同,具体取决于系统的状态。因此,getter 本质上是不确定的。setter会修改对象的状态,因此它们本质上就带有副作用。这意味着面向对象所有的方法(除静态方法外)或者是不确定的,或者会带来副作用。因此,面向对象编程并不纯粹,它与纯粹背道而驰。银弹[indent,无知并不值得羞愧,无知又不学才让人羞愧。—— 本杰明·富兰克林[/indent,虽然软件世界里困难重重,但我们仍存一线希望,即便无法解决所有的问题,至少也可以解决大多数问题。但是,只有当你愿意学习和应用,才能成功。银弹的定义是什么?就是可以解决所有问题的灵丹妙药。经过千百年的努力,数学界也有银弹。如果不确定性(即不可预测)成为现代科学的支柱,那你觉得我们的世界能走多远?可能不会太远,或许我们还停留在中世纪。医学界就经历过这样的情况,过去我们没有严格的试验来证实特定的治疗方法或药物的功效。不幸的是,如今的软件行业与过去的医学太相似了。现代软件行业的基础非常脆弱,也就是所谓的面向对象编程。我们希望软件也能够像医学一样,找到坚实的基础。
坚实的基础
在编程世界中,我们也可以拥有像数学一样可靠的基础吗?可以!我们可以将许多数学概念直接转化为编程,并以此为基础,也就是我们所说的函数式编程。函数式编程是编程界的数学,它是非常牢固且强大的基础,可用于构建可靠且健壮的程序。是什么让函数式编程如此强大?这种编程方式的基础是数学,尤其是 Lambda 演算。做个比较,现代面向对象编程的基础是什么?Alan Kay 的面向对象编程基于的是生物细胞。但是,现代 Java/C#的基础是一套荒谬的思想,比如类、继承和封装等,这些并非源自 Alan Kay 最初的思想。反观函数式编程,它的核心构件是函数,而且在大多数情况下是纯函数。纯函数是确定性的,因此它们是可预测的。这意味着由纯函数组成的程序将是可预测的。这倒不是说函数式编程没有 bug,但是如果程序中存在 bug,那也是确定的,即对于相同的输入始终会引发相同的 bug,因此非常容易修复。
以前,在过程式编程和函数式编程出现之前,goto 语句广泛用于编程语言中。goto 语句允许程序在执行的过程中跳至代码的任何部分。因此,开发人员很难回答:“代码是如何执行到这里的?”而且 goto 语句引发了大量 bug。如今,面向对象编程也有这个问题。在面向对象编程中,一切都是通过引用传递的。从理论上讲,这意味着任何对象都有可能被其他对象修改(面向对象编程对此没有任何约束)。封装根本没有帮助,它只不过是调用一种方法来更改某些对象的字段。这意味着,程序中的依赖关系很快就会乱成一锅粥,整个程序都会成为一个大型的全局状态。有什么办法可以解决这个问题吗?没错,就是采用函数式编程。过去曾经许多人对于停止使用 goto 的建议都有抵触,就像如今很多人会反对函数式编程和不可变状态的思想。
在面向对象编程中,“组合优于继承”被视为最佳实践。从理论上讲,这类的最佳实践有助于改善面条式代码。然而,这只是“最佳实践”,面向对象的编程范例本身没有任何约束,强制人们遵守这类最佳实践。团队中的初级开发人员是否遵循这类最佳实践,完全看个人,或者你在代码审查中强制实施。函数式编程如何?在函数式编程中,函数组合(和分解)是构建程序的唯一方法。这意味着编程范例本身会强制执行组合。这正是我们一直在寻找的解决方案。函数调用其他函数,大函数始终由小函数组成。组合在函数式编程中是很自然的选择。此外,在这种方式下,重构的难度也会降低,只需剪切代码,然后将其粘贴到新函数中即可。无需管理复杂的对象依赖项,也不需要复杂的工具(例如Resharper)。我们可以看出,要想更好地组织代码,选择面向对象编程并不明智,函数式编程明显更胜一筹。
面向对象编程本身就是一个巨大的错误。如果我知道我乘坐的汽车运行的软件是由面向对象编程语言编写的,我会感到害怕;知道我和家人乘坐的飞机使用了面向对象的代码,也会让我感到不安。我们应该采取行动,认识到面向对象编程的危险,并努力学习函数式编程。我知道这个过程很漫长,至少需要十年才能做出转变。但我相信在不久的将来,终有一天面向对象编程会退出这个舞台,就像如今的 COBOL 一样。 |
|