結(jié)構(gòu)化綁定詳解

結(jié)構(gòu)化綁定是C++17新增的語法,適當(dāng)使用能極大地提升編程體驗。結(jié)構(gòu)化綁定將引入的標(biāo)識符綁定到對象的元素或成員上。很多人將結(jié)構(gòu)化綁定視為引用的語法糖,誠然它們有許多相似之處,但二者在語義上還是有很多不同的地方。
對象、引用、結(jié)構(gòu)化綁定是C++中三個相互關(guān)聯(lián)但是彼此并列的概念,它們都是程序中的實體。
1. 綁定到數(shù)組的元素
首先,結(jié)構(gòu)化綁定能夠綁定到數(shù)組的元素上:
在這段代碼中,定義了?a
?b
?c
?三個結(jié)構(gòu)化綁定。通過?printf
?可以觀察到它們的值分別是?1
?2
?3
,對應(yīng)數(shù)組arr
的三個元素。
實際上在這個過程中編譯器幫我們干了下面的事:首先,引入一個匿名變量,在這里我們叫它_unnamed_
,它的各個元素從?arr
?復(fù)制初始化。然后,將結(jié)構(gòu)化綁定引入的三個名字分別綁定到這個匿名數(shù)組的三個元素上。
需要注意的是,這里的引用僅僅表示一種綁定關(guān)系,即?a
?綁定到_unnamed_[0]
,并不代表?a
?是個引用。例如,我們直接定義一個引用?int &ra = arr[0]
?,那么?decltype(ra)
?會得到?int&
,而結(jié)構(gòu)化綁定的聲明類型是不帶引用的:decltype(a)
?得到的是?int
。
那么如何證明上述匿名變量的存在,并且真的發(fā)生了復(fù)制呢?一個簡單的辦法是修改?a
?的值,arr[0]
?并不會隨之變化,說明?a
?和?arr[0]
指代的兩個不同的對象。當(dāng)然還有更直觀的辦法,那就是自定義類的復(fù)制構(gòu)造函數(shù)。
運(yùn)行上述代碼,我們很能夠觀察到程序輸出下列內(nèi)容:
分割線之前是數(shù)組?arr
?的三個元素從?int
?直接初始化的輸出,分割線之后是結(jié)構(gòu)化綁定過程中匿名數(shù)組各個元素的復(fù)制構(gòu)造的輸出。并且復(fù)制的對象的地址也能一一對應(yīng)。
但是,這只能證明發(fā)生了復(fù)制,還不足以證明這個匿名變量的存在。編譯器完全可以省略匿名變量,直接從數(shù)組的三個元素復(fù)制初始化三個變量,并且還可以避免前文提到的看起來像引用,卻又不是引用的綁定。
2. 綁定到數(shù)據(jù)成員
讓我們帶著這個問題來看看下面這段代碼:
程序的輸出如下:
可以看到,這里只調(diào)用了一次B的復(fù)制構(gòu)造,說明這個匿名變量確實存在。
和數(shù)組類似,結(jié)構(gòu)化綁定可以綁定到類的非靜態(tài)數(shù)據(jù)成員。當(dāng)然并不是每一種類都可以被結(jié)構(gòu)化綁定,它必須有如下性質(zhì):
它所有的非靜態(tài)數(shù)據(jù)成員在當(dāng)前語境中可訪問。
它所有的非靜態(tài)數(shù)據(jù)成員都是它自己,或者同一個基類的直接成員。
結(jié)構(gòu)化綁定并不要求成員必須有?public
?訪問權(quán)限,只要在當(dāng)前語境中可以訪問所有成員即可。
第二點似乎不太直觀,我們通過兩個例子來說明一下:
我們從?A
?派生出?B
?和?C
?兩個類。其中?B
?沒有增加任何數(shù)據(jù)成員,它可能只添加了一些成員函數(shù)擴(kuò)展了?A
?的功能,這在開發(fā)中也是很常見的手法。那么對于?B
?來說,它所有的非靜態(tài)數(shù)據(jù)成員都是從基類?A
?繼承而來的,因此?B
?符合結(jié)構(gòu)化綁定的要求。而?C
?則增加了一個數(shù)據(jù)成員,因此不滿足第二條性質(zhì)。
3. 結(jié)構(gòu)化綁定中的限定符
左值引用限定符
剛才我們見到的結(jié)構(gòu)化綁定都有一個復(fù)制的過
程,會產(chǎn)生一個匿名對象。有時候復(fù)制的開銷會比較大,我們當(dāng)然想避免不必要的復(fù)制。于是我們可以為結(jié)構(gòu)化綁定添加一個引用限定符,以引用的方式綁定到相應(yīng)的對象上。
還記得剛剛說過結(jié)構(gòu)化綁定過程中的匿名變量嗎?它再一次派上大用場了。如果結(jié)構(gòu)化綁定聲明中包含引用限定符,那么這個引入的匿名變量就是一個引用!
引用?_unnamed_
?綁定到?arr
,而?a
?又綁定到?_unnamed_[0]
,也就是說?a
?直接綁定到了?arr[0]
?上。b
?和?c
?同理。再一次強(qiáng)調(diào),即使添加了引用限定符,結(jié)構(gòu)化綁定也不是引用,decltype(a)
?仍然是?int
?而不是?int&
。這里的引用只是為了表達(dá)綁定關(guān)系。
定義引用不會產(chǎn)生可觀察的副作用,我們也就無法直接證明這個匿名變量確實是引用。當(dāng)然我們還是可以從側(cè)面來應(yīng)證它,比如說左值引用不能綁定到右值。
cv限定符
很自然地,你會想到用右值引用來綁定到右值表達(dá)式,但別忘了const
限定的左值引用,它們也可以綁定到右值。
加上?const
?限定之后,我們就不能修改這些結(jié)構(gòu)化綁定的值了。在需要的時候加上?const
?能讓我們的程序更加安全。
既然是cv限定符,自然還有?volatile
。我們稍微提一下,這個限定符實際上很少用到,甚至在C++20中棄用了大部分語境中的?volatile
?限定,包括結(jié)構(gòu)化綁定。你仍然可以寫,但編譯器可能會發(fā)出警告。volatile
?是另一個很大的話題,并且涉及到很多實現(xiàn)上的細(xì)節(jié),這里就不展開講了。
右值引用限定符
實際上在結(jié)構(gòu)化綁定中說“右值引用”限定符并不準(zhǔn)確,畢竟前面還有一個?auto
?占位符呢。auto&&
?是不是右值引用可說不準(zhǔn),讓我們來復(fù)習(xí)一下。
在上面的示例代碼中,auto&& lref = i
?會進(jìn)行類型推導(dǎo),由于初始化器i
是個左值,推導(dǎo)出?auto -> int&
?再經(jīng)過引用折疊?int& && -> int&
?最終得到?lref
?是個左值引用。
結(jié)構(gòu)化綁定引入的匿名變量也是如此,如果引用限定符是?&&
?那么匿名變量的類型就會根據(jù)這一規(guī)則自動推導(dǎo),這也是?auto&&
?被稱為萬能引用的原因。
存儲類說明符(C++20起)
從C++20開始,你可以為結(jié)構(gòu)化綁定加上?static
?或者?thread_local
?這兩個存儲類說明符,它們同樣是作用在引入的匿名變量上。
使用這兩個說明符的時候要注意,如果再加上引用限定符,綁定到某個局部變量上,很容易產(chǎn)生懸垂引用。這與普通的靜態(tài)變量規(guī)則是相同的。
4. 初始化器
上面的代碼中我們一直都是使用等于號形式的初始化器。實際上結(jié)構(gòu)化綁定還允許花括號和圓括號初始化。大多數(shù)情況下,它們區(qū)別不大。唯一的區(qū)別在于初始化匿名變量的時候,等于號的形式使用復(fù)制初始化,而花括號或者圓括號的形式使用直接初始化。復(fù)制初始化不考慮?explicit
?構(gòu)造函數(shù)。
5. 綁定到元組式類型的元素
結(jié)構(gòu)化綁定還能綁定到例如?std::tuple
?或者?std::pair
,甚至?std::array
?這些類型上。但仔細(xì)想想,就會發(fā)現(xiàn)事情并沒有那么簡單。pair
?還能用下面這個形式強(qiáng)行解釋一下,結(jié)構(gòu)化綁定是綁定到它的兩個數(shù)據(jù)成員上。
但?tuple
?呢?它有公開可訪問的數(shù)據(jù)成員嗎?標(biāo)準(zhǔn)庫似乎沒有提供給我們。訪問?tuple
?的元素必須通過?std::get
?函數(shù)。如果你了解一點模板元編程,那你應(yīng)該知道?tuple
?通常是用模板遞歸繼承的方式實現(xiàn)的,它的數(shù)據(jù)成員分布在一層一層的基類里。這明顯是不符合結(jié)構(gòu)化綁定綁定到數(shù)據(jù)成員的要求的。
再說說?std::array
,雖然它名字就叫?array
,長的像數(shù)組,用起來也像數(shù)組,但它畢竟不是數(shù)組,std::is_array<std::array<int,3>>::value
肯定是?false
。
那么結(jié)構(gòu)化綁定是如何實現(xiàn)的呢?其實,C++為我們提供了一套精fù妙zá
的機(jī)制,可以自定義結(jié)構(gòu)化綁定規(guī)則,我們通常叫它元組式綁定。
如果你只是使用標(biāo)準(zhǔn)庫提供的這些元組式類型,那么不必?fù)?dān)心:就把std::pair
和std::tuple
當(dāng)成所有成員都能公開訪問的結(jié)構(gòu)體,把std::array
當(dāng)成普通的數(shù)組。標(biāo)準(zhǔn)庫已經(jīng)給你實現(xiàn)好了相關(guān)的細(xì)節(jié),不了解這套機(jī)制的工作原理也不影響你使用。
使用例:
程序輸出:
如果你對其中的細(xì)節(jié)感興趣,或者想要給你寫的類實現(xiàn)自定義結(jié)構(gòu)化綁定,就讓我們開始吧。
首先,編譯器會檢查結(jié)構(gòu)化綁定的初始化表達(dá)式的類型,我們暫時稱它為?T
。如果?T
?是數(shù)組類型,那就按照前文所述的規(guī)則綁定到數(shù)組元素。否則,編譯器就會檢查?std::tuple_size<T>::value
?是否是一個合法整數(shù)類型的常量表達(dá)式。如果它是,那就進(jìn)行元組式綁定。否則,就按照前文所述的規(guī)則綁定到?T
?的數(shù)據(jù)成員。
std::tuple_size
?是標(biāo)準(zhǔn)庫中聲明的一個類模板,此外,標(biāo)準(zhǔn)庫還提供了針對?std::pair
,std::tuple
,std::array
?的特化。
上述代碼只是實現(xiàn)?std::tuple_size
?特化的方式之一,僅作為示例。如果你要給自定義類型實現(xiàn)結(jié)構(gòu)化綁定,第一步就是寫一個相應(yīng)的?std::tuple_size
?特化。它必須包含一個靜態(tài)的整數(shù)常量成員,名字為?value
。它的值必須是正整數(shù),表示可以結(jié)構(gòu)化綁定的元素的數(shù)量,如果它的值和[ 標(biāo)識符列表 ]
的數(shù)量不相等,則編譯器會報錯。慣例上將它的類型設(shè)定為?size_t
,但任意整數(shù)類型都是可以的。
然后,編譯器同樣會引入一個匿名變量來保存初始化表達(dá)式的值。我們以std::tuple
為例看看下面的代碼:
auto [i,c,d] = std::make_tuple(1, '2', 3.0);auto _unnamed_ = std::make_tuple(1, '2', 3.0);
為了將結(jié)構(gòu)化綁定中的標(biāo)識符綁定到某個對象,編譯器還會為每一個標(biāo)識符引入一個新的變量。它的類型是?std::tuple_element<0, T>::type
的引用。如果它對應(yīng)的初始化表達(dá)式的值類別是左值,那么它是左值引用,否則,它是右值引用。它對應(yīng)的初始化表達(dá)式的形式見后文詳述。
這也就意味著,為了實現(xiàn)自定義結(jié)構(gòu)化綁定,我們還需要自己實現(xiàn)相應(yīng)的?std::tuple_element
?特化。它有兩個模板參數(shù),第一個參數(shù)是一個整數(shù),表示結(jié)構(gòu)化綁定的標(biāo)識符的序號,從0開始遞增;第二個參數(shù)是你的自定類型。以?std::pair
?為例,看看?std::tuple_element
?的自定義特化要怎么寫:
針對?std::tuple
?的特化實現(xiàn)起來較為復(fù)雜,需要用到模板遞歸繼承,這里就不作展示了。感興趣的讀者可以自行查找資料,或者翻看STL的源代碼。
總結(jié)來說,std::tuple_element<I, T>::type
表示了類型?T
?的第?I
?個可綁定元素的類型。
有了類型,為每個結(jié)構(gòu)化綁定引入了額外的引用變量之后,接下來就要對這些變量進(jìn)行初始化了,畢竟引用必須在定義的時候就初始化。首先,編譯器會去找類型?T
?是否有名為?get
?的成員函數(shù)模板,并且?get
?的第一個模板參數(shù)是非類型模板參數(shù)。如果找到這樣的成員,那么就調(diào)用?_unnamed_.get<I>()
?來初始化第I
個變量。如果沒有這樣的成員,就調(diào)用?get<I>(_unnamed_)
來初始化,并且查找?get
?的過程只進(jìn)行實參依賴查找(ADL, Argument Dependent Lookup),不考慮其他形式。
另外,在調(diào)用?get
?的時候,如果匿名變量?_unnamed_
?的類型是左值引用,則調(diào)用過程中它保持為左值;否則將它轉(zhuǎn)換到亡值再調(diào)用。也就是說如果get
同時存在接受左值引用和右值引用的重載時,前者調(diào)用左值引用的版本,而后者調(diào)用右值引用的版本,這實際上類似于完美轉(zhuǎn)發(fā)。
最后,將結(jié)構(gòu)化綁定引入的標(biāo)識符綁定到額外引入的這些變量所指代的對象上。
最后,我們通過一個例子來看看完整的自定義結(jié)構(gòu)化綁定過程。考慮如下場景:標(biāo)準(zhǔn)庫在常用數(shù)學(xué)函數(shù)庫中提供了?div_t div(int, int)
?函數(shù)。它計算兩個整數(shù)相除得到的商和余數(shù),并通過一個結(jié)構(gòu)體返回。但是標(biāo)準(zhǔn)并未規(guī)定結(jié)構(gòu)體?div_t
?兩個成員的順序,因此直接綁定到數(shù)據(jù)成員可能會導(dǎo)致順序不對,于是我們可以為它定義一套元組式的綁定方式,讓第一個變量始終綁定到商,而第二個變量始終綁定到余數(shù)。
首先,我們需要為?std::tuple_size<T>
?寫一個特化。此處的?std::tuple_size<div_t>::value
?即結(jié)構(gòu)化綁定能綁定的成員的數(shù)量,因此我們將它設(shè)置為2。
然后,我們需要為?std::tuple_element
?這個模板類寫一些特化,用于確定各個元素的類型。div_t
?只有兩個成員,我們直接寫兩個全特化即可。
最后,我們需要寫一個?get
?函數(shù),用來綁定匿名變量的各個元素。此處的constexpr if
也是C++17的新特性,它的條件表達(dá)式必須是一個編譯期常量,因此它會在編譯期就能根據(jù)條件選擇相應(yīng)的分支,直接將另一個分支刪除,有點類似于預(yù)處理指令?#ifdef
-#else
-#endif
?的效果。
對于我們這個簡單的例子constexpr if
并不是必須的,因為這里的if兩個分支返回的類型是相同的。如果if
兩個分支返回不同的類型,就可以通過constexpr if
消除不需要的分支,保證編譯能夠通過。
完整的示例代碼如下:
完整語法
存儲類說明符?:(C++20起)
static
thread_local
cv限定符?:
const
volatile
(C++20起棄用)const volatile
(C++20起棄用)引用限定符?:
&
&&
初始化器?:
=
初始化表達(dá)式{
初始化表達(dá)式}
(
初始化表達(dá)式)
結(jié)構(gòu)化綁定聲明?:
存儲類說明符????cv限定符????
auto
?引用限定符????[
標(biāo)識符列表]
?初始化器?;
最后,歡迎來到QQ頻道交流討論。
頻道名稱:std::forward編程社區(qū)
搜索頻道號直達(dá):wxj6l1350o