🔷 第十二章 模板
📄 一句话概括

"有界数组"=一个知道自己边界、并且不让越界的数组。普通数组 int a[10]; a[100] = 1; 编译器一句都不说,运行时悄悄写坏别人的内存——这是 C/C++ 最经典的灾难来源。有界数组模板把数组包进一个类里,在每个入口检查下标:越界就报错、拒绝写入、返回安全值。它是本章的"结业作品",一次把四样本事都用上:类模板(12.2.1)、非类型参数 int size(12.2.4)、默认模板参数、operator[] 必须成员(11.2.3)。它也是"封装"最有用的一次现身——把一条规矩(必须在下标范围内)封进了类型里。

📝 本节和前面几节的关系

12.2 组到这里收官:12.2.1 学语法、12.2.2 写简单类模板、12.2.3/12.2.4 加配置,本节把它们合起来造一件真能用的工具。 往回收旧账:10.1.1 类概述说过"好类会守规矩"——本节兑现它:规矩就是"下标不能越界",而类让这条规矩有了执行的场所;11.2.3 重载运算符的运算讲过"operator[] 必须是成员函数";7.1 一维数组讲的是"裸数组有多大、多危险",本节是它的"带安全带版本"。

一、什么叫"有界"

先看没有界的世界:

int a[5] = { 10, 20, 30, 40, 50 };

a[0] = 99;        // ✓ 正常
a[4] = 99;        // ✓ 最后一个

a[5]  = 1;        // ✗ 已经是"数组后面的内存"了 —— 编译器不报错
a[-1] = 1;        // ✗ 同上
a[1000] = 1;      // ✗ 同上,可能直接崩溃,也可能悄悄改坏别的变量

裸数组的四个问题:

问题 后果
长度不跟着走 int a[5] 传给函数就退化成 int *,长度没了(7.5.1)
越界不检查 写坏别人的内存,报错延迟、原因难找
= 不能整体赋值 int b[5]; b = a; 编译错(数组不是可赋值对象)
不能返回 函数不能 return 一个数组

"有界数组"要做的:把数组装进类里,把长度和检查一起收进去。

"有界(bounded)"的含义:这个容器知道自己有多大,并且守住这条线。

二、设计:三个决定

# 决定 理由
① 长度用非类型模板参数 template <typename T, int size> 长度是编译期常量,可以直接拿来声明 T data[size];不需要 new/delete(10.3 的账就免了)
② 元素类型用类型参数 typename T 什么类型都能装
③ 越界怎么办 报错 + 拒绝/返回安全值 教材风格的简单做法。真实项目会抛异常(第 15 章讲);关键是"绝不偷偷读写非法内存"

还有一个细节必须说清:operator[] 查不查? [] 是"快"的写法,at() 是"安全"的写法——这正是 std::vector 的做法(v[i] 不检查、v.at(i) 检查)。 因为 [] 返回的是引用(11.2.3),越界时你没法给出一个"安全的返回值"——返回哪个引用都是错的(返回 data[0] 就改坏了第一个元素)。 所以本节的做法:[] 保持"像数组一样快、不做检查",而把带检查的读写做成 get() / set()——和标准库的 at() 是同一个思路。

三、完整实现

#include <iostream>
using namespace std;

template <typename T = int, int size = 10>
class BoundedArray {
private:
    T   data[size];
    int used;                     // 实际"用到了第几个"(为了 show 时好看)

public:
    BoundedArray() : used(0) {
        for (int i = 0; i < size; i++) data[i] = T();     // ★ 用 T 的零值初始化
    }

    int length() const { return size; }
    int count()  const { return used; }

    // ★ 带边界检查的写:越界直接拒绝,返回 false
    bool set(int index, T value) {
        if (index < 0 || index >= size) {
            cout << "!set(" << index << ") 越界:合法范围 0~" << size - 1
                 << ",本次写入被拒绝" << endl;
            return false;
        }
        data[index] = value;
        if (index + 1 > used) used = index + 1;
        return true;
    }

    // ★ 带边界检查的读:越界返回 T 的零值,绝不读非法内存
    T get(int index) const {
        if (index < 0 || index >= size) {
            cout << "!get(" << index << ") 越界:合法范围 0~" << size - 1
                 << ",返回零值" << endl;
            return T();
        }
        return data[index];
    }

    // 和 std::vector 一样:[] 不做检查(快)。要检查请用 get()
    T &operator[](int index) { return data[index]; }

    T maxValue() const {
        T m = data[0];
        for (int i = 1; i < used; i++) if (data[i] > m) m = data[i];
        return m;
    }

    T sum() const {
        T s = T();
        for (int i = 0; i < used; i++) s += data[i];
        return s;
    }

    void show() const {
        cout << "[" << size << "] ";
        for (int i = 0; i < used; i++) cout << data[i] << " ";
        cout << endl;
    }
};

逐块说明:

部位 说明
T data[size]; size 是编译期常量,所以能当数组维度——这是非类型模板参数的核心价值(12.2.4)
data[i] = T(); T() = T 的零值:int→0、double→0.0。不能写 = 0(12.1.2 讲过)
bool set(...) 返回值告诉你成没成功——比"悄悄失败"好得多
T get(...) const 返回值(不是引用),所以越界时能安全地返回 T()
T &operator[](...) 返回引用(11.2.3),所以 [] 没法做越界保护——这正是 std::vector 也这么选的原因
used 不是必须的(真实容器会区分"容量"与"已用");这里只是让 show 好看

四、动手 Demo

int main() {
    BoundedArray<int, 5> a;

    for (int i = 0; i < 5; i++) a.set(i, (i + 1) * 10);
    a.show();                                   // [5] 10 20 30 40 50

    cout << "a[2] = "   << a[2]   << endl;      // 直接下标(不检查,快)
    cout << "最大值 = " << a.maxValue() << endl;
    cout << "总和 = "   << a.sum()      << endl;

    cout << "── 故意越界 ──" << endl;
    cout << "读到 " << a.get(10) << endl;       // 越界 → 提示 + 返回 0
    a.set(-1, 999);                             // 越界 → 提示 + 拒绝

    cout << "── 越界之后,数组完好无损 ──" << endl;
    a.show();

    BoundedArray<double, 3> b;
    b.set(0, 1.5);
    b.set(1, 2.7);
    b.set(2, 0.3);
    b.show();
    cout << "b 最大值 = " << b.maxValue()
         << ",长度 = "  << b.length() << endl;
    return 0;
}

看到什么算成功:

[5] 10 20 30 40 50 
a[2] = 30
最大值 = 50
总和 = 150
── 故意越界 ──
!get(10) 越界:合法范围 0~4,返回零值
读到 0
!set(-1) 越界:合法范围 0~4,本次写入被拒绝
── 越界之后,数组完好无损 ──
[5] 10 20 30 40 50 
[3] 1.5 2.7 0.3 
b 最大值 = 2.7,长度 = 3

读结果:

现象 说明
BoundedArray<int, 5> 与 BoundedArray<double, 3> 都能用 两种类型 × 两种长度 = 两个独立的类,各自有各自的 data(12.2 性质 2)
越界时程序没崩、数组没坏 检查把非法操作挡在了门外——这就是"有界"的价值
a[2] 直接给 30,没有任何提示 [] 故意不检查(和 std::vector 一致)——"快"是它的定位
b 最大值 = 2.7 maxValue 里的 > 由 double 自己实现

30 秒实验:

  1. 把 set 里那个 if 整段注释掉,重新编译运行。
  2. 看 a.set(-1, 999) 那一步——它"成功"了,但它其实写在了 data[-1](数组前面那块内存)。
  3. 再看 a.show():数组看起来可能没事(因为 data[-1] 恰好落在 used 或别的成员上)——这就是越界最难查的地方:它常常"看起来没事"。
  4. 把 if 加回去,一切恢复正常。
  5. 换个更直观的:把 cout << a.get(10) 换成 cout << a[10](用不检查的 [])——它同样"不报错"。这就是 std::vector 给你留 at() 的原因。

五、这件作品"合"起来的四件事

用到的知识 出处 在代码里的位置
类模板 12.2.1 template <typename T, int size> class BoundedArray
非类型模板参数 12.1.2 实例01、12.2.4 int size,直接当数组维度
默认模板参数 12.2.3 / 12.2.4 T = int, int size = 10 → 可以写 BoundedArray<>
[] 必须成员、返回引用 11.2.3 T &operator[](int index)
用 T() 取零值 12.1.2 data[i] = T();、T s = T();
封装"不变式" 10.1.1 规矩="下标必须在范围内",由 set / get 守住

最后一行是本节真正的收获:

10.1.1 说"好的类会守规矩"。前面十章里,"规矩"大多是"数据别被乱改"。 到了"有界数组",规矩第一次变成一条"动态的、需要每次操作都检查的约束"——而类,正是唯一能强制它的地方。

六、常见误区

  1. "有了有界数组就不怕越界了"——不对。operator[] 依然不检查(和 std::vector 一样)。要安全就用 get()(等价于标准库的 at())。 这是性能与安全的取舍,不是"我做得不够仔细"。
  2. "越界返回 0 是最佳做法"——这是本 Demo 的简化选择。真实项目中,"越界"是严重的程序错误,应该抛异常(第 15 章)或直接终止,而不是给个假值让程序继续跑——因为继续跑下去的错误会传播得更远。
  3. "size 可以在运行时改"——不行。非类型模板参数是编译期常量。要"运行时可变长度"的容器,得用 new 动态分配(那是 std::vector 的活)。
  4. "BoundedArray a; 能靠默认值省掉尖括号"——不能。必须写 BoundedArray<> a;(12.2.3 铁律 3)。
  5. "operator[] 里也加个检查不就行了"——技术上可以,但语义上说不通:[] 返回引用,越界时你返回哪个引用都是错的(返回 data[0] 会让 a[100] = 1 改坏 a[0])。所以标准库的选择是:[] 不查、at() 查。
  6. "这个类可以当通用容器用"——它的定位是"固定容量、极快、无堆分配"。要能长大的容器,用标准库的 vector(12.3 会提到)。有界数组的价值在于"零开销 + 边界安全"这个组合。
自测三问(点开对答案) 1. `T data[size];` 为什么能把 `size` 直接写在方括号里?——因为 **`size` 是非类型模板参数,是"编译期常量"**。**普通变量不能当数组维度**,但编译期常量可以(这也是非类型模板参数最重要的用途)。 2. 为什么 `operator[]` 不做越界检查?——因为 **`[]` 要返回引用**(11.2.3),**越界时拿不出一个"安全的引用"**(返回任何元素的引用都会把那个元素暴露给错误写入)。**所以标准库的分工是:`v[i]` 不检查(快)、`v.at(i)` 检查(安全)**。 3. 这个"有界数组"到底"有界"在哪?——**它知道自己有多大(`size` 是编译期常量),并在带检查的入口(`get` / `set`)守住这条线**:越界时**拒绝写入、返回安全值**,**绝不偷偷读写非法内存**。**把"规矩"封进类型里——这就是封装最实用的一次现身。**

相关笔记