Lecture 9 (Week 5 - Tuesday): 类模板与 const 正确性 (Class Templates & Const Correctness)

目录 · ← l8 · l10 →

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 .cppVector<T>:: 语法、typenameclass 等价)。后半讲是 const 正确性(const correctness):const 成员函数、const 重载(const T& at() constT& at())、const_castmutable。学习目标是写出”既能被 const 对象安全使用、又能被普通对象高效修改”的通用容器类。本讲直接支撑作业 A4(Ispell,大量使用模板与 const 引用参数)。

核心特性与语法详解

1. 模板类:动机与定义

  • 定义与目的:没有模板时,为 intdoublestd::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_tboolint……),让”大小”成为类型的一部分。
  • 核心语法
    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) typenameclass 完全等价template <typename T>template <class T> 写法相同含义(历史原因:C++98 只有 class,后来才引入 typename)。template <class K, typename V> 混用也可以。
  • 设计意图与最佳实践:理解这些怪癖的”为什么”不必过深(涉及编译器/链接器的模板实现方式),但必须遵守——否则就是链接错误 undefined reference to ...。也有替代方案(如显式实例化、把实现放 .tpp 文件),课堂建议课后提问。

6. const 正确性与 const 成员函数

  • 定义与目的void printVec(const Vector<int>& v)v 是 const 引用,却调 v.size()v.at(i) 报错——因为 size() 没标 const,编译器无法保证它不修改 vconst 成员函数向编译器承诺”本函数不修改 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 版本
    }
    

    拆解:*thisconst 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 对象里也能改
    };
    
  • 设计意图与最佳实践:典型用途是调试/缓存元数据——课堂例子 CameraRaymutable 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 限制在界内;mainint 实例化并验证四种情况(越上界、越下界、正常、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 里普通对象 vat(0) 返回可写引用。

特性机制解说

  • size() 不标 constprintVecv.size() 编译失败:编译器拿到 const IntVec&,只允许调用 const 接口。const 成员函数中 this 的类型是 const IntVec*——函数体内写 data.push_back(1) 会直接编译报错,const 是编译器强制执行的契约,不是注释。
  • const 重载的选择v.at(0)v 非 const,选 int& at(size_t)printVecv 是 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)*thisconst 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 引用,但能更新 mutabledebugColor 调试字段,普通成员则被编译器禁止修改。

特性机制解说

  • 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 成员函数中 thisconst 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”——模板实现必须随使用点可见。
  • 声明与实现一处忘了 constsize_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 正确性tokenizespellcheck 的签名是 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“规则就是你要遵守的那套。