为什么 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 接口的语义。对于代表棋局内容的存储,更合适的做法是明确区分可写代理和只读访问。
备注:内容仅供参考。
