0. 笔记合订本
个人零散C++笔记合订在一起,方便个人查阅
建议顺序:
- 先打基础:命名空间、引用、重载。
- 再学对象:类与对象、成员函数、友元。
- 再进生命周期:构造、析构、拷贝、移动。
- 再进面向对象核心:继承、虚函数、多态。
- 再学泛型:模板、特化、完美转发。
- 最后补工程:内存管理、名字修饰、extern “C”。
1. 基础语法与表达习惯
1.1 命名空间(namespace)
命名空间是“防重名分区”。
1
2
3
4
5
6
7
8
9
|
namespace app {
int version = 1;
void hello() {}
}
int main() {
app::hello();
return 0;
}
|
补充:
- 可嵌套:
project::driver::spi。
- 匿名命名空间常用于“当前源文件私有符号”。
常见坑:
- 头文件里不要全局
using namespace std;。
- 尽量局部
using std::cout;,避免污染。
1.2 引用(Reference)
引用是别名,不是新对象。
1
2
3
|
int a = 10;
int& ref = a;
ref = 20; // a 也变成 20
|
核心规则:
- 引用必须初始化。
- 一旦绑定不能改绑。
- 常量引用可绑定临时对象。
1
|
const int& r = 42; // 合法
|
函数参数中的价值:
- 避免拷贝开销。
- 语义比裸指针更清晰。
常见坑:
- 返回局部变量引用会悬空。
- 把引用当“可空句柄”使用会出设计问题。
1.3 函数重载(Overload)
重载是“同一作用域下,同名函数按参数列表区分语义”。
重载(Overloading)的本质是编译期多态(静态多态)。
它让我们用同一个函数名表达“同一类行为”,但针对不同参数类型/数量执行不同逻辑。
1.3.1 函数重载的成立条件
函数重载成立需要同时满足:
- 同一作用域。
- 同名函数。
- 参数列表不同(个数、类型、顺序至少一项不同)。
不成立场景:只有返回值不同。
1
2
3
4
5
6
7
8
9
10
11
|
// 合法重载
int maxValue(int a, int b) { return a > b ? a : b; }
double maxValue(double a, double b) { return a > b ? a : b; }
int maxValue(int a, int b, int c) {
int t = a > b ? a : b;
return t > c ? t : c;
}
// 错误示例:只改返回值不构成重载
// int f(int x);
// double f(int x);
|
1.3.2 带默认参数(缺省参数)的函数
默认参数可以减少调用时传参数量,但和重载一起用时容易冲突。
1
2
3
4
5
6
7
8
9
|
float area(float r = 6.5f) {
return 3.14f * r * r;
}
int main() {
area(); // r = 6.5
area(7.5f); // r = 7.5
return 0;
}
|
多默认参数规则:默认值必须从右向左连续。
1
2
3
4
5
|
// 错误:默认参数右侧还有未默认参数
// void f1(float a, int b = 6, int c, char d = 'a');
// 正确
void f2(float a, int c, int b = 6, char d = 'a');
|
1.3.3 重载 + 默认参数为什么会二义性
1
2
3
4
5
6
7
8
9
10
11
12
13
|
int maxValue(int a, int b, int c = 1) {
int t = a > b ? a : b;
return t > c ? t : c;
}
int maxValue(int a, int b) {
return a > b ? a : b;
}
int main() {
// maxValue(1, 2); // 二义性:两个候选都能匹配
return 0;
}
|
结论:
- “带默认参数不能重载”不是绝对规则。
- 准确说法是:可以重载,但必须保证每个调用点只有唯一可匹配函数。
1.3.4 小结(函数重载)
- 重载由编译器在编译期完成决议。
- 只改返回值不构成重载。
- 默认参数和重载混用要谨慎,优先保证唯一匹配和可读性。
1.4 运算符重载(Operator Overload)
运算符重载是“把运算符表达式翻译成函数调用”。
运算符重载本质仍是函数重载,只是函数名变成了 operator+、operator<< 这类形式。
1.4.1 为什么需要运算符重载
内置运算符默认只面向基础类型。
当我们有自定义类型(如复数、向量、矩阵)时,希望也能写自然表达式。
1
2
|
// 希望这样写
// Complex c3 = c1 + c2;
|
1.4.2 运算符重载的基本写法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
class Complex {
public:
Complex() : real(0), imag(0) {}
Complex(double r, double i) : real(r), imag(i) {}
explicit Complex(double r) : real(r), imag(0) {}
// 成员函数方式
Complex operator+(const Complex& rhs) const;
Complex operator-(const Complex& rhs) const;
// 友元函数方式
friend Complex operator*(const Complex& lhs, const Complex& rhs);
// 输入输出通常使用友元
friend std::istream& operator>>(std::istream& in, Complex& c);
friend std::ostream& operator<<(std::ostream& out, const Complex& c);
private:
double real;
double imag;
};
|
1.4.3 成员函数重载运算符
表达式:
本质翻译:
1
|
// c3 = c1.operator+(c2);
|
实现示例:
1
2
3
4
5
6
7
|
Complex Complex::operator+(const Complex& rhs) const {
return Complex(real + rhs.real, imag + rhs.imag);
}
Complex Complex::operator-(const Complex& rhs) const {
return Complex(real - rhs.real, imag - rhs.imag);
}
|
规则记忆:
- 二元成员运算符参数个数 = 原操作数个数 - 1(左操作数是
this)。
1.4.4 友元函数重载运算符
表达式:
本质翻译:
1
|
// c3 = operator*(c1, c2);
|
实现示例:
1
2
3
4
5
6
|
Complex operator*(const Complex& lhs, const Complex& rhs) {
return Complex(
lhs.real * rhs.real - lhs.imag * rhs.imag,
lhs.real * rhs.imag + lhs.imag * rhs.real
);
}
|
规则记忆:
- 友元/普通函数形式参数个数 = 原操作数个数。
1.4.5 重载 « 和 »
<<、>> 一般定义成友元函数,因为左操作数是流对象而不是类对象。
1
2
3
4
5
6
7
8
9
|
std::istream& operator>>(std::istream& in, Complex& c) {
in >> c.real >> c.imag;
return in;
}
std::ostream& operator<<(std::ostream& out, const Complex& c) {
out << c.real << '+' << c.imag << 'i';
return out;
}
|
返回引用的原因:支持链式调用。
1
|
// std::cout << c1 << c2;
|
1.4.6 单目运算符重载(前置/后置++)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
class Counter {
public:
Counter& operator++() { // 前置++
++value;
return *this;
}
Counter operator++(int) { // 后置++(int为占位参数)
Counter old = *this;
++value;
return old;
}
private:
int value = 0;
};
|
重点:后置 ++ 用额外 int 占位参数区分。
1.4.7 运算符重载的规则与边界
必须遵守:
- 不能发明新运算符。
- 不能改变优先级和结合性。
- 不能改变操作数个数。
- 至少一个操作数是自定义类型。
实践建议:
- 优先成员函数或友元函数实现。
- 重载要符合直觉语义,不要为了“炫技”重载。
- 运算符语义必须稳定,避免误导阅读者。
1.4.8 小结(运算符重载)
- 运算符重载是语法层优化,本质是函数调用。
- 成员方式和友元方式都常用,按场景选。
- 流运算符重载、前置后置++区分是高频考点。
- 重载目的应是“增强可读性”。
2. 类与对象总览
2.1 类与对象
类是抽象模板,对象是具体实例。
1
2
3
4
5
6
7
8
|
class Student {
public:
void setId(int v) { id = v; }
int getId() const { return id; }
private:
int id = 0;
};
|
注意:
- 类声明本身不等于对象内存分配。
- 对象实例化后,成员才占实际空间。
2.2 访问权限与成员函数
访问修饰:
public:外部接口。
private:实现细节。
protected:给继承层使用。
成员函数能访问同类对象私有成员:
1
2
3
4
5
6
7
8
9
|
class Box {
public:
void copyFrom(const Box& other) {
value = other.value;
}
private:
int value = 0;
};
|
2.3 类外定义成员函数
用作用域解析运算符 :::
1
2
3
4
5
6
7
8
9
10
11
|
class Counter {
public:
void inc();
int get() const;
private:
int value = 0;
};
void Counter::inc() { ++value; }
int Counter::get() const { return value; }
|
2.4 友元函数与友元类
友元是“开权限的外部函数/类”,不是成员。
1
2
3
4
5
6
7
8
9
10
11
12
|
class Box {
private:
int value;
public:
explicit Box(int v) : value(v) {}
friend int readValue(const Box& b);
};
int readValue(const Box& b) {
return b.value;
}
|
注意:
- 友元关系是单向的。
- 友元不继承。
- 能少用就少用,优先保持封装边界。
3. 对象生命周期与资源语义
3.1 引言:为什么这章最关键
写C++,最终都要回到两件事。
- 对象什么时候创建、什么时候销毁(生命周期)。
- 资源由谁申请、由谁释放(资源语义)。
这章如果吃透,后面看 RAII、智能指针、异常安全、移动语义会顺很多。
3.2 构造函数与析构函数
- 构造函数负责对象“出生时初始化”。
- 析构函数负责对象“离开前收尾清理”。
核心规则:
- 构造函数名和类名相同,没有返回类型,也不能写
void。
- 构造函数可以重载。
- 析构函数名是
~类名,无参数,不允许重载,一个类只能有一个析构函数。
- 如果程序员不写,编译器会生成默认版本。
- 一般把构造/析构放在
public,否则外部无法按预期创建或销毁对象。
示例:
1
2
3
4
5
6
7
8
9
10
11
|
class Complex {
public:
Complex(); // 默认构造
Complex(double r, double i); // 初始化构造
explicit Complex(double r); // 单参构造(建议 explicit)
~Complex(); // 析构
private:
double real;
double imag;
};
|
3.3 默认构造、无参构造、全缺省构造
可以同时写“无参构造”和“全缺省构造”,语法上属于重载;
但在调用点(比如 ClassName obj;)会出现二义性。
1
2
3
4
5
6
7
8
9
|
class Demo {
public:
Demo() {}
Demo(int x = 0) {}
};
int main() {
// Demo d; // 二义性:两个构造都能匹配
}
|
结论:
- 默认构造函数本质是“可无参调用入口”。
- 一个类里应只保留一个“可无参调用入口”。
- 工程里通常写“全缺省构造”更灵活。
3.4 初始化列表:不是语法糖,是语义
初始化列表是“直接初始化成员”;
函数体赋值是“先默认构造再赋值”。
1
2
3
4
5
6
7
8
|
class Demo {
public:
Demo(int x, int y) : a(x), b(y) {}
private:
int a;
int b;
};
|
必须用初始化列表的场景:
const 成员。
- 引用成员。
- 成员对象没有默认构造函数。
补一句工程经验:
- 成员初始化顺序按“声明顺序”而不是按初始化列表书写顺序。
- 写错顺序会触发告警,复杂类里容易埋 bug。
3.5 析构函数:资源释放的最后防线
1
2
3
4
5
6
7
|
class Student {
public:
Student() = default;
~Student() {
// 例如:关闭文件句柄、释放堆内存、回收系统资源
}
};
|
要牢记:
- 析构函数在对象销毁时自动调用。
- 局部对象离开作用域会析构。
new 出来的对象在 delete 时析构。
- 资源类不写正确析构,泄漏风险极高。
继承下的关键点:
- 如果类会被当成基类使用(通过基类指针删除派生对象),基类析构函数必须是虚函数。
- 否则会出现“派生部分没析构完整”的严重问题。
3.6 拷贝构造函数:何时触发、为什么危险
定义:拷贝构造是“用一个已有对象初始化另一个新对象”。
典型触发场景:
T b = a;
- 函数按值传参。
- 函数按值返回(在未被消除拷贝的路径上)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
class Buffer {
public:
explicit Buffer(std::size_t n) : data(new char[n]), size(n) {}
Buffer(const Buffer& other)
: data(new char[other.size]), size(other.size) {
std::memcpy(data, other.data, size);
}
~Buffer() { delete[] data; }
private:
char* data;
std::size_t size;
};
|
浅拷贝与深拷贝:
- 浅拷贝:只复制指针地址,两个对象指向同一块资源。
- 深拷贝:复制资源本体,各管各的生命周期。
为什么浅拷贝危险:
- 两个对象析构时可能重复释放同一地址。
- 一个对象改数据会影响另一个对象。
3.7 编译器默认拷贝构造
编译器默认生成的拷贝构造,成员语义是“逐成员拷贝”。
对于这些类型通常没问题:
- 内置类型成员(
int、double 等)。
- 已经正确管理资源的标准库类型(
std::string、std::vector 等)。
对于这些类型高风险:
- 持有裸指针并自己管理堆资源。
- 持有文件句柄、socket、互斥锁等独占资源。
结论:
- “能编译过”不等于“资源语义正确”。
- 一旦类持有资源,要么自己定义拷贝/移动语义,要么直接禁拷贝。
3.7.1 默认操作补充:默认拷贝构造与默认拷贝赋值
如果没有显式写,编译器通常会为类生成:
- 默认拷贝构造函数。
- 默认拷贝赋值运算符。
它们的行为本质都是“逐成员拷贝”(member-wise copy)。
这在纯值类型里常常够用;
但在“自己管理动态内存/句柄”的类里,默认行为非常容易埋雷。
如果就是不希望类被拷贝,直接禁止:
1
2
3
4
5
|
class Complex {
public:
Complex(const Complex&) = delete;
Complex& operator=(const Complex&) = delete;
};
|
补充一个常见易错点:
- 拷贝构造参数应该是
const T&。
- 如果写成按值传参,会导致递归调用拷贝构造本身。
3.8 移动构造与移动语义
移动不是复制,而是资源所有权转移。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
class Buffer {
public:
explicit Buffer(std::size_t n) : data(new char[n]), size(n) {}
Buffer(const Buffer& other)
: data(new char[other.size]), size(other.size) {
std::memcpy(data, other.data, size);
}
Buffer(Buffer&& other) noexcept
: data(other.data), size(other.size) {
other.data = nullptr;
other.size = 0;
}
~Buffer() { delete[] data; }
private:
char* data = nullptr;
std::size_t size = 0;
};
|
要点:
- 移动构造建议
noexcept,标准容器才更愿意走移动路径。
- 被移动对象要保持“可析构、可赋值”的有效但未指定状态。
- 移动后对象不应再依赖原资源内容。
3.8.1 默认拷贝赋值(Copy Assignment)到底做了什么
拷贝赋值和拷贝构造要分清:
T b = a; 是拷贝构造(创建新对象)。
b = a; 是拷贝赋值(给已存在对象赋值)。
如果不自己写,编译器的默认拷贝赋值同样是“逐成员拷贝”。
对于资源类,常见后果:
- 两个对象指向同一块堆内存。
- 后续析构发生二次释放。
3.8.2 拷贝赋值实现细节(自赋值、异常安全、释放时机)
- 避免自赋值。
- 避免内存泄漏。
这里给一版工程可用写法(先申请再释放,异常安全更好):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
|
class ClassA {
public:
ClassA() = default;
explicit ClassA(const char* s) {
std::size_t n = std::strlen(s);
pszTestStr = new char[n + 1];
std::memcpy(pszTestStr, s, n + 1);
}
~ClassA() {
delete[] pszTestStr;
}
ClassA(const ClassA& rhs) {
if (rhs.pszTestStr) {
std::size_t n = std::strlen(rhs.pszTestStr);
pszTestStr = new char[n + 1];
std::memcpy(pszTestStr, rhs.pszTestStr, n + 1);
}
}
ClassA& operator=(const ClassA& rhs) {
if (this != &rhs) {
char* newBuf = nullptr;
if (rhs.pszTestStr) {
std::size_t n = std::strlen(rhs.pszTestStr);
newBuf = new char[n + 1];
std::memcpy(newBuf, rhs.pszTestStr, n + 1);
}
delete[] pszTestStr;
pszTestStr = newBuf;
}
return *this;
}
private:
char* pszTestStr = nullptr;
};
|
这段逻辑的核心是:
- 不在原内存上直接改,先准备好新资源。
- 新资源准备成功后再替换旧资源。
- 任何时刻对象都保持可析构状态。
3.8.3 硬拷贝与软拷贝
硬拷贝(Hard Copy):
- 每次拷贝都重新申请内存并复制内容。
- 优点是语义简单,生命周期独立。
- 缺点是大对象场景开销高。
软拷贝(Soft Copy):
- 多个对象共享同一资源。
- 通过引用计数追踪所有者数量。
- 最后一个所有者离开时释放资源。
现代 C++ 里,软拷贝通常直接交给
std::shared_ptr,不建议手写引用计数。
3.9 this 指针与链式调用
this 是当前对象地址。
常见用途:
- 区分同名成员和形参。
- 返回
*this 做链式调用。
1
2
3
4
5
6
7
8
9
10
|
class Counter {
public:
Counter& add(int value) {
this->value += value;
return *this;
}
private:
int value = 0;
};
|
3.10 资源语义总收口(这章必须带走的结论)
- 构造管初始化,析构管清理,二者共同决定对象生命周期边界。
- 只要类里有裸资源,就必须认真对待拷贝/移动/析构。
- 默认拷贝只做成员拷贝,不会自动做“资源语义正确化”。
- 深拷贝解决共享地址风险,移动语义解决不必要复制开销。
- 这章和第6章(动态内存)是同一条主线:最终目标是可控、可证明的资源管理。
3.11 生命周期调用时序实验
这一节是“看现象反推机制”。
把下面代码单独建个文件跑一下,会直观看到:
- 哪些地方触发拷贝构造。
- 哪些地方触发移动构造。
- 对象最终析构顺序是怎样的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
|
#include <cstring>
#include <iostream>
class TraceBuffer {
public:
explicit TraceBuffer(std::size_t n = 8) : size_(n), data_(new char[n]) {
std::cout << "[ctor] size=" << size_ << " this=" << this << std::endl;
}
TraceBuffer(const TraceBuffer& other)
: size_(other.size_), data_(new char[other.size_]) {
std::memcpy(data_, other.data_, size_);
std::cout << "[copy ctor] from=" << &other << " to=" << this << std::endl;
}
TraceBuffer(TraceBuffer&& other) noexcept
: size_(other.size_), data_(other.data_) {
other.size_ = 0;
other.data_ = nullptr;
std::cout << "[move ctor] from=" << &other << " to=" << this << std::endl;
}
TraceBuffer& operator=(const TraceBuffer& other) {
std::cout << "[copy assign] from=" << &other << " to=" << this << std::endl;
if (this != &other) {
char* newData = new char[other.size_];
std::memcpy(newData, other.data_, other.size_);
delete[] data_;
data_ = newData;
size_ = other.size_;
}
return *this;
}
TraceBuffer& operator=(TraceBuffer&& other) noexcept {
std::cout << "[move assign] from=" << &other << " to=" << this << std::endl;
if (this != &other) {
delete[] data_;
data_ = other.data_;
size_ = other.size_;
other.data_ = nullptr;
other.size_ = 0;
}
return *this;
}
~TraceBuffer() {
std::cout << "[dtor] size=" << size_ << " this=" << this << std::endl;
delete[] data_;
}
private:
std::size_t size_ = 0;
char* data_ = nullptr;
};
TraceBuffer makeBuffer() {
TraceBuffer tmp(16);
return tmp;
}
int main() {
std::cout << " step1: direct construct " << std::endl;
TraceBuffer a(4);
std::cout << " step2: copy construct " << std::endl;
TraceBuffer b = a;
std::cout << " step3: move construct " << std::endl;
TraceBuffer c = std::move(a);
std::cout << " step4: return value " << std::endl;
TraceBuffer d = makeBuffer();
std::cout << " step5: assignment " << std::endl;
b = d;
c = std::move(d);
std::cout << " end main " << std::endl;
return 0;
}
|
会观察到这些规律:
- 有名字的对象表达式通常是左值,即便类型是右值引用。
std::move 只是类型转换,不会真的移动资源;真正移动发生在移动构造/移动赋值里。
- 返回值优化可能让“看不到预期中的拷贝/移动调用”,这是编译器优化,不是语义错误。
实验建议:
- 用
-O0 先看基础调用路径。
- 再开优化看调用减少。
- 若要观察更多中间过程,可尝试关闭拷贝消除(不同编译器参数不同)。
这一节和前面 3.6~3.8 对应关系非常直接:
- 看到拷贝次数多,先思考能否改成移动或避免临时对象。
- 看到析构崩溃,先排查是否发生浅拷贝导致二次释放。
- 看到“对象被 move 过还继续当正常对象用”,要检查后续逻辑是否依赖原资源。
4. 继承、虚函数与多态
4.1 继承与派生
继承强调代码复用,派生强调能力扩展。
1
2
3
4
5
6
7
8
9
10
11
|
class Base {
public:
void hello() {}
protected:
int x = 0;
};
class Child : public Base {
public:
void run() { x = 1; }
};
|
4.2 继承方式对可见性的影响
| 基类成员 |
public继承 |
protected继承 |
private继承 |
| public |
public |
protected |
private |
| protected |
protected |
protected |
private |
| private |
不可直接访问 |
不可直接访问 |
不可直接访问 |
4.3 构造析构顺序(继承)
规则:
- 构造:先基类,后派生类。
- 析构:先派生类,后基类。
- 多继承时,基类构造顺序按继承列表声明顺序。
4.4 多继承歧义与同名隐藏
当多个基类有同名成员会出现二义性,需要类名限定。
1
2
|
// obj.A::func();
// obj.B::func();
|
如果派生类声明同名函数,会隐藏基类同名重载集合,必要时用 using Base::func;。
4.5 虚函数与动态绑定
虚函数让调用版本在运行期决定。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
class Shape {
public:
virtual int area() { return 0; }
virtual ~Shape() = default;
};
class Rectangle : public Shape {
public:
Rectangle(int w, int h) : width(w), height(h) {}
int area() override { return width * height; }
private:
int width;
int height;
};
|
动态多态前提:
- 基类虚函数。
- 派生类重写。
- 基类指针/引用调用。
4.6 抽象类与纯虚函数
1
2
3
4
5
|
class IShape {
public:
virtual int area() = 0;
virtual ~IShape() = default;
};
|
抽象类不可实例化,只能作为接口基类。
4.7 override 与 final
override:让编译器帮忙检查是否真的重写。
final:禁止继续重写(函数)或继续继承(类)。
常见坑:
- 基类析构函数非虚,导致基类指针删除派生对象时析构不完整。
- 把参数不一致的同名函数误当“重写”。
4.8 继承场景下的动态内存管理
分两种情况:
- 只有基类管理动态内存:先把基类的资源语义写正确(构造/析构/拷贝/赋值/移动),子类通常按普通成员处理。
- 子类也管理动态内存:子类必须在自己的拷贝构造、拷贝赋值、析构中处理新增资源,同时正确调用基类版本。
伪代码示意:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
|
class MyString {
public:
MyString(const MyString& rhs);
MyString& operator=(const MyString& rhs);
virtual ~MyString();
};
class MyMap : public MyString {
public:
MyMap(const char* key, const char* value) {
// 先执行基类构造,再初始化 keyname
}
~MyMap() {
delete[] keyname;
}
MyMap(const MyMap& rhs)
: MyString(rhs) {
// 深拷贝 keyname
}
MyMap& operator=(const MyMap& rhs) {
if (this != &rhs) {
MyString::operator=(rhs); // 先赋值基类部分
// 再处理派生类新增资源
}
return *this;
}
private:
char* keyname = nullptr;
};
|
在工程里可以用这套检查顺序:
- 基类资源是否安全。
- 派生类新增资源是否独立处理。
- 析构是否“先派生后基类”可完整释放。
- 赋值时是否先处理基类再处理子类。
5. 模板与泛型思维
5.1 函数模板
函数模板是“生成函数的规则”,不是立刻生成好的函数本体。
1
2
3
4
5
6
|
template<typename T>
void mySwap(T& a, T& b) {
T temp = a;
a = b;
b = temp;
}
|
class 与 typename 在模板参数列表中等价。
1
2
3
4
5
6
7
8
9
10
|
template<class T>
T myMax(T a, T b) {
return a > b ? a : b;
}
int main() {
int a = 3, b = 7;
myMax<int>(a, b); // 显式指定模板参数
myMax(a, b); // 让编译器自动推导
}
|
5.2 模板实例化(Instantiation)
实例化是“编译器根据实参类型,把模板变成具体函数/类”的过程。
- 隐式实例化:在调用点由编译器自动生成。
- 显式实例化:主动要求编译器提前生成某个版本。
1
2
3
4
5
6
|
template<typename T>
T myAbs(T x) {
return x < 0 ? -x : x;
}
template int myAbs<int>(int); // 显式实例化
|
5.3 特化(Specialization)
实例化是“按原规则生成”,特化是“对某些类型单独定制实现”。
5.3.1 函数模板全特化
1
2
3
4
5
6
7
8
9
|
template<typename T>
T maxValue(T a, T b) {
return a > b ? a : b;
}
template<>
double maxValue<double>(double a, double b) {
return a > b ? a : b;
}
|
注意:
- 函数模板不支持偏特化。
- 类模板支持偏特化。
函数模板如果想做“偏特化效果”,通常使用重载实现。
5.4 类模板(Class Template)
类模板是“按类型批量生成类定义”的模具。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
template<typename T>
class Storage {
public:
explicit Storage(T v) : value(v) {}
T get() const { return value; }
private:
T value;
};
int main() {
Storage<int> a(10);
Storage<std::string> b("hello");
}
|
5.4.1 类模板成员函数类外定义
类外定义时要重复模板声明,并写 类名<T>::函数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
template<typename T>
class Holder {
public:
void set(T v);
T get() const;
private:
T value{};
};
template<typename T>
void Holder<T>::set(T v) {
value = v;
}
template<typename T>
T Holder<T>::get() const {
return value;
}
|
5.5 类模板特化与偏特化
类模板全特化后,本质就是一个新类,不会自动继承主模板实现。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
template<typename T>
class Printer {
public:
void print() { std::cout << "normal type" << std::endl; }
};
// 偏特化:指针类型
template<typename T>
class Printer<T*> {
public:
void print() { std::cout << "pointer type" << std::endl; }
};
// 全特化:bool类型
template<>
class Printer<bool> {
public:
void print() { std::cout << "bool type" << std::endl; }
};
|
5.6 非类型模板参数(Non-type Template Parameter)
模板参数不一定是“类型”,也可以是“编译期常量值”。
1
2
3
4
5
6
7
8
|
template<typename T, std::size_t N>
class FixedArray {
public:
constexpr std::size_t size() const { return N; }
private:
T data[N]{};
};
|
常见非类型模板参数:整数、枚举、指针、引用、std::nullptr_t(标准版本支持范围不同)。
5.7 模板嵌套(模板参数也是模板)
1
2
3
4
5
6
7
8
9
10
11
|
template<template<typename> class Container, typename T>
class Wrapper {
private:
Container<T> data;
};
template<typename T>
class MyVector {
private:
std::vector<T> data;
};
|
应用场景:策略类、容器适配器、可插拔泛型组件。
5.8 泛型 lambda
泛型 lambda 是“匿名模板函数对象”。
1
2
3
|
auto add = [](auto x, auto y) {
return x + y;
};
|
本质:带模板调用运算符的函数对象。
补充:
- lambda 默认
operator() 是 const。
- 要修改按值捕获的变量,需要
mutable。
1
2
3
4
5
|
int x = 1;
auto f = [x]() mutable {
++x;
return x;
};
|
捕获注意:
[=] 默认按值捕获。
[&] 默认按引用捕获。
- 引用捕获要注意生命周期,防止悬空引用。
5.9 完美转发(万能引用)
完美转发要解决“中间函数转发参数时丢失左值/右值属性”的问题。
先看问题:
1
2
3
4
5
6
7
8
9
10
11
12
|
void target(const std::string& s) {
std::cout << "lvalue: " << s << std::endl;
}
void target(std::string&& s) {
std::cout << "rvalue: " << s << std::endl;
}
template<typename T>
void badForward(T&& v) {
target(v); // v有名字,在函数体内是左值
}
|
正确写法:
1
2
3
4
|
template<typename T>
void wrapper(T&& v) {
target(std::forward<T>(v));
}
|
5.9.1 万能引用(转发引用)推导规则
在模板参数推导场景下,T&& 不是普通右值引用,而是万能引用:
- 传右值:
T 推导为 Type,参数类型是 Type&&。
- 传左值:
T 推导为 Type&,参数类型经折叠后为 Type&。
5.9.2 引用折叠规则
| 形式 |
折叠结果 |
T& & |
T& |
T& && |
T& |
T&& & |
T& |
T&& && |
T&& |
5.9.3 std::move 与 std::forward 区别
std::move:无条件把表达式转成右值。
std::forward<T>:按 T 的推导结果“有条件地”保持左/右值属性。
std::move 常用于“我明确要转移资源”;
std::forward 常用于“模板中转发实参原有属性”。
5.9.4 常见坑
关键点:
T&& 在模板推导中是转发引用。
- 传左值时
T 推导为 Type&。
- 传右值时
T 推导为 Type。
常见坑:
- 写成
target(v) 会把有名变量当左值传递。
- 右值引用参数写成
const T&& 通常失去移动语义价值。
- 误把“引用本身是右值引用类型”和“表达式值类别是右值”混为一谈。
5.10 本章小结
- 模板是“规则”,实例化才是“具体代码”。
- 实例化和特化是两个不同概念,不要混用。
- 函数模板偏特化不支持,类模板偏特化支持。
- 泛型 lambda、完美转发本质都依赖模板类型推导。
- 模板学习核心不是语法堆砌,而是“类型推导 + 语义保持”。
6. 动态内存与资源管理
6.1 new / delete 基础
new 负责申请并初始化对象,delete 负责销毁并释放对象内存。
1
2
3
4
5
|
int* p = new int(5);
delete p;
int* arr = new int[100];
delete[] arr;
|
必须配对:
new 对 delete。
new[] 对 delete[]。
补充理解:
- 对于类类型,
new 会调用构造函数,delete 会调用析构函数。
malloc/free 只管字节分配回收,不调用构造/析构。
6.2 手动内存管理的三条底线
- 只释放自己申请到的首地址。
- 只释放一次。
- 释放后立刻置空,避免悬空指针再次访问。
1
2
3
|
int* p = new int(42);
delete p;
p = nullptr;
|
常见问题:
- 野指针:未初始化就使用。
- 悬空指针:释放后继续读写。
- 重复释放:
delete 已释放指针。
6.3 动态数组与释放规范
一维动态数组:
1
2
3
4
5
6
7
8
9
|
int n = 10;
int* arr = new int[n];
for (int i = 0; i < n; ++i) {
arr[i] = i * i;
}
delete[] arr;
arr = nullptr;
|
注意:数组必须 delete[],不能用 delete。
6.4 多维数组分配与释放顺序
二维动态数组常见写法:先分配“行指针数组”,再逐行分配列空间。
1
2
3
4
5
6
7
8
9
|
int** p = new int*[4];
for (int i = 0; i < 4; ++i) {
p[i] = new int[8];
}
for (int i = 0; i < 4; ++i) {
delete[] p[i];
}
delete[] p;
|
规则:先释放内层,再释放外层。
三维数组同理:
- 先释放最内层。
- 再释放中间层。
- 最后释放最外层。
6.5 分配失败处理与异常
现代 C++ 默认 new 失败会抛 std::bad_alloc。
1
2
3
4
5
6
|
try {
int* p = new int[1000000000];
delete[] p;
} catch (const std::bad_alloc& e) {
std::cerr << "alloc failed: " << e.what() << std::endl;
}
|
如果想用“空指针判断”风格:
1
2
3
4
5
|
int* p = new (std::nothrow) int[100];
if (p == nullptr) {
// 处理分配失败
}
delete[] p;
|
6.6 资源泄漏与异常安全
手动 new/delete 最大风险之一是:中间抛异常导致 delete 走不到。
1
2
3
4
5
|
void risky() {
int* p = new int[100];
// ...中间可能抛异常
delete[] p; // 可能执行不到
}
|
这就是为什么现代 C++ 推崇 RAII:把释放动作放到析构函数里,让作用域结束自动回收。
6.7 RAII 与资源管理类
把资源生命周期绑定到对象生命周期。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
class IntBuffer {
public:
explicit IntBuffer(std::size_t n) : data(new int[n]), size(n) {}
~IntBuffer() {
delete[] data;
}
IntBuffer(const IntBuffer&) = delete;
IntBuffer& operator=(const IntBuffer&) = delete;
private:
int* data;
std::size_t size;
};
|
重点:
- 构造负责申请资源。
- 析构负责释放资源。
- 禁止不安全拷贝,避免双重释放。
6.8 从裸指针到智能指针
工程里建议优先使用智能指针而不是手写 new/delete。
6.8.1 unique_ptr(独占所有权)
1
2
|
auto p = std::make_unique<int>(10);
// p独占资源,离开作用域自动释放
|
特点:
- 不能拷贝,只能移动。
- 适合明确单一所有者场景。
6.8.2 shared_ptr(共享所有权)
1
2
|
auto p1 = std::make_shared<int>(10);
auto p2 = p1; // 引用计数+1
|
特点:
- 多个对象共享同一资源。
- 引用计数归零时自动释放。
6.8.3 weak_ptr(打破循环引用)
当两个 shared_ptr 互相持有会循环引用,导致泄漏。
weak_ptr 不增加引用计数,用来观察资源而不拥有资源。
6.8.4 智能指针怎么理解最准确
- 智能指针“看起来像指针”。
- 但它本质是类对象,内部封装了资源释放策略。
也就是说它不是“系统自动回收魔法”,而是 RAII 在模板类上的工程化落地。
6.8.5 delete 与 delete[] 在类对象数组中的区别
对内置类型可能感觉差别不明显,但对类对象数组差别很大:
delete p 只会按单对象路径处理,通常只调用一个析构流程。
delete[] p 会按数组元素数量逐个调用析构,再释放整块数组内存。
所以规则必须严格遵守:
new 配 delete。
new[] 配 delete[]。
- 尤其类对象数组,配错不仅是泄漏,还可能造成资源清理不完整。
6.9 动态内存与拷贝/移动语义的关系
只要类里有裸资源(如 int*、char*),就要认真处理:
- 拷贝构造。
- 拷贝赋值。
- 移动构造。
- 移动赋值。
- 析构函数。
也就是常说的规则五(Rule of Five)。
如果用 std::vector、std::string、智能指针托管资源,通常更容易走向规则零(Rule of Zero)。
6.10 本章排错清单
遇到内存问题时,按这个顺序查:
new/delete 和 new[]/delete[] 是否配对。
- 是否有重复释放。
- 是否有释放后继续访问。
- 是否有异常路径绕过释放。
- 是否有共享所有权循环引用。
6.11 本章小结
- 手动内存管理要严格遵守“配对、一次、置空”。
- 多维数组释放顺序必须与分配顺序相反。
- 现代 C++ 默认用异常报告分配失败。
- RAII 是资源安全管理核心思想。
- 工程中优先智能指针和标准容器,尽量减少裸
new/delete。
7. 工程视角:名字修饰与链接认知
7.1 名字修饰(Name Mangling)
名字修饰是编译器在编译阶段给符号“重新命名”,目的是保证全局唯一并支持重载。
在 C 里,函数名通常就是裸名字;
在 C++ 里,函数名会编码参数信息、命名空间信息、类作用域信息。
示例:
void func(double) 在 GCC 下可能变成 _Z4funcd。
void foo(int) 可能是 _Z3fooi。
namespace ns { int add(int,int); } 可能是 _ZN2ns3addii。
这也是 C++ 能重载而 C 不能重载的底层原因之一。
7.2 C/C++ 混编为什么会链接失败
原因:
- C 的符号规则简单。
- C++ 符号包含类型编码。
- 两边规则不一致,链接器找不到对应符号。
典型报错(工程里最常见的就是这一类):
1
2
|
undefined reference to `foo`
undefined reference to `_Z3fooi`
|
本质上都是“声明时预期的符号名”和“库里真实导出的符号名”对不上。
7.3 先分清这3层问题
实际排错时,不要一上来就改代码,先判断是哪个层面出问题:
- 声明层问题:头文件声明不一致(如少了 extern “C”)。
- 编译层问题:C 文件被 C++ 编译器处理,或反过来。
- 链接层问题:库没进链接命令,或链接顺序不对。
这三层判断清楚,排错速度会快很多。
7.4 extern “C”
1
2
3
4
5
6
7
8
9
|
#ifdef __cplusplus
extern "C" {
#endif
void c_api(int x);
#ifdef __cplusplus
}
#endif
|
作用:告诉 C++ 编译器按 C 规则生成/查找符号。
适用场景:
- C/C++ 混编。
- 嵌入式启动文件调用 C++ 工程接口。
- 第三方 C 库接入。
补充一个工程里很实用的模式:
- C++ 内部保留类和模板实现。
- 对外导出一组 C 风格 API(extern “C”)。
- C 侧只认识这组接口,不直接碰 C++ 类。
示意:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
// api.h
#ifdef __cplusplus
extern "C" {
#endif
typedef struct DeviceHandle DeviceHandle;
DeviceHandle* device_create(void);
void device_destroy(DeviceHandle* h);
int device_run(DeviceHandle* h, int mode);
#ifdef __cplusplus
}
#endif
|
这样做的好处:接口稳定,语言边界清晰,链接问题可控。
7.5 工程排错流程
可以按下面固定流程走,几乎覆盖 80% 混编链接问题:
- 先看报错里“找不到的符号名”到底长什么样。
- 用符号工具看目标库/目标文件里有没有这个符号。
- 有符号但名字不一致:优先检查 extern “C” 与声明一致性。
- 名字一致但仍报错:检查链接命令是否带上对应库。
- 还不行:检查链接顺序(尤其静态库)。
常用命令(GNU工具链):
1
2
3
|
nm -C libxxx.a | grep foo
readelf -Ws libxxx.a | grep foo
objdump -t libxxx.a | grep foo
|
说明:
nm -C 会做 demangle,便于人读。
- 看不到符号,说明库里根本没导出。
- 符号有但类型不对(U/T/W等),要继续追定义位置。
7.6 常见错误清单与对策
7.6.1 头文件忘了加 extern “C” 宏保护
现象:C++ 调 C 库链接失败。
对策:把 extern “C” 包装放进头文件,而不是只写在某个 cpp 里。
7.6.2 把 C 文件当 C++ 编译
现象:同名函数符号变成 C++ 风格,导致原本 C 侧找不到。
对策:
- 明确
.c 用 C 编译器。
.cpp 用 C++ 编译器。
- 检查构建系统里每个源文件的语言设置。
7.6.3 声明签名和定义签名不一致
现象:看起来函数名一样,实际符号编码不同。
对策:统一头文件声明,不要手写重复声明。
7.6.4 静态库链接顺序错误
现象:库里明明有符号,还是 undefined reference。
对策:一般遵循“先目标对象,后依赖库”的顺序。
7.6.5 C 侧直接调用 C++ 类成员
现象:几乎必挂(名称、this指针、ABI都不匹配)。
对策:用 C API 封装层间接调用 C++ 内部实现。
7.7 arm-none-eabi 场景提示
在嵌入式(arm-none-eabi)里,这类问题更常见,因为经常会遇到:
- 启动文件/中断向量是 C 风格符号。
- 驱动层是 C 与 C++ 混合。
- 不同模块来自不同编译选项或不同语言源。
建议在工程里固定以下规范:
- 所有跨语言接口统一放在
api.h 并加 extern “C” 宏保护。
- 每个静态库发布时附一份“导出符号清单”。
- CI 增加一个符号检查步骤(检查关键接口是否导出)。
常用检查命令:
1
2
|
arm-none-eabi-nm -C build/xxx.elf | grep -E "main|Reset_Handler|foo"
arm-none-eabi-readelf -Ws build/xxx.elf | grep foo
|
7.8 一页速查
当再次遇到链接错误时,按这5步走:
- 记下 undefined reference 里的符号名。
- 在库里搜这个符号(nm/readelf)。
- 对比声明与定义是否完全一致(含 extern “C”)。
- 检查构建脚本里语言、库路径、链接顺序。
- 若是 C 调 C++,补 C 接口封装层,不直接跨语言调类成员。
7.9 本章小结
- 名字修饰不是“理论知识”,它直接决定链接能不能过。
- extern “C” 的本质是“统一符号规则”,不是语法装饰。
- 工程排错要分层:声明层、编译层、链接层。
- 先查符号再改代码,是混编排错最高效路径。
- 在嵌入式工程中,建立固定接口规范比临时修Bug更重要。
8. 设计模式
- 模板方法:流程固定,步骤可变。
- 策略模式:算法可替换,减少 if-else。
- 观察者模式:事件通知,一对多联动。
- 工厂方法:对象创建延迟到子类/工厂。
- 装饰器模式:动态扩展能力,避免子类爆炸。
建议:每个模式都做“重构前 vs 重构后”代码对照。
9. 高频bug速查
- 基类析构函数非虚,却通过基类指针删除派生对象。
- 资源类只做浅拷贝,导致二次释放。
new[] 后用 delete。
- 误以为函数模板支持偏特化。
- 把重载当重写。
- 头文件全局
using namespace std;。
- 完美转发里忘记
std::forward<T>。