
const這個關鍵字初學C的人都覺得太簡單了——不就表示“不能改”嘛??烧娴巾椖坷飳懘aconst的坑一點不比模板少。我見過有人把const int*和int* const搞混導致編譯不過見過const_cast用順手之后把一個只讀字符串寫崩掉也見過面試時被問到頂層const和底層const直接卡殼的。這些我都經歷過。這篇東西想把C里const的用法從頭到尾捋一遍從最簡單的const變量到const指針、const引用、const成員函數、const_cast再到constexpr和面試真題全部過一遍。不管你是剛學C想弄明白還是工作幾年想系統補一下看完應該都能收獲點東西。1. 先搞明白const到底是鎖住什么1.1 編譯期的“契約”不是運行時的“鎖”很多人把const理解成“運行時不允許修改”這個理解其實不太準確。const真正起作用的地方是編譯期——編譯器在靜態檢查時發現你對一個const對象做了賦值或修改操作直接報錯根本不會讓你生成可執行文件。它本質上是程序員和編譯器之間的一份契約我承諾這個對象不會被修改你幫我在編譯時盯著點。這也帶來兩個很實際的結果。第一const不會讓程序變慢因為它不會生成任何額外的運行時檢查代碼純粹是編譯期的約束。第二const對象在內存里的位置不一定受保護它可能就是一個普通的棧變量。真正危險的是那些被放進只讀區域的const數據比如字符串字面量、某些const全局對象編譯器很可能會把它們放到只讀數據段。這時候如果你繞過const去寫那不是編譯錯誤的問題而是未定義行為常見的表現就是段錯誤或者在微軟VC的調試環境里彈出一句熟悉的提示write access to const memory has been detected, the output may be wrong!。我見過不少剛入行的同事一看到這類運行時錯誤就懵了我明明有const_cast啊我明明沒有越界啊其實根本不是越界是“寫了不該寫的只讀內存”。記住這句話const在運行時提供的唯一“保護”就是讓那些真正放在只讀區域的臟數據在你臉上炸開給你一個慘痛的教訓。1.2 頂層const與底層const所有指針困惑的根源面試里問const十個里有八個會繞到頂層const和底層const上。這個概念不搞明白后面看別人代碼只能靠猜。先說定義頂層consttop-level const表示指針本身是const也就是指針變量不能指向別的對象。寫法是int* const p。這里的const修飾的是“p”這個指針變量。底層constlow-level const表示指針指向的對象是const也就是不能通過這個指針修改目標對象。寫法是const int* p或int const* p。這里的const修飾的是“int”。判斷技巧很簡單從變量名開始往右讀const在變量名右邊多半是頂層constconst在類型名那一邊多半是底層const。我自己的習慣是記住一個口訣const鎖的是離它最近的那個名詞。int* const p里const左邊是int*右邊是p它鎖的是p本身const int* p里const左邊是空白、右邊是int它鎖的是int。為什么要單獨拎出來講因為拷貝的時候頂層const和底層const的約束完全不同。頂層const拷貝不受影響我復制一個指針變量本身源對象是不是const無所謂但底層const的拷貝有嚴格限制——不能把一個const int*賦給一個int*否則你就能通過后者輕易修改一個本來聲明為const的對象等于單方面撕毀契約。反過來int*賦給const int*是允許的這是權限的收窄編譯器放心。2. 逐層拆解const在不同場景下的實戰用法2.1 const修飾變量常量怎么用才不“白用”最基礎的用法是const int maxLen 1024;。在C里如果初始化是編譯期就能確定的常量表達式這個const變量本身也是一個編譯期常量可以用來定義數組大小、作為模板參數、作為case標簽等很多場景。但注意這不代表所有const變量都是編譯期常量比如const int n getUserInput();這就是個運行時才確定的只讀變量不能當數組長度用。想區分這兩類得靠constexpr這個后面細講。一個容易忽略的點是const變量的鏈接性。C里普通非const變量默認是外部鏈接而const變量默認是內部鏈接也就是每個翻譯單元都有一份自己的拷貝。如果你在頭文件里定義了一個const變量每個包含這個頭文件的.cpp都會各自擁有一份這在多數場景下沒問題甚至可以利用這一點省掉一些不必要的跨翻譯單元訪問。但如果你想在多個源文件間共享同一個const對象必須在定義時加extern并且在一個.cpp里定義、在頭文件里用extern const聲明。這也是面試里比較喜歡掛的一個小考點。還有一個從C語言轉過來的誤區別用const變量去“偽裝”宏定義。const是正兒八經的變量有類型、有作用域、可以取地址。宏只是文本替換。能用const的地方優先用const這不是風格問題而是類型安全的問題。2.2 const與指針從右往左讀永遠不慌先做一組速記對比這幾行代碼是面試題重災區寫法指針是否可變指針指向的值是否可變const int* p可改可以指向其他int不可改int const* p可改不可改和上一行等價int* const p不可改可改const int* const p不可改不可改我在實際項目里最常遇到的是const char*和char* const的混用。比如某個函數簽名是void parse(const char* str);它表達的意思是傳入的字符串內容我不會修改。但如果你寫成void parse(char* const str);語義就變成了“指針本身不能變但我可能在函數里改這個字符串的內容”這兩個意思天差地別。特別注意字符串字面量。在C里字符串字面量比如hello的類型是const char[6]所以用const char* p hello;才是合法的。早期C為了兼容C允許char* p hello;這種寫法但一旦你通過p去修改字符串內容就是寫入只讀內存的未定義行為。我曾經在一個舊項目的兼容層里見過一堆char*直接指向字符串字面量的代碼平時不寫不出事某個路徑下代碼偷懶復用了緩沖區去覆蓋“字符串”內容結果整個內存被寫壞排查了很久才發現是這種隱式轉換埋的雷?,F在C11之后這種寫法已經被明確廢棄/禁止新代碼里再看到就可以直接改了。2.3 const引用傳參界的“性價比之王”引用本身在某種程度上有點像自動解引用的指針而const引用則是傳參時的首選。有個非常經典的規則如果函數不需要修改傳入的參數優先寫成const T。為什么不是按值傳T因為大對象拷貝成本高。為什么不是非const引用T因為如果函數內部不修改參數把傳入對象的修改權限開放出去反而限制了調用方——人家一個const對象根本傳不進來。用const T就能同時兼容const和非const實參還能避免拷貝這是C里少有的“既要又要”的解決方案。const T還有一個隱藏能力綁定臨時對象。比如const std::string s abc;是合法的因為編譯器會構造一個臨時string并延長它的生命周期到引用離開作用域為止。反過來std::string s abc;就是編譯錯誤因為非const左值引用不能綁定右值。這個細節在寫函數重載和轉發時特別容易踩坑比如你把參數從std::string s改成std::string s想省一次拷貝結果發現所有傳字面量的調用全部編譯失敗原因就在這。2.4 const修飾函數返回值別給自己找麻煩返回值加const的情況比參數少得多但有必要說清楚。返回一個const值比如const std::string get();在C11以后基本是負優化——它阻止了移動語義還會讓一些調用方的代碼編譯不過。我有段時間喜歡給返回值加const覺得安全后來發現唯一的效果就是讓寫auto s obj.get();的人都來問我為什么代碼報錯。返回值加const真正有意義的場景是返回引用或指針的時候比如const T operator[]、const T* get()這時候你是在聲明“返回的是內部數據的只讀視圖”防止調用方通過返回值修改對象內部狀態。3. const在類與對象中的進階玩法3.1 const成員函數到底改了什么類里面void print() const;這種寫法很多初學者只背結論const成員函數里不能修改成員變量。但真要理解得看它的本質。所謂const成員函數實際上是給this指針加了底層const約束也就是說this的類型從Foo*變成了const Foo*。所以不是“函數不能修改成員變量”而是“這個函數拿到的this指向一個const對象你不能通過它去修改對象”。這帶來一個連鎖約束const成員函數里只能調用其他const成員函數。道理很簡單你的this是const Foo*你沒法把它轉成Foo*去調用一個非const成員函數。反過來非const成員函數可以隨便調用const成員函數因為權限收窄是允許的。構造函數和析構函數不能是const這一點也經常被問到。構造函數期間對象還沒構造完const語義還沒建立析構函數更是要修改對象的內部狀態來釋放資源把它標成const毫無意義。3.2 mutable打破const限制的唯一正規通道如果在const成員函數里確實需要修改某個成員變量怎么辦標準答案是mutable。最常見的例子是安全計數器、統計字段、緩存這些“邏輯上不影響對象狀態”的數據class Service { public: int getData() const { callCount_; // mutable允許在const成員函數里改 return data_; } private: int data_{0}; mutable long callCount_{0}; };callCount_被const成員函數修改了但沒有改變對象的“邏輯狀態”所以這種用法是被認可的。另一個極其經典的場景是線程同步一個線程安全的類如果get()方法是const的但里面需要加鎖那互斥鎖就必須聲明為mutable否則根本沒法治。這在后面第5部分多線程場景里會再展開。我用過的最陰間的mutable用法是懶加載。有的老代碼為了在const接口里實現“首次訪問時初始化緩存”把緩存指針聲明為mutable在里面做new。這種代碼可以用但一定要保證線程安全否則并發環境下兩個線程同時觸發懶加載直接雙free我為此排查過一個線上崩潰教訓很深刻。3.3 const重載同一個函數名兩種語義const成員函數可以和非const成員函數構成重載編譯器根據調用對象是否為const來選擇。最典型的例子是容器的operator[]T operator[](size_t idx); const T operator[](size_t idx) const;對一個非const容器調用operator[]你拿到的可寫引用可以修改元素對一個const容器調用你只能拿到const引用不能修改。這很符合直覺const容器就不應該允許你改里面的東西。如果你只提供非const版本const對象沒法調用如果你只提供const版本非const對象也能調用但拿到的引用就永遠是const同樣很麻煩。在寫自定義類時養成習慣如果接口語義是只讀的就給const版本如果還要返回可寫引用就把兩個版本都寫全。不要偷懶只想寫一個。我踩過的一個坑是類內部有個緩存寫了const T getCache() const但沒有非const版本結果外部需要在某個深層邏輯里修改緩存內容被迫去const_cast繞了一圈最后全刪了重寫成兩個重載干凈利落。3.4 const與static結合時要注意什么static成員函數沒有this指針所以不能聲明為const。這一點面試里偶爾會出判斷題原因就是const成員函數本質是約束this的沒有this自然就談不上const。static成員變量有沒有const有通常用來定義類的編譯期常量比如static const int kMax 100;。注意如果這個static const變量被取地址或者ODR-used需要在外部的.cpp里再定義一次C17里可以用inline static const省掉這個麻煩。我在C14時代寫庫代碼被這個“undefined reference”折磨過一次之后全是inline static。4. 高頻考點const_cast、const與define、constexpr4.1 const_cast能用但大部分時候不該用const_cast是C里專門用來去掉const或volatile限定的強制轉換。為什么需要它一個典型場景是歷史遺留代碼里有很多C接口函數簽名是char*但你的數據實際上是const的而且背后是個可寫的緩沖區只是接口傳進來時給標成了const。這種你知根知底的情況下用const_cast是合理的。真正危險的是“對象本身是const”的情況。比如const int a 100; const_castint(a) 200; // 未定義行為a本身聲明為const編譯器完全可以把它放到只讀存儲里你強轉完去寫輕則寫入被忽略重則直接崩潰。我記得有次調試一個崩潰堆棧最后定位到就是有人對一個全局const配置項用了const_cast去修改“配置”編譯器把那個對象放到了只讀段一寫就段錯誤。這已經不是用錯工具的問題而是設計上根本沒有資格修改它。另一個絕對禁區是字符串字面量。前面說了字符串字面量的類型是const char[N]你就算用const_cast去掉const再寫同樣是改寫只讀內存運行時崩潰沒商量。給個判斷標準如果原始對象真的是const別用const_cast如果原始對象不是const只是你手里只有const引用或指針可以謹慎使用但要先問問自己為什么不一開始就用非const接口。項目中大部分const_cast都屬于設計異味能避免盡量避免。4.2 const和#define到底差在哪這個問題面試頻率極高。兩者表面上看都能定義“常量”實際差別非常大對比維度const#define處理階段編譯期預處理期文本替換類型檢查有完整類型編譯器會檢查無類型替換成字面值再來檢查作用域遵循C作用域規則從定義處到文件結束或undef調試是變量調試器可查看預處理后就沒了看不到內存const變量有實體可取地址不分配實體純字面替換能力只能定義常量可以定義函數宏、拼接等我最想強調的一點是作用域。define只要一不小心沒有undef就會污染后面所有的代碼和頭文件。我之前在維護一個老項目時有個頭文件里define了一個名字很通用的宏MAX導致后面包含它的所有文件里但凡有個成員函數想叫getMax()都會被預處理器活生生替換成get100()之類的災難場面。排查這種問題極其痛苦因為你看到的代碼和編譯器看到的代碼根本不一樣。而const變量老老實實遵守作用域完全不會有這種問題。C實踐里對“常量”的優先級是能用constexpr用constexpr能用const用const實在需要宏再考慮define。函數宏在現代C里多數能被inline函數和模板替代別再用宏寫“函數”了。4.3 constexpr從“只讀”到“編譯期算完”constexpr是C11引入的關鍵字目的很明確讓某些值和函數可以在編譯期就求出來。它和const的最大區別是const表達的是“運行時只讀”constexpr表達的是“編譯期常量”。一個const變量可能在運行時才知道值但一個constexpr變量初始化必須能用常量表達式完成而且它本身就隱含const語義。看個例子int a 5; const int b a; // 合法b運行時才確定 constexpr int c a; // 錯誤a不是常量表達式 constexpr int d 5 3; // 合法編譯期就算完了constexpr函數更有意思C14之后放寬了很多可以在里面寫循環、分支。它的規則是用常量實參調用時編譯期求值用運行期實參調用時退化成普通函數。這種“兩棲”能力讓它特別適合做一些配置表、工具計算又不犧牲普通運行時的靈活性。我在項目里最常用的組合是constexpr加上字符串解析、單位換算之類的純計算邏輯。比如一個把角度轉弧度的函數因為所有參數編譯期都知道直接被編譯器算成常量運行時零開銷。面試問到constexpr一般會順帶問“constexpr和const能不能同時出現”答案是能但constexpr已經隱含const變量上重復寫沒問題但有點冗余。5. 典型代碼場景與面試真題解析5.1 catch (const std::exception e) 為什么一定要const引用異常捕獲里最標準的寫法是catch (const std::exception e)這個const背后有好幾層意思。第一你應該把異常對象當成只讀信息它用來描述錯誤不是用來改的。第二引用可以避免對象切片——如果你寫成catch (std::exception e)當實際拋出的是std::runtime_error時派生部分會被切掉丟失重要的錯誤信息而用引用捕獲多態就能正常發揮作用。第三加const能讓接口語義更清晰編譯器幫你保證你不會無意中去修改異常對象。很多人寫catch (std::exception e)也能工作但既然不打算改用const才是最佳實踐。同樣道理函數簽名里const std::exception這種寫法在異常相關的接口中也非常常見。我排查線上問題的時候經常要靠e.what()的字符串定位錯誤源如果曾經因為切片丟失了what()的信息調試成本直接翻倍。5.2 回調函數和接口設計const void* 與const std::function回調函數里const最常見的體現是const void* data這種C風格參數。為什么要加const因為回調是別人把數據交給你處理如果你不是要修改數據就應該聲明為const這樣調用方可以放心地把const數據傳進去也避免你誤改。比如一個音頻采集回調、一個數據包解析回調絕大多數場景都是只讀標記const是對調用方的承諾。在C現代代碼里我更喜歡用std::function比如void onData(const std::functionvoid(const char* data, size_t len) callback);這里const引用包住std::function避免拷貝內部回調參數也是const指針表示只讀。設計接口的人如果不注意const就會出現調用方手里是const字符串卻傳不進回調的尷尬局面。所以寫庫的人有一點很關鍵不修改的參數盡量全部加const寧可多寫幾行也別讓使用方到處const_cast。5.3 多線程里的const陷阱const不代表原子也不代表線程安全const成員函數在多線程環境中有一個極易踩的坑你以為const方法不修改對象所以并發調用是安全的其實不一定。看這個例子class Counter { public: int get() const { return count_; } void inc() { count_; } private: int count_{0}; };get()是const的但如果一個線程在inc()另一個線程在get()這里依然有數據競爭。const只代表“不允許通過這個接口修改對象”不代表“對象不會被其他人修改”。反過來const成員函數內部如果只讀取成員變量在別處不修改的情況下并發讀是安全的。真正要做到線程安全得靠同步原語。于是mutex和mutable的經典組合就來了class ThreadSafeCounter { public: int get() const { std::lock_guardstd::mutex lock(mtx_); return count_; } private: mutable std::mutex mtx_; int count_{0}; };get()要加鎖必須能“修改”mtx_所以mtx_必須mutable。很多第一次看到mutable的人就是在這種線程安全的類里相遇的。一次我在審查代碼時看到有人直接在const成員函數里std::lock_guardstd::mutex lock(mtx_);編譯不過然后他把整個函數去掉const了導致所有const調用點全部編譯失敗。其實只需要給mtx_加mutable幾秒鐘的事。這個組合要當成固定套路記下來。5.4 面試高頻const真題速查面試里const相關問題翻來覆去就那幾個整理一個速查表問題核心答案要點const int* p和int* const p區別前者不能改指向的值后者不能改指針本身頂層const和底層const頂層管指針本身底層管指向對象拷貝權限只收不放const T 能綁定臨時對象嗎能且會延長臨時對象的生命周期const成員函數能修改成員變量嗎不能除非成員變量聲明為mutablestatic成員函數能是const嗎不能static函數沒有this指針const_cast什么時候安全原始對象本身非const只是接口傳成了const引用/指針時可用對象本身是const時絕對不能用const和define的區別預處理vs編譯期、有類型vs無類型、作用域、調試可見性constexpr和const的區別constexpr強調編譯期求值隱含const語義const容器能用operator[]嗎能用但返回的是const引用因為const版本operator[]返回const T為什么char* p hello;不行C中字符串字面量類型是const char[N]需要const char*來接收這些題如果都能不看資料答出來const的基礎算是扎實了。但面試只是門檻真正的考驗是項目里每一處接口簽名是否設計得合理。5.5 再補兩個實戰細節第一STL的const_iterator和iterator。const容器只能拿到const_iterator它指向的元素是只讀的。很多人會把const std::vectorint和std::vectorint::const_iterator搞混記住一點容器是const它給出來的迭代器就自動是const版本邏輯非常一致。第二const成員函數里如果想把某個容器成員返回出去返回const auto或者const T才能保持只讀語義。有人圖省事直接返回一個拷貝結果每次調用都多一次拷貝開銷有人直接返回了非const引用結果外部通過這個引用把內部數據改了等于給類的封裝開了個洞。6. 關于const我最想分享的經驗我在實際項目中養成了一個習慣寫任何接口前先問自己三個問題——這個參數我會在函數內修改嗎這個成員函數允許被const對象調用嗎這個返回值能不能暴露內部狀態每一問都會導向一個const相關的決定。這套思考流程救了我很多次也讓我給別人review代碼時能一眼看出接口設計是否合理。另外一個小技巧遇到復雜的指針const類型不要靠記憶硬扛直接按“從右往左讀變量聲明”來拆。const char* const* pp是個指針指向一個const char* const也就是p指向的元素是一個指向const字符的const指針。一層層剝絕對不會亂。最后再分享一個排查經驗如果程序莫名其妙報出寫只讀內存的錯誤先別急著崩潰看看代碼里有沒有出現const_cast特別是對字符串字面量和全局const對象的const_cast。把這行刪掉或者改用其他設計大概率事情就結束了。const這套機制說到底是為了幫我們寫出更可靠的代碼而不是為了讓我們在編譯期和運行時之間來回折騰。真正理解了它你會發現自己的C代碼從“能跑”到了“設計得清楚”那一步。