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 自动选择对应的版本。

个人习惯:我每次重载 [] 时,都会同时提供 const 和非 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 知识结构图

下面这张图总结了重载下标运算符的核心要点:

重载下标运算符 [] 知识结构 operator[] 重载 语法约束 • 必须是成员函数 • 不能是友元/普通函数 • 参数类型通常为 size_t 边界检查策略 • 抛出异常 (std::out_of_range) • 断言 (assert) • 返回默认值(不推荐) const 版本 • 返回 const T& • 用于 const 对象 • 只读访问 多维下标访问 • 使用代理类 (Proxy Pattern) • 第一个 [] 返回中间对象 • 第二个 [] 访问实际数据 返回值类型 • 返回引用 (T&) • 支持赋值操作 • 避免值拷贝开销

5.6 避坑指南

我曾经踩过的坑:
  • 忘记写 const 版本:const 对象无法调用非 const 成员函数,编译直接报错。后来我养成了「先写 const 版本,再写非 const 版本」的习惯。
  • 返回临时对象而非引用:如果返回 int 而不是 int&arr[i] = 42 这种赋值操作会编译失败。因为临时对象是右值,不能赋值。
  • 边界检查只做一半:只检查上界不检查下界。虽然 size_t 是无符号类型,但如果你用 int 做索引,负值会变成很大的正数,轻松绕过检查。
一个小技巧:如果你想让边界检查在调试时生效、发布时消失,可以用宏来控制:
#ifdef _DEBUG
if (index >= size) throw ...
#endif
这样既保证了调试阶段的健壮性,又避免了发布版本的性能损失。

5.7 总结

重载下标运算符,说白了就是给自定义类型穿上「数组的外衣」,同时加上边界检查这个「安全气囊」。核心要点就三个:

  1. 必须写成员函数,不能偷懒写成友元。
  2. 提供 const 和非 const 两个版本,这是接口完整性的体现。
  3. 边界检查要到位,异常或断言选一个,别用静默错误。

嗯,掌握了这些,你就能写出既安全又像原生数组一样好用的自定义容器了。下次写代码时,不妨试试给你的类加上 operator[],你会发现代码的可用性提升一大截。