恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
2026.9.05日C++学习复盘日志
首页
资讯中心
/
2026.9.05日C++学习复盘日志
2026.9.05日C++学习复盘日志
发布时间:2026/9/6 2:31:45
C学习回调函数、函数指针、构造函数、const 与运算符重载实战本文记录一次 C 手写代码复盘内容涉及回调函数、普通函数指针、成员函数指针、构造函数、成员初始化列表、const、运算符重载以及编译和链接错误。学习过程中最大的感受是看懂代码和真正从零写出来是两回事。前言最近继续学习 C这次没有只停留在看课程代码而是尝试自己手写一个简单的Date日期类。结果在写代码的过程中连续暴露出了不少以前“看起来懂了”但其实理解并不牢固的问题。比如函数名和函数指针到底是什么关系void (*p)()为什么必须加括号成员函数指针为什么不能直接p()构造函数初始化列表和函数体赋值有什么区别const Complex other和成员函数末尾的const分别限制谁B - A进入operator-后_day和date._day分别是谁为什么非void函数必须考虑所有返回路径undefined reference to std::cout为什么不一定是代码写错这篇文章就把这些知识重新串起来整理一遍。一、回调函数把“以后要执行的行为”交给别人回调函数可以先简单理解为把一个函数交给另一个函数在某个条件满足或者事件发生以后再由对方调用它。例如#include iostream using namespace std; void hello() { cout 任务完成 endl; } void doWork(void (*callback)()) { cout 正在执行任务 endl; callback(); } int main() { doWork(hello); return 0; }这里doWork(hello);不是直接调用hello();而是把hello这个函数交给doWork。doWork中的void (*callback)()是一个函数指针参数。最后callback();才真正调用这个函数。整个过程可以理解为准备一个函数 ↓ 把函数交给另一个函数 ↓ 另一个函数保存这个调用能力 ↓ 需要的时候再执行这就是回调的基本思想。二、STM32 中为什么经常出现 Callback后面学习 STM32 时经常会看到类似HAL_UART_RxCpltCallback(...) HAL_GPIO_EXTI_Callback(...) HAL_TIM_PeriodElapsedCallback(...)它们背后的思想其实也是类似的程序正常运行 ↓ 某个硬件事件发生 ↓ 底层库检测到事件 ↓ 调用提前准备好的 Callback ↓ 执行我们自己的处理逻辑所以回调函数真正重要的地方不只是记住callback这个单词。更重要的是理解程序可以把“以后执行什么”作为一种行为保存和传递。三、普通函数指针的基本语法普通函数指针的通用格式返回值类型 (*指针变量名)(参数列表);例如void (*p)();表示p是一个指针可以指向“无参数、返回值为void”的函数。假设有void Func() { cout Func endl; }可以void (*p)() Func;也可以void (*p)() Func;然后p();就可以调用Func()。四、为什么函数指针必须写括号下面两个写法非常像void (*p)();和void* p();但含义完全不同。第一种void (*p)();表示p是一个指针指向一个返回void的函数。第二种void* p();表示p是一个函数这个函数返回void*。因此(*p)这里的括号非常重要。一个比较好记的方法是假设原函数为int Add(int a, int b);把函数名Add替换成(*p)得到int (*p)(int, int);这就是对应的函数指针类型。五、函数名和函数指针是什么关系例如void Func();可以写void (*p)() Func;也可以void (*p)() Func;这很容易让人直接理解成Func本身就是一个指针。但这样并不严谨。更加准确的理解是Func表示函数本身在很多表达式环境中它可以自动转换成指向该函数的函数指针。所以不建议简单死记函数名 地址而应该理解函数名 ↓ 表示函数 在很多表达式中 ↓ 可以转换成指向函数的指针六、成员函数指针普通函数不属于某个具体对象。但是类的成员函数属于类。例如class A { public: void Func() { cout A::Func endl; } };如果想保存A::Func这样的成员函数就需要成员函数指针。语法void (A::*p)();表示p可以指向A类中无参数、返回void的成员函数。例如void (A::*p)() A::Func;七、typedef 和 using 给复杂类型起别名成员函数指针类型比较长void (A::*p)();所以可以写typedef void (A::*PF)();之后PF p A::Func;现代 C 也可以使用using PF void (A::*)();之后同样PF p A::Func;using通常会更加直观。八、为什么成员函数指针不能直接 p()普通函数指针可以p();但成员函数指针不可以直接这样调用。例如PF p A::Func;必须先有一个对象A a;然后(a.*p)();如果有对象指针A* pa a;则(pa-*p)();原因在于成员函数不仅要知道调用哪个函数还必须知道在哪个对象上调用。例如a.Func();这里Func()执行时必须知道当前对象是a。这也和 C 中的隐藏this指针有关。九、A::Func不能简单理解成普通地址普通函数Func很多时候可以直观地理解为得到一个指向Func的函数指针。但是A::Func得到的是成员函数指针值。不要把它简单理解成一个普通内存地址。因为涉及虚函数多重继承this指针调整时成员函数指针底层实现可能比单纯一个地址复杂。因此更准确的说法是A::Func得到一个能够描述“如何调用 A 类 Func 成员函数”的成员函数指针值。十、构造函数到底是什么接下来回到类和对象。例如class Date { public: Date(int year, int month, int day) { } };构造函数具有几个特点函数名与类名相同没有返回值类型创建对象时自动调用可以重载可以有默认参数。例如Date d(2026, 9, 5);就是创建一个Date对象并调用对应的构造函数。十一、构造和赋值不是一回事例如Date d;是在创建一个新对象。如果允许无参调用则属于默认构造。Date d(2026, 9, 5);属于带参构造。Date d2(d1);是在创建d2的同时用d1初始化它属于拷贝构造。但是Date d2; d2 d1;此时d2已经存在。所以d2 d1;不是构造而是赋值。我现在用一句话区分新对象正在出生就是构造对象已经存在再改变它就是赋值。十二、成员初始化列表一个标准的构造函数通常会写成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)虽然看起来像函数调用但不是调用函数。它表示用参数year初始化成员变量_year。十三、初始化列表和函数体赋值有什么区别还可以写Date(int year, int month, int day) { _year year; _month month; _day day; }最后结果看起来可能一样。但语义不同。初始化列表Date(int year) : _year(year) { }表示_year创建时直接初始化为year。而Date(int year) { _year year; }表示_year已经完成初始化进入函数体后再执行赋值。可以简单理解为:_year(year)是出生时直接初始化。而_year year;是出生以后再赋值。对于普通int差异可能不明显。但以后遇到const成员引用成员类对象成员初始化列表就会更加重要。十四、初始化列表的位置不能写错我一开始写过Date(int year, int month, int day) { : _year(year), _month(month), _day(day) }这是错误的。初始化列表必须写在参数列表 和 函数体 之间正确写法Date(int year, int month, int day) : _year(year), _month(month), _day(day) { }顺序一定是构造函数参数 ↓ : ↓ 初始化列表 ↓ 函数体十五、private 并不是谁都不能访问例如private: int _year;private并不是说这个成员任何地方都不能使用。真正含义是类外不能随意直接访问。但是类自己的成员函数可以访问自己的私有成员。例如class Date { public: Date(int year) : _year(year) { } private: int _year; };完全合法。十六、默认参数必须从右向左连续例如Date(int year 2026, int month 9, int day);这种形式是不合适的。如果前面的参数已经给出默认值那么右侧参数也需要有默认值。例如Date( int year 2026, int month 9, int day 1 );这样Date d;就可以使用全部默认值。十七、注意构造函数参数顺序如果构造函数是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);参数永远按照位置匹配。十八、const T 的意义C 中经常看到const Complex other这里表示引用传参。好处之一是不需要额外复制一个Complex对象。而const表示不能通过other修改这个对象。所以const T经常可以理解成只读引用传参。即不额外拷贝 不允许通过该引用修改对象十九、成员函数最后的 const例如Complex operator(const Complex other) const;这里有两个const。第一个const Complex other约束的是参数不通过other修改右操作数。最后面的... ) const约束的是当前对象这个成员函数不会修改当前对象。所以c1 c2如果对应c1.operator(c2);那么可以理解成不修改 c1 不修改 c2 最后返回一个新的 Complex整行代码Complex operator(const Complex other) const;可以翻译成人话定义一个运算符函数让当前对象和另一个Complex对象进行运算不修改两边原对象最后返回一个新的Complex对象。二十、运算符重载本质还是函数例如B - A如果operator-是成员函数可以理解为B.operator-(A);也就是说B ↓ 左操作数 ↓ 当前对象 A ↓ 右操作数 ↓ 函数参数所以运算符重载并不是让-符号突然拥有特殊魔法。本质仍然是编译器根据对象类型寻找合适的operator-函数。二十一、同一个 operator- 也可以发生函数重载例如int operator-(const Date date);实现Date - Date而Date operator-(int a);实现Date - int因此B - A右操作数是Date会匹配operator-(const Date)而B - 3右操作数是int会匹配operator-(int)这说明operator-本质依然是函数因此同样可以进行函数重载。二十二、_day和date._day分别是谁这是我手写Date类时最容易弄混的一点。例如int operator-(const Date date) { return _day - date._day; }假设Date A(2026, 9, 5); Date B(2026, 9, 10);调用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如果把调用对象和参数关系搞反就很容易写成date._day _day以后写成员形式的运算符重载时我准备先写一句左操作数.operator运算符(右操作数)再分析条件。二十四、判断条件不要为了复杂而复杂在实现Date - int时我一开始写过类似_day - a 31 - _day这样的判断。看起来好像考虑了很多东西但实际代入数字以后发现逻辑并不符合需求。如果当前只考虑2026 年 9 月不允许跨月。那么真正需要保证的只是减完以后不能小于 1 日所以_day - a 1就足够清晰。这次让我形成了一个比较重要的习惯先把需求翻译成人话再把人话翻译成代码。二十五、判断条件写完后要代数据测试例如_day - a 1可以直接代入10 - 3 → 合法 5 - 4 → 合法 5 - 5 → 结果为0非法 1 - 1 → 结果为0非法加法_day a 30代入29 1 → 30合法 30 1 → 31非法拿具体数字测试比盯着一个复杂条件看很久更容易发现错误。二十六、非 void 函数必须考虑所有返回路径例如Date operator-(int a) { if (_day - a 1) { return Date(_year, _month, _day - a); } }问题在于如果条件不成立程序会直接走到函数结尾。但是函数声明Date operator-(int a)意味着函数需要返回Date。所以所有可能的执行路径都应该考虑如何处理。这次让我对返回类型有了一个更清楚的理解返回类型不是“这个函数可能返回什么”而是函数对调用者作出的接口承诺。二十七、为了 return 而随便返回也不合理为了保证函数有返回值也不能随便return Date(2026, 9, 1);例如Date(2026, 9, 10) - 20明显是不合法操作。如果最后返回2026年9月1日调用者可能会误认为这真的是计算结果。所以程序设计时不能只考虑能不能编译还要考虑返回结果表达的含义是否合理以后可以进一步学习异常错误码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真正理解一个逻辑以后类似功能通常可以迁移思路。这比重新死记一套代码效率高很多。三十、一个当前阶段的 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写错了。但最后发现问题不是代码而是使用了gcc去处理 C 程序。三十二、gcc 和 gC 文件一般应该使用g test.cpp -o test.exe而不是gcc test.cpp -o test.exegcc在处理.cpp文件时可以调用 C 前端完成编译但最终链接阶段通常不会像g一样自动链接 C 标准库。而std::cout std::endl的实现依赖 C 标准库。因此就可能出现undefined reference所以写 C 程序时优先使用g三十三、编译错误和链接错误不是一回事C 程序可以先粗略理解成.cpp源文件 ↓ 预处理 ↓ 编译 ↓ 汇编 ↓ 目标文件 ↓ 链接 ↓ 可执行程序如果写cout ;这种通常属于编译阶段的语法错误。但是undefined reference to std::cout往往说明程序已经进入链接阶段。链接器找不到某个符号的最终实现。所以以后看到报错不能只问哪一行代码写错了还应该判断语法错误 编译错误 链接错误 运行时错误 还是逻辑错误三十四、类定义最后的分号不能忘类定义最后必须class Date { // ... };最后的;不能漏。而普通函数void Func() { }函数定义结束后通常不需要再加这个分号。这是一个很基础但手写时很容易暴露的问题。三十五、今天这些知识其实可以串成两条线第一条函数 ↓ 函数指针 ↓ 回调函数 ↓ 成员函数指针 ↓ 对象 ↓ this第二条类 ↓ 构造函数 ↓ 初始化列表 ↓ 对象创建 ↓ 成员函数 ↓ const ↓ 运算符重载 ↓ 函数重载最后再通过Date C B - 3;把这些知识真正连起来。这里同时涉及B是当前对象3是函数参数调用了重载后的operator-函数返回一个新的Date新对象再次通过构造函数完成初始化。以前这些知识是分散的。手写代码以后才发现它们其实一直在共同参与一行代码的执行。三十六、今天最值得记住的几个判断方法以后看到B - A先翻译B.operator-(A);看到const T先理解成引用原对象 不通过该引用修改对象看到Func(...) const先问这个成员函数是不是承诺不修改当前对象看到Date(...) : ...先区分这里是在初始化还是进入函数体以后赋值看到复杂的if先问我有没有先把需求翻译成人话看到undefined reference先问这是编译问题还是链接问题这些判断方法比单纯背语法更重要。总结今天最大的收获并不是写出了一个多复杂的Date类。真正有价值的是在手写过程中不断暴露出了以前理解不够牢固的地方。例如函数和函数指针的关系普通函数指针和成员函数指针的区别构造和赋值的区别初始化列表到底发生在什么时候const T和成员函数末尾const分别限制谁运算符重载为什么本质还是函数_day和date._day分别属于哪个对象非void函数为什么必须考虑所有路径边界条件为什么必须单独测试gcc和g为什么会导致不同的链接结果。以前很多知识属于老师写出来以后我能看懂。现在开始逐渐变成我知道为什么应该这样写写错以后也开始知道应该从哪里分析。我越来越觉得看懂代码只是在识别别人的答案真正从零手写才是在逼自己解决问题。后续准备继续完善这个Date类加入不同月份天数闰年判断跨月跨年完整日期差更规范的错误处理。把它继续当作一个练习 C 类、对象、构造函数与运算符重载的小项目。