存管理,夯實編程基本功)
1. 為什么我建議你學(xué)C語言哪怕你已經(jīng)會了好幾種語言說句實話每隔一段時間就會有人問我現(xiàn)在Python、Java這么香還有必要學(xué)C語言嗎。我的答案一直是有必要而且非常有必要。先說個我自己的經(jīng)歷。早些年我剛接觸編程一開始學(xué)的其實是某種腳本語言寫起來確實爽快——不用關(guān)心內(nèi)存、不用管類型代碼跑不起來就報個錯給你看。但等到真去做一些嵌入式項目、做網(wǎng)絡(luò)編程、搞性能優(yōu)化的時候我發(fā)現(xiàn)自己完全抓瞎。代碼莫名其妙崩了不知道怎么回事數(shù)據(jù)量一大程序就慢得離譜內(nèi)存占用一路飆升也不知道哪來的。那時候才意識到底層的東西必須得補而補底層的唯一捷徑就是老老實實啃C語言。這篇經(jīng)驗分享不是什么教科書式的C語言教程而是我這些年帶過不少新手、自己也從入門到真正上手之后總結(jié)出來的一套實操路徑。包含從哪里開始、學(xué)什么、練什么、踩過哪些坑、以及很多書上不會告訴你的細(xì)節(jié)。適合正在自學(xué)C語言、大學(xué)里剛上C語言課、或者壓根兒就是轉(zhuǎn)行想補基礎(chǔ)的朋友。C語言為什么值得學(xué)我可以用一句話概括它是所有現(xiàn)代編程語言的地基。Python解釋器是C寫的Java虛擬機的底層實現(xiàn)也離不開C/C操作系統(tǒng)的內(nèi)核更是C語言的天下。你把C語言學(xué)扎實了再回頭看其他語言很多高深的東西都會瞬間變得通透。2. 入門前的三個關(guān)鍵認(rèn)知搞清楚了再動手2.1 C語言一點都不難難的是思維方式的轉(zhuǎn)變很多新手學(xué)C語言覺得難第一個攔路虎就是指針。但我想說指針其實是C語言里最被妖魔化的概念之一。真正難的不是指針本身而是你要從只管用的思維轉(zhuǎn)變成我需要知道它底層是怎么運作的思維。比如變量在高級語言里你只需要聲明、賦值、使用但在C語言里你需要知道變量的本質(zhì)是內(nèi)存中的一段空間變量名只是這塊空間的門牌號。一個人更容易理解int a 10這句代碼背后發(fā)生的事情——在內(nèi)存中劃出4個字節(jié)的空間往里面寫入整數(shù)10給這塊空間起名字叫a——之后他學(xué)指針、學(xué)數(shù)組、學(xué)函數(shù)傳參都會非常順暢。反過來如果只知道a是個整數(shù)變量那到了指針這里就會一頭霧水。所以我的第一個建議就是不要怕慢前期把內(nèi)存模型揉碎了搞懂后面會節(jié)省你十倍的時間。具體來說要理解這樣幾個逐步遞進的層面變量與內(nèi)存地址的關(guān)系數(shù)組在內(nèi)存中的排列方式函數(shù)調(diào)用時棧空間是如何分配和回收的指針保存的是什么、解引用訪問的是什么2.2 確定學(xué)習(xí)路徑別一上來就啃大部頭現(xiàn)在市面上的C語言教材五花八門但說實話大部分新手失敗的原因不是教材不好而是學(xué)習(xí)方法不對。有一種非常典型的學(xué)習(xí)路徑是買一本幾百頁的經(jīng)典教材從頭到尾逐章閱讀。結(jié)果是什么前兩章看得津津有味第三章開始犯困等到了指針這一章徹底崩潰然后就沒有然后了。這種由厚到薄的線性閱讀方式對于完全沒有編程基礎(chǔ)的新手來說是非常不友好的。我推薦的學(xué)習(xí)方式恰恰相反是由薄到厚的項目驅(qū)動式學(xué)習(xí)法。先搞清楚C語言的基礎(chǔ)框架也就是變量、循環(huán)、條件、函數(shù)、數(shù)組這些大概兩周就能掌握。然后帶著目標(biāo)去學(xué)習(xí)指針、結(jié)構(gòu)體、文件操作等進階內(nèi)容。什么意思呢就是你要先有個我要做一個什么東西的想法然后為了完成它主動去學(xué)需要用到的知識點。比如你想寫一個學(xué)生成績管理系統(tǒng)那你就必須學(xué)會結(jié)構(gòu)體存數(shù)據(jù)、數(shù)組做排序、函數(shù)做模塊化這些需求會倒逼你去弄懂相關(guān)語法。這種方法的優(yōu)勢在于每一個知識點都有具體的應(yīng)用場景你不會覺得學(xué)了不知道干嘛用而且每完成一個小項目你會有真真切切的成就感。這種正向反饋對自學(xué)者來說比什么都重要。2.3 選對工具和平臺是成功的一半我見過太多新手剛學(xué)C語言先花了好幾天配置環(huán)境結(jié)果環(huán)境還沒配好熱情先沒了。其實開發(fā)工具的選擇沒有那么復(fù)雜核心就兩條編譯器加文本編輯器或者直接上一個順手的一體化開發(fā)環(huán)境。如果你是在自己的電腦上學(xué)習(xí)我建議別用什么太復(fù)雜的工具鏈。Windows平臺裝一個輕量級的編譯環(huán)境比如MinGW配置好gcc再用VS Code加幾個插件或者直接裝一個Code::Blocks這些都是零門檻的選擇。如果你在Linux系統(tǒng)上學(xué)習(xí)那更簡單了系統(tǒng)自帶gccvim或nano寫代碼命令行編譯運行從一開始就能體會純手工的感覺反而更能幫助你理解編譯鏈接的過程。這里我特別強調(diào)一下很多新手糾結(jié)XX開發(fā)工具是不是最好的這個問題的答案是——你能快速用起來不產(chǎn)生挫敗感的就是最適合你的。工具是輔助不是目的。如果花太多時間在工具選擇的糾結(jié)上反而是浪費時間。3. 核心語法與實踐要點抓住C語言的七寸3.1 數(shù)據(jù)類型和變量一切的基礎(chǔ)C語言的基本數(shù)據(jù)類型在入門階段要爛熟于心int、char、float、double以及它們對應(yīng)的unsigned版本和long版本。這里有一個非常容易踩的坑就是數(shù)據(jù)類型的取值范圍。不同的平臺和編譯器下int可能占用4個字節(jié)也可能占用2個字節(jié)long可能是4個字節(jié)也可能是8個字節(jié)。所以寫代碼的時候不要假設(shè)int一定能存下某個值該用long long就用long long該用無符號就用無符號。我自己在教新人的時候常讓他們做一個很有意思的小練習(xí)用sizeof運算符打印出每種數(shù)據(jù)類型在自己機器上占用的字節(jié)數(shù)。這個練習(xí)看著簡單但能讓你對當(dāng)前平臺的內(nèi)存模型有一個直觀認(rèn)識而且順便理解了為什么不同平臺下同一段代碼的行為可能不一樣。再來說變量。C語言要求所有變量先聲明后使用這是它的一個特點也是很多從Python轉(zhuǎn)過來的同學(xué)一開始不太適應(yīng)的點。但反過來看這也是C語言的優(yōu)點每一個變量的生命周期都是可控的你在什么地方聲明它、什么時候銷毀它心里很有數(shù)。這種掌控感在寫大型項目的時候非常有用。3.2 運算符和表達式別被優(yōu)先級坑了談到運算符C語言大概有四十多個運算符優(yōu)先級分十幾級。很多剛?cè)腴T的同學(xué)一看這個表格就頭大。我的建議是你不需要把所有優(yōu)先級都背下來你只需要記住兩個層級——括號永遠(yuǎn)優(yōu)先乘除加減遵循數(shù)學(xué)直覺其他的不確定就加括號。實際操作中最容易被坑的絕對不是優(yōu)先級順序本身而是賦值與比較的混淆。經(jīng)典的一個錯誤就是想判斷a是否等于b寫成了if (a b)。這在語法上是合法代碼但語義完全變了——它把b的值賦給了a然后判斷a是否為真。這種bug在早期特別常見而且很難排查因為編譯器不一定會警告你程序邏輯卻亂了。我的建議是養(yǎng)成常量放左邊的書寫習(xí)慣比如if (5 a)如果你寫成if (5 a)編譯器會直接報錯把可能的bug消滅在編譯階段。這是一個非常老派的技巧但真的能幫你省不少事。3.3 流程控制三條路走遍天下順序、分支、循環(huán)這就是C語言的三大流程控制結(jié)構(gòu)。if-else處理分支for和while處理循環(huán)這個沒什么玄學(xué)。但我注意到很多新手的代碼風(fēng)格在這里就容易出問題嵌套過深、邏輯混亂、一層套一層看得人眼冒金星。我的經(jīng)驗是任何超過三層以上的嵌套都說明你的邏輯設(shè)計出了問題。這時候應(yīng)該考慮如何拆分——把這個邏輯抽成一個函數(shù)或者用提前返回的技巧把特殊情況先處理掉讓主線邏輯清晰可見。代碼是給人讀的其次才是機器執(zhí)行。一個邏輯清晰的代碼不僅自己以后好維護別人review起來也舒服。還有一點細(xì)節(jié)很重要凡是分支語句哪怕if只有一句也建議加上花括號。為什么因為這是一個非常普遍的血淚教訓(xùn)——剛開始你沒加花括號后面需求變更需要加一行代碼結(jié)果忘了加花括號代碼邏輯瞬間就崩了。這種bug在真實項目中我見過太多次了。3.4 數(shù)組和字符串最容易被初始化坑到數(shù)組是C語言里非常重要的復(fù)合數(shù)據(jù)結(jié)構(gòu)所有同類型的數(shù)據(jù)集合都可以用數(shù)組來管理。入門階段要注意幾個要點。第一點是數(shù)組的下標(biāo)從0開始這一點所有初學(xué)者都會在這上面摔一次跟頭。越界訪問不會立即報錯這跟Python的列表不一樣。Python訪問越界會拋出異常而C語言的數(shù)組越界是未定義行為——程序可能正常跑、可能輸出垃圾值、可能直接崩潰全看運氣。這種不確定性是最可惡的因為你的bug可能只在特定輸入下才出現(xiàn)等你想復(fù)現(xiàn)的時候它又消失了。第二點是C語言中的字符串其實就是一個字符數(shù)組以\0結(jié)尾。這帶來一個嚴(yán)重的問題如果你聲明了一個長度為10的字符數(shù)組那么最多能存9個普通字符加一個結(jié)束符因為第10個位置要用來存\0。如果不小心多存了一個字符就溢出到相鄰內(nèi)存去了。當(dāng)然還有更省心的方式就是直接用標(biāo)準(zhǔn)庫的string.h函數(shù)但前提還是你要理解底層機制。對于數(shù)組初始化的習(xí)慣我建議不確定的內(nèi)容一律初始化。比如int arr[100] {0};或者用memset清零。這一步解決了很多由于未初始化變量造成的隨機性bug那種bug排查起來極其痛苦因為它不是必現(xiàn)的。3.5 函數(shù)與作用域模塊化的第一步C語言的函數(shù)是代碼組織的基本單位。我的建議是從你寫的第一行C語言開始就養(yǎng)成把功能拆分成函數(shù)的好習(xí)慣。不要什么事都塞在main函數(shù)里寫成一大坨否則以后學(xué)會了函數(shù)想重構(gòu)成本只會更高。初學(xué)者對函數(shù)最容易困惑的是傳值還是傳地址。C語言默認(rèn)是傳值調(diào)用也就是說函數(shù)拿到的參數(shù)是實參的一個拷貝在函數(shù)內(nèi)修改參數(shù)不會影響外部的變量。想讓函數(shù)修改外部的變量就需要傳指針。這里有個非常重要的實操點在很多真正的項目實踐中函數(shù)內(nèi)部避免直接修改全局變量是一種非常良好的編程習(xí)慣。全局變量雖然用著方便但會讓函數(shù)之間產(chǎn)生隱晦的依賴關(guān)系代碼一復(fù)雜起來你會完全搞不清楚某個變量的值是被誰修改的排查bug的難度直線上升。能用參數(shù)傳遞信息盡量用參數(shù)傳遞能用返回值傳遞結(jié)果就盡量用返回值。3.6 指針C語言的核心靈魂指針這個知識點說難確實難因為它是C語言區(qū)別于大多數(shù)其他語言的核心特征。但我想用一個很生活化的類比幫大家理解指針就像是一張寫著地址的紙條你可以根據(jù)紙條上的地址找到對應(yīng)的人家紙條本身并不是那個人家。具體到代碼中int *p a;表示p保存了變量a的地址*p就是對地址解引用訪問那個地址上的內(nèi)容。就這么簡單。再看一層指針也是一個變量它自己也有地址所以可以有指向指針的指針即int **pp p;。很多人在這里就混亂了但其實只要抓住指針變量存的是地址這個本質(zhì)一級指針、二級指針都只是套娃。指針最常見的應(yīng)用場景是動態(tài)內(nèi)存分配。malloc申請堆內(nèi)存free釋放內(nèi)存。這個過程中最容易出的問題有這么幾類申請了內(nèi)存但忘記釋放造成內(nèi)存泄漏。短小的程序可能沒什么感覺但在長時間運行的服務(wù)中內(nèi)存泄漏是致命的。釋放了內(nèi)存之后還去訪問它即懸垂指針。這是導(dǎo)致程序崩潰或行為詭異的最常見原因。重復(fù)釋放同一塊內(nèi)存同樣會造成崩潰。我的建議是誰分配誰釋放釋放后立即把指針置為NULL并養(yǎng)成這個肌肉記憶。3.7 結(jié)構(gòu)體與文件操作邁向?qū)嵱贸绦虻谋亟?jīng)之路結(jié)構(gòu)體struct是C語言實現(xiàn)自定義數(shù)據(jù)類型的方式。你想把學(xué)生的學(xué)號、姓名、成績打包成一個整體就需要結(jié)構(gòu)體。結(jié)構(gòu)體在實際項目中的使用非常普遍尤其是配合指針可以構(gòu)建鏈表、隊列、樹等各種數(shù)據(jù)結(jié)構(gòu)。文件操作是很多C語言教程放在最后講的章節(jié)但實際工作中用得非常頻繁。核心就四個操作fopen打開文件、fread/fscanf讀數(shù)據(jù)、fwrite/fprintf寫數(shù)據(jù)、fclose關(guān)閉文件。這里要特別強調(diào)三點第一打開文件后一定要檢查返回值是否為NULL因為文件可能不存在、可能沒有權(quán)限第二用完文件一定要fclose否則數(shù)據(jù)可能沒有完全寫入第三注意文件打開模式r表示讀、w表示寫會覆蓋原有內(nèi)容、a表示追加選錯了輕則出錯重則數(shù)據(jù)丟失。4. 實操過程一條跨過三道坎的完整練手路線4.1 第一階段過語法做小題目我建議第一階段的花費時間控制在兩到三周目標(biāo)是熟悉語法、適應(yīng)編譯運行流程。這個階段不需要刷很多題經(jīng)典的入門題做十幾道就夠了。我個人比較推薦的練習(xí)包括打印九九乘法表判斷一個數(shù)是否為素數(shù)求最大公約數(shù)和最小公倍數(shù)將一個十進制數(shù)轉(zhuǎn)為二進制用不同類型運算符實現(xiàn)一個簡易計算器這些題目看起來簡單但每一個都在訓(xùn)練基本的編碼能力。比如素數(shù)判斷你會學(xué)到循環(huán)的優(yōu)化寫法——只需循環(huán)到sqrt(n)而不是n/2十進制轉(zhuǎn)二進制你會學(xué)到取模和整除在數(shù)位拆解中的運用。這些基本功后面都用得上。寫代碼的過程中故意把錯誤類型積累下來語法錯誤、語義錯誤、運行時錯誤每種都親自遇幾次總結(jié)一下編譯器給的錯誤提示都長什么樣。這一步看似浪費時間但能極大提高你以后的排錯效率。4.2 第二階段啃指針做小系統(tǒng)有了基礎(chǔ)語法功底第二階段就可以正式開始做小系統(tǒng)了。我建議所有入門者做的一個經(jīng)典項目是學(xué)生成績管理系統(tǒng)。這個項目為什么經(jīng)典因為它幾乎用到了C語言入門的全部核心知識點結(jié)構(gòu)體存學(xué)生信息、數(shù)組或鏈表管理多條記錄、函數(shù)分模塊實現(xiàn)增刪改查、排序輸出成績、文件操作保存和加載數(shù)據(jù)。我的建議是做這個項目時不要一上來就追求完美先實現(xiàn)一個最基礎(chǔ)的增刪改查文件保存版本跑通之后再逐步擴展。擴展的方向可以是增加排序功能、增加按條件查詢、增加統(tǒng)計功能等。每一次擴展都會逼迫你去思考代碼結(jié)構(gòu)是否合理、函數(shù)是否足夠獨立這才是真正成長的地方。我?guī)У某鯇W(xué)者里做完這個項目之后幾乎每個人都能對函數(shù)傳參、指針使用、動態(tài)內(nèi)存分配有一個質(zhì)的飛躍。項目本身就是最好的老師。4.3 第三階段自己造輪子閱讀別人代碼第三階段其實就是畢業(yè)設(shè)計了。這時候可以嘗試做一些更有挑戰(zhàn)性的小項目比如寫一個命令行版通訊錄實現(xiàn)一個簡單的數(shù)據(jù)結(jié)構(gòu)庫自己實現(xiàn)鏈表、棧、隊列做一個文本文件統(tǒng)計工具統(tǒng)計單詞數(shù)、出現(xiàn)頻率等用C語言寫一個貪吃蛇小游戲控制臺版這幾類項目我強烈建議都做一個因為它們的側(cè)重點完全不同通訊錄側(cè)重復(fù)雜數(shù)據(jù)建模數(shù)據(jù)結(jié)構(gòu)庫側(cè)重指針和內(nèi)存管理的靈活運用文本統(tǒng)計分析側(cè)重字符串處理貪吃蛇則涉及數(shù)學(xué)邏輯和坐標(biāo)變換。把這幾個做下來你的C語言水平基本就能達到能獨立寫小型實用程序的程度了。另外一個非常重要的習(xí)慣是學(xué)會讀別人的代碼。光自己寫很容易形成自己的固定套路遇到問題思考方式也單一。去讀一些優(yōu)秀開源項目的源碼看別人怎么組織結(jié)構(gòu)、怎么寫注釋、怎么處理邊界條件這是一個高效但很多人忽略的提升方式??梢詮男№椖靠雌鸩灰婚_始就去硬啃幾萬行的大項目打擊自信心。5. 常見錯誤與排查技巧這些都是我見過的真實教訓(xùn)5.1 編譯通過不等于程序正確要養(yǎng)成調(diào)試思維經(jīng)常有新手問我老師我的程序沒有報錯但是結(jié)果不對怎么回事這個問題其實反映了一個很重要的認(rèn)知誤區(qū)編譯通過只能說明語法正確不能說明邏輯正確。C語言是一門很自由的語言它信任程序員也正因為如此程序員必須學(xué)會自己尋找問題。我的第一個建議是學(xué)會在關(guān)鍵位置打印printf觀察中間值。很多bug并不玄乎你把變量的值在運算前后打出來立刻就能定位問題在哪一步發(fā)生了偏差。有同學(xué)覺得printf調(diào)試太土想直接用調(diào)試器但你得承認(rèn)printf在絕大多數(shù)場景下是最快定位問題的方式。第二個建議是學(xué)會使用調(diào)試工具。比如gdb可以讓你看到程序崩潰時的調(diào)用棧知道它垮在了哪一行。這個信息在排查段錯誤Segmentation Fault時價值千金。段錯誤多半是由非法內(nèi)存訪問引起的比如訪問了空指針、數(shù)組越界、或者操作了已釋放的內(nèi)存。有了gdb你就能快速找到是哪個函數(shù)、哪一行代碼造成的。5.2 一個速查表收錄新手最常踩的坑我整理了這些年在社區(qū)答疑和帶新人過程中最常見的問題做成一個清晰的速查表方便大家對照排查自己代碼里的問題癥狀常見原因排查思路程序崩潰并提示段錯誤空指針/野指針解引用數(shù)組越界用調(diào)試器看崩潰調(diào)用棧檢查指針是否初始化檢查循環(huán)邊界輸出亂碼或奇怪字符字符串沒有以\0結(jié)尾字符數(shù)組越界檢查字符數(shù)組大小和內(nèi)容手動在末尾補\0內(nèi)存泄漏程序運行越久越卡申請的動態(tài)內(nèi)存未釋放或丟失了指向它的指針檢查每一個malloc是否對應(yīng)一個free函數(shù)里改了值外面沒變化傳值而非傳指針形參和實參是兩個東西確認(rèn)是否需要傳遞指針檢查函數(shù)原型條件判斷總是不成立 與 混淆檢查if的條件語句養(yǎng)成常量左邊習(xí)慣數(shù)組結(jié)果從0開始計數(shù)怎么都別扭下標(biāo)從0而不是1開始這是語言設(shè)計用for循環(huán)時注意邊界i n而不是i n代碼能編譯但行為隨機未初始化變量或未初始化的數(shù)組元素初始化所有變量和數(shù)組不要依賴碰巧為0文件內(nèi)容讀不出來或?qū)懖贿M去忘記檢查fopen返回值忘記fclose打開后判斷是否為NULL結(jié)束前關(guān)文件5.3 一些老手才知道的小技巧除了排查錯誤我更想分享一些能提升代碼質(zhì)量和調(diào)試效率的小習(xí)慣這些都是我在實際開發(fā)中慢慢積累起來的心得。第一編譯時把警告當(dāng)錯誤看。在gcc編譯時加-Wall和-Wextra參數(shù)讓編譯器盡量顯示出所有警告信息有時甚至可以加-Werror把所有警告都升級為錯誤。很多潛在bug比如比較無符號和有符號數(shù)、函數(shù)聲明不匹配等其實在警告階段就有跡可循。把警告消滅在編譯期比在運行時調(diào)試省力一萬倍。第二不要因為代碼短就不寫注釋。我的習(xí)慣是每一個函數(shù)至少要有一行注釋說明這個函數(shù)是干什么的每個關(guān)鍵算法步驟至少有一行注釋說明思路。這一習(xí)慣在項目變大之后價值極大。等你一個月后再來看自己寫的代碼你會發(fā)現(xiàn)沒有注釋的代碼跟別人的代碼沒什么兩樣??赡芨F(xiàn)實的問題是三個月后你自己寫的代碼連你自己都不想看。第三學(xué)會做減法保持函數(shù)短小精悍。一個函數(shù)能控制在二三十行以內(nèi)往往比大而全的函數(shù)更容易維護。一個函數(shù)只干一件事干好一件事這是軟件工程里最樸素又最正確的道理。C語言雖然沒有那么多語言層面的輔助特性但用函數(shù)拆分復(fù)雜邏輯是完全可控的事情。第四盡早養(yǎng)成手動測試的習(xí)慣。每寫完一個函數(shù)就單獨寫一小段測試代碼驗證它的正確性而不是把所有代碼都寫完再一起調(diào)試。如果最后一起調(diào)萬一出錯你在幾十個函數(shù)里找bug的成本遠(yuǎn)大于你每個函數(shù)測試時即測即糾的成本。這個習(xí)慣叫小步快跑在寫C語言的時候尤其好用。寫到最后說點掏心窩的話從我自己的學(xué)習(xí)經(jīng)歷來看C語言是一門先苦后甜的語言。入門階段確實會比其他高級語言多一些陣痛那個指針、內(nèi)存、地址、越界的概念會讓你頭疼好一陣子甚至想摔鍵盤。但只要你熬過這幾個月把你寫的第一個管理系統(tǒng)的代碼跑起來打心底里升起來的那種成就感是寫腳本語言很難體會到的。我個人在實際操作中的體會是C語言的學(xué)習(xí)不能貪快。很多人半個月就想學(xué)完這不現(xiàn)實。把節(jié)奏放穩(wěn)每學(xué)一個知識點就寫代碼驗證每學(xué)一個章節(jié)就做個十幾行到幾十行的小練習(xí)比囫圇吞棗地翻閱教程有效得多。另外遇到卡住的地方不要死磕太久跳過它繼續(xù)往前走往往等你后面寫了更多代碼、有了更多直覺之后回頭再看當(dāng)時卡住的知識點一下子就想通了。如果這篇分享能幫你少踩幾個坑、少走一段彎路那我覺得很值得。C語言這條路走下來你會收獲一批很扎實的基本功以后無論你轉(zhuǎn)去做后端、嵌入式、游戲引擎還是繼續(xù)深造計算機基礎(chǔ)都會知道當(dāng)年的這些折騰和辛苦真的沒有白費。