讓 C++ 程式碼說話:有些事,讓編譯器替你把關

前言

通常來說,撰寫程式碼往往不會是自己一個人的事情,你需要和別人合作。你會用到別人的程式碼,而別人也會使用到你的程式碼,這個時候如何讓 資訊 被正確傳遞就顯得相當重要了。

我應該繼承 XXX 類別來特殊化他嗎?
我能夠複寫 OOO 類別內的方法嗎?
這個函式會丟出例外嗎?
...

理論上,這些東西都能藉由 文件 來傳遞,但現實是,沒有體制,沒有自制力的情況下,文件要不壓根不存在,要不就是過期,不齊全,更枉論... 呃... 實驗室的專案。

在 C++ 中,除了用文字形式的文件來記錄這些事項外,其實語言本身也提供了些關鍵字讓開發者將這些事情 寫到 程式碼內,某些關鍵字也能在編譯時替你把關,阻擋那些不正確的使用。

話說在前頭

我並不是要否定文件存在的意義,說的直白些,我現在把這些技巧當作 自我保護 的手段。

"我話說的很白了,用錯,不關我的事!!!"

文件當然非常有幫助,程式碼關鍵字能傳遞的資訊終究有限,而這些不足僅能透過文件加以描述,除此之外毫無辦法 (例如 C++ 截至目前,函式會拋出何種例外依然僅能靠文件記錄)。然而在某些場合,你無法保證 (或是要求) 文件品質的時候,把某些限制用程式語言的語法 直接 擋住,比起用註解寫一行 這個 class 不該被繼承,我想後者毋庸置疑是更優秀的方案。

正題

那麼,下面就列出一些個人平常使用的東西吧!

virtual method

class Base {
    virtual void aMethod() = 0;
};

virtual 通常來說是一種很強烈的提示,它告知實作者這個方法是渴望被 複寫 的,告知這個類別是希望被以 繼承 的方式來使用的 (畢竟那就是 virtual method 存在的目的)。反過來說,如果一個類別,一個方法不期望被其他人繼承/複寫,就請不要加上 virtual 修飾字,這樣不僅會向其他人傳達錯誤的訊息,同時也會增加函式呼叫的成本 (查找 vtable)。

class/struct 的 final 修飾字

struct Foo final {};

class Base {};
class Derived final : public Base{};

在類別後面加上 final 後,等同於告訴編譯器 這個類別不該再被繼承複寫了,任何對該類別的繼承行爲在編譯時都會被視爲錯誤回報。final 關鍵在在這裏可以防止類別被錯誤的繼承,過去 (C++ 11 以前) 我們可能會觀察某類別是否含有 virtual method 來判別該類別是否期望以繼承的形式被使用,但是這樣的方式在繼承超過一層後便幾乎失去效果,你無法判斷第二層繼承類別的實作者希望你幹嘛?是要你住手別動,還是希望你以這個類別爲基礎繼續特殊化下去?但看程式碼,你無從得知。

加入 final 後事情就會明瞭許多,至少,你知道你不該繼承它 (你也不行)。對我個人來說,這確保了我寫的類別不會被其他人誤用然後回頭燒到我,而這份 無理取鬧 也不會無限的擴增下去。相信我,實驗室專案多得是亂七八糟的繼承,而阻止這一切的,就是 final (啊,不排除有人去修改標頭檔案然後把 fianl 刪掉就是。至少,我警告過其他人了)。

virtual method 的 final 修飾字

struct Base {
    virtual void aMethod() = 0;
    virtual void bMethod() = 0;
};

struct Derived : public Base {
    virtual void aMethod() final {}
    virtual void bMethod() {}
};

當 final 用在 virtual method 上時,宣告的意義是 這個虛擬函式的特殊化到此爲止,該住手了,後續繼承 Derived 類別的東西,就沒辦法繼續特殊化 aMethod。這個宣告個人其實比較少用到,也沒辦法給於什麼評價或是建議。

noexcept

void
doSmoething() noexcept {
    // ...
}

noexcept 修飾字對外宣告 這個函式不會丟出例外,請安心使用,如此,呼叫者不消考慮例外也不消處理。noexcept 可以用來修飾函式和類別方法,他們表達相同的意義,在 meta programing 時也能夠用 noexcept operator 來取得某函式或是方法是否有 noexcept 修飾字。

但是!!!

noexcept 僅僅是表達出 不會丟例外 的訊息而已,並無法檢查函式內的操作有無例外噴出來,如果函式實作有例外噴出來程式會直接以呼叫 std::terminate 結束,這點請千萬要注意!

這樣有什麼好處?第一點,任何例外的溢出都會導致程式結束,這在某種程度上逼迫函式實作者去接並且處理所有可能的例外。第二點,呼叫者不需要處理例外,因爲它 不該 出現,因例外溢出而導致的程式終止,責任並不在呼叫者,而是函式實作者。

個人認爲這項政策的導入可以更有效的分隔例外處理的職責,以及有效的偵測例外溢出的情況。在設計某些函式時,個人常常會希望例外在函式內就吸收掉,減少外界對函式實作的認知 (例如開檔且做處理的函式,呼叫者即便知道檔案不存在或是格式不正確也沒辦法做什麼,檔案就是讀失敗了。我能想到的例外丟出理由只剩下爲了 log 錯誤原因,但即使在這情境下,重新包裝例外或是用 std::error_code,比起自動傳遞出去,似乎都是比較適合的方案。)

離題

寫到這裏才想到有些技巧其實不太歸屬於 表達意圖 的類型,嘛,就一次把它們記錄在這裏吧。

override

struct Base {
    virtual void aMethod() = 0;
    virtual void bMethod() = 0;
};

struct Derived : public Base {
    virtual void aMethod() override {}
    virtual void bMethod() override {}
};

在方法後面加上 override 表示這個實作是在 複寫 base class 版本的 虛擬 函式實作。如果加上 override 卻不是在複寫,程式編譯時便會出錯。整體來說這個修飾字偏向於保護自己的類型,過去我就有以爲自己在複寫父類別方法,結果那個方法根本不是虛擬方法的慘痛經驗,加上 override 可以非常有效地避免掉這種錯誤。

簡單來說,是加了有好處,不加吃虧的類型。

測試

該小結爲個人意見,由於本人經驗淺薄,請酌量參考並給於指教

前面說了這麼多,但是終究還是敵不過 我要開檔案把它改掉 大法的攻擊,繼承不了?把 final 刪掉,懶得寫好架構?乾脆把方法加上 virtual 直接繼承,這實在是無法招架。爲了防止這種直接的破壞,替這些限制撰寫測試似乎是個不錯的方法。C++ 11 後 <type_traits> 標頭檔提供了許多檢查類別特性,檢查函式特性的泛型函式,例如,檢查某物件是否支援 move operation,是否支援 copy constructor 等等。將這些限制條件作爲測試的項目之一,雖然不是無敵,但是最起碼給了一層保護。

啊,當然,要讓測試達到保護效果,開發團隊至少要有自己的 CI/CD 流程,讓跑測試這件事自動化,讓每次送 patch 都跑一次,確保每次 commit 都是有效的修改。

這樣的手段到底效果如何,說實在話我並不太確定。我確實有受惠於此的經驗,但是並不多,就目前來說,我會優先替比較危險的東西撰寫這樣的保護測試,然後略過多數無所謂的。像是過去個人替某個 Singleton template class 撰寫一些保護測試,來確定繼承該類別的物件不具有 copy operation/constructor,途中也確實有不小心修改錯誤,在測試時發現的情況。所以我想,這手段或許有些許價值。

結語

當你發現,程式碼的註解要不是過期,或是亂七八糟,或是根本沒有的時候,你的第一件事就是找到把所有註解都隱藏的功能。

我並不是不相信人性的那種人,但是 (啜一口茶),事實就是天才太多,你最好把事情寫白,白到編譯時期就能阻止。好像有點消極啊。不管怎樣,把訊息放入程式碼關鍵字中,無疑是種增加程式碼強健度的方法,你不僅是強力且直接地表現除了你的意圖,其他人也能在編譯器的保護之下正確的使用您設計的程式碼,營造雙贏的場面。

當然,最好的方法還是要佐以正確,格式工整,跟上最新版本的文件/註解,才能更完整地提升程式碼品質!

你還知道其他的小技巧嗎?或是哪些增強程式碼表現能力的方法呢?歡迎各位在下面留言和其他人分享您的經驗!如果你喜歡這篇文章,也請不要吝嗇地和其他人分享吧 :)

Comments

comments powered by Disqus