博客 / 詳情

返回

C++ 語言特性的變更可能讓你的防禦成為馬奇諾防線

馬奇諾防線

馬奇諾防線是法國在1930年代修建的一道大型防禦工事系統。防線由複雜的地下工事網絡組成,包括炮台、掩體、地下兵營、彈藥庫和指揮所等設施,有些地下工事深達數十米。

然而,在1940年的法國戰役中,馬奇諾防線並未發揮預期作用,德軍繞過馬奇諾防線,通過比利時和阿登森林發動突襲,迅速擊敗了法軍。這使得馬奇諾防線成為了"過時防禦思維"的象徵。

代碼中的防禦

日常工作中,編寫一些功能類,或者一個相對完備的功能模塊是比較常有的,我們編寫它們要麼是供其他同事使用,要麼作為積累和展示。一旦想到自己的代碼可能被其他同行調用,我們就會一改平時漫不經心的態度,幻想着他人會觸碰我們代碼中的邏輯缺陷,敗壞他們的程序,自己落得顏面掃地。

於是我們步步為營,每添加或修改一點功能就會仔細推敲有可能發生的意外,運用自己掌握的手段攘除它們滋生的可能,保證我們的代碼永遠行駛在正軌之上,這就像代碼中的防禦工事。

但是這種防禦與網絡和數據安全領域的防禦又有所區別,後者的防禦對象經常是那些圖謀不軌者,就像戰爭中的敵人一樣與我們是對立的。而我們的這種防禦更像是一種輔助,它讓我們的用户儘可能不陷入困境,就像 Effective C++ 中説的,讓接口更難被誤用。

一個例子

假設我們有一個比較複雜的類 Complex,它有諸多成員變量,其中某些成員組合在一起可以描述一個抽象的屬性,多個屬性可能會涉及相同的成員變量,將與某個屬性關聯的成員打包到一個屬性類中是不可行的。

一個解決方案是,將各種的抽象屬性封裝為不同的代理類,它們各自只引用自己關心的 Complex 數據成員,通過這些代理類來讀取或修改相應的屬性,思路如圖:

一個確切的例子,假如我們有一個房子,它的內部包含多種家用電器,現在我們想為這個房子的智能控制系統編寫一系列的模式,比如歸家模式、電影模式、度假模式等等。我們就可以將這些模式包裝為多個類,各個模式只引用它們需要操作的電器,用以查詢或改變房子的狀態。假如我們編寫了如下代碼:

代碼

class House;
class MovieMode
{
public:
    enum class Atmosphere
    {
        Romantic,
        Action,
        Horror,
        Drama,
        //其他氛圍
        ...
    };
bool is_on() const;//電影模式是否開啓 void turn_on();//開啓電影模式
Atmosphere get_atmosphere() const;//獲取觀影氛圍 void set_atmosphere(Atmosphere ap);//設置觀影氛圍
//其他接口 ...
//明確表明不希望任何形式的複製 MovieMode(const MovieMode &) = delete; MovieMode &operator=(const MovieMode &) = delete; MovieMode &operator=(MovieMode &&) noexcept = delete;
private: MovieMode(House &house, Light &mood_light, Device &_projector, Device &stereo_system);
//用於從函數返回 MovieMode(MovieMode &&) noexcept = default;
private: House &_house;//操作的對象 Light &_mood_light;//氛圍燈 Device &_projector;//投影儀 Device &_stereo_system;//音響 //其他可能關聯的設備 ...
//將House聲明為友元,使它能夠創建和返回本類的實例 friend class House; };
class House { public: //構造、設置等接口 ...
//打開所有燈 void turn_on_all_lights(); //關閉所有燈 void turn_off_all_lights();
//打開所有電器 void turn_on_all_devices(); //關閉所有電器 void turn_off_all_devices();
//創建電影模式 MovieMode movie_mode(); const MovieMode movie_mode() const;
//創建其他模式 ... private: //各種燈具 Light _entry_light;//玄關燈 Light _corridor_light;//過道燈 Light _mood_light;//氛圍燈 Light _kitchen_light;//廚房燈 //其他的燈具 ...
//各種電器 Device _refrigerator;//電冰箱 Device _projector;//投影儀 Device _stereo_system;//音響 Device _air_filter;//空氣淨化器 //其他電器 ... }; {{the-copyright}} authored by cnblogs @saltymilk https://www.cnblogs.com/saltymilk/p/18998670 {{/the-copyright}}

這段代碼顯得有些粗製濫造,實際情況中,應該為各種家用電器設計一套繼承體系方便分類與管理,房子也可以分割為不同的房間,每個家用電器可以放置到不同的房間內。但是作為例子,用以説明本文的意圖足矣。

可以看到,MovieMode 類除定義了與功能相關的接口外,其構造函數、拷貝控制系列函數都做了顯式定義。而 House 類創建 MovieMode 的接口區分 const 和非 const 兩個版本。這樣的定義主要有以下考量:

1. MovieMode 構造函數和移動構造函數為私有,只能由友元 House 類的接口創建它的實例和從函數中返回:

代碼

MovieMode House::movie_mode()
{
    return MovieMode(*this, _mood_light, _projector, _stereo_system);
}
const MovieMode House::movie_mode() const { return const_cast(this)->movie_mode(); }

2. MovieMode 的拷貝構造、拷貝賦值、移動賦值函數皆顯式刪除,除通過 House 的實例獲取外,用户無法以其他方式創建或拷貝 MovieMode 的實例:

代碼

House house;
house.movie_mode().turn_on();
house.movie_mode().set_atmosphere(MovieMode::Atmosphere::Romantic);
MovieMode mode = house.movie_mode();//錯誤,無法拷貝或移動構造 MovieMode

這點很重要,因為 MovieMode 是一個代理類,它的內部保存着一個對 House 對象的引用,所以它的生命週期必須是它所綁定的 House 對象的子集,否則對它的讀寫操作就可能是在操作一個空懸引用。

3. House 創建 MovieMode 的接口區分 const 和非 const 版本,這保證了 const House 實例不會被意外修改

代碼

const House chouse;
MovieMode::Atmosphere ap = chouse.movie_mode().get_atmosphere();
chouse.movie_mode().turn_on();//錯誤,無法通過 const MovieMode 對象調用

經過測試用例的驗證,這一系列防禦措施確實可以防止預想中各種意外情況的發生,於是我們滿懷信心地將這份代碼提供了出去。

防禦失效

假如經過一段時間後,我們的這個模塊被其他人引用,但是我們發現了這樣的代碼,讓我們後背一涼:

代碼

const House chouse;
auto mode = chouse.movie_mode();
mode.set_atmosphere(MovieMode::Atmosphere::Romantic);
{{the-copyright}} authored by cnblogs @saltymilk https://www.cnblogs.com/saltymilk/p/18998670 {{/the-copyright}}

後兩句代碼直接無視了我們精心設下的防禦工事,既“複製”了 MovieMode 對象,還通過它修改了一個 const House 對象的狀態!這讓我們有點驚訝,我們在測試代碼時這樣的語句已經確定是無法通過編譯的,但是現在它們活生生的在我們眼前,編譯器沒有一點阻撓地編譯成功了。經過一番對比後我們恍然大悟:這些代碼是使用 C++17 語言標準編譯,而我們測試的環境是 C++14。

讀到此處有的讀者可能會想,C++26 都快發佈了,為什麼還用 C++14 ?在作者所處的傳統軟件工作環境中,C++17 及之後的版本普及率確實相當低,很多項目甚至還在用 C++11,像作者這種新手才會好奇地擺弄新的語言特性。

我們都知道 C++ 的拷貝省略(Copy Elision),而在這篇博客裏我正好已經討論過它與語義檢查的關係。C++14 標準下語義檢查會拒絕上述代碼的寫法,因為它引用了無法訪問的移動構造函數,而到了 C++17 中,由於強制拷貝省略要求這種情形下必須省略移動構造函數的調用,編譯器只需檢查在 mode 真正的構造函數調用處(movie_mode 的函數內部,更具體地説,是非 const 版本的 House 的 movie_mode 內部)MovieMode 的構造函數可訪問即可,而由於 House 類是 MovieMode 類的友元,在它的成員函數內構造 MovieMode 對象當然是沒問題的。

auto mode = chouse.movie_mode(); 這一句的語義是從一個函數返回的臨時的 const MovieMode 對象構建一個非 const 的 MovieMode 對象 mode,需要經歷一次構造,多次移動和移動構造,然而在強制拷貝省略的前提下,只需在真正的創建點原位構造一個非 const 的 MovieMode 對象即可,所以上述代碼可成功編譯。

就像馬奇諾防線一樣,我們的防禦失效了,新的語言標準可以讓危險代碼繞過我們堅固的防禦工事直擊要害。這讓我想起以前玩植物大戰殭屍時的情形:我佈置了強大的地面火力和結實的高堅果牆,但是突然出現幾隻氣球殭屍大搖大擺地飛進房子吃掉了腦子。

不過幸好這不像戰爭一樣只有一次機會,我們可以對我們的防禦工事進行補救。

方案一(成員函數引用限定符)

強制拷貝省略會直接無視我們的防禦措施所倚仗的流程,那麼有沒有辦法讓強制拷貝省略失效?查閲了一些資料看到説在返回臨時對象的函數內增加一些分支邏輯可以干擾編譯器對於強制拷貝省略可行性的判斷,這樣我們可以犧牲一點點效率來重新獲取安全性,比如:

代碼

MovieMode House::movie_mode()
{
    uintptr_t addr = (uintptr_t)this;
MovieMode *pm = nullptr;
if(addr & 0x1000)//一個運行時才能確定的判斷 return MovieMode(*this, _mood_light, _projector, _stereo_system); else { MovieMode mode(*this, _mood_light, _projector, _stereo_system); pm = &mode; return std::move(*pm); } }

上述代碼為了製造一些干擾已經非常刻意了,然而可惜的是,使用 MSVC、clang、GCC 對這段代碼進行編譯,在語言標準設定為 C++17 的情況下,auto mode = chouse.movie_mode(); 這一句代碼全都順利通過編譯,看來編譯器在這方面都是非常激進的。不過就算這樣能夠成功阻止編譯器施行強制拷貝省略,我也不打算編寫這樣醜陋不堪的代碼。

既然幾乎無法阻止獨立的 MovieMode 被構造出來,我們就要在其他地方想辦法了。有很長一段時間我都想不出什麼合適的方法來,直到一次翻 Effective Modern C++ 時,在條款十二(Declare overriding functions override)中看到成員函數的引用限定符這個 C++11 添加的語言特性。這個概念在我最開始讀 C++ Primer 時就知曉,但是以當時我的代碼經歷來説,這個特性毫無用武之地,之後的開發中也沒有用過它,所以漸漸淡忘了,再次看到它時我是非常激動的,它就是我尋找的東西!真是 “初聞不識曲中意,再聽已是曲中人” 啊。考慮到:

代碼

House house;
house.movie_mode().set_atmosphere(...);//set_atmosphere 是在一個右值上調用的
auto mode = house.movie_mode();
mode.set_atmosphere(...);//set_atmosphere 是在一個左值上調用的

添加一個右值引用限定符,我們就能限制 set_atmosphere 只能通過 MovieMode 的右值對象調用了:

代碼

class MovieMode
{
public:
    void set_atmosphere(Atmosphere ap) &&//右值引用限定
//其他成員 ... };
House house; house.movie_mode().set_atmosphere(...);//OK auto mode = house.movie_mode(); mode.set_atmosphere(...);//錯誤,不能在 lvalue 上調用 MovieMode::set_atmosphere {{the-copyright}} authored by cnblogs @saltymilk https://www.cnblogs.com/saltymilk/p/18998670 {{/the-copyright}}

現在就算用户得到一個獨立的 MovieMode 對象,也無法用它來調用 set_atmosphere 了。同理我們可以為 MovieMode 的其他接口都添加右值引用限定(const 屬性仍保持原樣),這樣就算用户通過某種方法獲得了一個空懸的 MovieMode 對象,他也沒法在其上做出進一步的操作而引發未定義行為了。

方案二(依賴穩定的語言特性)

上述諸多麻煩的根本原因是,我們的特性依賴了一個不是長期保持不變的語義,一旦這個語義發生根本性的改變,我們就必須做出調整。

我們知道,從一個非臨時的對象構造另一個對象需要調用拷貝構造函數這一語言規則是一定不會被改變的,我們的 House 類可以將控制系統支持的所有模式作為成員存儲在類內,只返回它們的引用:

代碼

class House;
class MovieMode
{
public:
    //明確表明不希望任何形式的複製
    MovieMode(const MovieMode &) = delete;
    MovieMode &operator=(const MovieMode &) = delete;
    MovieMode(MovieMode &&) noexcept = delete;
    MovieMode &operator=(MovieMode &&) noexcept = delete;
//其他成員函數 ... private: MovieMode(House &house, Light &mood_light, Device &_projector, Device &stereo_system); friend class House;
//其他成員 ... };
class House { public: MovieMode &movie_mode() { return _movie_mode; } const MovieMode &MovieMode() const { return _movie_mode; }
//其他成員函數 ... private: MovieMode _movie_mode;
//其他成員 ... };

現在用户連創建一個獨立的 MovieMode 變量都不可能了,操作某個 House 實例的 MovieMode 必須通過 movie_mode 接口調用來完成,而且這樣的設計可以確保長期的穩定性,因為它所依賴的語言特性是 C++ 對象模型最基礎的規則之一,幾乎不可能變更。

當然這種實現的代價是大大增加了 House 類的內存佔用,如果實際應用中需要創建非常多的 House 實例,這種方式可能也不是最佳選擇。

刻意的破壞不在防禦目標之列

有人説,仍然有方法可以繞開限制:

代碼

//針對方案一:
auto create_dangling_mode() -> decltype(std::declval().movie_mode())
{
    return House().movie_mode();
}
create_dangling_mode().set_atmosphere(...);//在空懸的 MovieMode 調用 set_atmosphere
const House chouse;
auto mode = chouse.movie_mode();
static_cast(mode).set_atmosphere(...);//修改 const 對象的數據
//針對方案二: const House chouse; const_cast(chouse.movie_mode()).set_atmosphere(...);//修改 const 對象的數據

這些並不是什麼高超的編程技巧,我們確實無法也沒有義務防禦這樣的操作。就像是坐飛機時非要解開安全帶撬開舷窗把頭伸出窗外一樣,我們只能説,自作孽不可活,祝他好運。

為易用性而妥協

上面的兩種修改方案,都能夠保證正常用户只能通過這樣的方式去調用 MovieMode 的接口:

代碼

House house;
house.movie_mode().xxx();

這樣能夠保證每個創建出來的 MovieMode 的生命週期都是它所綁定的 House 對象的子集,不會出現空懸引用。

但是正是最開始 auto mode = house.movie_mode(); 這句代碼,讓我們產生了思考,用户一般不會故意去測試代碼的邊界情況,那麼站到用户的角度來看,為什麼會這樣編寫呢?考慮到這樣使用場景,代碼中有多個條件分支都要對同一個 House 的 MovieMode 進行設置:

代碼

House house;
if(...)
    house.movie_mode().xxx();
else if(...)
    house.movie_mode().xxx();
//還有許多分支

這種情況下,重複地鍵入 house.movie_mode() 可能形成不好的體驗,而這樣的編寫方式會更方便自然:

代碼

House house;
auto mode = house.movie_mode();
if(...)
    mode.xxx();
else if(...)
    mode.xxx();
//還有許多分支

如果每次設置都需要通過調用一長串的函數來實現,確實會影響使用體驗,我們或許應該為易用性而妥協。文章開頭已經説到,我們與用户之間並不是你死我活的敵對關係,我們可以在接口文檔中提醒用户,哪些使用方式是危險的需要避免,相信用户不會故意去違背這些善意的提示編寫危險的代碼。

假如我們在方案一中去掉了 MovieMode 成員函數上施加的右值引用限定符,或者在方案二中解除了 MovieMode 不可複製的限制,為易用性讓路,並撰寫了詳細的説明文檔,用户也非常配合,然而開始的那個會意外修改 const 數據的問題會再次浮現出來:

代碼

const House chouse;
auto mode = chouse.movie_mode();
mode.xxx();//mode 不是 const 的,可能意外修改一個 const 對象的數據

創建的 mode 是一個非 const 的版本,通過它能夠修改 chouse 的數據。而要求用户注意到這一點,每次調用一個 const 版本的 House 實例的 movie_mode 都寫成: const auto mode = chouse.movie_mode(); 也是不切實際的,這個問題是我們必須解決的。

解決方法也很簡單,MSVC 標準庫 std::vector 的迭代器(iterator)設計已經給了我們示範:iterator 繼承自 const_iterator,const_iterator 實現讀操作,iterator 實現寫操作。

vector iterator

我們的 MovieMode 跟迭代器非常相似,完全可以採用相同的設計方式:

代碼

class Const_MovieMode; //所有的讀操作
class MovieMode : public Const_MovieMode//所有的寫操作
class House { public: Const_MovieMode movie_mode() const; MovieMode movie_mode(); };

現在用户創建一個 const 版本的 House 然後通過 movie_mode 得到的是一個 Const_MovieMode,在這個對象上是絕對無法修改綁定對象的數據的。

或許有能夠兼顧安全性和易用性的實現,畢竟在 C++ 中,你幾乎總能夠找到方案實現你的任何想法,但是就我目前的水平來看已經捉襟見肘了,還須繼續學習。

總結

1. C++ 中一旦你想構建一個與默認行為相異的類,你就不得不精細定製類的各種行為以達到你的設計目的,而這種定製大概率會改變一些你未注意到的行為,為此你不得不深究相關的語言細節,而當你瞭解的更深後,你又會回過頭髮現之前的實現可以重構,重構的過程中又會觸碰更多細節,如此往復。很多人説 C++ 是一門心智負擔很重的語言,但是在其他的編程語言中這種情形同樣存在,它是學習過程的必然現象,不過 C++ 在這一點上表現得尤為突出。

2. 語言標準的變更可能會破壞我們原有代碼的設計意圖,所以我們應該儘量依賴那些能夠保持長期不變的特性。

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.