
C每天會收到無數(shù)句憑什么。憑什么局部變量不初始化就可能是垃圾值憑什么輸入一個字母程序就死循環(huán)憑什么一個const變量碰都不能碰這些問題的答案其實都藏在C的性格里。我一直覺得學C基礎核心最好的方式不是逐句背語法而是把它當成一個人來讀初始化是出生記錄輸入輸出是待人接物的方式const是承諾和底線。這篇博客就把初始化、輸入輸出、const這三件事放到一起講透適合剛學到變量章節(jié)、正被各種寫法繞暈的初學者也適合想用最短時間把基礎核心重新串一遍的讀者。我會盡量用大白話配合能直接跑起來的代碼把規(guī)則背后的為什么也帶上。我理解C的方式一直帶著點傳記視角語言有自己的歷史、脾氣和習慣。你越是順著它的性格寫代碼它越少給你找麻煩。1. 變量初始化C的出生記錄有哪幾種寫法1.1 先分清初始化和賦值兩件事很多教材把int a 0;說成給 a 賦初值這句話沒有大錯但會讓新手忽略一個關鍵事實初始化發(fā)生在變量誕生的那一刻賦值發(fā)生在變量已經存在之后。C對這兩種情況的規(guī)則完全不同尤其在類類型和資源管理里誤把初始化當賦值可能造成先構造一個空對象再整體拷貝覆蓋的多余開銷甚至改變語義。int a; // 對象已經存在但值不確定局部變量默認不置零 a 7; // 賦值發(fā)生在 a 誕生之后 int b 7; // 拷貝初始化定義時立刻有了確定值你可能會想反正結果都是 a 和 b 的值是 7計較這些干什么因為C是一門講究每一步都有明確含義的語言。初始化和賦值在性能上、在對象的生命周期上、在const變量能否使用上差別都非常大。比如一個const int total 100;可以在初始化時定死卻絕不能在賦值語句里修改。如果你頭腦里沒有初始化是出生賦值是成長這條分界線后面學類、學引用、學移動語義時很多報錯都會看不懂。1.2 花括號初始化給出生記錄加上審核員C里定義一個變量可以有好幾種姿勢int v1 3.14; // 拷貝初始化3.14 被截成 3一般只有警告 int v2(3.14); // 直接初始化同樣發(fā)生窄化 int v3{3.14}; // 列表初始化編譯錯誤不允許窄化同樣是3.14到int花括號版本直接報錯其余版本大多只給警告甚至靜默轉換。很多人覺得這是C矯情但我恰恰認為這是現(xiàn)代C最值得養(yǎng)成的習慣花括號初始化把丟失信息的隱式縮窄變成編譯期錯誤。int存不下3.14的小數(shù)部分編譯器替你把關不讓這個出生記錄造假。C11之后我更推薦你默認寫花括號int count{}; // 值初始化count 為 0 double ratio{0.5}; // 普通初始化 std::vectorint v{1, 2, 3}; // 容器初始化int count{}這種空花括號寫法的好處是你明確告訴編譯器給我一個該類型默認狀態(tài)的值內置類型得到0類類型調用默認構造函數(shù)。它比int count;安全得多比int count 0;更現(xiàn)代。等你在函數(shù)里定義一堆局部變量時這個習慣能幫你擋住一大半未初始化變量的坑。1.3 默認初始化局部變量不置零別賭它一定是垃圾值C里有一個非常容易翻車的規(guī)則局部變量不會自動獲得一個安全值。定義int uninit;之后它可能拿到棧上殘留的任意數(shù)據(jù)。讀取它屬于標準里明確的未定義行為我見過太多Debug正常、Release隨機崩潰的疑難bug最后定位到源頭就只是一行沒初始化的局部變量。#include iostream int main() { int counter; counter; // 未定義行為 std::cout counter \n; return 0; }這段代碼在不同編譯器、不同優(yōu)化等級下可能輸出0可能輸出某個隨機數(shù)也可能程序直接異常。它之所以隱蔽是因為偶爾看起來正常。全局變量和靜態(tài)變量會默認零初始化類成員變量有一整套初始化規(guī)則但函數(shù)里的內置類型局部變量就是不給你默認值。我的實操建議很簡單聲明的同時就初始化哪怕只是int i{}或int j{0}。不要抱著反正后面會賦值的心態(tài)中間一旦出現(xiàn)提前返回或者異常分支那個變量就會變成定時炸彈。如果你用的編譯器支持-Wmaybe-uninitialized記得打開。這個警告雖然偶爾誤報但抓到真兇的概率遠大于冤枉好人的概率。2. 輸入輸出和C打交道的兩種基本口吻2.1 cin和cout的基礎禮儀學習C第二個繞不開的主題就是iostream。它像一個信使負責在程序和用戶之間傳話std::cin負責讀取鍵盤輸入std::cout負責把結果打到屏幕上std::cerr和std::clog則用來輸出錯誤和日志。幾乎所有入門程序都要靠它們完成和世界的第一次對話。#include iostream #include string int main() { std::cout 請輸入你的姓名: ; std::string name; std::cin name; std::cout 你好, name \n; return 0; }這里有兩個很有意思的小點。第一std::cin name按空白字符分隔輸入所以輸入張三 李四時只會讀取張三想讀一整行得用std::getline(std::cin, name)。第二我習慣用\n而不是std::endl來換行。std::endl除了換行還會強制刷新輸出緩沖區(qū)密集輸出時性能差距很明顯默認情況用\n就好別把兩者當同一個東西。std::cout的使用還要注意鏈式寫法的順序問題。它本身是從左到右逐段插入的但對于同一個變量如果在一條鏈里既寫它又修改它行為可能受求值順序影響。新手階段不用過度擔心但心里要有個概念輸入輸出不止是打印它涉及緩沖區(qū)、格式化、狀態(tài)標志這些背后的機制。2.2 輸入不匹配和緩沖區(qū)殘留cin翻車現(xiàn)場新手最常見的求救消息是我輸入一個字母程序就死循環(huán)了。下面這段代碼是典型的翻車現(xiàn)場int age 0; std::cin age; std::cout 你的年齡是: age \n;如果用戶輸入abcstd::cin嘗試把abc解析成int失敗后會把age留在原值同時把輸入流置于失敗狀態(tài)。更麻煩的是失敗的輸入會殘留在緩沖區(qū)里如果不清除后續(xù)所有std::cin讀取都會直接失敗。正確姿勢是檢查失敗狀態(tài)清除標志再跳過本次殘留內容int age 0; std::cin age; if (std::cin.fail()) { std::cin.clear(); // 清除失敗狀態(tài)標志 std::cin.ignore(12345, \n); // 跳過當前行剩余內容 }std::cin.ignore(12345, \n)的意思是從緩沖區(qū)里最多丟棄12345個字符直到遇到換行符為止。這個數(shù)字寫大一點只是為了確保把殘留內容清干凈專業(yè)一點可以寫成std::numeric_limitsstd::streamsize::max()需要包含limits頭文件。另一個常見寫法是循環(huán)讀取直到成功int age 0; while (!(std::cin age)) { std::cin.clear(); std::cin.ignore(12345, \n); std::cout 輸入無效請重新輸入一個整數(shù): ; }這段代碼的原理是讀取成功時std::cin age的結果可以隱式轉換為布爾值true讀取失敗時返回false于是進入循環(huán)處理錯誤。我建議你把這個模式背下來后面寫任何帶交互輸入的工具都會用到。2.3 輸出格式化讓數(shù)字按你想要的樣子說話std::cout直接輸出浮點數(shù)時默認只顯示幾位有效數(shù)字小數(shù)點后的位數(shù)可能是3.14159也可能是3。要精確控制輸出格式需要用到iomanip里的操作符。下面這張表是使用頻率最高的幾個控制符/操作作用典型用法std::setw(n)設置下一個輸出的最小寬度cout setw(10) xstd::setfill(c)設置填充字符cout setfill(0) setw(5) xstd::fixed使用固定小數(shù)計數(shù)法cout fixed setprecision(2) xstd::setprecision(n)設置精度有效數(shù)字或小數(shù)位cout setprecision(3) xstd::left/std::right設置對齊方向cout left setw(8) x實際組合起來大概長這樣#include iostream #include iomanip int main() { std::cout std::fixed std::setprecision(2); std::cout 3.14159 \n; // 輸出 3.14 std::cout std::setw(8) std::right 42 \n; // 輸出 42 return 0; }我自己的經驗是std::fixed和std::setprecision(2)是處理金額、面積這類精確兩位小數(shù)場景的黃金組合。注意std::fixed一旦設置會持續(xù)影響后續(xù)所有浮點輸出直到你切換成別的格式。做出一個好看的輸出表格不算核心邏輯但絕對是讓程序看起來專業(yè)的最快方式。3. const限定符定了就不許改這是代碼的信用體系3.1 const讀法核心是綁定不是值不能變const是C基礎核心里最容易被誤解的關鍵字。很多新手把它理解成把一個值的屬性改成只讀但更準確的說法是const建立了一個名字與可變權限之間的綁定。當你寫下const int N 100;你承諾不通過N這個名字修改底層存儲。底層存儲本身是否可改取決于對象本身是否為const。const int N 100; // N 200; // 編譯錯誤這個設計讓const成為代碼的信用體系看到const參數(shù)調用者就知道這個函數(shù)不會偷改我的數(shù)據(jù)看到const變量后續(xù)維護者就知道這個值是一個不能動的既定約定。對比C語言時代的#define N 100const有明確的類型、作用域在調試器里也看得見不會在預處理階段無腦替換污染全局這是C推薦用const替代宏的重要原因之一。3.2 指針和const從右往左讀一讀就懂指針和const組合起來是新手重災區(qū)。比如const int* p和int* const p有什么區(qū)別死記硬背效果很差我教你一個讀法技巧從右往左讀聲明。const int *pp 是指向const int的指針。不能通過*p修改它指向的整型值但 p 本身可以指向別處。int *const pp 是const指針。意味著 p 這個指針變量不能再指向別處但可以通過*p修改它指向的值。const int *const p兩者都不能改。int x 1, y 2; const int* p x; // *p 10; // 錯誤不能通過 p 修改 x p y; // 正確p 可以換目標 int* const q x; *q 10; // 正確x 被改成 10 // q y; // 錯誤q 不能再換目標還有一個高頻誤解const int* p指向的對象本身一定是const嗎不是。我可以先定義普通int x, 再讓const int* p x;這完全合法只表示通過 p 這條路徑不能寫x 本身仍然可以被x 5;修改。反過來真正 const 對象也不能隨便交給普通指針去修改那會直接破壞const承諾。const引用的規(guī)則也一并說清楚const int r 42;是合法的因為const引用可以綁定到臨時對象并延長其生命周期如果去掉const直接int r 42;會編譯失敗。所以函數(shù)形參里只要不需要修改外部數(shù)據(jù)強烈建議寫成const 引用既能避免拷貝開銷又能表達我只讀不改的意圖。3.3 constexpr比const更硬的編譯期常量const表示運行期不可變但它的值未必在編譯期就能確定。比如const int x getRuntimeValue(); // 合法x 運行期定值之后不可改 constexpr int y 2 3; // 必須編譯期能算出 5C11引入了constexpr它要求編譯器在編譯階段就能求出值適合用來定義數(shù)組長度、模板參數(shù)這類必須編譯期確定的量。現(xiàn)代C里我優(yōu)先推薦凡是明確要當編譯期常量使用的就直接用constexpr。C14放寬了constexpr函數(shù)的限制C17和C20又進一步加強現(xiàn)在constexpr函數(shù)里寫循環(huán)、分支都很正常了。constexpr int kBufSize 4096; char buffer[kBufSize]; // 合法kBufSize 是編譯期常量如果這里寫成const int kBufSize getInput();char buffer[kBufSize]在標準C里就不合法。學會constexpr還有一個附帶好處你能更清晰地判斷代碼里哪些計算可以提前到編譯期這對性能敏感的程序非常重要。3.4 給初學者的const使用準則寧多勿少、意圖明確關于const我在實際代碼里給自己定了三條準則分享給你函數(shù)參數(shù)只讀不改時傳引用優(yōu)先加const。比如void printName(const std::string name);。無const的按值傳遞雖然安全但多一次拷貝無const的引用傳遞雖然省拷貝但調用者會擔心你是否會偷偷修改。明明不會變的局部值就用const聲明。const double width readInput(...);這種寫法相當于給后續(xù)維護者留了張便條這個值算出來之后就不要動了。不要用const_cast去修改真正的const對象。那是未定義行為。const_cast只該用于接口設計有問題、但確認底層對象本非const的極少數(shù)場景新手階段請把它當禁詞。4. 把它們串起來寫一個帶輸入校驗的常量化計算Demo4.1 這次要解決什么問題前兩章的知識點如果只是分開看感受不深。我建議你馬上把它們拼成一個完整的小工具。這里做一個矩形面積計算器支持反復讀取寬度和高度遇到非數(shù)字輸入自動重試最多重試3次輸出結果保留兩位小數(shù)。這個Demo不大但覆蓋了初始化、輸入狀態(tài)處理、const/constexpr聲明、格式化輸出全套操作比單看語法規(guī)則有用得多。設計時我特意做了兩個決策。第一個是把最大重試次數(shù)定義成constexpr int kMaxRetry 3;因為它是編譯期常量后續(xù)想改成5次只需改一行。第二個是把讀入后不再修改的寬度、高度、面積都加const一旦我誤寫area 0;編譯器會立刻報錯而不是讓Bug在運行期悄悄溜過去。4.2 完整參考代碼#include iostream #include iomanip #include limits constexpr int kMaxRetry 3; double readPositiveDouble(const char* prompt) { double value 0.0; for (int attempt 1; attempt kMaxRetry; attempt) { std::cout prompt; std::cin value; if (std::cin.fail()) { std::cin.clear(); std::cin.ignore(std::numeric_limitsstd::streamsize::max(), \n); std::cout 輸入無效請重新輸入一個數(shù)字。\n; continue; } if (value 0.0) { return value; } std::cout 數(shù)值必須大于 0。\n; } return -1.0; // 表示重試次數(shù)耗盡 } int main() { const double width readPositiveDouble(請輸入寬度: ); if (width 0.0) { std::cout 重試次數(shù)過多程序退出。\n; return 1; } const double height readPositiveDouble(請輸入高度: ); if (height 0.0) { std::cout 重試次數(shù)過多程序退出。\n; return 1; } const double area width * height; std::cout std::fixed std::setprecision(2); std::cout 矩形面積: area \n; return 0; }這段代碼的核心是readPositiveDouble函數(shù)。它用一個for循環(huán)控制重試次數(shù)失敗或非法輸入就繼續(xù)循環(huán)成功且大于0就返回。std::cin.clear()和std::cin.ignore()必須成對出現(xiàn)前者恢復流狀態(tài)后者清掉殘留字符缺一個就可能陷入讀一次失敗一次的死循環(huán)。你可以把kMaxRetry改成1試試輸入兩次非法數(shù)據(jù)后程序會立刻走return 1分支邏輯非常直觀。4.3 編譯運行與常見報錯我用下面這條命令編譯并運行g -stdc17 -Wall -Wextra -pedantic rectangle_area.cpp -o rectangle_area-Wall -Wextra -pedantic三個選項是學習階段的護身符。運行效果大概這樣請輸入寬度: abc 輸入無效請重新輸入一個數(shù)字。 請輸入寬度: 10.5 請輸入高度: -3 數(shù)值必須大于 0。 請輸入高度: 7 矩形面積: 73.50我特意列出幾個初學者最容易遇到的報錯你可以對照檢查癥狀原因解決方向編譯時報limits file not found忘記包含limits加上#include limitserror: assignment of read-only variable area試圖修改const變量檢查代碼邏輯去掉賦值或改非const輸入字母后程序死循環(huán)失敗狀態(tài)沒有清除std::cin.clear()后std::cin.ignore(...)輸出小數(shù)位數(shù)不符合預期沒設置std::fixed或std::setprecision加上iomanip相關控制符這個地方我想多說一句編譯器報錯信息的第一行往往已經告訴你答案。很多新人見到英文報錯就慌直接復制到搜索引擎其實先自己讀一遍至少能看懂read-only variablefile not found這類關鍵短語。報錯不是審判書是調試線索。4.4 寫完這個Demo后你可以做的三個改動練習這個Demo跑通后別急著關掉。我建議你做三個小實驗來加深理解把kMaxRetry從constexpr改成const int編譯看看是否報錯在標準C里循環(huán)上限用運行期變量也能正常工作但你會感受到constexpr在編譯期定值這件事上的特殊地位。把readPositiveDouble里的const char* prompt參數(shù)改成按值傳std::string。你會發(fā)現(xiàn)調用處寫法變了還會多一次字符串拷貝這正是const引用和字符串對象之間微妙的取舍。把width的const去掉在main末尾加一行width width 1;再故意把height也改掉看編譯器給你哪些錯誤。記住這些報錯的英文表達以后一眼就能識別是不是有人動了const變量。5. 我的三條學習建議順序、編譯器和調試習慣5.1 把三個主題串成一條主線而不是背三個碎片初始化、輸入輸出、const這三者看上去是獨立知識點但其實可以串成一條完整的敘事線變量先被正確地帶到這個世界上初始化然后與世界交換信息輸入輸出最后用const守住不可動搖的邊界規(guī)則。我見過不少初學者把這三個主題當成三個孤島學初始化時拼命記寫法學輸入輸出時只知道cin和cout學const時背指針組合結果一到寫完整程序就不知道哪個環(huán)節(jié)出了問題。按上述主線理解之后你會發(fā)現(xiàn)很多問題可以互相解釋為什么輸入失敗要重新初始化變量因為流狀態(tài)和變量狀態(tài)都要恢復到可靠值。為什么面積這種“算出來就不該改”的值要用const因為一旦后續(xù)想通過賦值偷改就破壞了程序的規(guī)則一致性。學到這里下一站可以按順序去碰函數(shù)重載、引用生命周期、類構造函數(shù)初始化列表。構造函數(shù)初始化列表其實就是初始化概念在類層面的延伸類里大量const成員變量也只能通過初始化列表來設初值這就是為什么基礎階段的初始化習慣如此重要。5.2 固定現(xiàn)代編譯器并把警告當成朋友學習C時我不建議長期依賴瀏覽器在線運行工具。那些工具對單文件小示例很方便但你得不到本地編譯器、調試器、靜態(tài)分析的完整支持。請裝一個現(xiàn)代編譯器用標準C17甚至C20來編譯g -stdc17 -Wall -Wextra -pedantic your_file.cpp -o your_program出現(xiàn)警告時盡早處理而不是反正能運行就算成功。警告是編譯器在免費給你做代碼審查。我曾經在調試一個模擬項目X的排查過程里遇到詭異隨機結果加-fsanitizeaddress,undefined重新編譯后不到一分鐘就定位到一行為初始化的數(shù)組索引變量。相比肉眼讀代碼編譯器工具的定位效率高得多。運行時內存問題還可以用valgrind檢查它雖然慢但能抓到越界訪問、使用未初始化內存等一批經典問題。作為新手養(yǎng)成編譯必開警告、排查先跑清洗工具的習慣比多看十篇博客都有用。5.3 三個常見的愚蠢錯誤我都替你踩過了最后分享三個我親眼見過很多次的學習期錯誤。第一個是未初始化變量Debug模式下棧內存可能碰巧是0一旦切到Release同一段代碼表現(xiàn)完全不同。不要賭不要偷懶聲明即初始化。第二個是輸入失敗后不清理狀態(tài)用std::cin 讀取失敗必須clear并ignore否則后續(xù)所有讀取都會立刻失敗。這個坑最隱蔽的地方在于它不會直接崩潰而是讓你的程序看似卡住新手常常誤以為是死循環(huán)實際上只是流被鎖死。第三個是把const引用參數(shù)當普通變量隨手改比如void format(const std::string s)內部想給s加個后綴直接修改會編譯報錯。正確做法是定義一個非const的局部副本或使用新變量。這個錯誤會逼你重新理解const是承諾承諾不改就得用別的變量來存儲臨時結果。最后一個小技巧如果你正在學習C建議從今天開始在代碼文件頂部寫一行注釋記錄你使用的編譯標準、編譯器命令和編譯選項??雌饋聿黄鹧鄣^上一個月回看自己寫過的代碼當你需要復現(xiàn)某個結果時這行字能幫你省下一堆當時是怎么編譯的的猜謎時間。C基礎核心確實瑣碎但把初始化、輸入輸出、const這一組知識點串起來以后你會慢慢感覺它不再是散落的語法條目而是一個有著清晰性格的伙伴在陪你寫每一行代碼。