天天看點

帶你讀《C++語言導學》之三:模 塊 化模 塊 化

點選檢視第一章 點選檢視第二章

第3章

A Tour of C++, Second Edition

模 塊 化

我打斷你的時候你不許打斷我。—溫斯頓·丘吉爾

3.1 引言

一個C++程式包含許多獨立開發的部分,例如函數(參見1.2.1節)、使用者自定義類型(參見第2章)、類層次(參見4.5節)和模闆(參見第6章)等。其管理的關鍵就是清晰地定義這些組成部分之間的互動。第一步也是最重要的一步是将每個部分的接口和實作分離開來。在語言層面,C++使用聲明來表達接口。聲明(declaration)指明了使用一個函數或一個類型所需要的東西。例如:

帶你讀《C++語言導學》之三:模 塊 化模 塊 化

這裡的關鍵點是函數體,即函數的定義(definition)是位于“别處”的。對本例,我們可能也想讓Vector的表示位于“别處”,不過稍後将再對此進行介紹(抽象類型,參見4.3節)。sqrt()的定義如下所示:

帶你讀《C++語言導學》之三:模 塊 化模 塊 化

對于Vector來說,我們需要定義全部三個成員函數:

帶你讀《C++語言導學》之三:模 塊 化模 塊 化

我們必須定義Vector的函數,而不必定義sqrt(),因為它是标準庫的一部分。但是這沒什麼本質差別:庫不過就是一些“我們碰巧用到的其他代碼”,它也是用我們所使用的語言設施所編寫的。

一個實體(例如函數)可以有很多聲明,但隻能有一個定義。

3.2 分别編譯

C++支援一種名為分别編譯的概念,使用者代碼隻能看見所用類型和函數的聲明。這些類型和函數的定義則放置在分離的源檔案裡,并被分别編譯。這種機制有助于将一個程式組織成一組半獨立的代碼片段。這種分離可用來最小化編譯時間,并嚴格強制程式中邏輯獨立的部分分離開來(進而最小化發生錯誤的可能)。庫通常是一組分别編譯的代碼片段(如函數)的集合。

通常,我們将說明子產品接口的聲明放置在一個檔案中,檔案名訓示出預期用途。例如:

帶你讀《C++語言導學》之三:模 塊 化模 塊 化

這段聲明被置于檔案Vector.h中,我們稱這種檔案為頭檔案(header file),使用者将其包含(include)到自己的程式中以便通路接口。例如:

帶你讀《C++語言導學》之三:模 塊 化模 塊 化
帶你讀《C++語言導學》之三:模 塊 化模 塊 化

為了幫助編譯器確定一緻性,負責提供Vector實作部分的.cpp檔案同樣應該包含提供接口的.h檔案:

帶你讀《C++語言導學》之三:模 塊 化模 塊 化

user.cpp和Vector.cpp中的代碼共享Vector.h中提供的接口資訊,但這兩個檔案是互相獨立的,可以被分别編譯。這幾個程式片段可圖示如下。

帶你讀《C++語言導學》之三:模 塊 化模 塊 化

嚴格來說,使用分别編譯并不是一個語言問題,而是關于“如何以最佳方式利用特定語言實作”的問題。但不管怎麼說,其實際意義非常重要。程式組織的最佳方式就是将程式看作依賴關系定義良好的一組子產品,邏輯上通過語言特性表達子產品化,實體上通過檔案利用子產品化實作高效的分别編譯。

一個單獨編譯的.cpp檔案(包括它使用#include包含的.h檔案)稱為一個編譯單元(translation unit)。一個程式可以包含數以千計的編譯單元。

3.3 子產品(C++20)

使用#include是一種古老的、易出錯的且代價相當高的程式子產品化組織方式。如果你在101個編譯單元中使用#include header.h,編譯器将會處理header.h的文本101次。如果你在header2.h之前使用#include header1.h,則header1.h中的聲明和宏可能影響header2.h中代碼的含義。相反,如果你在header1.h之前使用#include header2.h,則header2.h可能影響header1.h中的代碼。顯然,這不是一種理想的方式,實際上,自1972年這種機制被引入C語言之後,它就一直是額外代價和錯誤的主要來源。

我們的最終目的是想找到一種在C++中表達實體子產品的更好方法。語言特性module尚未納入ISO C++标準,但已是ISO技術規範[ModulesTS]。已有C++實作提供了module特性,是以我在這裡冒一點風險推薦這個特性,雖然其細節可能發生改變,而且距離每個人都能使用它編寫代碼還有些時日。舊代碼,即使用#include的代碼,還會“生存”非常長的時間,因為代碼更新代價很高且非常耗時。

我們考慮使用module表達3.2節中的Vector和use()例子:

帶你讀《C++語言導學》之三:模 塊 化模 塊 化

這段代碼定義了一個名為Vector的子產品,它導出類Vector及其所有成員函數和非成員函數size()。

我們使用這個module的方式是在需要它的地方導入(import)它。例如:

帶你讀《C++語言導學》之三:模 塊 化模 塊 化
帶你讀《C++語言導學》之三:模 塊 化模 塊 化

我本可以對标準庫數學函數也采用import,但我使用了老式的#include,借此展示新舊風格是可以混合的。在漸進地将#include舊代碼更新為import新式代碼的過程中,這種混合方式是必要的。

頭檔案和子產品的差異不僅是文法上的。

  • 一個子產品隻會編譯一遍(而不是在使用它的每個編譯單元中都編譯一遍)。
  • 兩個子產品可以按任意順序導入(import)而不會改變它們的含義。
  • 如果你将一些東西導入一個子產品中,則子產品的使用者不會隐式獲得這些東西的通路權(但也不會被它們所困擾):import無傳遞性。

這些差異對可維護性和編譯時性能的影響是驚人的。

3.4 名字空間

除了函數(參見1.3節)、類(參見2.3節)和枚舉(參見2.5節)之外,C++還提供了一種稱為名字空間(namespace)的機制,用來表達某些聲明屬于一個整體以及它們的名字不會與其他名字沖突。例如,我希望利用自己定義的複數類型(參見4.2.1節、14.4節)進行實驗:

帶你讀《C++語言導學》之三:模 塊 化模 塊 化

通過将我的代碼放在名字空間My_code中,就可以確定我的名字不會與名字空間std(參見3.4節)中的标準庫名字沖突。這種預防措施是明智的,因為标準庫的确提供了complex算術運算(參見4.2.1節、14.4節)。

通路另一個名字空間中的名字,最簡單的方法是用名字空間的名字對其進行限定(例如std::cout和My_code::main)。“真正的main()”定義在全局名字空間中,換句話說,它不屬于任何自定義的名字空間、類或者函數。

如果反複對一個名字進行限定變得令人乏味、分散注意力,我們可以使用using聲明将名字引入作用域中:

帶你讀《C++語言導學》之三:模 塊 化模 塊 化

using聲明令來自一個名字空間中的名字變得可用,就如同它聲明在目前作用域中一樣。我們使用using std::swap後,就像是已在my_code()中聲明了swap一樣。

為擷取标準庫名字空間中所有名字的通路權,我們可以使用using訓示:

帶你讀《C++語言導學》之三:模 塊 化模 塊 化

using訓示的作用是将具名名字空間中未限定的名字變得在目前作用域中可通路。是以,對std使用using訓示之後,我們直接使用cout就可以了,無須再寫std::cout。使用using訓示後,我們就失去了選擇性地使用名字空間中名字的能力,是以必須小心使用這一特性,通常是用在一個庫遍布于應用中時(如std)或是在轉換一個未使用namespace的應用時。

名字空間主要用于組織較大規模的程式元件,例如庫。名字空間簡化了用單獨開發的元件組合程式的過程。

3.5 錯誤處理

錯誤處理是一個大而複雜的主題,其内容和涉及面都遠遠超越了語言設施層面,而深入到了程式設計技術和工具的範疇。不過C++還是提供了一些對此有幫助的特性,其中最主要的一個工具就是類型系統。我們不應基于内置類型(如char、int和double)和語句(如if、while和for)來費力地構造應用程式,而是應構造适合我們應用的類型(如string、map和regex)和算法(如sort()、find_if()和draw_all())。這些進階構造簡化了程式設計,減少了産生錯誤的可能(例如,你不太可能對一個對話框應用樹周遊算法),同時也增加了編譯器捕獲錯誤的機會。大多數C++構造都緻力于設計并實作優雅且高效的抽象(如使用者自定義類型和使用這些自定義類型的算法)。這種抽象機制的一個效果就是運作時錯誤的捕獲位置與錯誤處理的位置被分離開來。随着程式規模不斷增大,特别是庫的廣泛使用,處理錯誤的标準變得愈加重要。在程式開發中,盡早地明确錯誤處理政策是一個好辦法。

3.5.1 異常

讓我們重新考慮Vector的例子。對2.3節中的向量,當我們試圖通路某個越界的元素時,應該發生什麼呢?

  • Vector的編寫者并不知道使用者在面臨這種情況時希望如何處理(通常情況下,Vector的編寫者甚至不知道向量被用在何種程式中)。
  • Vector的使用者不能保證每次都檢測到問題(如果他們能做到的話,越界通路也就不會發生了)。

假設越界通路是一種錯誤,我們希望能從中恢複,合理的解決方案是由Vector的實作者檢測意圖越界的通路并通知使用者,然後使用者可以采取适當的應對措施。例如,Vector::operator[]()能夠檢測到意圖越界的通路,并抛出一個out_of_range異常:

帶你讀《C++語言導學》之三:模 塊 化模 塊 化

throw将程式的控制權從某個直接或間接調用Vector::operator[]()的函數轉移到out_of_range異常處理代碼。為此,C++實作需能展開(unwind)函數調用棧以便傳回調用者的上下文。換句話說,異常處理機制會退出一系列作用域和函數以便回到對處理這種異常表達出興趣的某個調用者,一路上會按需要調用析構函數(參見4.2.2節)。例如:

帶你讀《C++語言導學》之三:模 塊 化模 塊 化

如果希望處理某段代碼的異常,應将其放在一個try塊中。顯然,對v[v.size()]的指派操作将會出錯。是以,程式進入到catch子句中,它提供了out_of_range類型錯誤的處理代碼。out_of_range類型定義在标準庫中(在中),事實上,它也被一些标準庫容器通路函數使用。

我捕獲異常時采用了引用方式以避免拷貝,我還使用了what()函數來列印在throw點放入異常中的錯誤資訊。

異常處理機制的使用令錯誤處理變得更簡單、更系統、更具可讀性。為了達到這一目的,要注意不能過度使用try語句。我們将在4.2.2節中介紹令錯誤處理簡單且系統的主要技術(稱為資源請求即初始化(Resource Aquisition Is Initialization,RAII))。RAII背後的基本思想是,由構造函數擷取類操作所需的資源,由析構函數釋放所有資源,進而令資源釋放得到保證并隐式執行。

我們可以将一個永遠不會抛出異常的函數聲明成noexcept。例如:

帶你讀《C++語言導學》之三:模 塊 化模 塊 化
帶你讀《C++語言導學》之三:模 塊 化模 塊 化

一旦所有的好計劃都失敗了,函數user()仍抛出異常,此時會調用std::terminate()立即終止目前程式的執行。

3.5.2 不變式

使用異常報告越界通路錯誤是一個典型的函數檢查其實參的例子,因為基本假設,即所謂的前置條件(precondition)沒有滿足,函數拒絕執行。如果我們正式說明Vector的下标運算符,我們将定義類似于“索引必須在[0:size())範圍内”的規則,而這正是在operator[]()中要檢查的。符号[a:b)指定了一個半開區間,表示a是區間的一部分,而b不是。每當定義一個函數時,就應考慮它的前置條件是什麼以及如何檢驗它(參見3.5.3節)。對大多數應用來說,檢驗簡單的不變式是一個好主意,參見3.5.4節。

但是,operator[]()對Vector類型的對象進行操作,而且隻在Vector的成員有“合理”的值時才有意義。特别是,我們說過“elem指向一個含有sz個double的數組”,但這隻是注釋中的說明而已。對于類來說,這樣一條關于假設某事為真的聲明稱為類不變式(class invariant),簡稱為不變式(invariant)。建立類的不變式是構造函數的任務(進而成員函數可以依賴該不變式),成員函數的責任是確定當它們退出時不變式仍然成立。不幸的是,我們的Vector構造函數隻履行了一部分職責。它正确地初始化了Vector成員,但是沒有檢驗傳入的實參是否有效。考慮如下情況:

帶你讀《C++語言導學》之三:模 塊 化模 塊 化

這條語句很可能會引起混亂。

下面是一個更好的定義:

帶你讀《C++語言導學》之三:模 塊 化模 塊 化

本書使用标準庫異常length_error報告元素數目為非正數的錯誤,因為一些标準庫操作也是用這個異常報告這種錯誤。如果new運算符找不到可配置設定的記憶體,那麼就會抛出std::bad_alloc。可以編寫如下代碼:

帶你讀《C++語言導學》之三:模 塊 化模 塊 化
帶你讀《C++語言導學》之三:模 塊 化模 塊 化

你可以定義自己的異常類,并令它們将任意資訊從異常檢測點傳遞到異常處理點(參見3.5.1節)。

通常,當抛出異常後,函數就無法繼續完成配置設定給它的任務了。于是,“處理”異常的含義是做一些簡單的局部清理然後重新抛出異常。例如:

帶你讀《C++語言導學》之三:模 塊 化模 塊 化

設計良好的代碼中很少見到try塊,你可以通過系統地使用RAII技術(參見4.2.2節、5.3節)來避免過度使用try塊。

不變式的概念是設計類的核心,而前置條件在函數設計中也起到類似的作用。不變式

  • 幫助我們準确地了解想要什麼。
  • 強制我們明确表達想要什麼,這給我們更多的機會編寫出正确的代碼(在調試和測試之後)。

不變式的概念是C++中由構造函數(參見第4章)和析構函數(參見4.2.2節、13.2節)支撐的資源管理概念的基礎。

3.5.3 錯誤處理替代

錯誤處理在現實世界的所有軟體中都是一個主要問題,是以很自然地有很多解決方法。如果錯誤被檢測出來後無法在函數内局部處理,函數就必須以某種方法與某個調用者溝通這個問題。抛出異常是C++解決此問題的最一般的方法。

在有的語言中,提供異常機制的目的是為傳回值提供一種替代機制。但C++不是這樣的語言:異常是用來報告錯誤、完成給定任務的。異常與構造函數和析構函數一起為錯誤處理和資源管理提供一個一緻的架構(參見4.2.2節、5.3節)。目前的編譯器都針對傳回值進行了優化,使其比抛出一個相同的值作為異常高效得多。

對于錯誤不能局部處理的問題,抛出異常不是報告錯誤的唯一方法。函數可用如下方式指出它無法完成配置設定給它的任務:

  • 抛出一個異常。
  • 以某種方式傳回一個值來指出錯誤。
  • 終止程式(通過調用terminate()、exit()或abort()這樣的函數)。

在下列情況下,我們傳回一個錯誤訓示符(一個“錯誤碼”):

  • 錯誤是正常的、預期的。例如,打開檔案的請求失敗就是很正常的(可能沒有給定名字的檔案或檔案不能按請求的權限打開)。
  • 預計直接調用者能合理地處理錯誤。

在下列情況下我們抛出異常:

  • 錯誤很罕見,以緻程式員很可能忘記檢查它。例如,你最後一次檢查printf()的傳回值是什麼時候?
  • 立即調用者無法處理錯誤。取而代之,錯誤必須層層回到最終調用者。例如,讓一個應用中的所有函數都可靠地處理每個配置設定錯誤或網絡故障是不可行的。
  • 在一個應用中,底層子產品添加了新的錯誤類型,以緻編寫高層子產品時不可能處理這種錯誤。例如,當修改一個舊的單線程應用令其能使用多線程,或使用放置在遠端需要通過網絡通路的資源時。
  • 錯誤代碼沒有合适的傳回路徑。例如,構造函數無法傳回值給“調用者”檢查。特别是,構造函數的調用是發生在構造多個局部變量時或是在一個複雜對象構造了一部分時,這樣基于錯誤碼的清理工作就會變得非常複雜。
  • 由于在傳回值的同時還要傳回錯誤訓示符,函數的傳回路徑變得更為複雜或代價更高(例如使用pair,參見13.4.3節),這可能導緻使用輸出參數、非局部錯誤狀态訓示符或其他變通方法。
  • 錯誤必須沿着調用鍊傳遞到“最終調用者”。反複檢查錯誤碼會很乏味、低效且易出錯。
  • 錯誤恢複依賴于多個函數調用的結果,導緻需要維護調用和複雜控制結構間的局部狀态。
  • 發現錯誤的函數是一個回調函數(函數參數),是以立即調用者甚至可能不知道調用了哪個函數。
  • 錯誤處理需要執行某個“撤銷動作”。

在如下情況下,我們終止程式:

  • 錯誤是無法恢複的類型。例如,對很多(但不是所有)系統,沒有合理的方法從記憶體耗盡錯誤中恢複。
  • 在檢測到一個非平凡錯誤時,系統的錯誤處理基于重新開機一個線程、一個程序或一台計算機。

確定程式終止的一種方法是向函數添加noexcept(),進而在函數實作的任何地方抛出異常都會進入terminate()。注意,有的應用不能接受無條件終止,這就需要使用替代方法。

不幸的是,上述條件并不總是邏輯上互斥的,也不總是容易應用。程式的規模和複雜度都會對此有影響。有時,随着應用的進化,各種因素間的權衡會發生改變,這時就需要程式員的經驗了。如果存疑,你應該優先選擇異常機制,因為其伸縮性更好,也不需要外部工具來檢查是否所有的錯誤都被處理了。

不要認為所有的錯誤碼或所有的異常都是糟糕的,它們都有清晰的用途。而且,不要相信異常處理很緩慢的傳言,它通常比正确處理複雜的或罕見的錯誤條件以及重複檢驗錯誤碼要更快。

對于使用異常實作簡單、高效的錯誤處理,RAII(參見4.2.2節、5.3節)是很必要的。充斥着try塊的代碼通常反映了基于錯誤碼構思的錯誤處理政策最糟糕的那一面。

3.5.4 合約

我們經常需要為不變式、前置條件等編寫可選的運作時檢驗,目前對此還沒有通用的、标準的方法。為此,已為C++20提出了一種合約機制[Garcia,2016] [Garcia,2018]。一些使用者想依賴檢驗來保證程式的正确性—在調試時進行全面的運作時檢驗,而随後部署的代碼包含盡量少的檢驗,合約的目标是為此提供支援。一些組織依賴系統、全面的檢驗,在其高性能應用中這一需求就很常見。

到目前為止,我們還不得不依賴特别的機制。例如,我們可以使用指令行宏來控制運作時檢驗:

帶你讀《C++語言導學》之三:模 塊 化模 塊 化

标準庫提供了調試宏assert(),以主張在運作時某個條件必須成立。例如:

帶你讀《C++語言導學》之三:模 塊 化模 塊 化

在“調試”模式下,如果assert()的條件失敗,程式會終止。如果不在調試模式下,assert()則不會被檢查。這相當粗糙,也很不靈活,但通常已經足夠了。

3.5.5 靜态斷言

異常負責報告運作時發現的錯誤。如果錯誤能在編譯時發現,當然更好。這是大多數類型系統以及自定義類型接口說明設施的主要目的。不過,我們也能對大多數編譯時可知的性質做一些簡單檢查,并以編譯器錯誤消息的形式報告所發現的問題。例如:

帶你讀《C++語言導學》之三:模 塊 化模 塊 化

如果4<=sizeof(int)不成立,即目前系統中一個int占據的空間不足4位元組,則輸出integers are too small資訊。将這種表達我們的期望的機制稱為斷言(assertion)。

static_assert機制能用于任何可以表示為常量表達式(參見1.6節)的東西。例如:

帶你讀《C++語言導學》之三:模 塊 化模 塊 化

一般而言,static_assert(A,S)的作用是當A不為true時,将S作為一條編譯器錯誤資訊輸出。如果你不希望列印特定消息,可以忽略S,編譯器會提供一條預設消息:

帶你讀《C++語言導學》之三:模 塊 化模 塊 化

預設消息通常是static_assert所在位置加上表示斷言謂詞的字元。

static_assert最重要的用途是在泛型程式設計中為類型參數設定斷言(參見7.2節、13.9節)。

3.6 函數參數和傳回值

函數調用是從程式的一個部分向另一個部分傳遞資訊的主要方式,也是推薦方式。執行任務所需的資訊作為參數傳遞給函數,生成的結果作為傳回值傳回。例如:

帶你讀《C++語言導學》之三:模 塊 化模 塊 化

函數間也存在其他傳遞資訊的路徑,例如全局變量(參見1.5節)、指針和引用參數(參見3.6.1節),以及類對象中的共享狀态(參見第4章)。全局變量是衆所周知的錯誤之源,我們強烈建議不要使用它,而狀态通常隻應在共同實作了一個良好定義的抽象的函數間共享(例如,類的成員函數,參見2.3節)。

了解了函數傳遞資訊的重要性,就不會對存在多種傳遞方式感到驚訝了。其中的重點是:

  • 對象是拷貝的還是共享的?
  • 如果共享對象,它可變嗎?
  • 對象可以移動進而留下一個“空對象”嗎?(參見5.2.2節)

參數傳遞和傳回值的預設行為是“拷貝”(參見1.9節),但某些拷貝可隐式優化為移動。

在sum()例子中,得到的int被拷貝出sum()而将可能非常大的vector拷貝進sum()會很低效且無意義,是以參數是以引用方式傳遞的(用&指出,參見1.7節)。

sum()沒有理由修改其實參。這種不可變性是通過将vector參數聲明為const實作的(參見1.6節),是以vector是以const引用方式傳遞的。

3.6.1 參數傳遞

首先考慮如何将值傳入函數。預設是拷貝方式(“傳值”),如果我們希望在調用者的環境中引用一個對象,則可采用引用方式(“傳引用”)。例如:

帶你讀《C++語言導學》之三:模 塊 化模 塊 化
帶你讀《C++語言導學》之三:模 塊 化模 塊 化

當關注性能時,我們通常采用傳值方式傳遞小對象,用傳引用方式傳遞大對象。這裡“小”的含義是指“拷貝代價确實很低的東西”。“小”的準确含義依賴于機器架構,但“兩三個指針大小或更小”是一條很好的經驗法則。

如果基于性能原因想采用傳引用方式,但又不希望修改實參,則可采用傳const引用的方式,就像sum()例子中那樣。這是目前為止普通程式代碼中最常見的情況:這種參數傳遞方式又快又不易出錯。

函數參數具有預設值是很常見的,即一個值被認為是首選的或是最常見的。我們可以采用預設函數參數(default function argument)來指定這樣一個預設值。例如:

帶你讀《C++語言導學》之三:模 塊 化模 塊 化

它是重載的一種替代,符号上更為簡單:

帶你讀《C++語言導學》之三:模 塊 化模 塊 化

3.6.2 傳回值

一旦計算出了結果,就需要将其從函數傳遞回調用者。再次強調,傳回值的預設方式是拷貝,對小對象這是很理想的。我們僅在希望授權調用者通路函數的非局部對象時才以“傳引用”方式傳回值。例如:

帶你讀《C++語言導學》之三:模 塊 化模 塊 化

Vector的第i個元素的存在與下标運算符的調用是無關的,是以我們可以傳回它的引用。

另一方面,在函數傳回時局部變量就消失了,是以我們不應該傳回局部變量的指針或引用:

帶你讀《C++語言導學》之三:模 塊 化模 塊 化

幸運的是,所有主要的C++編譯器都能捕獲bad()中的明顯錯誤。

傳回一個“小”類型的引用或值都很高效,但如何将大量資訊從函數中傳遞出來呢?考慮下面的代碼:

帶你讀《C++語言導學》之三:模 塊 化模 塊 化

一個Matrix可能非常大,進而在現代硬體上做拷貝的代價很高。是以不進行拷貝,而是為Matrix設計一個移動構造函數(參見5.2.2節),将Matrix移出operator+()的代價是很低的。我們無須倒退到使用手工記憶體管理:

帶你讀《C++語言導學》之三:模 塊 化模 塊 化

不幸的是,通過傳回指針來傳回大對象的方式在舊代碼中很常見,這是一些很難發現的錯誤的主要來源。不要編寫這樣的代碼。注意,operator+()與add()一樣高效,但遠比其更容易定義、更容易使用、更不易出錯。

如果一個函數不能執行我們要求它執行的任務,它可以抛出異常(參見3.5.1節)。這有助于避免代碼中到處是“異常問題”的錯誤碼檢驗。

一個函數的傳回類型可以從其傳回值推斷出來。例如:

帶你讀《C++語言導學》之三:模 塊 化模 塊 化

這很友善,特别是對泛型函數(函數模闆,參見6.3.1節)和lambda(參見6.3.3節),但要小心使用它,因為推斷類型不能提供一個穩定的接口:改變函數(或lambda)的實作就可能改變類型。

3.6.3 結構化綁定

一個函數隻能傳回一個值,但這個值可以是一個包含很多成員的類對象。這令我們可以高效地傳回很多值。例如:

帶你讀《C++語言導學》之三:模 塊 化模 塊 化

在本例中,我們用{s,i}構造Entry類型傳回值。類似地,可以将一個Entry的成員“解包”到局部變量中:

帶你讀《C++語言導學》之三:模 塊 化模 塊 化

auto [n,v]聲明了兩個局部變量n和v,它們的類型是從read_entry()的傳回值推斷出來的。這種為類對象的成員賦予局部名字的機制稱為結構化綁定(structured binding)。

考慮另一個例子:

帶你讀《C++語言導學》之三:模 塊 化模 塊 化

照例,我們用const和&裝點auto。例如:

帶你讀《C++語言導學》之三:模 塊 化模 塊 化

當我們将結構化綁定用于沒有私有資料的類時,很容易看到綁定是如何進行的:定義的用于綁定的名字數目必須與類的非靜态資料數目一緻,且綁定時引入的每個名字為對應的成員命名。與顯式使用組合對象的版本相比,代碼品質沒有什麼差别,結構化綁定的使用隻關乎如何更好地表達一個思想。

如果類是通過成員函數來通路的,結構化綁定也能處理。例如:

帶你讀《C++語言導學》之三:模 塊 化模 塊 化

一個complex有兩個成員,但其接口由通路函數組成,如real()和imag()。将一個complex映射到兩個局部變量(如re和im)是可行的,也很高效,但完成這一目的的技術已經超出了本書的範圍。

3.7 建議

[ 1 ] 區分聲明(用作接口)和定義(用作實作);3.1節。

[ 2 ] 使用頭檔案描述接口、強調邏輯結構;3.2節;[CG: SF.3]。

[ 3 ] 使用#include将頭檔案包含到實作其函數的源檔案中;3.2節;[CG: SF.5]。

[ 4 ] 在頭檔案中應避免定義非内聯函數;3.2節;[CG: SF.2]。

[ 5 ] 優先選擇module而非頭檔案(在支援module的地方);3.3節。

[ 6 ] 用名字空間表達邏輯結構;3.4節;[CG: SF.20]。

[ 7 ] 将using訓示用于程式轉換、基礎庫(如std)或局部作用域中;3.4節;[CG: SF.6] [CG: SF.7]。

[ 8 ] 不要在頭檔案中使用using訓示;3.4節;[CG: SF.7]。

[ 9 ] 抛出一個異常來指出你無法完成配置設定的任務;3.5節;[CG: E.2]。

[10] 異常隻用于錯誤處理;3.5.3節;[CG: E.3]。

[11] 預計直接調用者會處理錯誤時就使用錯誤碼;3.5.3節。

[12] 如果通過很多函數調用預計錯誤會向上傳遞,則抛出異常;3.5.3節。

[13] 如果對使用異常還是錯誤碼存疑,優先選擇異常;3.5.3節。

[14] 在設計早期就規劃好錯誤處理政策;3.5節;[CG: E.12]。

[15] 用專門設計的使用者自定義類型(而非内置類型)作為異常;3.5.1節。

[16] 不要試圖在每個函數中捕獲所有異常;3.5節;[CG: E.7]。

[17] 優先選擇RAII而非顯式的try塊;3.5.1節、3.5.2節;[CG: E.6]。

[18] 如果你的函數不抛出異常,那麼将其聲明成noexcept;3.5節;[CG: E.12]。

[19] 令構造函數建立不變式,如果不成功,就抛出異常;3.5.2節;[CG: E.5]。

[20] 圍繞不變式設計你的錯誤處理政策;3.5.2節;[CG: E.4]。

[21] 能在編譯時檢查的問題通常最好在編譯時檢查;3.5.5節;[CG: P.4] [CG: P.5]。

[22] 采用傳值方式傳遞“小”值,采用傳引用方式傳遞“大”值;3.6.1節;[CG: F.16]。

[23] 優先選擇傳const引用方式而非傳普通引用方式;_module.arguments_;[CG: F.17]。

[24] 用函數傳回值方式(而非輸出參數)傳回結果;3.6.2節;[CG: F.20] [CG: F.21]。

[25] 不要過度使用傳回類型推斷;3.6.2節。

[26] 不要過度使用結構化綁定,使用命名傳回類型在程式文本角度下通常更為清晰;

繼續閱讀