
C學習回調(diào)函數(shù)、函數(shù)指針、構(gòu)造函數(shù)、const 與運算符重載實戰(zhàn)本文記錄一次 C 手寫代碼復盤內(nèi)容涉及回調(diào)函數(shù)、普通函數(shù)指針、成員函數(shù)指針、構(gòu)造函數(shù)、成員初始化列表、const、運算符重載以及編譯和鏈接錯誤。學習過程中最大的感受是看懂代碼和真正從零寫出來是兩回事。前言最近繼續(xù)學習 C這次沒有只停留在看課程代碼而是嘗試自己手寫一個簡單的Date日期類。結(jié)果在寫代碼的過程中連續(xù)暴露出了不少以前“看起來懂了”但其實理解并不牢固的問題。比如函數(shù)名和函數(shù)指針到底是什么關(guān)系void (*p)()為什么必須加括號成員函數(shù)指針為什么不能直接p()構(gòu)造函數(shù)初始化列表和函數(shù)體賦值有什么區(qū)別const Complex other和成員函數(shù)末尾的const分別限制誰B - A進入operator-后_day和date._day分別是誰為什么非void函數(shù)必須考慮所有返回路徑undefined reference to std::cout為什么不一定是代碼寫錯這篇文章就把這些知識重新串起來整理一遍。一、回調(diào)函數(shù)把“以后要執(zhí)行的行為”交給別人回調(diào)函數(shù)可以先簡單理解為把一個函數(shù)交給另一個函數(shù)在某個條件滿足或者事件發(fā)生以后再由對方調(diào)用它。例如#include iostream using namespace std; void hello() { cout 任務完成 endl; } void doWork(void (*callback)()) { cout 正在執(zhí)行任務 endl; callback(); } int main() { doWork(hello); return 0; }這里doWork(hello);不是直接調(diào)用hello();而是把hello這個函數(shù)交給doWork。doWork中的void (*callback)()是一個函數(shù)指針參數(shù)。最后callback();才真正調(diào)用這個函數(shù)。整個過程可以理解為準備一個函數(shù) ↓ 把函數(shù)交給另一個函數(shù) ↓ 另一個函數(shù)保存這個調(diào)用能力 ↓ 需要的時候再執(zhí)行這就是回調(diào)的基本思想。二、STM32 中為什么經(jīng)常出現(xiàn) Callback后面學習 STM32 時經(jīng)常會看到類似HAL_UART_RxCpltCallback(...) HAL_GPIO_EXTI_Callback(...) HAL_TIM_PeriodElapsedCallback(...)它們背后的思想其實也是類似的程序正常運行 ↓ 某個硬件事件發(fā)生 ↓ 底層庫檢測到事件 ↓ 調(diào)用提前準備好的 Callback ↓ 執(zhí)行我們自己的處理邏輯所以回調(diào)函數(shù)真正重要的地方不只是記住callback這個單詞。更重要的是理解程序可以把“以后執(zhí)行什么”作為一種行為保存和傳遞。三、普通函數(shù)指針的基本語法普通函數(shù)指針的通用格式返回值類型 (*指針變量名)(參數(shù)列表);例如void (*p)();表示p是一個指針可以指向“無參數(shù)、返回值為void”的函數(shù)。假設有void Func() { cout Func endl; }可以void (*p)() Func;也可以void (*p)() Func;然后p();就可以調(diào)用Func()。四、為什么函數(shù)指針必須寫括號下面兩個寫法非常像void (*p)();和void* p();但含義完全不同。第一種void (*p)();表示p是一個指針指向一個返回void的函數(shù)。第二種void* p();表示p是一個函數(shù)這個函數(shù)返回void*。因此(*p)這里的括號非常重要。一個比較好記的方法是假設原函數(shù)為int Add(int a, int b);把函數(shù)名Add替換成(*p)得到int (*p)(int, int);這就是對應的函數(shù)指針類型。五、函數(shù)名和函數(shù)指針是什么關(guān)系例如void Func();可以寫void (*p)() Func;也可以void (*p)() Func;這很容易讓人直接理解成Func本身就是一個指針。但這樣并不嚴謹。更加準確的理解是Func表示函數(shù)本身在很多表達式環(huán)境中它可以自動轉(zhuǎn)換成指向該函數(shù)的函數(shù)指針。所以不建議簡單死記函數(shù)名 地址而應該理解函數(shù)名 ↓ 表示函數(shù) 在很多表達式中 ↓ 可以轉(zhuǎn)換成指向函數(shù)的指針六、成員函數(shù)指針普通函數(shù)不屬于某個具體對象。但是類的成員函數(shù)屬于類。例如class A { public: void Func() { cout A::Func endl; } };如果想保存A::Func這樣的成員函數(shù)就需要成員函數(shù)指針。語法void (A::*p)();表示p可以指向A類中無參數(shù)、返回void的成員函數(shù)。例如void (A::*p)() A::Func;七、typedef 和 using 給復雜類型起別名成員函數(shù)指針類型比較長void (A::*p)();所以可以寫typedef void (A::*PF)();之后PF p A::Func;現(xiàn)代 C 也可以使用using PF void (A::*)();之后同樣PF p A::Func;using通常會更加直觀。八、為什么成員函數(shù)指針不能直接 p()普通函數(shù)指針可以p();但成員函數(shù)指針不可以直接這樣調(diào)用。例如PF p A::Func;必須先有一個對象A a;然后(a.*p)();如果有對象指針A* pa a;則(pa-*p)();原因在于成員函數(shù)不僅要知道調(diào)用哪個函數(shù)還必須知道在哪個對象上調(diào)用。例如a.Func();這里Func()執(zhí)行時必須知道當前對象是a。這也和 C 中的隱藏this指針有關(guān)。九、A::Func不能簡單理解成普通地址普通函數(shù)Func很多時候可以直觀地理解為得到一個指向Func的函數(shù)指針。但是A::Func得到的是成員函數(shù)指針值。不要把它簡單理解成一個普通內(nèi)存地址。因為涉及虛函數(shù)多重繼承this指針調(diào)整時成員函數(shù)指針底層實現(xiàn)可能比單純一個地址復雜。因此更準確的說法是A::Func得到一個能夠描述“如何調(diào)用 A 類 Func 成員函數(shù)”的成員函數(shù)指針值。十、構(gòu)造函數(shù)到底是什么接下來回到類和對象。例如class Date { public: Date(int year, int month, int day) { } };構(gòu)造函數(shù)具有幾個特點函數(shù)名與類名相同沒有返回值類型創(chuàng)建對象時自動調(diào)用可以重載可以有默認參數(shù)。例如Date d(2026, 9, 5);就是創(chuàng)建一個Date對象并調(diào)用對應的構(gòu)造函數(shù)。十一、構(gòu)造和賦值不是一回事例如Date d;是在創(chuàng)建一個新對象。如果允許無參調(diào)用則屬于默認構(gòu)造。Date d(2026, 9, 5);屬于帶參構(gòu)造。Date d2(d1);是在創(chuàng)建d2的同時用d1初始化它屬于拷貝構(gòu)造。但是Date d2; d2 d1;此時d2已經(jīng)存在。所以d2 d1;不是構(gòu)造而是賦值。我現(xiàn)在用一句話區(qū)分新對象正在出生就是構(gòu)造對象已經(jīng)存在再改變它就是賦值。十二、成員初始化列表一個標準的構(gòu)造函數(shù)通常會寫成class Date { public: Date(int year, int month, int day) : _year(year), _month(month), _day(day) { } private: int _year; int _month; int _day; };這里_year(year)雖然看起來像函數(shù)調(diào)用但不是調(diào)用函數(shù)。它表示用參數(shù)year初始化成員變量_year。十三、初始化列表和函數(shù)體賦值有什么區(qū)別還可以寫Date(int year, int month, int day) { _year year; _month month; _day day; }最后結(jié)果看起來可能一樣。但語義不同。初始化列表Date(int year) : _year(year) { }表示_year創(chuàng)建時直接初始化為year。而Date(int year) { _year year; }表示_year已經(jīng)完成初始化進入函數(shù)體后再執(zhí)行賦值。可以簡單理解為:_year(year)是出生時直接初始化。而_year year;是出生以后再賦值。對于普通int差異可能不明顯。但以后遇到const成員引用成員類對象成員初始化列表就會更加重要。十四、初始化列表的位置不能寫錯我一開始寫過Date(int year, int month, int day) { : _year(year), _month(month), _day(day) }這是錯誤的。初始化列表必須寫在參數(shù)列表 和 函數(shù)體 之間正確寫法Date(int year, int month, int day) : _year(year), _month(month), _day(day) { }順序一定是構(gòu)造函數(shù)參數(shù) ↓ : ↓ 初始化列表 ↓ 函數(shù)體十五、private 并不是誰都不能訪問例如private: int _year;private并不是說這個成員任何地方都不能使用。真正含義是類外不能隨意直接訪問。但是類自己的成員函數(shù)可以訪問自己的私有成員。例如class Date { public: Date(int year) : _year(year) { } private: int _year; };完全合法。十六、默認參數(shù)必須從右向左連續(xù)例如Date(int year 2026, int month 9, int day);這種形式是不合適的。如果前面的參數(shù)已經(jīng)給出默認值那么右側(cè)參數(shù)也需要有默認值。例如Date( int year 2026, int month 9, int day 1 );這樣Date d;就可以使用全部默認值。十七、注意構(gòu)造函數(shù)參數(shù)順序如果構(gòu)造函數(shù)是Date(int year 2026, int month 9, int day 1);那么Date A(5);表示的是year 5 month 9 day 1并不是2026年9月5日如果想表示2026年9月5日應該寫Date A(2026, 9, 5);參數(shù)永遠按照位置匹配。十八、const T 的意義C 中經(jīng)??吹絚onst Complex other這里表示引用傳參。好處之一是不需要額外復制一個Complex對象。而const表示不能通過other修改這個對象。所以const T經(jīng)??梢岳斫獬芍蛔x引用傳參。即不額外拷貝 不允許通過該引用修改對象十九、成員函數(shù)最后的 const例如Complex operator(const Complex other) const;這里有兩個const。第一個const Complex other約束的是參數(shù)不通過other修改右操作數(shù)。最后面的... ) const約束的是當前對象這個成員函數(shù)不會修改當前對象。所以c1 c2如果對應c1.operator(c2);那么可以理解成不修改 c1 不修改 c2 最后返回一個新的 Complex整行代碼Complex operator(const Complex other) const;可以翻譯成人話定義一個運算符函數(shù)讓當前對象和另一個Complex對象進行運算不修改兩邊原對象最后返回一個新的Complex對象。二十、運算符重載本質(zhì)還是函數(shù)例如B - A如果operator-是成員函數(shù)可以理解為B.operator-(A);也就是說B ↓ 左操作數(shù) ↓ 當前對象 A ↓ 右操作數(shù) ↓ 函數(shù)參數(shù)所以運算符重載并不是讓-符號突然擁有特殊魔法。本質(zhì)仍然是編譯器根據(jù)對象類型尋找合適的operator-函數(shù)。二十一、同一個 operator- 也可以發(fā)生函數(shù)重載例如int operator-(const Date date);實現(xiàn)Date - Date而Date operator-(int a);實現(xiàn)Date - int因此B - A右操作數(shù)是Date會匹配operator-(const Date)而B - 3右操作數(shù)是int會匹配operator-(int)這說明operator-本質(zhì)依然是函數(shù)因此同樣可以進行函數(shù)重載。二十二、_day和date._day分別是誰這是我手寫Date類時最容易弄混的一點。例如int operator-(const Date date) { return _day - date._day; }假設Date A(2026, 9, 5); Date B(2026, 9, 10);調(diào)用B - A;實際可以理解成B.operator-(A);因此_day表示B._day而date._day表示A._day所以最終_day - date._day實際上就是10 - 5二十三、判斷條件寫反的問題由于B - A等價于B.operator-(A);所以如果要求B 比 A 晚那么應該判斷_day date._day如果把調(diào)用對象和參數(shù)關(guān)系搞反就很容易寫成date._day _day以后寫成員形式的運算符重載時我準備先寫一句左操作數(shù).operator運算符(右操作數(shù))再分析條件。二十四、判斷條件不要為了復雜而復雜在實現(xiàn)Date - int時我一開始寫過類似_day - a 31 - _day這樣的判斷??雌饋砗孟窨紤]了很多東西但實際代入數(shù)字以后發(fā)現(xiàn)邏輯并不符合需求。如果當前只考慮2026 年 9 月不允許跨月。那么真正需要保證的只是減完以后不能小于 1 日所以_day - a 1就足夠清晰。這次讓我形成了一個比較重要的習慣先把需求翻譯成人話再把人話翻譯成代碼。二十五、判斷條件寫完后要代數(shù)據(jù)測試例如_day - a 1可以直接代入10 - 3 → 合法 5 - 4 → 合法 5 - 5 → 結(jié)果為0非法 1 - 1 → 結(jié)果為0非法加法_day a 30代入29 1 → 30合法 30 1 → 31非法拿具體數(shù)字測試比盯著一個復雜條件看很久更容易發(fā)現(xiàn)錯誤。二十六、非 void 函數(shù)必須考慮所有返回路徑例如Date operator-(int a) { if (_day - a 1) { return Date(_year, _month, _day - a); } }問題在于如果條件不成立程序會直接走到函數(shù)結(jié)尾。但是函數(shù)聲明Date operator-(int a)意味著函數(shù)需要返回Date。所以所有可能的執(zhí)行路徑都應該考慮如何處理。這次讓我對返回類型有了一個更清楚的理解返回類型不是“這個函數(shù)可能返回什么”而是函數(shù)對調(diào)用者作出的接口承諾。二十七、為了 return 而隨便返回也不合理為了保證函數(shù)有返回值也不能隨便return Date(2026, 9, 1);例如Date(2026, 9, 10) - 20明顯是不合法操作。如果最后返回2026年9月1日調(diào)用者可能會誤認為這真的是計算結(jié)果。所以程序設計時不能只考慮能不能編譯還要考慮返回結(jié)果表達的含義是否合理以后可以進一步學習異常錯誤碼std::optional輸入合法性檢查。二十八、邊界條件不能忽略當前程序只考慮2026年9月9 月只有30天所以合法范圍是1 ~ 30而不是1 ~ 31這種問題看起來很小但邊界恰恰是程序最容易出錯的地方。應該專門測試1日 30日 1日 - 1 30日 1 29日 1 29日 2二十九、Date int 和 Date - int 可以類比如果Date operator-(int a)的核心條件是_day - a 1那么Date operator(int a)在當前 9 月份的簡化版本中可以類比為_day a 30真正理解一個邏輯以后類似功能通??梢赃w移思路。這比重新死記一套代碼效率高很多。三十、一個當前階段的 Date 示例下面是一個只考慮 2026 年 9 月的教學版本#include iostream using namespace std; class Date { public: Date(int year 2026, int month 9, int day 1) : _year(year), _month(month), _day(day) { } void Print() const { cout _year 年 _month 月 _day 日 endl; } // Date - Date int operator-(const Date date) const { return _day - date._day; } // Date - int Date operator-(int day) const { if (_day - day 1) { return Date(_year, _month, _day - day); } cout 日期減法超出當前月份范圍 endl; return *this; } // Date int Date operator(int day) const { if (_day day 30) { return Date(_year, _month, _day day); } cout 日期加法超出當前月份范圍 endl; return *this; } private: int _year; int _month; int _day; }; int main() { Date A(2026, 9, 5); Date B(2026, 9, 10); cout A; A.Print(); cout B; B.Print(); cout B - A B - A 天 endl; Date C B - 3; cout B - 3; C.Print(); Date D B 3; cout B 3; D.Print(); return 0; }需要說明的是return *this;這里只是當前教學階段的一種簡單兜底寫法并不是完整日期類面對錯誤輸入時的最佳設計。三十一、最后遇到的鏈接錯誤代碼寫完后還遇到了undefined reference to std::cout undefined reference to std::endl collect2.exe: error: ld returned 1 exit status一開始很容易懷疑#include iostream或者cout endl寫錯了。但最后發(fā)現(xiàn)問題不是代碼而是使用了gcc去處理 C 程序。三十二、gcc 和 gC 文件一般應該使用g test.cpp -o test.exe而不是gcc test.cpp -o test.exegcc在處理.cpp文件時可以調(diào)用 C 前端完成編譯但最終鏈接階段通常不會像g一樣自動鏈接 C 標準庫。而std::cout std::endl的實現(xiàn)依賴 C 標準庫。因此就可能出現(xiàn)undefined reference所以寫 C 程序時優(yōu)先使用g三十三、編譯錯誤和鏈接錯誤不是一回事C 程序可以先粗略理解成.cpp源文件 ↓ 預處理 ↓ 編譯 ↓ 匯編 ↓ 目標文件 ↓ 鏈接 ↓ 可執(zhí)行程序如果寫cout ;這種通常屬于編譯階段的語法錯誤。但是undefined reference to std::cout往往說明程序已經(jīng)進入鏈接階段。鏈接器找不到某個符號的最終實現(xiàn)。所以以后看到報錯不能只問哪一行代碼寫錯了還應該判斷語法錯誤 編譯錯誤 鏈接錯誤 運行時錯誤 還是邏輯錯誤三十四、類定義最后的分號不能忘類定義最后必須class Date { // ... };最后的;不能漏。而普通函數(shù)void Func() { }函數(shù)定義結(jié)束后通常不需要再加這個分號。這是一個很基礎(chǔ)但手寫時很容易暴露的問題。三十五、今天這些知識其實可以串成兩條線第一條函數(shù) ↓ 函數(shù)指針 ↓ 回調(diào)函數(shù) ↓ 成員函數(shù)指針 ↓ 對象 ↓ this第二條類 ↓ 構(gòu)造函數(shù) ↓ 初始化列表 ↓ 對象創(chuàng)建 ↓ 成員函數(shù) ↓ const ↓ 運算符重載 ↓ 函數(shù)重載最后再通過Date C B - 3;把這些知識真正連起來。這里同時涉及B是當前對象3是函數(shù)參數(shù)調(diào)用了重載后的operator-函數(shù)返回一個新的Date新對象再次通過構(gòu)造函數(shù)完成初始化。以前這些知識是分散的。手寫代碼以后才發(fā)現(xiàn)它們其實一直在共同參與一行代碼的執(zhí)行。三十六、今天最值得記住的幾個判斷方法以后看到B - A先翻譯B.operator-(A);看到const T先理解成引用原對象 不通過該引用修改對象看到Func(...) const先問這個成員函數(shù)是不是承諾不修改當前對象看到Date(...) : ...先區(qū)分這里是在初始化還是進入函數(shù)體以后賦值看到復雜的if先問我有沒有先把需求翻譯成人話看到undefined reference先問這是編譯問題還是鏈接問題這些判斷方法比單純背語法更重要??偨Y(jié)今天最大的收獲并不是寫出了一個多復雜的Date類。真正有價值的是在手寫過程中不斷暴露出了以前理解不夠牢固的地方。例如函數(shù)和函數(shù)指針的關(guān)系普通函數(shù)指針和成員函數(shù)指針的區(qū)別構(gòu)造和賦值的區(qū)別初始化列表到底發(fā)生在什么時候const T和成員函數(shù)末尾const分別限制誰運算符重載為什么本質(zhì)還是函數(shù)_day和date._day分別屬于哪個對象非void函數(shù)為什么必須考慮所有路徑邊界條件為什么必須單獨測試gcc和g為什么會導致不同的鏈接結(jié)果。以前很多知識屬于老師寫出來以后我能看懂?,F(xiàn)在開始逐漸變成我知道為什么應該這樣寫寫錯以后也開始知道應該從哪里分析。我越來越覺得看懂代碼只是在識別別人的答案真正從零手寫才是在逼自己解決問題。后續(xù)準備繼續(xù)完善這個Date類加入不同月份天數(shù)閏年判斷跨月跨年完整日期差更規(guī)范的錯誤處理。把它繼續(xù)當作一個練習 C 類、對象、構(gòu)造函數(shù)與運算符重載的小項目。