C++ / 2026-08-01
CS106L 第 9 讲:类模板与 const 正确性
从泛型容器实现出发,理解类模板、模板实例化与 const 正确性。
原始课件: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::string 和 Vector 拼接成一个记号,但带有 :: 的类型并不能自然地拼成合法类名。
而模板不需要根据元素类型修改类名:
Vector<std::string>
这正是模板语法的重要优势。
练习:手动展开宏
观察:
#define MAKE_BOX(TYPE) \
class TYPE##Box { \
public: \
TYPE value; \
};
MAKE_BOX(int)
预处理后大致会生成什么代码?
答案与解释
TYPE 会被替换成 int,TYPE##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;
判断下面说法是否正确。
a和b的类型相同。a和c的类型相同,因为它们都来自Vector。d的元素类型是int。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 中,但需要提前列出要支持的具体类型。本节暂时不展开。
typename 和 class 在模板参数列表中的关系
下面两个声明等价:
template <typename T>
class Vector {
};
template <class T>
class Vector {
};
在模板参数列表中,typename 和 class 都可以表示:
这个模板参数是一种类型。
多个参数也可以混用:
template <typename Key, typename Value>
struct Pair {
};
template <class Key, class Value>
struct Pair {
};
template <class Key, typename Value>
struct Pair {
};
这三种写法在这里含义相同。
不过,“typename 和 class 永远完全相同”是不准确的。typename 在模板的其他位置还有额外作用。
本节只需要记住:
在模板类型参数列表中,
typename T和class 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
try 和 catch (...) 用于处理元素复制过程中可能抛出的异常。
如果复制某个 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();
second 是 const 对象,而 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_box 是 const Box&,只能选择:
const int& value() const;
输出:
const version
20
完整输出为:
non-const version
non-const version
20
const version
20
read_only_box 和 box 引用的是同一个对象,因此读取到的仍然是已经被改成 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<目标类型>(表达式)
它可以添加或移除类型的 const、volatile 限定。
例如:
const int value = 10;
const int& read_only = value;
int& writable = const_cast<int&>(read_only);
这段代码能够编译,但通过 writable 修改真正声明为 const 的 value 会产生未定义行为:
// 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?
- 银行账户余额。
- 上一次计算结果的缓存。
- 只用于调试的颜色。
- 学生的考试成绩。
- 某个只读查询函数被调用的次数统计。
答案与解释
第 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_cast 与 mutable
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
);
新的重复已经出现:
容器类型变化
打印循环不变
这与本节开头的 IntVector、DoubleVector、StringVector 问题非常相似。
既然类可以把类型作为参数,那么函数是否也可以?
下一节的函数模板(function template)将解决这个问题:
同一个函数逻辑
+
不同的参数类型
↓
模板函数