1. 程式人生 > >Effective Java (3rd Editin) 讀書筆記:3 類和介面

Effective Java (3rd Editin) 讀書筆記:3 類和介面

3 類和介面

Item 15:最小化類和成員的訪問許可權

一個設計優秀的類應該隱藏它的所有實現細節,將它的 API 和內部實現乾淨地分離開。這種軟體設計的基本準則被稱為“封裝”(encapsulation)。

封裝的優點:

  1. 組成系統的各元件之間解耦,使得它們能夠獨立地開發、測試、優化、使用、理解和修改
  2. 基於第一條,提高了元件的複用性
  3. 基於第二條,即使整個系統開發失敗,某個獨立的元件仍可以非常成功

經驗法則很簡單:最小化每個類和成員的訪問許可權。

對於非內部類和介面,只有兩種訪問許可權:package-private 和 public。

對於 package-private 非內部類和介面,它們不會被暴露在 API 中,因此有了更多可維護性。如果它只被一個類使用時,可以讓它成為這個類的內部類或介面。

對於成員(欄位,方法,內部類和內部介面),有四種訪問許可權:private,package-private,protected 和 public。

private 和 package-private 的成員不會暴露在 API 中,除非類實現了 Serializable 介面(Item 86 和 Item 87)。

子類重寫父類的方法時,方法的訪問許可權不能縮小(滿足里氏替換原則)。因此,如果一個類實現了介面,那麼它實現的方法都是 public。

public 類的欄位的訪問許可權應該儘可能小於 public,對於 mutable 欄位更是如此。而 public static final 的陣列欄位是保證不了 immutability 的,使用者仍然可以修改陣列的元素,解決方法參考如下:

private static final Thing[] PRIVATE_VALUES = {...};
public static final List<Thing> VALUES = 
    Collections.unmodifiableList(Arrays.asList(PRIVATE_VALUES));

或者:

private static final Thing[] PRIVATE_VALUES = {...};
public static final Thing[] values() {
    return PRIVATE_VALUES.clone();
}

Item 16:public 類中,使用 getter 而不是 public 欄位

// Degenerate classes like this should not be public!
class Point {
    public double x;
    public double y;
}

上面的這種 public non-final 欄位有很多反封裝的缺點,不能保證不變性,不能方便地修改類,不能在訪問欄位時新增輔助操作。

當然,我們可以採用 JavaBean 式的解決方案,使欄位為 private,並新增 setter 和 getter。如果類是 public,這樣做很有必要,它遮蔽了內部的欄位細節,便於將來的版本更新。如果類是 package-private 或者 private 的內部類,因為它的訪問範圍較小,所以要求可以放寬一點。

Java 平臺中的 java.awt 包中的 Point 和 Dimension 類違反了本條規則,因此在使用它們時要非常小心。

Item 17:最小化 mutability

Immutable 類是指例項不能修改的類,每個例項中的欄位資訊在物件生命週期中是固定的。比如 String,原始型別的包裝類,BigInteger,BigDecimal。

在設計類的時候,首先考慮能不能設計成 immutable 類,如果有特殊原因不能的話,也要儘可能限制它的 mutability,儘可能使每個欄位是 final 的,構造器中初始化好所有欄位。

設計一個 Immutable 類時的五條規則:

  1. 不要新增會修改物件狀態的方法,如 setter 方法
  2. 確保類不被繼承,兩種方案
  3. 所有欄位 final。更寬鬆一點的說法是,沒有方法會產生內部可見的狀態改變,String 類中就採用了非 final 的 hash 變數來快取懶載入的雜湊值。
  4. 所有欄位 private。雖然 public final 欄位也是安全的,但是考慮到未來的版本更新,儘量隱藏內部細節。
  5. 確保對 mutable 元件的獨佔訪問。如果類中定義了 mutable 物件的引用,確保使用者獲取不到這些引用欄位,也不要用使用者提供的引用來初始化這些欄位。如果類是 Serializable,需要顯式提供 readObject 或 readResolve 方法,或者使用 ObjectOutputStream.writeUnsharedObjectInputStream.readUnshared 方法(Item 88)。

Immutable 類的缺點是,每一個不同的值需要建立一個不同的物件。比如,BigInter 的 flipBit 方法在反轉每一位後返回一個新的物件,而 BigSet 的 flip 方法反轉某一位後返回修改後的原物件,前者是 immutable,後者是 mutable。如果修改操作非常多的時候,這會產生大問題。解決方法是提供 mutable 的夥伴類,如 String 的 StringBuilder。有的 immutable 的類非常聰明,比如 BigInteger,它會使用 package-private 的夥伴類來加速多步計算,比如 modular exponentiation 計算。

Item 18:使用組合代替繼承

繼承違反了封裝原則,存在很多問題:

  1. 被重寫的方法的自身呼叫的實現細節可能會變化,我們針對它的重寫有潛在風險
  2. 如果父類添加了新的方法,子類沒有及時重寫,子類中訪問此方法可能達不到預期增強效果
  3. 子類添加了新的方法,未來的父類版本中可能也會新增同名方法,造成無意的重寫
  4. 子類會繼承父類的 API 中的缺點,而組合可以隱藏這些缺點

解決上述問題的一個方案是組合,讓舊類的例項成為新類的元件,新類的方法呼叫轉發給舊類。為了增加複用性,我們分成了兩部分:

// Wrapper class —— 使用組合代替繼承的包裝類
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; }
}
// 可複用的轉發類
public class ForwardingSet<E> implements Set<E> {
    private final Set<E> s;
    public ForwardingSet(Set<E> s) {this.s = s;}

    @Override public int size() { return s.size(); }
    @Override public boolean isEmpty() { return s.isEmpty(); }
    @Override public boolean contains(Object o) { return false; }
    @Override public Iterator<E> iterator() { return s.iterator(); }
    @Override public Object[] toArray() { return s.toArray(); }
    @Override public <T> T[] toArray(T[] a) { return s.toArray(a); }
    @Override public boolean add(E e) { return s.add(e); }
    @Override public boolean remove(Object o) { return s.remove(o); }
    @Override public boolean containsAll(Collection<?> c) { return s.containsAll(c); }
    @Override public boolean addAll(Collection<? extends E> c) { return s.addAll(c); }
    @Override public boolean retainAll(Collection<?> c) { return s.retainAll(c); }
    @Override public boolean removeAll(Collection<?> c) { return s.removeAll(c); }
    @Override public void clear() { s.clear(); }
}

InstrumentedSet 包裝類的設計又稱為“裝飾器模式”(Decorator pattern),因為它通過新增裝備“裝飾”了一個 Set 物件。

包裝類的一個注意事項是:包裝類不適合在回撥框架中使用(一個物件傳遞自己的引用給其它物件,以便將來的呼叫(回撥))。一個被包裝的物件不知道自己的包裝者是誰,因此它會把自己的引用傳遞給其它物件,回撥函式就忽略了包裝類。

java.util.Properties 就是一個不合適的繼承案例,Properties 類繼承自 HashTable,它的設計初衷是隻能新增 String 型別的鍵和值,getProperty(key) 可以返回預設值,但是因為繼承自 HashTable,所以還可以呼叫 put 和 putAll 方法來新增非 String 型別的鍵和值,get(key) 可能返回 null。一旦違反了 Properties 的設計,就不能有效使用 Properties 的其它 API 了(load 和 store)。

Item 19:可繼承類要說明自身呼叫

一個為繼承而設計的類需要在 API 文件中說明自己的所有自身呼叫(self-use),比如按什麼順序呼叫了類中的其它可重寫的方法。

// AbstractList

/**
 * Removes from this list all of the elements whose index is between
 * {@code fromIndex}, inclusive, and {@code toIndex}, exclusive.
 * Shifts any succeeding elements to the left (reduces their index).
 * This call shortens the list by {@code (toIndex - fromIndex)} elements.
 * (If {@code toIndex==fromIndex}, this operation has no effect.)
 *
 * <p>This method is called by the {@code clear} operation on this list
 * and its subLists.  Overriding this method to take advantage of
 * the internals of the list implementation can <i>substantially</i>
 * improve the performance of the {@code clear} operation on this list
 * and its subLists.
 *
 * <p>This implementation gets a list iterator positioned before
 * {@code fromIndex}, and repeatedly calls {@code ListIterator.next}
 * followed by {@code ListIterator.remove} until the entire range has
 * been removed.  <b>Note: if {@code ListIterator.remove} requires linear
 * time, this implementation requires quadratic time.</b>
 *
 * @param fromIndex index of first element to be removed
 * @param toIndex index after last element to be removed
 */
protected void removeRange(int fromIndex, int toIndex) {...}

這樣顯然違反了“API 文件應該描述方法做了什麼,而隱藏如何做到”的教條,但是沒有辦法,繼承本身已經違反了封裝的原則。

如果一個類沒有自身呼叫,你需要在 API 文件中說明這一點,這種父類可以被安全地繼承。

對允許繼承的類的要求:

  1. 構造器中禁止直接或間接呼叫可重寫的方法(private、final、static 方法是不可重寫的)。原因:父類的構造器會先執行,如果子類重寫的方法中依賴了需要子類構造器初始化的變數,父類構造器呼叫此方法時就會出現異常。

    public class Base
    {
        private String baseName = "base";
        public Base(){ callName(); }
        public void callName() { System.out.println(baseName); } // null
     
        static class Sub extends Base {
            private String baseName = "sub";
            public void callName() { System.out.println (baseName); }
        }
        
        public static void main(String[] args) {
            Base b = new Sub(); 
        }
    }
    
  2. 不要使父類實現 Cloneable 和 Serializable 介面,替代方案見 Item 13 和 Item 86。如果非要實現的話,要注意,clone 和 readObject 方法的功能有點像構造器,因此也要滿足第一條要求。

  3. 父類實現了 Serializable 介面且有 readResolve 或者 writeReplace 方法時,這兩個方法應該是 protected 而非 private。

讓一個類不可被繼承是一種安全的做法:

  1. final 修飾
  2. 使得所有構造器 private 或 package-private,並新增靜態工廠方法代替

有的類實現了包含了類的實現要素的介面,比如 Set,List 或 Map,完全可以用非繼承的方式實現功能的增強,方案是 wrapper class 模式(Item 18)。

Item 20:介面優於抽象類

介面有一些顯然的優勢:

  1. 已經存在的類很容易通過改造,實現一個新的介面。

  2. 介面非常適合定義 mixin。定義 mixin 是指允許一個類在它的原始功能中混合進(mix in)可選擇的功能,比如 Comparable 介面允許一個類宣告它的例項可以排序。抽象類不能被用來定義 mixin,因為類只能有一個父類,繼承抽象類會破壞類的繼承結構。

結合介面和抽象類的優點,使用介面的抽象 skeletal implementation 類,又稱為 AbstractInterface,介面中可以提供預設方法,而且 AbstractInterface 類中實現剩餘的 non-primitives 方法(primitives 是指留給子類實現的方法),使得子類的實現更容易。比如 AbstractCollection,AbstractList 等。在設計模式中又稱為“Template Method”模式。

static List<Integer> initArrayAsList(int[] a) {
    Objects.requireNonNull(a);
    return new ArrayList<Integer>() {
        @Override
        public Integer get(int index) {
            return a[index]; // Autoboxing
        }

        @Override
        public Integer set(int index, Integer element) {
            int oldVal = a[index];
            a[index] = element; // Auto-unboxing
            return oldVal; // Autoboxing
        }

        @Override
        public int size() {
            return a.length;
        }
    };
}

上面程式碼中就通過使用 AbstractList 很輕鬆地實現了 List 介面,同時又是一個“介面卡模式”,將 int 陣列視為了一個 Integer List 來操作。

模擬多重繼承(simulated multiple inheritance):一個直接實現了介面的類,可以將介面的方法呼叫轉發給自己的繼承自 AbstactInterface 的內部類,這種設計技巧既提供了多重繼承的優勢,又避開了陷阱(Item 18)。

skeletal implementation 的設計步驟:

  1. 研究介面,決定哪些方法作為 primitives 不實現,其餘的方法可以由我們實現
  2. 在 non-primitives 中,優先考慮哪些可以作為介面的預設方法來實現(介面的預設方法不能實現 Object 類的方法比如,equals,hashCode,toString),如果預設方法覆蓋了所有 non-primitives,就不必再設計 AbstactInterface 了
  3. 定義 AbstactInterface 類,實現剩餘的介面預設方法不能實現的 non-primitives 方法。
  4. 作為一個抽象類,完成必要的說明文件。

總之,skeletal implementation 中,儘可能通過介面的預設方法來實現 non-primitives,使得所有實現類都能使用這些方法,在預設方法不能實現時,再提供抽象類來實現這些 non-primitives。

Item 21:用介面的預設方法來新增新方法是不得已之舉

Java 8 之前,介面中是不能新增新方法的,否則所有已經存在的實現類,因為沒有實現這個方法而不能通過編譯。但是 Java 8 引入了預設方法,可以給介面新增新的方法,已經存在的實現類可以通過編譯,但執行時還是可能出現問題,因此一定要謹慎。

比如,Collection 介面在 Java 8 中添加了一個預設方法:

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

Apache Commons 庫中的 SynchronizedCollection 類並沒有實現這個方法,如果在 Java 8 中使用此類的 removeIf 方法,並不能保證此類的基本承諾:自動同步每一個方法呼叫。在另一個執行緒併發修改的情景中,此類的 removeIf 方法會丟擲 ConcurrentModificationException 或者產生其它異常行為。

但是另一方面,在設計介面的一開始,可以考慮使用預設方法來提供標準的方法實現,來減輕實現介面的負擔。

Item 22:介面作為一種定義行為的型別來使用,而不要定義純粹的常量介面

一個類實現了一個介面,這個介面就可以作為一個型別來引用此類的例項,根據介面的 API,就可以知道此類的例項具有哪些行為。

從這個意義上理解的話,常量介面是對介面的不恰當使用:

// 常量介面反模式 —— 不要這樣定義!
public interface PhysicalConstants {
    // Avogadro's number (1/mol)
    static final double AVOGADRO_NUMBER = 6.022_140_857e23;
    
    // Boltzmann constant (J/K)
    static final double BOLTZMANN_CONSTANT = 1.380_648_52e-23;
    
    // Mass of the electron (kg)
    static final double ELECTRON_MASS = 9.109_383_56e-31;
}
  1. 類內部使用的常量屬於實現細節,實現常量介面使得實現細節暴露在 API 中
  2. 如果將來發布的版本中,此類不再需要這些常量,為了使得二進位制相容,它還是要實現此介面
  3. 一個可以繼承的類實現了常量介面的話,它的子類的名稱空間也都會被介面中的常量汙染

Java 平臺庫中的 java.io.ObjectStreamConstants 介面就是這樣一個反例。

正確定義常量的推薦做法有:

  1. 若這些常量和類或介面緊密繫結,就將它們新增到類和介面的常量欄位中。如 Integer 和 Double,暴露了常量 MIN_VALUE 和 MAX-VALUE
  2. 如果這些常量最好看作是列舉型別的成員,就使用列舉型別(Item 34)
  3. 否則,就使用不可例項化的工具類
// 常量工具類
public class PhysicalConstants {
    private PhysicalConstants() {} // Prevents instantiation
    
    public static final double AVOGADRO_NUMBER = 6.022_140_857e23;
    public static final double BOLTZMANN_CONSTANT = 1.380_648_52e-23;
    public static final double ELECTRON_MASS = 9.109_383_56e-31;
}

Item 23:將 tagged 類重構為類繼承體系

Tagged 類:類的例項有多種不同的型別,通過類內部的 tag 欄位來表明一個例項是哪種型別。如下:

// Tagged class —— 非常不推薦,應該改造為類繼承體系
class Figure {
    enum Shape {RECTANGLE, CIRCLE};

    // Tag field - the shape of the 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.width = width;
        this.length = length;
    }

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

tagged 類的缺點很多,概括起來就是臃腫(可讀性差)、易出錯(擴充套件麻煩)、低效(佔記憶體)。

將 tagged 類重構為類繼承體系:

  1. 定義一個抽象類,為 tagged 類中取決於 tag 值而選擇性表達的每一個方法定義一個抽象方法,tagged 類中不取決於 tag 值的方法以及所有型別共用的欄位直接放在抽象類中。
  2. 為 tagged 類中的每一種型別定義一個抽象類的實現類,包含此型別特有的資料欄位,針對此型別實現每一個抽象方法
// 代替 tagged 類的類繼承體系
abstract class Figure {
    abstract double area();
}

class Circle extends Figure {
    final double radius;
    
    Circle(double radius) { this.radius = radius; }

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

class Rectangle extends Figure {
    final double length;
    final double width;

    Rectangle(double length, double width) {
        this.length = length;
        this.width = width;
    }
    
    @Override double area() { return length * width; }
}

類繼承體系改正了 tagged 的缺點,同時反映了類之間的自然等級關係,增加靈活性,編譯時檢查。如,正方形作為一種特殊的矩形,實現起來也非常容易:

class Square extends Rectangle {
    Square(double side) {
        super(side, side);
    }
}

Item 24:根據使用場景選擇巢狀類的種類

巢狀類(nested class)有四種:靜態成員類,非靜態成員類,匿名類,區域性類;後三種又稱為內部類(inner class)。

靜態成員類,最簡單的巢狀類,可以看做一個普通類,恰好被宣告在另一個類中,可以訪問封閉類(enclosing class)的所有成員(包括 private)。如果 private,那麼訪問範圍侷限在封閉類中。一個常見的使用場景是作為 public 幫助類,比如提供常量等。

非靜態成員類,隱式地和一個封閉類的例項關聯,建立一個非靜態成員類的例項時必須先有一個封閉類的例項。在非靜態成員類例項的方法中,可以呼叫封閉類例項的方法,並用 EncosingClassName.this 引用封閉例項。一個常見的使用場景是介面卡模式,比如,HashMap 中使用非靜態成員類 KeySet、EntrySet、Values 來實現 collection 的檢視;再比如 collection 介面(如 Set 和 List)的實現中,使用非靜態成員類實現迭代器。

// 非靜態成員類的典型應用
public class MySet<E> extends AbstractSet<E> {
    //...
    
    @Override public Iterator<E> iterator() {
        return new MyIterator();
    }
    
    private class MyIterator implements Iterator<E> {
        ...;
    }
}

非靜態成員類相較於靜態成員類的缺點有:

  1. 非靜態成員類例項中,會儲存封閉例項的引用,這個儲存要花費空間和時間
  2. 更大的缺點是,對封閉例項的引用會影響 GC 過程,導致記憶體洩漏

所以,除非成員類真的需要使用一個封閉物件的例項,否則總是使用 static 修飾符。比如 Map 中的 private static 的 Entry。

匿名類在使用時定義和初始化,僅僅在非靜態上下文中才會有封閉例項的引用,它們的靜態成員只能是常量(final 原始型別,或常量表達式初始化的 String 欄位)。在能用 lamda 表示式時傾向使用 lamda 代替匿名類。

Item 25:一個 Java 原始檔中只定義一個頂層類或頂層介面

假設一個包中有 3 個 Java 原始檔:

Main.java:

public class Main {
    public static void main(String[] args) {
        System.out.println(Utensil.NAME + Dessert.NAME);
    }   
}

Utensil.java:

// 一個原始檔定義兩個 top-level 類,不要這樣做!
class Utensil {
    static final String NAME = "pan";
}

class Dessert {
    static final String NAME = "cake";
}

Dessert.java:

// 一個原始檔定義兩個 top-level 類,不要這樣做!
class Utensil {
    static final String NAME = "pot";
}

class Dessert {
    static final String NAME = "pie";
}

命令列編譯時:

  • 如果輸入 javac Main.java Dessert.java 會報錯“you’ve multiply defined the classes Utensil and Dessert”,因為編譯器首先讀入 Main.java 檔案,在遇到 Utensil.NAME,會在目錄中尋找 Utensil.java 原始檔來載入類,結果就會在原始檔中找到了 Utensil 和 Dessert 兩個類,當編譯器接著讀入 Dessert.java 檔案時,就會遇到 Utensil 和 Dessert 的重定義。
  • 如果輸入 javac Main.java Utensil.java 或者 javac Main.java 都會正常編譯,執行時輸出“pancake”。
  • 如果輸入 javac Dessert.java Main.java 也會通過編譯,但是執行時輸出“potpie”。

推薦修改方案有兩種,一是,將每個頂層類放到單獨的 java 原始檔中;二是,將多個類作為靜態成員類放到一個頂層類中,如下示例。

public class Main {
    public static void main(String[] args) {
        System.out.println(Utensil.NAME + Dessert.NAME);
    }   
    
    private static class Utensil {
        static final String NAME = "pot";
    }

    private static class Dessert {
        static final String NAME = "pie";
    }  
}