1. 程式人生 > >組合優於繼承

組合優於繼承

《Effective Java 中文版第2版》書中第16條中說到:

繼承是實現程式碼複用的有力手段,但它並非永遠是完成這項工作的的最佳工具。

組合優於繼承。

繼承有什麼問題?

繼承打破了類的封裝性,子類依賴於父類中特定功能的實現細節。

繼承什麼時候是安全的

  • 在包的內部是用繼承,不存在跨包繼承。
  • 專門為了擴充套件而設計,並且具備很好的文件說明。

一個例子

實現這樣一個HashSet,可以跟蹤從它被建立之後曾經新增過幾個元素。

使用繼承實現

public class InstrumentedSet<E> extends HashSet<E
>
{ // The number of attempted element insertions private int addCount = 0; public InstrumentedSet() { } public InstrumentedSet(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; } } 複製程式碼

類中使用 addCount 欄位記錄新增元素的次數,並覆蓋父類的 add()addAll() 實現,對 addCount 欄位進行設值。

在下面的程式中,我們期望 getAddCount() 返回3,但實際上返回的是6。

InstrumentedSet<String> s = new
InstrumentedSet<String>(); s.addAll(Arrays.asList("Snap", "Crackle", "Pop")); 複製程式碼

問題出在於:在 HashSet 中,addAll() 的實現是基於 add() 方法的。子類在擴充套件父類的功能時,如果不清楚實現細節,是非常危險的,況且父類的實現在未來可能是變化的,畢竟它並不是為擴充套件而設計的。

使用組合實現

不用擴充套件現有的類,而是在新的類中增加一個私有欄位,引用現有類的例項。這種設計被叫做組合

先建立一個乾淨的 SetWrapper 組合類。

public class SetWrapper<E> implements Set<E> {
  private final Set<E> s;
  public SetWrapper(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(); }
}
複製程式碼

SetWrapper 實現了裝飾模式,通過引用 Set<E> 型別的欄位,面向介面程式設計,相比直接繼承 HashSet 類來得更靈活。可以在呼叫該類的構造方法中傳入任意 Set 具體類。擴充套件該類以實現需求。

public class InstrumentedSet<E> extends SetWrapper<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;
  }
}
複製程式碼

舉一反三

注:以下程式碼均是虛擬碼,組合方式的實現待封裝成 Android 庫並開源,敬請期待。沒錯,這裡打了個廣告。

筆者曾開發的某個應用有以下2張截圖:

遊戲詳情頁面

評論列表頁面

詳情頁面和評論列表頁面均複用了評論項的實現。

評論列表頁面的 GameComentsAdapter

public class GameCommentsAdapter extends RecyclerView.Adapter<BaseViewHolder> {
    private static final int ITEM_TYPE_COMMENT = 1;
    private List<Object> mDataSet;

    @Override
    public int getItemViewType(int position) {
        Object item = getItem(position);
        if (item instanceof Comment) {
            return ITEM_TYPE_COMMENT;
        }
        return super.getItemViewType(position);
    }

    protected Object getItem(int position) {
        return mDataSet.get(position);
    }

    @NonNull
    @Override
    public BaseViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
        LayoutInflater inflater = LayoutInflater.from(parent.getContext());
        if (viewType == ITEM_TYPE_COMMENT) {
            View itemView = inflater.inflate(R.layout.item_comment, parent, false);
            return new CommentViewHolder(itemView);
        }
        return null;
    }

    @Override
    public int getItemCount() {
        return mDataSet.size();
    }
}
複製程式碼

if-else 方式實現

修改 GameComentsAdapter 類,增加對遊戲詳情項的適配支援。

public class GameCommentsAdapter extends RecyclerView.Adapter<BaseViewHolder> {
    private static final int ITEM_TYPE_COMMENT = 1;
    private static final int ITEM_TYPE_GAME_DETAIL = 2;
    private List<Object> mDataSet;

    @Override
    public int getItemViewType(int position) {
        Object item = getItem(position);
        if (item instanceof Comment) {
            return ITEM_TYPE_COMMENT;
        }
        if (item instanceof GameDetail) {
            return ITEM_TYPE_GAME_DETAIL;
        }
        return super.getItemViewType(position);
    }

    protected Object getItem(int position) {
        return mDataSet.get(position);
    }

    @NonNull
    @Override
    public BaseViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
        LayoutInflater inflater = LayoutInflater.from(parent.getContext());
        if (viewType == ITEM_TYPE_COMMENT) {
            View itemView = inflater.inflate(R.layout.item_comment, parent, false);
            return new CommentViewHolder(itemView);
        }
        if (viewType == ITEM_TYPE_GAME_DETAIL) {
            View itemView = inflater.inflate(R.layout.item_game_detail, parent, false);
            return new GameDetailViewHolder(itemView);
        }
        return null;
    }

    @Override
    public int getItemCount() {
        return mDataSet.size();
    }
}
複製程式碼

在遊戲詳情頁面為 RecyclerView 建立一個 GameCommentsAdapter 物件。但該方式會讓 GameCommentsAdapter 變得臃腫,也不滿足OCP開閉原則。

繼承方式實現

擴充套件一個 Adapter 至少要實現 getItemViewType()onCreateViewHolder() 等方法,為了複用 GameComentsAdapter 類中對評論項,詳情頁面的 GameDetailAdapter 繼承該類。

class GameDetailAdapter extends GameCommentsAdapter {
    private static final int ITEM_TYPE_GAME_DETAIL = 2;

    @Override
    public int getItemViewType(int position) {
        Object item = getItem(position);
        if (item instanceof GameDetail) {
            return ITEM_TYPE_GAME_DETAIL;
        }
        return super.getItemViewType(position);
    }

    @NonNull
    @Override
    public BaseViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
        if (viewType == ITEM_TYPE_GAME_DETAIL) {
            LayoutInflater inflater = LayoutInflater.from(parent.getContext());
            View itemView = inflater.inflate(R.layout.item_game_detail, parent, false);
            return new GameDetailViewHolder(itemView);
        }
        return super.onCreateViewHolder(parent, viewType);
    }
}
複製程式碼

突然來了一個新需求

產品希望在詳情頁面新增推薦項,複用首頁列表項,如下圖所示:

首頁

實現效果如下圖所示:

詳情頁面增加

Java 是單繼承的,GameDetailAdapter 已經繼承了 GameComentsAdapter 類了,無法再繼承 HomeAdapter

難道繼續在 GameComentsAdapter 類中增加 if 判斷?

組合方式

先把各個列表項的建立、繫結等抽離出來,引入 Subadapter 類。

interface Subadapter {
    boolean isFor(Object item);
    int getLayoutId();
    BaseViewHolder onCreateViewHolder(View itemView);
}
複製程式碼

實現 CommentSubadapter

class CommentSubadapter implements Subadapter {

    @Override
    public boolean isFor(Object item) {
        return item instanceof Comment;
    }

    @Override
    public int getLayoutId() {
        return R.layout.item_comment;
    }

    @Override
    public BaseViewHolder onCreateViewHolder(View itemView) {
        return new CommentViewHolder(itemView);
    }
}
複製程式碼

實現 GameDetailSubadapter

class GameDetailSubadapter implements Subadapter {

    @Override
    public boolean isFor(Object item) {
        return item instanceof GameDetail;
    }

    @Override
    public int getLayoutId() {
        return R.layout.item_game_detail;
    }

    @Override
    public BaseViewHolder onCreateViewHolder(View itemView) {
        return new GameDetailViewHolder(itemView);
    }
}
複製程式碼

實現 SubadapterManager

class SubadapterManager {

    private SparseArray<Subadapter> mSubadapters = new SparseArray<>();

    public void addSubadapter(int itemType, Subadapter subadapter) {
        mSubadapters.put(itemType, subadapter);
    }

    public int getItemViewType(Object item) {
        for (int i = 0; i < mSubadapters.size(); i++) {
            Subadapter subadapter = mSubadapters.get(i);
            if (subadapter.isFor(item)) {
                return mSubadapters.keyAt(i);
            }
        }
        return 0;
    }

    public BaseViewHolder onCreateViewHolder(ViewGroup parent, int type) {
        LayoutInflater inflater = LayoutInflater.from(parent.getContext());
        Subadapter subadapter = mSubadapters.get(type);
        View itemView = inflater.inflate(subadapter.getLayoutId(), parent, false);
        return subadapter.onCreateViewHolder(itemView);
    }
}
複製程式碼

實現唯一的 Adapter

class Adapter extends RecyclerView.Adapter<BaseViewHolder> {
    private List<Object> mDataSet;
    private SubadapterManager mSubadapterManager;

    protected Object getItem(int position) {
        return mDataSet.get(position);
    }

    public void addSubadapter(int itemType, Subadapter subadapter) {
        mSubadapterManager.addSubadapter(itemType, subadapter);
    }

    @Override
    public int getItemViewType(int position) {
        Object item = getItem(position);
        return mSubadapterManager.getItemViewType(item);
    }

    @NonNull
    @Override
    public BaseViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
        return mSubadapterManager.onCreateViewHolder(parent, viewType);
    }

    @Override
    public void onBindViewHolder(@NonNull BaseViewHolder holder, int position) {
        holder.bind(getItem(position));
    }

    @Override
    public int getItemCount() {
        return mDataSet.size();
    }

    public void setDataSet(List<Object> set) {
        mDataSet = set;
    }
}
複製程式碼

組合起來使用。在評論列表頁面,建立一個 Adapter 例項,並新增評論項功能。

List<Object> dataSet = new ArrayList<>();
dataSet.add(new Comment());
dataSet.add(new GameDetail());

Adapter adapter = new Adapter();
adapter.addSubadapter(ITEM_TYPE_COMMENT, new CommentSubadapter());
adapter.addSubadapter(ITEM_TYPE_GAME_DETAIL, new GameDetailSubadapter());
adapter.setDataSet(dataSet);
複製程式碼

實現 GameSubadapter 完成新需求。

class GameSubadapter implements Subadapter {
    @Override
    public boolean isFor(Object item) {
        return item instanceof Game;
    }

    @Override
    public int getLayoutId() {
        return R.layout.item_game;
    }

    @Override
    public BaseViewHolder onCreateViewHolder(View itemView) {
        return new GameViewHolder(itemView);
    }
}
複製程式碼

在遊戲詳情頁面,建立一個 Adapter 例項,並新增遊戲項功能。

List<Object> dataSet = new ArrayList<>();
dataSet.add(new Comment());
dataSet.add(new GameDetail());
dataSet.add(new Game());

Adapter adapter = new Adapter();
adapter.addSubadapter(ITEM_TYPE_COMMENT, new CommentSubadapter());
adapter.addSubadapter(ITEM_TYPE_GAME_DETAIL, new GameDetailSubadapter());
adapter.addSubadapter(ITEM_TYPE_GAME, new GameSubadapter());
adapter.setDataSet(dataSet);
複製程式碼

當某個頁面不再支援評論項時,我們只要刪除以下程式碼即可,不會修改到其他地方,滿足OCP設計原則。

dataSet.add(new Comment());
adapter.addSubadapter(ITEM_TYPE_COMMENT, new CommentSubadapter());
複製程式碼

延伸

在 Java 生態圈之外,有不少組合優於繼承的實踐。

Kotlin

Kotlin 語言有 delegation 機制,可以方便開發者使用組合。

interface Base {
   fun print()
}

class BaseImpl(val x: Int) : Base {
   override fun print() { print(x) }
}

class Derived(b: Base) : Base by b

fun main(args: Array<String>) {
   val b = BaseImpl(10)
   Derived(b).print()
}
複製程式碼

Kotlin 版 InstrumentedHashSet

class InstrumentedHashSet<E>(val set: MutableSet<E>)
    : MutableSet<E> by set {

    private var addCount : Int = 0

    override fun add(element: E): Boolean {
        addCount++
        return set.add(element)
    }

    override fun addAll(elements: Collection<E>): Boolean {
        addCount += elements.size
        return set.addAll(elements)
    }
}
複製程式碼

Go

Go 語言沒有繼承機制,通過原生支援組合來實現程式碼的複用。以下分別是 ReaderWriter 介面定義。

type Reader interface {
	Read(p []byte) (n int, err error)
}

type Writer interface {
	Write(p []byte) (n int, err error)
}
複製程式碼

通過組合可以定義出具備讀取和寫入的新型別。

type ReadWriter interface {
	Reader
	Writer
}
複製程式碼

上述的例子是介面組合,也可以是實現組合。(下面的例子來自 Go in Action 一書)

type user struct {
	name  string
	email string
}

// notify implements a method that can be called via
// a value of type user.
func (u *user) notify() {
	fmt.Printf("Sending user email to %s<%s>\n",
		u.name,
		u.email)
}

// admin represents an admin user with privileges.
type admin struct {
	user  // Embedded Type
	level string
}

// main is the entry point for the application.
func main() {
	// Create an admin user.
	ad := admin{
		user: user{
			name:  "john smith",
			email: "[email protected]",
		},
		level: "super",
	}

	// We can access the inner type's method directly.
	ad.user.notify()

	// The inner type's method is promoted.
	ad.notify()
}
複製程式碼

推薦書籍

參考資料