5. 重载下标运算符:重载[]运算符,实现安全的数组访问与边界检查
下标运算符 [] 是 C++ 里最常用的运算符之一。你想想看,我们每天都在用 arr[i] 访问数组元素,但原生数组有个大问题——它不检查边界。我早期做项目时就被这个坑过,数组越界访问导致数据被静默破坏,查了两天才定位到问题。
说白了,重载 [] 运算符的目的就是:让自定义类型也能像数组一样使用,同时加上边界检查,避免踩内存雷。
5.1 基本语法与约束
重载 [] 运算符的语法很直接。它必须是一个成员函数,不能是友元函数或普通函数。这是 C++ 的规定,因为下标访问天然与对象实例绑定。
class SafeArray {
private:
int* data;
size_t size;
public:
// 重载 [] 运算符
int& operator[](size_t index) {
// 这里可以加边界检查
return data[index];
}
// const 版本,用于 const 对象
const int& operator[](size_t index) const {
return data[index];
}
};
注意看,我写了两个版本:一个返回 int&,一个返回 const int&。为什么要两个?因为 const 对象不能修改内部数据,编译器会根据对象是否 const 自动选择对应的版本。
5.2 边界检查的实现
原生数组不检查边界,这是 C/C++ 的「特色」。但我们可以通过重载 [] 来补上这个短板。我在项目中实现过一个带边界检查的数组类,核心逻辑如下:
#include <stdexcept>
#include <cassert>
class SafeArray {
private:
int* data;
size_t size;
public:
SafeArray(size_t n) : size(n) {
data = new int[size]();
}
~SafeArray() {
delete[] data;
}
// 带边界检查的下标访问
int& operator[](size_t index) {
// 方式一:抛出异常(推荐用于库代码)
if (index >= size) {
throw std::out_of_range("Index out of range");
}
return data[index];
}
// const 版本
const int& operator[](size_t index) const {
if (index >= size) {
throw std::out_of_range("Index out of range");
}
return data[index];
}
// 获取数组大小
size_t getSize() const { return size; }
};
这里我用了 std::out_of_range 异常。为什么选异常而不是直接 assert?嗯,这要看场景。
- 调试阶段:用
assert快速定位问题,程序直接崩溃,方便调试。 - 发布阶段:用异常或返回错误码,让调用方有机会处理越界情况。
我个人建议:库代码用异常,应用代码用 assert。因为库使用者可能不想被异常打断,而应用开发者更希望尽早发现问题。
5.3 两种边界检查策略对比
| 策略 | 实现方式 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|---|
| 抛出异常 | throw std::out_of_range(...) |
库代码、生产环境 | 调用方可捕获处理 | 性能开销较大 |
| 断言 | assert(index < size) |
调试阶段、内部使用 | 零开销(Release 下) | Release 下不检查 |
| 返回默认值 | if (越界) return dummy; |
容错场景 | 程序不崩溃 | 静默错误,难排查 |
我曾经在一个嵌入式项目里用过「返回默认值」策略,结果数据被静默篡改,设备运行了三天才暴露问题。从那以后,我再也不敢在生产代码里用这种「温柔」的越界处理方式了。
5.4 多维下标访问
二维数组怎么重载 []?你可能会想写 operator[][],但 C++ 不支持这种语法。正确的做法是:让第一个 [] 返回一个中间对象,第二个 [] 再访问实际数据。
class Matrix {
private:
int* data;
size_t rows, cols;
// 行代理类
class RowProxy {
private:
int* rowData;
size_t cols;
public:
RowProxy(int* data, size_t c) : rowData(data), cols(c) {}
int& operator[](size_t col) {
if (col >= cols) throw std::out_of_range("Column out of range");
return rowData[col];
}
};
public:
Matrix(size_t r, size_t c) : rows(r), cols(c) {
data = new int[rows * cols]();
}
~Matrix() { delete[] data; }
// 第一维访问,返回 RowProxy
RowProxy operator[](size_t row) {
if (row >= rows) throw std::out_of_range("Row out of range");
return RowProxy(data + row * cols, cols);
}
};
使用时就像二维数组一样自然:
Matrix mat(3, 4);
mat[1][2] = 42; // 先调用 operator[] 得到 RowProxy,再调用 RowProxy::operator[]
这个技巧叫「代理类」或「中间对象」。我第一次看到这种写法时觉得挺绕,但用顺手了就会发现它非常优雅。STL 里的 std::vector<std::vector<T>> 其实也是类似思路,只不过它没有边界检查。
5.5 知识结构图
下面这张图总结了重载下标运算符的核心要点:
5.6 避坑指南
- 忘记写 const 版本:const 对象无法调用非 const 成员函数,编译直接报错。后来我养成了「先写 const 版本,再写非 const 版本」的习惯。
- 返回临时对象而非引用:如果返回
int而不是int&,arr[i] = 42这种赋值操作会编译失败。因为临时对象是右值,不能赋值。 - 边界检查只做一半:只检查上界不检查下界。虽然
size_t是无符号类型,但如果你用int做索引,负值会变成很大的正数,轻松绕过检查。
#ifdef _DEBUG if (index >= size) throw ...#endif这样既保证了调试阶段的健壮性,又避免了发布版本的性能损失。
5.7 总结
重载下标运算符,说白了就是给自定义类型穿上「数组的外衣」,同时加上边界检查这个「安全气囊」。核心要点就三个:
- 必须写成员函数,不能偷懒写成友元。
- 提供 const 和非 const 两个版本,这是接口完整性的体现。
- 边界检查要到位,异常或断言选一个,别用静默错误。
嗯,掌握了这些,你就能写出既安全又像原生数组一样好用的自定义容器了。下次写代码时,不妨试试给你的类加上 operator[],你会发现代码的可用性提升一大截。