原文发表于博客:blog.zhangbing.site
C++和Java可能是计算机科学中最严重的错误。两者都受到了OOP创始人Alan Kay本人以及其他许多著名计算机科学家的严厉批评。然而,C++和Java为最臭名昭著的编程范式–现代OOP铺平了道路。
它的普及是非常不幸的,它对现代经济造成了极大的破坏,造成了数万亿美元至数万亿美元的间接损失。成千上万人的生命因OOP而丧失。在过去的三十年里,没有一个行业不受潜伏的OO危机的影响,它就在我们眼前展开。
为什么OOP如此危险?让我们找出答案。
想象一下,在一个美丽的周日下午,带着家人出去兜风。外面的天气很好,阳光明媚。你们所有人都进入车内,走的是已经开过一百万次的同一条高速公路。
然而这次却有些不一样了–车子一直不受控制地加速,即使你松开油门踏板也是如此。刹车也不灵了,似乎失去了动力。为了挽救局面,你铤而走险,拉起了紧急刹车。这样一来,在你的车撞上路边的路堤之前,就在路上留下了一个150英尺长的滑痕。
听起来像一场噩梦?然而这正是2007年9月让-布克特在驾驶丰田凯美瑞时发生的事情。这并不是唯一的此类事件。这是众多与所谓的“意外加速”有关的事件之一。“意外加速”已困扰丰田汽车十多年,造成近百人死亡。汽车制造商很快就将矛头指向了“粘性踏板”、驾驶员失误,甚至地板垫等方面。然而,一些专家早就怀疑可能是有问题的软件在作怪。
为了帮助解决这个问题,请来了美国宇航局的软件专家,结果一无所获。直到几年后,在调查Bookout事件的过程中,另一个软件专家团队才找到了真凶。他们花了近18个月的时间来研究丰田的代码,他们将丰田的代码库描述为“意大利面条代码”——程序员的行话,意思是混乱的代码。
软件专家已经演示了超过1000万种丰田软件导致意外加速的方法。最终,丰田被迫召回了900多万辆汽车,并支付了超过30亿美元的和解费和罚款。
某些软件故障造成的100条生命是太多了,真正令人恐惧的是,丰田代码的问题不是唯一的。
两架波音737 Max飞机坠毁,造成346人死亡,损失超过600亿美元。这一切都是因为一个软件bug, 100%肯定是意大利面条式代码造成的。
意大利面条式的代码困扰着世界上太多的代码库。飞机上的电脑,医疗设备,核电站运行的代码。
程序代码不是为机器编写的,而是为人类编写的。正如马丁·福勒(Martin Fowler)所说:“任何傻瓜都可以编写计算机可以理解的代码。好的程序员编写人类可以理解的代码。”
如果代码不能运行,那么它就是坏的。然而如果人们不能理解代码,那么它就会被破坏。很快就会。
我们绕个弯子,说说人脑。人脑是世界上最强大的机器。然而,它也有自己的局限性。我们的工作记忆是有限的,人脑一次只能思考5件事情。这就意味着,程序代码的编写要以不压垮人脑为前提。
意大利面条代码使人脑无法理解代码库。这具有深远的影响–不可能看到某些改变是否会破坏其他东西,对缺陷的详尽测试变得不可能。
为什么代码会随着时间的推移变成意大利面条代码?因为熵–宇宙中的一切最终都会变得无序、混乱。就像电缆最终会变得纠缠不清一样,我们的代码最终也会变得纠缠不清。除非有足够的约束条件。
为什么我们要在道路上限速?是的,有些人总会讨厌它们,但它们可以防止我们撞死人。为什么我们要在马路上设置标线?为了防止人们走错路,防止事故的发生。
类似的方法在编程时完全有意义。这样的约束不应该让人类程序员去实施。它们应该由工具自动执行,或者最好由编程范式本身执行。
我们如何执行足够的约束以防止代码变成意大利面条?两个选择–手动,或者自动。手动方式容易出错,人总会出错。因此,自动执行这种约束是符合逻辑的。
不幸的是,OOP并不是我们一直在寻找的解决方案。它没有提供任何约束来帮助解决代码纠缠的问题。人们可以精通各种OOP的最佳实践,比如依赖注入、测试驱动开发、领域驱动设计等(确实有帮助)。然而,这些都不是编程范式本身所能强制执行的(而且也没有这样的工具可以强制执行最佳实践)。
内置的OOP功能都无助于防止意大利面条代码——封装只是将状态隐藏并分散在程序中,这只会让事情变得更糟。继承性增加了更多的混乱,OOP多态性再次让事情变得更加混乱——在运行时不知道程序到底要走什么执行路径是没有好处的,尤其是涉及到多级继承的时候。
OOP进一步加剧了意大利面条代码的问题
缺乏适当的约束(以防止代码变得混乱)不是OOP的唯一缺点。
在大多数面向对象的语言中,默认情况下所有内容都是通过引用共享的。实际上把一个程序变成了一个巨大的全局状态的blob,这与OOP的初衷直接冲突。OOP的创造者Alan Kay有生物学的背景,他有一个想法,就是想用一种类似生物细胞的方式来编写计算机程序的语言(Simula),他想让独立的程序(细胞)通过互相发送消息来进行交流。独立程序的状态绝不会与外界共享(封装)。
Alan Kay从未打算让“细胞”直接进入其他细胞的内部进行改变。然而,这正是现代OOP中所发生的事情,因为在现代OOP中,默认情况下,所有东西都是通过引用来共享的。这也意味着,回归变得不可避免。改变程序的一个部分往往会破坏其他地方的东西(这在其他编程范式,如函数式编程中就不那么常见了)。
我们可以清楚地看到,现代OOP存在着根本性的缺陷。它是每天工作中会折磨你的“怪物”,而且它还会在晚上缠着你。
意大利面代码是个大问题,面向对象的代码特别容易意大利化。
意大利面条代码使软件无法维护,然而这只是问题的一部分。我们也希望软件是可靠的。但这还不够,软件(或任何其他系统)被期望是可预测的。
任何系统的用户无论如何都应该有同样的可预测的体验。踩汽车油门踏板的结果总是汽车加速。按下刹车应该总是导致汽车减速。用计算机科学的行话来说,我们希望汽车是确定性的。
汽车出现随机行为是非常不可取的,比如油门无法加速,或者刹车无法制动(丰田问题),即使这样的问题在万亿次中只出现一次。
然而大多数软件工程师的心态是“软件应该足够好,让我们的客户继续使用”。我们真的不能做得更好吗?当然,我们可以,而且我们应该做得更好!最好的开始是解决我们方案的非确定性。
在计算机科学中,非确定性算法是相对于确定性算法而言的,即使对于相同的输入,也可以在不同的运行中表现出不同的行为。
——维基百科关于非确定性算法的文章
如果上面维基百科上关于非确定性的引用你听起来不顺耳,那是因为非确定性没有任何好处。我们来看看一个简单调用函数的代码样本。
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
这次,函数为相同的输入返回了不同的值。两者之间有什么区别?前者的函数总是在给定相同的输入的情况下产生相同的输出,就像数学中的函数一样。换句话说,函数是确定性的。后一个函数可能会产生预期值,但这是不保证的。或者换句话说,这个函数是不确定的。
是什么使函数具有确定性或不确定性?
function computea(x) {
return x * x;
}
function computeb(x) {
return Math.random() < 0.9
? x * x
: x;
}
在上面的例子中,computea
是确定性的,在给定相同输入的情况下,它总是会给出相同的输出。因为它的输出只取决于它的参数 x
。
另一方面,computeb
是非确定性的,因为它调用了另一个非确定性函数 Math.random()
。我们怎么知道Math.random()是非确定性的?在内部,它依赖于系统时间(外部状态)来计算随机值。它也不接受任何参数–这是一个依赖于外部状态的函数的致命漏洞。
确定性与可预测性有什么关系?确定性的代码是可预测的代码,非确定性代码是不可预测的代码。
我们来看看一个加法函数:
function add(a, b) {
return a + b;
};
我们始终可以确定,给定 (2, 2)
的输入,结果将始终等于 4
。我们怎么能这么肯定呢?在大多数编程语言中,加法运算都是在硬件上实现的,换句话说,CPU负责计算的结果要始终保持不变。除非我们处理的是浮点数的比较,(但这是另一回事,与非确定性问题无关)。现在,让我们把重点放在整数上。硬件是非常可靠的,可以肯定的是,加法的结果永远是正确的。
现在,让我们将值 2
装箱:
const box = value => ({ value });
const two = box(2);
const twoPrime = box(2);
function add(a, b) {
return a.value + b.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' == 4
// 2 + 2' == 4
到目前为止,函数是确定性的!
现在,我们对函数的主体进行一些小的更改:
function add(a, b) {
a.value += b.value;
return a.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
的输出。不管你调用函数多少次,不管你是否并行调用函数,也不管函数外的世界是什么样子。
非确定性程序正好相反,在大多数情况下,调用 add(2, 2)
将返回 4
。但偶尔,函数可能会返回3、5,甚至1004。在程序中,非确定性是非常不可取的,希望你现在能明白为什么。
非确定性代码的后果是什么?软件缺陷,也就是通常所说的 “bug”。错误使开发人员浪费了宝贵的调试时间,如果他们进入生产领域,会大大降低客户体验。
为了使我们的程序更可靠,我们应该首先解决非确定性问题。
这给我们带来了副作用的问题。
什么是副作用?如果你正在服用治疗头痛的药物,但这种药物让你恶心,那么恶心就是一种副作用。简单来说,就是一些不理想的东西。
想象一下,你已经购买了一个计算器,你把它带回家,开始使用,然后突然发现这不是一个简单的计算器。你给自己弄了个扭曲的计算器!您输入 10 * 11
,它将输出 110
,但它同时还向您大喊一百和十。这是副作用。接下来,输入 41+1
,它会打印42
,并注释“42,生命的意义”。还有副作用!你很困惑,然后开始和你的另一半说你想要点披萨。计算器听到了对话,大声说“ok”,然后点了一份披萨。还有副作用!
让我们回到加法函数:
function add(a, b) {
a.value += b.value;
return a.value;
}
是的,该函数执行了预期的操作,将 a
添加到 b
。然而,它也引入了一个副作用,调用 a.value += b.value
导致对象 a
发生变化。函数参数 a
引用的是对象 2
,因此是 2
,value
不再等于 2
。第一次调用后,其值变为 4
,第二次调用后,其值为 6
,依此类推。
在讨论了确定性和副作用之后,我们准备谈谈纯函数,纯函数是指既具有确定性,又没有副作用的函数。
再一次,确定性意味着可预测–在给定相同输入的情况下,函数总是返回相同的结果。而无副作用意味着该函数除了返回一个值之外,不会做任何其他事情,这样的函数才是纯粹的。
纯函数有什么好处?正如我已经说过的,它们是可以预测的。这使得它们非常容易测试,对纯函数进行推理很容易——不像OOP,不需要记住整个应用程序的状态。您只需要关心正在处理的当前函数。
纯函数可以很容易地组合(因为它们不会改变其作用域之外的任何东西)。纯函数非常适合并发,因为函数之间不共享任何状态。重构纯函数是一件非常有趣的事情——只需复制粘贴,不需要复杂的IDE工具。
简而言之,纯函数将欢乐带回到编程中。
面向对象编程的纯度如何?
为了举例说明,我们来讨论一下OOP的两个功能:getter和setter。
getter的结果依赖于外部状态——对象状态。多次调用getter可能会导致不同的输出,这取决于系统的状态。这使得getter具有内在的不确定性。
现在说说setter,Setters的目的是改变对象的状态,这使得它们本身就具有副作用。
这意味着OOP中的所有方法(也许除了静态方法)要么是非确定性的,要么会引起副作用,两者都不好。因此,面向对象的程序设计绝不是纯粹的,它与纯粹完全相反。
但是我们很少有人敢尝试。
无知不是耻辱,而是不愿学习。
— Benjamin Franklin
在软件失败的阴霾世界中,仍有一线希望,那将会解决大部分问题,即使不是所有问题。一个真正的银弹。但前提是你愿意学习和应用——大多数人都不愿意。
银弹的定义是什么?可以用来解决我们所有问题的东西。数学是灵丹妙药吗?如果说有什么区别的话,那就是它几乎是一颗银弹。
我们应该感谢成千上万的聪明的男人和女人,几千年来他们辛勤工作,为我们提供数学。欧几里得,毕达哥拉斯,阿基米德,艾萨克·牛顿,莱昂哈德·欧拉,阿朗佐·丘奇,还有很多很多其他人。
如果不确定性(即不可预测)的事物成为现代科学的支柱,你认为我们的世界会走多远?可能不会太远,我们会停留在中世纪。这在医学界确实发生过——在过去,没有严格的试验来证实某种特定治疗或药物的疗效。人们依靠医生的意见来治疗他们的健康问题(不幸的是,这在俄罗斯等国家仍然发生)。在过去,放血等无效的技术一直很流行。像砷这样不安全的物质被广泛使用。
不幸的是,今天的软件行业与过去的医药太相似了。它不是建立在坚实的基础上。相反,现代软件业大多是建立在一个薄弱的摇摇欲坠的基础上,称为面向对象的编程。如果人的生命直接依赖于软件,OOP早就消失了,就像放血和其他不安全的做法一样,被人遗忘了。
有没有其他选择?在编程的世界里,我们能不能有像数学一样可靠的东西?是的,可以!许多数学概念可以直接转化为编程,并为所谓的函数式编程奠定基础。
是什么让它如此稳健?它是基于数学,特别是Lambda微积分。
来做个比较,现代的OOP是基于什么呢?是的,真正的艾伦·凯是基于生物细胞的。然而,现代的Java/C# OOP是基于一组荒谬的思想,如类、继承和封装,它没有天才Alan Kay所发明的原始思想,剩下的只是一套创可贴,用来弥补其劣等思想的缺陷。
函数式编程呢?它的核心构建块是一个函数,在大多数情况下是一个纯函数,纯函数是确定性的,这使它们可预测,这意味着由纯函数组成的程序将是可预测的。它们会永远没有bug吗?不,但是如果程序中有一个错误,它也是确定的——相同的输入总是会出现相同的错误,这使得它更容易修复。
在过去,在过程/函数出现之前 goto
语句在编程语言中被广泛使用。goto
语句只是允许程序在执行期间跳转到代码的任何部分。这让开发人员真的很难回答 “我是怎么执行到这一步的?” 的问题。是的,这也造成了大量的BUG。
如今,一个非常类似的问题正在发生。只不过这次的难题是 “我怎么会变成这个样子”,而不是 “我怎么会变成这个执行点”。
OOP(以及一般的命令式编程)使得回答 “我是如何达到这个状态的?” 这个问题变得很难。在OOP中,所有的东西都是通过引用传递的。这在技术上意味着,任何对象都可以被任何其他对象突变(OOP没有任何限制来阻止这一点)。而且封装也没有任何帮助–调用一个方法来突变某个对象字段并不比直接突变它好。这意味着,程序很快就会变成一团乱七八糟的依赖关系,实际上使整个程序成为一个全局状态的大块头。
有什么办法可以让我们不再问 “我怎么会变成这样” 的问题?你可能已经猜到了,函数式编程。
过去很多人都抵制停止使用 goto
的建议,就像今天很多人抵制函数式编程,和不可变状态的理念一样。
在OOP中,它被认为是 “优先选择组成而不是继承” 的最佳实践。从理论上讲,这种最佳做法应该对意大利面条代码有所帮助。不幸的是,这只是一种 “最佳实践”。面向对象的编程范式本身并没有为执行这样的最佳实践设置任何约束。这取决于你团队中的初级开发人员是否遵循这样的最佳实践,以及这些实践是否在代码审查中得到执行(这并不总是发生)。
那函数式编程呢?在函数式编程中,函数式组成(和分解)是构建程序的唯一方法。这意味着,编程范式本身就强制执行组成。这正是我们一直在寻找的东西!
函数调用其他函数,大的函数总是由小的函数组成,就是这样。与OOP中不同的是,函数式编程中的组成是自然的。此外,这使得像重构这样的过程变得极为简单——只需简单地剪切代码,并将其粘贴到一个新的函数中。不需要管理复杂的对象依赖关系,不需要复杂的工具(如Resharper)。
可以清楚地看到,OOP对于代码组织来说是一个较差的选择。这是函数式编程的明显胜利。
抱歉让您失望,它们不是互补的。
面向对象编程与函数式编程完全相反。说OOP和FP是互补的,可能就等于说放血和抗生素是互补的,是吗?
OOP违反了许多基本的FP原则:
归根结底,函数式编程是软件世界的数学。如果数学已经为现代科学打下了坚实的基础,那么它也可以以函数式编程的形式为我们的软件打下坚实的基础。
OOP是一个非常大且代价高昂的错误,让我们最终都承认吧。
想到我坐的车运行着用OOP编写的软件,我就害怕。知道带我和我的家人去度假的飞机使用面向对象的代码并没有让我感到更安全。
现在是我们大家最终采取行动的时候了。我们都应该从一小步开始,认识到面向对象编程的危险,并开始努力学习函数式编程。这不是一个快速的过程,至少需要十年的时间,我们大多数人才能实现转变。我相信,在不久的将来,那些一直使用OOP的人将会被视为 “恐龙”,就像今天的COBOL程序员一样,被淘汰。C ++和Java将会消亡, C#将死亡,TypeScript也将很快成为历史。
我希望你今天就行动起来——如果你还没有开始学习函数式编程,就开始学习吧。成为真正的好手,并传播这个词。F#、ReasonML和Elixir都是入门的好选择。
巨大的软件革命已经开始。你们会加入,还是会被甩在后面?