——“面向對象的三大特性是什麼?”
——“封裝、繼承、多態。”
這大概是最容易回答的面試題了。但是,封裝、繼承、多態到底是什麼?它們在面向對象中起到了什麼樣的作用呢?
多態
多態(Polymorphic)其實也是一個顧名思義的詞:“多態”就是一種事物的“多”種形“態”。
“多態”這個概念在面向對象中同樣有多種形態:類的多態、方法的多態、以及實例的多態。
類的多態
類的多態最常見、也最好理解。子類繼承了父類(包括實現某個接口)後,父類就有了多種形態:它既可以是父類本身,也可以是它的子類A,還可以是它的子類B、甚至可以是子類A的子類A1……在《繼承》一文中,這種多態的例子比比皆是,這裏就不贅述了。
方法的多態
絕大多數情況下,子類繼承了父類之後都會重寫父類的方法實現。這時,同一個方法就有了多種不同的實現,這就是一種方法的多態。當然,除了重寫之外,還有一種方法的多態叫做“重載”,即通過修改方法的參數列表(參數類型、參數個數等)來爲同一個方法提供多種實現。
不過我個人不太喜歡重載,也不太願意把歸入多態之中。Java以方法名+參數列表作爲一個方法的“簽名”,而重載修改了參數列表,也就修改了方法簽名。這時,重載後的這些方法還能稱爲“同一個方法”的不同實現嗎?此外,Java會在編譯期就確定使用哪個重載方法,但只有到了運行時才能知道使用的是哪個類重寫的方法。也就是說,重載並不具備多態所應有的“在運行時改變程序功能”的作用。套用繼承章節中的一句話來說:如果重載甚至不能“like-a”多態,那還能說它“is-a”多態嗎?
例如,在下面這段代碼中,雖然i_am_a_set實際上是一個Set,並且test(Set<String>)方法也能更精確地匹配到它,但是調用test(i_am_a_set)時,還是執行了test(Collection<String>)方法。
public class OverLoadTest{
private static void test(Set<String> set){
System.out.println("set:"+set);
}
private static void test(Collection<String> collection){
System.out.println("collection:"+collection);
}
public static void main(String[] args){
Collection<String> i_am_a_set = new HashSet<>();
// 這裏還是會調用test(Collection<String>)方法
test(i_am_a_set);
}
}
當然,重載不算多態只是我的一家之言,姑妄言之姑妄聽之吧。
實例的多態
實例的多態同樣是一家之言:同一個類擁有多個實例,並且每個實例中的數據各不相同,那麼這就是一種實例的多態。考慮到“對象”不僅指編譯期間的靜態的類、也包括了運行期間的動態的實例,其實實例的多態和類的多態一樣,也是同一個對象的多種形態。從這個角度來看,實例的多態也可以理解爲一種更廣義的多態。
這麼一看,我們每次new一個對象、並對它設置不同的數據,都是一種實例的多態。但是實例的多態還有更強大的作用。雖然“絕大多數情況下,子類繼承了父類之後都會重寫父類的方法實現”,但是在個別情況下,子類並不重寫父類的方法,而只是給父類中的某些字段設置一些不一樣的值。
例如在下面的例子中,爲了讓開發和QA看到不同的儀表盤,我們創建了兩個不同的子類DashBoardServcie4Dev和DashBoardServcie4Qa。它們並沒有重寫父類ServiceDashBoardAsChain的任何方法,只是通過構造函數爲父類字段list設置了不同的值:
class ServiceDashBoardAsChain implements DashBoardServcie{
protected List<DashBoardServcie> list;
@Override
public void fill(DashBoard dashBoard){
list.foreach(s->s.fill(dashBoard));
}
}
@Service
class DashBoardServcie4Dev extends ServiceDashBoardAsChain {
public DashBoardServcie4Dev(@Autowired DashBoardServcie service4Cpu,
@Autowired DashBoardServcie service4Memory){
super();
list = Arrays.asList(service4Cpu,service4Memory);
}
}
@Service
class DashBoardServcie4Qa extends ServiceDashBoardAsChain {
public DashBoardServcie4Qa(@Autowired DashBoardServcie service4Cover,
@Autowired DashBoardServcie service4Tests,
@Autowired DashBoardServcie service4Sonar){
super();
list = Arrays.asList(service4Tests,service4Cover,
service4Sonar);
}
}
把對象單純的理解爲編譯期間的靜態的類、而把運行期間的動態的實例拋諸腦後,是出現這種類的主要原因。把這種類的多態轉化成實例的多態,既能減少冗餘的類定義、降低類爆炸的風險,又能提高程序功能的靈活性和擴展性:
class ServiceDashBoardAsChain implements DashBoardServcie{
@Setter
private DashBoardServcie list;
@Override
public void fill(DashBoard dashBoard){
list.foreach(s->s.fill(dashBoard));
}
}
/** 有了這一個類,就可以生成DashBoardServcie4Dev、DashBoardServcie4QA、
* DashBoardServcie4Ops、DashBoardServcie4Pm等多個類所需的實例。
* 用戶還能根據自己的關注點來自定義不同的面板,更加靈活。* */
@Component
class DashBoardServcieFactory{
/** 單獨注入。其中:
* CPU --> service4Cpu
* MEMORY --> service4Memory
* TEST --> service4Tests
* COVER --> service4Cover
* SONAR --> service4Sonar
* */
@Resource
private Map<DashBoardType, DashBoardServcie> dashBoardMap;
public DashBoardServcie build(DashBoardType... types){
ServiceDashBoardAsChain chain = new ServiceDashBoardAsChain();
chain.setList(Stream.of(types)
.map(dashBoardMap::get)
.collect(Collector.toList()));
return chain;
}
}
顯然,實例的多態都可以轉化爲類的多態;再把重載排除在多態之外的話,出現了方法的多態就一定會出現類的多態。所以,我們後面的討論都圍繞類的多態展開。
多態與面向對象
與封裝、繼承不同,多態不是構成面向對象的要素,而是面向對象自身的特徵。如果說封裝是父親、繼承是母親、面向對象是他們的孩子,那麼多態就是這個孩子身上無窮的生命力和無盡的可能性。
面向對象模擬着現實世界的類型體系創建了對象體系,這套對象體系也就與類型體系有着相同之處。現實世界中的“多態”俯拾皆是,我們所熟知的“生物多樣性”就是一個絕佳的例子。
地球上的生物自誕生之初,就呈現出千人千面的形態。在寒武紀生命大爆炸中出現的三葉蟲,就有諸如萊德利基蟲、球接子、褶頰蟲、鏡眼蟲、小油櫛蟲,大衛奇異蟲、齒肋蟲、裂肋蟲等不同種類;植物登上陸地之後,在石炭紀就演化出了石松類、節蕨植物、真蕨類、種子蕨和裸子植物的參天綠意;時至今日,哪怕是小小的加拉帕戈斯雀,也因身體大小、鳥喙形態的不同而分了十幾種之多。生命就是以這樣的千姿百態,適應了地球上千奇百怪的環境,佔據了從赤道到兩極、從天空到深淵、從雨林到沙漠的每一個角落;更是在歷經五次大滅絕之後,從(最殘酷時)殘存不到5%的物種開始,復甦、生髮、壯大,最終形成了現在這個千嬌百媚的世界。
如果用計算機的語言來描述,“地球Online”的這套生態系統,就是靠着生物物種的“多態”特性,滿足了形形色色的“產品需求”。即使是在五次刪庫跑路之後,藉助着“多態”特性,這個系統也能在殘存不到5%的代碼和數據的基礎上,演化出了今天這套“地球Online 6.0”:不僅仍能滿足所有環境和生態位的需求,更是開發出了智能生物,不得不令人嘖嘖稱奇。
面向對象的對象體系在通過繼承來模擬現實中的類型體系的時候,也毫不客氣地把多態特性“順手牽羊”了。實際上,只要有了繼承、只要允許多個子類繼承同一個父類,那麼就自然而然地有了多態特性。由於有了多態特性,對象體系也擁有了“演化”能力,也就是在保持系統和抽象基本穩定的前提下,引入新的功能、結構以滿足新的需求的能力。而這種演化能力,正是系統爲滿足不同的業務需求而必須具備的生命力和可能性。
例如下圖就是我們系統中某個功能模塊的“演化”過程。
最初,這個模塊只提供一種功能,只需要ServiceA這一個類就足夠了。後來,我們需要在原有功能的基礎上增加一項新的功能。新功能與原功能大同小異,而且原功能還要保留——調用ServiceA的地方非常多,無論開發還是測試,都不能保證覆蓋到每一個改動點。這時,多態特性就發揮了作用:我們在ServiceA的基礎上擴展出了一個子類ServiceB。所有沿用原功能、調用了ServiceA的地方無需做任何改動;所有需要使用新功能的地方調用ServiceB即可。然而,隨着對業務的深入理解,我們發現ServiceA和ServiceB看似一脈相承、實則南轅北轍。這一點在隨後的需求中就體現了出來:我們需要從ServiceB的功能中細分出一種更新的功能;它與ServiceB還是異曲同工,但與ServiceA已相去甚遠。爲了更好的描述對應這些功能的類之間的關係,我們通過抽取出BaseService類,把ServiceA和ServiceB由父子關係轉變爲兄弟關係。同時再次藉助繼承與多態,在ServiceB的基礎上擴展出子類ServiceC,用以滿足新的業務功能。
人們常說:系統架構不是設計出來的、而是演化出來的。然而,如果沒有多態特性,我們的系統只能一次又一次地“重做”,根本無法演化。
多態與抽象
從抽象的角度來看,多態是什麼呢?首先,抽象的作用在於“隱藏細節”。多態就是它要隱藏的一種細節。例如,我們系統需要根據用戶的位置信息定位到用戶所在的省、市、區縣。顯然的,這個功能可以抽象爲這樣一個LocationService接口:
public interface LocationServcie{
/**
* 根據Location中的經緯度或者ip地址,定位City中的省、市、區縣代碼及名稱。
*
* @param loc 經度和維度要麼都有值、要麼都沒有值;不能一個有值、一個沒有值。
* 經緯度和IP地址至少有一個有值。
* @return 如果定位成功,將返回省、市、區縣三級代碼及相應的名稱。
* 三級代碼保證非空;如果是在直轄市,三級代碼相同;如果在市區,市、區縣代碼相同。
* 如果定位失敗,將返回null。
* */
City locate(Location loc);
}
不過,具體要怎樣定位呢?如果用戶授權我們使用定位信息,那麼我們就可以根據經緯度信息,調用某地圖API來查到地址;然後將地址轉換爲所需的代碼。如果某地圖API出了問題——無論是服務自身還是運營商網絡出了問題——我們都可以更換另一家地圖API來查詢地址。如果所有地圖API都調用失敗、或者用戶壓根就不讓我們使用定位信息,我們還可以使用IP地址進行定位——儘管IP定位並不準確,但是些許聊勝無,至少它可以“儘可能”地保證業務繼續處理下去:
但是,對調用方來說,它只需要知道LocationService接口的相關約束:入參要傳入哪些值、出參會返回哪些值,這就夠了。至於這個模塊到底是通過經緯度定位的、還是通過IP地址定位的呢?這個模塊到底是用哪一家的地圖API定位的呢?調用方不需要知道。這就像我們去銀行取錢時,只要輸入正確的密碼、能拿到所需的鈔票就可以了。至於櫃檯後面坐着的是男是女、是老是少、是機器人還是外星人,這不重要。
我們還可以換一個角度來看多態與抽象:多態不僅僅是抽象內部的細節,同時也是實現細節的“最佳實踐”。我們不妨設想一下:如果面向對象不支持多態——例如,一個接口只能有一個實現類、一個抽象類只能有一個子類、不允許非抽象類擁有子類,我們要怎樣實現一個接口呢?
仍以上面的定位功能爲例。如果LocationService接口下只允許有一個實現類,那麼,爲了提供ByMapApiXxx、ByMapApiYyy和ByIp這三種服務,這個碩果僅存的實現類只會有兩種可能的編碼方式:要麼,它對外暴露三個方法、分別提供三種服務,由調用方自己選擇和處理方法調用邏輯;要麼,它仍然只提供一個方法,但是方法內部用if-else等方式把三種調用邏輯“一網打盡”。
我們網上購物湊優惠時常常遇到極其複雜的規則:又要組戰隊、又要每日簽到、又要分享集贊……不僅又囉嗦又麻煩,而且稍不留神就會算錯折扣;好不容易算好了賬,活動方一個規則補丁,又要全部從頭再來。跟直接撒幣發紅包相比,這種所謂的“優惠”實在是費時費力又不討好。
如果接口使用三個方法來提供三種服務——就像下面這段代碼這樣,那就跟這種毫無誠意的優惠活動差不多:本來簡單明瞭的一件事情,由於接口把內部細節都暴露了出來,使得調用方代碼又囉嗦又容易重複,並且調用方很容易對接口邏輯產生誤解和誤用。不僅如此,接口方法一旦發生變化——尤其是新增或者下線一個定位服務——那麼所有的調用方都要修改代碼。這種“發散變化”是任何一個開發人員都不能接受的。
/** 接口和實現類定義了三個不同的方法 */
public class LcationServiceImpl implements LocationService{
public City locateByApiXxx(Location loc){...}
public City locateByApiYyy(Location loc){...}
public City locateByIp(Location loc){...}
}
public class CityService{
public void userCity(UserInfo user, Location loc){
/* 調用方使用時就要這樣寫代碼 */
City city = locationServcie.locateByApiXxx(loc);
if(city == null){
city = locationServcie.locateByApiYyy(loc);
}
if(city == null){
city = locationService.locateByIp(loc);
}
if(city != null){
// 略
}
}
}
那麼,在一個方法內用if-else等方式把多種服務“一網打盡”呢?相比提供多個方法,這種方式的確可以更好地保持接口的抽象性和穩定性。但是,這種方式就像是使用了二向箔一樣:它抹平了抽象的層級,把本可以逐層分解的業務複雜性全部堆疊到一層,人爲地推高了代碼複雜性,不僅把代碼變得難以維護,而且把原本簡單的業務也變成了水中花、霧中月,捉摸不透、脆弱不堪。
在我們某個系統中,所有的業務功能都是通過if-else來區分處理的。當if-else累積到一定程度之後,出現了一件非常詭異的事情:所有人都說這個系統的業務邏輯很簡單;但所有人都說不出系統中的業務邏輯是怎樣的——哪怕只是一個產品、一種用戶的完整業務都說不出來。爲什麼?因爲這些流程全都散落在系統的if-else裏:這個if裏有一段、那個else裏有一段;這種產品跟那種產品的邏輯糾纏在一起,這類用戶和那類用戶的流程混合在一起。要想把它們挑揀出來、拼湊完整,簡直比從肯德基全家桶裏拼湊出一隻完整的小公雞還要困難。
這還只是問題的開始。由於沒有人能說清楚完整的業務邏輯,所以每當產品提出新需求的時候,也就沒有人能說清楚到底要怎麼改,只能通過“扒代碼”來估計改動範圍和開發工作量。但是,就如用歸納法永遠也找不出真理一樣,“扒代碼”永遠也無法明確地告訴你“改動範圍就這麼大”、“工作量就這麼點”。事實上,在開發過程中發現新的改動點、在測試時發現其它功能受到影響,對這個系統來說是家常便飯;相應的,延期、加班、線上bug……也就紛至沓來了。
如果使用多態呢?我曾經用多態的方式,重構過一個類似的系統。重構完成之後,只用一張表格就可以把完整的業務流程、以及不同產品不同用戶所做的特殊操作全部展示出來。在這張表格中,一個新的需求要改什麼、加什麼、刪什麼,全都一目瞭然;改動範圍和工作量也都變得清晰明確了。延期?不存在;加班?沒必要;bug?我們有槍手——“槍手,走遍天下,蚊蟲無憂”哈哈哈。
爲什麼使用了多態就能達到這樣的效果呢?因爲多態能夠充分利用抽象的層級特性,從而把紛繁複雜的實現細節分散在不同的抽象層級中。通過這樣的層層分解,我們一定能找到這樣兩個抽象層級:一個既能夠完整的描述業務流程,又不會陷入底層細節中、繞行“山路十八彎”後仍然“雲深不知處”;另一個則把某一類業務的細節描述得纖毫畢現,但對其它類型的業務則“事不關己高高掛起”。
有了第一個抽象層級,我們對業務流程就有了一個清晰而明確的總體認識,對產品需求有哪些改動點、有多少工作量、有哪些潛在風險,自然也就一目瞭然了。這就像遇到了張鬆的劉備一樣,掌握了蜀中的道路、地形、佈防、民情等整體情報後,對如何施行“跨有荊益”這一戰略、入蜀要途徑哪些城池關隘、哪裏可以募兵哪裏可以籌糧等問題自然也就胸有成竹了。有了這樣清晰的戰略部署,成都還不是手到擒來。
而有了第二個抽象層級,我們的業務流程和代碼模塊就可以充分地解耦合,從而降低彼此之間的牽制和掣肘,從而減少不必要的bug和開發測試工作量。
多態與高內聚低耦合
無論使用多態還是if-else,都可以把抽象下的多種服務聚合到同一個模塊內。但是,多態所提供的低耦合是其它任何方式都無法比擬的。
例如,在我們的系統中,有這樣一段代碼:
public class AuditServiceImpl implements AuditService{
@Override
public void audit(Apply apply){
// 一堆公共邏輯
// 然後根據產品類型做不同的必填項校驗
if(apply.getProduct() == ProductA) {
// 執行ProductA對應的校驗,略
}else if(apply.getProduct() == ProductB)){
// 執行ProductB對應的校驗,略
}else{
// 執行ProductC對應的校驗,略
}
// 又一堆公共邏輯
// 又根據產品類型組裝不同的數據
if(apply.getProduct() == ProductB) {
// 略
}// else-if,略
// 還有一堆公共邏輯
// 再次根據產品類型按不同的邏輯處理返回數據
if(apply.getProduct() == ProductC) {
// 略
}// else-if,略
}
}
這好像是我們寫業務代碼時最常見的方式:一開始只有ProductA;然後業務上增加了大同小異的ProductB,於是代碼中也在差異化的地方加上if(ProductB);接着又有了ProudctC/ProductD/ProudctE,於是代碼中這個地方加一個if(ProductC),那個地方加一個if(ProductD || ProductE),久而久之,代碼就變成了上面這個樣子。我們經常吐槽說自己系統裏的代碼是“Shit Hill”,其實很多時候,“Shit Hill”就是這麼來的。
“Shit Hill”的問題可謂罄竹難書,模塊間的強耦合就是罪魁禍首之一:從ProductA到ProductE,相關的功能代碼全都雜糅在一起,使得這幾個本應相互獨立的產品和業務之間產生了耦合性最強、也最另令人深惡痛絕的內容耦合。
Content coupling is said to occur when one module uses the code of other module, for instance a branch. This violates information hiding - a basic design concept.內容耦合是指一個模塊直接使用另一個模塊的代碼。這種耦合違反了信息隱藏這一基本的設計概念。
花園的景昕,公衆號:景昕的花園細說幾種耦合
內容耦合使得我們在爲一個產品修改代碼的時候,總會感到“戰戰兢兢,如履薄冰,如臨深淵”,因爲誰也不知道自己改的代碼會不會影響到其它產品的業務。我就曾經在這樣一個方法的第十幾行處加了一行代碼;沒想到在一百多行開外,這行代碼引發了另一個bug。
要怎樣化解這些問題呢?多態就是一種非常好的方案。例如,我們可以用多態把上面這段代碼改寫成這樣:
abstract class AuditServcieAsSkeleton implements AuditService{
@Override
public void audit(Apply apply){
// 一堆公共邏輯
// 校驗入參
doValid(apply);
// 又一堆公共邏輯
// 構建請求數據
Request request = buildRequst(apply);
// 再來一堆公共邏輯
// 處理返回數據
dealResponse(response);
}
protected abstract void doValid(Apply apply);
protected abstract Request buildRequest(Apply apply);
protected abstract void dealResopsne(Response resp);
}
class AuditServiceAsDispatcher implements AuditService{
private Map<ProductType, AuditService> dispatcher;
@Override
public void audit(Apply apply){
dispatcher.get(apply.getProductType())
.audit(apply);
}
}
class AuditService4ProductA extends AuditServiceAsSkeleton{
@Override
protected void doValid(Apply apply){
// 產品A的校驗邏輯
}
@Override
protected Request buildRequest(Apply apply){
// 組裝產品A所需的請求數據
}
@Override
protected void dealResopsne(Response resp){
// 按產品A的邏輯處理返回結果
}
}
class AuditService4ProductB extends AuditServiceAsSkeleton{
// 按產品B的需求處理;略。// 產品C、D、E的類也略。
}
藉助多態方案,ProductA/B/C/D/E的相關代碼被分散到完全獨立的幾個類中,從而把產品功能之間的內容耦合降低爲特徵耦合甚至數據耦合。這樣,無論是哪個產品要修改自己的功能、或者我們要再新增一套新的產品,都可以做到與其它產品毫無瓜葛;從而做到“代碼耦合少,bug遠離我”。
Stamp coupling occurs when modules share a composite data structure and use only parts of it, possibly different parts .特徵耦合是指多個模塊共享一個數據結構、但是隻使用了這個數據結構的一部分——可能各自使用了不同的部分。
花園的景昕,公衆號:景昕的花園細說幾種耦合
Data coupling occurs when modules share data through, for example, parameters. Each datum is an elementary piece, and these are the only data shared (e.g., passing an integer to a function that computes a square root).數據耦合是指模塊間通過傳遞數值來共享數據。傳遞的每個值都是基本數據,而且傳遞的值是就是要共享的值。
花園的景昕,公衆號:景昕的花園細說幾種耦合
不過,使用多態就難免要使用繼承;因而也難免會遇到困擾繼承的子類耦合。但這並不是多態帶來的問題,而是使用繼承所需要特別注意的。