面向?qū)ο笤O(shè)計的SOLID原則, 2022-09-28

(2022.09.28 Wed)
SOLID是計算科學(xué)家Robert C. Martin, a.k.a., Uncle Bob提出的面向?qū)ο笤O(shè)計和模式設(shè)計的原則粘捎,可保證代碼的可懂榴芳、可讀不傅、可測試性("To create understandable, readable, and testable code that many developers can collaboratively work on.")捺檬,即代碼的穩(wěn)定性和擴展性。SOLID原則探討了如何在類中安排函數(shù)和數(shù)據(jù)結(jié)構(gòu),類之間如何聯(lián)系赃泡,或者說面向?qū)ο蟮拇a如何細(xì)分(split up),代碼的哪些部分設(shè)計為面向內(nèi)部或外部鞋真,代碼如何復(fù)用其他代碼等問題崇堰。這里雖用到了"類(class)",但并不代表該原則只適用于面向?qū)ο缶幊躺Аn愂呛瘮?shù)和數(shù)據(jù)的耦合組(coupled grouping)海诲。每個軟件系統(tǒng)都有這樣的組合,可能叫類或其他名字檩互,SOLID原則正是適用于這些組合特幔。

該原則的目標(biāo)是創(chuàng)建中間層軟件架構(gòu)使其

  • 可被改變
  • 易懂
  • 成為其他軟件系統(tǒng)的基本組件(the basis of components)

SOLID

  • The Single Responsibility Principle
  • The Open-Closed Principle
  • The Liskov Substitution Principle
  • The Interface Segregation Principle
  • The Dependency Inversion Principle

The Single Responsibility Principle, SRP獨立責(zé)任/功能原則

A module should be responsible to one, and only one, actor.

你可能會顧名思義地將其理解為一個函數(shù)能且只能處理一個功能。這里更準(zhǔn)確的理解是

A module should have one, and only one, reason to change.

但考慮到開發(fā)的需求提出者是人闸昨,這個說法的更準(zhǔn)確表述是

A module should be responsible to one, and only one, user or stakeholder.

又考慮到提需求的可能是一組或一類人蚯斯,或某角色的人,故更準(zhǔn)確地說法是

A module should be responsible to one, and only one, actor.

該表述中出現(xiàn)了module(模塊)一詞饵较,其最簡單的定義是一個源文件(source file)拍嵌。然而有的編程語言和開發(fā)環(huán)境不用源文件包含代碼,所以module的描述可以是函數(shù)和數(shù)據(jù)結(jié)構(gòu)的一個集合(cohesive set)循诉。

Case

Uncle Bob給出的案例如下:

有一個類Employee横辆,其中有三個方法
+ calculatePay - 財務(wù)部制定,匯報給CFO
+ reportHours - 人事部制定茄猫,匯報給COO
+ save - DBA制定狈蚤,匯報給CTO
三個方法放在同一個類中困肩,開發(fā)者將不同部分耦合在一起,會導(dǎo)致潛在的問題脆侮。

比如锌畸,calculatePayreportHours都需要計算非加班工時(non-overtime hours)。開發(fā)者為避免代碼重合他嚷,設(shè)計了一個方法regularHours用于計算非加班工時蹋绽。

現(xiàn)在CFO提出對非加班工時的計算方法提出修改,而COO不需要這樣的修改筋蓖。此時卸耘,開發(fā)者修改regularHours函數(shù),結(jié)果可滿足CFO要求粘咖,但能會導(dǎo)致COO想要的結(jié)果無法滿足要求蚣抗。

這種問題的發(fā)生源自開發(fā)者將不同角色的代碼放在了一起,而SRP原則建議將不同角色需要/依賴的代碼分開瓮下。

解決方案 -
最簡單的解決方案是將數(shù)據(jù)從Employee分離翰铡,同時不同的方法單獨創(chuàng)建類。

數(shù)據(jù)保存在類EmployeeData中讽坏,該類僅僅是包含數(shù)據(jù)的類锭魔,無其他方法。三個操作的類互相獨立且隔離路呜,分別為
PayCalculator
+ calculatePay
HourReporter
+ reportHours
EmployeeSaver
+ save

這種設(shè)計的缺點在于需要實例化三個類迷捧。解決該問題的模式為facade pattern(外觀模式)。該模式簡單來說就是當(dāng)系統(tǒng)中含有多個子系統(tǒng)胀葱,且多個用戶需要分別調(diào)用不同的子系統(tǒng)時漠秋,設(shè)置一個外觀類/接口,該接口同時調(diào)用所有子系統(tǒng)抵屿,用戶可通過該接口按需調(diào)用不同的子系統(tǒng)庆锦。類似于不同的電器各自有一個開關(guān),設(shè)置了一個總開關(guān)控制所有電器轧葛,總開關(guān)即facade搂抒。比如用戶對電腦開機,CPU執(zhí)行freeze朝群、jump和運行三個操作燕耿,內(nèi)存執(zhí)行l(wèi)oad操作,硬盤執(zhí)行read操作姜胖,而用戶無需分別去操作這三個部分誉帅,只需要一個外觀接口,即開關(guān),即可實現(xiàn)對不同硬件部分的操作蚜锨。

EmployeeFacade
+ PayCalculator.calculatePay
+ HourReporter.reportHours
+ EmployeeSaver.save

(2022.09.29 Thu)

The Open-Closed Principle, OCP 開閉原則

A module should be open for extension but closed for modification.

OCP原則被認(rèn)為是在OOP編程中最重要的原則档插。簡單來說,在設(shè)計模塊時需要保證模塊可被擴展亚再,而不對模塊做修改郭膛。

比如,在一個支付系統(tǒng)中氛悬,支付方式可以是信用卡则剃,也可以是借記卡,因此可寫成如下形式的類/模塊

class PaymentProcessor:
    def pay_debit(self, order, security_code):
        print("Processing debit payment type")
        print(f"Verifying security code: {security_code}")
        order.status = "paid"

    def pay_credit(self, order, security_code):
        print("Processing credit payment type")
        print(f"Verifying security code: {security_code}")
        order.status = "paid"

此時如捅,需要加入新的支付模式棍现,比如在線支付方式,如AliPay镜遣、PayPal等己肮。按現(xiàn)有模式和代碼,需要修改類PaymentProcessor悲关,違背OCP原則谎僻。

修改方案是用一個基類(PaymentProcessor)來定義底層支付邏輯,之后通過子類的創(chuàng)建寓辱,比如DebitPaymentProcessor來實現(xiàn)具體支付方法艘绍。這樣每當(dāng)添加一種新的支付方式,直接實現(xiàn)一個新的子類即可秫筏。

from abc import ABC, abstractmethod

class PaymentProcessor(ABC):
    @abstractmethod
    def pay(self, order, security_code):
        pass

class DebitPaymentProcessor(PaymentProcessor):
    def pay(self, order, security_code):
        print("Processing debit payment type")
        print(f"Verifying security code: {security_code}")
        order.status = "paid"

class CreditPaymentProcessor(PaymentProcessor):
    def pay(self, order, security_code):
        print("Processing credit payment type")
        print(f"Verifying security code: {security_code}")
        order.status = "paid"

(2022.12.15 Thur)
另一個案例

首先看bad example鞍盗。員工類做為基類,其中包括attributes為namesalary跳昼。不同類型的員工繼承該基類,并定義各自的工作方法肋乍,比如tester的工作方法是test鹅颊,developer的工作方法是develop。有一個公司類company墓造,其中對員工的工作方法根據(jù)員工的工種來判斷員工的工作行為堪伍。

class Employee:
    
    def __init__(self, name: str, salary: str):
        self.name = name
        self.salary = salary
    
class Tester(Employee):
    
    def __init__(self, name: str, salary: str):
        super().__init__(name, salary)
    
    def test(self):
        print("{} is testing".format(self.name))

class Developer(Employee):
    
    def __init__(self, name: str, salary: str):
        super().__init__(name, salary)
    
    def develop(self):
        print("{} is developing".format(self.name))


class Company:
    
    def __init__(self, name: str):
        self.name = name
    
    def work(self, employee):
        if isinstance(employee, Developer):
            employee.develop()
        elif isinstance(employee, Tester):
            employee.test()
        else:
            raise Exception("Unknown employee")

之所以認(rèn)為這是一個糟糕案例,想想我們向company中加入新的工種觅闽,比如analyst帝雇。繼承employee類,定義其工作方法蛉拙,在company類中又要加入新的判斷尸闸。每次有新的工種加入,company類就要被修改。

一個改進方案如下

from abc import ABC, abstractmethod

class Employee(ABC):

    def __init__(self, name: str, salary: str):
        self.name = name
        self.salary = salary

    @abstractmethod
    def work(self):
        pass

class Tester(Employee):

    def __init__(self, name: str, salary: str):
        super().__init__(name, salary)

    def test(self):
        print("{} is testing".format(self.name))

    def work(self):
        self.test()

class Developer(Employee):

    def __init__(self, name: str, salary: str):
        super().__init__(name, salary)

    def develop(self):
        print("{} is developing".format(self.name))

    def work(self):
        self.develop()

class Company:

    def __init__(self, name: str):
        self.name = name

    def work(self, employee: Employee):
        employee.work()

carbon = Company("Carbon")
developer = Developer("Nusret", "1000000")
tester = Tester("Someone", "1000000")
carbon.work(developer) # Will print Nusret is developing
carbon.work(tester) # Will print Someone is testing

employee抽象類有個抽象方法work吮廉,其子類需要實現(xiàn)work方法苞尝。Developer中的work方法調(diào)用develop方法,而tester的work方法調(diào)用test方法宦芦。在company類中宙址,調(diào)用員工的方法僅需要調(diào)用employee類的work方法。如果employee類中新加了analyst類调卑,進需要在analyst子類中實現(xiàn)work方法即可抡砂,而無需對company方法做任何修改。

The Liskov Substitution Principle, LSP 里式替換

Subclasses should be substitutable for their base classes

派生類(derived class)可替代基類(base class)恬涧,即在派生類替代基類之后注益,本該基類(卻改成調(diào)用派生類)的用戶感受不到異常。

Rectangular-Square問題
從案例入手解釋LSP气破,在本例中類square不是類rectangle的適合子類聊浅,因為rectangle中的高度和長度可以獨立改變,而square中的兩條邊需要同時改變现使。而調(diào)用rectangle的用戶如果改為調(diào)用square則會產(chǎn)生混亂低匙。

有如下測試過程,rectangle可通過碳锈,但square無法通過顽冶。

rectangle r  = ...
r.setW(5)
r.setH(10)
assert r.area == 50

如果想同時正確調(diào)用rectanglesquare,需要用戶端對形狀做判斷售碳,并做出相應(yīng)操作强重。考慮到這樣的修改取決于調(diào)用的對象類型贸人,因此對象就是不可替代的(not substitutable)间景。

LSP適用于類、JAVA接口艺智、REST接口等倘要。從架構(gòu)層面理解LSP的最佳方式是看看違背LSP將會帶來什么。

開發(fā)者開發(fā)了出租車分配系統(tǒng)十拣,通過URI調(diào)用不同公司的出租車用于傳輸信息和訂車封拧。不同的公司使用了相同的URI規(guī)則。比如預(yù)約司機John的車可通過如下的URI

url = 'purpletaxi.com/'
/driver/bob
/destination/ord
/pickupTime/1530
/pickupAddr/24maplest

不同公司的API都遵從如上所示的規(guī)則夭问。假設(shè)A公司有個新來的開發(fā)者泽西,不曾閱讀API規(guī)范文檔,將/destination改成了/dest缰趋,使得接口不可替代捧杉。這將導(dǎo)致分配系統(tǒng)在遇到A公司的訂單時失敗陕见。(如運行正常將在訂單系統(tǒng)中加入對出租車公司的判斷。)

(2022.12.15 Thur)
另一個案例糠溜。學(xué)校成員有老師淳玩、學(xué)生和管理者。不同成員從成員基類member繼承生成非竿。

from abc import ABC, abstractmethod

class Member(ABC):
    def __init__(self, name: str, age: int):
        self.name = name
        self.age = age

    @abstractmethod
    def save_database(self):
        pass

class Teacher(Member):
    def __init__(self, name: str, age: int, teacher_id: str):
        super().__init__(name, age)
        self.teacher_id = teacher_id

    def save_database(self):
        print("Saving teacher data to database")

class Student(Member):
    def __init__(self, name: str, age: int , student_id: str):
        super().__init__(name, age)
        self.student_id = student_id

    def save_database(self):
        print("Saving student data to database")

下面給成員加入新pay方法蜕着,支付工資。學(xué)生無法支付红柱,因為他們沒有工資承匣。

from abc import ABC, abstractmethod


class Member(ABC):
    def __init__(self, name, age):
        self.name = name
        self.age = age

    @abstractmethod
    def save_database(self):
        pass

    @abstractmethod
    def pay(self):
        pass


class Teacher(Member):
    def __init__(self, name, age, teacher_id):
        super().__init__(name, age)
        self.teacher_id = teacher_id

    def save_database(self):
        print("Saving teacher data to database")

    def pay(self):
        print("Paying")


class Manager(Member):
    def __init__(self, name, age, manager_id):
        super().__init__(name, age)
        self.manager_id = manager_id

    def save_database(self):
        print("Saving manager data to database")

    def pay(self):
        print("Paying")


class Student(Member):
    def __init__(self, name, age, student_id):
        super().__init__(name, age)
        self.student_id = student_id

    def save_database(self):
        print("Saving student data to database")

    def pay(self):
        raise NotImplementedError("It is free for students!")

運行時將拋出異常,這與Liskov substitution原則不符锤悄。解決這個問題韧骗,將pay方法從member類中移除,并創(chuàng)建新類payer零聚。對teachermanager使用多繼承袍暴,從memberpayer兩個類。而student則只繼承member一個類隶症。

from abc import ABC, abstractmethod

class Payer(ABC):
    @abstractmethod
    def pay(self):
        pass

class Member(ABC):
    def __init__(self, name, age):
        self.name = name
        self.age = age

    @abstractmethod
    def save_database(self):
        pass


class Teacher(Member, Payer):
    def __init__(self, name, age, teacher_id):
        super().__init__(name, age)
        self.teacher_id = teacher_id

    def save_database(self):
        print("Saving teacher data to database")

    def pay(self):
        print("Paying")


class Manager(Member, Payer):
    def __init__(self, name, age, manager_id):
        super().__init__(name, age)
        self.manager_id = manager_id

    def save_database(self):
        print("Saving manager data to database")

    def pay(self):
        print("Paying")


class Student(Member):
    def __init__(self, name, age, student_id):
        super().__init__(name, age)
        self.student_id = student_id

    def save_database(self):
        print("Saving student data to database")


payers: List[Payer] = [Teacher("John", 30, "123"), Manager("Mary", 25, "456")]
for payer in payers:
    payer.pay()

至此政模,不符合Liskov substitution原則的問題得到解決。

The Interface Segregation Principle, ISP 接口隔離原則

Many client specific interfaces are better than one general purpose interface

ISP是支持COM一類組件底層的技術(shù)(enabling tech supporting component substrates such as COM)蚂会,可使得組件和類更有用和便攜(portable)淋样。

ISP的核心比較簡單。類如果有多個用戶胁住,則針對每個用戶創(chuàng)建專用接口趁猴,僅調(diào)用該用戶使用的方法,而不是在一個類中調(diào)用所有用戶需要的所有方法彪见。

比如儡司,三個用戶都需要調(diào)用OPS類的操作,設(shè)user1只用OPS類中的op1方法余指,user2只用op2方法枫慷,user3只用op3方法。
假設(shè)OPS類是用Java實現(xiàn)的類浪规。user1的源碼因為疏忽,依賴了op2op3探孝。一旦op2的源碼變更笋婿,則user1需要重新編譯和重新部署,即便變更的部分和user1的業(yè)務(wù)無關(guān)顿颅。

這個問題可通過隔離的方法解決缸濒。OPS類不變,派生子類U1OPS,派生類中只有op1方法庇配,U1OPS專供user1只用斩跌,user1不再直接以來OPS類。此時OPS類的變更將不會導(dǎo)致user1的重編譯和重部署(redeployed)捞慌。

簡言之耀鸦,用戶需要什么就提供什么,不需要的一概不提供啸澡。

The Dependency Inversion Principle, DIP 依賴倒置原則

Depend upon Abstractions. Do not depend upon concretions.

按照DIP原則袖订,最靈活的系統(tǒng)是其中的源代碼僅依賴抽象(abstractions)而非具體類或具體實現(xiàn)(concretions)。

在Java一類的靜態(tài)語言(statically typed)中嗅虏,use洛姑、importinclude等命令只能指向包含接口抽象類和其他抽象聲明的源代碼模塊,不依賴任何具體類(concrete)皮服。在動態(tài)類型語言中楞艾,如Ruby和Python,這個原則同樣適用龄广。源碼依賴不該引用具體模塊(concrete modules)硫眯,但在動態(tài)語言中定義具體模塊相對復(fù)雜,特別地蜀细,可定義為其中函數(shù)被調(diào)用的模塊舟铜。

當(dāng)然,將這個想法作為標(biāo)準(zhǔn)并不現(xiàn)實奠衔,因為軟件系統(tǒng)一定會依賴很多具體實現(xiàn)谆刨。比如Java的String類是具體而且很難迫使它改為抽象類。對具體類java.lang.string的調(diào)用也不可且不該避免归斤。String類非常穩(wěn)定痊夭,修改該類的行為很罕見且被嚴(yán)格控制。開發(fā)者無需焦慮對String的頻繁修改脏里∷遥基于此,我們傾向于在實踐DIP時忽略操作系統(tǒng)和平臺的穩(wěn)定性迫横。容忍具體依賴因為信任其不變和穩(wěn)定番舆。不穩(wěn)定的具體類(volatile concrete)才是應(yīng)該避免依賴的,也就是那些處在活躍開發(fā)和頻繁更新的類矾踱。

案例如前面提到的支付類

class PaymentProcessor:
    def pay_debit(self, order, security_code):
        print("Processing debit payment type")
        print(f"Verifying security code: {security_code}")
        order.status = "paid"

    def pay_credit(self, order, security_code):
        print("Processing credit payment type")
        print(f"Verifying security code: {security_code}")
        order.status = "paid"

類初始化時恨狈,security_code不當(dāng)設(shè)置成短信驗證、郵箱驗證等特定的方式呛讲,而應(yīng)設(shè)置為一個抽象類禾怠,在類中判斷驗證類型返奉。

class Authorizer(ABC):
    @abstractmethod
    def is_authorized(self) -> bool:
        pass

class SMSAuth(Authorizer):
    authorized = False

    def verify_code(self, code):
        print(f"Verifying code {code}")
        self.authorized = True

    def is_authorized(self) -> bool:
        return self.authorized

class PaymentProcessor(ABC):
    @abstractmethod
    def pay(self, order):
        pass

class DebitPaymentProcessor(PaymentProcessor):
    def __init__(self, security_code, authorizer: Authorizer):
        self.security_code = security_code
        self.authorizer = authorizer
        self.verified = False

    def pay(self, order):
        if not self.authorizer.is_authorized():
            raise Exception("Not authorized")
        print("Processing debit payment type")
        print(f"Verifying security code: {self.security_code}")
        order.status = "paid"

DIP同樣可以應(yīng)用于微服務(wù)開發(fā)。比如服務(wù)間的直接通信可由message bus或消息隊列代替吗氏。采用消息隊列時芽偏,服務(wù)將消息放到一個單一的普通位置,而無須介意是哪個服務(wù)會使用該消息弦讽。

Reference

1 Robert C. Martin, Design Principles and Design Patterns
2 Robert C. Martin, Clean Architecture, Prentice Hall
3 RollingStarky, Uncle Bob 的 SOLID 軟件設(shè)計原則——Python 實例講解污尉,簡書
4 SOLID principle with Python, Muhammet Nusret ?zate?, medium

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市坦袍,隨后出現(xiàn)的幾起案子十厢,更是在濱河造成了極大的恐慌,老刑警劉巖捂齐,帶你破解...
    沈念sama閱讀 219,490評論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異奠宜,居然都是意外死亡包颁,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,581評論 3 395
  • 文/潘曉璐 我一進店門压真,熙熙樓的掌柜王于貴愁眉苦臉地迎上來娩嚼,“玉大人,你說我怎么就攤上這事滴肿≡牢颍” “怎么了?”我有些...
    開封第一講書人閱讀 165,830評論 0 356
  • 文/不壞的土叔 我叫張陵泼差,是天一觀的道長贵少。 經(jīng)常有香客問我,道長堆缘,這世上最難降的妖魔是什么滔灶? 我笑而不...
    開封第一講書人閱讀 58,957評論 1 295
  • 正文 為了忘掉前任,我火速辦了婚禮吼肥,結(jié)果婚禮上录平,老公的妹妹穿的比我還像新娘。我一直安慰自己缀皱,他們只是感情好斗这,可當(dāng)我...
    茶點故事閱讀 67,974評論 6 393
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著啤斗,像睡著了一般表箭。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上争占,一...
    開封第一講書人閱讀 51,754評論 1 307
  • 那天燃逻,我揣著相機與錄音,去河邊找鬼臂痕。 笑死伯襟,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的握童。 我是一名探鬼主播姆怪,決...
    沈念sama閱讀 40,464評論 3 420
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼澡绩!你這毒婦竟也來了稽揭?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 39,357評論 0 276
  • 序言:老撾萬榮一對情侶失蹤肥卡,失蹤者是張志新(化名)和其女友劉穎溪掀,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體步鉴,經(jīng)...
    沈念sama閱讀 45,847評論 1 317
  • 正文 獨居荒郊野嶺守林人離奇死亡揪胃,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,995評論 3 338
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了氛琢。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片喊递。...
    茶點故事閱讀 40,137評論 1 351
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖阳似,靈堂內(nèi)的尸體忽然破棺而出骚勘,到底是詐尸還是另有隱情,我是刑警寧澤撮奏,帶...
    沈念sama閱讀 35,819評論 5 346
  • 正文 年R本政府宣布俏讹,位于F島的核電站,受9級特大地震影響挽荡,放射性物質(zhì)發(fā)生泄漏藐石。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 41,482評論 3 331
  • 文/蒙蒙 一定拟、第九天 我趴在偏房一處隱蔽的房頂上張望于微。 院中可真熱鬧,春花似錦青自、人聲如沸株依。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,023評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽恋腕。三九已至,卻和暖如春逆瑞,著一層夾襖步出監(jiān)牢的瞬間荠藤,已是汗流浹背伙单。 一陣腳步聲響...
    開封第一講書人閱讀 33,149評論 1 272
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留哈肖,地道東北人吻育。 一個月前我還...
    沈念sama閱讀 48,409評論 3 373
  • 正文 我出身青樓,卻偏偏與公主長得像淤井,于是被迫代替她去往敵國和親布疼。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 45,086評論 2 355

推薦閱讀更多精彩內(nèi)容