[译] 类 vs 数据结构

原文作者:Robert C. Martin (Uncle Bob)
原文链接:https://blog.cleancoder.com/uncle-bob/2019/06/16/ObjectsAndDataStructures.html

类是什么?

类是一组相似对象的范本。

对象又是什么呢?

对象是对封装的数据进行操作的一组函数。

不如这么说:对象是对隐含数据进行操作的一组函数。

隐式数据?什么意思?

对象的函数隐含意味着某些数据是存在的。但是该数据无法在对象外部直接访问或看到。

数据不是对象内部的吗?

可能是; 但没有规则说必须如此。从用户的角度来看,一个对象不过是一组函数。这些函数所需要的数据必须存在,但是用户不知道该数据的位置。

嗯,好,我同意。

好。现在说说,数据结构是什么?

数据结构是一组内聚的数据。

或者,换句话说,数据结构是由隐含函数操作的一组数据。

好的,好的。我知道了。数据结构没有定义对数据结构进行操作的函数,但是数据结构的存在意味着某些函数必须存在。

对。你有什么发现没有?

对象和数据结构似乎是彼此相反的。

确实。它们是彼此互补的。

  • 对象是对隐式数据进行操作的一组函数。
  • 数据结构是被隐式函数操作的一组数据。

哇,所以对象不是数据结构了?

说对了。对象与数据结构相反。

那么DTO(Data Transfer Object)不是对象吗?

DTO是数据结构。

所以数据库表也不是对象吗?

数据库包含数据结构,而不是对象。

可是,等等。ORM(Object Relational Mapper)不是将数据库表映射到对象吗?

当然不是。数据库表和对象之间没有映射。数据库表是数据结构,而不是对象。

那么,ORM算是干嘛的呢?

在数据结构之间传输数据。

ORM与对象无关是吗?

完全无关,ORM其实是不存在的。因为数据库表和对象之间没有映射。

但是我还以为ORM为我们创建了业务对象呢。

不对,ORM会提取业务对象所操作的数据。该数据包含在ORM加载的数据结构中。

但是,业务对象是不是会包含该数据结构啊?

它可能包含,也可能不包含。但是这不关ORM的事。

这种说法似乎是咬文嚼字,不太重要。

不,这具有重大意义。

怎么讲?

例如数据库schema的设计与业务对象的设计。业务对象定义业务行为的结构。数据库schema定义业务数据的结构。这两个结构受到非常不同的力的约束。业务数据的结构不一定是业务行为的最佳结构。

嗯?令人困惑。

这样想吧。数据库schema不仅为一个应用程序服务;它必须服务于整个企业。因此,该数据的结构是许多不同应用程序之间的折衷方案。

好,我知道了。

好。但是现在来说单独的应用程序。每个应用程序的对象模型描述了该应用程序的行为的构造方式。每个应用程序将具有不同的对象模型,并根据该应用程序的行为进行调整。

哦,我懂了。由于数据库schema是各种应用程序的折衷方案,因此该schema将不符合任何特定应用程序的对象模型。

对!对象和数据结构受到非常不同的作用力的约束。他们很少能对齐。人们习惯称其为对象/关系阻抗失衡(Object/Relational impedance mismatch)。

我听说过,但是我认为阻抗失衡是由ORM解决的。

现在你知道不是这样的了。因为对象和数据结构是互补的,而不是同构的,所以没有阻抗失配。

什么?

它们是对立的,不是相似的实体。

相反吗?

是的,以一种非常有趣的方式。你会看到,对象和数据结构意味着相反的控制结构。

等一下,什么?

考虑一组符合公共接口的类。例如,想象一下表示二维形状的类,这些类都具有计算area面积和perimeter周长的函数。

为什么每个软件示例都总是提到形状?

让我们只考虑两种不同的类型:Square和Circle。应该很容易看清楚的是,这两个类的area和permimeter函数在不同的隐式数据结构上运行。还应该清楚的是,调用这些函数的方式是通过动态多态性进行的。

等下,慢一点,什么?

有两种不同的area函数;一个是Square的,另一个是Circle的。当调用者在特定对象上调用area函数时,只有该对象才知道要调用哪个函数。我们称之为动态多态性。

好。当然。该对象知道其方法的实现。当然。

现在,让我们将这些对象换成数据结构。我们将使用标签联合(discriminated union)。

标签联合是什么?

标签联合。在我们当前讨论的情况下,这只是两个不同的数据结构。一个是Square另一个是Circle。Circle数据结构有一个圆心和半径。它还有一个将其标识为Circle的类型码。

你是说像枚举?

当然。Square数据结构有左顶点,和边长。它还有类型区分符(type discriminator)–枚举。

好。具有类型码的两个数据结构。

对。现在考虑area函数。它要有一个switch语句,不是吗?

嗯,当然,对应两种不同的情况。一个用于Square另一个用于Circle。并且perimeter函数也需要类似的switch语句。

对。现在考虑这两种场景的结构。在对象场景中,area函数的两个实现彼此独立,并且(在某种意义上)从属于类型。Square的area函数属于Square,Circle的area函数属于Circle。

好的,我知道您的意思了。在数据结构场景中,area的两个实现在同一个函数中,它们并不“从属于”类型。

接下来会更有趣。如果要将Triangle类型添加到对象方案中,必须更改哪些代码?

无需更改代码。您只需创建新Triangle类。哦,我想必须更改实例的创建者。

对。因此,当添加新类型时,几乎没有什么变化。现在,假设您要添加一个新函数-比如center函数。

那么,你就必须在Circle,Square,和Triangle这三个类里面都去添加center函数。

好。因此添加新函数很困难,必须更改每个类。

但是在数据结构上却有所不同。为了添加Triangle,必须更改每个函数以将Triangle的case添加到switch语句里面去。

对。添加新类型很困难,必须更改每个函数。

但是,当您添加新center函数时,无需更改任何现存代码。

对。添加新函数很容易。

哇。恰恰相反。

对。我们来复习:

  • 向一组类中添加新函数很困难,必须更改每个类。
  • 向一组数据结构中添加新函数很容易,只需添加函数,无需其他改变。
  • 向一组类中添加新类型很容易,只需添加新类即可。
  • 向一组数据结构中添加新类型很困难,必须更改每个函数。

是的,相反。以一种有趣的方式对立。我的意思是,如果您知道要向一组类型中添加新函数,则应该使用数据结构。但是,如果您知道要添加新的类型,则可以使用类。

说得好!但是,今天我们还有最后一件事要考虑。数据结构和类的对立还有另一种方式,与依赖关系有关。

依赖关系?

是的,源代码的依赖方向。

具体来说呢?

考虑数据结构的情况。每个函数都有一个switch语句,该语句根据类型码选择适当的实现。

对,然后?

考虑对area函数的调用。调用者依赖于area函数,而area函数依赖于每个特定的实现。

您所说的“依赖”是什么意思?

想象一下,area的每个实现都被写入了单独的函数中。所以有circleArea,squareArea和triangleArea。

OK,switch语句会调用这几个函数。

想象一下这几个函数在不同的源文件中。

那么,包含有switch语句的源文件就必须import,use或include这些源文件。

对。这就是源代码依赖性。一个源文件依赖于另一个源文件。这种依赖的方向是什么?

带有switch语句的源文件依赖于包含实现的源文件。

那area函数的调用者呢?

area函数的调用者依赖于包含switch语句的源文件,该文件的switch语句依赖于各个实现。

正确。从调用者到实现,所有源文件依赖性都指向调用的方向。因此,如果您对其中的一种实现进行了微小的更改……

好的,我知道您的意思。对任何一种实现的更改将导致重新编译带有switch语句的源文件,从而导致每个调用switch语句的人重新编译。

对。至少对于依赖于源文件的更改日期来确定应编译哪些模块的语言系统而言,这么说是正确的。

几乎所有使用静态类型的语言,对吗?

是的,有些非静态的也会。

大量的重新编译。

还有大量的重新部署。

是的,但是在类的情况下这是相反的吗?

是的,因为area函数的调用者依赖于接口,而函数实现也依赖于该接口。

我明白你的意思了。Square类的源文件将import,use或include Shape接口的源文件。

对。实现的源文件指向调用的相反方向。他们从实现指向调用者。至少对于静态类型的语言来说是这样。对于动态类型的语言,area函数的调用者完全不依赖任何内容。链接在运行时确定。

对,因此,如果您更改其中一种实现方式…

仅更改的文件需要重新编译或重新部署。

那是因为源文件之间的依赖关系指向调用方向的反方向。

对。我们称之为依赖倒置。

好,让我看看我是否可以总结一下。类和数据结构在至少三种不同的方式上是相反的。

  • 类使函数可见,隐藏数据。数据结构使数据可见,隐藏函数。
  • 类使添加类型变得容易,但是却难以添加函数。数据结构使添加函数变得容易,但难以添加类型。
  • 数据结构导致调用者重新编译和重新部署。类将调用者与重新编译和重新部署隔离开。

你说对了。这些都是每个优秀的软件设计人员和架构师都需要牢记的问题。