代碼風格(5)——類

一、類應該短小

類和函數一樣應該短小。對於函數,我們通過計算代碼行數衡量大小。對於類,我們採用不同的衡量方法,計算 權責

類的名稱應當描述其權責。實際上,命名正是幫助判斷類的長度的第一個手段。如果無法爲某個類命以精確的名稱,這個類大概就太長了。類名越含混,該類越有可能擁有過多權責。例如,如果類名中包括含義模糊的詞,如 Processor 或 Manager 或 Super,這種現象往往說明有不恰當的權責聚集情況存在。

1.1 單一權責原則

單一權責原則(SRP)認爲,類或模塊應有且只有 一條加以修改的理由。該原則既給出了權責的定義,又是關於類的長度的指導方針。類只應有一個權責——只有一條修改的理由

public class SuperDashboard extends JFrame implements MetaDataUser
{
    public Component getLastFocusedComponent()
    public void setLastFocused(Component lastFocused)
    public int getMajorVersionNumber()
    public int getMinorVersionNumber()
    public int getBuildNumber()
}

上述 SuperDashboard 類有兩條加以修改的理由。首先,它跟蹤大概會隨軟件每次發佈而更新的版本信息。第二,它管理 Java Swing 組件(派生自 JFrame,頂層 GUI 窗口的 Swing 表現形態)。每次修改 Swing 代碼時,無疑都要更新版本號,但反之未必可行:也可能依據系統中其他代碼的修改而更新版本信息。

鑑別權責(修改的理由)常常幫助我們在代碼中認識到並創建出更好的抽象。可以輕易地將全部三個處理版本信息的 SuperDashboard 方法拆解到名爲 Version 的類中。Version 類是個極有可能在其他應用程序中得到複用的構造!

public class Version
{
    public int getMajorVersionNumber()
    public int getMinorVersionNumber()
    public int getBuildNumber()
}

再強調一下:系統應該由許多短小的類而不是少量巨大的類組成。每個小類封裝一個權責,只有一個修改的原因,並與少數其他類一起協同達成期望的系統行爲。

1.2 內聚

類應該只有少量實體變量。類中的每個方法都應該操作一個或多個這種變量。通常而言,方法操作的變量越多,就越黏聚到類上。如果一個類中的每個變量都被每個方法所使用,則該類具有最大的內聚性。

一般來說,創建這種極大化內聚類是既不可取也不可能的;另一方面,我們希望內聚性保持在較高位置。內聚性高,意味着類中的方法和變量相互依賴、互相結合成一個邏輯整體。

如下 Stack 類的實現方法。這個類非常內聚。在三個方法中,只有 size() 方法沒有使用所有兩個變量。

public class Stack
{
    private int topOfStack = 0;
    List<Integer> elements = nes LinkedList<Integer>();

    public int size()
    {
        return topOfStack;
    }

    public void push(int element)
    {
        topOfStack++;
        elements.add(element);
    }

    public int pop() throws PoppedWhenEmpty
    {
        if(topOfStack == 0)
        {
            throw new PoppedWhenEmpty();
        }
        int element = elements.get(--topOfStack);
        elements.remove(topOfStack);
        return element;
    }
}

保持函數和參數列表短小的策略,有時會導致爲一組子集方法所用的實體變量數量增加。出現這種情況時,往往意味着至少有一個類要從大類中掙扎出來。你應當嘗試將這些變量和方法分拆到兩個或多個類中,讓新的類更爲內聚。

1.3 保持內聚性就會得到許多短小的類

僅僅是將較大的函數切割爲小函數,就將導致更多的類出現。想想看一個有許多變量的大函數。你想把該函數中某一小部分拆解成單獨的函數。不過,你想要拆出來的代碼使用了該函數中聲明的4個變量。是否必須將這4個變量都作爲參數傳遞到新函數中去呢?

完全沒必要!只要將4個變量提升爲類的實體變量,完全無需傳遞任何變量就能拆解代碼了。應該很容易將函數拆分爲小塊。

可惜這也意味着類喪失了內聚性,因爲堆積了越來越多隻爲允許少量函數共享而存在的實體變量。如果有些函數想要共享某些變量,爲什麼不讓它們擁有自己的類呢?當類喪失了內聚性,就拆分它!

所以,將大函數拆爲許多小函數,往往也是將類拆分爲多個小類的時機。程序會更加有組織,也會擁有更爲透明的結構。

二、構造函數

2.1 總述

不要在構造函數中調用虛函數,也不要在無法報出錯誤時進行可能失敗的初始化。

2.2 定義

在構造函數中可以進行各種初始化操作。

2.3 優點

  • 無需考慮類是否被初始化。
  • 經過構造函數完全初始化後的對象可以爲 const 類型,也能更方便地被標準容器或算法使用。

2.4 缺點

  • 如果在構造函數內調用了自身的虛函數,這類調用是不會重定向到子類的虛函數實現。即使當前沒有子類化實現,將來仍是隱患。
  • 在沒有使程序崩潰 (因爲並不是一個始終合適的方法) 或者使用異常 (因爲已經被 禁用 了) 等方法的條件下,構造函數很難上報錯誤。
  • 如果執行失敗,會得到一個初始化失敗的對象,這個對象有可能進入不正常的狀態,必須使用 bool IsValid() 或類似這樣的機制才能檢查出來,然而這是一個十分容易被疏忽的方法。
  • 構造函數的地址是無法被取得的,因此,舉例來說,由構造函數完成的工作是無法以簡單的方式交給其他線程的。

2.5 結論

  • 構造函數不允許調用虛函數。如果代碼允許,直接終止程序是一個合適的處理錯誤的方式。否則,考慮用 Init() 方法或工廠函數。
  • 構造函數不得調用虛函數,或嘗試報告一個非致命錯誤。如果對象需要進行有意義的 (non-trivial) 初始化,考慮使用明確的 Init() 方法或使用工廠模式。Avoid Init() methods on objects with no other states that affect which public methods may be called (此類形式的半構造對象有時無法正確工作)。
  • 不在構造函數中做太多邏輯相關的初始化。

三、結構體和類

僅當只有數據成員時使用 struct,其它一概使用 class

在 C++ 中 struct 和 class 關鍵字幾乎含義一樣。我們爲這兩個關鍵字添加我們自己的語義理解,以便爲定義的數據類型選擇合適的關鍵字。

struct 用來定義包含數據的被動式對象,也可以包含相關的常量,但除了存取數據成員之外,沒有別的函數功能。並且存取功能是通過直接訪問位域,而非函數調用。除了構造函數,析構函數,Initialize()Reset()Validate() 等類似的用於設定數據成員的函數外,不能提供其它功能的函數。

如果需要更多的函數功能,class 更適合。如果拿不準,就用 class

爲了和 STL 保持一致,對於仿函數等特性可以不用 class 而是使用 struct

注意:類和結構體的成員變量使用不同的命名規則。

四、繼承

4.1 總述

使用組合常常比使用繼承更合理。如果使用繼承的話,定義爲 public 繼承。

4.2 定義

當子類繼承基類時,子類包含了父基類所有數據及操作的定義。C++ 實踐中,繼承主要用於兩種場合:實現繼承,子類繼承父類的實現代碼;接口繼承,子類僅繼承父類的方法名稱。

4.3 優點

實現繼承通過原封不動的複用基類代碼減少了代碼量。由於繼承是在編譯時聲明,程序員和編譯器都可以理解相應操作並發現錯誤。從編程角度而言,接口繼承是用來強制類輸出特定的 API。在類沒有實現 API 中某個必須的方法時,編譯器同樣會發現並報告錯誤。

4.4 缺點

對於實現繼承,由於子類的實現代碼散佈在父類和子類間之間,要理解其實現變得更加困難。子類不能重寫父類的非虛函數,當然也就不能修改其實現。基類也可能定義了一些數據成員,因此還必須區分基類的實際佈局。

4.5 結論

所有繼承必須是 public 的。如果你想使用私有繼承,你應該替換成把基類的實例作爲成員對象的方式。

不要過度使用實現繼承。組合常常更合適一些。儘量做到只在 “是一個” (“is-a”, YuleFox 注: 其他 “has-a” 情況下請使用組合) 的情況下使用繼承:如果 Bar 的確 “是一種” FooBar 才能繼承 Foo

必要的話,析構函數聲明爲 virtual。如果你的類有虛函數,則析構函數也應該爲虛函數。

對於可能被子類訪問的成員函數,不要過度使用 protected 關鍵字。 注意,數據成員都必須是 私有的

對於重載的虛函數或虛析構函數,使用 override, 或 (較不常用的) final 關鍵字顯式地進行標記。較早 (早於 C++11) 的代碼可能會使用 virtual 關鍵字作爲不得已的選項。因此,在聲明重載時,請使用 override, final 或 virtual 的其中之一進行標記。標記爲 override 或 final 的析構函數如果不是對基類虛函數的重載的話,編譯會報錯,這有助於捕獲常見的錯誤。這些標記起到了文檔的作用,因爲如果省略這些關鍵字,代碼閱讀者不得不檢查所有父類, 以判斷該函數是否是虛函數。

四、聲明順序

將相似的聲明放在一起,將 public 部分放在最前。

類定義一般應以 public: 開始,後跟 protected:,最後是 private:。省略空部分。

在各個部分中,建議將類似的聲明放在一起,並且建議以如下的順序:類型 (包括 typedef, using 和嵌套的結構體與類),常量,工廠函數,構造函數,賦值運算符,析構函數,其它函數,數據成員。

不要將大段的函數定義內聯在類定義中。通常,只有那些普通的,或性能關鍵且短小的函數可以內聯在類定義中。參見 內聯函數 一節。


• 由 Leung 寫於 2019 年 11 月 3 日

• 參考:Google 開源項目風格指南——3. 類
    [代碼整潔之道]

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章