共计 8262 个字符,预计需要花费 21 分钟才能阅读完成。
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 进一步加剧了意大利面条代码的问题
不足适当的束缚(以避免代码变得凌乱)不是 OOP 的惟一毛病。
在大多数面向对象的语言中,默认状况下所有内容都是 通过援用共享 的。实际上把一个程序变成了一个微小的全局状态的 blob,这与 OOP 的初衷间接抵触。OOP 的创造者 Alan Kay 有生物学的背景,他有一个想法,就是想用一种相似生物细胞的形式来编写计算机程序的语言(Simula),他想让独立的程序(细胞)通过相互发送音讯来进行交换。独立程序的状态绝不会与外界共享(封装)。
Alan Kay 从未打算让“细胞”间接进入其余细胞的外部进行扭转。然而,这正是古代 OOP 中所产生的事件,因为在古代 OOP 中,默认状况下,所有货色都是通过援用来共享的。这也意味着,回归变得不可避免。改变程序的一个局部往往会毁坏其余中央的货色(这在其余编程范式,如函数式编程中就不那么常见了)。
咱们能够分明地看到,古代 OOP 存在着根本性的缺点。它是每天工作中会折磨你的“怪物”,而且它还会在早晨缠着你。
让咱们来谈谈可预测性
意大利面代码是个大问题,面向对象的代码特地容易意大利化。
意大利面条代码使软件无奈保护,然而这只是问题的一部分。咱们也心愿软件是 牢靠的 。但这还不够,软件(或任何其余零碎) 被冀望是 可预测 的。
任何零碎的用户无论如何都应该有同样的可预测的体验。踩汽车油门踏板的后果总是汽车减速。按下刹车应该总是导致汽车加速。用计算机科学的行话来说,咱们心愿汽车是 确定性的。
汽车呈现随机行为是十分不可取的,比方油门无奈减速,或者刹车无奈制动(丰田问题),即便这样的问题在万亿次中只呈现一次。
然而大多数软件工程师的心态是“软件应该足够好,让咱们的客户持续应用”。咱们真的不能做得更好吗?当然,咱们能够,而且咱们应该做得更好!最好的开始是解决咱们计划的 非确定性。
非确定性 101
在计算机科学中,非确定性算法是绝对于确定性算法而言的,即便对于雷同的输出,也能够在不同的运行中体现出不同的行为。
——维基百科对于非确定性算法的文章
如果下面维基百科上对于非确定性的援用你听起来不逆耳,那是因为非确定性没有任何益处。咱们来看看一个简略调用函数的代码样本。
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
这次,函数为雷同的输出返回了不同的值。两者之间有什么区别?前者的函数总是在给定 雷同的输出的状况下产生雷同的输入 ,就像数学中的函数一样。换句话说,函数是 确定性 的。后一个函数可能会产生预期值,但这是不保障的。或者换句话说,这个函数是不确定的。
是什么使函数具备确定性或不确定性?
- 不依赖内部状态的函数是 100%确定性的。
- 仅调用其余确定性函数的函数是确定性的。
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 违反了许多根本的 FP 准则:
- FP 提倡污浊,而 OOP 提倡杂质。
- FP 代码基本上是确定性的,因而是可预测的。OOP 代码实质上是不确定性的,因而是不可预测的。
- 组合在 FP 中是天然的,在 OOP 中不是天然的。
- OOP 通常会导致错误百出的软件和意大利面条式的代码。FP 产生了牢靠、可预测和可保护的软件。
- 在 FP 中很少须要调试,而简略的单元测试往往不须要调试。另一方面,OOP 程序员生存在调试器中。
- OOP 程序员把大部分工夫花在修复 bug 上。FP 程序员把大部分工夫花在交付后果上。
归根结底,函数式编程是软件世界的数学。如果数学曾经为现代科学打下了松软的根底,那么它也能够以函数式编程的模式为咱们的软件打下松软的根底。
采取行动,为时已晚
OOP 是一个十分大且代价昂扬的谬误,让咱们最终都抵赖吧。
想到我坐的车运行着用 OOP 编写的软件,我就胆怯。晓得带我和我的家人去度假的飞机应用面向对象的代码并没有让我感到更平安。
当初是咱们大家最终采取行动的时候了。咱们都应该从一小步开始,意识到面向对象编程的危险,并开始努力学习函数式编程。这不是一个疾速的过程,至多须要十年的工夫,咱们大多数人才能实现转变。我置信,在不久的未来,那些始终应用 OOP 的人将会被视为“恐龙”,就像明天的 COBOL 程序员一样,被淘汰。C ++ 和 Java 将会沦亡,C# 将死亡,TypeScript 也将很快成为历史。
我心愿你明天就口头起来——如果你还没有开始学习函数式编程,就开始学习吧。成为真正的好手,并流传这个词。F#、ReasonML 和 Elixir 都是入门的好抉择。
微小的软件反动曾经开始。你们会退出,还是会被甩在前面?