PAYATHON 2026

为什么 C++ 的 const 成员函数返回代理对象时需要 const_cast?

数据小陈

结论

编译失败是因为 Board::operator() const 通过 const 对象访问普通数组成员 m_array,得到的元素表达式具有 const uint32_t 类型,而 OptPieceRef 的构造函数要求 uint32_t&。非 const 引用不能绑定到 const 元素。

const_cast 可以绕过这项类型检查。转换本身不构成未定义行为,但如果随后通过代理修改原本就是 const 的对象,就会产生未定义行为。按值返回 const OptPieceRef 也不能可靠地阻止修改。

建议让非 const 重载返回可写代理,让 const 重载返回 std::optional<Piece> 值,或者返回内部持有 const uint32_t& 的专用只读代理。

为什么两个重载的表现不同

区别在于构造函数的第一个实参:

explicit OptPieceRef(uint32_t& array, const size_t& index);

在非 const 成员函数中:

Chess::OptPieceRef Chess::Board::operator()(
    const size_t& row, const size_t& col) {
    return OptPieceRef(m_array[row], col);
}

m_array[row] 是 uint32_t 左值,可以绑定到 uint32_t&。

在 const 成员函数中,this 指向 const Board,通过它访问普通数据成员时会受到 const 限制:

const Chess::OptPieceRef Chess::Board::operator()(
    const size_t& row, const size_t& col) const {
    return OptPieceRef(m_array[row], col); // 无法绑定到 uint32_t&
}

因此,m_array[row] 不能传给要求可写引用的构造函数。即使调用方持有的是非 const 对象,执行这个 const 成员函数时,这条访问路径仍然受到 const 限制。

返回类型前面的 const 只限定返回的代理对象,不改变构造函数参数的绑定规则。

为什么 OptPieceRef::value() const 却能构造 PieceRef

两个类都有名为 m_array 的成员,但声明不同:

// Board 的成员:对象内部的数组
uint32_t m_array[8];

// OptPieceRef 的成员:引用其他对象的引用
uint32_t& m_array;

const 对象中的普通数据成员受到 const 限制,但引用成员所引用的外部对象不会因此变成 const。例如:

struct Ref {
    int& value;

    void set() const {
        value = 42;
    }
};

int number = 0;
const Ref ref{number};
ref.set(); // 合法,修改的是非 const 的 number

因此,在 OptPieceRef::value() const 中,m_array 仍然表示一个 uint32_t 左值,可以传给 PieceRef(uint32_t&, ...)。

指针成员也一样:对象为 const,不会自动使指针指向的对象变成 const。const 不会沿着引用或指针递归传播到被引用、被指向的对象。

const_cast 在什么情况下会导致未定义行为

这取决于底层对象本身是否为 const,而不只是访问它时使用的类型。

底层对象本身不是 const

Chess chess;
const Chess& view = chess;

通过 view 访问棋盘时会调用 const 重载,但实际存储属于非 const 的 chess。只要对象仍然存活、索引有效等条件成立,去除访问路径上的 const 后修改这些存储,不会仅仅因为 const_cast 而产生未定义行为。

不过,这样的接口允许调用方通过 const 访问路径修改棋盘。

底层对象本身就是 const

const Chess chess;

此时棋盘数组是 const 对象的普通子对象。把其元素转换成 uint32_t&,再通过这个引用写入,会产生未定义行为。

仅执行转换、保存引用或读取数据,不会因为去除 const 本身就产生这种未定义行为;问题在于修改实际为 const 的对象。即使程序看起来运行正常,也不能证明这种写入合法。

为什么返回 const OptPieceRef 仍然会泄露写权限

原接口按值返回:

const OptPieceRef operator()(
    const size_t& row, const size_t& col) const;

这里的顶层 const 不足以保护底层棋盘:按值进行 auto 类型推导时,顶层 const 会被去掉。在 C++17 及之后,下面的初始化还可以直接构造目标对象,不需要依赖原代码中那个接收非 const 左值引用的复制构造函数:

// 针对原接口;假设使用 C++17 或更高标准
Chess chess;
const Chess& view = chess;

auto cell = view.m_board(3, 3); // cell 的类型是 OptPieceRef
cell = std::nullopt;           // 通过 const 访问路径修改棋盘

如果底层对象本身是 const,同样的写入就会产生未定义行为:

const Chess frozen;
auto cell = frozen.m_board(3, 3);
cell = std::nullopt; // 未定义行为:修改实际为 const 的数组元素

const PieceRef value() const 也有相同的隐患:返回值仍是内部持有可写引用的代理,调用方可以通过按值接收获得非 const 代理。

修改方案:const 重载返回棋子的值

对于每格使用 4 位编码的设计,const 访问时可以直接解码为 std::optional<Piece>。这种实现比较简单,也不需要 const_cast。

先把 Board 中两个重载的声明改为:

public:
    OptPieceRef operator()(const size_t& row, const size_t& col);
    std::optional<Piece> operator()(
        const size_t& row, const size_t& col) const;

非 const 重载保留原有的可写代理返回方式。const 重载可以这样实现:

#include <stdexcept>

std::optional<Chess::Piece> Chess::Board::operator()(
    const size_t& row, const size_t& col) const {
    if (row >= 8 || col >= 8) {
        throw std::out_of_range("Board index out of range");
    }

    const uint32_t key =
        (m_array[row] >> (4 * col)) & UINT32_C(0xF);

    if (key == 12) {
        return std::nullopt;
    }

    return Piece{
        static_cast<Piece::Color>(key / 6),
        static_cast<Piece::Kind>(key % 6)
    };
}

这段代码沿用原有编码约定:0 到 11 表示棋子,12 表示空格,前提是其他写入操作始终维护这一约定。如果底层数据可能来自外部输入,还应明确如何处理 13 到 15。

调用方式与原接口仍然接近:

Chess chess;
chess.m_board(3, 3) = Chess::Piece{
    Chess::Piece::Color::White,
    Chess::Piece::Kind::Rook
};

const Chess& view = chess;
auto cell = view.m_board(3, 3); // std::optional<Chess::Piece>

if (cell.has_value()) {
    auto kind = cell.value().kind;
    (void)kind;
}

cell = std::nullopt; // 修改的是局部副本

这种方式返回调用时的值,棋盘之后发生变化,已返回的副本不会更新。如果调用方需要始终读取当前棋盘状态,可以设计单独的只读代理,例如 ConstOptPieceRef,让它保存 const uint32_t&,并且只提供读取操作。它的 value() 也应返回 Piece 值或只读的 ConstPieceRef,使所有访问层级保持只读。

还需要注意什么

非 const 重载同样应检查 row 和 col。row 越界会造成数组越界访问,col 越界可能使移位次数达到或超过类型位宽,产生未定义行为。

代理内部保存棋盘存储的引用,其使用期限不能超过所属棋盘的生命周期。返回棋子值则没有这项引用生命周期限制。

也不建议为了保留现有构造函数而把整个数组声明为 mutable。这会允许通过 const 对象修改棋盘状态,改变 const 接口的语义。对于代表棋局内容的存储,更合适的做法是明确区分可写代理和只读访问。

备注:内容仅供参考。