造函數(shù)為什么必須用const引用?一文拆透值傳遞與指針的致命缺陷)
1. 一個最容易被背錯答案的問題這道題到底在考什么我在社區(qū)里見過不少 C 的初學(xué)者提問十有八九繞不開這個經(jīng)典考點拷貝構(gòu)造函數(shù)的形參為什么不能用值傳遞很多人的回答是“會用死循環(huán)導(dǎo)致棧溢出”也確實有人背過“必須用引用”這個結(jié)論但追問一句“為什么用指針也不行”往往就卡殼了。這其實是一個非常典型的 C 原理題背后牽扯的不只是語法規(guī)則而是編譯器如何理解對象構(gòu)造、如何匹配構(gòu)造函數(shù)以及一個類作為“值類型”在語言層面最基本的語義約定。搞懂這個問題一類“為什么標準庫這么設(shè)計”“為什么我的類不能放進某些容器”的疑問會順帶打開。先把概念釘死拷貝構(gòu)造函數(shù)是一個類的一個重載構(gòu)造函數(shù)參數(shù)是const T作用是用一個同類型的既有對象去初始化另一個新對象。標準形式長這樣class MyClass { public: MyClass(); // 默認構(gòu)造 MyClass(const MyClass rhs); // 拷貝構(gòu)造 };為什么規(guī)定必須是const T這個const和兩個修飾符哪個都不能缺。下面我們從值傳遞、指針傳遞和引用傳遞三條路徑逐一拆解看看編譯器面對它們時分別經(jīng)歷了什么你就徹底明白這條規(guī)則不是為了考試通關(guān)而是語言設(shè)計的必然結(jié)論。2. 當(dāng)形參寫成值傳遞一次無限遞歸的現(xiàn)場演示把人直接按進水里之前先看它掙扎的過程。假如我們把拷貝構(gòu)造函數(shù)的形參改成值傳遞會是什么情形代碼如下class BadClass { public: BadClass() {} BadClass(const BadClass other) { // 形參是值傳遞 // 業(yè)務(wù)邏輯... } };你猜大多數(shù)編譯器面對這段代碼會怎么做直接拒絕編譯。GCC、Clang 和 MSVC 都會提示大概這樣一句話拷貝構(gòu)造函數(shù)的形參必須是const BadClass而不能是BadClass或BadClass。為什么編譯器直接抬出這個限制因為語言的語義約束其實就是在根源上杜絕炸彈。但從理解的角度我們必須親眼看看如果編譯器允許這個代碼存在運行時會發(fā)生什么。這是整個機制中最核心的部分——拷貝構(gòu)造函數(shù)的值傳參會引發(fā)無窮無盡的“套娃構(gòu)造”。2.1 從“用對象初始化新對象”到“給函數(shù)傳參”每一次值傳遞都在呼叫拷貝構(gòu)造先明確一個底層事實在 C 里函數(shù)形參如果聲明為“按值傳遞”那么在傳入實參的那一刻系統(tǒng)必須創(chuàng)建一個形參副本。這個副本的創(chuàng)建方式就是調(diào)用類的拷貝構(gòu)造函數(shù)。打個比方值傳參相當(dāng)于把一本書的內(nèi)容手抄一份送給函數(shù)而“手抄”這一行為本身必須靠“抄寫員”來完成這里的“抄寫員”就是拷貝構(gòu)造?,F(xiàn)在麻煩來了如果拷貝構(gòu)造函數(shù)本身的形參就是按值傳遞那么函數(shù)體內(nèi)的邏輯是“用other來初始化this所指的新對象”。這個初始化根本還沒開始函數(shù)入口處就已經(jīng)發(fā)生了一次新的值傳遞把外面?zhèn)魅氲膶崊?fù)制成形參other。而這一復(fù)制動作需要再次調(diào)用同一個類的拷貝構(gòu)造函數(shù)。每次調(diào)用函數(shù)入口都要再做一次復(fù)制于是形成了一個沒有終點的調(diào)用鏈。用偽代碼描述的話整個遞歸過程是這樣的拷貝構(gòu)造(other): // 進入之前用外部對象初始化形參 other需要調(diào)用拷貝構(gòu)造 拷貝構(gòu)造(other): // 進入之前用參數(shù)初始化形參 other需要調(diào)用拷貝構(gòu)造 拷貝構(gòu)造(other): // 無限遞歸下去...對真實編譯器而言這種遞歸在語義分析階段就已經(jīng)不是“能不能運行”的問題而是“在類型層面這個函數(shù)在反復(fù)套自己”標準委員會干脆直接規(guī)定拷貝構(gòu)造函數(shù)必須接受引用。這不是堵死一條實現(xiàn)繞路的通道而是從語義上承認“生成一個拷貝”這個動作不應(yīng)該以“再生成一個拷貝”為前提。2.2 遞歸的代價棧上每層都要留一套完整幀如果硬要繞過編譯器的檢查去模擬這種遞歸例如用一個返回類型為T的函數(shù)強行觸達我們要面對的災(zāi)難是每一層調(diào)用都要在調(diào)用棧上分配一份包含形參對象和函數(shù)局部狀態(tài)的空間。假設(shè)這個對象包含 1KB 的數(shù)據(jù)值傳參一次就需要在棧上復(fù)制 1KB函數(shù)還沒等跑完業(yè)務(wù)邏輯第一層“復(fù)制實參到形參”的動作就吃完了棧。這個過程會一直膨脹到棧爆掉。所以即便沒有編譯器的攔截就算某天標準放寬允許這種寫法程序也幾乎沒有可能正常返回。它不是一個“低效但能用”的設(shè)計而是一個“根本不可能完成第一步有效工作”的設(shè)計。值傳遞的拷貝構(gòu)造函數(shù)等于讓每個拷貝都要先拷貝出一個中間人來執(zhí)行拷貝而這個中間人又需要另一個中間人來產(chǎn)生。與“先有雞還是先有蛋”的哲學(xué)困境不同這里連蛋殼都落不了地。2.3 一個圖景類比理發(fā)師悖論的工程版你一定聽過理發(fā)師悖論或者“這個列表包含所有不包含自己的列表”。值傳遞的拷貝構(gòu)造函數(shù)與此類同拷貝的目標是生成一份新對象但為了生成新對象卻又必須先完成一次新的拷貝。用生活化的方式理解可以想象有兩個寫字的人需要互相幫忙抄寫文件。如果規(guī)定“幫我抄文件的這個人必須由一個和我長得一樣的人來指定”但那個指定者也需要有人來指定最終你永遠等不到一個能開始抄寫的人。搞懂這一層你在面試里如果被問到“值傳遞會發(fā)生什么”就不會只背一句“無限遞歸”了。你能描述出遞歸鏈條每一環(huán)的本質(zhì)函數(shù)入口的“實參到形參的復(fù)制動作”本身就是一個合法拷貝構(gòu)造的調(diào)用點。編譯器不允許這種寫法是在入口處直接拆除了炸彈的引信。3. 指針形參語法上能編譯語義上徹底走偏接著是另一個高頻誤區(qū)有人說“既然值傳遞不行那我就用指針傳反正指針不會觸發(fā)構(gòu)造函數(shù)”。這話一半對。語法層面類確實可以寫一個參數(shù)為Foo*的構(gòu)造函數(shù)這種構(gòu)造函數(shù)也能夠被正常調(diào)用、編譯運行。但它有一個致命問題它根本不被語言判定為拷貝構(gòu)造函數(shù)。3.1 語言定義里的“拷貝構(gòu)造函數(shù)”只認T和const T打開 C 標準對類構(gòu)造函數(shù)的限定你會發(fā)現(xiàn)拷貝構(gòu)造函數(shù)有一個專門的定義要么參數(shù)是T要么參數(shù)是const T其他形如T* p的形式都不在拷貝構(gòu)造函數(shù)的范疇內(nèi)。這意味著什么意味著編譯器在以下三個場景中不會調(diào)用這個“指針拷貝構(gòu)造”:Foo b(a)這樣直接用已有對象初始化新對象時編譯器找的是真正的拷貝構(gòu)造函數(shù)按值返回局部對象時編譯器嘗試用拷貝構(gòu)造來構(gòu)造返回值如果類里只寫了Foo(Foo*)這個過程就會失敗把對象放入標準庫容器比如std::vectorFoo需要拷貝對象時容器模板內(nèi)部要求類型滿足“可拷貝構(gòu)造”找不到合適的拷貝構(gòu)造函數(shù)會導(dǎo)致編譯錯誤或行為異常。一句話指針版“拷貝構(gòu)造”只是類里一個名字碰巧很像的普通構(gòu)造函數(shù)它不能滿足 C 對值語義對象的基本要求——能夠被按值復(fù)制。3.2 從使用場景細看指針傳參的麻煩有多大就算我們放下“標準定義”這個框架只看實用層面指針傳參也充滿了各種麻煩事。第一是無名臨時對象。C 里大量對象生成于表達式中間比如func(Bar())這個臨時對象沒有名字不能取地址。如果你類的指針拷貝構(gòu)造函數(shù)長得像Foo(Foo*)那Foo f(Bar())這種寫法就無法工作因為Bar()是一個非法表達式。而 C 程序員時刻都在和臨時對象打交道一個無法接收臨時對象的構(gòu)造接口等于自動屏蔽掉一半的使用場景。第二是可讀性的混亂。Foo f(source)讀起來像什么像在明確表示“我要把source這塊內(nèi)存的地址傳給構(gòu)造器”至于這個構(gòu)造器是打算拷貝內(nèi)容、還是只是觀察一下、甚至是侵入性地接管所有權(quán)讀者無法從調(diào)用點判斷。拷貝是一個很基礎(chǔ)的動作它不應(yīng)該要求調(diào)用者去記憶“傳指針會被當(dāng)成拷貝傳引用也會被當(dāng)成拷貝二者行為卻有差異”這類心照不宣的潛規(guī)則。第三是空指針和有效性檢查。如果拷貝構(gòu)造函數(shù)接受指針那函數(shù)體內(nèi)必須時刻準備處理nullptr。但拷貝操作要求的語義是“源對象一定存在且處于有效狀態(tài)”一個合法的拷貝動作不應(yīng)當(dāng)可能出現(xiàn)“源對象根本不存在”的情況。把非空作為前提程序里到處塞空指針防御邏輯既降低效率又增加犯錯面。3.3 指針版的地獄除非你接受一個不可能達成的語義有人可能會辯解說“我可以讓指針版構(gòu)造函數(shù)內(nèi)部做深拷貝行為不就一樣了嗎”問題在于語言層面對拷貝行為的調(diào)度是基于類型系統(tǒng)的。想象一個極短的場景class Foo { public: Foo(int v) : data(v) {} Foo(const Foo* src) { data src-data; } // 指針“拷貝” private: int data; }; Foo getFoo() { Foo local(42); return local; // 這里會發(fā)生什么 }函數(shù)getFoo()按值返回local編譯器試圖用local來構(gòu)造返回對象。它需要的是一個接受Foo作為右值或有 const 引用形參的構(gòu)造入口但類里沒有。于是編譯器報錯“無法將Foo轉(zhuǎn)換為const Foo*”。即使你在返回處手寫return local也不行因為返回類型是Foo而不是Foo*類型根本不匹配。這就是指針方案最尷尬的地方它自己覺得能做拷貝類型系統(tǒng)卻不認賬。每到需要標準拷貝語義的場合編譯器都會禮貌地告訴你缺一個真正的拷貝構(gòu)造函數(shù)。所以從工程結(jié)論上講指針傳遞是一個“寫得出來但用不上的擺設(shè)”誰用誰在真正的拷貝場合翻車。4. 為什么答案偏偏落在 const 引用接口設(shè)計的三個層次聊完兩個錯誤答案真相已經(jīng)露出來了拷貝構(gòu)造函數(shù)必須用const T。這個結(jié)論并非來自一個人的偏好而是源于三點環(huán)環(huán)相扣的語言設(shè)計邏輯。4.1 引用保障了“零拷貝地看源對象”引用本質(zhì)上是目標對象的一個別名它不產(chǎn)生新對象、不復(fù)制任何內(nèi)存。因此在拷貝構(gòu)造的入口使用引用傳參可以保證調(diào)用拷貝構(gòu)造不會因為“傳參”這個動作本身再觸發(fā)一次構(gòu)造。整個調(diào)用鏈是平整的外部傳入一個對象函數(shù)內(nèi)部以引用方式觀看它然后只需要復(fù)制一次其內(nèi)部數(shù)據(jù)就完成了新對象的初始化。這一點和值傳遞形成了決定性反差。值傳參本身就是一次拷貝拷貝構(gòu)造函數(shù)最不能容忍的恰恰是“為了拷貝而先拷貝”。引用把這條捷徑堵住讓拷貝只在一個地方發(fā)生——即構(gòu)造函數(shù)的函數(shù)體內(nèi)。4.2 const 保護了源對象不被意外改寫又有人問那為什么不用普通的T理由有很多其中最重要的一條是保護傳入對象??截悩?gòu)造的目的是“創(chuàng)建一個獨立的副本”而不是“順手修改原對象”。把形參限定為const T等于由編譯器替我們確保在拷貝構(gòu)造過程中源對象的任何成員都不可被改動。如果有人試圖在拷貝構(gòu)造里寫rhs.data 5編譯器會直接報錯。這個設(shè)計非常重要。因為拷貝動作最常見的場景是形形色色的值語義容器。你把對象塞進vectorvector擴容時會拷貝元素如果容器內(nèi)部對元素調(diào)用拷貝構(gòu)造時不加 const 保護可能出現(xiàn)擴容過程中源元素被莫名其妙篡改的數(shù)據(jù)競態(tài)。對不可變性的堅持消除了大量潛在的邏輯漏洞。同時const T還能綁定非 const 左值、const 左值以及臨時右值。類型系統(tǒng)的這種靈活性讓拷貝構(gòu)造可以被廣泛調(diào)用無論是Foo f1(src);、拷貝一個 const 對象還是Foo f2(Foo(1));這種臨時對象場景都能正常工作。4.3 接口設(shè)計的三重邏輯行為完整、調(diào)用自由、編譯期兜底如果把const T的三個性質(zhì)拆開看它們恰好構(gòu)成一個完整的設(shè)計矩陣引用消除無限遞歸保證編譯和運行兩個層面都不會出現(xiàn)自指循環(huán)const 保證源對象只讀不改把拷貝構(gòu)造成本控制在“只讀”的低風(fēng)險姿態(tài)里引用配合 const 提供了對所有表達式類別的兼容左值、右值、const 對象一網(wǎng)打盡。工程上我們其實很難找出另一個形參類型能同時滿足這三個條件。值傳遞從根上破壞第一點T破壞了第二點的安全性指針傳遞雖然繞過遞歸但不符合語言對拷貝語義的識別規(guī)則。于是設(shè)計者們選擇了唯一一個在類型系統(tǒng)內(nèi)自洽的方案。這里順帶提一個很多人的困惑為什么不能用T右值引用因為右值引用只能綁定臨時對象而拷貝的應(yīng)用場景大多是持久化的左值對象。如果拷貝構(gòu)造函數(shù)能被右值引用調(diào)用那Foo f(src)這種常規(guī)拷貝反而會報錯因為src是左值。右值引用是給移動構(gòu)造函數(shù)準備的與拷貝的目的天然相反。5. 真正會混淆人的反例為什么拷貝賦值運算符的形參可以是值傳遞很多人在這個知識點上栽過一次跟頭因為他們發(fā)現(xiàn)拷貝賦值運算符operator的形參可以寫值傳遞而且這在工程上非常常用。這跟拷貝構(gòu)造函數(shù)完全是兩碼事卻總被放在一起比較。我們把這條線也理清楚否則你對“為什么拷貝構(gòu)造不能值傳”的理解始終缺一塊拼圖。5.1 executor和文書的區(qū)別構(gòu)造函數(shù)與賦值函數(shù)的本質(zhì)差異拷貝構(gòu)造負責(zé)的是“從無到有生成一個新對象”它發(fā)生在對象生命周期的最開頭。而拷貝賦值負責(zé)的是“把已有對象的內(nèi)容覆蓋到另一個已經(jīng)存在的對象”它發(fā)生在對象生命周期中期?;氐?2.3 節(jié)的比喻拷貝構(gòu)造就像“新招一個文書讓他模仿老文書的字跡寫一份文檔”問題是這個新文書自身需要先由另一個文書仿制出來——于是自指。賦值則不同它的操作對象是“文書已經(jīng)坐在工位上了讓他照著隔壁工位的文檔重寫一遍自己的文檔”。他不會因為參考資料而需要再被復(fù)制一遍因為文檔抄寫是發(fā)生在人自己頭腦里的。從語言層面看operator(Foo other)中形參是值傳遞但這次值傳遞是調(diào)用賦值函數(shù)之前一次“合法的拷貝構(gòu)造”。外部傳入實參時會用拷貝構(gòu)造復(fù)制一份作為形參other然后函數(shù)體在對已有對象this做賦值操作。這里沒有循環(huán)因為每個角色都在正確的位置上文書的復(fù)制動作發(fā)生在賦值前而不是賦值又要觸發(fā)賦值。5.2 copy-and-swap 慣用法一次值傳參實現(xiàn)強異常安全具體工程里值傳遞的operator通常跟 copy-and-swap 慣用法綁定出現(xiàn)。核心思路是構(gòu)造一個臨時副本再把副本與當(dāng)前對象交換讓所有資源管理的臟活累活都集中在拷貝構(gòu)造和析構(gòu)函數(shù)里class Resource { public: Resource() default; Resource(const Resource rhs) { /* 真正的深拷貝 */ } void swap(Resource rhs) noexcept { using std::swap; swap(ptr_, rhs.ptr_); } Resource operator(Resource rhs) { swap(rhs); return *this; } private: char* ptr_ nullptr; };此時operator的形參是值傳遞外界傳入實參時先通過拷貝構(gòu)造生成rhs隨后用swap交換當(dāng)前對象與rhs的內(nèi)容函數(shù)結(jié)束后rhs作為局部對象自動析構(gòu)將舊資源釋放掉。整個過程中若拷貝構(gòu)造拋異常則賦值操作直接失敗且不會破壞原對象強異常安全就是這么來的。5.3triple 對比一張表說清楚“能值傳”和“不能值傳”的邊界操作形參類型是否合法為什么拷貝構(gòu)造函數(shù)const T合法拷貝動作本身不再觸發(fā)新拷貝遞歸被斬斷拷貝構(gòu)造函數(shù)T非法入口傳參觸發(fā)自調(diào)用無限遞歸拷貝構(gòu)造函數(shù)T*合法但不被承認為拷貝構(gòu)造無法綁定臨時對象語義失真容器無法使用拷貝賦值運算符T值傳遞合法正因先走了合法拷貝構(gòu)造才有臨時副本可交換放到面試場景如果你能把這個對照表講清楚同時順帶說明“拷貝賦值值傳遞之所以不遞歸是因為它依賴的是合法拷貝構(gòu)造”基本就證明你理解了對象生命周期和函數(shù)調(diào)用的真實鏈路。6. 實戰(zhàn)中的旁證與踩坑從“拒絕編譯”到“默認構(gòu)造被吞掉”理論知識說完了最后分享幾個實戰(zhàn)層面的直觀印證。這些東西回頭看都是在理解了“拷貝構(gòu)造必須 const 引用”之后才真正想通的。6.1 誤寫值傳遞時編譯器究竟在吼什么假設(shè)你真的在代碼里寫了Foo(Foo other)不同編譯器給出的診斷信息各有風(fēng)味。GCC 大概會說“invalid constructor; you probably meant Foo (const Foo)”。MSVC 則說“copying variable of type Foo invokes deleted copy constructor”。不管措辭怎么變本質(zhì)都是編譯器在明確地把這個構(gòu)造聲明排除在拷貝構(gòu)造之外。更重要的是一旦這個構(gòu)造函數(shù)被排除原本依賴拷貝構(gòu)造的很多能力也會瞬間消失Foo b a;會編譯失敗函數(shù)按值返回Foo會失敗放進std::vector或std::pair會失敗。你能從錯誤列表里感受到一種“連鎖崩塌”——因為整個值語義體系都建立在拷貝構(gòu)造這個地基上地基一歪全部上層建筑跟著歪。6.2 使用std::move時的教科書級誤判另一個有趣的旁證來自移動語義時代。有人會覺得有了移動構(gòu)造函數(shù)之后我把一個對象std::move進新對象走的是移動構(gòu)造而非拷貝構(gòu)造哪還需要糾結(jié)拷貝構(gòu)造但注意如果沒有顯式聲明移動構(gòu)造編譯器在遇到右值實參時仍然會去匹配const T作為替代方案——只是這個替代方按拷貝處理。如果你的類設(shè)計里拷貝構(gòu)造被誤刪或者誤寫成非法形式那些看似與拷貝無關(guān)的右值初始化也會一起失敗。更進一步C17 的拷貝省略copy elision并不能拯救所有拷貝場景。返回值優(yōu)化可以把“返回臨時對象”省略掉但當(dāng)對象真正需要被復(fù)制時例如從容器里取出元素并復(fù)制一份拷貝構(gòu)造仍然是必經(jīng)之路。把希望寄托在編譯器的省略優(yōu)化上不如把一個合規(guī)拷貝構(gòu)造函數(shù)擺正。6.3 我個人的一條工程箴言多年來我形成了一條習(xí)慣定義任何資源管理類時先寫T(const T)和T(T)兩個構(gòu)造聲明再回頭寫其他重載。這條習(xí)慣幫我避開了大量因為“構(gòu)造函數(shù)重載列表亂糟糟”而產(chǎn)生的編譯謎題。當(dāng)你核對過一個類的特構(gòu)造函數(shù)族之后你很快會發(fā)現(xiàn)const T幾乎是所有正確設(shè)計的軸心它能被左值綁定、能被右值綁定、能接收 const 對象、能接收非 const 對象并且絕不參與任何復(fù)制流程堪稱一切初始化操作的兼容底座。如果還有人問起“為什么不能是指針”我的回答永遠是傳指針意味著傳遞的是一個地址而地址所指向的對象可能不存在、可能臨時、可能被 const 限定。拷貝的語義要求一個穩(wěn)定且完整的源對象引用恰好把“對象本身”呈現(xiàn)在函數(shù)內(nèi)部而不必為對象再支付一次“產(chǎn)生新對象”的代價。這條原理從 C 誕生至今沒有變過未來也不太可能變。把這個問題想透了你對整個值語義、對象生命周期和編譯器重載決議的把握都會上一個臺階。