Rust超越了面向对象?- Jimmy


本文试图解释 Rust 如何偏离面向对象编程范式的原则以及为什么这是一件好事?

Rust不是一种面向对象的编程语言。

Rust可能看起来像一种面向对象的编程语言:
类型可以与 "方法 "相关联,要么是 "内在的",要么是通过 "特征traits"。
方法通常可以用C++或Java风格的OOP语法来调用:
map.insert(key, value)

foo.clone()。
就像在OOP语言中一样,这种语法涉及一个 "接收器 receiver"参数,放在调用者的点号“.”之前,在被调用者中被称为self。

但不要搞错了:
尽管Rust可能借用了一些术语和语法,但它不是一种面向对象的编程语言。
面向对象编程有三个支柱:封装、多态性和继承。
其中,Rust完全放弃了继承,所以它永远不可能成为一种 "真正的 "面向对象编程语言。
但即使是封装和多态,Rust的实现方式也与OOP语言不同--这一点我们将在后面详细介绍。

这一切对很多程序员来说都是一个惊喜和调整:
我看到Rust新手在Reddit上询问如何从字面上实现OOP设计模式,试图让像 "形状 "或 "车辆 "这样的 "类层次 "与作为 "Rust版本的继承 "的traits一起工作。
换句话说,试图解决他们只因为致力于OOP方法而遇到的问题,并做一些伪造的OOP例子来试图学习他们期望的只是另一个版本的东西。

这对很多人来说是个绊脚石。我经常看到Rust新手和怀疑论者在网上提到 "缺乏OOP",认为这是Rust难以适应的原因,或者不适合他们,甚至是它永远不会流行的原因。
对于那些在OOP成为趋势的高峰期学习编程的人来说,对一种非OOP语言的大量炒作让人感觉不对劲:当时像C和ML这样完美的语言不得不变成Objective-C和OCaML这样的面向对象。

OOP意识形态
OOP不仅仅是一套代码组织实践,而是一场编程的革命。OOP方式被认为是更直观的,特别是对非程序员来说,因为它与我们对自然界的思考方式更一致。

对于这种营销的一个典型例子,这里有一篇摘录自流行杂志(Byte杂志,1981年)上第一篇关于OOP的公开文章:

许多不知道计算机如何工作的人认为面向对象编程的想法很自然。相反,许多有计算机经验的人最初认为面向对象系统有一些奇怪的地方。

这也是相当容易接受的。
当然,我们的日常生活中并没有像子程序或变量那样的东西,或者说,即使有,我们也不会明确地去想它们!但它确实有我们可以使用的对象。但它确实有我们可以互动的对象,每个对象都有自己的能力。这怎么可能不直观呢?

这是很有说服力的伪认知科学,轻研究,重真正有说服力的理由:
物体Objects可以被认为是 "代理人agents",几乎和人一样,因此你可以利用你的社会技能来实现它,而不仅仅是分析性思维(别忘了,物体的行为和人完全不一样,实际上在某种程度上,它仍然需要分析性思维,所以大大地笨拙)。
或者:
你可以把物体Object和类Class看作是形式世界本身的几乎平面化的代表,使其在哲学上具有说服力。

在我肆意妄为的青年时期。我个人沉浸在OOP和柏拉图哲学之间的联系中。我深入研究了元对象协议,以及Smalltalk中每个类都必须有一个元类的事实。Smalltalk代码元类Metaclass class的概念对我来说几乎是神秘的,因为任何值都可以被组织在相同的层次结构中,而Object是它的根。

我记得在一本书上看到,OOP风格的多态性使得if-else 语句变得多余,因此我们应该努力最终只使用OOP风格的多态性。
不知何故,这在当时非但没有让我失望,反而让我感到兴奋。
当我了解到Smalltalk实际上是这样做的(如果你忽略了那些优化了部分抽象的实现细节的话),我就更加兴奋了。
在Smalltalk中,if-then-else的概念是通过ifTrue:和ifFalse:以及ifTrue:ifFalse:这样的方法在单实例True和False类上实现的,其全局对象是true和false。

作为一个更成熟的程序员,接触到C++中较少的OOP和Haskell中函数式编程的替代方案后,我的立场有所缓和,然后发生了巨大的转变,现在我几乎不喜欢OOP,尤其是当它的最佳理念被Haskell和Rust带到了一个新的范式中。
我意识到,这种对新程序员的炒作对任何范式来说都是典型的;任何新的编程范式对新手来说都比对不同范式的老程序员更直观。对函数式编程也是如此。Rust也是如此。这与一种范式是否更好并没有多大关系。

至于if语句是否可以完全被多态性所取代,很容易想出一套图灵完备的原语。你可以用多态性来模拟if语句,没错。你也可以用递归来模拟 while 循环,或者用 while 循环和一个显式堆栈来模拟递归。你可以用 while 循环来模拟 if 语句。

这些事实都没有使这种替换成为一个好主意。在编程语言中,不同的功能存在于不同的情况下,适度地将它们区分开来,实际上是一件好事。

毕竟,编程的目的是编写程序,而不是为了证明图灵完备性、研究哲学或写概念诗。

实用性
因此,在这个博客系列中,我打算从实践的角度来评价OOP,作为一个有经验的程序员,我想知道是什么让编程语言在认知上更容易管理或更容易进行抽象。我将从我解决实际编程问题的经验出发--我认为这是一个不好的迹象,许多关于OOP抽象如何工作的例子只有在真正的高级程序中才有意义,或者是关于不同类型的形状或动物园里的动物的臆造的例子。

与大多数OOP介绍不同的是,我不会主要关注OOP与前OOP编程语言的比较。相反,我将与Rust进行比较,Rust从OOP中吸收了许多好的想法,也许还与Haskell等函数式编程语言进行比较。这些编程语言采用了OOP的一些好的想法,但以一种修复它们的缺陷的方式对它们进行了改造,使它们超越了可以合理地称为OOP的范围。

我将根据面向对象编程的三个传统支柱来组织这次比较:封装、多态性和继承,这第一篇文章主要讨论封装。对于每个特点,我将讨论OOP是如何定义它的,在OOP世界之外存在哪些等价物或替代物,以及这些替代物在编程的实际便利性和力量方面如何比较。

但在我开始讨论之前,我想先谈一谈一个将这一切颠覆的用例:图形用户界面或GUI。特别是在浏览器时代之前,编写直接在桌面(或笔记本)计算机上运行的GUI程序是程序员工作的一个重要部分。OOP的许多早期发展是与Xerox PARC的图形用户界面的研究同时进行的,OOP是独特的适合这种使用情况的。由于这个原因,图形用户界面值得特别考虑。

例如,人们在其他编程语言中模仿OOP是很常见的。Gtk+就是一个巨大的例子,它将OOP实现为C语言中的一系列宏和约定。这样做有很多原因,包括对OOP设计的熟悉和对创造某种运行时多态性的渴望。但根据我的经验,这在实现GUI框架时是最常见的。

在这一系列的文章中,我们将主要关注将OOP应用于其他用例,但我们也会适当地讨论GUI。在这个介绍性的部分,我只想指出,在传统的OOP设计和编程语言之外,甚至在Rust中,GUI框架显然是可能的。有时,它们通过完全不同的机制来工作,比如大多在Haskell中开创的函数式反应编程,我个人更喜欢传统的基于OOP的编程,而传统的OOP特性对其没有帮助。

现在,废话不多说,让我们从实用主义的角度出发,将OOP与Rust和其他后OOP编程语言逐一进行比较。在这第一篇文章的其余部分,我们将重点讨论封装。

第一根支柱:封装
在面向对象的编程中,封装是与类的概念联系在一起的,类是面向对象编程的基本抽象层。

  • 每个类都包含一个记录格式的数据布局,也就是一个数据结构,每个实例都包含一定数量的字段。
  • 记录类型的单个实例被称为 "对象"。
  • 每个类还包含与该记录类型紧密配对的代码,被组织成称为方法的程序。

这样做的目的是,所有的字段都只能从方法内部访问,或者通过OOP思想的惯例,或者通过编程语言的强制规则。

这里的基本好处是:interface接口,也就是代码与其他代码的交互方式,或者说你必须了解什么才能使用这些代码,要比实现简单得多,因为实现是代码如何实际完成其工作的更流畅的细节。

但当然,很多编程语言都有这样的抽象概念:任何超过十几行的程序都有太多的部分,你的大脑无法一下子记住,所以所有遥远的现代编程语言都有办法将一个程序分成更小的组件,作为管理复杂性的一种方式,这样接口就比实现更简单,无论是由编程语言强制执行还是 "荣誉系统 "的问题。因此,在更广泛的意义上,所有现代编程语言都有某种版本的封装。

一种简单的封装形式--大多数面向对象的编程语言都将其作为类中的一层来维护--是过程procedures,也被称为函数、子程序或(OOP称之为)方法。
现代编程语言不允许任何一行代码跳转到其他任何一行代码,而是倾向于将代码块组合成程序,然后你可以改变程序procedures的内容而不影响外部代码,改变外部代码也不影响程序,只要它们遵循相同的接口和契约。

契约通常至少有一部分是人类层面的约定。通常没有什么可以阻止你把一个本应处理某些数据的过程变成无限期循环或使程序崩溃的过程。但是其中的一些规定,比如过程与程序其他部分的分离,以及在许多情况下允许它在调用中接受和返回的值的数量和类型,将由编程语言来执行。

例如,在过程中声明的变量通常是局部的,通常没有办法在过程之外引用它们。输入和输出通常被列在过程顶部的签名中。通常情况下,外部代码只能在过程的第一行进入,而不是在中途的任意一行。在一些编程语言中--包括Rust--过程甚至可以包含其他过程,这些过程只能在外部过程中调用。

但当然,现代程序往往比仅有的几个过程更复杂。因此,现代编程语言(再说一遍,这里的 "现代 "一词是以一种非常宽松的方式使用的)有另一层封装的抽象:模块。

模块通常包含一组过程procedures,有些可以从外部访问,有些则不能。
在非纠错类型的语言中,它们通常会定义一些集合类型,同样有些是可以从外部访问的,有些则不是。
一般来说,甚至有可能抽象地暴露这些类型,所以一个类型的存在可以被程序的其他部分所访问,但不是记录字段,甚至不是它是一个记录类型的事实。甚至C语言在其模块系统中也有这种能力--C++没有引入这种能力,只是增加了一个额外的、正交的逐个字段的访问控制级别。

从我的实用主义观点来看,基于类的封装并不是OOP的一些特殊地方,特殊地方是一种模块形式封装
在OOP编程语言中,我们有类的概念,它是模块的一种特殊形式(有时是唯一支持的形式,有时甚至分层在一个完全不同的、更传统的模块概念之下,以增加混乱)。只是,对于一个 "类 "来说,通常只能定义一个主要类型,它与模块本身共享一个名字,并且该类型的字段被赋予特殊的保护,以防止被类外的代码访问。

当然,类和模块之间还有其他区别,但这些都与其他支柱有关,我们将在后面讨论。现在,我们只讨论 "类 "的概念,因为它与封装有关--一个类只是一个特殊的模块,有一个特权的、抽象的类型。

这也是写模块的一种合理方式,但它并不像面向对象编程所描述的那样特殊:

下面是Rust代码:

pub struct Point {
    pub x: f64,
    pub y: f64,
    pub z: f64,
}

"JavaBean "等价物:

class Point {
    private double x;
    private double y;
    private double z;

    double getX() {
        return x;
    }

    double setX(double x) {
        this.x = x;
    }

    double getY() {
        return y;
    }

    double setY(double y) {
        this.y = y;
    }

    double getZ() {
        return z;
    }

    double setZ(double z) {
        this.z = z;
    }
}

这样的数据类型使用OOP类没有得到的任何其他特性,如多态性或继承性。在这样的 "JavaBean "类中使用这样的特性也会违反最小惊喜原则。对于这些记录类型,"类 "的概念是矫枉过正的。

当然,Java开发者(或Smalltalk,或C#)会说,通过这些getter和setter方法间接访问字段,他们是在为类提供未来保护,以防设计发生变化(事实上,当Reddit上有人提出这个观点时,我被提醒要添加这一段)。但我发现这是不真诚的,或者至少是被误导的--它经常被用于程序内部的一部分结构,在那里,更合理的做法是向该结构的所有用户公开地改变字段。此外,除了字面上的设置或获取字段之外,要想让这些方法做一件不令人惊讶的事情也是非常困难的,就像方法名称所暗示的那样--例如,进行网络调用,对于一个获取或设置方法来说是一个令人震惊的惊喜,因此至少是违反了隐性契约。在我对面向对象的编程语言进行编程的过程中,我从未见过一个getter或setter除了字面上的获取或设置字段之外还能做什么的情况。

(banq:作者的眼界限制了他的后面逻辑推理, setter中如果加入业务规则检查,这个类就变成充血对象了。在重视商业软件的世界里,这个规则被喊了几十年,他却视而不见,可见这是两个世界的人,只接触过C++等纯技术的人的思维中是没有业务逻辑概念的,只有取而代之的算法,类似数学的概念,这是计算机教育的偏见。)

如果代码确实改变了,需要getter或setter做一些其他的事情,我宁愿改变方法的名字来反映它做的其他事情,而不是假装这不是一个破坏性的改变。fetchZFromNetwork或setAndValidateZ似乎比getZ或setZ更合适,因为它做的事情不仅仅是我们假设的setter或getter做的简单字段访问。
OOP坚持认为每个类型都应该是它自己的代码抽象边界,当应用于这些轻量级的聚合类型时,这往往是荒谬的。这些类型的getter和setter被用来保护一个不应该存在的抽象边界,而只是碍于情面,并防止未来在不改变接口的情况下对实现的改变。

(banq:这是最简单的给自己留有余地的做法,虽然很难看,但实用,至少不用添加或更改方法名称,因为一旦修改方法名称,会造成全局代码依赖性的冲击,GoF设计模式为这种修改提供了很多变通方式,作者看来没有了解设计模式)

简而言之,Setters 和 getters是一种反模式。如果你打算创建一个除了 "数据结构 "之外的抽象,其中验证或网络调用或其他任何超越原始字段访问的东西都是合适的,那么这些get和set的名字对这个抽象来说是错误的。

(banq:这种只有Setters 和 getters的DTO可以称为贫血对象,在重视商业业务的DDD中也是不提倡的,建议用领域事件和值对象替代,值对象是一种没有Setter的DTO,如果Java不提供这种简陋的基础的Javabean,那么就要提供DDD中的值对象类型或充血对象类型,重视技术的程序员又不开心了,所以,Javabean这个现状只能被认为是合理存在,结合不同使用上下文进行阉割即可,如果在DDD上下文中,使用充血实体和值对象方式,如果在作者所处的纯技术世界里,就使用lomok之类框架即可,大家各取所需,但是没有必要为了自己的上下文方便,而去指责Javabean的中立。)

在现实中,get和set函数只是作为封装器来满足面向对象意识形态的约束。它们声称提供的面向未来的功能是一种假象。如果你在抽象边界上提供了 "JavaBean "风格的类型,或者带有属性的类型,你实际上就像提供原始字段访问一样被锁定了--你最有可能想要对这些结构进行的改变不允许转移getters和setters以保持兼容性。对于你想做的改变来说,利用这种面向未来的方法可能是完全不可能的,最多就是涉及到一个可怕的黑客。

(banq注:黑客精神是编程精神所在,没有一个库包能够在很长时间应用中不被黑着用的,因为这个库包诞生的上下文不适合当前上下文了,作者真的可能是一个学院书呆子)

在这些方面,Rust似乎与OOP语言一样;从表面上看,它有与类非常相似的东西:
你可以定义与给定类型相关的函数:它们甚至被称为方法像OOP方法一样,它们在语法上享有特权,以该类型的值(或对这些值的引用)作为第一个参数,称为特殊名称self。你甚至可以将记录类型(在Rust中称为结构strut)的字段标记为公共或(默认)私有,鼓励私有字段,就像在面向对象编程语言中一样。

根据这个“封装”特点,Rust似乎非常接近于OOP。对于这个支柱特点来说,这是一个公平的评价,也是一个有意的选择,使Rust编程对习惯了C++(或Java,或JavaScript)中OOP编程的日常语法的人来说更加舒适。

但这种相似性只是皮毛而已:
封装是OOP中最不明显的支柱(毕竟所有的现代编程语言都有某种形式的封装),而且Rust中的实现并不与类型绑定。
当你在Rust中声明一个字段是私有的(不指定pub),这并不意味着对其方法是私有的,这意味着对模块是私有的。
一个模块可以提供多种类型,该模块中的任何函数,无论是否是该类型的 "方法",都可以访问该类型中定义的所有字段。
在适当的时候,传递记录是被鼓励的,而不是不鼓励到访问器被强制代替的地步,即使是在严格约束的相关代码中。

这是我们看到的第一个迹象,表明Rust,尽管其表面的语法,不是一种OOP编程语言。

(banq:Rust这种方式应该说是OOP的进化,因为Javabeans中字段私有是用于可变状态的保护,如果是不可变,无所谓私有或公有了,而Rust是基于不可变和可变区分这个上文的假设前提下发明的,而Java发明时,业界没有那么强的意识去区分可变和不可变,这样JavaBean就做了一个中立表态,用Javabean可以实现可变或不可变,但是Rust正好倒过来,不可变是默认的,已经有了自己的默认设置和立场,那么在这种情况下,私有这个概念就不适合用于可变的状态了,因为Rust语言中可变状态是被特殊照顾的,比如用枚举等类型配合switch去实现的,没有那么容易通过一个普通struct结构就能实现可变状态了,这种方式引入了函数式编程概念,是一种进化。)