Lecture 9 (Week 5 - Tuesday): 类模板与 const 正确性 (Class Templates & Const Correctness)
Lecture 9 (Week 5 - Tuesday): 类模板与 const 正确性 (Class Templates & Const Correctness)
概述
本讲把”类”提升到”类的工厂”层面:类模板(class template)让你写一份逻辑、为任意类型生成对应的类(std::vector<int>、std::vector<std::string> 背后是同一个 template <typename T> class vector),并讲解模板实例化机制、模板与类型的区别、非类型模板参数(std::array)以及模板实现的三个经典怪癖(.h 底部 include .cpp、Vector<T>:: 语法、typename 与 class 等价)。后半讲是 const 正确性(const correctness):const 成员函数、const 重载(const T& at() const 与 T& at())、const_cast 与 mutable。学习目标是写出”既能被 const 对象安全使用、又能被普通对象高效修改”的通用容器类。本讲直接支撑作业 A4(Ispell,大量使用模板与 const 引用参数)。
核心特性与语法详解
1. 模板类:动机与定义
- 定义与目的:没有模板时,为
int、double、std::string各写一个IntVector/DoubleVector/StringVector是灾难性的重复;模板把”逻辑”与”类型”解耦——逻辑写一次,类型当参数。 - 核心语法:
template <typename T> // T 是"类型参数" class Vector { public: T& at(size_t index); void push_back(const T& elem); private: T* elems; // 所有"元素类型"处都用 T }; - 设计意图与最佳实践:把类里所有出现”元素类型”的地方(成员类型、参数、返回值)替换为
T。课堂讲了一段历史:STL 之前人们用预处理器宏#define GENERATE_VECTOR(MY_TYPE)生成类——语法笨拙、难以类型检查、忘了调用或调用两次都是灾难;模板让编译器自己完成代码生成,且带完整类型检查。template声明不是代码,是”配方”。
2. 模板实例化:按需代码生成
- 定义与目的:模板本身不产生任何代码;只有当你写下
Vector<int> v;这种实例化时,编译器才为T = int生成一份具体的类代码。 - 核心语法:
Vector<int> intVec; // 编译器生成 IntVector 版代码 Vector<double> doubleVec; // 生成 DoubleVector 版代码 Vector<std::string> strVec; // 生成 StringVector 版代码 Vector<Vector<int>> vecVec; // 嵌套模板:元素类型本身是 Vector<int> - 设计意图与最佳实践:模板像工厂:输入
int产出Vector<int>,输入std::string产出Vector<std::string>。实例化是惰性的——只有用到的类型才会生成代码,未实例化的模板不产生任何目标代码。
3. 模板 vs 类型:两个完全不同的类型
- 定义与目的:
template <typename T> class Vector是模板,不是类型;Vector<std::string>才是类型(也叫模板实例化)。 - 核心语法:
void foo(std::vector<int> v); std::vector<double> v; foo(v); // ❌ 编译错误:没有从 vector<double> 到 vector<int> 的转换 - 设计意图与最佳实践:
Vector<int>与Vector<double>是完全不同的两个类型(编译期与运行期皆不同),不能互相赋值/转换。对比 Java:ArrayList<Integer>与ArrayList<Double>在运行时是同一个类型(类型擦除)——C++ 的模板则保留完整类型信息,这也是 C++ 模板能极致优化的原因。
4. 非类型模板参数与 std::array
- 定义与目的:模板参数不一定是类型,还可以是编译期常量(
size_t、bool、int……),让”大小”成为类型的一部分。 - 核心语法:
template <size_t N> class SizeTemplate {}; // N 是编译期值 template <typename T, std::size_t N> struct std::array; // 标准库示例 std::array<std::string, 5> arr; // 恰好 5 个 string - 设计意图与最佳实践:
std::array<T, N>的大小烘焙进类型,编译器精确知道它占多少字节,因此可以栈上分配、完全避免堆分配(对比std::vector的堆缓冲)。适合固定大小、性能敏感的场景(游戏、嵌入式)。
5. 模板实现的三个怪癖(Quirks)
- 定义与目的:模板代码生成发生在编译器内部,导致实现组织方式与非模板类完全不同。课堂给了三条”👻 怪癖”:
- (1)
.cpp实现必须原样复制template <typename T>,并且类名要写Vector<T>:// Vector.cpp —— 漏掉 template 前缀或写成 Vector::at 都会报错 template <typename T> T& Vector<T>::at(size_t i) { /* 实现... */ } // 不是 Vector::at! - (2) 模板的
.h必须在文件底部 include.cpp:// Vector.h —— 非模板类是 .cpp include .h;模板类反过来 template <typename T> class Vector { public: T& at(size_t i); }; #include "Vector.cpp" // 底部 include 实现 - (3)
typename与class完全等价:template <typename T>与template <class T>写法相同含义(历史原因:C++98 只有class,后来才引入typename)。template <class K, typename V>混用也可以。
- (1)
- 设计意图与最佳实践:理解这些怪癖的”为什么”不必过深(涉及编译器/链接器的模板实现方式),但必须遵守——否则就是链接错误
undefined reference to ...。也有替代方案(如显式实例化、把实现放.tpp文件),课堂建议课后提问。
6. const 正确性与 const 成员函数
- 定义与目的:
void printVec(const Vector<int>& v)里v是 const 引用,却调v.size()、v.at(i)报错——因为size()没标const,编译器无法保证它不修改v。const 成员函数向编译器承诺”本函数不修改 this 所指对象”。 - 核心语法:
class Vector { public: size_t size() const; // 声明处加 const }; // 实现处也必须加 const(否则是不同函数): template <class T> size_t Vector<T>::size() const { return logical_size; } - 设计意图与最佳实践:const 方法的本质是把
this的类型变成const Vector<T>*——于是函数体内任何写成员的操作都直接编译报错(如this->logical_size = 106;报 “cannot assign … within const member function”)。规则:不修改对象的成员函数一律标 const,这样 const 对象与普通对象都能用。const 对象只能访问 const 接口。
7. const 重载(const overloading)
- 定义与目的:
at()既要让 const 用户”能读”,又要让普通用户”能写”——一个签名满足不了,于是定义两个重载,由对象的 const 性自动选择。 - 核心语法:
class Vector { public: const T& at(size_t index) const; // const 对象调用:只读 T& at(size_t index); // 普通对象调用:可读写 }; - 设计意图与最佳实践:单独用
T& at(size_t) const是错的(const 用户拿到非 const 引用后v.at(0) = 42;能改掉 const 对象);单独用const T& at(size_t) const也错(普通用户无法写元素)。两个版本各司其职;实现几乎相同(都是return elems[index];),但 C++ 用 const 性参与重载决议,自动匹配。
8. const_cast
- 定义与目的:
const_cast<target_type>(expr)用来”剥掉”或”加上” const 性。最经典的合法用途:在 const 重载里委托——const 版本调用非 const 版本,避免复制粘贴逻辑。 - 核心语法:
template <typename T> const T& Vector<T>::findElement(const T& value) const { return const_cast<Vector<T>&>(*this).findElement(value); // 剥掉 const 非 const 版本 }拆解:
*this是const Vector<T>&;const_cast<Vector<T>&>把它变成非 const 引用;于是.findElement(value)解析到非 const 重载(编译器按静态类型选重载)。 - 设计意图与最佳实践:课堂原话——”short answer: just about never”(几乎从不)。
const_cast是在对编译器说”别担心,我兜底”,如果对象本来就是 const,通过 const_cast 修改它属于未定义行为。想改数据就别声明 const;它只在”接口必须 const、实现确实不修改”的委托场景有正当用途。
9. mutable 关键字
- 定义与目的:比 const_cast 更细粒度的”豁免”:把个别成员标为
mutable,即使对象是 const,这些成员仍可修改(const_cast 是整对象解禁,mutable 是逐成员解禁)。 - 核心语法:
struct MutableStruct { int dontTouchThis; // const 对象里不可改 mutable double iCanChange; // const 对象里也能改 }; - 设计意图与最佳实践:典型用途是调试/缓存元数据——课堂例子
CameraRay存mutable Color debugColor;,renderRay(const CameraRay&)里可以给调试光线着色而不改变光线本身的 const 语义。同样要谨慎使用:它绕过了 const 的保护。
代码示例与逐步解说(核心)
示例 1:手写 Vector 模板与实例化(C++11)
代码:
// C++11
#include <iostream>
#include <string>
template <typename T>
class Vector {
private:
T* elems = nullptr;
size_t logical_size = 0;
size_t array_size = 0;
void grow() {
array_size = array_size == 0 ? 4 : array_size * 2;
T* bigger = new T[array_size];
for (size_t i = 0; i < logical_size; ++i) bigger[i] = elems[i];
delete[] elems;
elems = bigger;
}
public:
void push_back(const T& elem) {
if (logical_size == array_size) grow();
elems[logical_size++] = elem;
}
T& at(size_t index) { return elems[index]; }
size_t size() const { return logical_size; }
~Vector() { delete[] elems; }
};
int main() {
Vector<int> intVec; // 实例化:T = int
intVec.push_back(1);
intVec.push_back(2);
Vector<std::string> strVec; // 实例化:T = std::string
strVec.push_back("hello");
strVec.push_back("world");
std::cout << intVec.at(1) << " " << strVec.at(0) << "\n"; // 2 hello
std::cout << intVec.size() << " " << strVec.size() << "\n"; // 2 2
}
代码做什么:用 template <typename T> 写一个极简可增长数组:push_back 在容量不足时 grow() 翻倍扩容;main 分别实例化 Vector<int> 与 Vector<std::string> 并读写。
特性机制解说:
- 实例化 = 惰性代码生成:编译器见到
Vector<int>时把模板里的T全部替换成int生成一份类代码,见到Vector<std::string>再生成一份——两次生成的类互不相干;main里从不出现的Vector<double>永远不会被生成。 - 这份极简实现省略了拷贝构造/赋值(默认浅拷贝会导致双重
delete[]),真实容器必须实现”三/五法则”或使用 RAII 类型——本讲只关注模板机制。
示例 2:非类型模板参数与 std::array(C++11)
代码:
// C++11
#include <array>
#include <iostream>
#include <string>
template <size_t N> // N 是编译期常量,不是类型
class FixedBuffer {
private:
std::array<int, N> data{}; // 大小 N 烘焙进类型
public:
void fill(int v) { data.fill(v); }
size_t size() const { return data.size(); }
};
int main() {
std::array<std::string, 5> arr; // 恰好 5 个 string,栈上分配
arr[0] = "CS106L";
std::cout << arr.size() << "\n"; // 5
FixedBuffer<8> buf; // 8 个 int 的栈上缓冲区
buf.fill(42);
std::cout << buf.size() << "\n"; // 8
}
代码做什么:用非类型模板参数 size_t N 定义固定大小缓冲区;演示 std::array<std::string, 5> 与 FixedBuffer<8>。
特性机制解说:
FixedBuffer<8>与FixedBuffer<16>是两个不同类型,sizeof分别为 32 与 64 字节——大小是类型的一部分,编译器据此在栈上精确分配,零堆分配、零运行时开销;std::array<T, N>的声明本质就是template<typename T, std::size_t N> struct array;,它把 C 数组的紧凑性与std::vector的接口(.size()、.fill()、迭代器)合二为一。
示例 3:课堂真实代码 BoundedValue——模板实现的怪癖(C++17,单文件可运行版)
代码:
// C++17(课堂 bounded_value.h / bounded_value.cpp 的完成版,此处合并为单文件以便运行)
#include <algorithm>
#include <iostream>
template <typename T>
class BoundedValue {
private:
T value;
T minValue;
T maxValue;
public:
// 初始化并保证 value 落在 [minValue, maxValue] 内
BoundedValue(T value, T minValue, T maxValue)
: value{std::clamp(value, minValue, maxValue)},
minValue{minValue}, maxValue{maxValue} {}
T get() const { return value; } // const:只读查询
void set(T newValue) { // 超出界限则夹到界限上
value = std::clamp(newValue, minValue, maxValue);
}
T getMin() const { return minValue; }
T getMax() const { return maxValue; }
void adjust(T delta) { set(value + delta); } // 加分项
};
int main() {
BoundedValue<int> health{120, 0, 100}; // 120 被夹到 100
std::cout << health.get() << "\n"; // 100
health.set(-10);
std::cout << health.get() << "\n"; // 0
health.set(50);
std::cout << health.get() << "\n"; // 50
health.adjust(60);
std::cout << health.get() << "\n"; // 100
}
代码做什么:BoundedValue<T> 存一个值及其上下界,任何写入都被 std::clamp 限制在界内;main 用 int 实例化并验证四种情况(越上界、越下界、正常、adjust 越界)。
特性机制解说:这是课堂真实代码 bounded_value.h + bounded_value.cpp(学生完成 TODO)+ main.cpp 的拆分方式,正是本讲怪癖的活教材:
// bounded_value.h —— 👻 怪癖(2):.h 底部 include .cpp
template <typename T> class BoundedValue { /* 声明 */ };
#include "bounded_value.cpp"
// bounded_value.cpp —— 👻 怪癖(1):复制 template 前缀 + BoundedValue<T>::
template <typename T>
BoundedValue<T>::BoundedValue(T value, T minValue, T maxValue) { /* ... */ }
template <typename T>
T BoundedValue<T>::get() const { return value; }
- 编译方式:
g++ -std=c++20 main.cpp -o main——不要把 bounded_value.cpp 单独编译/链接(模板实现没有独立目标代码,靠.h底部的 include 进入每个使用点)。 - 所有
get/getMin/getMax标了const,因为它们是只读查询——这正是 const 正确性在真实代码中的样子。
示例 4:const 成员函数与 const 接口(C++11)
代码:
// C++11
#include <initializer_list>
#include <iostream>
#include <vector>
class IntVec {
private:
std::vector<int> data;
public:
IntVec(std::initializer_list<int> il) : data{il} {}
size_t size() const { return data.size(); } // const 成员函数
const int& at(size_t index) const { return data.at(index); }
int& at(size_t index) { return data.at(index); } // const 重载
};
void printVec(const IntVec& v) { // v 是 const 引用:只能用 const 接口
for (size_t i = 0; i < v.size(); ++i)
std::cout << v.at(i) << " "; // 解析到 const 版本
std::cout << "\n";
}
int main() {
IntVec v{1, 2, 3};
printVec(v); // 1 2 3
v.at(0) = 42; // 非 const 对象 → 非 const 重载,可写
std::cout << v.at(0) << "\n"; // 42
}
代码做什么:模拟课堂 Vector 的 const 接口:printVec 接收 const IntVec& 并成功调用 size() 与 at(i);main 里普通对象 v 的 at(0) 返回可写引用。
特性机制解说:
- 若
size()不标const,printVec里v.size()编译失败:编译器拿到const IntVec&,只允许调用 const 接口。const 成员函数中this的类型是const IntVec*——函数体内写data.push_back(1)会直接编译报错,const 是编译器强制执行的契约,不是注释。 - const 重载的选择:
v.at(0)中v非 const,选int& at(size_t);printVec里v是 const 引用,选const int& at(size_t) const。同一函数名、同一参数列表,仅凭 const 性区分两个重载。
示例 5:const_cast 委托——消除 const 重载的冗余(C++11)
代码:
// C++11
#include <iostream>
#include <stdexcept>
#include <vector>
class IntVec {
private:
std::vector<int> data;
public:
IntVec(std::initializer_list<int> il) : data{il} {}
int& findElement(int value) { // 非 const 版本:真正实现
for (int& e : data)
if (e == value) return e;
throw std::out_of_range("Element not found");
}
const int& findElement(int value) const { // const 版本:委托
return const_cast<IntVec&>(*this).findElement(value);
}
};
int main() {
IntVec v{10, 20, 30};
const IntVec& cv = v;
std::cout << cv.findElement(20) << "\n"; // 20(const 版本,只读)
v.findElement(10) = 99; // 非 const 版本,可写
std::cout << v.findElement(99) << "\n"; // 99
}
代码做什么:findElement 的两个 const 重载中,const 版本用一行 const_cast<IntVec&>(*this).findElement(value) 委托给非 const 版本,逻辑只写一份。
特性机制解说:
- 逐步拆解
const_cast<IntVec&>(*this).findElement(value):*this是const IntVec&;const_cast<IntVec&>剥掉 const 得到非 const 引用;此时.findElement按静态类型解析到非 const 重载,返回int&;最后隐式转换为const int&返回。安全性:这里的对象(cv引用的v)本来就是非 const 的,只是通过 const 引用访问——剥 const 没有风险;若对象真是 const,内部修改就是未定义行为。 - 替代写法是把实现抽成私有
int& findElementImpl(...)让两个公共版本都调用;const_cast 委托只是少写一个函数。课堂结论:const_cast 几乎从不使用。
示例 6:mutable——const 对象中的”可改豁免”(C++11)
代码:
// C++11
#include <iostream>
struct CameraRay {
int origin = 0;
int direction = 0;
mutable int debugColor = 0; // 调试元数据:const 下也可写
};
void renderRay(const CameraRay& ray) {
ray.debugColor = 42; // ✅ mutable 成员可修改
// ray.origin = 1; // ❌ 普通成员不可修改
std::cout << ray.debugColor << "\n";
}
int main() {
CameraRay ray;
renderRay(ray); // 42
}
代码做什么:renderRay 接收 const 引用,但能更新 mutable 的 debugColor 调试字段,普通成员则被编译器禁止修改。
特性机制解说:
mutable只豁免被标记的成员,比 const_cast(整对象解禁)更细粒度、更安全。典型场景:缓存、调试着色、统计计数——它们不属于对象的”逻辑状态”,修改它们不影响 const 语义。编译器视角:const 对象里普通成员按const T处理,mutable成员始终按T处理。- 滥用 mutable 会破坏 const 契约的可信度,务必克制。
与旧标准(如 C++98)的对比
| 本讲特性(C++11+) | C++98 的做法 | 新特性优势 |
|---|---|---|
类模板本身(template <typename T> class Vector) | 预处理器宏 #define GENERATE_VECTOR(T) | 宏的文本替换无类型检查、语法笨拙、忘调/重调即出错;模板由编译器做类型安全的代码生成 |
std::array<T, N>(C++11) | C 数组 T arr[N] 或 std::vector | 有 .size()/迭代器/越界检查接口,又不堆分配;大小进入类型系统 |
模板别名 template<class T> using Vec = std::vector<T>;(C++11) | 无(typedef 不能模板化) | 可为模板”起别名”,简化 Vector<Vector<int>> 这类长类型 |
| const 成员函数、const_cast、mutable | 本为 C++98 已有机制 | (提醒:这些是”老机制”,本讲的现代性在于 const 重载与委托惯用法的普及) |
std::clamp(C++17,示例 3 用到) | 手写 max(min(x, hi), lo) | 语义清晰、避免重复嵌套 |
关键要点
- 模板是编译期”代码工厂”:
template <typename T> class Vector不是类型,Vector<int>才是;每种 T 各生成一份独立代码,且Vector<int>与Vector<double>是完全不同的类型。 - 模板实现必须对编译器可见:实现放
.h(底部#include "Vector.cpp"),成员函数定义要写全template <typename T>与Vector<T>::;否则链接错误。 - 不修改对象的成员函数一律标
const:const 成员函数中this是const Vector<T>*,编译器强制保证不修改;const 对象只能用 const 接口。 - const 重载让”读”与”写”各得其所:
const T& at(size_t) const服务 const 用户,T& at(size_t)服务普通用户;const 版本可用const_cast委托非 const 版本消除重复。 - const_cast 与 mutable 是逃生门:几乎从不使用;唯一的常见合法用途是 const 重载委托与调试元数据。
常见陷阱与注意事项
- 模板实现放
.cpp且.h不 include 它、或写成Vector::at而漏掉Vector<T>::与template <typename T>:前者编译期正常、链接期报undefined reference;后者当场报”Vector 不是类型 / 不知道 T”——模板实现必须随使用点可见。 - 声明与实现一处忘了
const:size_t size() const;声明了、size_t Vector<T>::size()实现没写const——二者是不同函数,报 “no matching function”。 - 在 const 方法里返回非 const 引用(
T& at(size_t) const):const 用户v.at(0) = 42;就能改写 const 对象,const 契约被击穿;应返回const T&或用 const 重载。 - 对真正 const 的对象用 const_cast 修改:未定义行为(可能静默崩溃);const_cast 只应在对象本体非 const 时使用。
- 误以为模板实例之间可转换:
std::vector<int>与std::vector<double>互不兼容;把Vector<double>传给接收Vector<int>的函数会编译失败。
关联作业提示
本讲对应 A4: Ispell(拼写检查器,基于 STL 算法与 ranges 库),模板与 const 知识贯穿全程:
- 模板函数:作业提供的
find_all<Iterator, UnaryPred>就是本讲”模板按需实例化”的实战——它会用std::string::iterator实例化,也会用别的迭代器实例化,一份逻辑通用所有类型。 - 类型别名:
using Corpus = std::set<Token>;、using Dictionary = std::unordered_set<std::string>;正是 Lecture 7 的using String = std::string;的推广(本讲补充了模板别名)。 - const 正确性:
tokenize与spellcheck的签名是Corpus tokenize(std::string& input)与std::set<Misspelling> spellcheck(const Corpus& source, const Dictionary& dictionary)——spellcheck对两个参数都用 const 引用,因为只读不写;lambda 捕获source必须按引用([&]或[&source]),否则拷贝会破坏 Token 的迭代器语义(作业特别警告过)。这也呼应”const 接口”思想:只读函数不该拿到可变权限。 - 模板构造函数:
Token有一个模板构造函数template <typename It> Token(std::string& source, It begin, It end);——它在std::transform的 lambda 里以任意迭代器类型实例化,是”模板逻辑写一次、类型当参数”的直接应用。 - 若你之前按 A3 加分项把类写成了类模板,本讲的”
.h底部 include 实现、编译命令去掉.cpp“规则就是你要遵守的那套。
