1. 程式人生 > >07良好的類接口

07良好的類接口

便在 能力 方便 應該 initial 初始 解決 tro void

1. 好的抽象

1.1 類的接口應該展現一致的抽象層次

? 在考慮類的時候有一個很好地辦法,就是把類看做一種用來實現抽象數據類型的機制。每一個類應該實現一個 ADT,並且僅實現這個 ADT。如果你發現某個類實現了不止一個ADT,或者你不能確定究竟它實現了何種 ADT,你就應該把這個類重新組織為一個或多個更加明確的 ADT。

? 如果把類的公用子程序看中是潛水艇上用來防止進水的氣鎖閥,那麽類中不一致的公用子程序就相當於是漏水的儀表盤。這些漏水的儀表盤可能不會讓水像打開氣鎖閥那樣迅速進入,但只要有足夠的時間,他們還是能讓潛水艇沈沒。實際上,這就是混雜抽象層的後果。在修改程序時,混雜的抽象層次會讓程序越來越難理解,整個程序也會逐漸墮落直到變得無法維護。

1.2 一定要理解類所實現的抽象是什麽

? 一些類非常相像,你必須非常仔細地理解類的接口應該捕捉的抽象到底是哪一個。我曾經開發過這樣一個程序,用戶可以用表格的形式來編輯信息。我們想用一個簡單的柵格控件,但它卻不能給數據輸入單元格換顏色,因此我們決定用一個能提供這一功能的電子表格控件。

? 電子表格控件要比柵格控件復雜得多,它提供了150個子程序,而柵格控件只有15個。由於我們的目標是使用一個柵格控件而不是電子表格控件,因此我們讓程序員寫一個包裹類,隱藏起“把電子表格控件用作柵格控件”這一事實。這位程序員強烈抱怨,認為這樣做是毫無必要地增加成本,是官僚作風,然後就走了。幾天以後,他帶來了寫好的包裹類,而這個類竟然忠實地把電子表格控件所擁有的全部150個子程序都暴露出來了。

? 這並不是我們想要的。我們要的是一個柵格控件接口,這個接口封裝了“肥厚實際上是在用一個更為復雜的電子表格控件”的事實。那位程序員應該只暴露那15個柵格控件的子程序,再加上第16個支持設置單元格顏色的子程序。他把全部150個子程序都暴露出來,也就意味著一旦想要修改底層實現細節,我們就得支持150個公用子程序。這位程序員沒有實現我們所需要的封裝,也給他自己帶來了大量無畏的工作。

? 根據具體情況的不同,正確的抽象可能是一個電子表格控件,也可能是一個柵格控件。當你不得不在兩個相似的抽象之間做出選擇時,請確保你的選擇時正確的。

1.3 提供成對的服務(參考亞控命名規範)

? 大多數操作都有和其相應的、相等的以及相反的操作。如果有一個操作用來把燈打開,那很可能也需要另一個操作來把燈關閉。如果有一個操作來向列表中添加項目,那很可能也需要另一個操作來從列表中刪除項目。如果有一個操作用來激活菜單項,那很可能也也需要另一個操作來屏蔽菜單項。在設計一個類的時候,要檢查每一個公用子程序,決定是否需要另一個與其互補的操作。不要盲目地創建相反操作,但你一定要考慮,看看是否需要它。

1.4 把不相關的信息轉移到其他類中

? 有時你會發現,某個類中一半子程序使用著該類的一般數據,而另一半子程序使用另一半數據。這時你其實已經把兩個類混在一起使用了,把他們拆開吧!

1.5 盡可能讓接口可編程,而不是表達語義

? 每個接口都有一個可編程的部分和一個語義部分組成。可編程的部分由接口中的數據類型和其他屬性構成,編譯器能強制性地要求它們(在編譯時檢查錯誤)。而語義部分則由“本接口將會被怎樣使用”的假定組成,而這些事無法通過編譯器來強制實施的。語義接口中包含的考慮比如“ RoutineA 必須在 RoutineB之前被調用”或“如果 dataMember 未經初始化就傳給 RoutineA 的話,將會導致 RoutineA 崩潰”。語義接口應通過註釋說明,但要盡可能讓接口不依賴於這些說明。一個接口中任何無法通過編譯器強制實施的部分,就是一個可能被誤用的部分。要想辦法把語義接口的元素抓換為編程接口的元素,比如說用 Asserts 或其他的技術。

1.6 謹防在修改時破壞接口的抽象

? 在對類進行修改和擴展的過程中,你常常會發現額外所需的一些功能。這些功能並不十分適應原有的類接口,可看上去卻也很難用另一種方法來實現。舉例來說,你可能會發現 Employee 類演變成了下面這個樣子。

//在維護時被破壞的類接口
class Employee{
  public:
    FullName GetName() const;
    Address GetAddress() const;
    PhoneNumber GetWorkPhone() const;
    ...
    bool IsJobClassificationValid(JobClassification jobClass);
    bool IsZipCodeValid(Address address);
    bool IsPhoneNumberVaild(PhoneNumber phoneNumber);
    SqlQuery GetQueryToCreateNewEmployee() const;
    SqlQuery GetQueryToModifyEmployee() const;
    SqlQuery GetQueryToRetrieveEmployee() const;
    ...
  private:
    ...
};

? 前面代碼示例中的清晰抽象,現在已經變成了由一些零散功能組成的大雜燴。在雇工和檢查郵政編碼、電話號碼或職位的子程序之間並不存在什麽邏輯上的關聯,那些暴露 SQL 語句查詢細節的子程序所處的抽象成比 Employee 類也要低得多,他們都破壞了 Employee 類的抽象。

1.7 不要添加與接口抽象不一致的公用成員

? 每次你向類的接口中添加子程序時,問問“這個子程序與現有接口所提供的抽象一致嗎?”如果發現不一致,就要換另一種方法來進行修改,以便能夠保證抽象的完整性。

1.8 同時考慮抽象性和內聚性

? 抽象性和內聚性這兩個概念之間的關系非常緊密 —— 一個呈現出很好的抽象的類接口通常也有很高的內聚性。而具有很強內聚性的類往往也會呈現為很好地抽象,盡管這種關系並不如前者那麽強。

? 我發現,關註類的接口所表現出來的抽象,比關註類的內聚性更有助於深入地理解類的設計。如果你發現某個類的內聚性很弱,也不知道該怎麽改,那就換一種方法,問問你自己這個類是否表現為一直的抽象。

2. 良好的封裝

? 封裝是一個比抽象更強的概念。抽象通過 提供一個可以讓你忽略實現細節的模型來管理復雜度,而封裝則強制阻止你看到細節 —— 即便你想這麽做。

? 這兩個概念之所以相關,是因為沒有封裝時,抽象往往很容易被打破。依我的經驗,要麽就是封裝與抽象兩者皆有,要麽就是兩者皆失。除此之外,沒有其他可能。

2.1 盡可能地限制類和成員的可 訪問性

? 讓可訪問性盡可能低是促成封裝的原則之一。當你在猶豫某個子程序的可訪問性設為公有、私有亦或受保護時,經驗之舉是應該采用最嚴格且可行的訪問級別。我認為這是一個很好地知道建議,但我認為還有更重要的建議,即考慮“采用哪種方式能最好地保護接口抽象的完整性?”如果暴露一個子程序不會讓抽象變得不一致的話,這麽做很可能是可行的。如果你不確定,那麽隱藏通常比少隱藏要好。

2.2 不要公開暴露成員數據

? 暴露成員數據會破壞封裝性,從而限制你對這個抽象的控制能力。一個 Point 類如果暴露了下面這些成員的話:

float x;
float y;
float z;

他就破壞了封裝性,因為調用方代碼可以自由地使用 Point 類裏面的數據, 而 Point 類卻甚至連這些數據什麽時候被改動過都不知道。然而,如果 Point 類暴露的時這些方法的話:

float GetX();
float GetY();
float GetZ();
void SetX(float x);
void SetY(float y);
void SetZ(float z);

那它還是封裝完好的。你無法得知底層實現用的是不是 float x、y、z,也不會知道 Point 是不是把這些數據保存為 double 然後再轉換成 float,也不可能知道 Point 是不是把它們保存在月亮上,然後再從外層空間中的衛星上把它們找回來。

2.3 避免把私用的實現細節放入類的接口中

? 做到真正的 封裝以後,程序員們是根本看不到任何實現細節的。無論是在字面上還是在喻義上,它們都被隱藏起來。然而,包括 C++ 在內的一些流行編程語言卻從語言結構上要求程序員在類的接口中透漏實現細節。

//暴露了類內部實現細節
class Employee{
public:
    ...
    Employee(
        FullName name,
        String address,
        String workPhone,
        String homePhone,
        TaxId taxIdNumber,
        JobClassification jobClass
    );
    ...
private:
    String m_Name;
    String m_Address;
    int m_jobClass;
    ...
};

? 把 private 段的聲明放到類的頭文件中,看上去似乎只是小小地違背了原則,但它實際是在鼓勵程序員查閱實現細節。在這個例子中,客戶代碼本意是要使用 Address 類型來表示地址信息,但頭文件中卻把“地址信息用 String 來保存”的這一實現細節暴露了出來。

? Scott Meyers 在《Effective C++》一書第 2 版中的第 34 條裏介紹了可以解決這個問題的一個管用技法。他建議你把類的接口與類的實現隔離開,並在類的聲明中 包含一個指針,讓該指針指向類的實現,但不能包含任何其他實現細節。

//隱藏了類的實現細節
class Employee{
public:
    ...
    Employee( ... ));
    FullName GetName() const;
    Address GetAddress() const;
    ...
private:
    EmplyeeImplementation* m_implementation;
};

? 現在你就可以把實現細節放到 EmplyeeImplementation 類裏了,這個類只對 Employee 類可見,而對使用 Employee 類的代碼來說則不可見。

? 如果你已經在項目裏寫了很多沒有采用這種方法的代碼,你可能會覺得把大量的現有代碼改成使用這種方法是不值得的。但是當你讀到那些暴露了其實現細節的代碼時,你就應該頂住誘惑,不要到類接口的私用部分去尋找關於實現細節的線索。

2.4 不要對類的使用者做出任何假設

? 類的設和實現應該符合在類的接口中所隱含的契約。它不應該對接口會被如何使用或不會被如何使用做出任何假設 —— 除非在接口中有過明確說明。向下面這樣一段註釋就顯示出這個類過多地假定了它的使用者。

//請把 x , y 和 z 初始化為 1.0,因為如果把它們初始化為 0.0 的話,DerivedClass 就會崩潰。
2.5 避免使用友元類

? 有些場合下,比如說 State 模式中,按照正確的方式使用友元類會有助於管理復雜度。但在一般情況下友元類會破壞封裝,因為它讓你在同一時刻需要考慮更多的代碼量,從而增加了復雜度。

2.6 不要因為一個子程序裏僅使用公開子程序,就把他歸入公開接口

? 一個子程序僅僅使用公用的子程序這一事實並不是十分重要的考慮因素。相反,應該問的問題是,把這個子程序暴露給外界後,接口所展示的抽象是否還是一致的。

2.7 讓閱讀代碼比編寫代碼更方便

? 閱讀代碼的次數要比編寫代碼多得多,即使在開發的初期也是如此。因此,為了讓編寫代碼更方便而降低代碼的可讀性是非常不經濟的。尤其是在創建類的接口時,即使某個子程序與接口的抽象不很相配,有時人們也往往把這個子程序加入到接口裏,從而讓正開發的這個類的某處調用代碼能更方便地使用它。然而,這段子程序的添加正式代碼走下坡路的開始,所以還是不要走出這一步為好。

2.8 要格外警惕從語義上破壞封裝性

? 我曾經認為,只要學會避免語法錯誤,就能穩操勝券。然而很快我就發現,學會避免語法錯誤僅僅是個開始,接踵而來的時無以計數的編碼錯誤,而其中大多數錯誤都比語法錯誤更難於診斷和更正。

? 比較起來,語義上的封裝性和語法上的封裝性二者的難度相差無幾。從語法的角度說,要想避免窺探另一個類的內部實現細節,只要把它內部的子程序和數據都聲明為 private 就可以了,這是相對容易辦到的。然而,要想達到語義上的封裝性就完全是另一碼事兒了。下面是一些類的調用方代碼從語義上破壞其封裝性的例子。

  • 不去調用 A 類的 InitializeOperations() 子程序,因為你知道 A 類的 PreformFirstOperation() 子程序會自動調用它。

  • 不再調用 employee.Retrive(database) 之前去調用 database.Connect() 子程序,因為你知道在未建立數據庫連接的時候 employee.Retrive() 會去連接數據庫。

  • 不去調用 A 類的 Terminate() 子程序,因為你知道 A 類的 PreformFinalOperation() 子程序已經調用它了。

  • 即便在 ObjectA 離開作用域之後,你仍去使用有 ObjectA 創建的、指向 ObjectB 的指針或引用,因為你知道 ObjectA 把 ObjectB 放置在靜態存儲空間中了,因此 ObjectB 肯定還可以用。

  • 使用 ClassB.MAXIMUM_ELEMENTS 而不用 ClassA.MAXIMUM_ELEMENTS,因為你知道它們兩個值是相等的。

    ? 上面這些例子的問題都在於,它們讓調用方代碼不依賴與類的公開接口,而是依賴於類的私有實現。每當你發現自己是通過查看類的內部實現來得知該如何使用這個類的時候,那就不是在針對接口編程,而是在透過接口針對內部實現編程了。如果你透過接口來編程的話,封裝性就被破壞了,而一旦封裝性開始遭到破壞,抽象能力也就快遭殃了。

    ? 如果僅僅根據類的接口文檔還是無法得知如何使用一個類的話,正確的做法不是拉出這個類的源代碼,從中查看其內部實現。這是個好的初衷,但卻是個錯誤的決斷。正確的做法應該是去聯系類的作者,告訴他“我不知道該怎麽使用這個類。”而對於類的作者來說,正確的做法不是面對面告訴你答案,而是從代碼庫中 check out 類的接口文件,修改類的接口文檔,再把文件 check in 回去,然後告訴你“看看現在你知不知道該怎麽用它了。”你希望讓這一次對話出現在接口代碼裏,這樣就能留下來讓以後的程序員也能看到。你不希望讓這一次對話值存在於自己的腦海裏,這樣會給使用該類的調用方代碼烙下語義上額微妙依賴性。你也不想然這一次對話只在個人之見進行,這樣只能讓你的代碼獲益,而對其他人沒有好處。

2.9 留意過於緊密的耦合關系
“耦合”是指兩個類之見關聯的緊密程度,通常,這種關系越松越好,根據這一概念可以得出以下一些指導建議:
  • 盡可能地限制類和成員的可訪問性。
  • 避免友元類,因為它們之間是緊密耦合的。
  • 在基類中把數據聲明為 private 而不是 protected,以降低派生類和基類之間耦合的程度。
  • 避免在類的公開接口中暴露成員數據。
  • 要對從語義上破壞封裝性保持警惕。
  • 察覺“ DEmeter 法則”
    耦合性與抽象性和封裝性有著非常密切的聯系。緊密的耦合性總是發生在抽象不嚴謹或封裝性遭到破壞的時候。如果一個類提供了一套不完整的服務,其他的子程序就可能要去直接讀寫該類的內部數據。這樣一來就把類給拆開了,把它從一個黑盒子變成一個玻璃盒子,從而事實上消除了類的封裝性。

07良好的類接口