admin管理员组文章数量:1442343
笔记《Effective Java》03(上):类和接口
1、前言
《Effective Java》这本书可以说是程序员必读的书籍之一。这本书讲述了一些优雅的,高效的编程技巧。对一些方法或API的调用有独到的见解,还是值得一看的。刚好最近重拾这本书,看的是第三版,顺手整理了一下笔记,用于自己归纳总结使用。建议多读一下原文。今天整理第三章节:类和接口。
2、最小化类和成员的可访问性
2.1、信息隐藏(封装)
区分设计良好的组件和设计不良好的组件最重要的因素是,这个组件能在多大程度上将其内部数据和其他实现细节对别的组件隐藏起来。设计良好的组件会隐藏其所有的实现细节,并将API与实现清晰地隔离。组件之间通过他们的API进行通信。而对彼此的内部工作一无所知。这个概念被称为信息隐藏或封装。
2.2、Java中访问控制机制
Java有很多帮助实现信息隐藏的设施,访问控制机制指定了类、接口和成员的可访问性。有个很简单的经验法则就是:尽可能使每个类或成员不可访问。公有类的实例字段尽量不要设计为公有的,带有公有可变字段的类通常不是线程安全的。即使一个final的字段,并且引用了一个不可变的对象秒如果将其设计为公有的,当我们想去掉这个字段转而采用新的内部数据表示时,就没有灵活性了。
但是,有一个例外。可通过公有静态final的字段来暴露常量,前提是这些常量是这个类所提供的抽象的必要组成部分。如:
代码语言:java复制public static final double RATE = 0.35;
但是,一个长度不为零的数组总是可以修改的,所以如果类有一个公有静态final数组字段,或有一个返回这类字段的访问器方法,这样的设计是错误的。
潜在的安全漏洞:
代码语言:java复制public static final Thing[] VALUES = {...}
解决这类问题有两种方式,一种是把公有的数组变成私有的,并添加一个公有的不可变列表:
代码语言:java复制private static final String[] PRIVATE_VALUES = {};
public static final List<String> VALUES = Collections.unmodifiableList(Arrays.asList(PRIVATE_VALUES));
// 或
public static final List<String> VALUES = List.of(PRIVATE_VALUES);
另一种是把数组变成私有的,并添加一个公有的方法,让他返回该私有数组的一个副本:
代码语言:java复制private static final String[] PRIVATE_VALUES = { };
public static final String[] values(){
return PRIVATE_VALUES.clone();
}
2.3、Java9模块化
从Java9开始,模块化的方式带来了两个隐式访问级别。通常模块声明通常会包含在module-info.java文件中。模块中未导出的包中的公有成员和受保护的成员,在模块之外是不可访问的。而在模块内,可访问性不受导出声明的影响。例如:
2.4、小结
总而言之,应该尽可能(合理地)降低程序元素的可访问性。精心设计了一个最小的公有API后,应该防止任何游离的类,接口或成员成为这个API的一部分。除了作为常量的公有静态final字段外,公有类不应该有任何公有的字段。
3、在公有类中,使用访问器方法,而不使用公有的字段
有时,我们可能会忍不住编写一些退化的类,只是为了将几个实例字段组织到一起,别无他用:
代码语言:java复制class Point {
public double x;
public double y;
}
对于这样的类,因为其数据字段可以直接访问,所以不具备封装的好处。正如上面一节提到的,最小化类和成员的可访问性。
3.1、使用访问器方法封装
坚持面向对象编程的程序员认为这样的类就是祸害,应该总是用私有的字段和公有访问器方法(getter)替代:
代码语言:java复制@Getter
@Setter
class Point {
private double x;
private double y;
}
对于共有类而言,坚持面向对象编程思想的看法是正确的。如果类在包外可以访问,就提供访问器方法。然而,对于包私有的类或私有的嵌套类,暴露其数据字段本质上没有什么问题,只要他们可以充分描述类所提供的抽象。
4、使可变性最小化
在设计类时,除非有充分的理由将其设计为可变鹅,否则就应该将其设计为不可变的。如果类无法被设计为不可变的,就应该尽可能限制其可变性。
不可变的类是指其实例无法修改的类。要使一个类成为不可变类,需要遵循以下规则:
- 不要提供修改对象状态的方法
- 确保这个类不能被扩展
- 将所有字段都声明为final类型
- 将所有字段都声明为私有的
- 确保对任何可变组件的独占访问
以下是一个不可变类的实例:
代码语言:java复制public final class ImmutablePerson {
private final String name;
private final int age;
public ImmutablePerson(String name, int age) {
this.name = name;
this.age = age;
}
public String getName() {
return name;
}
public int getAge() {
return age;
}
}
4.1、不可变对象优点
- 不可变对象只能处于一个状态,就是他被创建时的状态。如果能够确保所有的构造器都建立了类的不变式,则可以保证这些不变式始终成立,不需要付出额外的努力。
不可变对象本质上是线程安全的,它们不需要同步。它们不会被多个并发访问的线程破坏,这是实现线程安全最简单的方式。正因为不可变,所以一个线程也就不可能会看到另一个线程对这样的对象造成任何影响,因而不可变对象可以自由地共享。因此,不可变类应该鼓励客户端尽可能复用现有实例。要实现这一点,一个简单的做法是为常用值提供公有的静态常量。例如,Complex类可能会提供这些常量:
代码语言:java复制public static final Complex ZER0 = new Complex(0, 0);
public static final Complex ONE = new Complex(1, 0);
public static final Complex I = new Complex(0, 1);
客户端之间就可以共享实例而不是创建新实例,从而降低内存占用和垃圾收集开销。
- 除了可以共享不可变对象外,还可以共享他们的内部数据。
- 不管是可变对象还是不可变对象,都可以将不可变对象当作其模块,这是非常不错的选择。
- 不可变对象保证了故障的原子性。他们的状态永远不会改变,因此不存在临时不一致的可能性。
4.2、缺点
不可变类的主要缺点是,他们需要为每个不同的值创建一个单独的对象。创建这些对象时,可能开销很大,特别是大型的对象。
5、组合优于继承
继承是面向对象编程中代码重用的有力手段,但它并非总是最佳选择。继承会破坏封装,换句话说,子类会依赖超类的实现细节来保证其正常的功能。而超类可能会随着新版本的发布发生变化,如果真的发生了变化,即使没有碰子类的代码,他也可能会出现问题。
5.1、继承的问题
举个例子说明:假设有个使用了HashSet的程序,我们需要统计他自创建以来添加了多少个元素。我们可能会这样实现:
代码语言:java复制public class InstrumentedHashSet <E> extends HashSet<E> {
// 尝试插入元素的数量
private int addCount = 0;
@Override
public boolean add(E e) {
addCount++;
return super.add(e);
}
@Override
public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return super.addAll(c);
}
public int getAddCount() {
return addCount;
}
}
这样实现看起来很合理,但是当我们尝试添加多个元素时:
代码语言:java复制public static void main(String[] args) {
InstrumentedHashSet<Object> set = new InstrumentedHashSet<>();
set.addAll(List.of("张三", "里斯", "王五"));
System.out.println("InstrumentedHashSet内元素添加了几次:" + set.getAddCount());
}
发现只加了3个元素,但是他显示了6次。原因是超类的addAll方法,调用的是add()。而我们子类重写了add,同时加上了计数,因此被重复累加了。
虽然有办法可以修复以及调整,但是他的正确性依赖于这一事实:HashSet的addAll方法是基于其add方法实现的。这种“自身使用”属于实现细节,不能保证Java平台的所有实现都是这样的,而且还有可能随着版本的变化而变化。
5.2、组合转发
有一种做法可以避免上述问题,不要扩展现有类,而是在我们的类中提供一个私有的字段,让他引用现有类的实例。这种设计成为“组合”。新类中的每个实例方法都会调用所包含的现有类的实例上的相应方法,并返回结果,这种成为“转发”。
举个例子说明:
代码语言:java复制public class InstrumentedSet<E> {
private final Set<E> s;
private int addCount = 0;
public InstrumentedSet(Set<E> s) {
this.s = s;
}
public boolean add(E e) {
addCount++;
return s.add(e);
}
public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return s.addAll(c);
}
public int getAddCount() {
return addCount;
}
}
在组合方式中,InstrumentedSet 类包含一个 HashSet 实例作为成员变量,并通过转发方法将操作委托给 HashSet 实例。
6、要么为继承而设计并提供文档说明,要么就禁止继承
上面提到过,对于“外部的”一个并非为继承而设计并提供文档说明的类,继承他是存在风险的。那么,“为继承而设计并提供文档说明的类”是什么意思呢?
首先,这个类必须在文档中准确的说明重写任何方法会带来的影响。换句话说,这个类必须将存在自身使用可重写方法的情况写在文档中。
对于专门为了继承而设计并且具有良好文档说明的类而言,该类的文档必须精确地描述覆盖每个方法所带来的影响。该类必须有文档说明它可覆盖的方法的自用性。对于每个公有的或受保护的方法或者构造器,它的文档必须指明该方法或者构造器调用了哪些可覆盖的方法,是以什么顺序调用的,每个调用的结果又是如何影响后续的处理过程的。更一般的,类必须在文档中说明,在哪些情况下它会调用可覆盖的方法。
要测试一个为继承而设计的类,唯一的测试方法就是编写子类。如果缺少了某个关键的受保护成员,尝试编写子类就会使遗漏所带来的痛苦变得更加明显。相反,如果编写了多个子类,并且无一使用受保护的成员,或许应该把它做成是私有的。经验表明,3个子类通常就足以测试一个可扩展的类,除了超类的创建者外,都要编写民一个或者多个这种子类。
6.1、问题
为了允许继承,类还必须遵守其他的一些约束。构造器绝不能调用可被覆盖的方法,无论是直接调用还是间接调用。 因为超类的构造器在子类的构造器之前运行,所以,子类中覆盖版本的方法将会在子类的构造器运行之前就先被调用,如果该覆盖版本的方法依赖于子类构造器所执行的任何初始化工作,该方法将不会如预期般地执行。
例如:
代码语言:java复制public class Super{
// 存在问题 - 构造器调用了一个可重写的方法
public Super{
overrideMe();
}
public void overrideMe(){
}
}
下面的子类重写了overrideMe(),这个方法错误的被Super类唯一的构造器调用了:
代码语言:java复制public final class Sub extends Super {
// 一个空的final字段,由构造器设置
private final Date date;
Sub(){
date=new Date();
}
// 超类构造器调用的重写方法
@Override
public void overrideMe(){
Systen.out.println(date);
}
public static void main(String[] args){
Sub sub=new Sub();
sub.overrideMe();
}
}
你可能期望这个程序打印两次 instant 实例,但是它第一次打印出 null,因为在 Sub 构造方法有机会初始化 instant 属性之前,overrideMe 被 Super 构造方法调用。 请注意,这个程序观察两个不同状态的 final 属性! 还要注意的是,如果 overrideMe 方法调用了 instant 实例中任何方法,那么当父类构造方法调用 overrideMe 时,它将抛出一个 NullPointerException 异常。 这个程序不会抛出 NullPointerException 的唯一原因是 println 方法容忍 null 参数。
在为了继承而设计的类的时候,Cloneable和Serializable接口出现了特殊的困难。如果类是为了继承而被设计的,无论实现这其中的哪个接口通常都不是一个好主意,因为它们把一些实质性的负担转嫁到扩展这个类的程序员的身上。
如果你决定在一个为了继承而设计的类中实现Cloneable或者Serializable接口,就应该意识到,因为clone和readObject方法在行为上非常类似于构造器,所以类似的限制规则也是使用的:无论是clone还是readObject,都不可以调用可覆盖的方法,不管是以直接还是间接的方式。
如果你决定在一个为了继承而设计的类中实现Serializable,并且该类有一个readResolve或者writeReplace方法,就必须使readResolve或者writeReplace成为受保护的方法,而不是私有的方法。
6.2、禁止对其子类化
但是普通的具体类呢? 传统上,它们既不是 final 的,也不是为了子类化而设计和文档说明的,但是这种情况是危险的。每次修改这样的类,则继承此类的子类将被破坏。 这不仅仅是一个理论问题。 在修改非 final 的具体类的内部之后,接收与子类相关的错误报告并不少见,这些类没有为继承而设计和文档说明。
解决这个问题的最佳方案是,对于并非为可以安全地子类化而设计并提供文档说明的类,禁止对其进行子类化。 有两种方法禁止子类化。 一种是将类声明为 final。 另一种方法是使所有的构造方法都是私有的或包级私有的,并且添加公共静态工厂来代替构造方法。 两种方法都是可以接受的。
如果一个具体的类没有实现一个标准的接口,那么你可能会通过禁止继承来给一些程序员带来不便。 如果你觉得你必须允许从这样的类继承,一个合理的方法是确保类从不调用任何可重写的方法,并文档说明这个事实。 换句话说,完全消除类的自用(self-use)的可重写的方法。 这样做,你将创建一个合理安全的子类。 重写一个方法不会影响任何其他方法的行为。
本文标签: 笔记《Effective Java》03(上)类和接口
版权声明:本文标题:笔记《Effective Java》03(上):类和接口 内容由网友自发贡献,该文观点仅代表作者本人, 转载请联系作者并注明出处:http://www.betaflare.com/biancheng/1748019164a2791819.html, 本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容,一经查实,本站将立刻删除。
发表评论