面向?qū)ο蟮幕?/h2>
從事軟件開發(fā)的朋友或多或少都聽過(guò)以下一些原則:比如KiSS、DRY、LKP、COC、DbC、SoC、HP、SOLID等。這些原則已經(jīng)在業(yè)界被證實(shí)了自身的價(jià)值,尤其當(dāng)談到面向?qū)ο笤O(shè)計(jì)的時(shí)候,SOLID則是一個(gè)避不開的主題。
作為面向?qū)ο蟮幕驹瓌t,SOLID本身就是一個(gè)明顯的招牌 - 堅(jiān)固的磐石,撐起了面向?qū)ο笤O(shè)計(jì)大廈。
SOLID由五大原則構(gòu)成:
- Single Responsibility Principle【單一職責(zé)原則】
- Open Close Principle【開閉原則】
- Liskov Substitution Principle【里氏替換原則】
- Interface Segregation Principle【接口隔離原則】
- Dependency Inversion Principle【依賴倒置原則】
對(duì)于大部分OO程序員,這五大原則的名字可能已經(jīng)耳熟能詳,卻總不能很清晰的描述出SOLID是如何為我們服務(wù),因?yàn)镾OLID從來(lái)也沒(méi)有告訴我們How,它只在說(shuō):"這就是你最終要達(dá)到的目的地"。
本文我將帶著我的思考來(lái)捋一下LSP,LSP可能是一個(gè)很容易被破壞的原則,理解了它將能夠很好地驅(qū)動(dòng)我們?nèi)ニ伎既绾握_地做抽象設(shè)計(jì)。
打破里氏替換原則
In a computer program, if S is a subtype of T, then objects of type T may be replaced with objects of type S without altering any of the desirable properties of the program (correctness, task performed, etc.) -- Barbara Liskov in 1987
簡(jiǎn)單描述LSP:一個(gè)子類實(shí)例對(duì)象替換掉其父類實(shí)例對(duì)象,不會(huì)引發(fā)程序的任何變化。
如果要保證這點(diǎn),我們?cè)谠O(shè)計(jì)類的繼承關(guān)系的時(shí)候,子類不應(yīng)重寫父類的方法,這樣保證了父類的行為沒(méi)有被修改。來(lái)看個(gè)代碼示例,一個(gè)Square類繼承自Rectangle類,我們計(jì)算它們的面積:
class RectangleTest {
@Test
void should_return_area_when_calculate_given_width_and_height_valid() {
Rectangle rectangle = new Rectangle();
rectangle.setHeight(3);
rectangle.setWidth(5);
assertThat(rectangle.calculateArea()).isEqualTo(15);
}
@Test
void should_return_area_when_calculate_given_width_and_height_valid() {
Rectangle rectangle = new Square();
rectangle.setHeight(3);
rectangle.setWidth(5);
assertThat(rectangle.calculateArea()).isEqualTo(25);
}
}
前者返回的是15,而后者返回的是25。這兩者的差別在于我們使用了Square替換掉Rectangle,從而導(dǎo)致了程序的行為發(fā)生了改變。
看看Rectangle和Square的實(shí)現(xiàn),不難發(fā)現(xiàn)子類Square重寫了setHeight和setWidth方法,修改了父類行為,導(dǎo)致了替換失敗。
public class Rectangle {
protected double width;
protected double height;
public void setWidth(double width) { this.width = width; }
public void setHeight(double height) { this.height = height; }
public double calculateArea() { return width * height; }
}
public class Square extends Rectangle {
@Override
public void setHeight(double height) {
this.height = height;
this.width = height;
}
@Override
public void setWidth(double width) {
this.height = width;
this.width = width;
}
}
所以按照LSP的觀點(diǎn),這個(gè)繼承關(guān)系被扣上不良的帽子,注意這里我用了不良,而非錯(cuò)誤。因?yàn)槲抑滥憧赡軙?huì)懟我:"我設(shè)計(jì)這個(gè)子類我就想基于父類做一個(gè)擴(kuò)展,不同的子類有不同的實(shí)現(xiàn),沒(méi)讓你在使用的時(shí)候去替換父類呀! " (我敢打賭你在項(xiàng)目中遇到過(guò)這種覆寫父類的實(shí)現(xiàn),并且軟件還能正常Work)。在我懟回去之前,請(qǐng)先跟我來(lái)回顧一下面向?qū)ο筇匦浴?/p>
從復(fù)用來(lái)看繼承
從一踏入職場(chǎng)那一刻,我就在面試中多次被問(wèn)過(guò):請(qǐng)談?wù)勀銓?duì)面向?qū)ο蟮娜筇匦缘睦斫猓?/p>
簡(jiǎn)單捋一下面向?qū)ο蟮娜筇匦裕?/p>
- 封裝:隱藏對(duì)象的屬性和實(shí)現(xiàn)細(xì)節(jié),僅對(duì)外公開接口。比如
Rectangle類,首先對(duì)自身屬性width和height進(jìn)行了隱藏,通過(guò)calculateArea方法提供服務(wù),將依賴自身數(shù)據(jù)的計(jì)算細(xì)節(jié)也進(jìn)行了隱藏 - 繼承:允許子類在不需要重新編寫父類的前提下,復(fù)用父類的所有功能,并能夠按需進(jìn)行擴(kuò)展。比如
Square繼承了Rectangle,就具有了calculateArea的功能。(當(dāng)心:這個(gè)繼承不合理) - 多態(tài):允許對(duì)象在運(yùn)行期表現(xiàn)出不同的形體
恰巧LSP中提到了子類和父類的概念,所以不得不說(shuō)說(shuō)繼承。在這之前,我假設(shè):在做面向?qū)ο筌浖O(shè)計(jì)的你認(rèn)同面向?qū)ο笤O(shè)計(jì)的價(jià)值 -- 提升軟件的對(duì)變化的響應(yīng)力。
繼承的最核心的目的之一是為了復(fù)用,很多時(shí)候我們?yōu)榱藦?fù)用采用了繼承。如果我們?cè)O(shè)計(jì)繼承單純?yōu)榱藦?fù)用,你可能會(huì)問(wèn)為啥不用組合?而且很多時(shí)候提倡組合優(yōu)于繼承。這就需要我們思考面向?qū)ο蟮脑O(shè)計(jì)初衷:面向?qū)ο蠼⒃趯?duì)真實(shí)世界的抽象前提上,它很大程度上反映了我們的真實(shí)世界。比如一只鸚鵡是一只鳥,鳥能飛,鸚鵡也能飛,所以讓鸚鵡繼承自鳥,鸚鵡具備了飛的能力。
public class Bird {
public void fly() {
System.out.println("I am flying");
}
}
public class Parrot extends Bird {}
如何決定繼承關(guān)系,你可以用Is-A來(lái)進(jìn)行初步驗(yàn)證,比如A parrot is a bird。當(dāng)你使用Is-A讀起來(lái)就能讓自己發(fā)笑得的時(shí)候,就說(shuō)明這個(gè)繼承就明顯不合理了。比如,你有一架飛機(jī),它也能飛,為了復(fù)用你讓飛機(jī)繼承鳥 -- A Plane is a Bird。當(dāng)關(guān)系不是那么明顯的時(shí)候怎么辦?比如,A Square is a Rectangle,下文我將給出答案。
所以,繼承首先它應(yīng)該體現(xiàn)一種現(xiàn)實(shí)世界的真實(shí)規(guī)則Is-A,復(fù)用是它提供的一個(gè)核心能力,也是我們期望在設(shè)計(jì)上能獲得的好處,而要達(dá)到復(fù)用,就要遵守一個(gè)規(guī)則:子類不去更改父類已有的行為,否則就與復(fù)用不沾邊了(復(fù)用,代表你啥也不用做,直接具備的行為,如果你重新實(shí)現(xiàn)了,那叫新的實(shí)現(xiàn),你付出了新的努力。至于你要添加新的行為,這屬于擴(kuò)展,按需就好)。
我想你已經(jīng)能夠運(yùn)用Is-A來(lái)避免很多明顯恰當(dāng)?shù)睦^承關(guān)系。而當(dāng)你面臨模棱兩可的繼承場(chǎng)景時(shí),從復(fù)用的視角出發(fā),LSP提供了很好的校驗(yàn)規(guī)則。
抽象是為了更好地復(fù)用
回到文章一開始的例子,使用Is-A來(lái)解讀:A Square is a Rectangle,好像還湊合,但有點(diǎn)把握不準(zhǔn)。
讓Square繼承自Rectangle,Square能夠復(fù)用Rectangle中的所有行為,假如你不對(duì)Square做任何事情就能完美復(fù)用,但這樣子出來(lái)的正方形可能寬和高就不一樣了(無(wú)法滿足客戶真實(shí)需求,這可都是無(wú)用功喲)。為了滿足客戶需求,你就不得不對(duì)setWidth和setHeight進(jìn)行重寫:
public class Square extends Rectangle {
@Override
public void setHeight(double height) {
this.height = height;
this.width = height;
}
@Override
public void setWidth(double width) {
this.height = width;
this.width = width;
}
}
一旦重寫,當(dāng)你將下面代碼的Rectangle替換成Square時(shí)候就掛了:
@Test
void should_return_area_when_calculate_given_width_and_height_valid() {
// Replace with Square
Rectangle rectangle = new Rectangle();
rectangle.setHeight(3);
rectangle.setWidth(5);
// 25 if using Square
assertThat(rectangle.calculateArea()).isEqualTo(15);
}
所以,Liskov就開始吶喊了:"說(shuō)好的Square只是復(fù)用Rectangle的呢,為啥把Rectangle的行為改了,程序掛了,你不守規(guī)矩,怎么回事!"
那規(guī)矩又是什么呢?此時(shí)你必須重寫setWidth和setHeight,畢竟?jié)M足客戶才是你首要目的。到這個(gè)時(shí)候,已經(jīng)說(shuō)明了該繼承關(guān)系出了點(diǎn)問(wèn)題。你需要做的是跳出來(lái),重新審視一下你的設(shè)計(jì):
Square和Rectangle都有寬和高,并且計(jì)算面積的方式一樣,不同的是setWidth和setHeight。是否可以將共同的特征進(jìn)一步抽象提煉。就這樣逼著自己去思考,你可能很快就抽象出一個(gè)四邊形,因?yàn)?code>setWidth和setHeight行為不確定,先將它們抽象化。
你很快用Java代碼實(shí)現(xiàn):
public abstract class Quads {
protected int width;
protected int height;
public abstract void setWidth(int width);
public abstract void setHeight(int height);
public int calculateArea() { return width * height; }
}
然后你讓Rectangle和Square分別繼承自Quads,各自在自己的類中實(shí)現(xiàn)setWidth和setHeight。這時(shí)候你使用Rectangle和Square的方式也改了:
class QuadsTest {
@Test
void should_return_area_when_calculate_given_width_and_height_valid() {
Quads quads = new Rectangle();
quads.setHeight(3);
quads.setWidth(5);
assertThat(quads.calculateArea()).isEqualTo(15);
}
@Test
void should_return_area_when_calculate_given_width_and_height_valid() {
Quads quads = new Square();
quads.setWidth(5);
assertThat(quads.calculateArea()).isEqualTo(25);
}
}
通過(guò)進(jìn)一步抽象,你改變了繼承關(guān)系,Rectangle is A Quads,Square is A Quads,此時(shí)這種繼承關(guān)系就更加清楚明顯了,并且沒(méi)有違背LSP(Quads是一個(gè)抽象類,不能實(shí)例化對(duì)象,所以不會(huì)出現(xiàn)子類實(shí)例對(duì)象替換父類實(shí)例對(duì)象的場(chǎng)景)。
到這里,你已經(jīng)成功通過(guò)了進(jìn)一步抽象拯救了這個(gè)繼承關(guān)系,而且新的繼承關(guān)系更加合理,更加符合面向?qū)ο笤O(shè)計(jì),也最大化發(fā)揮了繼承的核心能力 -- 復(fù)用。
很多時(shí)候我們遇到這種,可能是因?yàn)槲覀冞^(guò)于著急寫代碼或是疏忽大意,那些貌似像Is-A的關(guān)系也被我們用上了繼承,這也促成了繼承被濫用。而解決辦法也很簡(jiǎn)單,LSP這個(gè)工具提供了很大的幫助,最終你會(huì)發(fā)現(xiàn)大多是由于恰當(dāng)抽象的缺失。
總結(jié)
如果用一句話來(lái)形容LSP,我覺得是:當(dāng)你無(wú)法根據(jù)Is-A 來(lái)判斷繼承關(guān)系是否合理時(shí),你應(yīng)該思考如何進(jìn)行下一步抽象,從而避免讓繼承產(chǎn)生二義性
借用極限編程的理念來(lái)講:將我們認(rèn)同的有效軟件開發(fā)原理和實(shí)踐應(yīng)用到極限。我們?cè)谧雒嫦驅(qū)ο笤O(shè)計(jì)時(shí),不妨拿起LSP這個(gè)現(xiàn)成的工具,幫助我們有效地減少繼承的濫用、模糊意圖等設(shè)計(jì)缺陷,提升軟件設(shè)計(jì)。
當(dāng)然,很多不符合LSP的軟件也能工作,這就像很多軟件充斥著壞味道照樣能工作一樣(比如代碼注釋)。而它背后隱含的邏輯應(yīng)該是:
我們應(yīng)該積極去思考更好的設(shè)計(jì),而不是過(guò)早放棄思考的機(jī)會(huì)
注釋
- KiSS: Keep it simple, stupid
- DRY: Don't Repeat Yourself
- LKP: Least Knowledge Principle (LOD: Law of Demeter)
- CoC: Convention over Configuration
- DbC: Design by Contract
- SoC: Segregation of Concerns
- HP: Hollywood Principle
參考閱讀
Posted by 袁慎建@ThoughtWorks
版權(quán)聲明:自由轉(zhuǎn)載?非商用?非衍生?保持署名 | Creative Commons BY-NC-ND 4.0