軟件設計原則
在軟件開發中,程序員應盡量遵守這六條軟件設計原則,這六條原則可以幫助我們提高軟件系統的可維護性和可復用性,增加軟件的可拓展性和靈活性。
軟件設計六大原則:
- 開閉原則
- 里氏代換原則
- 依賴倒轉原則
- 接口隔離原則
- 迪米特法則
- 合成復用原則
1、開閉原則
對拓展開放,對修改關閉
在程序需要拓展原有功能時,不能對原有代碼進行修改,而要實現一個熱插拔的效果:需要什么就添加上去,不要影響原來的程序功能。其目的在於使得程序可拓展性好,易於維護與升級。
要想達到這樣的效果,我們需要使用接口和抽象類。
為什么呢?其實本質上接口和抽象類定義的就是規范,只要我們合理的抽象,它可以覆蓋很大的一塊功能實現,從而維持軟件架構的穩定。
而那些易變的細節,則可以交給具體的實現類來完成,當軟件需求發生變化,只需要再派生一個實現類完成功能即可。
這里某種程度上其實暗合了依賴倒轉原則。
實現開閉原則簡單實例:我們創建一個代表皮膚展示的接口,然后通過多個類實現該接口來完成皮膚的實現,最后通過一個測試類來進行測試。
//接口,表示皮膚展示的抽象意義
public interface Skin {
void showSkin();
}
//實現類一,實現了第一種皮膚的展示
public class ShowSkin01 implements Skin {
@Override
public void showSkin() {
System.out.println("Skin01");
}
}
//實現類二,實現了第二種皮膚的展示
public class ShowSkin02 implements Skin {
@Override
public void showSkin() {
System.out.println("Skin02");
}
}
//IoC簡單實現,將選擇何種皮膚的權利交給用戶
public class Shower {
private Skin skin;
public void setSkin(Skin skin) {
this.skin = skin;
}
public void show(){
skin.showSkin();
}
}
//客戶端,如果輸入1,則展示皮膚1;如果輸入2,則展示皮膚2;其他輸入會顯示無效輸入
public class Client {
public static void main(String[] args) {
Shower shower = new Shower();
Scanner scanner = new Scanner(System.in);
int i = scanner.nextInt();
switch (i){
case 1:
shower.setSkin(new ShowSkin01());
shower.show();
break;
case 2:
shower.setSkin(new ShowSkin02());
shower.show();
break;
default:
System.out.println("input no sense!");
}
}
}
2、里氏代換原則
任何父類出現的地方,子類一定也可以出現
通俗理解就是,子類可以拓展父類的功能,補充原來沒有的功能,但是,不能改變父類原有的功能。
從編程的角度來理解的話,那就是在子類繼承父類時,盡量不要重寫父類已經實現了的方法,而應該轉而拓展出父類本來不具有的方法。
如果重寫了父類已經實現了的方法,不僅會造成父類對該方法的定義浪費,而且在使用多態的時候會非常容易發生錯誤。
下面我們來實現一個經典反例:正方形不是長方形
從數學知識來講,顯然,正方形是長方形的一個特例,它特殊在正方形的長和寬永遠是相等的,所以從邏輯上講,當然我們應該將正方形作為長方形的子類進行編程。
//非常大眾化定義的長方形,有寬和長
public class Rectangle {
private int length;
private int width;
public int getLength() {
return length;
}
public void setLength(int length) {
this.length = length;
}
public int getWidth() {
return width;
}
public void setWidth(int width) {
this.width = width;
}
}
//繼承自長方形的正方形,由於正方形的特殊性,這里我們重寫set方法,因為我們要保證正方形的長和寬永遠相等(這一步違背了里氏代換原則)
public class Square extends Rectangle{
@Override
public void setLength(int length) {
super.setLength(length);
super.setWidth(length);
}
@Override
public void setWidth(int width) {
super.setWidth(width);
super.setLength(width);
}
}
/*
這里我們做一個應用,該應用旨在測試長方形的寬是否大於等於長,如果滿足,那么長的值加一直到長比寬大為止。但是這里我們就會明顯發現,雖然這
個地方可以放長方形對象,但一旦使用於正方形,就會形成死循環,因為我們重寫了set方法,而正方形的set方法使得正方形的長和寬永遠不可能不同,
也就無法跳出循環,從而造成死機
*/
public class RectangleTest {
public static void main(String[] args) {
Rectangle rectangle = new Rectangle();
rectangle.setLength(10);
rectangle.setWidth(100);
resize(rectangle);
System.out.println(rectangle.getLength()+" : "+rectangle.getWidth());
Rectangle square = new Square();
square.setLength(10);
resize(square);//程序永遠無法走出這里
System.out.println(square.getLength()+" : "+square.getWidth());
}
public static void resize(Rectangle rectangle){
while(rectangle.getWidth()>= rectangle.getLength()){
rectangle.setLength(rectangle.getLength()+1);
}
}
}
以上這個例子我們發現,由於正方形的特殊性,導致我們在設計resize這種應用時,正方形無法適用於長方形能適用的方法,這違背了繼承的基本原則,所以從程序的角度來審視,正方形其實並不是長方形。
所以這里如果我們要滿足里氏代換原則,顯然我們就不能讓正方形繼承自長方形,我們應該設計一個更抽象的類,使得長方形和正方形都繼承自它。
所以改進方案是,我們設計一個平行四邊形抽象類,該類擁有獲得長和寬的兩個get抽象方法。
然后我們的正方形可以繼承平行四邊形,引入屬性邊長,實現邊長的set方法和兩個get方法。
長方形則引入長和寬兩個屬性,分別實現二者的get和set。
如此一來,我們針對長方形實現的方法resize()
就不會對正方形生效,也就避免出現上面的錯誤。
3、依賴倒轉原則
高層模塊不應該依賴低層模塊,二者都應該依賴其抽象。抽象不應該依賴細節,細節應該依賴抽象。
通俗理解就是應當對抽象進行編程,不要對實現進行編程,這樣就降低了客戶和模塊之間的耦合。是不是還是不明白?
下面我們給出一個非常通俗易懂的例子:
假設:
我們的客戶要求我們為他的電腦定制一套驅動程序,於是給了我們他的電腦配置
具體配置是:因特爾的CPU,金士頓的內存條,英偉達的顯卡
於是我們給這台電腦以及三個部件編寫驅動程序,如果我們編寫如下:
class Computer{
private intelCPU cpu;
private NVIDIAGPU gpu;
private KinstonMemory memory;
/*
各種驅動的實現省略
*/
}
class intelCPU{
/*
各種驅動省略
*/
}
class NVIDIAGPU{
/*
各種驅動省略
*/
}
class KinstonMemory{
/*
各種內存省略
*/
}
顯然,在正確的安裝下,這台電腦配上這些驅動程序,是可以正常運行的。客戶滿意的收下了。
結果第二天,客戶說他想換新的顯卡,英偉達的顯卡太貴了他買不起,於是換了AMD的顯卡,結果電腦就開不了機了,要求我們重新編寫一套驅動程序。
這下我們傻眼了,為什么?
因為我們的電腦類已經定死了只能使用英偉達的顯卡,如果需要修改,除了重新寫一個AMDGPU
類的驅動程序以外,還必須把整個電腦類翻新重寫,我們得一點一點在這個類中找出當初寫的所有和NVIDIAGPU
類有關的東西,然后把他們全部重新寫一遍,替換成我們剛剛寫好了的AMDGPU
,而如果我們真的這么做了,交付給客戶以后。結果交付的第二天客戶又說他換了條三星的內存,結果又打不開機了。。。那簡直是吐血
顯然,這種設計方法寫出來的程序既是在***難用戶,亦是在***難自己。
再重讀一遍什么叫依賴倒轉原則:高層模塊不應該依賴低層模塊,二者都應該依賴其抽象。抽象不應該依賴細節,細節應該依賴抽象。
其意思就很明顯了:
- 我們的高級模塊中所依賴的低級模塊不能是真正的低級模塊而應該是低級模塊的抽象,可以是抽象類,可以是接口。
- 同樣的,低級模塊中的那些依賴其他類的地方也不能是直接的依賴,而應該是對其的抽象的依賴。
- 抽象不能依賴與任何已存在的實現,否則就不足以稱為抽象了
- 已存在的實現都應當依賴於抽象
聽起來就像繞口令,但說白了就是對類的依賴應盡量依賴其最抽象,最核心的部分,因為無論以后我們會用到該類的任何一個子類,都可以輕易的適配進去。比如之前的例子,如果我們改為如下情況
class Computer{
private CPU cpu;
private GPU gpu;
private Memory memory;
/*
各種驅動的抽象,省略
*/
}
//抽象類
abstract class CPU{
/*
各種驅動的抽象,省略
*/
}
abstract class GPU{
/*
各種驅動的抽象,省略
*/
}
abstract class Memory{
/*
各種驅動的抽象,省略
*/
}
//實現
class intelCPU extends CPU{
/*
各種驅動實現,省略
*/
}
class NVIDIAGPU extends GPU{
/*
各種驅動實現,省略
*/
}
class KinstonMemory extends Memory{
/*
各種內存實現,省略
*/
}
如此一來,無論我們的用戶換了怎么樣的硬件,我們都不會再像剛剛那樣需要反復修改代碼了。這就是依賴倒轉原則的用意之所在。
4、接口隔離原則
客戶端不應該被迫依賴於它不使用的方法,一個類對於另一個類的依賴應該建立於最小的接口上
也就是說,在進行面向對象編程的時候,我們要盡量將接口功能的實現類拆分開,不要讓某一個實現類耦合了過多的接口功能,造成浪費。
下面來看個例子:假設我們有一個接口,它表示一個防盜門的功能,包括:防火,防水,防盜。
正好,客戶交來的先進防盜門就能滿足這三種功能,現在我們來實現一下看看。
interface safetyDoor{
void fireProof();
void waterProof();
void thiefProof();
}
class advanceSafetyDoor implements safetyDoor{
public void fireProof();
public void waterProof();
public void thiefProof();
}
class test{
public static void main(String args[]){
safetyDoor door = new advanceSafetyDoor();
door.fireProof();
door.waterProof();
door.thiefProof();
}
}
非常不錯,先進防盜門完美的實現了防盜門的所有功能,在測試中也表現良好。另一家客戶聽說了這事,把他們的防盜門也給我們看了:
簡約防盜門,功能:防盜。
那么現在顯然,問題大了,我們的"防盜門"接口要求,一扇防盜門必須要能夠防火,防水,防盜,但是客戶的這家防盜門僅僅只能防盜,我們如果令該實現類就這樣繼承防盜門接口,顯然有兩種方法我們就無法實現,從而陷入兩難的境地。
所以結論很明顯了,我們不能將過多的功能耦合在一個接口里,需要對其進行最小化的拆分,方便后面復用繼承
interface antiFire{
void fireProof();
}
interface antiWater{
void waterProof();
}
interface antiThief{
void thiefProof();
}
class advanceSafetyDoor implements antiFire, antiWater, antiThief{
public void fireProof();
public void waterProof();
public void thiefProof();
}
class simpleSafetyDoor implements antiThief{
public void antiThief();
}
class test{
public static void main(String args[]){
advanceSafetyDoor door = new advanceSafetyDoor();
door.fireProof();
door.waterProof();
door.thiefProof();
simpleSafetyDoor door_ = new simpleSafetyDoor();
door_.thiefProof();
}
}
如此一來,我們就可以成功的實現先進安全門和簡約安全門的類了,二者都可以安全,順利的運行。
這里再思考一個問題:先進安全門和簡約安全門可不可以繼承自同一個類?這樣我們就能像之前幾個原則中一樣通過一個高度抽象的類概括所 有子類
答案是可以,在這里,我們只需要使用
antiThief
就可以創建兩種類了。但是顯然這么做不符合生活常識,因為如果從常識上判斷,兩種安全門肯定都屬於安全門,而安全門都應該是門
但是!在編程中,抽象的方式一定不要是從實體上想,要盡量往方法上想,換個角度來說,安全門的基礎功能是什么?
就是防盜
那么防盜的可不可以是其他東西?也可以,比如保安。那么如果我實現一個antiThief
,我就可以把這個接口帶到門,安保,保險箱等等類中去,而不僅僅是局限在所謂的 "門" 這個實際概念上。這就是接口隔離原則的意義,因為現實中程序都是由許多功能組成的,而這些功能也同樣可能可以應用到其他程序中,把這些功能一個個抽象出來,其意義遠大於抽象出一個 "安全門"。那么答案就顯而易見了,為了拓寬程序的可復用性,我們的抽象要盡量脫離實體的束縛,多從功能上考慮
5、迪米特法則
又稱最小知識原則:即只跟你的直接朋友說話,不要跟陌生人說話,即便他是朋友的朋友
從程序角度上來說就是,如果兩個軟件實體無需直接通信,那么就不應當發生直接的相互調用,可以通過第三方轉發該調用。簡言之就是最小化類與類之間的依賴關系,其目的是降低模塊之間的耦合度,提高模塊的相對獨立性。
迪米特法則中的直接朋友指的是:
- 當前類本身的對象
- 該對象的成員對象
- 該對象所創建的對象
- 當前類對象的方法參數
- 各個與當前對象存在聚合,組合,關聯關系的對象
舉一個經典的例子:
一個開發公司,有許多個軟件工程師,顯然,工程師與客戶並不是朋友,而公司是客戶的朋友,所以當客戶需要開發某個軟件的時候,他們無需去找工程師,而應該去找開發公司,再由開發公司把具體任務下發給這些工程師,這樣才能保證各個部分都獨立自主的處理自己的事務,降低各個部分的耦合性。
class Engineer{//工程師僅僅和公司是朋友
Company company;
String name;
public void develope();
}
class Company{//公司和工程師,客戶是朋友
List<Engineer> engineers;
List<Mission> mission;
public void workOnMission(engineer,mission){
Mission mission = customer.getMission();
engineer.develope();
}
}
class Customer{
Mission mission;
public Mission getMission(){
return mission;
}
}
思考,這里為什么要這么做?可不可以讓工程師直接與客戶交朋友?
假設我們讓工程師與客戶交上了朋友,比如說令
develope()
方法需要一個參數develope(Customer customer)
,這會帶來什么影響呢?就上面這個程序而言,你完全可以這么做,但是假設有一天,不是客戶,而是老板突發奇想,需要工程師來做一個公司管理系統的開發任務。老板雖然也有
Mission
,但是老板並不是Customer
那么要怎么辦?你當然可以再在engineer
類中寫一個參數是boss
的方法,但其中的絕大多數代碼都會是冗余的,因為實際上工程師只關心Mission
,不關心給的人是誰,如果在company
中添加一個workOnBoss()
方法顯然就要簡單的多了。這就是降低耦合性的意義
6、合成復用原則
盡量使用聚合或組合的方式來進行復用,其次才考慮使用繼承來進行復用
類的復用分兩種:
- 繼承復用
- 合成復用
繼承復用雖然簡單且易於實現,但也有如下缺點:
- 繼承復用破壞了類的封裝性,父類的一切屬性都會暴露給子類,因而這種復用亦被稱為"白箱復用"
- 子類與父類耦合度極高,父類的任何變化都會導致子類一同變化,不利於維護
- 限制了復用的靈活性,繼承而來的實現是靜態的,一經繼承即確定,無法在運行中變化
與之對應,采用合成復用也就會有如下優點了:
- 合成復用維持了類的封裝性,即便一個類是另一個類的成員,也必須通過該成員類自己的方法才能訪問其內部的組成,亦稱"黑箱復用"
- 對象間的耦合度低,我們可以在成員中聲明抽象,即便成員對象改變,對於其他類來說,這些對象也是一個"黑盒子",無需關心其內部的實現細節
- 復用靈活性高,這種復用可以在運行時動態地進行,對於該類的不同對象,可以引用同類的不同對象作為成員對象
這里可以用一個很好的例子來詮釋這兩種復用的巨大差別:
假設我們需要實現一個汽車類,它有兩個成員對象:汽車顏色,能源種類
其中顏色有紅,藍兩種,能源有汽油,電力兩種。
那么,如果我們僅僅采用繼承復用不使用組合復用來實現汽車類:
class car{
}
class redCar extends car{
private Red red;
}
class blueCar extends car{
private Blue blue;
}
class redElecCar extends redCar{
private Electronic elec;
}
class redGasCar extends redCar{
private Gas gas;
}
class blueElecCar extends blueCar{
private Electronic elec;
}
class blueGasCar extends blueCar{
private Gas gas;
}
顯然,這么做是很蠢的,本來我們只需要組合Color和Energy兩個抽象類就能做到控制汽車的這兩個成員對象,現在我們創建了如此多的子類才做到相同的事情。假設哪天客戶突然說,他們又開發了四種顏色,兩種新能源,那我們的軟件工程師估計當天就要全部辭職了。
而且這么做的耦合度極高,一旦Car類中某個方法做出了修改,我們會額外影響整整六個類,在項目中,這樣的做法是十分危險的。
而如果換成合成復用呢?
class car{
private Energy energy;
private Color color;
car(Energy energy,Color color){
this.energy = energy;
this.color = color
}
}
interface Energy{
void energize();
}
interface Color{
void showColor();
}
class red implements Color{
public void showColor(){
showRed;
}
}
class blue implements Color{
public void showColor(){
showblue;
}
}
class gasoline implements Energy{
public void energize(){
gasoEnergize();
}
}
class elecity implements Energy{
public void energize(){
elecEnergize();
}
}
顯然,這么做的話,我們如果需要一輛藍色電力車,我們只需要Car car = new car(new elecity,new blue);
這么一句話就足夠了,而如果我們需要開發新的能源和顏色,只需要實現我們的Energy
和Color
接口,就可以完成添加,非常方便快捷。這就是合成復用的好處。