組合優於繼承

吳下阿吉發表於2018-11-15

《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 庫並已開源,名叫 Modapter( Modular Adapter 之意)。沒錯,這裡打了個廣告。

先來看看問題。筆者曾開發的某個應用有以下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 判斷?

組合方式

為了方便閱讀,部分程式碼已省略。

定義一個模組化的介面卡 Adapter 類,為了能夠被以上 Adapter 類所管理,資料項和檢視項需要做一些配合:前者繼承 AbstractItem 類,後者需要繼承 ItemViewHolder 類。

class Comment extends AbstractItem {}
class GameDetail extends AbstractItem {}
class Game extends AbstractItem {}

class CommentViewHolder extends ItemViewHolder<Comment> {}
class GameDetailViewHolder extends ItemViewHolder<GameDetail> {}
class GameViewHolder extends ItemViewHolder<Game> {}
複製程式碼

AbstractItem 類定義了一個 type 屬性,代表資料項的型別,會與通過註冊的資料項配置資訊進行比對,當 type 屬性值一樣時,就會為該資料項 AbstractItem 建立對應的檢視項 ViewHolder

如果因為 Java 單繼承的關係無法繼承 AbstractItem 類,可以選擇實現 Item 介面,實現以下方法。

public interface Item {

    void setType(int type);

    int getType();
}
複製程式碼

此時,資料項和檢視項的準備工作已完成,接下來可以組合它們實現需求。

在評論列表頁面,建立一個 Adapter 例項,並新增評論項功能。

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

Adapter adapter = new Adapter();
adapter.getManager()
        .register(ITEM_TYPE_COMMENT, CommentViewHolder.class)
        .register(ITEM_TYPE_GAME_DETAIL, GameDetailViewHolder.class)
        .setList(dataSet);
複製程式碼

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

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

Adapter adapter = new Adapter();
adapter.getManager()
        .register(ITEM_TYPE_COMMENT, CommentViewHolder.class)
        .register(ITEM_TYPE_GAME_DETAIL, GameDetailViewHolder.class)
        .register(ITEM_TYPE_GAME, GameViewHolder.class)
        .setList(dataSet);
複製程式碼

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

dataSet.add(new Comment());
adapter.getManager().unregister(ITEM_TYPE_COMMENT);
複製程式碼

實現原理

引入 ItemManager 介面,統一管理項資料、註冊和登出檢視項配置資訊。

public interface ItemManager {

    ItemManager setList(List<? extends Item> list);

    <T extends ViewHolder> ItemManager register(int type, Class<T> holderClass);

    <T extends ViewHolder> ItemManager register(int type, @LayoutRes int layoutId, Class<T> holderClass);
    
    ItemManager register(ItemConfig config);

    ItemManager unregister(int type);
    
    <T extends Item> T getItem(int position);
}
複製程式碼

該介面的實現類是 AdapterDelegate,主要實現了getItemViewTypeonCreateViewHolder, onBindViewHolder 三個 if-else 重災區方法。

public final class AdapterDelegate implements ItemManager {

    public int getItemViewType(int position) {
        Item item = getItem(position);
        ItemConfig adapter = null;
        if (item != null) {
            adapter = registry.get(item.getType());
        }
        if (adapter == null) {
            // TODO
            return 0;
        }
        return adapter.getType();
    }

    public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
        ItemConfig adapter = registry.get(viewType);
        if (adapter == null) {
            return null;
        }

        int layoutId = adapter.getLayoutId();
        layoutId = layoutId == 0 ? adapter.getType() : layoutId;
        if (layoutId > 0) {
            View itemView = LayoutInflater.from(parent.getContext())
                    .inflate(layoutId, parent, false);
            return createViewHolder(itemView, adapter.getHolderClass());
        }

        return null;
    }

    @SuppressWarnings("unchecked")
    public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
        Item item = getItem(position);
        if (holder instanceof ItemViewHolder) {
            ItemViewHolder viewHolder = (ItemViewHolder) holder;
            viewHolder.setItem(item);
            viewHolder.onViewBound(item);
        }
    }
}
複製程式碼

使用 AdapterDelegate 實現唯一的 Adapter,將主要的程式碼委託給前者。

public class Adapter extends RecyclerView.Adapter<ViewHolder> {

    private AdapterDelegate delegate = new AdapterDelegate();

    @Override
    public int getItemViewType(int position) {
        return delegate.getItemViewType(position);
    }

    @NonNull
    @Override
    public final ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
        return delegate.onCreateViewHolder(parent, viewType);
    }

    @Override
    public final void onBindViewHolder(@NonNull ViewHolder holder, int position) {
        delegate.onBindViewHolder(holder, position);
    }
    
    public ItemManager getManager() {
        return delegate;
    }
}
複製程式碼

延伸

在 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: "john@yahoo.com",
		},
		level: "super",
	}

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

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

推薦書籍

參考資料

相關文章