【C++代碼整潔之道】Type-Rich編程——利用編譯器保障類型安全

本系列文章均整理自《C++代碼整潔之道——C++17可持續軟件開發模式實踐》

Type-Rich 編程

不要相信名字
而要相信類型
因爲類型不會說謊
類型是你的好朋友
—— Mario Fusco

1999年9月23日, NASA 失去了它的太空探測器 Mars Climate Orbiter I. 這次事故的直接原因可以歸結於參與這項工程的兩個工作小組一個使用 國際單位制 作爲參數單位, 而另一個小組使用 英制單位. 兩者的系統各自獨立都可以正常工作, 而最後的結果則暴露出軟件開發中一個經常被忽視的環節——溝通. 雖然這次事故的重點不在於類型不匹配, 但是這次慘痛的事故恰恰可以說明 Type-Rich 編程的價值.

在我看來, Type-Rich 編程不一定在所有場合都是必要的, 不過可以想象在涉及到各種物理量的系統中, Type-Rich 編程一定是大放異彩的. 這種編程思想我認爲是值得思考的, 接下來我們就來探討何爲 Type-Rich 編程以及 Type-Rich 編程的價值.

一種無設計感的接口

我們幾乎無時無刻不在設計這樣的接口:

class SpacecraftTrajectoryControl
{
public:
    void applyMomentumToSpacecraftBody( const double impulseValue );
};

這樣的接口設計可以做到讓程序正確運行, 但是隱形中這個接口將一個責任拋給了接口的使用者:

接口的使用者需要自行保證參數的物理含義符合接口內部的實現 !

這裏需要注意的是: 一個基礎類型 double 不能提供任何信息. 我們需要的實參值到底是什麼單位, 使用什麼單位制都是無法確定的. double 是一種數據類型, 但是他不是一種語義類型.

一種解決方案是我們可以將形參名修改爲 impulseValueInNewtonSeconds, 這是一種比什麼都不做好一點的方法, 但是本質上並不能避免用戶傳入一個錯誤單位的值.

可能另一種更加常見的方法是給接口添加註釋, 但其實這種做法的問題是和修改參數名一樣, 而且在原書中的前幾章作者也對註釋做了一定的說明. 結合筆者自身的工作實踐, 項目代碼對註釋的容忍度應該非常低, 我們更傾向於代碼本身就是最好的註釋, 代碼應該是自解釋的, 當你必須使用一段註釋來解釋代碼時可以被認爲這就是一種 壞味道.

Type-Rich 編程下的接口設計

那麼我們如何做的更好的一點? Type-Rich 編程來了 !

class SpacecraftTrajectoryControl
{
public:
    void applyMomentumToSpacecraftBody( const Momentum& impulseValue );
};

我們使用明確定義的 Momentum 類型來作爲接口參數的類型, 而不是一個沒有語義價值的 double. 這時一些我們不期望的非法調用將導致編譯錯誤.

SpacecraftTrajectoryControl control;
const double someValue = 15.56;
control.applyMomentumToSpacecraftBody( someValue ); // 編譯時報錯!
Force force { 15.56 };
control.applyMomentumToSpacecraftBody( force ); // 編譯時報錯!
Momentum momentum { 15.56 };
control.applyMomentumToSpacecraftBody( momentum ); // 正確做法!

其實這就是 Type-Rich 編程, 其最大的意義是: 類型安全在編譯期間即得到保障! 另外統一運用這種方法也可以避免程序邏輯中出現無意義的數字運算.

換句話說, 我們應該在很大程度上避免在公共接口 API 中使用通用的, 底層的內置類型, 比如 int, double, 或者最壞的 void* 等等.

在 C++ 下針對物理量的 Type-Rich 編程

上面其實已經闡述完了 Type-Rich 編程的核心思想, 可以看出其並不複雜, 本質上就是利用編譯器來保證正確性, 編譯器是不會說謊的~ 下面是在 C++ 下針對物理量進行的一次 Type-Rich 編程實踐.

首先我們定義一個模板來表示基於 MKS 單位體系的物理量. 縮寫 MKS 分別表示米(長度), 千克(質量)和秒(時間). 這三種基礎單位組合起來可以表示任何給定的物理單位. 另外我們還需要一個表示值的類模板.

template <int M, int K, int S>
struct MksUnit
{
    enum { metre = M, kilogram = K, second = S };
};

template <typename MksUnit>
class Value
{
private:
    long double magnitude{ 0.0 };

public:
    explicit Value( const long double magnitude ) : magnitude( magnitude ) { }
    long double getMagnitude( ) const { return magnitude; }
};

接下來我們就可以用這兩個類來定義具體物理量的別名了

using Length = Value< MksUnit<1, 0, 0> >;
using Area = Value< MksUnit<2, 0, 0> >;
using Time = Value< MksUnit<0, 0, 1> >;
using Speed = Value< MksUnit<1, 0, -1> >;
using Force = Value< MksUnit<1, 1, -2> >;
using Momentum = Value< MksUnit<1, 1, -1> >;
// 等等

同時可以使用 constexpr 關鍵字實現 Value 類的編譯時計算, 而且在實現了必要的運算符重載之後, 不同單位間的計算也成爲可能. 具體實現不再贅述, 用法如下.

constexpr Momentum impuseValueForCourseCorrection = Force{ 30.0 } * Time{ 3.0 }; // 避免 double impuse = 30.0 * 3.0;
SpacecraftTrajectoryControl control;
control.applyMomentumToSpacecraftBody( impuseValueForCourseCorrection );

利用 C++ 新特性的 trick

在現代 C++ 中, C++ 11 之後我們可以爲文字提供自定義後綴來爲他們定義特殊的函數, 也就是所謂的 文字操作符. 一旦定義了文字操作符之後, 就可以像下面的代碼中那樣使用他們. 這種形式對於領域專家來說更加友好, 也更安全.

constexpr Time operator"" _s(long double magnitude) {
    return Time( magnitude );
}

Time time = 10.0; // 編譯時報錯!
Time time = 10.0_N; // 編譯時報錯!
Time time = 10.0_s; // 正確!
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章