Effective Java (3rd Editin) 讀書筆記:3 類和介面
3 類和介面
Item 15:最小化類和成員的訪問許可權
一個設計優秀的類應該隱藏它的所有實現細節,將它的 API 和內部實現乾淨地分離開。這種軟體設計的基本準則被稱為“封裝”(encapsulation)。
封裝的優點:
- 組成系統的各元件之間解耦,使得它們能夠獨立地開發、測試、優化、使用、理解和修改
- 基於第一條,提高了元件的複用性
- 基於第二條,即使整個系統開發失敗,某個獨立的元件仍可以非常成功
經驗法則很簡單:最小化每個類和成員的訪問許可權。
對於非內部類和介面,只有兩種訪問許可權: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 類時的五條規則:
- 不要新增會修改物件狀態的方法,如 setter 方法
- 確保類不被繼承,兩種方案
- 所有欄位 final。更寬鬆一點的說法是,沒有方法會產生內部可見的狀態改變,String 類中就採用了非 final 的 hash 變數來快取懶載入的雜湊值。
- 所有欄位 private。雖然 public final 欄位也是安全的,但是考慮到未來的版本更新,儘量隱藏內部細節。
- 確保對 mutable 元件的獨佔訪問。如果類中定義了 mutable 物件的引用,確保使用者獲取不到這些引用欄位,也不要用使用者提供的引用來初始化這些欄位。如果類是 Serializable,需要顯式提供 readObject 或 readResolve 方法,或者使用
ObjectOutputStream.writeUnshared
和ObjectInputStream.readUnshared
方法(Item 88)。
Immutable 類的缺點是,每一個不同的值需要建立一個不同的物件。比如,BigInter 的 flipBit 方法在反轉每一位後返回一個新的物件,而 BigSet 的 flip 方法反轉某一位後返回修改後的原物件,前者是 immutable,後者是 mutable。如果修改操作非常多的時候,這會產生大問題。解決方法是提供 mutable 的夥伴類,如 String 的 StringBuilder。有的 immutable 的類非常聰明,比如 BigInteger,它會使用 package-private 的夥伴類來加速多步計算,比如 modular exponentiation 計算。
Item 18:使用組合代替繼承
繼承違反了封裝原則,存在很多問題:
- 被重寫的方法的自身呼叫的實現細節可能會變化,我們針對它的重寫有潛在風險
- 如果父類添加了新的方法,子類沒有及時重寫,子類中訪問此方法可能達不到預期增強效果
- 子類添加了新的方法,未來的父類版本中可能也會新增同名方法,造成無意的重寫
- 子類會繼承父類的 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 文件中說明這一點,這種父類可以被安全地繼承。
對允許繼承的類的要求:
-
構造器中禁止直接或間接呼叫可重寫的方法(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(); } }
-
不要使父類實現 Cloneable 和 Serializable 介面,替代方案見 Item 13 和 Item 86。如果非要實現的話,要注意,clone 和 readObject 方法的功能有點像構造器,因此也要滿足第一條要求。
-
父類實現了 Serializable 介面且有 readResolve 或者 writeReplace 方法時,這兩個方法應該是 protected 而非 private。
讓一個類不可被繼承是一種安全的做法:
- final 修飾
- 使得所有構造器 private 或 package-private,並新增靜態工廠方法代替
有的類實現了包含了類的實現要素的介面,比如 Set,List 或 Map,完全可以用非繼承的方式實現功能的增強,方案是 wrapper class 模式(Item 18)。
Item 20:介面優於抽象類
介面有一些顯然的優勢:
-
已經存在的類很容易通過改造,實現一個新的介面。
-
介面非常適合定義 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 的設計步驟:
- 研究介面,決定哪些方法作為 primitives 不實現,其餘的方法可以由我們實現
- 在 non-primitives 中,優先考慮哪些可以作為介面的預設方法來實現(介面的預設方法不能實現 Object 類的方法比如,equals,hashCode,toString),如果預設方法覆蓋了所有 non-primitives,就不必再設計 AbstactInterface 了
- 定義 AbstactInterface 類,實現剩餘的介面預設方法不能實現的 non-primitives 方法。
- 作為一個抽象類,完成必要的說明文件。
總之,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;
}
- 類內部使用的常量屬於實現細節,實現常量介面使得實現細節暴露在 API 中
- 如果將來發布的版本中,此類不再需要這些常量,為了使得二進位制相容,它還是要實現此介面
- 一個可以繼承的類實現了常量介面的話,它的子類的名稱空間也都會被介面中的常量汙染
Java 平臺庫中的 java.io.ObjectStreamConstants 介面就是這樣一個反例。
正確定義常量的推薦做法有:
- 若這些常量和類或介面緊密繫結,就將它們新增到類和介面的常量欄位中。如 Integer 和 Double,暴露了常量 MIN_VALUE 和 MAX-VALUE
- 如果這些常量最好看作是列舉型別的成員,就使用列舉型別(Item 34)
- 否則,就使用不可例項化的工具類
// 常量工具類
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 類重構為類繼承體系:
- 定義一個抽象類,為 tagged 類中取決於 tag 值而選擇性表達的每一個方法定義一個抽象方法,tagged 類中不取決於 tag 值的方法以及所有型別共用的欄位直接放在抽象類中。
- 為 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> {
...;
}
}
非靜態成員類相較於靜態成員類的缺點有:
- 非靜態成員類例項中,會儲存封閉例項的引用,這個儲存要花費空間和時間
- 更大的缺點是,對封閉例項的引用會影響 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";
}
}