"有界数组"=一个知道自己边界、并且不让越界的数组。普通数组 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 秒实验:
- 把
set里那个if整段注释掉,重新编译运行。 - 看
a.set(-1, 999)那一步——它"成功"了,但它其实写在了data[-1](数组前面那块内存)。 - 再看
a.show():数组看起来可能没事(因为data[-1]恰好落在used或别的成员上)——这就是越界最难查的地方:它常常"看起来没事"。 - 把
if加回去,一切恢复正常。 - 换个更直观的:把
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 说"好的类会守规矩"。前面十章里,"规矩"大多是"数据别被乱改"。 到了"有界数组",规矩第一次变成一条"动态的、需要每次操作都检查的约束"——而类,正是唯一能强制它的地方。
六、常见误区
- "有了有界数组就不怕越界了"——不对。
operator[]依然不检查(和std::vector一样)。要安全就用get()(等价于标准库的at())。 这是性能与安全的取舍,不是"我做得不够仔细"。 - "越界返回 0 是最佳做法"——这是本 Demo 的简化选择。真实项目中,"越界"是严重的程序错误,应该抛异常(第 15 章)或直接终止,而不是给个假值让程序继续跑——因为继续跑下去的错误会传播得更远。
- "
size可以在运行时改"——不行。非类型模板参数是编译期常量。要"运行时可变长度"的容器,得用new动态分配(那是std::vector的活)。 - "
BoundedArray a;能靠默认值省掉尖括号"——不能。必须写BoundedArray<> a;(12.2.3 铁律 3)。 - "
operator[]里也加个检查不就行了"——技术上可以,但语义上说不通:[]返回引用,越界时你返回哪个引用都是错的(返回data[0]会让a[100] = 1改坏a[0])。所以标准库的选择是:[]不查、at()查。 - "这个类可以当通用容器用"——它的定位是"固定容量、极快、无堆分配"。要能长大的容器,用标准库的
vector(12.3 会提到)。有界数组的价值在于"零开销 + 边界安全"这个组合。
自测三问(点开对答案)
1. `T data[size];` 为什么能把 `size` 直接写在方括号里?——因为 **`size` 是非类型模板参数,是"编译期常量"**。**普通变量不能当数组维度**,但编译期常量可以(这也是非类型模板参数最重要的用途)。 2. 为什么 `operator[]` 不做越界检查?——因为 **`[]` 要返回引用**(11.2.3),**越界时拿不出一个"安全的引用"**(返回任何元素的引用都会把那个元素暴露给错误写入)。**所以标准库的分工是:`v[i]` 不检查(快)、`v.at(i)` 检查(安全)**。 3. 这个"有界数组"到底"有界"在哪?——**它知道自己有多大(`size` 是编译期常量),并在带检查的入口(`get` / `set`)守住这条线**:越界时**拒绝写入、返回安全值**,**绝不偷偷读写非法内存**。**把"规矩"封进类型里——这就是封装最实用的一次现身。**- ☐ 12.3 模板的使用(下一节)
相关笔记
- 为具体类型的参数提供默认值 —— 上一节:为什么把具体类型设为默认
- 模板的使用 —— 下一节:什么时候必须"定制"
- 函数模板的作用(含实例01) ——
int N的第一次登场 - 重载运算符的运算 ——
operator[]为什么必须成员 - 一维数组 —— 裸数组的四个毛病
- 类概述 —— "好类会守规矩"
- 🔷 C++基础 · 索引 —— 全课路线图