制作網(wǎng)站參考案例廣州優(yōu)化疫情防控舉措
0.前情提要
(很久)之前上編譯原理時(shí),一次實(shí)驗(yàn)課需要補(bǔ)充完善一個(gè)用 c 寫的詞法分析器;而這個(gè)分析器在定義語法樹結(jié)點(diǎn)時(shí)使用了 union
存儲(chǔ)語言中不同表達(dá)式的類型標(biāo)簽或值本身。因?yàn)楫?dāng)時(shí)剛好學(xué)完了 cpp,拿著錘子看啥都像釘子,所以嘗試(并且勉強(qiáng)成功地)將給好的程序用 cpp 重寫了一遍(好孩子不要學(xué))。
重寫過程中遇到的最大問題就是:源程序中的 union
與 cpp 的類型系統(tǒng)不太兼容,不管怎么寫編譯器都會(huì)給我糊一個(gè)編譯錯(cuò)誤;這就引出了一個(gè)問題:cpp 的 union
究竟該如何使用。
1.union
與 cpp
從類型論角度來看,union
是一種“和類型1”,這種類型允許在同一個(gè)地址空間、但在不同時(shí)間存放不同類型的數(shù)據(jù)。
#include <bitset>
#include <iostream>// 例如說對(duì)于下面這個(gè) union
union U {float mem1;int mem2;
};int main()
{static_assert(sizeof(U) == std::max(sizeof(float), sizeof(int)));// union 的大小通常等于其最大的成員分量的大小U tmp { 3.14f }; // 可以先存入一個(gè) float 類型數(shù)據(jù)std::cout << tmp.mem1 << std::endl; // 使用掉它tmp.mem2 = 114514; // 稍后再往同一個(gè)內(nèi)存空間存另一種類型的數(shù)據(jù)std::cout << tmp.mem1 << std::endl;// 這樣就實(shí)現(xiàn)了一段內(nèi)存空間的復(fù)用
}
union
類型經(jīng)常被用在一些語法解析器中,因?yàn)樗闷饋韺?shí)在是很方便(指能夠以定長(zhǎng)空間存儲(chǔ)多種類型數(shù)據(jù))。
自 cpp11 后,cpp 標(biāo)準(zhǔn)提高了類型安全的要求,但如果我們看一下 union
定義就會(huì)發(fā)現(xiàn),這個(gè)語言功能天生就極其的類型不安全。舉個(gè)例子,下面這段代碼就直接“擊穿”了 cpp 的類型系統(tǒng)(雖然這種擊穿隨處可見):
#include <iostream>
#include <cstring>union Breaker {int answer;double magic_number;char* magic_string;
};int main()
{// 活躍成員為 int 類型// 活躍成員是指最近一次存有有效數(shù)據(jù)的成員分量Breaker bk { 42 };std::cout << bk.answer << std::endl; // Okstd::cout << bk.magic_number << std::endl; // 能夠通過編譯,但運(yùn)行期行為未定義// std::cout << strlen( bk.magic_string ) << std::endl; // 同上,但這樣做通常會(huì)導(dǎo)致越界訪問,進(jìn)而導(dǎo)致程序 crashbk.magic_string = new char[21] { "Say something" };std::cout << bk.magic_string << std::endl;bk.magic_number = 3.14; // 活躍成員的切換導(dǎo)致指向堆上資源的指針被覆蓋// 最終導(dǎo)致內(nèi)存泄漏發(fā)生
}
得益于 cpp “充分信任程序員2”的理念,除非編譯器對(duì)這種行為有單獨(dú)且明確的警告,否則這種代碼完全能夠通過編譯并執(zhí)行(cpp 是自由的);但這種非主觀地突破類型系統(tǒng)的行為通常會(huì)導(dǎo)致程序出現(xiàn)各種運(yùn)行期錯(cuò)誤,最明顯不過的就是上述代碼的越界訪問。
并且,如果試圖往 union
中填入標(biāo)準(zhǔn)庫的容器類型或是其他自定義的對(duì)象類型(這相當(dāng)實(shí)用且常見),編譯器有時(shí)還會(huì)沒頭沒尾地爆出“默認(rèn)析構(gòu)函數(shù)已被棄置”的編譯錯(cuò)誤;這是為什么?
2.讓代碼先通過編譯
答案是標(biāo)準(zhǔn)的規(guī)定。根據(jù) cpp 標(biāo)準(zhǔn):
- 在 cpp11 以前,帶有非平凡的構(gòu)造函數(shù)和析構(gòu)函數(shù)的非靜態(tài)成員(下稱非平凡成員)不能被放置在
union
中; - 在 cpp11 之后,非平凡成員可以被填入
union
中,但這個(gè)union
自己的復(fù)制、移動(dòng)、默認(rèn)構(gòu)造函數(shù)以及復(fù)制賦值、移動(dòng)賦值運(yùn)算符和析構(gòu)函數(shù)都不會(huì)被編譯器默認(rèn)提供,并且由用戶提供的默認(rèn)構(gòu)造函數(shù)中只允許一個(gè)成員使用默認(rèn)成員初始化器(就是構(gòu)造函數(shù)里的那個(gè)冒號(hào))。
這都 4202 年了,cpp11 之前的事情我們不管,現(xiàn)在只需要把焦點(diǎn)聚焦在 cpp11 后的標(biāo)準(zhǔn)規(guī)定上。那么首先,什么是“非平凡”?
平凡類型指的是標(biāo)量類型(如 int
和 std::nullptr_t
等)和平凡類類型,以及前兩種類型組成的數(shù)組類型。
平凡類類型必須滿足:
- 它是一個(gè)可平凡復(fù)制類(也就是可以被以
memcpy
這種方式復(fù)制); - 有一個(gè)以上的合格的平凡默認(rèn)構(gòu)造函數(shù),并且這些構(gòu)造函數(shù)必須什么都不干(即由編譯器提供)。
這里的定義很復(fù)雜(一般也懶得看),要求也很苛刻,但不滿足約束條件的結(jié)果只有一個(gè):該 union
的六大特殊成員函數(shù)3都會(huì)被默認(rèn)棄置(即編譯器不會(huì)幫你自動(dòng)生成);這也就是前文中會(huì)爆出編譯錯(cuò)誤的原因(因?yàn)榫幾g器壓根找不到要用的函數(shù)在哪)。
從這里可以看出,
union
在語法功能上與struct
和class
極其類似:它們都有析構(gòu)函數(shù),也有構(gòu)造函數(shù),也都可以有自己的成員函數(shù);甚至每個(gè)成員分量都可以有自己的訪問控制權(quán)限。
通常來說,一個(gè)這樣的 union
在編譯時(shí)會(huì)這樣的編譯錯(cuò)誤:
#include <string>union U {int integer;double floating;std::string str;
};int main()
{U uni;
} // error: use of deleted function 'U::~U()'
但如果為這個(gè) union
類型添加一個(gè)什么都不做的析構(gòu)函數(shù)和默認(rèn)構(gòu)造函數(shù),就一切都正常了。
#include <string>union U {int integer;double floating;std::string str;U() {}~U() {}
};int main()
{U uni;
} // everything ok
你以為這么簡(jiǎn)單就結(jié)束了嗎?當(dāng)然沒有。不妨再細(xì)想一下:當(dāng)活躍成員是一個(gè) std::string
時(shí),如果需要將活躍成員切換為另一個(gè)分量時(shí),我們是安全的嗎?
3.正確使用 union
這里有個(gè)前提:由于編譯器無從得知一個(gè) union
的當(dāng)前活躍成員是誰,因此自然而然的,union
內(nèi)的對(duì)象的析構(gòu)函數(shù)永遠(yuǎn)不會(huì)自動(dòng)被執(zhí)行。
因?yàn)?
union
可以被作為參數(shù)在不同函數(shù)調(diào)用棧間傳遞與修改,因此通過追蹤代碼流走向,進(jìn)而查出一個(gè)union
的當(dāng)前活躍成員絕對(duì)是一件不可能的事情。
這就導(dǎo)致了,當(dāng) union
的活躍成員從一個(gè)非平凡成員上切走時(shí),我們必須主動(dòng)調(diào)用該成員的析構(gòu)函數(shù);如果不這樣做,答案自然是內(nèi)存泄漏(因?yàn)檫@種操作打破了 RAII 保證)。
而當(dāng)我們將 union
切換到另一個(gè)非平凡成員分量時(shí),在除了創(chuàng)建該 union
以外的情景下,都必須使用 placement new 的方式在指定地址調(diào)用構(gòu)造函數(shù)。
必須使用 placement new 是因?yàn)?#xff1a;在 cpp 標(biāo)準(zhǔn)定義中,任何對(duì)象在被聲明后都一定被構(gòu)造完畢(可能是通過默認(rèn)無參構(gòu)造,也可能是通過參數(shù)構(gòu)造),總之該對(duì)象所處的內(nèi)存區(qū)域的數(shù)據(jù)必然有效且良定義;
而union
本身只能被視作是一塊存有無序數(shù)據(jù)的內(nèi)存,因此位于其上的對(duì)象是完全不存在的,這樣的對(duì)象可能處于任何狀態(tài);此時(shí)如果試圖調(diào)用移動(dòng)構(gòu)造函數(shù)覆蓋原有數(shù)據(jù)自然也是不符合標(biāo)準(zhǔn)的。
這就導(dǎo)致了正確使用 union
的代碼極其割裂和丑陋。
#include <string>
#include <iostream>template<typename T, typename E>
union UnionLike { // 沒錯(cuò),union 當(dāng)然可以模板化T result_value_;E error_info_;UnionLike( T value ) : result_value_ { move( value ) } {}UnionLike( E error ) : error_info_ { move( error ) } {}~UnionLike() {}
};int main()
{UnionLike<int, std::string> result( "Unknown" ); // 創(chuàng)建時(shí)不需要 placement newstatic_assert(sizeof( result ) == std::max( sizeof( std::string ), sizeof( int ) ));// 沒問題std::cout << result.error_info_ << std::endl;// 未定義行為// std::cout << result.result_value_ << std::endl;// 切換活躍成員之前必須主動(dòng)調(diào)用析構(gòu)函數(shù)result.error_info_.~basic_string();// 然后通過 placement new 在原地址上構(gòu)造新對(duì)象new (&result) int( 42 ); // 當(dāng)然,對(duì)于平凡類型不必如此cout << result.result_value_ << endl;// here is definitely an UB// std::cout << result.error_info_ << std::endl;
}
因此通常來說,要想安全使用 union
都需要使用一個(gè) class
做一遍封裝。
令人高興的是,自 cpp17 后標(biāo)準(zhǔn)庫中有了 std::variant
,這就是一個(gè)類型安全的 union
;而在 cpp17 以前,則可以選擇 boost::variant
作為代餐。
至于
std::variant
是如何實(shí)現(xiàn)的,就是一個(gè)相當(dāng)復(fù)雜的問題了(我也不想知道);感興趣的可以打開自己的 STL 頭文件慢慢看,反正 cpp 模板庫都是開源的(逃)。
#include <variant>
#include <iostream>int main()
{std::variant<int, std::string> result( "Unknown" );// 因?yàn)閷?shí)現(xiàn)機(jī)制的問題,所以求 std::variant 的實(shí)際大小時(shí)需要減去一個(gè)指針的長(zhǎng)度static_assert((sizeof( result ) - sizeof( void* )) == std::max( sizeof( std::string ), sizeof( int ) ));// 但和 cpp 中其他泛型容器一樣,東西放進(jìn)去容易,取出來很麻煩// 可以獲取當(dāng)前活躍成員所在的索引下標(biāo)std::cout << "Index of: " << result.index();// 然后通過指定類型與 std::get 訪問對(duì)應(yīng)成員,但如果活躍成員不是這個(gè)類型,就會(huì)拋出異常std::cout << " is value of: " << std::get<std::string>( result ) << std::endl;result = 42; // 使用賦值運(yùn)算符直接切換活躍成員,不需要手動(dòng)析構(gòu)成員// 也可以使用訪問器std::visit( []( auto&& arg ) { std::cout << arg << std::endl; }, result );
}
不過如果能確保使用 union
時(shí)都是一些非常底層的場(chǎng)景,從頭到尾都在干一些臟活而不會(huì)向 union
中填入非平凡類型的話,大膽使用 union
就好了,畢竟即使是“零抽象開銷”的標(biāo)準(zhǔn)庫也不是真的完全是毫無開銷的。
與之相對(duì)的,元組(或者是 c/cpp 的結(jié)構(gòu)體)是一種“積類型”,也就是可以在不同空間、同一時(shí)間存入不同數(shù)據(jù)。 ??
更多時(shí)候像是一種毫無約束的自由;而放縱的自由就意味著混亂。 ??
分別是:默認(rèn)構(gòu)造函數(shù)、復(fù)制構(gòu)造函數(shù)、移動(dòng)構(gòu)造函數(shù)、復(fù)制賦值運(yùn)算符、移動(dòng)賦值運(yùn)算符和析構(gòu)函數(shù)。 ??