盡管大家都認(rèn)為SOLID是非常重要的設(shè)計(jì)原則,并且對(duì)每一條原則都耳熟能詳娄蔼,但我發(fā)現(xiàn)大部分開(kāi)發(fā)者并沒(méi)有真正理解怖喻。要獲得最大收益,就必須理解它們之間的關(guān)系岁诉,并綜合應(yīng)用所有這些原則锚沸。只有把SOLID作為一個(gè)整體,才可能構(gòu)建出堅(jiān)實(shí)(Solid)的軟件涕癣。遺憾的是哗蜈,我們看到的書(shū)籍和文章都在羅列每個(gè)原則,沒(méi)有把它們作為一個(gè)整體來(lái)看坠韩,甚至提出SOLID原則的Bob大叔也沒(méi)能講透徹距潘。因此我嘗試介紹一下我的理解。
先拋出我的觀點(diǎn): 單一職責(zé)是所有設(shè)計(jì)原則的基礎(chǔ)只搁,開(kāi)閉原則是設(shè)計(jì)的終極目標(biāo)音比。里氏替換原則強(qiáng)調(diào)的是子類(lèi)替換父類(lèi)后程序運(yùn)行時(shí)的正確性,它用來(lái)幫助實(shí)現(xiàn)開(kāi)閉原則氢惋。而接口隔離原則用來(lái)幫助實(shí)現(xiàn)里氏替換原則洞翩,同時(shí)它也體現(xiàn)了單一職責(zé)。依賴倒置原則是過(guò)程式編程與OO編程的分水嶺焰望,同時(shí)它也被用來(lái)指導(dǎo)接口隔離原則骚亿。關(guān)系如下圖:
單一職責(zé)原則(Single Responsibility Principle)
單一職責(zé)是最容易理解的設(shè)計(jì)原則,但也是被違反得最多的設(shè)計(jì)原則之一熊赖。
要真正理解并正確運(yùn)用單一職責(zé)原則循未,并沒(méi)有那么容易。單一職責(zé)就跟“鹽少許”一樣,不好把握的妖。Robert C. Martin(又名“Bob大叔”)把職責(zé)定義為變化原因绣檬,將單一職責(zé)描述為 ”A class should have only one reason to change." 也就是說(shuō),如果有多種變化原因?qū)е乱粋€(gè)類(lèi)要修改嫂粟,那么這個(gè)類(lèi)就違反了單一職責(zé)原則娇未。那么問(wèn)題來(lái)了,什么是“變化原因”呢星虹?
利益相關(guān)者角色是一個(gè)重要的變化原因零抬,不同的角色會(huì)有不同的需求,從而產(chǎn)生不同的變化原因宽涌。作為居民平夜,家用的電線是普通的220V電線,而對(duì)電網(wǎng)建設(shè)者卸亮,使用的是高壓電線忽妒。用一個(gè)Wire類(lèi)同時(shí)服務(wù)于兩類(lèi)角色,通常意味著壞味道兼贸。
變更頻率是另一個(gè)值得考慮的變化原因段直。即使對(duì)同一類(lèi)角色,需求變更的頻率也會(huì)存在差異溶诞。最典型的例子是業(yè)務(wù)處理的需求比較穩(wěn)定鸯檬,而業(yè)務(wù)展示的需求更容易發(fā)生變更,畢竟人總是喜新厭舊的螺垢。因此這兩類(lèi)需求通常要在不同的類(lèi)中實(shí)現(xiàn)喧务。
單一職責(zé)原則某種程度上說(shuō)是在分離關(guān)注點(diǎn)。分離不同角色的關(guān)注點(diǎn)枉圃,分離不同時(shí)間的關(guān)注點(diǎn)功茴。
在實(shí)踐中,怎么運(yùn)用單一職責(zé)原則呢讯蒲?什么時(shí)候要拆分痊土,什么時(shí)候要合并?我們看看新廚師在學(xué)炒菜時(shí)墨林,是如何掌握“鹽少許”的赁酝。他會(huì)不斷地品嘗,直到味道剛好為止旭等。寫(xiě)代碼也一樣酌呆,你需要識(shí)別需求變化的信號(hào),不斷“品嘗”你的代碼搔耕,當(dāng)“味道”不夠好時(shí)隙袁,持續(xù)重構(gòu)痰娱,直到“味道”剛剛好。
開(kāi)閉原則(Open-closed Principle)
開(kāi)閉原則指軟件實(shí)體(類(lèi)菩收、模塊等)應(yīng)當(dāng)對(duì)擴(kuò)展開(kāi)放梨睁,對(duì)修改閉合。這聽(tīng)起來(lái)似乎很不合理娜饵,不能修改坡贺,只能擴(kuò)展?那我怎么寫(xiě)代碼箱舞?
我們先看看為什么要有開(kāi)閉原則遍坟。假設(shè)你是一名成功的開(kāi)源類(lèi)庫(kù)作者,很多開(kāi)發(fā)者使用你的類(lèi)庫(kù)晴股。如果某天你要擴(kuò)展功能愿伴,只能通過(guò)修改某些代碼完成,結(jié)果導(dǎo)致類(lèi)庫(kù)的使用者都需要修改代碼电湘。更可怕的是隔节,他們被迫修改了代碼后,又可能造成別的依賴者也被迫修改代碼胡桨。這種場(chǎng)景絕對(duì)是一場(chǎng)災(zāi)難官帘。
如果你的設(shè)計(jì)是滿足開(kāi)閉原則的瞬雹,那就完全是另一種場(chǎng)景昧谊。你可以通過(guò)擴(kuò)展,而不是修改來(lái)改變軟件的行為酗捌,將對(duì)依賴方的影響降到最低呢诬。
這不正是設(shè)計(jì)的終極目標(biāo)嗎?解耦胖缤、高內(nèi)聚尚镰、低耦合等等設(shè)計(jì)原則最終不都是為了這個(gè)目標(biāo)嗎?暢想一下哪廓,類(lèi)狗唉、模塊、服務(wù)都不需要修改涡真,而是通過(guò)擴(kuò)展就能夠改變其行為分俯。就像計(jì)算機(jī)一樣,組件可以輕松擴(kuò)展哆料。硬盤(pán)太懈准簟?直接換個(gè)大的东亦,顯示器不夠大的杏节?來(lái)個(gè)8K的怎么樣?
什么時(shí)候應(yīng)該應(yīng)用開(kāi)閉原則,怎么做到呢奋渔?沒(méi)有人能夠在一開(kāi)始就識(shí)別出所有擴(kuò)展點(diǎn)镊逝,也不可能在所有地方都預(yù)留出擴(kuò)展點(diǎn),這么做的成本是不可接受的嫉鲸。因此一定是由需求變化驅(qū)動(dòng)蹋半。如果你有領(lǐng)域?qū)<业闹С郑梢詭湍阕R(shí)別出變化點(diǎn)充坑。否則减江,你應(yīng)該在變化發(fā)生時(shí)來(lái)做決策,因?yàn)樵跊](méi)有任何依據(jù)時(shí)做過(guò)多預(yù)先設(shè)計(jì)違反了Yagni捻爷。
實(shí)現(xiàn)開(kāi)閉原則的關(guān)鍵是抽象辈灼。在Bertrand Meyer提出開(kāi)閉原則的年代(上世紀(jì)80年代),在類(lèi)庫(kù)中增加屬性或方法也榄,都不可避免地要修改依賴此類(lèi)庫(kù)的代碼巡莹。這顯然導(dǎo)致軟件很難維護(hù),因此他強(qiáng)調(diào)的是要允許通過(guò)繼承來(lái)擴(kuò)展類(lèi)甜紫。隨著技術(shù)發(fā)展降宅,我們有了更多的方法來(lái)實(shí)現(xiàn)開(kāi)閉原則,包括接口囚霸、抽象類(lèi)腰根、策略模式等。
我們也許永遠(yuǎn)都無(wú)法完全做到開(kāi)閉原則拓型,但不妨礙它是設(shè)計(jì)的終極目標(biāo)额嘿。SOLID的其它原則都直接或間接為開(kāi)閉原則服務(wù),例如接下來(lái)要介紹的里氏替換原則劣挫。
里氏替換原則 (The Liskov Substitution Principle)
里氏替換原則說(shuō)的是派生類(lèi)(子類(lèi))對(duì)象能夠替換其基類(lèi)(父類(lèi))對(duì)象被使用册养。學(xué)過(guò)OO的同學(xué)都知道,子類(lèi)本來(lái)就可以替換父類(lèi)压固,為什么還要里氏替換原則呢球拦?這里強(qiáng)調(diào)的不是編譯錯(cuò)誤,而是程序運(yùn)行時(shí)的正確性帐我。
程序運(yùn)行的正確性通晨擦叮可以分為兩類(lèi)。一類(lèi)是不能出現(xiàn)運(yùn)行時(shí)異常焚刚,最典型的是UnsupportedOperationException点弯,也就是子類(lèi)不支持父類(lèi)的方法。第二類(lèi)是業(yè)務(wù)的正確性矿咕,這取決于業(yè)務(wù)上下文抢肛。
下例中狼钮,由于java.sql.Date不支持父類(lèi)的toInstance方法,當(dāng)父類(lèi)被它替換時(shí)捡絮,程序無(wú)法正常運(yùn)行熬芜,破壞了父類(lèi)與調(diào)用方的契約,因此違反了里氏替換原則福稳。
package java.sql;
public class Date extends java.util.Date {
@Override
public Instant toInstant() {
throw new java.lang.UnsupportedOperationException();
}
}
接下來(lái)我們看破壞業(yè)務(wù)正確性的例子涎拉,最典型的例子就是Bob大叔在《敏捷軟件開(kāi)發(fā):原則、模式與實(shí)踐》中講到的正方形繼承矩形的例子了的圆。從一般意義來(lái)看鼓拧,正方形是一種矩形,但這種繼承關(guān)系破壞了業(yè)務(wù)的正確性越妈。
public class Rectangle {
double width;
double height;
public double area() {
return width * height;
}
}
public class Square extends Rectangle {
public void setWidth(double width) {
this.width = width;
this.height = width;
}
public void setHeight(double height) {
this.height = width;
this.width = width;
}
}
public void testArea(Rectangle r) {
r.setWidth(5);
r.setHeight(4);
assert(r.area() == 20); //! 如果r是一個(gè)正方形季俩,則面積為16
}
代碼中testArea方法的參數(shù)如果是正方形,則面積是16梅掠,而不是期望的20酌住,所以結(jié)果顯然不正確了。
如果你的設(shè)計(jì)滿足里氏替換原則阎抒,那么子類(lèi)(或接口的實(shí)現(xiàn)類(lèi))就可以保證正確性的前提下替換父類(lèi)(或接口)酪我,改變系統(tǒng)的行為,從而實(shí)現(xiàn)擴(kuò)展且叁。BranchByAbstraction和絞殺者模式 都是基于里氏替換原則都哭,實(shí)現(xiàn)系統(tǒng)擴(kuò)展和演進(jìn)。這也就是對(duì)修改封閉谴古,對(duì)擴(kuò)展開(kāi)放质涛,因此里氏替換原則是實(shí)現(xiàn)開(kāi)閉原則的一種解決方案稠歉。
而為了達(dá)成里氏替換原則掰担,你需要接口隔離原則。
接口隔離原則 (Interface Segregation Principle)
接口隔離原則說(shuō)的是客戶端不應(yīng)該被迫依賴于它不使用的方法怒炸。簡(jiǎn)單來(lái)說(shuō)就是更小和更具體的瘦接口比龐大臃腫的胖接口好带饱。
胖接口的職責(zé)過(guò)多,很容易違反單一職責(zé)原則阅羹,也會(huì)導(dǎo)致實(shí)現(xiàn)類(lèi)不得不拋出UnsupportedOperationException這樣的異常勺疼,違反里氏替換原則。因此捏鱼,應(yīng)該將接口設(shè)計(jì)得更瘦执庐。
怎么給接口減肥呢?接口之所以存在导梆,是為了解耦轨淌。開(kāi)發(fā)者常常有一個(gè)錯(cuò)誤的認(rèn)知迂烁,以為是實(shí)現(xiàn)類(lèi)需要接口。其實(shí)是消費(fèi)者需要接口递鹉,實(shí)現(xiàn)類(lèi)只是提供服務(wù)盟步,因此應(yīng)該由消費(fèi)者(客戶端)來(lái)定義接口。理解了這一點(diǎn)躏结,才能正確地站在消費(fèi)者的角度定義Role interface却盘,而不是從實(shí)現(xiàn)類(lèi)中提取Header Interface。
什么是Role interface? 舉個(gè)例子媳拴,磚頭(Brick)可以被建筑工人用來(lái)蓋房子黄橘,也可以被用來(lái)正當(dāng)防衛(wèi):
public class Brick {
private int length;
private int width;
private int height;
private int weight;
public void build() {
//...包工隊(duì)蓋房
}
public void defense() {
//...正當(dāng)防衛(wèi)
}
}
如果直接提取以下接口,這就是Header Interface:
public interface BrickInterface {
void buildHouse();
void defense();
}
普通大眾需要的是可以防衛(wèi)的武器屈溉,并不需要用磚蓋房子旬陡。當(dāng)普通大眾(Person)被迫依賴了自己不需要的接口方法時(shí),就違反接口隔離原則语婴。正確的做法是站在消費(fèi)者的角度描孟,抽象出Role interface:
public interface BuildHouse {
void build();
}
public interface StrickCompetence {
void defense();
}
public class Brick implement BuildHouse, StrickCompetence {
}
有了Role interface,作為消費(fèi)者的普通大眾和建筑工人就可以分別消費(fèi)自己的接口:
Worker.java
brick.build();
Person.java
brick.strike();
接口隔離原則本質(zhì)上也是單一職責(zé)原則的體現(xiàn)砰左,同時(shí)它也服務(wù)于里氏替換原則匿醒。而接下來(lái)介紹的依賴倒置原則可以用來(lái)指導(dǎo)接口隔離原則的實(shí)現(xiàn)。
依賴倒置原則 (Dependence Inversion Principle)
依賴倒置原則說(shuō)的是高層模塊不應(yīng)該依賴底層模塊缠导,兩者都應(yīng)該依賴其抽象廉羔。
這個(gè)原則其實(shí)是在指導(dǎo)如何實(shí)現(xiàn)接口隔離原則,也就是前文提到的僻造,高層的消費(fèi)者不應(yīng)該依賴于具體實(shí)現(xiàn)憋他,應(yīng)該由消費(fèi)者定義并依賴于Role interface,底層的具體實(shí)現(xiàn)也依賴于Role interface髓削,因?yàn)樗獙?shí)現(xiàn)此接口竹挡。
依賴倒置原則是區(qū)分過(guò)程式編程和面向?qū)ο缶幊痰姆炙畮X。過(guò)程式編程的依賴沒(méi)有倒置立膛,A Simple DIP Example | Agile Principles, Patterns, and Practices in C#這篇文章以開(kāi)關(guān)和燈的例子很好地說(shuō)明了這一點(diǎn)揪罕。
上圖的關(guān)系中,當(dāng)Button直接調(diào)用燈的開(kāi)和關(guān)時(shí)宝泵,Button就依賴于燈了好啰。其代碼完全是過(guò)程式編程:
public class Button {
private Lamp lamp;
public void Poll() {
if (/*some condition*/)
lamp.TurnOn();
}
}
如果Button還想控制電視機(jī),微波爐怎么辦儿奶?應(yīng)對(duì)這種變化的辦法就是抽象框往,抽象出Role interface ButtonServer:
不管是電燈,還是電視機(jī)闯捎,只要實(shí)現(xiàn)了ButtonServer椰弊,Button都可以控制嘁酿。這是面向?qū)ο蟮木幊谭绞健?/p>
總結(jié)
總的來(lái)說(shuō),單獨(dú)應(yīng)用SOLID的某一個(gè)原則并不能讓收益最大化男应。應(yīng)該把它作為一個(gè)整體來(lái)理解和應(yīng)用闹司,從而更好地指導(dǎo)你的軟件設(shè)計(jì)。單一職責(zé)是所有設(shè)計(jì)原則的基礎(chǔ)沐飘,開(kāi)閉原則是設(shè)計(jì)的終極目標(biāo)游桩。里氏替換原則強(qiáng)調(diào)的是子類(lèi)替換父類(lèi)后程序運(yùn)行時(shí)的正確性,它用來(lái)幫助實(shí)現(xiàn)開(kāi)閉原則耐朴。而接口隔離原則用來(lái)幫助實(shí)現(xiàn)里氏替換原則借卧,同時(shí)它也體現(xiàn)了單一職責(zé)。依賴倒置原則是過(guò)程式編程與OO編程的分水嶺筛峭,同時(shí)它也被用來(lái)指導(dǎo)接口隔離原則铐刘。