Reflection Means Hidden Coupling

The following text is a partial translation of the original English article, performed by ChatGPT (gpt-3.5-turbo) and this Jekyll plugin:

反射式编程(或反射)是指代码在运行时自身发生变化的情况。例如,当我们调用一个类的方法时,它除了其他操作外还会向该类添加一个新的方法(也称为猴子补丁)。Java、Python、PHP、JavaScript等等,无一例外都拥有这个“强大”的特性。那么,这个特性有什么问题呢?嗯,它的问题在于它的速度慢存在风险,而且很难阅读和调试。然而,所有这些与其引入到代码中的耦合比起来都不算什么。

有很多情况下反射可以“帮助”你。让我们逐个了解它们,并看看为什么它们给代码增加的耦合是不必要和有害的。

Here is the code:

public int sizeOf(Iterable items) {
  return ((Collection) items).size();
}

我不确定每个人都会同意这是反射,但我相信它是:我们在运行时检查类的结构,然后调用在Iterable中不存在的size()方法。这个方法只有在运行时才会“显示出来”,当我们在字节码中创建一个动态快捷方式时。

除了以下几点之外,为什么这样做是不好的:1)它很,2)它更冗长,因此不易读,3)它引入了一个新的失败点,因为对象items可能不是Collection类的实例,导致MethodNotFoundException

上述代码对整个程序造成的最大问题是它与客户端之间的耦合,例如:

这种方法可能有效,也可能无效。这取决于list的实际类别。如果它是Collection,则调用sizeOf将成功。否则,将会发生运行时故障。通过查看calc方法,我们无法确定处理list以避免运行时故障的正确方法。我们需要阅读sizeOf的内容,然后才能将calc更改为类似这样的代码:

这段代码目前看起来还不错。然而,当 sizeOf 将其实现更改为类似下面这样的情况时会发生什么(我从这篇关于类型转换的文章中获取的):

现在,sizeOf 完美地处理了任何类型的输入,无论它是 Collection 的实例还是其他类型。然而,calc 方法并不知道在 sizeOf 方法中所做的更改。相反,它仍然认为除了 Collection 之外的任何输入都会导致 sizeOf 失效。为了使它们保持同步,我们必须记住 calc 对于 sizeOf 的了解,并在 sizeOf 发生更改时进行修改。因此,可以说 calcsizeOf 相互依赖,而这种依赖关系是隐藏的:很可能,我们会忘记在 sizeOf 得到更好的实现时修改 calc。此外,在程序中可能还有许多类似于 calc 的地方,我们必须记住当 sizeOf 方法发生变化时进行修改。显然,我们会忘记其中大部分。

这种依赖关系是一个很大的可维护性问题,它是由于 Java 中反射的存在而引入的。如果我们不能使用 instanceof 运算符和类转换(或者根本没有它们),首先就不可能存在这种依赖关系。

Consider this code:

class Book {
  private String author;
  private String title;
  Book(String a, String t) {
    this.author = a;
    this.title = t;
  }
  public void print() {
    System.out.println(
      "The book is: " + this.name()
    );
  }
  private String name() {
    return this.title + " by " + this.author;
  }
}

你如何为这个类及其方法print()编写单元测试?显然,如果不对这个类进行重构,几乎是不可能的。 print方法将文本发送到控制台,由于其为“静态”方法,我们无法轻松地进行模拟。正确的方式是将System.out作为依赖注入,但我们中的一些人认为反射是更好的选择,这将允许我们直接测试私有方法name,而无需首先调用print方法。

您还可以使用PowerMock Java库来处理私有方法的许多”美丽”事情。

这个测试的问题在于它与它所测试的对象紧密耦合:测试过多地了解了Book类。测试知道这个类包含一个私有方法name。测试还知道方法name将在某个时候被方法print调用。测试并没有测试print,而是测试了它不应该了解的东西:Book类的内部。

单元测试的主要目的是为我们这些试图修改先前编写的代码或非常早期编写的代码的程序员提供一个”安全网”:如果我们破坏了任何东西,测试会及时给我们一个信号,”突出显示”代码被破坏的位置。如果没有突出显示,测试都是通过的,我就可以继续修改代码。我依赖于我的测试所提供的信息。我信任它们。

我拿到Book类并想要对它进行修改,只是将方法name的返回类型从String改为StringBuilder。这是一个相当无害的修改,可能是出于性能考虑的需要。在我开始做任何改动之前,我运行所有的测试(这是一个良好的实践)它们都通过了。然后我进行我的改动,期望没有测试失败:

然而,测试BookTest将会失败,因为它期望我的Book类有一个返回Stringname方法。如果这不是我的测试,或者我很久之前就写的,我会对这个事实感到沮丧:测试要求我只能用一种特定的方式编写私有方法。为什么?返回StringBuilder有什么问题吗?我认为这背后一定有什么隐藏的原因。否则,为什么测试要求私有实现的类做任何事情?很快,经过一番调查,我会发现没有理由。这只是测试对Book内部的假设,这种假设除了”我们没有时间重构类并使System.out可注入”之外没有其他理由。

顺便说一下,这种测试方法被称为“Inspector”测试反模式。

接下来我该怎么办?我必须撤销我的更改,然后开始重构测试类,以消除这种假设。然而,同时改变测试和主要代码是一种危险的做法,我相信,可能会引入一些新的错误。

对我来说,测试不再是一个“安全网”。我不能相信它们。我修改代码,我知道我没有破坏任何东西。然而,测试却给我一个红色信号。如果它在这样一个简单的场景中都会说谎,我怎么能相信它呢?

如果不能使用反射,单元测试BookTest和类Book之间的耦合就不会发生。如果没有人能以任何方式访问私有方法,那么在单元测试中使用Inspector反模式就是不可能的。

当然,如果我们也没有私有方法,生活会更好。

以下是典型工厂的运作方式:

工厂方法是make。它期望提供”operator”的名称,然后利用Java反射API中的Class.forName()构造类的名称,在类路径中找到它,并创建一个实例。现在,假设有两个类都实现了接口Operator

然后我们使用它们,首先通过询问我们的工厂方法来根据操作符名称创建对象:

结果将为13。

如果没有反思,我们将无法做到这一点。我们将不得不这样做:

如果你问我,这段代码看起来更易读且更易维护。首先,因为在任何启用代码导航的IDE中,可以点击OpMinusOpPlus并立即跳转到类的主体。其次,类查找的逻辑由JVM提供:我不需要猜测调用make("Plus")时会发生什么。

有几个原因使人们喜欢静态工厂。我不同意这些观点。这篇博文解释了原因。如果没有反射,根本无法使用静态工厂,而且代码会更好、更易维护。

在Java中,您可以将注释(DTO-ish接口的实例)附加到类(或类的元素,如方法或参数)。然后可以在运行时或编译时读取来自注释的信息。在现代框架(如Spring)中,这个特性经常用于自动化对象的连线:您只需将一些注释附加到您的类上,框架将找到它们,实例化它们,将它们放入DI容器中,并分配给其他对象的属性。

我之前说过,这种发现对象并自动将它们连接在一起的机制是一种反模式。我之前也说过,注释是一种反模式。如果没有反射,依赖注入容器、自动连线和注释都不会存在。生活会更好,Java/OOP会更清晰。

使用带注释的对象/类的客户端与它们耦合,并且这种耦合是隐藏的。带注释的对象可以更改其接口或修改注释,代码仍然可以编译通过。问题只会在运行时稍后出现,当其他对象的期望无法满足时。

当程序员不理解面向对象的范式时,他们会制作DTO而不是正确的对象。然后,为了将DTO传输到网络上或将其保存到文件中,他们会对其进行序列化或编组。这通常由一个特殊的序列化引擎完成,该引擎接受DTO,突破所有可能的封装障碍,读取所有字段的值,并将它们打包成,比如一个JSON片段。

为了让序列化引擎突破封装障碍,编程语言必须具备反射功能。首先,因为DTO的某些字段可能是私有的,只能通过反射访问。其次,即使DTO设计得“正确”,具备了所有必要的获取私有字段的getter方法,仍然需要反射来了解哪些getter方法存在并可以调用。

序列化表达的态度和ORM非常相似。它们都不与对象交互,而是“强行”将对象拆散,拿走所需的部分,使对象陷入无意识状态。如果将来一个对象决定更改其结构、重命名某些字段或更改返回值的类型,那些通过序列化与该对象实际关联的其他对象将一无所知。他们会在运行时才注意到,当“无效数据格式”异常开始上浮时。对象的开发者将没有机会注意到他们对对象接口的更改会影响到代码库中的其他地方。

我们可以说,序列化是一种“完美”的耦合两个对象的方法,使得两者都不知道这种耦合的存在。

面向对象编程的核心思想是对象为王。只有对象自己可以决定如何处理其所封装的数据。这一原则的存在和遵守有助于避免由一个简单的情况引起的运行时错误:A使用来自B的数据,而不告诉B如何使用,然后B更改数据的格式或语义,A无法理解。

显然,如果没有反射,这种“滥用”式的序列化是不可能的。一种更谨慎的序列化是可能的,并且会被使用,但不是通过反射,而是通过对象实现的打印机。

总之,反射引入了隐藏的耦合。这是最危险的耦合类型,因为它很难跟踪、很难找到并且很难消除。如果没有反射,面向对象设计将更加清晰和稳定。但即使存在这个特性,我建议您在编程语言中永远不要使用反射。

Translated by ChatGPT gpt-3.5-turbo/42 on 2023-12-16 at 15:54

sixnines availability badge   GitHub stars