作者 | Jimmy Hartzell
策劃 | 云昭
本文的作者Jimmy Hartzell是一名在公司內(nèi)部教授高級C++課程的專家,卻在重返“現(xiàn)代化”C++之后,對這門語言的改進感到非常失望。在這篇文章中,作者將重點討論各種C++的小“毒瘤”,這些“毒瘤”的設(shè)計決策讓作者大有“恨鐵不成鋼”之感。
作者同時也有豐富的Rust經(jīng)驗,但并沒有將C++與 Rust 或其他編程語言進行比較,而是重點從 C++ 的角度來探討這些看似很有技術(shù)含量的“毒瘤”究竟有沒有意義。
一、“技巧”并不高大上,也不值得炫耀
重返C++開發(fā),我自信滿滿,滿懷期待:我仍然懷揣C++各種“吊詭”技能,并且仍然可以高效地工作,而且如今我已經(jīng)使用了一種更現(xiàn)代的編程語言,遺留問題應(yīng)該會更少。但現(xiàn)實是C++ 帶來的刺痛更多。
Rust 中有很多我懷念的功能,都能在C++中輕松添加,比如很明顯的安全功能,再比如對sum type(在 Rust 中稱為 enums)或元組(tuple)的first-class支持。(澄清:std::tuple和std::variant 不是first-class的支持,這兩個實際上非常的笨重。)
不過,在開始討論吊詭的“剪紙”技巧之前,我想先談?wù)勎宜吹降?C++ 的主要防御“措辭”之一,我發(fā)現(xiàn)下面的描述特別令人困惑:
C++ 是一種很棒的編程語言。這些抱怨只是來自那些不勝任的人。如果他們是更好的程序員,他們會欣賞 C++ 的做事方式,并且他們不需要他們的幫助。像 Rust 這樣的語言對于真正的專業(yè)人士來說沒有幫助。
顯然,這種措辭有點“抓馬”,但我已經(jīng)多次看到這種態(tài)度。我對它最寬容的看法是,C++ 的困難正是其功能強大的標(biāo)志,也是使用強大的編程語言的自然成本。然而,在很多情況下,它對我來說是精英主義的一種形式:讓“弱雞”程序員變得輕松是一個毫無意義的大眾想法,而優(yōu)秀的程序員不會從讓事情變得更容易中受益。
作為一個在我職業(yè)生涯的大部分時間里都從事 C++ 專業(yè)編程并且教授(公司內(nèi)部)高級 C++ 課程的人,這對我來說簡直是無稽之談。我確實知道如何駕馭 C++ 的許多“剪紙和避雷”的技巧,并且很高興在處理 C++ 代碼庫時這樣做。但盡管我經(jīng)驗豐富,但它們?nèi)匀粫下业乃俣炔⒎稚⑽业淖⒁饬?,將注意力從我試圖解決的實際問題上轉(zhuǎn)移開,并導(dǎo)致代碼的可維護性較差。
至于好處,我沒看到C++的這些技巧有什么高大上的。除非是遺留代碼庫或者僅在恰好不支持 Rust 的特定編譯器中可用的優(yōu)化方面,C++ 比 Rust 更高效或更合適,否則這些技巧大多用來處理跟編程語言的實際設(shè)計無關(guān)的其他問題。
雖然我為自己的 C++ 技能感到自豪,但令我感到擔(dān)憂的是,這些看起來“更好的技術(shù)”可以使它們部分過時。即便擁有了讓它變得更容易的功能,我也不會欣賞。在大多數(shù)情況下,這種“技巧”并不能使C++為我解決更多工作的問題,反而是C++ 創(chuàng)造了不必要的額外工作的問題,因為使用這些所謂的技巧本身就讓你忽略了你工作的目的——不要這樣做。不要讓我開始研究頭文件!
二、雞肋的“剪紙”技巧,意義不大
我也希望我的編程語言對初學(xué)者友好。我總是會與其他具有各種技能的程序員一起工作,而且我寧愿不必糾正我同事的錯誤——或者我自己早期、更愚蠢版本的錯誤。雖然我也不同意為了讓一種編程語言對初學(xué)者更友好而犧牲掉功能,但許多甚至大多數(shù) C++ 對初學(xué)者不友好(并且令專家厭煩)的功能,實際上并沒有使該語言變得更強大。
言歸正傳,以下是我在從Rust回歸 C++ 開發(fā)時注意到的最大的雞肋“剪紙”技巧。
1.const不是默認值
const當(dāng)可以標(biāo)記參數(shù)時,很容易忘記標(biāo)記參數(shù)。你可能只是忘記輸入關(guān)鍵字。對于 來說尤其如此 this,它是一個隱式參數(shù):你沒有時間顯式地輸入this參數(shù),因此如果沒有適當(dāng)?shù)男揎椃粫谀抢锟雌饋砗苡腥ぁ?/p>
如果 C++ 有相反的默認值,其中除非顯式聲明每個值、引用和指針都是const可變的,那么我們更有可能根據(jù)函數(shù)是否需要改變它來正確聲明每個參數(shù)。如果有人包含mutable關(guān)鍵字,那是因為他們知道自己需要它。如果他們需要它但忘記了,編譯器錯誤會提醒他們。
現(xiàn)在,你可能認為這并不重要,因為你可以不使用const或不擁有具有不需要的功能的函數(shù) - 但有時你必須const在 C++ 中接受這些事情。如果你通過非引用獲取參數(shù)const,則調(diào)用者只能使用左值來調(diào)用你的函數(shù)。但如果通過引用獲取參數(shù)const,調(diào)用者可以使用左值或右值。因此,為了以自然的方式使用某些函數(shù),必須通過const引用獲取其參數(shù)。
一旦有了const引用,你就只能(輕松)用它調(diào)用接受const引用的函數(shù),因此,如果其中任何函數(shù)忘記聲明參數(shù)const,則必須包含const_cast – 或稍后更改函數(shù)以正確接受const。
以免你認為這只是一個草率的新手錯誤,請注意,標(biāo)準(zhǔn)庫中的許多函數(shù)必須更新,以代替 const_iterator或補充,iterator當(dāng)正確發(fā)現(xiàn)它們對像const_iterator: 這樣的函數(shù)有意義時erase。事實證明,對于像 之類的函數(shù)erase,集合必須是可變的,而不是迭代器——C++ 庫的維護者一開始就犯了錯誤。
2.強制Copy
在 C++ 中,可復(fù)制對象是對象行為的默認特權(quán)方式。如果你不希望對象可復(fù)制,并且其所有字段都可復(fù)制,則通常必須將復(fù)制構(gòu)造函數(shù)和復(fù)制賦值運算符標(biāo)記為= delete。默認情況下,編譯器會為你編寫代碼 - 代碼可能不正確。
但是,如果你確實讓你的class只能移動,請小心,因為這意味著在某些情況下你無法使用它。在 C++11 中,沒有符合人體工程學(xué)的方法來通過 move 進行 lambda 捕獲——這通常是我想要將變量捕獲到閉包中的方式。
這在 C++14 中已被“修復(fù)”——因為當(dāng)你想要從一開始就應(yīng)該使用默認值時,你現(xiàn)在可以使用極其笨拙的移動捕獲語法。
然而,即便如此,祝你使用 lambda 好運。如果你想把它放在 a 中std::function,那么直到今天你仍然不走運。std::function期望它管理的對象是可復(fù)制的,如果你的閉包對象是僅移動的,則將無法編譯。
這個問題將在 C++23 中得到解決, std::move_only_function 但與此同時,我被迫使用拋出某種運行時邏輯異常的復(fù)制構(gòu)造函數(shù)來編寫類。即使在 C++23 中,可復(fù)制函數(shù)也將是默認的假設(shè)情況。
奇怪的是,因為大多數(shù)復(fù)雜的對象,尤其是閉包,永遠不會也不應(yīng)該被復(fù)制。一般來說,復(fù)制復(fù)雜的數(shù)據(jù)結(jié)構(gòu)是一個錯誤——缺少&或缺少std::move。但這是一個錯誤,沒有任何警告,并且代碼中沒有明顯的跡象表明正在執(zhí)行復(fù)雜的、需要大量分配的操作。這是給新 C++ 開發(fā)人員的早期教訓(xùn)——不要按值傳遞非原始類型——但即使是高級開發(fā)人員也可能時不時地搞砸,而且一旦它進入代碼庫,就很容易錯過。
3.通過引用獲取參數(shù):反人性的設(shè)計
在 C++ 中按元組返回多個值是反人性的。std::tie這是可以做到的,但是對和 的調(diào)用std::make_tuple是冗長且分散注意力的,更不用說你將不習(xí)慣地編寫,這對于正在閱讀和調(diào)試你的代碼的人來說總是不好的。
旁注:有人在評論中提出了結(jié)構(gòu)化綁定,好像這解決了問題。結(jié)構(gòu)化綁定是現(xiàn)代 C++ 支持者喜歡引用的半途而廢的一個很好的例子。結(jié)構(gòu)化綁定對某些人有幫助,但如果你認為它們使按元組返回符合人體工程學(xué),那你就錯了。你仍然需要在函數(shù)返回語句中或 在函數(shù)的返回類型中寫入std::pair或 。這不是最糟糕的,但它仍然不如完整的一流元組支持那么輕量級,并且還不足以說服人們不使用參數(shù),這是我真正的抱怨。std::make_tuplestd::tuple即便如此,并不是輸出參數(shù)(或輸入輸出參數(shù))不好,而是它們在 C++ 中不好,因為沒有好的方法來表達它們。
那么我們該怎么辦呢?元組的笨重導(dǎo)致人們轉(zhuǎn)而使用參數(shù)。要使用輸出參數(shù),你最終會通過非引用獲取參數(shù)const,這意味著該函數(shù)應(yīng)該修改該參數(shù)。
問題是,這僅在函數(shù)簽名中標(biāo)記。如果你有一個通過引用獲取參數(shù)的函數(shù),則該參數(shù)在調(diào)用站點看起來與按值參數(shù)相同:
// Return false on failure. Modify size with actual message size,
// decreasing it if it contains more than one message.
bool got_message(const char *void mesg, size_t &size);
size_t size = buff.size();
got_message(buff.data(), size);
buff.resize(size);
如果你快速閱讀調(diào)用代碼,則該調(diào)用可能看起來 resize是多余的,但事實并非如此。size正在被 修改 got_message,并且知道它正在被修改的唯一方法是查看函數(shù)簽名,該函數(shù)簽名通常位于另一個文件中。
出于這個原因,有些人更喜歡通過指針傳遞 out 參數(shù)和 in-out 參數(shù):
bool got_message(const char *void mesg, size_t *size);
size_t size = buff.size();
got_message(buff.data(), &size);
buff.resize(size);
如果指針不可為空的話,那就太好了。nullptr在這種情況下參數(shù)意味著什么?它會觸發(fā)未定義的行為嗎?如果將調(diào)用者的指針傳遞給它會怎樣?開發(fā)者經(jīng)常忘記記錄函數(shù)如何使用空指針。
這可以通過不可為空的指針來解決,但很少有程序員在實踐中真正這樣做。當(dāng)某些東西不是默認值時,它往往不會在適當(dāng)?shù)牡胤绞褂谩Υ说目沙掷m(xù)答案是改變默認設(shè)置,而不是與人性作斗爭的英勇嘗試。
4.方法實現(xiàn)可能會矛盾
在 C++ 中,每次編寫一個類(尤其是較低級別的類)時,你都有責(zé)任對編程語言中具有特殊語義重要性的某些方法做出決策:
- 構(gòu)造函數(shù)(copy):X(const X&)
- 構(gòu)造函數(shù)(move):X(X&&)
- 作業(yè)(copy):operator=(const X&)
- 作業(yè)(move):operator=(X&&)
- 析構(gòu)函數(shù):~X()
對于許多類,默認實現(xiàn)就足夠了,如果可能的話你應(yīng)該依賴它們。這是否可行取決于簡單地復(fù)制所有字段是否是復(fù)制整個對象的明智方法,而這很容易忘記考慮。
但是,如果你需要其中之一的自定義實現(xiàn),那么你就需要編寫所有這些。這就是所謂的“5 法則”。你必須編寫所有這些,即使兩個賦值運算符的正確行為可以完全由適當(dāng)?shù)臉?gòu)造函數(shù)與析構(gòu)函數(shù)相結(jié)合來確定。編譯器可以默認實現(xiàn)引用這些其他函數(shù)的賦值運算符,因此始終是正確的,但事實并非如此。正確實現(xiàn)它們是很棘手的,需要諸如顯式防止自分配或與按值參數(shù)交換等技術(shù)。無論如何,它們都是樣板文件,并且是具有許多此類內(nèi)容的編程語言中可能出錯的另一件事。
旁注:確實,許多類可以使用= default所有這些方法。但是,如果你自定義復(fù)制構(gòu)造函數(shù)或移動構(gòu)造函數(shù),則還必須自定義賦值運算符以匹配,即使默認實現(xiàn)可能是正確的(如果語言定義得更智能的話)。通過引用5規(guī)則就可以清楚地看出這一點,它基本上說明了這一點。完整的規(guī)則在CPP參考中進行了解釋。如果自定義復(fù)制或移動構(gòu)造函數(shù),相應(yīng)的= default 賦值運算符將會出錯。當(dāng)心!請注意示例代碼如何不使用= default賦值運算符,即使賦值運算符不包含邏輯。
三、C++ 做了太多錯誤的決策
看到 Hacker News 上的評論后,我覺得有必要添加這一部分。每當(dāng)有人抱怨 C++ 中的任何問題時,就會有人提到修復(fù)該問題的較新版本的 C++。這些“修復(fù)”通常不是那么好,只有當(dāng)你習(xí)慣了一切都有點笨拙時,才感覺像是修復(fù)。
原因如下:
- 默認方式仍然是舊的、糟糕的方式。例如,通過 move 捕獲 lambda 應(yīng)該是默認值,而std::move_only_functionC++23 中即將推出的 lambda 應(yīng)該是默認值std::function。
- 出于這個原因,并且因為舊的、糟糕的方式從來沒有啟用警告,所以即使是新程序員也會繼續(xù)以糟糕的方式做事。
當(dāng)然,我知道這對于向后兼容性很重要。但這就是整個問題:C++ 積累了太多錯誤的決策。為什么要復(fù)制參數(shù)傳遞集合的默認值,更不用說 lambda 捕獲了?我知道歷史原因,但這并不意味著現(xiàn)代編程語言應(yīng)該這樣工作。
即使 C++11 也無法消除這樣一個事實:原始指針和 C 風(fēng)格數(shù)組具有良好的語法,而智能指針看起來很std::array 糟糕。即使 C++11 也無法澄清它正在圍繞一種無需移動而設(shè)計的語言工作。
四、寫在最后:C++痼疾難消
不幸的是,我非常清楚為什么做出這些決定,而這正是原因之一:與遺留代碼的兼容性。C++ 沒有版本系統(tǒng),無法棄用核心語言功能。如果創(chuàng)建了 C++ 的新版本,它將不再是 C++ ——盡管我支持人們將 C++ 轉(zhuǎn)換為新語法并清理其中一些內(nèi)容的努力。
這也是唯一的好處:與歷史的連續(xù)性。雖然我可以看到其中的價值,但它的價值非常有限,范圍也非常有限。但是,如果你忽略掉向后兼容性和現(xiàn)有的大型代碼庫,那么這些“剪紙”技巧都不會使編程語言變得更強大或更好,只會更難使用。我看到過支持“人工維護頭文件”的觀點,這讓我很驚訝,告訴我 C++ 在這些問題上的設(shè)計選擇有什么好處。
有人可能會說這些事情微不足道,但這些都會減慢程序員的速度,同時又讓他們煩惱。如果你有足夠的經(jīng)驗,你的潛意識可能擅長駕馭這些“招式”,但設(shè)想一下,這些潛意識原本是要來注意哪些方面的。
想象一下,你在初級同事的代碼審查中能很快察覺到這些錯誤嗎?如果你是嚴(yán)格審核人,還需要多少時間?如果這些問題得到解決,開發(fā)者會變得更加有效、更加高效、更加快樂。編程也會變得既有趣又快捷。
原文鏈接:https://www.thecodedmessage.com/posts/c++-papercuts/