暴露不可变对象的状态是否可以?

64

最近我接触到了不可变对象的概念,我想知道控制状态访问的最佳实践。尽管面向对象的那部分大脑让我想在看到公共成员时退缩,但我认为像这样的东西没有技术问题:

public class Foo {
    public final int x;
    public final int y;

    public Foo( int x, int y) {
        this.x = x;
        this.y = y;
    }
}

我更愿意将字段声明为private并为每个字段提供getter方法,但当状态明确为只读时,这似乎过于复杂。

对于提供不可变对象的状态访问权限,最佳实践是什么?

13个回答

62

这完全取决于你如何使用对象。公共字段本质上并不是邪恶的,只是默认将所有内容设置为公共的是不好的。例如,java.awt.Point类使其x和y字段公共,并且它们甚至不是最终的。您的示例似乎是公共字段的良好用途,但是您可能不想公开另一个不可变对象的所有内部字段。没有万能规则。


15
在大多数情况下,方法内联可以清除所有开销的痕迹。 - Marko Topolnik
7
正如Kevin所指出的,没有万能规则。但我更倾向于将接口+工厂作为“默认”,而不是公共类+公共构造函数。我偶尔会想:“哦,我应该在这里使用接口而不是具体类” - 但我从来没有想过:“哦,我应该在这里使用具体类而不是接口”;-) - Marco13
6
另一方面,我的座右铭是“每一行代码都必须证明其价值。”这似乎会导致非常不同的默认设置。 - Marko Topolnik
7
@MarkoTopolnik,确实,但我也认为这会导致选择除Java以外的语言。 :) - Charles Duffy
3
@Zack: 你无法重构使用你代码的代码。当你发布你的代码(即使是公司内部),几乎不可能改变接口。 - Davor
显示剩余18条评论

31

我曾经也有过同样的想法,但通常最终会使变量变为私有并使用getter和setter,这样以后我仍然可以选择更改实现方式而保持相同的接口。

这让我想起最近在Robert C. Martin的《Clean Code》中读到的一些内容。在第6章中,他提供了一个稍微不同的角度。例如,在第95页上,他说:

"对象将其数据隐藏在抽象后面,并公开处理该数据的函数。 数据结构公开其数据并没有任何有意义的函数。"

并且在第100页上:

豆类的准封装似乎让一些OO纯粹主义者感觉更好,但通常没有其他好处。

根据代码示例,Foo类似乎是一个数据结构。因此,根据我从《Clean Code》中的讨论中理解的内容(不仅仅是我给出的两个引用),该类的目的是公开数据,而不是功能,具有getter和setter可能没有太多好处。

再次强调,根据我的经验,我通常会使用"bean"方法,即使用私有数据和getter和setter。但话说回来,也没有人要求我写一本关于如何编写更好代码的书,所以也许马丁有话要说。


4
Java中的概念较为匮乏。许多Java类的用例与封装和抽象无关。其他编程语言提供了向量、元组和哈希等方便的特性,具有简洁的字面语法。它们完全透明,并公开其所有数据——这是一件好事。 - Marko Topolnik
9
Getter是一种非常薄弱的封装形式,它暴露了类的所有属性,并使用完全相同的类型。Setter更糟糕。在面向对象理论中,方法(类接收到的消息)是要求对象执行某项工作的指令,而不是请求信息。在我看来,Getter有非常少的可证明用途,而且我从未见过使用Setter的情况。 - AlfredoCasado
5
@AlfredoCasado 最令人惊奇的是,向Java“最佳实践者”解释这个显而易见的真理是多么困难,不幸的是这是一个非常普遍的现象。但请注意,通常情况下,确实存在用于getter/setter的用例,只是不适用于纯数据对象。例如,配置对象可能涉及一些逻辑来支持“设置属性”操作。 - Marko Topolnik
1
@AlfredoCasado:+1,我试图向那些纯面向对象编程的人解释这个问题,但却让我发疯了。大量使用getter方法很明显表明你只是在写冗长的过程式代码。 - Phoshi
2
@Davor 当然你可以这样做,但这不是一个“getter”,而是一个以“get”开头的方法名称,两者并不相同,而且可能是一个非常糟糕的方法名称。 - AlfredoCasado
显示剩余2条评论

11
如果你的对象只在局部范围内使用,未来不关心API变化引发的问题,那么就不需要为实例变量添加getter。但这是一个普遍的话题,与不可变对象无关。
使用getter的优点在于增加了一层额外的间接性,如果你正在设计一个将被广泛使用并且其效用将延伸到不可预见的未来的对象时,这可能会派上用场。

“如果你正在设计一个将被广泛使用且其效用将扩展到不可预见未来的对象,这可能会非常有用。” 你能举一个小例子吗? - Geek
最简单的例子是构成库公共API的对象。在项目中,你可能会有类似库的内部模块。 - Marko Topolnik
1
+1. 我的规则是“在未来,我是否可以使用IDE的'封装字段'重构功能?如果可以,就使用公共字段,否则不使用。” 这适用于创建公共库API,我的重构无法到达的情况。 - orip
@orip 这是一个非常好的表述方式 - 请与我们分享,您实际上有多少次需要封装一个 final 字段?或者说,任何字段都需要封装吗? - Marko Topolnik

6
无论是否不可变,您仍然暴露了此类的实现。在某个阶段,您需要更改实现(或者可能生成各种派生版本,例如使用Point示例,您可能需要使用极坐标来创建类似的Point类),而您的客户端代码也会受到影响。
上述模式可能非常有用,但我通常将其限制在非常局部的实例中(例如传递信息的元组-我倾向于发现,看似不相关的信息对象要么是不良封装,要么信息相关,我的元组转换为完整的对象)。

那么你会建议使用单独的接口和抽象工厂吗? - Marko Topolnik
1
所以你不喜欢公共字段,但是可以接受客户端与特定实现紧密耦合?我不明白你的推理为何选择一个间接性而非另一个,因为实践明显表明两者都非常有用。 - Marko Topolnik
通常我对我定义的类的潜在用途、派生和实例化有更好的想法,而不是底层实现(根据我的经验),后者会变化得更多。当然,你的情况可能不同,但这是我对这个问题的一般看法。我会定期使用接口和工厂(不一定是抽象工厂)。 - Brian Agnew
例如,我有一个包私有类,用于在两个类之间传输数据元组。这种使用情况是否需要使用getter方法? - Marko Topolnik
1
我猜你的回答措辞假定对象是公共API的一部分,并且其客户端与提供者不同。实际上,我看到很多答案都认为这是理所当然的。对于定义公共API和实现代码的代码,适用的规则完全不同,显然每行公共API至少需要10行实现代码。 - Marko Topolnik
显示剩余4条评论

5
需要翻译的内容如下:

需要记住的一件大事是函数调用提供了通用接口。任何对象都可以使用函数调用与其他对象互动。你所要做的就是定义正确的签名,然后开始运行。唯一的问题是你必须仅通过这些函数调用进行交互,尽管通常效果良好,但有时可能会笨拙。

直接公开状态变量的主要原因是能够直接在这些字段上使用原始操作符。如果做得好,这可以提高可读性和便利性:例如,使用+加入复数或使用[]访问带键集合。如果您使用语法遵循传统惯例,则这样做的好处可能会令人惊讶。

问题在于操作符不是一个通用接口。只有一组特定的内置类型可以使用它们,这些类型只能以语言预期的方式使用,并且不能定义任何新类型。因此,一旦您使用原语定义了公共接口,就已经锁定了自己的使用方式,只能使用该原语(和其他可以轻松转换为它的东西)。要使用其他任何东西,您必须每次与此交互时绕过该原语,这从DRY的角度来看非常困难:事情很快就变得非常脆弱。

有些语言将操作符转化为通用接口,但Java没有这样做。这不是对Java的控诉:它的设计者故意选择不包括运算符重载,并且他们有充分的理由这样做。即使您使用似乎与运算符的传统含义很合适的对象,让它们以实际上有意义的方式工作也可能会令人惊讶,如果您不能完全掌握它,那么以后您就要为此付出代价。通常,基于函数的接口比经历这个过程更易读和可用,而且您甚至经常最终得到了比使用操作符更好的结果。

然而,在这个决定中存在权衡取舍。有时基于运算符的接口确实比基于函数的接口更好,但是在没有运算符重载的情况下,这种选择并不可行。试图无论如何塞入操作符都会把你锁定在一些设计决策上,你可能并不真正想被设立为石头。Java设计者认为这种权衡是值得的,他们可能在这方面是正确的。但是这样的决定并不会没有后果,这种情况就是事情出现问题的地方。

简而言之,问题不在于公开你的实现,per se问题在于将自己锁定在该实现中。


4

实际上,以任何方式暴露对象的任何属性都会破坏封装性-每个属性都是实现细节。仅仅因为每个人都这样做并不意味着它是正确的。使用访问器和修改器(getter和setter)也不能使其更好。相反,应该使用CQRS模式来维护封装性。


3
您开发的类在当前状态下应该很好。问题通常出现在有人尝试更改这个类或从中继承时。例如,看到上面的代码后,有人想添加一个类Bar的另一个成员变量实例。
public class Foo {
    public final int x;
    public final int y;
    public final Bar z;

    public Foo( int x, int y, Bar z) {
        this.x = x;
        this.y = y;
    }
}

public class Bar {
    public int age; //Oops this is not final, may be a mistake but still
    public Bar(int age) {
        this.age = age;
    }
}

在上面的代码中,Bar的实例不能被改变,但是任何人都可以从外部更新Bar.age的值。
最佳实践是将所有字段标记为私有,并为字段编写getter。如果您返回一个对象或集合,请确保返回其不可修改版本。
在并发编程中,不可变性至关重要。

3

我只知道一种在最终属性中使用getter的方法。这种情况是当您希望通过接口访问属性时。

    public interface Point {
       int getX();
       int getY();
    }

    public class Foo implements Point {...}
    public class Foo2 implements Point {...}

否则,公共的最终字段是可以的。

2
一个具有公共最终字段的对象,它们从公共构造函数参数中加载,有效地表现为一个简单的数据持有者。虽然这样的数据持有者并不特别 "面向对象",但它们对于允许单个字段、变量、参数或返回值封装多个值非常有用。如果类型的目的是作为将几个值简单粘合在一起的简单手段,这样的数据持有者通常是在没有真正价值类型的框架中最好的表示。
考虑这样一个问题,如果某个方法 Foo 想要给调用者一个封装了 "X=5, Y=23, Z=57" 的 Point3d,并且它恰好引用了一个 X=5、Y=23 和 Z=57 的 Point3d。如果 Foo 所拥有的东西被认为是一个简单的不可变数据持有者,那么 Foo 应该简单地给调用者一个引用。然而,如果它可能是其他东西(例如它可能包含超出 X、Y 和 Z 之外的其他信息),那么 Foo 应该创建一个新的简单数据持有者,其中包含 "X=5, Y=23, Z=57",并将一个引用给予调用者。 Point3d 被封闭并公开其内容作为公共最终字段,将意味着像 Foo 这样的方法可以假定它是一个简单的不可变数据持有者,并可以安全地共享对其实例的引用。如果存在使这样的假设的代码,则可能很难或不可能更改 Point3d 以成为除简单的不可变数据持有者之外的任何其他东西,而不破坏此类代码。另一方面,假设 Point3d 是一个简单的不可变数据持有者的代码可以比必须处理其可能是其他东西的代码更简单和更有效。

2
你在Scala中经常会看到这种风格,但是这两种语言之间有一个关键的区别:Scala遵循统一访问原则,而Java则不遵循。这意味着只要你的类不改变,你的设计就没问题,但是当你需要适应功能时,它可能会以多种方式破坏:
  • 你需要提取接口或超类(例如,你的类表示复数,你还想有一个兄弟类来表示极坐标)
  • 你需要从你的类继承,并且信息变得冗余(例如,x可以从子类的附加数据计算出来)
  • 你需要测试约束条件(例如,x必须为非负数,由于某种原因)
此外,请注意,您不能将此样式用于可变成员(如臭名昭著的java.util.Date)。只有使用getter方法,你才有机会进行防御性拷贝,或者更改表示方式(例如,将Date信息存储为long)。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接