一
一群盲人被帶到一頭大象面前,讓他們摸摸大象像什么。一個瞎子摸到了大象的腿,說大象像一棵樹;另一個瞎子摸到了大象的耳朵,說大象像一個扇子;第三個摸著大象的身體,說它像一堵墻;第四個瞎子則拽著大象的尾巴說,它分明像一根繩子...

這就是我們熟知的《盲人摸象》的寓言。它主要是用來諷刺:我們不應該只看到一個事物的側面,就匆忙給出結論,這樣我們就與瞎子無異。
二
正如萬事只要換個坐標系,就可能會得出不同結論一樣;回到我們軟件設計領域,重新衡量這個問題,會發現盲人摸象的效果恰恰是我們所苦苦追求的。
假設我們擁有一頭大象。如果現在有人需要一把扇子,我們就讓他使用大象的耳朵來扇會兒風;如果有人需要一把長茅,就讓他把象牙當作武器;而如果有人需要一根繩子,我們則可以把大象尾巴借他當繩子用會兒……
總而言之,對于不同個體,需要的是更加具體的服務,而不是一頭大象,因而他也并不關心為他服務的事物背后是否是一頭大象。
而大象,是個完整的個體,不可分割(這很重要)。它很大,大到可以為外部提供多種功能的服務。而對于每種不同的服務需要者,它就扮演不同的角色。
如果我們將剛才的描述映射到軟件的組件技術,就可以用下圖來展現:

大象是一個不可分割的完整組件,但對外提供不同的服務。不同類型的客戶使用不同的服務,完全無需知道其它服務的存在,也無需知道背后是誰再為自己提供服務。
而這種方式,無疑降低了客戶與服務提供者之間的耦合:
- 一方面,每個服務背后的具體提供者都可以自由替換,而不用擔心客戶代碼受到影響(但要遵從里氏替換原則,即要遵從客戶與服務之間的契約);
- 另一方面:作為服務提供者——大象——不會強迫客戶依賴它無需依賴的東西,而只依賴自己特定需要的服務接口;
- 同時,由于你沒有把整只大象呈現在客戶面前,這就約束了客戶隨意使用大象提供的其它接口所帶來的不必要的耦合。
如果回到具體程序設計上,一種可能的實現則會如下:
public interface Fan
{
// Fan related methods.
}
public interface Rope
{
// Rope related methods.
}
public interface Snake
{
// Snake related methods.
}
public interface Tree
{
// Tree related methods.
}
public interface Wall
{
// Wall related methods.
}
public interface Spear
{
// Spear related methods
}
class Elephant implements Fan, Rope, Snake, Tree, Wall, Spear
{
// Implementation of Fan related methods.
// Implementation of Rope related methods.
// Implementation of Snake related methods.
// Implementation of Tree related methods.
// Implementation of Wall related methods.
// Implementation of Spear related methods.
// all Elephant Data.
}
在這個Java
版本的實現方式中,所有接口的具體實現都集中在Elephant
類里。當接口足夠多,實現足夠復雜時,這毫無疑問會形成上帝類。
不過,這貌似沒有太大問題。畢竟,大象本來就是個大塊頭。
三
組件化的設計方式,某種程度也是一種多角色對象設計方式。
我們每個人在生活中都扮演不止一個角色:
- 在孩子面前,我們是父母;
- 在父母面前,我們是子女;
- 職場上,在上司面前,我們是下屬;
- 在下屬面前,你是上司...
不同角色,要求履行的職責也不同:
- 作為父母:我們要給孩子講故事,陪他們玩游戲,哄它們睡覺;
- 作為子女:我們則要孝敬父母,聽取他們的人生建議;
- 作為下屬:在老板面前,我們需要聽從其工作安排;
- 作為上司:需要安排下屬工作,并進行培養和激勵;
- ...
所有這些角色,或者有所關聯,或者風馬牛不相及,但卻可以很好的集中于一個人身上。
四
每一個角色,都有其存在的上下文(Context
),或稱為環境。
比如:你不會回到家里去扮演上司的角色;也不會在公司環境下扮演父母的角色。
對某個人來說,如果一個上下文并不存在,或者已經脫離了某個上下文,那么對他而言,對應的角色也就不會或不再存在。比如,如果一個人還沒有孩子,那他就不會扮演父母這個角色;一旦一個人離開職場,那他就無需再承擔與職場有關的角色。
類似的,一個人也會由于上下文的變化而承擔他過去無需承擔的責任。比如,當有了孩子之后,他就要開始承擔作為父母的責任;而一旦被提拔為管理者,就需要開始承擔上司的職責。
因而,每個人需要承擔的角色,都在隨著環境的變化而變化。
對于軟件開發而言,環境對應的是Use Case
,對應的是需求。
需求的變化,對于很多系統而言是很頻繁的。這就意味著我們之前在一個類里實現所有角色相關代碼所得到的上帝類,在頻繁變化的需求面前,由于角色變更而導致的變化也是很頻繁的。
五
在頻繁的變化面前,上帝類的修改,往往并不是簡單的增加或刪除某個角色相關代碼那么簡單。
我們已經知道,OO
的主要作用是為了模塊化,通過將關聯緊密的元素放到一個類中,然后通過封裝手段將易于變化的細節隱藏起來,只暴露更為抽象,更為穩定的接口,從而降低模塊間耦合。最終達到讓軟件在變化面前,局部化影響,容易修改的目的。
與之相反的是,毫無邊界控制的全局數據訪問,從而造成大面積無規則的對于實現細節的依賴。這樣的做法,在初次實現時,一定是最快速簡單的。但同時也是在變化面前最為脆弱的。
而類,在OOPL里,是不可再分割的最小模塊。而在類的內部,沒有邊界訪問控制,對于類內部的一切實現細節的訪問均是自由的。因而,在一個類內部,所有的成員變量都相當于全局變量,所有的函數,都相當于全局函數。而在類內部,沒有任何強制手段可以阻止對這些“全局變量”和“全局函數”的自由訪問。
如果一個類很小,職責單一,那么內部的高耦合所造成的影響就會很?。ㄋ^高內聚,正是要把關聯緊密的事物放在一起,從而將變化帶來的影響控制在類內部)。
但高內聚的另外一面是:只有關聯緊密的事才應該被放在一起。對于一個多重職責的上帝類,內部的各個元素之間的關聯緊密程度幾乎可以肯定是不一致的。
在這種情況下,沒有任何邊界訪問控制的類內部,就會很容易導致本不該有的高耦合。
如果還是覺得難以理解,就不妨想象一下,把整個系統都放到單個類里,這樣的設計會導致怎樣的耦合度。
六
2003年,伴隨著Eric Evans
出版了《領域驅動設計》,Martin Fowler
很快發表了一篇文章《Anemic Domain Model》(《貧血領域模型》 ),對那些只有數據,沒有有價值行為的所謂領域對象進行了強烈的批評。
這篇文章引起了社區很大的反響。但卻并沒有阻擋住社區依然在大量使用貧血模型的腳步,Service
,而不是Domain Object,被當作表達業務邏輯的核心場所。
但這也不全是這些團隊的錯。其根本原因在于,當大家嘗試使用充血模型時,發現在易于變化的業務邏輯面前,那些領域類很容易就變成了上帝類,然后隨著業務的變化不斷修改。完全無法達到局部化影響的效果。大家都是要解決問題的,不能為了充血而充血不是?
因而,很多團隊在實踐DDD
時,繼續糾結的披著面向對象的外衣,行著面向過程之實。
七
在單一類里實現所有角色所得到的上帝類,被稱作水平上帝類(或橫向上帝類)。
水平上帝類帶來的問題,除了像所有上帝類一樣,造成了不必要的高耦合之外,還會導致難以復用的問題。
現在我們定義幾個不同的class
,用來表現不同類型的人。通過這些class
,可以實例化一個個不同的對象:具體的人。
首先是A類型人的實現:
struct TypeAPerson
: Parent
, Child
, Underling
{
// 父母角色相關接口
void tellStory() {...}
void playGameWithChild() {...}
// ...
// 子女角色相關接口
void getAdviceFromParent() {...}
// ...
// 下屬角色相關接口
void acceptTask() {...}
void reportStatus() {...}
// ...
private:
// 所有角色所需的數據成員都放置于此
// ...
};
下面是B類型人的實現:
struct TypeBPerson
: Parent
, Boss
, Underling
{
// 父母角色相關接口
void tellStory() {...}
void playGameWithChild() {...}
// ...
// 老板角色相關接口
void assignTask() {...}
void motivate() {...}
// ...
// 下屬角色相關接口
void acceptTask() {...}
void reportStatus() {...}
// ...
private:
// 所有角色所需的數據成員都放置于此
// ...
};
對于TypeAPerson
和TypeBPerson
,他們都扮演了Parent
和Underling
的角色,并且這兩個角色的實現方式也完全相同。同時,他們也各自扮演了對方不具備的角色:TypeAPerson
扮演了Child
,而TypeBPerson
則扮演了Boss
。
這就造成了這兩個類之間是有部分重復代碼的。
但這的重復根本難不倒我們,將兩者重合的角色代碼提取到一個基類中即可。
struct BaseTypePerson
: Parent
, Underling
{
// 父母角色相關接口
void tellStory() {...}
void playGameWithChild() {...}
// ...
// 下屬角色相關接口
void acceptTask() {...}
void reportStatus() {...}
// ...
private:
// 其它成員:數據成員和私有函數
// ...
};
然后,讓兩個類都從此它繼承:
struct TypeAPerson
: BaseTypePerson
, Child
{
// 子女角色相關接口
void getAdviceFromParent() {...}
// ...
};
struct TypeBPerson
: BaseTypePerson
, Boss
{
// 老板角色相關接口
void assignTask() {...}
void motivate() {...}
// ...
};
到目前為止,一切都好。此時我們再增加一個新的類型,讓角色的復用關系更加復雜。如下:
class TypeCPerson
: Child
, Boss
{
// 子女角色相關接口
void getAdviceFromParent() {...}
// ...
// 老板角色相關接口
void assignTask() {...}
void motivate() {...}
// ...
private:
// 所有角色所需的數據成員都放置于此
// ...
};
此時,再想通過單根繼承來解決復用問題,將會變成一個不可能完成的任務。
八
單根繼承的最大問題在于:只能解決單個變化方向的問題,對于多個變化方向無能為力。
比如,在下面的關系中,基類Interface
存在兩個抽象函數:f
和g
。這代表兩個不同的變化方向。如果f
和g
各自存在兩種不同的實現方式,則會存在4
種不同的組合關系。

這種情況下,使用單根繼承是無法消除掉所有重復代碼的。比如,我們將f1
和f2
的重復代碼各自提取到不同的中間類F1
和F2
中,卻依然無法避免g1
和g2
的代碼重復。

由此可以看出,當存在多個變化方向時,使用單根繼承來消除重復,不僅會造成大量的僅僅為消除重復存在的中間類(比如本例子中的F1
和F2
),卻最終依然無法徹底消除重復。
因而,為了解決我們之前所述的多角色對象的重復問題,我們必須另辟蹊徑。
九
七巧板,是大家熟知的一種其源自中國的古老智力游戲。

由這么七塊簡單的小素材,可以拼出變化無窮的圖案。受限的只是你的想象力:

這個簡單的游戲,蘊含這一種極具價值的設計思想:組合。
因而,我們首先將四個角色相關的實現拆解為四個類:
struct ConcreteChild : Child
{
// 子女角色相關接口
void getAdviceFromParent() {...}
// ...
private:
// 子女角色所需的數據成員
// ...
};
struct ConcreteParent : Parent
{
// 父母角色相關接口
void tellStory() {...}
void playGameWithChild() {...}
// ...
private:
// 父母角色所需的數據成員
// ...
};
struct ConcreteBoss : Boss
{
// 老板角色相關接口
public void assignTask() {...}
public void motivate() {...}
// ...
private:
// 老板角色所需的數據成員
// ...
};
struct ConcreteUnderling : Underling
{
// 下屬角色相關接口
public void acceptTask() {...}
public void reportStatus() {...}
// ...
private:
// 下屬角色所需的數據成員
// ...
};
現在我們有了這四個“零件”,下一個問題就是如何把它們組合成我們最終所需的TypeAPerson
,TypeBPerson
和TypeCPerson
。
在C++
下,對于這類問題,最好的組合方式是多重繼承:
struct TypeAPerson
: ConcreteParent
, ConcreteChild
, ConcreteUnderling
{
};
struct TypeBPerson
: ConcreteParent
, ConcreteUnderling
, ConcreteBoss
{
};
struct TypeCPerson
: ConcreteChild
, ConcreteBoss
{
};
一旦轉為組合的設計方式,其應對變化的能力將得到極大的增強。
比如,TypeAPerson
隨后便為管理者,則只需要簡單的組合一個新的角色:ConcreteBoss
:
struct TypeAPerson
: ConcreteParent
, ConcreteChild
, ConcreteUnderling
, ConcreteBoss // 新增角色
{
};
而TypeBPerson
退休,不再從事職場工作,則只需要將職場相關兩個角色刪除:
struct TypeBPerson
: ConcreteParent
// , ConcreteUnderling // 刪除角色
// , ConcreteBoss // 刪除角色
{
};
另外,雖然TypeAPerson
和TypeBPerson
都扮演了Parent
的角色,但隨后TypeBPerson
的Parent
角色的實現方式發生了變化,那么我們只需要增加一個新的Parent
實現:
struct ConcreteParent2 : Parent
{
// 另一種父母角色相關接口實現
void tellStory() {...}
void playGameWithChild() {...}
};
然后把TypeBPerson
的Parent
角色替換為新的實現:
struct TypeBPerson
: ConcreteParent2
{
};
這種關系,正如下圖所示:

所有那些角色的實現,正如七巧板的那些小組件一樣,作為素材庫,每一個對象的設計者只需要首先查看素材庫,看里面是否有自己所需的角色實現。如果存在,則通過簡單的組合方式來復用。如果不存在,則編寫自己針對某個角色的特定實現,除了自己使用之外,也變成了素材庫的一部分。
十
面向對象方法學,在最初被創造出來時,更多的是希望通過用對象來模擬現實世界的問題域,從而讓軟件更容易理解。
但是,遵從這個思路去使用面向對象,容易得到上帝對象。這種現象的背后,反映了這種方法論的本質缺陷——它沒有觸及軟件設計的真正挑戰:軟件設計如何才能讓軟件在需求變化面前容易變更。
我們現在知道,為了讓軟件能更容易的應對變化,則必須遵從高內聚低耦合原則,以封裝和隔離變化。但這樣會必然會導致一堆單一職責的小類。而這些類并非一定存在于領域的直接概念上,往往是為了設計的靈活性而由設計師創造出來的。而這些小類最后則實例化為諸多的小對象,而這些小對象,當然也不是能夠直接映射到現實問題領域的。而它們的種類事實上要遠多于領域概念中的對象數量。
所以,這兩種哲學的矛盾,一直無法讓OO
發揮其應有的威力:前者更容易理解,但得到的軟件更難修改;后者更靈活,卻模糊了現實與領域的映射。
之所以產生這種矛盾的原因是:很多人把class
與object
看作對等的的東西。class
無非是用來實例化object
的模版。
但事實上,類與對象是完全兩種不同的事物——
類的作用,是為了模塊化,我們應該遵從高內聚低耦合的原則去劃分類,那怕由此產生了遠超領域實體概念數量的類,也無妨。讓軟件容易應對變化,是我們無論采取何種方法論都應該遵從的原則。
而對象,是我們運行時承載了數據和行為的實體:它的種類和數量應該與領域的真實概念存在清晰、明確、直接的映射。
因而,類應該是小的,對象應該是大的。上帝類是糟糕的,但上帝對象卻恰恰是我們所期盼的。
而從類到對象,是一種多對一的關系:最終一個對象模版是由諸多單一職責的小類——它們分別都可以有自己的數據和行為——所構成。
而將類映射到對象的過程,在Ruby
中的Mixin
;在Scala
中則通過Traits
;而C++
則通過多重繼承。
因而,自Scala
以來,諸多新設計的語言都開始包含Trait
這個語法特性。但是其中一些完全沒理解Trait
的真正價值,不允許Trait
包含數據,因而它們也失去了發揮更強大威力的潛力。
零
2011
年,我們在一個電信項目的重構和開發過程中,發現在一個概念上不可分割的領域對象上,其過多的變化方向上導致了大規模重復代碼,從而導致代碼極難理解和維護。而如果將其切分為很多小對象,可以將重復消除掉,但卻會導致對于諸多小對象的管理問題,以及大量的內存浪費,當時那個項目內存優化也是一種重要的目標。
這逼迫我們對OO
進行了更深入的思考,最終明確了小類,大對象的概念,也開始真正發揮OO
的威力。由此,在那個項目上,我們得到了兩全其美的解決方案:不僅大大增強了系統的可理解性和可維護性,也大幅降低內存占用(內存節省了70%)。并且作為一種通用方法,在隨后的項目中不斷發揮其威力。
總而言之,通過將類和對象看作不同事物,現代OO
方法學漂亮的解決了設計中最重要的兩個問題(見《簡單設計》):
- 類作為一種模塊化手段,遵循高內聚,低耦合,讓軟件易于應對變化;讓貧血模型和充血模型不再成為一個兩難選擇;
- 對象作為一種領域對象的的直接映射,解決了過多的類帶來的可理解性問題,讓領域可以指導設計,設計真正反映領域,而這才是領域驅動設計的真正目的和精髓。
自此,已經沒有人可以阻擋我們深信OO
是一種非常有效的分析和設計方法論了。