本節書摘來自異步社區出版社《Imperfect C++中文版》一書中的第1章,第1.3節,作者: 【美】Matthew Wilson,更多章節內容可以訪問云棲社區“異步社區”公眾號查看。
1.3 運行期契約:前置條件、后置條件和不變式
Imperfect C++中文版
“如果例程的所有前置條件(precondition)已經被調用者滿足了,那么該例程必須確保當它完成時所有后置條件(postconditions)(以及任何不變式)皆為真。”——Hunt and Thomas, The Pragmatic Programmers [Hunt2000]。
如果我們無法執行編譯期強制,那么還可以采用運行期強制。運行期強制的一個系統化的實現途徑是指定函數契約。函數契約精確定義了在函數被調用之前調用者必須滿足哪些條件(前置條件),以及在函數返回之時哪些條件(后置條件)是調用者可以期望的。契約的定義以及它們的強制實施是DbC(Design by Contract,契約式設計)[Meye1997]的基石。
前置條件是指函數履行其契約所必須滿足的條件。滿足前置條件是調用者的責任,而被調用者則假定它的前置條件已經被滿足,并且僅當它的前置條件被滿足時才負責提供正確的行為。這一點非常重要,在[Meye 1997]中被強調指出。倘若調用者沒有滿足前置條件,則被調用者做出任何事情都是完全合理的。事實上,通常這會引發一個斷言(見1.4節),進而可能導致程序終止。這聽起來似乎頗令人恐慌,剛接觸DbC的程序員通常會對此感到很不舒服,直到你問起他們:如果一個函數的(前置)條件都不能被滿足,那還能指望它有什么樣的行為時,他們才啞口無言。事實上,契約越嚴格,違反它所導致的后果越嚴重,從而軟件的質量就會越好。當轉到DbC上時,要理解這一點是最為困難的。
后置條件在函數執行完畢時必須為真。確保后置條件被滿足是被調用者的責任。當函數返回控制時調用者可以假定后置條件已經得到了滿足。在現實中,有些時候有所保留(不要把賭注全部押在被調用者身上)還是必要的,例如,當調用應用服務器中的第三方插件時就是如此。然而,我認為前面所講的原則仍然是對的。事實上,對違反契約的插件的合理反應之一是將它卸載掉,并給公司經理以及第三方插件廠商發一封電子郵件。既然我們對于違反契約的行為可以作出任何反應,那么有什么理由不這么做呢?
前置條件和后置條件可以被應用到類的成員函數,也可以被用到自由函數身上,這對于C++(更一般地說,面向對象編程)來說很有益處。事實上,還有另外一個與DbC相關的東西,它只能依附于類而存在,那就是類不變式(class invariant)。類不變式是指一個或一組條件式,它們對于一個處于良好定義狀態的對象總是為真。根據定義,類的構造函數負責確保類的實例進入一個符合該類的不變式的狀態中,而類的(public)成員函數則在它們完成之際確保類的實例仍然處在該狀態中。僅當處于構造函數、析構函數或其他某個成員函數的執行過程中時,類不變式才不一定要為真。
在某些場合下,將不變式的作用范圍定義為比“單個對象的狀態”的范圍更廣可能更合適一些。原則上,不變式可以被應用到操作環境的整個狀態上,然而,在實踐中,這種情況是極其少見的,類不變式則很常見。因此,在本章以及本書剩余的篇幅中,如果提到不變式,均是指類不變式。
對部分或根本沒有進行封裝的類型提供不變式是可行的(見3.2節和4.4.1小節),這個不變式是由與該類型相關的API函數(以及該函數的前置條件)來強制實施的。事實上,當使用這種類型時,不變式是極好的主意,因為它們缺乏封裝性的特質提高了濫用的風險。不過這種不變式相當容易被“繞過”,這也說明了為什么通常應該避免使用這種類型。事實上,[Stro2003]中某種程度上提到:如果存在一個不變式,則公有數據簡直毫無意義。封裝既是關于隱藏實現又是關于保護不變式的。至于“屬性”(第35章),可能是為了結構上的一致性(見20.9節)而引入的,只不過為我們提供公有成員變量的表象而已,它仍然具有不變式。
對于違反前置條件、后置條件或者不變式,你所采取的行動完全由你來決定。你可以把信息記錄到日志文件中,也可以拋出異常,或者給你家人發一封SMS,告訴她今夜你將debug到很晚。不過,通常我們采取的行動是引發一個斷言。
1.3.1 前置條件
在C++中,前置條件測試相當簡單。在這本書中我們已經看到了好幾個例子。它和使用斷言一樣簡單:
template< . . . >
typename pod_vector<. . .>::reference pod_vector<. . .>::front()
{MESSAGE_ASSERT("Vector is empty!", 0 != size());assert(is_valid());return m_buffer.data()[0];
}
1.3.2 后置條件
這是C++容易產生磕磕碰碰的地方。這里的挑戰是在函數的退出點捕獲返回值和“輸出”參數。1當然了,C++提供了特別有用的RAII(Resource Acquisition Is Initialization,資源獲取即初始化)機制(見3.5節),該機制保證當執行流程退出某個作用域時棧上對象的析構函數都會得到調用。這就意味著我們可能借助這一點實現一個可行方案,至少該機制具備這個潛力。
我們的選擇之一是聲明監視器對象,它持有對輸出參數和返回值的引用。
int f(char const *name, Value **ppVal, size_t *pLen)
{int retVal;retval_monitor rvm(retVal, . . . policy . . . );outparam_monitor opm1(ppVal, . . . policy . . . );outparam_monitor opm2(pLen, . . . policy . . . );. . . // 函數體return retVal;
}
一些策略會被用來檢查變量是否為NULL,或者是否位于一個特定的區間內,或者是一組數值中的一個,等等。盡管實現這些東西都有困難,這里仍然存在兩個問題。第一,rvm的析構函數會對它所持有的指向函數返回值變量retVal的引用來施行約束。如果函數的其他任何部分返回了一個不同的值(或一個常量),那么rvm無可避免地會報告一次失敗。為了能夠正確工作,我們不得不強制讓所有函數都通過單個變量來返回,這肯定不符合一些人的口味,在某些場合下也是不可能的。
然而,最主要的問題還在于各個后置條件監視器之間是沒有關聯的。大多數函數的后置條件是復合型的,個體輸出參數和返回值僅當符合某種一致的關系時才有意義,例如:
assert(retVal > 0 || (NULL == *ppVal && 0 == *pLen));
我不打算建議你如何將這3個個體監視器對象以這樣的方式結合起來,以便強制實施各種各樣的后置條件狀態,這類事情對于模板元編程愛好者可能是一個令人激動的挑戰,不過對于其他人,它所帶來的復雜性不值得我們付出代價。
Imperfection: C++對后置條件未提供合適的支持。
在我看來,惟一合理的(雖然看起來很平凡)解決方案是,通過一個轉發函數將(待調用)函數和對它的(后置條件)檢查分離開來,就像在程序清單1.5中展示的那樣:
程序清單1.5
int f(char const *name, Value **ppVal, size_t *pLen)
{. . . // 進行f()的前置條件檢查int retVal = f_unchecked(name, ppVal, pLen);. . . // 進行f()的后置條件檢查return retVal;
}
int f_unchecked(char const *name, Value **ppVal, size_t *pLen)
{. . . // f的語義
}
在實際代碼中,你可能希望在不需要執行DbC的地方省略掉所有的檢查,為此我們需要使用預處理器:
程序清單1.6
int f(char const *name, Value **ppVal, size_t *pLen)
#ifdef ACMELIB_DBC
{. . . // 進行f()的前置條件檢查int retVal = f_unchecked(name, ppVal, pLen);. . . // 進行f()的后置條件檢查return retVal;
}
int f_unchecked(char const *name, Value **ppVal, size_t *pLen)
#endif /* ACMELIB_DBC */
{. . . // f的語義
}
這完全算不上優雅,不過它可以工作,并可以很容易地合并到代碼生成器中。當處理被重寫的(overridden)類成員函數時,問題可能要稍微復雜一點,因為你要面對是否實施父類的前置條件和后置條件的問題。這得條分縷析后才能決定,已經超出了我們的討論范圍。2
1.3.3 類不變式
在C++中,實現類不變式幾乎和實現前置條件一樣簡單。我個人的做法是為類定義一個名為is_valid()的方法,像這樣:
template<. . . >
inline bool pod_vector<. . .>::is_valid() const
{if(m_buffer.size() < m_cItems){return false;}. . . // 這里進行進一步的檢查return true;
}
然后,該類的每個公有方法都把它放在斷言里進行調用,在進入方法時斷言一次,退出方法前再來一次。我喜歡在緊接著前置條件檢查之后進行類不變式的檢查(見1.3.1小節):
template< . . . >
inline void pod_vector<. . .>::clear()
{assert(is_valid());m_buffer.resize(0);m_cItems = 0;assert(is_valid());
}
作為一種替代策略,我們可以將斷言放在不變式函數自身之中。然而,除非你手頭擁有的是一個“久經考驗”的斷言(見1.4節),否則這會令你不得不選擇提供關于“肇事”的條件或方法的斷言信息(文件+行+消息)。我傾向于后者,因為違反不變式畢竟是非常少見的情況。不過,你可能會選擇前者,如果是那樣的話,你可能希望將斷言放到is_valid()成員函數中。
事實上,對此存在一個合理的折中方案,我通常在具有良好的日志/跟蹤界面的環境中使用這種策略(見21.2節),具體做法是在is_valid()成員函數中記錄違反不變式的細節,并且讓“肇事”成員函數3來觸發該斷言。
與輸出參數和返回值檢查不同,使用RAII(見3.5節)來使類不變式的檢查自動化還是相當容易的(這種檢查也作為方法退出前的后置條件驗證的一部分),像這樣:
template< . . . >
inline void pod_vector<. . .>::clear()
{check_invariant<class_type> check(this);m_buffer.resize(0);m_cItems = 0;
}
缺點是,強制會在check_invariant模板實例的構造函數和析構函數中被實施,這意味著使用預處理器來獲悉 FILE 和 LINE 信息的簡單的斷言可能會給出誤導信息。然而,要想實現一個可以正確顯示斷言失敗位置的“宏+模板”的斷言形式并不算是很大的挑戰,甚至可以結合運用非標準的 FUNCTION 預處理符號(當然,對于那些支持它的編譯器而言)。
1.3.4 檢查?總是進行
在[Stro2003]中,Bjarne Stroustrup做了一個非常重要的觀察:不變式只對那些具有方法的類才是必要的,而對于僅僅作為變量聚合體的簡單結構而言是沒有必要的(例如,我們將會在4.4.2小節看到的Patron類型就不需要不變式)。在我看來,這話還可以這么說:任何具有方法的類都應該具有類不變式。不過,在實踐中對此有一個下限。如果你的類持有一個指向某些資源的指針,那么,它要么是NULL,要么不是NULL。除非你的類不變式方法可以使用非空指針所指向的有效的外部資源,否則你的類不變式將無事可干。在這種情況下,是否使用一個“存根(stub)”類不變式取決于你自己,或者你也可以干脆什么都不干。但如果你的類將來會不斷升級,那么在里面放上一塊有待以后擴充的“存根”方法可以令后續的精化工作變得容易一些。如果你使用了某種代碼生成器的話,我建議你總是用它來生成類不變式,并生成對所生成的類不變式的調用。
類不變式較之散落在類實現周圍的一堆斷言而言,好處是非常明顯的。類不變式使你的代碼更容易閱讀,并且在不同的類的實現之間具有一致的外觀,以及具有更好的可維護性,這是因為對于每個類你都把類不變式定義在了某個單一的地方。
1.3.5 DbC還是不DbC
到目前為止,我所描繪的關于運行期契約的藍圖其實隱含了一個假定,那就是:在進行適當的測試后,人們會對他們的系統進行一次構建(build),4在這次構建中,DbC元素都被預處理器消去。5
事實上,關于“是否任何構建(build)都應該不實施DbC”這個問題[Same2003],仍然頗有爭議。一個論據是(借用[Same2003]里的邏輯)DbC里的契約實施就好比電力系統中的保險絲,任何人都不應該在部署一個成熟的電力設備之前把它里面的所有保險絲都拔掉。
斷言和保險絲之間的區別在于前者涉及運行期測試,而測試的代價明顯不為零。盡管保險絲中的合金成分的電阻可能與它所在系統中的其他部分的電阻略有差別,然而這跟斷言引入的代價相比仍然無法相提并論。我的看法是,這需要仔細分析才能求得一個良好的平衡。這就是為什么本節的例子代碼中包含了ACMELIB_DB這個符號的緣故。我沒有使用NDEBUG(或者_DEBUG),因為DbC的使用不應該直接和“調試版/發行版(debug/release)”的二進制概念耦合起來。究竟何時使用它,何時消除它,取決于你自己。6
1.3.6 運行期契約:尾聲
盡管我們已經看到C++在后置條件方面是有缺陷的,然而進行前置條件和類不變式的測試仍然是合理的。在實踐中,將這兩者結合使用往往能發揮DbC大部分的威力。對返回值和輸出參數的后置條件測試的能力缺失雖然令人遺憾,但也并非十分嚴重的事情。如果你必需這種能力的話,你可以求助于預處理器,就像在1.3.2小節中看到的那樣。
如同約束一樣, 對于不變式,我們可以通過使用一個間接層讓日子好過一些。這個間接層對于約束來說是一個宏,而對于不變式來說則是一個成員函數。正因為如此,提供對新的編譯器的支持或者修改某個類的內部實現也變得更為容易了,并且,我們還把該機制不爽的那一面全部隱藏到了類不變式方法中。
1譯者注:即用于向外界返回東西的函數參數,例如指向待填充的緩沖區的指針。
2在這一點上,我承認我有點膽小自私,不過我有很好的借口。即便是在成熟運用DbC的語言中,對于繼承體系中的層與層之間的關聯契約的用處(事實上是機制)仍然是模棱兩可的。此外,為C++加入DbC的提議直到本書的撰寫時仍然不過是納入考慮而已[Otto2004],因此,我認為在這里過多地在細節上饒舌沒有什么好處。
3譯者注:而非不變式函數。
4譯者注:對程序進行編譯和連接的過程。
5譯者注:其實通常就是發行版(release)的構建,其中assert(exp)會展開為空。
6在ISE Eiffel 4.5中,你無法去掉前置條件,大概是因為前置條件可以在程序變成未定義狀態之前進行反饋,從而對于程序捕獲違反前置條件的異常并繼續執行是有意義的。
本文僅用于學習和交流目的,不代表異步社區觀點。非商業轉載請注明作譯者、出處,并保留本文的原始鏈接。