C++笔记合订本

0. 笔记合订本

个人零散C++笔记合订在一起,方便个人查阅

建议顺序:

  1. 先打基础:命名空间、引用、重载。
  2. 再学对象:类与对象、成员函数、友元。
  3. 再进生命周期:构造、析构、拷贝、移动。
  4. 再进面向对象核心:继承、虚函数、多态。
  5. 再学泛型:模板、特化、完美转发。
  6. 最后补工程:内存管理、名字修饰、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;
}

补充:

  1. 可嵌套:project::driver::spi
  2. 匿名命名空间常用于“当前源文件私有符号”。 常见坑:
  3. 头文件里不要全局 using namespace std;
  4. 尽量局部 using std::cout;,避免污染。

1.2 引用(Reference)

引用是别名,不是新对象。

1
2
3
int a = 10;
int& ref = a;
ref = 20; // a 也变成 20

核心规则:

  1. 引用必须初始化。
  2. 一旦绑定不能改绑。
  3. 常量引用可绑定临时对象。
1
const int& r = 42; // 合法

函数参数中的价值:

  1. 避免拷贝开销。
  2. 语义比裸指针更清晰。 常见坑:
  3. 返回局部变量引用会悬空。
  4. 把引用当“可空句柄”使用会出设计问题。

1.3 函数重载(Overload)

重载是“同一作用域下,同名函数按参数列表区分语义”。

重载(Overloading)的本质是编译期多态(静态多态)。 它让我们用同一个函数名表达“同一类行为”,但针对不同参数类型/数量执行不同逻辑。

1.3.1 函数重载的成立条件

函数重载成立需要同时满足:

  1. 同一作用域。
  2. 同名函数。
  3. 参数列表不同(个数、类型、顺序至少一项不同)。

不成立场景:只有返回值不同。

 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. “带默认参数不能重载”不是绝对规则。
  2. 准确说法是:可以重载,但必须保证每个调用点只有唯一可匹配函数。

1.3.4 小结(函数重载)

  1. 重载由编译器在编译期完成决议。
  2. 只改返回值不构成重载。
  3. 默认参数和重载混用要谨慎,优先保证唯一匹配和可读性。

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 + c2;

本质翻译:

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. 二元成员运算符参数个数 = 原操作数个数 - 1(左操作数是 this)。

1.4.4 友元函数重载运算符

表达式:

1
// c3 = c1 * c2;

本质翻译:

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. 友元/普通函数形式参数个数 = 原操作数个数。

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. 不能发明新运算符。
  2. 不能改变优先级和结合性。
  3. 不能改变操作数个数。
  4. 至少一个操作数是自定义类型。 实践建议:
  5. 优先成员函数或友元函数实现。
  6. 重载要符合直觉语义,不要为了“炫技”重载。
  7. 运算符语义必须稳定,避免误导阅读者。

1.4.8 小结(运算符重载)

  1. 运算符重载是语法层优化,本质是函数调用。
  2. 成员方式和友元方式都常用,按场景选。
  3. 流运算符重载、前置后置++区分是高频考点。
  4. 重载目的应是“增强可读性”。

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;
};

注意:

  1. 类声明本身不等于对象内存分配。
  2. 对象实例化后,成员才占实际空间。

2.2 访问权限与成员函数

访问修饰:

  1. public:外部接口。
  2. private:实现细节。
  3. 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;
}

注意:

  1. 友元关系是单向的。
  2. 友元不继承。
  3. 能少用就少用,优先保持封装边界。

3. 对象生命周期与资源语义

3.1 引言:为什么这章最关键

写C++,最终都要回到两件事。

  1. 对象什么时候创建、什么时候销毁(生命周期)。
  2. 资源由谁申请、由谁释放(资源语义)。

这章如果吃透,后面看 RAII、智能指针、异常安全、移动语义会顺很多。

3.2 构造函数与析构函数

  1. 构造函数负责对象“出生时初始化”。
  2. 析构函数负责对象“离开前收尾清理”。

核心规则:

  1. 构造函数名和类名相同,没有返回类型,也不能写 void
  2. 构造函数可以重载。
  3. 析构函数名是 ~类名,无参数,不允许重载,一个类只能有一个析构函数。
  4. 如果程序员不写,编译器会生成默认版本。
  5. 一般把构造/析构放在 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; // 二义性:两个构造都能匹配
}

结论:

  1. 默认构造函数本质是“可无参调用入口”。
  2. 一个类里应只保留一个“可无参调用入口”。
  3. 工程里通常写“全缺省构造”更灵活。

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;
};

必须用初始化列表的场景:

  1. const 成员。
  2. 引用成员。
  3. 成员对象没有默认构造函数。 补一句工程经验:
  4. 成员初始化顺序按“声明顺序”而不是按初始化列表书写顺序。
  5. 写错顺序会触发告警,复杂类里容易埋 bug。

3.5 析构函数:资源释放的最后防线

1
2
3
4
5
6
7
class Student {
public:
    Student() = default;
    ~Student() {
        // 例如:关闭文件句柄、释放堆内存、回收系统资源
    }
};

要牢记:

  1. 析构函数在对象销毁时自动调用。
  2. 局部对象离开作用域会析构。
  3. new 出来的对象在 delete 时析构。
  4. 资源类不写正确析构,泄漏风险极高。 继承下的关键点:
  5. 如果类会被当成基类使用(通过基类指针删除派生对象),基类析构函数必须是虚函数。
  6. 否则会出现“派生部分没析构完整”的严重问题。

3.6 拷贝构造函数:何时触发、为什么危险

定义:拷贝构造是“用一个已有对象初始化另一个新对象”。

典型触发场景:

  1. T b = a;
  2. 函数按值传参。
  3. 函数按值返回(在未被消除拷贝的路径上)。
 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;
};

浅拷贝与深拷贝:

  1. 浅拷贝:只复制指针地址,两个对象指向同一块资源。
  2. 深拷贝:复制资源本体,各管各的生命周期。

为什么浅拷贝危险:

  1. 两个对象析构时可能重复释放同一地址。
  2. 一个对象改数据会影响另一个对象。

3.7 编译器默认拷贝构造

编译器默认生成的拷贝构造,成员语义是“逐成员拷贝”。

对于这些类型通常没问题:

  1. 内置类型成员(intdouble 等)。
  2. 已经正确管理资源的标准库类型(std::stringstd::vector 等)。

对于这些类型高风险:

  1. 持有裸指针并自己管理堆资源。
  2. 持有文件句柄、socket、互斥锁等独占资源。

结论:

  1. “能编译过”不等于“资源语义正确”。
  2. 一旦类持有资源,要么自己定义拷贝/移动语义,要么直接禁拷贝。

3.7.1 默认操作补充:默认拷贝构造与默认拷贝赋值

如果没有显式写,编译器通常会为类生成:

  1. 默认拷贝构造函数。
  2. 默认拷贝赋值运算符。

它们的行为本质都是“逐成员拷贝”(member-wise copy)。

这在纯值类型里常常够用; 但在“自己管理动态内存/句柄”的类里,默认行为非常容易埋雷。 如果就是不希望类被拷贝,直接禁止:

1
2
3
4
5
class Complex {
public:
    Complex(const Complex&) = delete;
    Complex& operator=(const Complex&) = delete;
};

补充一个常见易错点:

  1. 拷贝构造参数应该是 const T&
  2. 如果写成按值传参,会导致递归调用拷贝构造本身。

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;
};

要点:

  1. 移动构造建议 noexcept,标准容器才更愿意走移动路径。
  2. 被移动对象要保持“可析构、可赋值”的有效但未指定状态。
  3. 移动后对象不应再依赖原资源内容。

3.8.1 默认拷贝赋值(Copy Assignment)到底做了什么

拷贝赋值和拷贝构造要分清:

  1. T b = a; 是拷贝构造(创建新对象)。
  2. b = a; 是拷贝赋值(给已存在对象赋值)。

如果不自己写,编译器的默认拷贝赋值同样是“逐成员拷贝”。

对于资源类,常见后果:

  1. 两个对象指向同一块堆内存。
  2. 后续析构发生二次释放。

3.8.2 拷贝赋值实现细节(自赋值、异常安全、释放时机)

  1. 避免自赋值。
  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;
};

这段逻辑的核心是:

  1. 不在原内存上直接改,先准备好新资源。
  2. 新资源准备成功后再替换旧资源。
  3. 任何时刻对象都保持可析构状态。

3.8.3 硬拷贝与软拷贝

硬拷贝(Hard Copy):

  1. 每次拷贝都重新申请内存并复制内容。
  2. 优点是语义简单,生命周期独立。
  3. 缺点是大对象场景开销高。 软拷贝(Soft Copy):
  4. 多个对象共享同一资源。
  5. 通过引用计数追踪所有者数量。
  6. 最后一个所有者离开时释放资源。 现代 C++ 里,软拷贝通常直接交给 std::shared_ptr,不建议手写引用计数。

3.9 this 指针与链式调用

this 是当前对象地址。 常见用途:

  1. 区分同名成员和形参。
  2. 返回 *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 资源语义总收口(这章必须带走的结论)

  1. 构造管初始化,析构管清理,二者共同决定对象生命周期边界。
  2. 只要类里有裸资源,就必须认真对待拷贝/移动/析构。
  3. 默认拷贝只做成员拷贝,不会自动做“资源语义正确化”。
  4. 深拷贝解决共享地址风险,移动语义解决不必要复制开销。
  5. 这章和第6章(动态内存)是同一条主线:最终目标是可控、可证明的资源管理。

3.11 生命周期调用时序实验

这一节是“看现象反推机制”。

把下面代码单独建个文件跑一下,会直观看到:

  1. 哪些地方触发拷贝构造。
  2. 哪些地方触发移动构造。
  3. 对象最终析构顺序是怎样的。
 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;
}

会观察到这些规律:

  1. 有名字的对象表达式通常是左值,即便类型是右值引用。
  2. std::move 只是类型转换,不会真的移动资源;真正移动发生在移动构造/移动赋值里。
  3. 返回值优化可能让“看不到预期中的拷贝/移动调用”,这是编译器优化,不是语义错误。

实验建议:

  1. -O0 先看基础调用路径。
  2. 再开优化看调用减少。
  3. 若要观察更多中间过程,可尝试关闭拷贝消除(不同编译器参数不同)。

这一节和前面 3.6~3.8 对应关系非常直接:

  1. 看到拷贝次数多,先思考能否改成移动或避免临时对象。
  2. 看到析构崩溃,先排查是否发生浅拷贝导致二次释放。
  3. 看到“对象被 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 构造析构顺序(继承)

规则:

  1. 构造:先基类,后派生类。
  2. 析构:先派生类,后基类。
  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;
};

动态多态前提:

  1. 基类虚函数。
  2. 派生类重写。
  3. 基类指针/引用调用。

4.6 抽象类与纯虚函数

1
2
3
4
5
class IShape {
public:
    virtual int area() = 0;
    virtual ~IShape() = default;
};

抽象类不可实例化,只能作为接口基类。

4.7 override 与 final

  1. override:让编译器帮忙检查是否真的重写。
  2. final:禁止继续重写(函数)或继续继承(类)。

常见坑:

  1. 基类析构函数非虚,导致基类指针删除派生对象时析构不完整。
  2. 把参数不一致的同名函数误当“重写”。

4.8 继承场景下的动态内存管理

分两种情况:

  1. 只有基类管理动态内存:先把基类的资源语义写正确(构造/析构/拷贝/赋值/移动),子类通常按普通成员处理。
  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
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;
};

在工程里可以用这套检查顺序:

  1. 基类资源是否安全。
  2. 派生类新增资源是否独立处理。
  3. 析构是否“先派生后基类”可完整释放。
  4. 赋值时是否先处理基类再处理子类。

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;
}

classtypename 在模板参数列表中等价。

 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. 显式实例化:主动要求编译器提前生成某个版本。
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;
}

注意:

  1. 函数模板不支持偏特化。
  2. 类模板支持偏特化。 函数模板如果想做“偏特化效果”,通常使用重载实现。

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;
};

本质:带模板调用运算符的函数对象。 补充:

  1. lambda 默认 operator()const
  2. 要修改按值捕获的变量,需要 mutable
1
2
3
4
5
int x = 1;
auto f = [x]() mutable {
    ++x;
    return x;
};

捕获注意:

  1. [=] 默认按值捕获。
  2. [&] 默认按引用捕获。
  3. 引用捕获要注意生命周期,防止悬空引用。

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&& 不是普通右值引用,而是万能引用:

  1. 传右值:T 推导为 Type,参数类型是 Type&&
  2. 传左值:T 推导为 Type&,参数类型经折叠后为 Type&

5.9.2 引用折叠规则

形式 折叠结果
T& & T&
T& && T&
T&& & T&
T&& && T&&

5.9.3 std::move 与 std::forward 区别

  1. std::move:无条件把表达式转成右值。
  2. std::forward<T>:按 T 的推导结果“有条件地”保持左/右值属性。

std::move 常用于“我明确要转移资源”; std::forward 常用于“模板中转发实参原有属性”。

5.9.4 常见坑

关键点:

  1. T&& 在模板推导中是转发引用。
  2. 传左值时 T 推导为 Type&
  3. 传右值时 T 推导为 Type

常见坑:

  1. 写成 target(v) 会把有名变量当左值传递。
  2. 右值引用参数写成 const T&& 通常失去移动语义价值。
  3. 误把“引用本身是右值引用类型”和“表达式值类别是右值”混为一谈。

5.10 本章小结

  1. 模板是“规则”,实例化才是“具体代码”。
  2. 实例化和特化是两个不同概念,不要混用。
  3. 函数模板偏特化不支持,类模板偏特化支持。
  4. 泛型 lambda、完美转发本质都依赖模板类型推导。
  5. 模板学习核心不是语法堆砌,而是“类型推导 + 语义保持”。

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;

必须配对:

  1. newdelete
  2. new[]delete[]

补充理解:

  1. 对于类类型,new 会调用构造函数,delete 会调用析构函数。
  2. malloc/free 只管字节分配回收,不调用构造/析构。

6.2 手动内存管理的三条底线

  1. 只释放自己申请到的首地址。
  2. 只释放一次。
  3. 释放后立刻置空,避免悬空指针再次访问。
1
2
3
int* p = new int(42);
delete p;
p = nullptr;

常见问题:

  1. 野指针:未初始化就使用。
  2. 悬空指针:释放后继续读写。
  3. 重复释放: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;

规则:先释放内层,再释放外层。

三维数组同理:

  1. 先释放最内层。
  2. 再释放中间层。
  3. 最后释放最外层。

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;
};

重点:

  1. 构造负责申请资源。
  2. 析构负责释放资源。
  3. 禁止不安全拷贝,避免双重释放。

6.8 从裸指针到智能指针

工程里建议优先使用智能指针而不是手写 new/delete

6.8.1 unique_ptr(独占所有权)

1
2
auto p = std::make_unique<int>(10);
// p独占资源,离开作用域自动释放

特点:

  1. 不能拷贝,只能移动。
  2. 适合明确单一所有者场景。

6.8.2 shared_ptr(共享所有权)

1
2
auto p1 = std::make_shared<int>(10);
auto p2 = p1; // 引用计数+1

特点:

  1. 多个对象共享同一资源。
  2. 引用计数归零时自动释放。

6.8.3 weak_ptr(打破循环引用)

当两个 shared_ptr 互相持有会循环引用,导致泄漏。
weak_ptr 不增加引用计数,用来观察资源而不拥有资源。

6.8.4 智能指针怎么理解最准确

  1. 智能指针“看起来像指针”。
  2. 但它本质是类对象,内部封装了资源释放策略。 也就是说它不是“系统自动回收魔法”,而是 RAII 在模板类上的工程化落地。

6.8.5 delete 与 delete[] 在类对象数组中的区别

对内置类型可能感觉差别不明显,但对类对象数组差别很大:

  1. delete p 只会按单对象路径处理,通常只调用一个析构流程。
  2. delete[] p 会按数组元素数量逐个调用析构,再释放整块数组内存。

所以规则必须严格遵守:

  1. newdelete
  2. new[]delete[]
  3. 尤其类对象数组,配错不仅是泄漏,还可能造成资源清理不完整。

6.9 动态内存与拷贝/移动语义的关系

只要类里有裸资源(如 int*char*),就要认真处理:

  1. 拷贝构造。
  2. 拷贝赋值。
  3. 移动构造。
  4. 移动赋值。
  5. 析构函数。

也就是常说的规则五(Rule of Five)。

如果用 std::vectorstd::string、智能指针托管资源,通常更容易走向规则零(Rule of Zero)。

6.10 本章排错清单

遇到内存问题时,按这个顺序查:

  1. new/deletenew[]/delete[] 是否配对。
  2. 是否有重复释放。
  3. 是否有释放后继续访问。
  4. 是否有异常路径绕过释放。
  5. 是否有共享所有权循环引用。

6.11 本章小结

  1. 手动内存管理要严格遵守“配对、一次、置空”。
  2. 多维数组释放顺序必须与分配顺序相反。
  3. 现代 C++ 默认用异常报告分配失败。
  4. RAII 是资源安全管理核心思想。
  5. 工程中优先智能指针和标准容器,尽量减少裸 new/delete

7. 工程视角:名字修饰与链接认知

7.1 名字修饰(Name Mangling)

名字修饰是编译器在编译阶段给符号“重新命名”,目的是保证全局唯一并支持重载。 在 C 里,函数名通常就是裸名字; 在 C++ 里,函数名会编码参数信息、命名空间信息、类作用域信息。

示例:

  1. void func(double) 在 GCC 下可能变成 _Z4funcd
  2. void foo(int) 可能是 _Z3fooi
  3. namespace ns { int add(int,int); } 可能是 _ZN2ns3addii

这也是 C++ 能重载而 C 不能重载的底层原因之一。

7.2 C/C++ 混编为什么会链接失败

原因:

  1. C 的符号规则简单。
  2. C++ 符号包含类型编码。
  3. 两边规则不一致,链接器找不到对应符号。

典型报错(工程里最常见的就是这一类):

1
2
undefined reference to `foo`
undefined reference to `_Z3fooi`

本质上都是“声明时预期的符号名”和“库里真实导出的符号名”对不上。

7.3 先分清这3层问题

实际排错时,不要一上来就改代码,先判断是哪个层面出问题:

  1. 声明层问题:头文件声明不一致(如少了 extern “C”)。
  2. 编译层问题:C 文件被 C++ 编译器处理,或反过来。
  3. 链接层问题:库没进链接命令,或链接顺序不对。

这三层判断清楚,排错速度会快很多。

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 规则生成/查找符号。 适用场景:

  1. C/C++ 混编。
  2. 嵌入式启动文件调用 C++ 工程接口。
  3. 第三方 C 库接入。

补充一个工程里很实用的模式:

  1. C++ 内部保留类和模板实现。
  2. 对外导出一组 C 风格 API(extern “C”)。
  3. 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% 混编链接问题:

  1. 先看报错里“找不到的符号名”到底长什么样。
  2. 用符号工具看目标库/目标文件里有没有这个符号。
  3. 有符号但名字不一致:优先检查 extern “C” 与声明一致性。
  4. 名字一致但仍报错:检查链接命令是否带上对应库。
  5. 还不行:检查链接顺序(尤其静态库)。 常用命令(GNU工具链):
1
2
3
nm -C libxxx.a | grep foo
readelf -Ws libxxx.a | grep foo
objdump -t libxxx.a | grep foo

说明:

  1. nm -C 会做 demangle,便于人读。
  2. 看不到符号,说明库里根本没导出。
  3. 符号有但类型不对(U/T/W等),要继续追定义位置。

7.6 常见错误清单与对策

7.6.1 头文件忘了加 extern “C” 宏保护

现象:C++ 调 C 库链接失败。 对策:把 extern “C” 包装放进头文件,而不是只写在某个 cpp 里。

7.6.2 把 C 文件当 C++ 编译

现象:同名函数符号变成 C++ 风格,导致原本 C 侧找不到。 对策:

  1. 明确 .c 用 C 编译器。
  2. .cpp 用 C++ 编译器。
  3. 检查构建系统里每个源文件的语言设置。

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)里,这类问题更常见,因为经常会遇到:

  1. 启动文件/中断向量是 C 风格符号。
  2. 驱动层是 C 与 C++ 混合。
  3. 不同模块来自不同编译选项或不同语言源。

建议在工程里固定以下规范:

  1. 所有跨语言接口统一放在 api.h 并加 extern “C” 宏保护。
  2. 每个静态库发布时附一份“导出符号清单”。
  3. 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步走:

  1. 记下 undefined reference 里的符号名。
  2. 在库里搜这个符号(nm/readelf)。
  3. 对比声明与定义是否完全一致(含 extern “C”)。
  4. 检查构建脚本里语言、库路径、链接顺序。
  5. 若是 C 调 C++,补 C 接口封装层,不直接跨语言调类成员。

7.9 本章小结

  1. 名字修饰不是“理论知识”,它直接决定链接能不能过。
  2. extern “C” 的本质是“统一符号规则”,不是语法装饰。
  3. 工程排错要分层:声明层、编译层、链接层。
  4. 先查符号再改代码,是混编排错最高效路径。
  5. 在嵌入式工程中,建立固定接口规范比临时修Bug更重要。

8. 设计模式

  1. 模板方法:流程固定,步骤可变。
  2. 策略模式:算法可替换,减少 if-else。
  3. 观察者模式:事件通知,一对多联动。
  4. 工厂方法:对象创建延迟到子类/工厂。
  5. 装饰器模式:动态扩展能力,避免子类爆炸。 建议:每个模式都做“重构前 vs 重构后”代码对照。

9. 高频bug速查

  1. 基类析构函数非虚,却通过基类指针删除派生对象。
  2. 资源类只做浅拷贝,导致二次释放。
  3. new[] 后用 delete
  4. 误以为函数模板支持偏特化。
  5. 把重载当重写。
  6. 头文件全局 using namespace std;
  7. 完美转发里忘记 std::forward<T>
experience
使用 Hugo 构建
主题 StackJimmy 设计