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編譯器允許一個源文件包含多個頂層類,但是不建議這么做。

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容

  • 1. Java基礎部分 基礎部分的順序:基本語法,類相關的語法,內部類的語法,繼承相關的語法,異常的語法,線程的語...
    子非魚_t_閱讀 31,754評論 18 399
  • 類與接口是Java語言的核心,設計出更加有用、健壯和靈活的類與接口很重要。 13、使類和成員的可訪問性最小化 設計...
    Alent閱讀 687評論 0 2
  • 這個方案更多是在composer層面做修改。有很大的局限性。在這里記錄一下思路。 composer實現資產匿名 目...
    tuxy閱讀 254評論 0 0
  • 最近一天接幾十個地產中介的電話,一開口就問我要不要賣房子。中介電話我習慣不買也會接,他們大多是在下班以后,周末打電...
    米婭C閱讀 184評論 2 4
  • 那夏出生在臺北的一個小鎮,那里,如同沈從文老先生筆下的邊城一樣美好。一個小小的城鎮,對于外鄉人來說,也許是窮...
    程洐閱讀 410評論 0 0