2026.9.05日C++学习复盘日志
2026/9/6 2:31:25 网站建设 项目流程

C++学习:回调函数、函数指针、构造函数、const 与运算符重载实战

本文记录一次 C++ 手写代码复盘,内容涉及回调函数、普通函数指针、成员函数指针、构造函数、成员初始化列表、const、运算符重载,以及编译和链接错误。
学习过程中最大的感受是:看懂代码和真正从零写出来,是两回事。


前言

最近继续学习 C++,这次没有只停留在看课程代码,而是尝试自己手写一个简单的Date日期类。

结果在写代码的过程中,连续暴露出了不少以前“看起来懂了”,但其实理解并不牢固的问题。

比如:

  • 函数名和函数指针到底是什么关系?

  • void (*p)()为什么必须加括号?

  • 成员函数指针为什么不能直接p()

  • 构造函数初始化列表和函数体赋值有什么区别?

  • const Complex& other和成员函数末尾的const分别限制谁?

  • B - A进入operator-后,_daydate._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-本质依然是函数,因此同样可以进行函数重载。


二十二、_daydate._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.exe

gcc在处理.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分别限制谁;

  • 运算符重载为什么本质还是函数;

  • _daydate._day分别属于哪个对象;

  • void函数为什么必须考虑所有路径;

  • 边界条件为什么必须单独测试;

  • gccg++为什么会导致不同的链接结果。

以前很多知识属于:

老师写出来以后,我能看懂。

现在开始逐渐变成:

我知道为什么应该这样写,写错以后也开始知道应该从哪里分析。

我越来越觉得:

看懂代码,只是在识别别人的答案;真正从零手写,才是在逼自己解决问题。

后续准备继续完善这个Date类,加入:

  • 不同月份天数;

  • 闰年判断;

  • 跨月;

  • 跨年;

  • 完整日期差;

  • 更规范的错误处理。

把它继续当作一个练习 C++ 类、对象、构造函数与运算符重载的小项目。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询