为什么要继续对不可变对象使用getter呢?

Why continue to use getters with immutable objects?

本文关键字:getter 对象 继续 不可变 为什么      更新时间:2023-10-16

使用不可变对象已经变得越来越普遍,即使手头的程序从未打算并行运行。然而,我们仍然使用getter,它需要为每个字段编写3行样板文件,每次访问都需要5个额外的字符(在你最喜欢的主流OO语言中)。虽然这可能看起来微不足道,而且许多编辑器无论如何都消除了程序员的大部分负担,但这似乎仍然是不必要的努力。

继续使用访问器而不是直接访问不可变对象的字段的原因是什么?具体来说,强迫用户使用访问器(对于客户端或库编写者)是否有好处,如果有,有什么好处?


注意,我指的是不可变对象,不像这个问题,它指的是一般的对象。需要说明的是,不可变对象上没有设置器。

我得说这实际上是语言相关的。如果你不介意的话,我将讨论一下c#,因为我认为它将有助于回答这个问题。

我不确定你是否熟悉c#,但它的设计、工具等都非常直观,对程序员很友好。
c#的一个特性(也存在于Python, D等中)有助于此的是属性;属性基本上是一对方法(getter和/或setter),从表面上看,它们就像一个实例字段:你可以给它赋值,也可以像实例变量一样从中读取。
当然,在内部,它是一个方法,它可以做任何事情。

但是c#数据类型有时也有GetXYZ()和SetXYZ()方法,有时它们甚至直接暴露它们的字段…这就引出了一个问题:你如何选择什么时候做什么?

微软对于c#属性和何时使用getter/setter有一个很好的指南:

属性应该表现得像字段一样;如果方法不能,则不应将其更改为属性。在以下情况下,方法优于属性:

  • 该方法执行耗时的操作。该方法明显比设置或获取字段值所需的时间慢。
  • 该方法执行转换。访问一个字段不会返回它所存储的数据的转换版本。
  • Get方法有明显的副作用。检索字段的值不会产生任何副作用。
  • 执行的顺序很重要。设置一个字段的值不依赖于其他操作的发生。
  • 连续两次调用该方法会产生不同的结果。方法是静态的,但是返回一个可以被调用者修改的对象。检索字段的值不允许调用者更改该字段存储的数据。
  • 方法返回一个数组。

注意 这些准则的全部目标是使所有属性在外部看起来像字段。

所以使用属性而不是字段的真正原因是:

  1. 你想要封装,等等等等。
  2. 需要验证输入
  3. 您需要从其他地方检索数据(或将数据发送到)。
  4. 您需要转发二进制(ABI)兼容性。我是什么意思?如果您在将来某个时候决定需要添加某种类型的验证(例如),那么将字段更改为属性并重新编译库将破坏依赖于它的任何其他二进制文件。但是,在源代码级别,什么都不会改变(除非您接受地址/引用,这可能是不应该的)。

现在让我们回到Java/c++和不可变数据类型。

哪一点适用于我们的场景?

  1. 有时它并不适用,因为不可变数据结构的全部意义在于存储数据,而不是具有(多态)行为(例如String数据类型)。
    如果你要隐藏数据,什么都不做,那么存储数据有什么意义呢?
    但有时它确实适用(例如,假设你有一个不可变的树)——你可能不想公开元数据。
    但是在这种情况下,您显然会隐藏不想公开的数据,并且您一开始就不会问这个问题!:)
  2. 不适用;没有输入验证,因为没有任何变化。
  3. 不适用,否则不能使用字段!
  4. 可能适用,也可能不适用。

现在Java和c++没有属性,但是方法代替了它们的位置——所以上面的建议仍然适用,没有属性的语言的规则变成了:

如果(1)你不需要ABI兼容性,(2)你的getter的行为就像一个字段(即它满足上面MSDN文档中的要求),那么你应该使用字段而不是getter。

重要的是要认识到,这一切都不是哲学问题;所有这些指南都是基于程序员所期望的。显然,最终的目标是(1)完成工作,(2)保持代码的可读性/可维护性。上面的指南已经被发现对实现这一目标很有帮助——你的目标应该是做任何适合你的事情来实现这一目标。

封装有几个有用的目的,但最重要的是信息隐藏。通过将字段隐藏为实现细节,您可以保护对象的客户端不依赖于那里实际存在一个字段。例如,您的对象的未来版本可能想要延迟计算或获取值,这只能在您可以拦截读取字段的请求时完成。

也就是说,没有理由让getter变得特别冗长。特别是在Java世界中,即使在"get"前缀非常牢固的地方,您仍然会发现以值本身命名的getter方法(即方法foo()而不是getFoo()),这是节省一些字符的好方法。在许多其他OO语言中,您可以定义getter,但仍然使用类似字段访问的语法,因此根本没有额外的冗长。

不可变对象应该使用直接字段访问一致性,因为它允许设计对象完全按照客户端期望的方式执行

考虑一个系统,其中每个可变字段都隐藏在访问器后面,而每个不可变字段都不是。现在考虑下面的代码片段:

class Node {
    private final List<Node> children;
    Node(List<Node> children) {
        this.children = new LinkedList<>(children);
    }
    public List<Node> getChildren() {
        return /* Something here */;
    }
}

在不知道Node的确切实现的情况下,当你使用契约式设计的时候,你必须这样做,当你看到root.getChildren()的时候,你只能假设发生了以下三件事之一:

  • 。字段children原样返回,您不能修改列表,因为这会破坏Node的不变性。为了修改List,您必须复制它,这是一个O(n)操作。
  • 复制,例如:return new LinkedList<>(children);。这是一个0 (n)的操作。您可以修改此列表。
  • 返回不可修改的版本,例如:return new UnmodifiableList<>(children);。这是一个0(1)的操作。同样,为了修改这个List,你必须复制它,一个0 (n)操作。

在所有情况下,修改返回的列表需要O(n)操作来复制它,而只读访问需要O(1)或O(n)中的任何地方。这里需要注意的重要一点是,通过遵循契约式设计,您无法知道库作者选择了哪个实现,因此必须假设最坏的情况,O(n)。因此, 0 (n)访问和0 (n)创建您自己的可修改副本。

现在考虑以下内容:

class Node {
    public final UnmodifiableList<Node> children;
    Node(List<Node> children) {
        this.children = new UnmodifiableList<>(children);
    }
}

现在,无论在哪里看到root.children,都有一种可能性,即它是UnmodifiableList,因此可以假设O(1)访问和O(n)用于创建本地可变副本。

显然,在后一种情况下,可以得出访问字段的性能特征的结论,而在前一种情况下可以得出的唯一结论是,在最坏的情况下,也就是我们必须假设的情况下,性能远不如直接访问字段。提醒一下,这意味着程序员必须考虑每次访问时上的O(n)复杂度函数。


总之,在这种类型的系统中,只要看到getter,客户端就会自动知道要么getter对应于一个可变字段,要么getter执行某种操作,无论是耗时的O(n)防御性复制操作、延迟初始化、转换还是其他操作。当客户端看到一个直接的字段访问时,他们立即知道访问该字段的性能特征。

通过遵循这种风格,程序员可以从与他/她交互的对象提供的契约推断出更多的信息。这种风格还促进了统一的不可变性,因为一旦将上面代码片段的UnmodifiableList更改为接口List,直接字段访问就允许对象发生变化,从而迫使您的对象层次结构被仔细设计为从上到下的不可变。

好消息是,您不仅获得了不可变性的所有好处,而且还能够推断访问字段的性能特征,无论它在哪里,无需查看实现,并且确信它永远不会更改。

Joshua Bloch, in Effective Java (2nd Edition)"第14条:在公共类中,使用访问方法,而不是公共字段"对于暴露不可变字段有如下说明:

虽然公共类直接公开字段从来都不是一个好主意,但它确实是如果字段是不可变的,则危害较小。你不能改变的表示在不更改其API的情况下创建这样的类,并且当字段被读取,但可以强制执行不变量。

并以:

总结本章
总而言之,公共类不应该公开可变字段。它更少对于公共类来说,暴露不可变字段是有害的,尽管仍然值得怀疑。

你可以有公共final字段(模仿某种不可变性),但这并不意味着被引用的对象不能改变它们的状态。在某些情况下,我们仍然需要防御副本。

 public class Temp {
    public final List<Integer> list;
    public Temp() {
        this.list = new ArrayList<Integer>();
        this.list.add(42);
    }
   public static void foo() {
      Temp temp = new Temp();
      temp.list = null; // not valid
      temp.list.clear(); //perferctly fine, reference didn't change. 
    }
 }

继续使用访问器而不是直接访问不可变对象的字段的原因是什么?具体来说,强迫用户使用访问器(对于客户端或库编写者)是否有好处,如果有,有什么好处?

你听起来像一个过程程序员在问为什么你不能直接访问字段,而必须创建访问器。主要问题是连你提问题的方式都是错误的。这不是OO设计的工作方式——您通过它的方法设计对象行为并公开它。然后,如果有必要,您可以创建内部字段来实现该行为。因此,这样说:"我正在创建字段,然后通过getter公开每个字段,这太啰嗦了"是OO设计不当的明显标志。

封装字段然后只通过getter方法公开它是一种OOP实践。如果您直接暴露字段,这意味着您必须将其公开。把字段设为公共不是一个好主意,因为它暴露了对象的内部状态。

因此,使您的字段/数据成员公开不是一个好的实践,它违反了面向对象的封装原则。我还想说,这不是特定于不可变对象;对于非不可变对象也是如此。

编辑正如@Thilo所指出的;另一个原因是:也许您不需要公开字段的存储方式。

谢谢@Thilo。

继续在Java程序中生成getter(我希望现在没有人手工编写它们)的一个非常实际的原因,即使对于不可变的"值"对象,在我看来,它是不必要的开销:

许多库和工具依赖于旧的JavaBeans约定(或者至少是其中的getter和setter部分)。

这些使用反射或其他动态技术通过getter来访问字段值的工具不能处理访问简单的公共字段。JSP是我想到的一个例子。

现代的ide也使得一次为一个或多个字段生成getter变得很简单,而且当字段的名称改变时,也可以更改getter的名称。

我们继续写getter即使是不可变对象