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 和 g++
C++ 文件一般应该使用:
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++ 类、对象、构造函数与运算符重载的小项目。