K KASS 返回文章列表
公开文章

READING APPEARANCE

选择阅读主题

选择会保存在当前设备,下次阅读自动沿用。

C++ / 2026-08-01

CS106L 第 9 讲:类模板与 const 正确性

从泛型容器实现出发,理解类模板、模板实例化与 const 正确性。

CS106L 第 9 讲:类模板与 const 正确性 的封面
C++ · CLASS-C

原始课件:2026Spring-09-TemplateClasses.pdf

本节课真正要解决的两个问题

上一节课中,我们已经学会用类把数据和操作封装在一起。比如,我们可以自己实现一个保存整数序列的 IntVector

class IntVector {
public:
    IntVector();
    ~IntVector();

    std::size_t size();
    bool empty();

    void push_back(const int& value);
    int& at(std::size_t index);
};

这个类可以保存整数,但需求很快就会变化:

  • 现在要保存 double
  • 接着要保存 std::string
  • 后来又要保存自定义的 Student
  • 甚至要保存“整数序列的序列”。

如果每遇到一种类型就复制整个类,然后把所有 int 改成新类型,我们会得到:

IntVector
DoubleVector
StringVector
StudentVector
VectorOfIntVector
……

这些类的算法完全相同,变化的只是元素类型。

于是出现了本节课的第一个核心问题:

能不能只写一次容器逻辑,再把“元素类型”当成一个可以替换的参数?

解决这个问题之后,我们还会遇到另一个麻烦。假设一个函数只想读取容器:

void printVector(const Vector<int>& values);

这里通过 const 承诺不修改 values。可是编译器怎么知道 values.size()values.at() 会不会偷偷修改对象?

于是出现了第二个核心问题:

一个类怎样明确区分“只读操作”和“会修改对象的操作”?

这两个问题分别引出:

  • 类模板(class template);
  • const 正确性(const correctness)。

学习本节课需要的前置知识

开始之前,需要能够看懂下面这些基础语法:

class Student {
public:
    Student(const std::string& name);

    const std::string& name() const;
    void rename(const std::string& new_name);

private:
    std::string name_;
};

其中:

  • public 是类对外提供的接口;
  • private 是类内部保存的数据;
  • Student(...) 是构造函数;
  • const std::string& 表示“对不可修改字符串的引用”;
  • name() const 末尾的 const 表示这个成员函数不会修改当前对象;
  • this 表示当前正在调用成员函数的对象。

本节会重新解释末尾的 const,因此暂时没有完全理解也没关系。

还需要知道:

  • 指针可以保存对象地址;
  • 引用是已有对象的别名;
  • new T[n] 会创建一个动态数组;
  • delete[] 用于释放由 new[] 创建的数组;
  • std::size_t 是一种用于表示大小和下标的无符号整数类型。

第一部分:为什么不能为每种类型重新写一个类

IntVector 开始

假设我们已经写好了一个保存整数的类:

#include <cstddef>

class IntVector {
public:
    int& at(std::size_t index);
    void push_back(const int& value);

private:
    int* elems_;
    std::size_t logical_size_;
    std::size_t capacity_;
};

三个成员变量分别表示:

elems_
  │
  ▼
[10][20][30][?][?][?][?][?]
             ↑
             logical_size_ = 3

数组总空间 capacity_ = 8
  • elems_ 指向动态数组;
  • logical_size_ 表示真正保存了多少个元素;
  • capacity_ 表示数组一共能容纳多少个元素。

课件中的一个 IntVector 示例旁边写着:

// Implements a sequence of strings
class IntVector {
    // ...
};

这里的 strings 是复制粘贴造成的笔误。这个类保存的是整数,应当写成:

// Implements a sequence of integers

现在用户提出新需求:

我还想保存一组 double

我们可以复制整个类:

#include <cstddef>

class DoubleVector {
public:
    double& at(std::size_t index);
    void push_back(const double& value);

private:
    double* elems_;
    std::size_t logical_size_;
    std::size_t capacity_;
};

继续保存字符串:

#include <cstddef>
#include <string>

class StringVector {
public:
    std::string& at(std::size_t index);
    void push_back(const std::string& value);

private:
    std::string* elems_;
    std::size_t logical_size_;
    std::size_t capacity_;
};

把三个类放在一起观察:

IntVector       DoubleVector       StringVector
    │                │                  │
    ├── at           ├── at             ├── at
    ├── push_back    ├── push_back      ├── push_back
    ├── elems_       ├── elems_         ├── elems_
    ├── size         ├── size           ├── size
    └── capacity     └── capacity       └── capacity

除了元素类型,所有逻辑几乎完全相同。

这会带来几个问题。

第一,代码大量重复。修复一个扩容错误时,必须修改所有版本。

第二,各个版本容易逐渐不一致。例如:

  • IntVector 增加了边界检查;
  • DoubleVector 忘记增加;
  • StringVector 又使用了另一种扩容策略。

第三,我们不可能提前猜到用户会创建什么类型。

struct Player {};
struct CameraRay {};
struct Course {};

难道标准库需要提前提供:

PlayerVector
CameraRayVector
CourseVector

当然不可能。

真正应该复用的不是某一种元素类型,而是“保存一组元素”的算法。


一个关键观察:逻辑没有变化,类型发生了变化

观察下面这段伪代码:

void push_back(const int& value);
int& at(std::size_t index);
int* elems_;

真正与整数有关的部分只有 int

void push_back(const [元素类型]& value);
[元素类型]& at(std::size_t index);
[元素类型]* elems_;

因此我们希望为“元素类型”取一个临时名字,例如 T

void push_back(const T& value);
T& at(std::size_t index);
T* elems_;

这里的 T 不是某个真实类型。

它的意思是:

稍后创建具体容器时,再告诉编译器 T 究竟是什么。

这就是模板的出发点。


第二部分:模板出现之前,人们怎样生成重复代码

预处理宏:在编译之前进行文本替换

在模板出现之前,一种可能的方案是使用预处理宏(preprocessor macro)。

预处理器会在真正的 C++ 编译开始之前处理 #include#define 等指令。

例如:

#define GENERATE_VECTOR(MY_TYPE)                \
class MY_TYPE##Vector {                         \
public:                                         \
    MY_TYPE& at(std::size_t index);             \
    void push_back(const MY_TYPE& value);       \
private:                                        \
    MY_TYPE* elems_;                            \
    std::size_t logical_size_;                  \
    std::size_t capacity_;                      \
};

这里出现了两个比较特殊的符号。

反斜杠 \ 表示:

这条宏定义还没有结束,下一行仍属于同一个宏。

## 是记号拼接运算符,可以把两个词拼成一个新的词。

调用:

GENERATE_VECTOR(int)

预处理器会进行近似这样的文本替换:

class intVector {
public:
    int& at(std::size_t index);
    void push_back(const int& value);

private:
    int* elems_;
    std::size_t logical_size_;
    std::size_t capacity_;
};

接下来才能写:

intVector values;
values.push_back(5);

从“根据类型生成代码”的角度看,这已经有一点模板的影子了。


宏为什么不是一个理想方案

宏只是在处理文本。它并不真正理解:

  • 什么是类型;
  • 什么是类;
  • 什么是函数;
  • 什么是作用域;
  • 什么是合法的 C++ 表达式。

这会带来许多问题。

问题一:语法笨重

每一行都要加 \

#define GENERATE_VECTOR(MY_TYPE) \
class MY_TYPE##Vector {          \
    /* ... */                    \
};

漏掉一个反斜杠,宏就会提前结束。

问题二:错误信息难以理解

编译器看到的通常是宏展开后的代码,而不是程序员最初写的宏调用。

发生错误时,错误信息可能指向一大段生成代码。

问题三:宏不会进行可靠的类型检查

模板属于 C++ 类型系统,而宏只是文本替换。宏在展开前并不知道传入的内容是不是一个适合的类型。

问题四:可能忘记生成某个版本

如果忘记写:

GENERATE_VECTOR(double)

那么 doubleVector 根本不存在。

问题五:可能重复生成

如果同一个宏在同一作用域展开多次,可能出现类的重复定义。

问题六:复杂类型很难拼接成类名

例如:

GENERATE_VECTOR(std::string)

宏尝试把 std::stringVector 拼接成一个记号,但带有 :: 的类型并不能自然地拼成合法类名。

而模板不需要根据元素类型修改类名:

Vector<std::string>

这正是模板语法的重要优势。


练习:手动展开宏

观察:

#define MAKE_BOX(TYPE)       \
class TYPE##Box {            \
public:                      \
    TYPE value;              \
};

MAKE_BOX(int)

预处理后大致会生成什么代码?


答案与解释

TYPE 会被替换成 intTYPE##Box 会拼成 intBox

class intBox {
public:
    int value;
};

这里并没有发生“类型推导”。预处理器只是把文本替换并拼接。

初学者容易把宏理解为一种函数调用,但宏甚至不属于正常的 C++ 函数系统。它发生在编译器分析类型之前。


第三部分:把类型本身变成参数

类模板的基本声明

C++ 提供类模板(class template),允许我们把类型作为参数:

template <typename T>
class Vector {
public:
    T& at(std::size_t index);
    void push_back(const T& value);

private:
    T* elems_;
    std::size_t logical_size_;
    std::size_t capacity_;
};

先拆开第一行:

template <typename T>

它告诉编译器:

接下来声明的是一个模板。这个模板接收一个类型参数,参数名叫 T

其中:

  • template 表示开始声明模板;
  • <...> 中放模板参数;
  • typename 表示接下来的参数代表一种类型;
  • T 是我们为这个未知类型取的名字。

T 只是惯例,也可以写成:

template <typename ElementType>
class Vector {
public:
    ElementType& at(std::size_t index);
};

较长的名字有时更容易理解。不过,标准库和许多教材习惯使用 T


T 在类中表示什么

在模板内部,T 可以出现在正常类型能够出现的位置:

T value;
T* pointer;
T& reference;
const T& const_reference;

因此:

T& at(std::size_t index);

表示返回某个 T 对象的引用。

void push_back(const T& value);

表示参数是对一个不可修改的 T 对象的引用。

T* elems_;

表示 elems_ 指向一组 T 对象。

此时我们还没有决定 T 是什么。


从模板创建具体类型:模板实例化

模板实例化(template instantiation)是指为模板提供具体参数,得到一个具体类型。

Vector<int> int_vector;
Vector<double> double_vector;
Vector<std::string> string_vector;

对每一行分别理解:

Vector<int>

表示把模板参数 T 替换成 int

Vector<double>

表示把 T 替换成 double

Vector<std::string>

表示把 T 替换成 std::string

还可以嵌套:

Vector<Vector<int>> matrix;

最里面的:

Vector<int>

本身已经是一个具体类型。

因此外层相当于:

Vector<某种类型>
       │
       └── 这种类型恰好是 Vector<int>

也可以使用自定义类型:

struct Player {
    int score;
};

Vector<Player> players;

我们不需要提前编写 PlayerVector


编译器可以怎样理解实例化

对于初学阶段,可以把:

Vector<int> values;

理解成编译器根据模板生成了一个类似这样的具体类:

class VectorOfInt {
public:
    int& at(std::size_t index);
    void push_back(const int& value);

private:
    int* elems_;
    std::size_t logical_size_;
    std::size_t capacity_;
};

而:

Vector<std::string> words;

可以理解成生成了:

class VectorOfString {
public:
    std::string& at(std::size_t index);
    void push_back(const std::string& value);

private:
    std::string* elems_;
    std::size_t logical_size_;
    std::size_t capacity_;
};

这种理解有助于建立直觉:

模板自动完成了根据类型生成代码的工作。

更准确地说,编译器会对模板进行实例化,形成特定的模板特化。它不一定会立刻生成模板中的每一个成员函数;很多实现会在某个成员真正需要时才实例化它。

初学阶段先记住:

模板 + 具体模板参数
          ↓
一个具体类型

就足够了。


工厂类比:成立在哪里,又不完全成立在哪里

课件把模板比作工厂:

                  int
                   │
                   ▼
template Vector ───────→ Vector<int>

                string
                   │
                   ▼
template Vector ───────→ Vector<string>

这个类比成立的地方是:

  • 同一个模板可以接收不同参数;
  • 不同参数会得到不同的具体类型;
  • 公共逻辑只需要写一次。

它不完全准确的地方是:

  • 模板不是运行时工厂;
  • 程序运行时不会把 int 送进一台机器再动态创建类;
  • 模板处理主要发生在编译阶段。

因此,更正式的理解是:

类模板是编译期生成一组相关类型的规则。


模板本身不是类型

下面这段代码:

template <typename T>
class Vector {
    // ...
};

声明的是模板 Vector

仅仅写:

Vector value;

通常不够,因为编译器不知道这里的 T 是什么。

而:

Vector<int>

是一个具体类型。

可以这样对比:

Vector                  一个类模板
Vector<int>             一个具体类型
Vector<double>          另一个具体类型
Vector<std::string>     又一个具体类型

类似于:

“制作盒子的设计规则”    不是一个具体盒子
“整数盒子”              是一种具体盒子
“字符串盒子”            是另一种具体盒子

同一个模板的不同实例是不同类型

下面两个类型来自同一个模板:

Vector<int>
Vector<double>

但它们是彼此不同的类型。

因此下面的调用不合法:

#include <vector>

void process(std::vector<int> values) {
}

int main() {
    std::vector<double> values;
    process(values);
}

process 需要:

std::vector<int>

传入的却是:

std::vector<double>

单个 double 有时可以转换为 int,但这不表示整个:

std::vector<double>

会自动转换成:

std::vector<int>

它们有各自独立的元素类型、成员函数和对象表示。

课件还拿 Java 作了比较。这里需要修正一个语法细节:Java 泛型不能直接写 ArrayList<int>,通常应写成 ArrayList<Integer>。Java 泛型通常使用类型擦除,因此不同泛型参数经常共享同一个运行时类;C++ 模板实例则是不同的 C++ 类型。


练习:判断类型是否相同

观察:

Vector<int> a;
Vector<int> b;
Vector<double> c;
Vector<Vector<int>> d;

判断下面说法是否正确。

  1. ab 的类型相同。
  2. ac 的类型相同,因为它们都来自 Vector
  3. d 的元素类型是 int
  4. d 的元素类型是 Vector<int>

答案与解释

第 1 项正确。

Vector<int> a;
Vector<int> b;

两者都是 Vector<int>

第 2 项错误。

Vector<int>
Vector<double>

是两个不同类型。模板相同,不代表实例化结果相同。

第 3 项错误,第 4 项正确。

Vector<Vector<int>>

外层 Vector 的模板参数是:

Vector<int>

因此它保存的每个元素都是一个 Vector<int> 对象。

结构可以画成:

d
│
├── Vector<int>
│   ├── 1
│   └── 2
│
├── Vector<int>
│   ├── 3
│   └── 4
│
└── Vector<int>
    ├── 5
    └── 6

第四部分:模板参数不一定是类型

非类型模板参数

到目前为止,模板参数 T 代表一种类型:

template <typename T>
class Box {
};

模板参数也可以是一个编译期值。这种参数叫作非类型模板参数(non-type template parameter)。

例如:

#include <cstddef>

template <std::size_t N>
class FixedSizeObject {
};

使用时:

FixedSizeObject<5> object;

这里的模板参数不是一种类型,而是数值 5

也可以使用布尔值:

template <bool Enabled>
class Feature {
};

Feature<true> enabled_feature;
Feature<false> disabled_feature;

Feature<true>Feature<false> 是不同类型。


std::array<T, N> 为什么有两个模板参数

标准库的 std::array 可以近似理解成:

template <typename T, std::size_t N>
class array {
    // ...
};

它同时接收:

  • 元素类型 T
  • 元素数量 N

例如:

#include <array>
#include <string>

std::array<std::string, 5> names;

它表示一个恰好容纳五个字符串的数组。

数组长度是类型的一部分:

std::array<int, 3>
std::array<int, 4>

是两个不同类型。

因此:

std::array<int, 3> a{};
std::array<int, 4> b{};

// a = b;  // 编译错误:类型不同

std::array 与动态分配的准确关系

课件把 std::array 的优势描述为:

它避免堆分配,因此可以放在栈上。

这个说法表达了一个有用直觉,但需要补充几个限制。

更准确地说:

std::array<T, N>N 个元素直接保存在对象内部,std::array 自己不需要为了保存这些元素再进行一次动态内存分配。

例如:

std::array<int, 5> values;

对象可以近似理解为:

values
┌────┬────┬────┬────┬────┐
│int │int │int │int │int │
└────┴────┴────┴────┴────┘

而一个典型的 std::vector<int> 对象更像:

vector 对象
┌──────────┬──────┬──────────┐
│ 指针     │ 大小 │ 容量     │
└────┬─────┴──────┴──────────┘
     │
     ▼
动态分配的元素区域
[10][20][30]

不过,“std::array 一定在栈上”并不准确。

对象放在哪里,取决于它怎样创建:

void example() {
    std::array<int, 5> local;  // 通常是自动存储期
}

也可以动态创建:

auto* pointer = new std::array<int, 5>;
delete pointer;

还可以作为其他对象的一部分。

另外:

std::array<std::string, 5>

虽然五个 std::string 对象直接存放在 std::array 内部,但每个字符串内部仍可能为了保存字符而动态分配内存。

因此应当记住:

std::array 直接保存固定数量的元素,但这不保证元素类型自身绝不分配内存。


练习:判断能否赋值

下面的代码能否编译?

#include <array>

int main() {
    std::array<int, 3> first{1, 2, 3};
    std::array<int, 3> second{4, 5, 6};
    std::array<int, 4> third{7, 8, 9, 10};

    first = second;
    first = third;
}

答案与解释

下面这行合法:

first = second;

因为两边类型都是:

std::array<int, 3>

下面这行不合法:

first = third;

因为:

std::array<int, 3>

和:

std::array<int, 4>

是不同类型。

虽然元素类型都是 int,但数组长度也是模板参数,也是类型的一部分。


第五部分:在类外实现模板成员函数

普通类的类外实现

对于普通类,可以在头文件中声明:

// Point.h

class Point {
public:
    int getX() const;

private:
    int x_;
};

再在源文件中实现:

// Point.cpp

#include "Point.h"

int Point::getX() const {
    return x_;
}

这里:

Point::getX

表示 Point 类作用域中的 getX


模板类为什么多出一些语法

假设头文件中声明:

template <typename T>
class Vector {
public:
    T& at(std::size_t index);
};

初学者很容易尝试这样实现:

T& Vector::at(std::size_t index) {
    // ...
}

这里有两个问题。

第一个问题是,当前实现外面没有声明 T

编译器看到:

T&

会问:

T 是谁?

所以必须重新写模板参数声明:

template <typename T>

第二个问题是,Vector 本身是模板,不是具体类型。

在当前实现中,我们正在描述的是:

Vector<T>

因此完整写法是:

template <typename T>
T& Vector<T>::at(std::size_t index) {
    // ...
}

可以按下面的顺序阅读:

template <typename T>
        │
        └── 接下来仍然在定义一个以 T 为参数的模板

T&
│
└── 返回 T 对象的引用

Vector<T>::at
│
└── Vector<T> 类型中的 at 成员函数

练习:修复模板成员函数

类声明如下:

template <typename T>
class Box {
public:
    T& value();

private:
    T value_;
};

下面的实现为什么不正确?

T& Box::value() {
    return value_;
}

请写出正确实现。


答案与解释

原实现有两个问题:

  • 没有重新声明模板参数 T
  • Box 是模板,具体类型应写成 Box<T>

正确代码是:

template <typename T>
T& Box<T>::value() {
    return value_;
}

第一行让 T 在这段定义中成为有效的模板参数。

第二行中的:

Box<T>

表示当前正在定义的具体模板实例形式。


为什么模板实现通常必须放在头文件中

普通类常用这种文件结构:

Point.h
Point.cpp
main.cpp

编译命令是:

g++ -std=c++20 main.cpp Point.cpp -o main

模板有一个额外需求。

假设 main.cpp 中写:

Vector<int> values;

编译器此时需要根据 Vector 的完整定义实例化 Vector<int>

如果编译器只能看到成员函数声明:

T& at(std::size_t index);

却看不到函数实现,它就无法生成对应的 Vector<int>::at

因此,模板定义通常必须在使用模板的位置可见。

常见写法有两种。

第一种是直接把所有实现写在头文件中。

第二种是把实现放在 .tpp.ipp 文件中,然后在头文件末尾包含它:

// Vector.h

template <typename T>
class Vector {
    // ...
};

#include "Vector.tpp"

课件使用的是:

#include "Vector.cpp"

这种方法可以工作,但容易产生误解。

.cpp 通常意味着“应该被单独编译的源文件”。模板实现片段一般不会单独编译,因此使用 .tpp.ipp 更能表达它的用途。

推荐结构:

SimpleVector.h
SimpleVector.tpp
main.cpp

其中:

// SimpleVector.h 的末尾
#include "SimpleVector.tpp"

编译时只需要:

g++ -std=c++20 main.cpp -o main

不要再把 SimpleVector.tpp 单独加入编译命令,因为它已经被头文件包含。

还有一种更高级的方法叫显式实例化,可以把部分模板实现放在 .cpp 中,但需要提前列出要支持的具体类型。本节暂时不展开。


typenameclass 在模板参数列表中的关系

下面两个声明等价:

template <typename T>
class Vector {
};
template <class T>
class Vector {
};

在模板参数列表中,typenameclass 都可以表示:

这个模板参数是一种类型。

多个参数也可以混用:

template <typename Key, typename Value>
struct Pair {
};
template <class Key, class Value>
struct Pair {
};
template <class Key, typename Value>
struct Pair {
};

这三种写法在这里含义相同。

不过,“typenameclass 永远完全相同”是不准确的。typename 在模板的其他位置还有额外作用。

本节只需要记住:

在模板类型参数列表中,typename Tclass T 等价。

为了让代码更容易阅读,下面统一使用 typename


第六部分:实现一个可以运行的 SimpleVector<T>

下面实现一个教学用途的动态数组类。

它并不是标准库 std::vector 的完整替代品,而是为了观察:

  • 类型参数怎样进入成员变量和成员函数;
  • 模板类怎样分文件;
  • 对象如何创建和销毁;
  • const 接口怎样设计;
  • 返回引用为什么需要区分可修改和不可修改版本。

这个教学版本有几个有意保留的限制:

  • T 必须能够默认构造;
  • T 必须能够复制赋值;
  • 使用 findElement 时,T 必须支持 ==
  • 为了避免错误的浅复制,暂时禁止复制整个 SimpleVector
  • 没有实现移动语义、迭代器和插入删除等完整容器功能。

这些限制不代表标准库 std::vector 也有同样限制。


SimpleVector.h

#ifndef SIMPLE_VECTOR_H
#define SIMPLE_VECTOR_H

#include <cstddef>
#include <stdexcept>

template <typename T>
class SimpleVector {
public:
    SimpleVector();
    ~SimpleVector();

    SimpleVector(const SimpleVector&) = delete;
    SimpleVector& operator=(const SimpleVector&) = delete;

    std::size_t size() const;
    bool empty() const;

    void push_back(const T& value);

    T& at(std::size_t index);
    const T& at(std::size_t index) const;

    T& operator[](std::size_t index);
    const T& operator[](std::size_t index) const;

    T& findElement(const T& value);
    const T& findElement(const T& value) const;

private:
    void grow();
    std::size_t findIndex(const T& value) const;

    T* elems_;
    std::size_t logical_size_;
    std::size_t capacity_;
};

#include "SimpleVector.tpp"

#endif

这部分主要描述类的接口。

template <typename T>
class SimpleVector

说明 SimpleVector 是一个接收类型参数 T 的类模板。

成员:

T* elems_;

会根据实例化类型改变:

SimpleVector<int>          int* elems_
SimpleVector<double>       double* elems_
SimpleVector<string>       string* elems_

下面两行禁止复制整个容器:

SimpleVector(const SimpleVector&) = delete;
SimpleVector& operator=(const SimpleVector&) = delete;

因为这个类内部拥有动态数组。如果直接使用编译器生成的默认复制行为,只会复制指针:

原对象.elems_ ─────┐
                   ├──→ 同一块动态数组
复制对象.elems_ ───┘

两个对象析构时会试图释放同一块内存,产生严重错误。

完整容器应当实现深复制或移动语义。为了不让复制控制抢走本节重点,这里明确禁止复制。


SimpleVector.tpp

template <typename T>
SimpleVector<T>::SimpleVector()
    : elems_(new T[4]), logical_size_(0), capacity_(4) {
}

template <typename T>
SimpleVector<T>::~SimpleVector() {
    delete[] elems_;
}

template <typename T>
std::size_t SimpleVector<T>::size() const {
    return logical_size_;
}

template <typename T>
bool SimpleVector<T>::empty() const {
    return logical_size_ == 0;
}

template <typename T>
void SimpleVector<T>::grow() {
    const std::size_t new_capacity = capacity_ * 2;
    T* new_elems = new T[new_capacity];

    try {
        for (std::size_t i = 0; i < logical_size_; ++i) {
            new_elems[i] = elems_[i];
        }
    } catch (...) {
        delete[] new_elems;
        throw;
    }

    delete[] elems_;
    elems_ = new_elems;
    capacity_ = new_capacity;
}

template <typename T>
void SimpleVector<T>::push_back(const T& value) {
    if (logical_size_ == capacity_) {
        grow();
    }

    elems_[logical_size_] = value;
    ++logical_size_;
}

template <typename T>
T& SimpleVector<T>::at(std::size_t index) {
    if (index >= logical_size_) {
        throw std::out_of_range(
            "SimpleVector::at index out of range"
        );
    }

    return elems_[index];
}

template <typename T>
const T& SimpleVector<T>::at(std::size_t index) const {
    if (index >= logical_size_) {
        throw std::out_of_range(
            "SimpleVector::at index out of range"
        );
    }

    return elems_[index];
}

template <typename T>
T& SimpleVector<T>::operator[](std::size_t index) {
    return elems_[index];
}

template <typename T>
const T& SimpleVector<T>::operator[](std::size_t index) const {
    return elems_[index];
}

template <typename T>
std::size_t SimpleVector<T>::findIndex(const T& value) const {
    for (std::size_t i = 0; i < logical_size_; ++i) {
        if (elems_[i] == value) {
            return i;
        }
    }

    throw std::out_of_range(
        "SimpleVector::findElement value not found"
    );
}

template <typename T>
T& SimpleVector<T>::findElement(const T& value) {
    return elems_[findIndex(value)];
}

template <typename T>
const T& SimpleVector<T>::findElement(const T& value) const {
    return elems_[findIndex(value)];
}

main.cpp

#include <iostream>
#include <string>

#include "SimpleVector.h"

void printIntVector(const SimpleVector<int>& values) {
    for (std::size_t i = 0; i < values.size(); ++i) {
        std::cout << values.at(i) << ' ';
    }

    std::cout << '\n';
}

int main() {
    SimpleVector<int> numbers;

    numbers.push_back(10);
    numbers.push_back(20);
    numbers.push_back(30);

    printIntVector(numbers);

    numbers.at(1) = 99;
    printIntVector(numbers);

    const SimpleVector<int>& read_only_numbers = numbers;

    std::cout
        << "found: "
        << read_only_numbers.findElement(99)
        << '\n';

    SimpleVector<std::string> words;

    words.push_back("class");
    words.push_back("template");

    for (std::size_t i = 0; i < words.size(); ++i) {
        std::cout << words.at(i) << ' ';
    }

    std::cout << '\n';
}

编译:

g++ -std=c++20 -Wall -Wextra -pedantic main.cpp -o main

运行:

./main

输出:

10 20 30
10 99 30
found: 99
class template

程序怎样一步步运行

第一步:创建 numbers

执行:

SimpleVector<int> numbers;

模板参数是:

T = int

因此成员可以理解成:

int* elems_;
std::size_t logical_size_;
std::size_t capacity_;

调用构造函数:

SimpleVector<int>::SimpleVector()
    : elems_(new int[4]),
      logical_size_(0),
      capacity_(4) {
}

构造完成后:

numbers
┌────────────────────────┐
│ elems_ ─────────────────────→ [?][?][?][?]
│ logical_size_ = 0      │
│ capacity_ = 4          │
└────────────────────────┘

这里 ? 表示这些整数位置还没有被程序赋予有意义的值。

new int[4] 创建了四个 int 位置,但默认初始化的普通整数值是不确定的。程序不能读取尚未写入的元素。


第二步:添加 10

执行:

numbers.push_back(10);

参数类型是:

const int&

函数中的 value 引用传入的整数值,不会为了传参再创建一个新的 int 副本。

进入函数时:

logical_size_ = 0
capacity_ = 4

条件:

logical_size_ == capacity_

结果为:

0 == 4  → false

所以不扩容。

接着执行:

elems_[logical_size_] = value;

也就是:

elems_[0] = 10;

然后:

++logical_size_;

状态变成:

[10][?][?][?]
 ↑
 logical_size_ = 1

第三步:继续添加元素

执行:

numbers.push_back(20);
numbers.push_back(30);

最终状态:

numbers
┌────────────────────────┐
│ elems_ ─────────────────────→ [10][20][30][?]
│ logical_size_ = 3      │
│ capacity_ = 4          │
└────────────────────────┘

此时没有扩容。

当以后添加第五个元素时:

logical_size_ = 4
capacity_ = 4

条件成立,调用 grow()

扩容过程可以表示为:

扩容之前:

elems_
  ↓
[10][20][30][40]

新建容量为 8 的数组:

new_elems
  ↓
[?][?][?][?][?][?][?][?]

复制已有元素:

new_elems
  ↓
[10][20][30][40][?][?][?][?]

释放旧数组:

delete[] elems_

更新指针:

elems_ = new_elems
capacity_ = 8

trycatch (...) 用于处理元素复制过程中可能抛出的异常。

如果复制某个 T 对象失败,就先释放临时数组,然后继续把异常交给调用者,避免泄漏 new_elems

这里先理解它是“失败时清理临时资源”即可,异常安全不是本节重点。


第四步:只读打印,不复制容器

调用:

printIntVector(numbers);

函数参数:

const SimpleVector<int>& values

其中:

  • & 表示引用,因此不会复制整个 numbers
  • const 表示函数承诺不通过 values 修改 numbers

指向关系:

numbers
┌────────────────────┐
│ SimpleVector<int>  │
└─────────▲──────────┘
          │
          │ 引用同一个对象
          │
values ───┘

没有创建第二个 SimpleVector<int>

循环依次调用:

values.size()
values.at(i)

由于 values 是对 const 对象的引用,只能调用类的只读接口。这个问题会在后面详细分析。


第五步:通过引用修改元素

执行:

numbers.at(1) = 99;

numbers 不是 const 对象,因此选择:

T& at(std::size_t index);

T = int 时,返回类型是:

int&

函数返回:

elems_[1]

的引用。

因此整个表达式相当于:

elems_[1] = 99;

状态变化:

修改前:

[10][20][30][?]

修改后:

[10][99][30][?]

这里没有替换整个容器,也没有修改 at 返回的某个副本。

at 返回的是原始元素的引用。


第六步:创建只读引用

执行:

const SimpleVector<int>& read_only_numbers = numbers;

这不会复制对象。

两个名字指向同一个容器:

numbers ───────────────┐
                       ▼
                 [同一个对象]
                       ▲
read_only_numbers ─────┘

区别是:

  • 通过 numbers 可以调用可修改接口;
  • 通过 read_only_numbers 只能调用 const 接口。

所以可以:

read_only_numbers.findElement(99);

但不能:

// read_only_numbers.push_back(40);

第七步:创建字符串版本

执行:

SimpleVector<std::string> words;

这一次:

T = std::string

所以:

T* elems_;

变成:

std::string* elems_;

push_back 变成近似:

void push_back(const std::string& value);

我们没有重新编写字符串版本的扩容、下标访问和查找逻辑。

这就是类模板解决的核心问题:

保留算法,只替换类型。


第八步:对象销毁

main 结束时,局部对象按照创建的相反顺序销毁。

先销毁:

words

再销毁:

numbers

每次都会调用对应实例的析构函数:

delete[] elems_;

其中:

SimpleVector<std::string>

释放数组时,也会逐个调用数组中 std::string 对象的析构函数。

标准库字符串拥有的内部资源会自动清理,不需要我们手动释放每个字符串中的字符。


第七部分:为什么 const Vector<int>& 调不了普通成员函数

问题从一个打印函数开始

我们希望写一个只读取容器的函数:

void printVector(const SimpleVector<int>& values) {
    for (std::size_t i = 0; i < values.size(); ++i) {
        std::cout << values.at(i) << ' ';
    }
}

这里使用:

const SimpleVector<int>&

有两个目的。

第一,引用避免复制容器。

第二,const 保证打印函数不会修改容器。

但假设类中只有:

template <typename T>
class SimpleVector {
public:
    std::size_t size();
    T& at(std::size_t index);
};

编译器会拒绝:

values.size();
values.at(i);

初学者可能会疑惑:

类里不是明明有 size()at() 吗?

问题不在于函数名不存在,而在于它们不是 const 成员函数。


编译器为什么不能自己相信 size() 不修改对象

观察:

std::size_t size() {
    return logical_size_;
}

从函数实现看,它似乎只是读取数据。

但函数声明:

std::size_t size();

没有向调用者保证“不修改对象”。

普通成员函数能够访问私有成员:

std::size_t size() {
    logical_size_ = 1000;
    return logical_size_;
}

如果允许 const 对象随意调用普通成员函数,那么 const 承诺就失去了意义。

因此 C++ 采用的规则是:

const 对象只能调用明确标记为 const 的成员函数。


在成员函数末尾添加 const

只读成员函数应写成:

std::size_t size() const;

实现也必须带相同的 const

template <typename T>
std::size_t SimpleVector<T>::size() const {
    return logical_size_;
}

声明和定义必须匹配。

下面两者是不同的成员函数类型:

std::size_t size();
std::size_t size() const;

如果头文件声明:

std::size_t size() const;

实现却写:

std::size_t SimpleVector<T>::size() {
    return logical_size_;
}

编译器会认为你实现了另一个没有声明的函数,而不是实现头文件中的 const 版本。


三种不同位置的 const

初学者容易把下面几种写法混在一起:

const SimpleVector<int>& values
const T& at(std::size_t index) const;

把第二行拆开:

const T&        at(...)        const
   │                              │
   │                              └── 当前对象不能通过该函数被普通方式修改
   │
   └── 返回的是对不可修改 T 对象的引用

而:

const SimpleVector<int>& values

表示参数是对不可修改 SimpleVector<int> 对象的引用。

三个 const 分别约束不同东西:

写法 约束的对象
const Vector<int>& values 函数参数所引用的容器
size() const 调用该成员函数的当前对象
const T& 函数返回的元素引用

它们不能互相替代。


第八部分:成员函数中的 this 到底是什么

每个非静态成员函数都知道“当前对象”

观察:

class Point {
public:
    void setX(int x) {
        x_ = x;
    }

private:
    int x_;
};

调用:

Point point;
point.setX(10);

setX 内部,程序必须知道当前修改的是 point.x_

C++ 通过隐藏指针 this 表示当前对象。

函数中的:

x_ = x;

可以写成:

this->x_ = x;

对于普通成员函数,this 的类型可以理解为:

Point*

它指向当前对象。


const 成员函数中的 this

正确的 getter 可以写成:

class Point {
public:
    int getX() const {
        return this->x_;
    }

private:
    int x_;
};

课件示意代码把 getter 写成了:

int Point::getX(int x) const

getter 并不需要接收一个新的 x,这里的参数属于笔误。更合理的声明是:

int Point::getX() const;

const 成员函数中,this 的类型可以理解为:

const Point*

因此:

this->x_ = 42;

会被拒绝。

同样,在:

template <typename T>
std::size_t SimpleVector<T>::size() const {
    return logical_size_;
}

中,this 的类型是:

const SimpleVector<T>*

所以不能执行:

logical_size_ = 106;

编译器会阻止修改普通成员。


const 成员函数的正式含义

可以先用下面的直觉理解:

末尾的 const 是成员函数对编译器作出的承诺:这个函数不会通过当前对象修改它的普通成员状态。

例如:

std::size_t size() const;
bool empty() const;

都属于只读查询。

而:

void push_back(const T& value);

会改变:

  • 元素数组;
  • logical_size_
  • 有时还会改变 capacity_

因此它不能是 const 成员函数。


练习:哪些调用合法

观察:

class Counter {
public:
    int value() const {
        return value_;
    }

    void increment() {
        ++value_;
    }

private:
    int value_ = 0;
};

下面哪些调用合法?

Counter first;
first.value();
first.increment();

const Counter second;
second.value();
second.increment();

答案与解释

以下调用合法:

first.value();
first.increment();

first 是非 const 对象。非 const 对象既可以调用普通成员函数,也可以调用 const 成员函数。

下面也合法:

second.value();

secondconst 对象,而 value()const 成员函数。

下面不合法:

second.increment();

increment() 不是 const 成员函数,而且它确实会修改 value_

调用能力可以总结为:

对象 普通成员函数 const 成员函数
const 对象 可以 可以
const 对象 不可以 可以

第九部分:什么是类的 const 接口

一个类中所有末尾带 const 的成员函数,共同组成它的 const 接口。

例如:

template <typename T>
class SimpleVector {
public:
    std::size_t size() const;
    bool empty() const;

    void push_back(const T& value);
};

对于非 const 对象:

SimpleVector<int> values;

可以调用:

values.size();
values.empty();
values.push_back(10);

对于 const 对象或 const 引用:

const SimpleVector<int>& read_only = values;

只能调用:

read_only.size();
read_only.empty();

不能调用:

// read_only.push_back(10);

这就是“只读视图”的价值。

同一个实际对象可以通过不同访问路径表现出不同权限:

values
  │
  ├── 非 const 名字
  │      ├── size()
  │      ├── empty()
  │      └── push_back()
  │
  └── const 引用 read_only
         ├── size()
         └── empty()

一个重要纠正:const 对象不是简单地把所有成员类型改写一遍

课件中的示意图把:

std::size_t logical_size_;
T* elems_;

近似画成:

const std::size_t logical_size_;
const T* elems_;

这张图想表达的是:

通过 const 对象不能随意修改其状态。

作为直觉,它有帮助,但并不是严格的语言规则,尤其是指针成员部分。

假设:

struct PointerHolder {
    int* pointer;

    void changePointee() const {
        *pointer = 42;
    }
};

这个函数可能通过编译。

const 成员函数中:

  • 不能让成员指针改为指向别处;
  • 但指针所指向的对象不会自动变成 const

也就是:

pointer = another_pointer;  // 不允许
*pointer = 42;              // 可能允许

这叫作浅层 const(shallow const)。

因此,对于拥有指针成员的容器,仅仅把成员函数标成 const,并不足以自动保护指针指向的元素。

这正是接下来 at() 返回类型问题的来源。


第十部分:为什么 T& at(...) const 仍然破坏只读性

第一次尝试:只在函数末尾加 const

为了让 const 容器可以访问元素,我们可能写:

template <typename T>
class Vector {
public:
    T& at(std::size_t index) const;
};

函数末尾确实有 const

于是下面的读取能够通过:

void printVector(const Vector<int>& values) {
    std::cout << values.at(0);
}

但它留下了一个严重漏洞:

void modifyByAccident(const Vector<int>& values) {
    values.at(0) = 42;
}

参数明明是:

const Vector<int>&

却修改了元素。

原因是:

T& at(...) const;

返回的是:

T&

也就是可修改引用。

调用链可以画成:

values 是 const Vector<int>&
          │
          ▼
调用 const 成员函数 at()
          │
          ▼
返回 int&
          │
          ▼
通过 int& 修改原元素

函数本身虽然没有直接写:

elems_[0] = 42;

但它把一个可修改引用交给了调用者。

因此,const 接口不仅要检查函数内部,还要检查函数返回了什么能力。


第二次尝试:永远返回 const T&

我们可以改成:

const T& at(std::size_t index) const;

这样:

const Vector<int>& values

调用 at() 时只能得到:

const int&

下面的修改会被拒绝:

// values.at(0) = 42;

但新的问题出现了。

对于普通的非 const 容器:

Vector<int> values;

我们希望允许:

values.at(0) = 42;

如果类只有:

const T& at(std::size_t index) const;

即使 values 本身可以修改,返回的仍然是 const T&,所以不能给元素赋值。

我们需要同时满足两种需求:

const 容器
    └── 只能得到 const T&

非 const 容器
    └── 可以得到 T&

一个函数签名无法同时表达这两种返回权限。


第十一部分:const 重载

为同一个操作提供两个版本

C++ 允许成员函数仅通过末尾的 const 形成重载:

template <typename T>
class Vector {
public:
    T& at(std::size_t index);
    const T& at(std::size_t index) const;
};

这两个函数名字和参数列表相同,但当前对象的 const 性质不同。

const 版本:

T& at(std::size_t index);

this 的类型是:

Vector<T>*

返回:

T&

const 版本:

const T& at(std::size_t index) const;

this 的类型是:

const Vector<T>*

返回:

const T&

这种设计叫作 const 重载(const overloading)。


编译器怎样选择版本

对于:

Vector<int> values;

调用:

values.at(0);

会优先选择非 const 版本:

int& at(std::size_t index);

所以可以:

values.at(0) = 42;

对于:

const Vector<int>& read_only = values;

调用:

read_only.at(0);

只能选择:

const int& at(std::size_t index) const;

因此不能:

// read_only.at(0) = 42;

状态关系如下:

非 const 对象 values
        │
        ▼
T& at(index)
        │
        ▼
得到可修改引用

const 对象 read_only
        │
        ▼
const T& at(index) const
        │
        ▼
得到只读引用

两个版本的实现

template <typename T>
T& Vector<T>::at(std::size_t index) {
    return elems_[index];
}
template <typename T>
const T& Vector<T>::at(std::size_t index) const {
    return elems_[index];
}

虽然函数体相同:

return elems_[index];

但返回权限不同。

第一个返回:

T&

第二个返回:

const T&

这不是无意义的重复。它让对象的可修改性自然传递到返回元素。

标准库中大量接口都采用这种模式,包括:

  • std::vector::operator[]
  • std::vector::at
  • std::string::operator[]
  • 许多容器的 front()back()

operator[] 也应当成对重载

如果类提供:

T& operator[](std::size_t index);

通常也应提供:

const T& operator[](std::size_t index) const;

否则:

const Vector<int>& values

可能无法使用:

values[0];

或者设计者为了让它可用而错误地让 const 版本返回 T&

正确接口通常是:

template <typename T>
class Vector {
public:
    T& at(std::size_t index);
    const T& at(std::size_t index) const;

    T& operator[](std::size_t index);
    const T& operator[](std::size_t index) const;
};

at()operator[] 的主要区别通常是:

  • at() 检查下标,越界时抛出异常;
  • operator[] 通常不检查下标;
  • 越界使用 operator[] 会产生未定义行为。

练习:预测重载选择

观察完整程序:

#include <iostream>

class Box {
public:
    int& value() {
        std::cout << "non-const version\n";
        return value_;
    }

    const int& value() const {
        std::cout << "const version\n";
        return value_;
    }

private:
    int value_ = 10;
};

int main() {
    Box box;
    const Box& read_only_box = box;

    box.value() = 20;
    std::cout << box.value() << '\n';
    std::cout << read_only_box.value() << '\n';
}

预测输出,并说明每次调用选择哪个版本。


答案与解释

第一次:

box.value() = 20;

box 是非 const 对象,因此选择:

int& value();

先输出:

non-const version

返回 value_ 的可修改引用,然后把 value_ 改为 20

第二次:

std::cout << box.value() << '\n';

box 仍然是非 const 对象,所以还是选择非 const 版本,输出:

non-const version
20

第三次:

std::cout << read_only_box.value() << '\n';

read_only_boxconst Box&,只能选择:

const int& value() const;

输出:

const version
20

完整输出为:

non-const version
non-const version
20
const version
20

read_only_boxbox 引用的是同一个对象,因此读取到的仍然是已经被改成 20 的值。


第十二部分:findElement 为什么会出现重复代码

两个正确但重复的版本

现在为容器增加查找功能:

template <typename T>
class Vector {
public:
    T& findElement(const T& value);
    const T& findElement(const T& value) const;
};

直观实现是:

template <typename T>
T& Vector<T>::findElement(const T& value) {
    for (std::size_t i = 0; i < logical_size_; ++i) {
        if (elems_[i] == value) {
            return elems_[i];
        }
    }

    throw std::out_of_range("Element not found");
}

以及:

template <typename T>
const T& Vector<T>::findElement(const T& value) const {
    for (std::size_t i = 0; i < logical_size_; ++i) {
        if (elems_[i] == value) {
            return elems_[i];
        }
    }

    throw std::out_of_range("Element not found");
}

两个版本只有返回类型和末尾 const 不同,查找循环完全一样。

课件代码中写成了:

if (elems_[i] == elem)

但函数参数名是:

value

elem 并没有定义,这是一个名称不一致的笔误。

正确条件应为:

if (elems_[i] == value)

更安全的消除重复方式:提取公共查找逻辑

可以让公共函数只查找下标:

template <typename T>
std::size_t Vector<T>::findIndex(const T& value) const {
    for (std::size_t i = 0; i < logical_size_; ++i) {
        if (elems_[i] == value) {
            return i;
        }
    }

    throw std::out_of_range("Element not found");
}

然后两个接口分别根据下标返回不同权限的引用:

template <typename T>
T& Vector<T>::findElement(const T& value) {
    return elems_[findIndex(value)];
}
template <typename T>
const T& Vector<T>::findElement(const T& value) const {
    return elems_[findIndex(value)];
}

这正是前面完整 SimpleVector 采用的设计。

公共逻辑只有一份:

findIndex
    │
    ├── 检查每个元素
    ├── 比较是否相等
    └── 返回下标

两个公开函数只负责决定返回权限:

非 const findElement
    └── 返回 T&

const findElement
    └── 返回 const T&

这种方式不需要移除 const,更容易证明安全。


第十三部分:理解 const_cast

什么是类型转换

类型转换(casting)表示把一个表达式视为另一种类型。

C++ 提供了多种显式转换工具,例如:

  • static_cast
  • dynamic_cast
  • reinterpret_cast
  • const_cast

本节只讨论 const_cast

基本语法:

const_cast<目标类型>(表达式)

它可以添加或移除类型的 constvolatile 限定。

例如:

const int value = 10;
const int& read_only = value;

int& writable = const_cast<int&>(read_only);

这段代码能够编译,但通过 writable 修改真正声明为 constvalue 会产生未定义行为:

// writable = 20;  // 未定义行为

const_cast 只是要求编译器暂时放弃检查。

它不会把一个真正位于只读状态的对象安全地变成普通对象。


课件中的委托写法

课件使用了下面的方式消除重复:

template <typename T>
T& Vector<T>::findElement(const T& value) {
    for (std::size_t i = 0; i < logical_size_; ++i) {
        if (elems_[i] == value) {
            return elems_[i];
        }
    }

    throw std::out_of_range("Element not found");
}

template <typename T>
const T& Vector<T>::findElement(const T& value) const {
    return const_cast<Vector<T>&>(*this).findElement(value);
}

关键表达式是:

const_cast<Vector<T>&>(*this).findElement(value)

可以从内向外拆解。


第一步:this 的类型

当前位于:

const T& Vector<T>::findElement(...) const

这是一个 const 成员函数。

因此:

this

的类型是:

const Vector<T>*

它指向当前 const 对象。


第二步:解引用 *this

执行:

*this

得到当前对象本身。

表达式类型可以理解为:

const Vector<T>&

也就是对当前只读对象的引用。

状态图:

this
 │
 │ const Vector<T>*
 ▼
[当前 Vector<T> 对象]

*this
 │
 └── const Vector<T>&

第三步:用 const_cast 移除 const

执行:

const_cast<Vector<T>&>(*this)

把:

const Vector<T>&

转换成:

Vector<T>&

编译器现在把当前对象视为可修改对象。


第四步:调用非 const 重载

转换后的表达式是:

Vector<T>&

因此调用:

.findElement(value)

时会选择非 const 版本:

T& findElement(const T& value);

返回的 T& 可以转换为 const T&,作为外层 const 函数的返回值。

完整流程:

this
 │
 │ const Vector<T>*
 ▼
*this
 │
 │ const Vector<T>&
 ▼
const_cast<Vector<T>&>
 │
 │ Vector<T>&
 ▼
调用非 const findElement
 │
 │ T&
 ▼
作为 const T& 返回

这种写法有什么危险

如果当前对象原本真的被声明为 const

const Vector<int> values;

那么 const_cast 只是移除了类型系统中的限制。

如果被调用的非 const 函数修改了对象,行为是未定义的。

例如,未来有人修改了非 const findElement

template <typename T>
T& Vector<T>::findElement(const T& value) {
    ++search_count_;
    // ...
}

如果 search_count_ 不是 mutable,而这个函数通过 const_cast 在真正的 const 对象上调用,就可能产生未定义行为。

所以课件总结“几乎不要使用 const_cast”是很重要的提醒。

const_cast 的含义近似于:

编译器先别管,我保证这里安全。

一旦保证错误,编译器无法继续保护程序。


更常见的委托方向

如果确实想通过重载委托减少重复,更安全的方向通常是:

const 版本调用 const 版本,而不是让 const 版本调用非 const 版本。

例如:

template <typename T>
const T& Vector<T>::findElement(const T& value) const {
    for (std::size_t i = 0; i < logical_size_; ++i) {
        if (elems_[i] == value) {
            return elems_[i];
        }
    }

    throw std::out_of_range("Element not found");
}

const 版本:

template <typename T>
T& Vector<T>::findElement(const T& value) {
    const Vector<T>& read_only_self = *this;

    return const_cast<T&>(
        read_only_self.findElement(value)
    );
}

这里调用非 const 版本时,原始对象一定是非 const 的。

过程是:

非 const 对象
     │
     ▼
暂时当作 const 对象调用只读查找
     │
     ▼
得到 const T&
     │
     ▼
恢复为 T&

因为底层对象本来就是非 const,恢复元素的可修改引用是合理的。

不过,这段代码仍然需要谨慎阅读。对于本节的 SimpleVector,提取 findIndex() 更清楚,也完全不需要 const_cast

推荐优先级可以记成:

提取公共辅助函数
        ↓
必要时让非 const 版本委托给 const 版本
        ↓
尽量不要让 const 版本通过 const_cast 调用非 const 版本

练习:判断是否产生未定义行为

观察两组代码。

第一组:

int value = 10;
const int& read_only = value;

int& writable = const_cast<int&>(read_only);
writable = 20;

第二组:

const int value = 10;
const int& read_only = value;

int& writable = const_cast<int&>(read_only);
writable = 20;

哪一组修改是合法的?


答案与解释

第一组合法。

底层对象最初是:

int value = 10;

它本身不是 const

只是后来通过:

const int& read_only

提供了一个只读访问路径。

使用 const_cast 恢复可修改引用后,修改的仍然是原本可修改的 int 对象。

修改后:

value = 20

第二组会产生未定义行为。

底层对象从一开始就是:

const int value = 10;

const_cast 不能把真正的 const 对象安全地变成可修改对象。

即使程序某次运行时看起来成功修改,也不能依赖这种结果。


第十四部分:比 const_cast 更细粒度的 mutable

有些成员不属于对象的“逻辑状态”

假设有一个射线对象:

struct CameraRay {
    Point origin;
    Direction direction;
    Color debug_color;
};

渲染函数只读取射线的起点和方向:

void renderRay(const CameraRay& ray);

但调试时,希望记录“这条射线使用什么颜色显示”。

从程序业务意义上看,修改调试颜色并没有改变射线本身的几何含义:

射线的逻辑状态:
- 起点
- 方向

辅助状态:
- 调试颜色
- 缓存
- 统计次数

C++ 提供 mutable 关键字,允许某个成员即使位于 const 对象中,也可以被修改。


一个完整的 mutable 示例

#include <iostream>

struct Point {
    double x;
    double y;
};

struct Direction {
    double x;
    double y;
};

enum class Color {
    White,
    Yellow,
    Red
};

struct CameraRay {
    Point origin;
    Direction direction;
    mutable Color debug_color = Color::White;
};

void renderRay(const CameraRay& ray) {
    ray.debug_color = Color::Yellow;

    std::cout << "Rendering ray\n";
}

int main() {
    const CameraRay ray{
        {0.0, 0.0},
        {1.0, 1.0},
        Color::White
    };

    renderRay(ray);
}

注意枚举成员的 C++ 访问语法是:

Color::Yellow

课件示例写成了:

Color.Yellow

那不是标准 C++ 语法。

结构体定义末尾也必须有分号:

struct CameraRay {
    // ...
};

mutable 改变了什么

成员声明:

mutable Color debug_color;

表示即使当前对象是 const

const CameraRay ray;

也允许修改:

ray.debug_color = Color::Yellow;

但其他普通成员仍然受到保护:

// ray.origin.x = 10.0;  // 编译错误

mutable 是比对整个对象使用 const_cast 更细粒度的工具。

const CameraRay
├── origin          不可修改
├── direction       不可修改
└── debug_color     可以修改,因为是 mutable

物理 const 与逻辑 const

物理 const 可以粗略理解为:

对象的任何比特都不能变化。

逻辑 const 更关注:

从使用者的角度看,对象所代表的值有没有改变。

例如,一个查找函数可能使用缓存:

class ExpensiveObject {
public:
    int result() const {
        if (!cache_valid_) {
            cached_result_ = calculate();
            cache_valid_ = true;
        }

        return cached_result_;
    }

private:
    int calculate() const {
        return 42;
    }

    mutable bool cache_valid_ = false;
    mutable int cached_result_ = 0;
};

调用:

object.result();

不会改变对象对外表达的逻辑含义,只是在内部保存计算结果。

这是 mutable 的合理用途之一。

不合理的用途是为了逃避正确设计:

class BankAccount {
private:
    mutable double balance_;
};

余额显然属于账户的核心状态,不应该为了让 const 函数修改它而加 mutable


练习:判断 mutable 是否合适

下面哪些成员可能适合使用 mutable

  1. 银行账户余额。
  2. 上一次计算结果的缓存。
  3. 只用于调试的颜色。
  4. 学生的考试成绩。
  5. 某个只读查询函数被调用的次数统计。

答案与解释

第 2、3、5 项可能适合。

缓存通常不改变对象对外的逻辑值。

调试颜色只服务于可视化和调试,也可能不属于对象核心含义。

查询次数统计属于辅助信息,不一定属于对象代表的业务状态。

银行账户余额和学生成绩属于对象的核心状态。修改它们意味着对象真正发生了变化,不应通过 mutable 绕过 const

判断标准不是“能不能编译”,而是:

修改这个成员后,使用者是否会认为对象本身的值发生了变化?


第十五部分:类模板对元素类型提出了哪些要求

Vector<T> 并不代表任何类型都一定能使用所有操作

我们常说:

模板能够适用于任意类型。

更准确的说法是:

模板可以用不同类型实例化,但某个操作能否编译,取决于模板实现对该类型做了什么。

例如:

template <typename T>
std::size_t Vector<T>::findIndex(const T& value) const {
    for (std::size_t i = 0; i < logical_size_; ++i) {
        if (elems_[i] == value) {
            return i;
        }
    }

    throw std::out_of_range("Not found");
}

这里使用了:

elems_[i] == value

因此 T 必须支持 ==

下面的类型可以:

struct Student {
    int id;
};

bool operator==(const Student& left, const Student& right) {
    return left.id == right.id;
}

然后:

Vector<Student> students;
students.findElement(Student{1001});

如果 Student 没有合适的 ==,创建:

Vector<Student> students;

本身可能仍然没有问题。

但真正调用:

students.findElement(...)

时,编译器需要实例化查找函数,就会发现 == 不可用。

同样:

new T[4]

要求 T 可以默认构造。

elems_[i] = value;

要求 T 可以赋值。

模板没有让类型规则消失,而是把检查推迟到具体实例化和使用时。


第十六部分:容易混淆的概念集中对比

类模板与模板实例

类模板:

template <typename T>
class Vector {
};

它是生成类型的规则。

模板实例:

Vector<int>

它是一个具体类型。

不能把两者都简称为“一个类”后就忽略区别,因为类外实现时必须写:

Vector<T>::at

而不能只写:

Vector::at

类型模板参数与非类型模板参数

类型模板参数:

template <typename T>

传入:

int
std::string
Student

非类型模板参数:

template <std::size_t N>

传入:

3
5
100

二者可以组合:

template <typename T, std::size_t N>
class FixedArray {
};

使用:

FixedArray<int, 10>

参数中的 const 与成员函数末尾的 const

参数:

void push_back(const T& value);

表示函数不能通过 value 修改传入对象。

成员函数:

std::size_t size() const;

表示函数不能普通地修改当前 Vector 对象。

一个函数完全可能同时出现两者:

bool contains(const T& value) const;

含义是:

  • 不修改传入的 value
  • 不修改当前容器;
  • 只检查容器是否包含该值。

T&const T&

返回:

T&

调用者可以通过引用修改原对象:

values.at(0) = new_value;

返回:

const T&

调用者可以读取,但不能通过该引用修改:

std::cout << values.at(0);

// values.at(0) = new_value;  // 不允许

两者都没有复制整个 T 对象。

区别在于修改权限。


at()operator[]

典型设计:

T& at(std::size_t index);
const T& at(std::size_t index) const;

会进行范围检查。

而:

T& operator[](std::size_t index);
const T& operator[](std::size_t index) const;

通常不检查。

对于越界下标:

values.at(100);

通常抛出 std::out_of_range

而:

values[100];

可能产生未定义行为。


const_castmutable

const_cast 作用于某个表达式:

const_cast<Vector<T>&>(*this)

它可能一下子移除整个对象访问路径的 const 限制。

mutable 作用于类的某个成员声明:

mutable int cache_;

它只允许指定成员在 const 对象中变化。

二者都能绕过普通 const 保护,因此都应谨慎使用。

通常优先考虑:

  • 正确区分普通成员函数和 const 成员函数;
  • 使用 const 重载;
  • 提取公共辅助函数;
  • 重新思考这个数据是否真的属于对象的逻辑状态。

第十七部分:常见编译错误和设计错误

错误一:忘记模板参数声明

错误:

T& Vector<T>::at(std::size_t index) {
    return elems_[index];
}

修正:

template <typename T>
T& Vector<T>::at(std::size_t index) {
    return elems_[index];
}

错误二:类外实现只写 Vector::

错误:

template <typename T>
T& Vector::at(std::size_t index) {
    return elems_[index];
}

修正:

template <typename T>
T& Vector<T>::at(std::size_t index) {
    return elems_[index];
}

Vector 是模板,Vector<T> 才表示当前具体类型形式。


错误三:声明有 const,实现忘记写

声明:

std::size_t size() const;

错误实现:

template <typename T>
std::size_t Vector<T>::size() {
    return logical_size_;
}

正确实现:

template <typename T>
std::size_t Vector<T>::size() const {
    return logical_size_;
}

末尾的 const 是函数签名的一部分。


错误四:const 成员函数返回可修改引用

危险接口:

T& at(std::size_t index) const;

这可能让只读容器的调用者修改元素。

应当返回:

const T& at(std::size_t index) const;

同时为非 const 对象提供:

T& at(std::size_t index);

错误五:把所有成员函数都标成 const

下面不应该是 const

void push_back(const T& value) const;

因为它需要修改容器大小和元素。

const 不是“代码质量装饰符”,而是一个必须遵守的语义承诺。


错误六:模板实现放在不可见的 .cpp

如果只在 Vector.h 声明,在 Vector.cpp 中实现,而使用位置看不到实现,可能产生链接错误。

推荐:

Vector.h
Vector.tpp
main.cpp

并在 Vector.h 末尾:

#include "Vector.tpp"

错误七:把不同模板实例当成同一类型

错误:

Vector<int> integers;
Vector<double> decimals;

// integers = decimals;

它们是不同类型,不能直接赋值。


错误八:认为 const 会自动保护指针指向的内容

const 成员函数中:

T* elems_;

指针成员不能被重新赋值,但它指向的 T 对象不一定自动成为 const

因此设计接口时仍需返回:

const T&

而不是:

T&

错误九:滥用 const_cast

下面的代码能够编译,不代表安全:

const Object object;
Object& writable = const_cast<Object&>(object);

如果通过 writable 修改真正的 const object,行为未定义。


错误十:认为模板支持任何操作

如果模板中写:

left == right

那么类型必须支持 ==

如果模板中写:

std::cout << value;

那么类型必须支持输出运算符 <<

错误通常会在对应成员函数被实例化时出现。


第十八部分:综合练习——实现固定容量的模板容器

练习要求

实现:

FixedVector<T, Capacity>

其中:

  • T 是元素类型;
  • Capacity 是最大容量;
  • 元素直接保存在 std::array 中;
  • 不进行动态内存分配;
  • 提供 size()empty()full()
  • 提供 push_back()
  • 提供带边界检查的 at()
  • at() 必须支持 const 重载;
  • 提供 findElement()
  • findElement() 必须支持 const 重载;
  • 容器已满时,push_back() 抛出 std::length_error
  • 下标越界或查找失败时,抛出 std::out_of_range

测试程序应能完成:

FixedVector<std::string, 3> words;

words.push_back("class");
words.push_back("template");

words.at(0) = "const";

const FixedVector<std::string, 3>& read_only_words = words;

std::cout << read_only_words.at(0) << '\n';

同时,下面的修改应当无法编译:

// read_only_words.at(0) = "changed";

设计提示

对象状态可以表示成:

FixedVector<string, 3>
┌──────────────────────────────┐
│ elems_                       │
│ ["class"]["template"][""]    │
│                              │
│ logical_size_ = 2            │
└──────────────────────────────┘

Capacity 已经是类型的一部分:

FixedVector<string, 3>
FixedVector<string, 5>

是两个不同类型。


答案与解释

#include <array>
#include <cstddef>
#include <iostream>
#include <stdexcept>
#include <string>

template <typename T, std::size_t Capacity>
class FixedVector {
public:
    std::size_t size() const {
        return logical_size_;
    }

    bool empty() const {
        return logical_size_ == 0;
    }

    bool full() const {
        return logical_size_ == Capacity;
    }

    void push_back(const T& value) {
        if (full()) {
            throw std::length_error(
                "FixedVector is full"
            );
        }

        elems_[logical_size_] = value;
        ++logical_size_;
    }

    T& at(std::size_t index) {
        checkIndex(index);
        return elems_[index];
    }

    const T& at(std::size_t index) const {
        checkIndex(index);
        return elems_[index];
    }

    T& findElement(const T& value) {
        return elems_[findIndex(value)];
    }

    const T& findElement(const T& value) const {
        return elems_[findIndex(value)];
    }

private:
    void checkIndex(std::size_t index) const {
        if (index >= logical_size_) {
            throw std::out_of_range(
                "FixedVector::at index out of range"
            );
        }
    }

    std::size_t findIndex(const T& value) const {
        for (
            std::size_t i = 0;
            i < logical_size_;
            ++i
        ) {
            if (elems_[i] == value) {
                return i;
            }
        }

        throw std::out_of_range(
            "FixedVector::findElement value not found"
        );
    }

    std::array<T, Capacity> elems_{};
    std::size_t logical_size_ = 0;
};

void printWords(
    const FixedVector<std::string, 3>& words
) {
    for (std::size_t i = 0; i < words.size(); ++i) {
        std::cout << words.at(i) << ' ';
    }

    std::cout << '\n';
}

int main() {
    FixedVector<std::string, 3> words;

    words.push_back("class");
    words.push_back("template");

    printWords(words);

    words.at(0) = "const";
    printWords(words);

    const FixedVector<std::string, 3>& read_only_words =
        words;

    std::cout
        << read_only_words.findElement("template")
        << '\n';
}

编译:

g++ -std=c++20 -Wall -Wextra -pedantic main.cpp -o main

输出:

class template
const template
template

综合程序的执行过程

创建:

FixedVector<std::string, 3> words;

模板参数分别是:

T = std::string
Capacity = 3

成员:

std::array<T, Capacity> elems_{};

变成:

std::array<std::string, 3> elems_{};

初始状态:

elems_ = ["", "", ""]
logical_size_ = 0
Capacity = 3

执行:

words.push_back("class");

full() 返回:

0 == 3 → false

然后:

elems_[0] = "class";
logical_size_ = 1;

状态:

["class", "", ""]
logical_size_ = 1

接着:

words.push_back("template");

状态:

["class", "template", ""]
logical_size_ = 2

执行:

words.at(0) = "const";

words 是非 const 对象,因此选择:

T& at(std::size_t index);

返回:

std::string&

于是原元素被修改:

["const", "template", ""]

创建:

const FixedVector<std::string, 3>& read_only_words =
    words;

不会复制对象。

通过 read_only_words 调用:

read_only_words.findElement("template")

选择:

const T& findElement(const T& value) const;

返回:

const std::string&

可以读取,但不能通过它修改原字符串。


为什么公共辅助函数应该是 const

void checkIndex(std::size_t index) const;
std::size_t findIndex(const T& value) const;

这两个函数都只读取:

  • logical_size_
  • elems_ 中已有的元素。

它们不会修改容器,因此应标成 const

这样,两个公开版本都能调用它们:

非 const at()
    └── checkIndex()

const at()
    └── checkIndex()

如果 checkIndex() 不是 const,那么 const at() 就无法调用它。


为什么这里没有使用 const_cast

查找公共逻辑放在:

findIndex()

中。

两个重载只负责返回不同权限的引用:

T& findElement(...)
const T& findElement(...) const

这样的控制流更直接:

findElement
     │
     ▼
findIndex
     │
     ▼
得到下标
     │
     ├── 非 const 版本返回 T&
     └── const 版本返回 const T&

不需要暂时移除 const,也不需要程序员额外保证某个非 const 函数绝不会修改对象。


第十九部分:本节知识的完整串联

我们从一个保存整数的类开始:

IntVector

当元素类型变化时,复制整个类会产生大量重复代码:

IntVector
DoubleVector
StringVector

早期的宏可以通过文本替换生成代码,但宏:

  • 不了解 C++ 类型;
  • 语法笨重;
  • 错误信息困难;
  • 难以处理复杂类型。

于是 C++ 提供类模板:

template <typename T>
class Vector {
};

它把元素类型变成编译期参数。

使用:

Vector<int>
Vector<double>
Vector<std::string>

会得到不同的具体类型。

模板本身不是类型:

Vector             模板
Vector<int>        类型

不同实例彼此不同:

Vector<int> != Vector<double>

模板参数也不一定是类型:

template <typename T, std::size_t N>

这使得:

std::array<T, N>

能够把元素数量纳入类型。

类外实现模板成员时,需要同时写:

template <typename T>

以及:

Vector<T>::

模板实现通常需要在实例化位置可见,因此常放在头文件或被头文件包含的 .tpp 中。

当容器通过:

const Vector<int>&

传递时,只能调用 const 成员函数。

所以只读查询应写成:

std::size_t size() const;
bool empty() const;

const 成员函数中的 this 指向 const 对象,不能普通地修改成员。

但仅仅在 at() 末尾添加 const 仍不够:

T& at(...) const;

会泄露可修改引用,让只读调用者修改元素。

正确方式是 const 重载:

T& at(std::size_t index);
const T& at(std::size_t index) const;

对象是否为 const,决定调用哪个版本:

非 const 对象 → T&
const 对象    → const T&

当两个版本逻辑重复时,优先提取公共辅助函数。

const_cast 能移除类型上的 const 限制,但不能安全地修改真正声明为 const 的对象。它是需要程序员自行证明安全的低层工具,正常业务代码中很少需要。

mutable 可以允许某个辅助成员在 const 对象中变化,适合:

  • 缓存;
  • 调试信息;
  • 查询统计。

它不适合用来偷偷修改对象的核心状态。

最终,模板解决的是:

怎样让同一套代码适用于多种类型。

const 正确性解决的是:

怎样让类型系统准确表达对象和返回值的修改权限。


下一节:当重复的不是类,而是函数

类模板让我们能够写:

SimpleVector<int>
SimpleVector<double>
SimpleVector<std::string>

但我们的打印函数仍然只支持一种具体类型:

void printIntVector(
    const SimpleVector<int>& values
);

如果要打印字符串版本,我们可能又要写:

void printStringVector(
    const SimpleVector<std::string>& values
);

新的重复已经出现:

容器类型变化
打印循环不变

这与本节开头的 IntVectorDoubleVectorStringVector 问题非常相似。

既然类可以把类型作为参数,那么函数是否也可以?

下一节的函数模板(function template)将解决这个问题:

同一个函数逻辑
        +
不同的参数类型
        ↓
模板函数