Effective Java英文第三版读书笔记(2) -- 类与接口最佳实践

写在前面

《Effective Java》原书国内的翻译只出版到第二版,书籍的编写日期距今已有十年之久。这期间,Java已经更新换代好几次,有些实践经验已经不再适用。去年底,作者结合Java7、8、9的最新特性,编著了第三版(参考https://blog.csdn.net/u014717036/article/details/80588806)。当前只有英文版本,可以在互联网搜索到PDF原书。本读书笔记都是基于原书的理解。


以下是正文部分

类与接口最佳实践(Classes and Interfaces)

实践15 最小化类与成员的访问范围(Minimize the accessibility of classes and members)

一个好的API设计,是最大化隐藏底层实现细节的,接口上只暴露最基本必要的信息。在Java中,实体的可访问性由两个要素决定:

  • 实体的定义域:例如方法内部的变量以{}为边界
  • 访问修饰符:public, protected, private

对于一个Java文件而言,其主类(与文件名同名的类)的作用域有两种选择:Package可访问、public访问。通常我们思维定势地认为应该定义为public,实际上,如果能够Package作用域,那么它就应该只定义到Package作用域。
如果一个类只被另外的某一个类依赖使用的话,那么这个类应该挪到依赖类中,成为一个private class。

单依赖类改造

把一个类成员从默认作用域(Package访问)变成protected访问,实际是大大提升了作用范围和维护要求。应该尽量减少对protected的使用。
对于访问域缩小,有两个例外情况

  1. 重载方法的可访问性不能低于父类方法
  2. 接口的方法都是public作用域

通常,类的实例区变量都是非public的。否则就等同于失去了对实例中存储内容的控制,带有public实例区的类都是非线程安全的。这里有一个例外是public static final类型的值,可以安全放心地向外暴露,他们通常以大小字母+下划线命名。但是注意,数组类始终是可以被改变的,因此不要这么做:

// NOT GOOD
public static final Thing[] VALUES = { ... };
// GOOD
private static final Thing[] PRIVATE_VALUES = { ... };
public static final List<Thing> VALUES = Collections.unmodifiableList(Arrays.asList(PRIVATE_VALUES));
// ALSO GOOD
private static final Thing[] PRIVATE_VALUES = { ... }; 
public static final Thing[] values() { 
  return PRIVATE_VALUES.clone(); 
}

实践16 将public类的成员变量设为private,并使用setter函数操作(In public classes, use accessor methods, not public fields)

虽然直接把类成员变量设置为public看起来代码很简洁,使用很方便。但是这付出的代价也很高:修改变量名必须修改API,不能控制变量的可修改性,数据修改时没法做其他操作。这些缺陷对于public类尤其明显,在Package作用域或者内部private类则不那么重要。

// Encapsulation of data by accessor methods and mutators 
class Point { 
  private double x; 
  private double y;
  public Point(double x, double y) { this.x = x; this.y = y; }
  public double getX() { return x; } 
  public double getY() { return y; }
  public void setX(double x) { this.x = x; } 
  public void setY(double y) { this.y = y; } 
}

实践17 最小化可修改性(Minimize mutability)

Java标准库中包含多个不变类,例如String,基础类型,BigIntegerBigDecimal。这么做的理由是不变类相比可变类在设计和实现上都更加简单(不容易出错,更加安全),且易于使用。下面列出5条准则,指导我们如何实现一个不可变类

  1. 不要再成员方法中修改对象的状态;
  2. 确保类不被继承;
    方式1:声明类为final
    方式2:构造函数置为private,只提供public的静态工厂方法
  3. 将所有成员变量置为final;
  4. 将所有成员变量置为private;
  5. 不要与任何可变量进行关联访问。

代码块Complex是一个不可变类的示例。这个类是一个复数类,注意加减乘除操作中,没有改变原有对象,而是直接new一个新的,这被称为函数式(functional)访问。相对的,过程式(procedural)以及命令式(imperative)*编程会改变对象状态:

public class Complex {
  private final double im;
  private final double re;

  public Complex(double re, double im) {
    this.re = re;
    this.im = im;
  }

  public double realPart() { return re; }

  public double imaginaryPart() { return im; }

  public Complex plus(Complex c) {
    return new Complex(re + c.re, im + c.im);
  }

  public Complex minus(Complex c) {
    return new Complex(re - c.re, im - c.im);
  }

  public Complex times(Complex c) {
    return new Complex(re * c.re - im * c.im, re * c.im + im * c.re);
  }

  public Complex dividedBy(Complex c) {
    double tmp = c.re * c.re + c.im * c.im;
    return new Complex((re * c.re + im * c.im) / tmp, (im * c.re - re * c.im) / tmp);
  }

  @Override
  public boolean equals(Object o) {
    if (o == this) return true;
    if (!(o instanceof Complex)) return false;
    Complex c = (Complex) o;
    return Double.compare(c.re, re) == 0 && Double.compare(c.im, im) == 0;
  }

  @Override
  public int hashCode() {
    return 31 * Double.hashCode(re) + Double.hashCode(im);
  }

  @Override
  public String toString() {
    return "(" + re + " + " + im + "i)";
  }
}

优点:

  • 简单:状态在创建时确定,并且一直保持。
  • 安全:天然线程安全且不需要同步。可随意复用。
    缺点:
  • 对任意不同的值,都需要创建新的对象,开销大

如果使用IDEA进行Java开发,我们通常能看到如下提示。

String不可变类的性能问题

这正式因为String是不可变类,如果在循环里反复去操作,每次都要新建String对象,增大开销。一个可行的方法是使用String的可变形式(mutable companian) StringBuilder, StringBuffer

public String s() {
  StringBuilder ret = new StringBuilder();
  for (int i = 0; i < 100; i++) {
    ret.append(i);
  }
  return ret.toString();
}

实践18 多使用组合,少使用继承(Favor composition over inheritance)

首先,这里的继承是指"extend"继承,不包括"implements"接口。继承是一个强大的代码复用手段,但是要谨防滥用。通常,在包内部使用继承是安全的,它保证了父类和子类都由同一开发者实现。
继承有如下缺点:

  • 破坏了封装的特性。子类与父类绑定过深,父类修改对子类的影响很大。
    1)父类的实现方式可能影响子类的结果
    下面是一个不当示例。这个类继承自HashSet,可以统计集合自创建以来添加过多少个元素(注意不是当前大小,因为删除元素后,集合大小会减少)。
// Broken - Inappropriate use of inheritance!
class InstrumentedHashSet<E> extends HashSet<E> {
  private int addCount = 0;

  public InstrumentedHashSet() {}

  public InstrumentedHashSet(int initCap, float loadFactor) {
    super(initCap, loadFactor);
  }

  @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;
  }
}

看起来代码没问题。可是当我们用addAll尝试添加3个元素后,getAddCount的值是6。这是因为HashSet中,addAll方法实际是借助add方法实现的,因此出现了重复累加的情况。虽然我们可以修改代码适配,但谁知道下个Java版本addAll是否还这么实现呢?

InstrumentedHashSet<String> s = new InstrumentedHashSet<>(); 
s.addAll(List.of("Snap", "Crackle", "Pop"));
System.out.println(s.getAddCount()); // will print 6

2)父类新增方法可能对子类产生不当影响。
设想子类继承父类,向容器内添加元素,处于安全考虑,子类重载了父类的add方法要求所有元素添加前都进行某种预检。但是一旦父类新增一个添加元素的方法例如addTwo,子类不变的情况下,很可能这新方法成为一个漏洞,引入非法元素。

就算你在子类中,不做方法重写,且很少添加新函数,同样也不安全。例如你在父类升级后,可能恰好有一个方法的函数签名和你子类的一样,这样对子类而言要不是隐藏,要不是重写,造成不可预知的行为。

综上,只有不继承才是最安全的。规避继承的最佳实践是为它们在类内创建private对象,这种方式被称为组合(Composition)。如果一个类的方法都是调用组合类的方法,并且忠实返回,这称为转发(Forwarding)。

// Wrapper class - uses composition in place of inheritance
public class InstrumentedSet<E> extends ForwardingSet<E> {
  private int addCount = 0;

  public InstrumentedSet(Set<E> s) {
    super(s);
  }

  @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;
  }
}
// Reusable forwarding class
public class ForwardingSet<E> implements Set<E> {
  private final Set<E> s;

  public ForwardingSet(Set<E> s) {
    this.s = s;
  }

  public void clear() {
    s.clear();
  }

  public boolean contains(Object o) {
    return s.contains(o);
  }

  public boolean isEmpty() {
    return s.isEmpty();
  }

  public int size() {
    return s.size();
  }

  public Iterator<E> iterator() {
    return s.iterator();
  }

  public boolean add(E e) {
    return s.add(e);
  }

  public boolean remove(Object o) {
    return s.remove(o);
  }

  public boolean containsAll(Collection<?> c) {
    return s.containsAll(c);
  }

  public boolean addAll(Collection<? extends E> c) {
    return s.addAll(c);
  }

  public boolean removeAll(Collection<?> c) {
    return s.removeAll(c);
  }

  public boolean retainAll(Collection<?> c) {
    return s.retainAll(c);
  }

  public Object[] toArray() {
    return s.toArray();
  }

  public <T> T[] toArray(T[] a) {
    return s.toArray(a);
  }

  @Override
  public boolean equals(Object o) {
    return s.equals(o);
  }

  @Override
  public int hashCode() {
    return s.hashCode();
  }

  @Override
  public String toString() {
    return s.toString();
  }
}

继承方式建议只在确实存在"is-a”关系的场景使用。Java标准库有时也违背了这个准则,例如Stack显然不是Vector,但是:

class Stack<E> extends Vector<E> {
...
}

实践19 为可继承精心设计并编写文档(Design and document for inheritance or else prohibit it)

(略)

实践20 用接口替代抽象类(Prefer interfaces to abstract classes)

接口和抽象类,在Java中都支持定义多个实现。两者的主要区别是,为了实现抽象类你必须继承它,且Java规定了只能单一继承;而任意类只要实现所有约定方法都可以配置接口,不用约束在继承体系内。

  • 现有类可以快速地增加一个新的接口。例如很多类都实现了Comparable, IterableAutocloseable接口。但是,想增加一个父类是不可能的,必须调整继承体系。
  • 接口是理想的多态定义方式。例如Comparable就是一个多态接口,允许一个类声明它的实例是根据其他可相互比较的对象排序的
  • 接口允许建立非继承体系的框架。不是任何东西都能往继承里面套。例如歌手和作曲家,可能一个人既是歌手又是作曲家,我们用接口就可以方便实现。
public interface Singer { 
  AudioClip sing(Song s); 
} 

public interface Songwriter { 
  Song compose(int chartPosition); 
}

public interface SingerSongwriter extends Singer, Songwriter {
  AudioClip strum(); 
  void actSensitive(); 
}
  • 接口的良好封装性可以安全、强大地进行功能扩展。尤其是Java8引入默认方法(default method)后,降低了类与接口的耦合性,可以往接口增加方法而不必改动所有的实现类。
interface InterfaceA {
    // 默认方法
    default void foo() {
        System.out.println("InterfaceA foo");
    }
}
 
class ClassA implements InterfaceA {
}
 
public class Test {
    public static void main(String[] args) {
        new ClassA().foo(); // 打印:“InterfaceA foo”
    }
}

当然,默认方法在实际使用中会有一些限制,尤其当你并不是接口的开发者的时候。一个好的实践是将抽象类与接口相结合,采用模板方法模式(Template Method pattern)实现一个抽象接口框架类。这种类的命名通常是“Abstract+[接口名]”。例如:AbstractList

// Java 标准库代码
public abstract class AbstractList<E> extends AbstractCollection<E> implements List<E> {
}

应用实例:

static List<Integer> intArrayAsList(int[] a) {
  Objects.requireNonNull(a);

  return new AbstractList<Integer>() {
    @Override
    public Integer get(int i) {
      return a[i]; // Autoboxing(Item 6)
    }

    @Override
    public Integer set(int i, Integer val) {
      int oldVal = a[i];
      a[i] = val; // Auto-unboxing
      return oldVal; // Autoboxing
    }
  
    @Override
    public int size() { return a.length; } 
  };
}

实践21 设计接口要考虑长远、周全(Design interfaces for posterity)

Java8新增的默认方法使得我们可以向接口添加新的方法而不影响现有的接口实现。但是这种方式具有一定的潜在问题。因此建议在早期设计接口时充分考虑,后期不要随意向接口添加方法。

  • 既有实现并不知晓这些新增的方法,但是实际又是可用的,对既有实现的兼容性等存在潜在影响。例如Java8中Collection接口新增的removeIf默认方法。它能实现函数式编程的特性,官方代码如下。但是对于既有实现类,可能有并发同步操作在操作前需要锁,调用该方法会造成不可预知的问题。
default boolean removeIf(Predicate<? super E> filter) {
  Objects.requireNonNull(filter);
  boolean removed = false;
  final Iterator<E> each = iterator();
  while (each.hasNext()) {
    if (filter.test(each.next())) {
      each.remove();
      removed = true;
    }
  }
  return removed;
}
  • 新增默认方法只能保证编译通过,不保证没有运行时错误。

实践22 接口应仅用于描述类型(Use interfaces only to define types)

接口的作用是用于对象实例的类型定义,它应该描述对象实例可以做什么。除此之外,接口不应用于其他用途。
最常见的错误用途是常量接口(constant interface)。这种接口里面除了一堆final的值域,没有定义任何方法。常量接口的实例如下。

// Constant interface antipattern - do not use!
public interface PhysicalConstants { 
  static final double AVOGADROS_NUMBER = 6.022_140_857e23;
  static final double BOLTZMANN_CONSTANT = 1.380_648_52e-23;
  static final double ELECTRON_MASS = 9.109_383_56e-31;
}

另外Java标准库中ObjectStreamConstants也是一个常量接口,不要去效仿这种特殊情况。定义常量的合理方式是1)类成员变量、2)Enum类、3)不能实例化的工具类(utility class),示例如下。

// Constant utility class
class PhysicalConstants {
  public static final double AVOGADROS_NUMBER = 6.022_140_857e23;
  public static final double BOLTZMANN_CONST = 1.380_648_52e-23;
  public static final double ELECTRON_MASS = 9.109_383_56e-31;
  private PhysicalConstants() {} // Prevents instantiation
}

注意:以上代码中的下划线 "_",从Java7引入,用于清晰展示数值,不影响数值本身。

实践23 用继承区分类行为而不是类内标签(Use interfaces only to define types)

如果类在函数行为上需要根据不同的实例类型做区分,那么最好用继承的方式写多个类,而不是在类内部用标签区分。下面是一个标签类(tagged classes)的示例:

// Tagged class - vastly inferior to a class hierarchy!
class Figure {
  // Tag field - the shape of this figure
  final Shape shape;
  // These fields are used only if shape is RECTANGLE
  double length;;
  double width;
  // This field is used only if shape is CIRCLE
  double radius;
  // Constructor for circle
  Figure(double radius) {
    shape = Shape.CIRCLE;
    this.radius = radius;
  }
  // Constructor for rectangle
  Figure(double length, double width) {
    shape = Shape.RECTANGLE;
    this.length = length;
    this.width = width;
  }

  double area() {
    switch (shape) {
      case RECTANGLE:
        return length * width;
      case CIRCLE:
        return Math.PI * (radius * radius);
      default:
        throw new AssertionError(shape);
    }
  }

  enum Shape {
    RECTANGLE,
    CIRCLE
  }
}

缺点显而易见:

  • 代码冗杂,可读性差
  • 无关成员变量造成内存占用上升
  • 增加新的tag非常麻烦

重构代码如下:

abstract class Figure {
  abstract double area();
}

class Circle extends Figure {

  private final double radius;

  Circle(double radius) {
    this.radius = radius;
  }

  @Override
  double area() {
    return Math.PI * (radius * radius);
  }
}

实践25 使用静态成员类(Favor static member classes over nonstatic)

嵌套类(nested class)应只服务于其上级类。如果业务范围超出上级类,则其应该提升为顶级类。有四种类型的嵌套类。除了第一种外,其他的都是内部类(inner class)。下面分析四种形式的最佳实践应用场景。

  1. 静态成员类
    静态成员类具有对上级类的全部访问权限,包括private域。它的访问方式与上级类的其他静态成员一致。不过如果声明为private,则只能在上级类内部访问。通常,静态成员类用于辅助(helper class),在使用时需结合外部类。例如
// 外部类.静态成员类.成员
Calculator.Operation.PLUS
  1. 非静态成员类
    虽然只比静态成员类少了个修饰符,但是区别很大。每个非静态成员类的实例都隐式地与一个外部类实例关联,1)这种关联在创建实例时绑定,不可修改;2)单独实例化非静态成员类是不可能的,3)占据空间,影响效率和垃圾回收。通常,非静态成员类用于适配器(Adapter),即将某个类以另外的形式展示。例如Map展示位Collections
// Typical use of a nonstatic member class
class MySet<E> extends AbstractSet<E> {
  @Override
  public Iterator<E> iterator() {
    return new MyIterator();
  }

  private class MyIterator implements Iterator<E> {}
}
  1. 匿名类
    匿名类是没有名称的,只在使用时实例化。除了基础类型和String的常量,它不能包含任何static成员。缺点是除了声明的地方没办法显式实例化;不能使用instanceof;不能extends多个接口,或者继承一个类外加实现一个接口。
    lambda之前,匿名类常用于创建函数对象(function objects)或者过程对象(process objects)。另外,匿名类还可用于静态工厂方法。
  2. 本地类
    不常用。

实践25 一个源文件只包含一个顶层类(Limit source files to a single top-level class)

尽管Java编译器允许一个源文件包含多个顶层类,但是不建议这么做。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,948评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,371评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,490评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,521评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,627评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,842评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,997评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,741评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,203评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,534评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,673评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,339评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,955评论 3 313
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,770评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,000评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,394评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,562评论 2 349

推荐阅读更多精彩内容

  • 1. Java基础部分 基础部分的顺序:基本语法,类相关的语法,内部类的语法,继承相关的语法,异常的语法,线程的语...
    子非鱼_t_阅读 31,598评论 18 399
  • 类与接口是Java语言的核心,设计出更加有用、健壮和灵活的类与接口很重要。 13、使类和成员的可访问性最小化 设计...
    Alent阅读 673评论 0 2
  • 这个方案更多是在composer层面做修改。有很大的局限性。在这里记录一下思路。 composer实现资产匿名 目...
    tuxy阅读 240评论 0 0
  • 最近一天接几十个地产中介的电话,一开口就问我要不要卖房子。中介电话我习惯不买也会接,他们大多是在下班以后,周末打电...
    米娅C阅读 175评论 2 4
  • 那夏出生在台北的一个小镇,那里,如同沈从文老先生笔下的边城一样美好。一个小小的城镇,对于外乡人来说,也许是穷...
    程洐阅读 397评论 0 0