Effective C++ 条款28:避免使用 handles 指向对象内部
避免返回 handles(包括引用、指针、迭代器)指向对象内部。遵守这个条款可增加封装性,帮助 const 成员函数的行为像个 const,并将发生"虚吊号码牌"(dangling handles)的可能性降至最低。
一、什么是 handles?
在 C++ 中,handles 是指用于访问对象内部数据的机制,主要包括:
| Handle 类型 | 示例 | 风险等级 |
|---|---|---|
| 引用(Reference) | int& getData() |
高 |
| 指针(Pointer) | int* getData() |
高 |
| 迭代器(Iterator) | std::vector<int>::iterator begin() |
中 |
返回 handles 指向对象内部成分,相当于把对象的内部实现细节暴露给了外部,这会破坏封装性。
二、封装性破坏的典型案例
2.1 基本示例:矩形类
cpp
// ❌ 不好的设计:返回内部成员的引用
class Point {
public:
Point(int xVal, int yVal) : x(xVal), y(yVal) {}
// 危险!暴露了内部数据的可写引用
int& getX() { return x; }
int& getY() { return y; }
private:
int x, y;
};
class Rectangle {
public:
Rectangle(const Point& ul, const Point& lr)
: upperLeft(ul), lowerRight(lr) {}
// ❌ 返回内部成员的引用,封装性被破坏
Point& getUpperLeft() { return upperLeft; }
Point& getLowerRight() { return lowerRight; }
private:
Point upperLeft;
Point lowerRight;
};
// 客户端代码
Rectangle rect(Point(0, 0), Point(100, 100));
// 可以直接修改矩形内部状态!封装性完全被破坏了
rect.getUpperLeft().getX() = 50; // 修改了私有成员!
rect.getUpperLeft() = Point(10, 10); // 直接替换了私有成员!
2.2 更好的设计:返回副本或 const 引用
cpp
// ✅ 好的设计:保持封装性
class Point {
public:
Point(int xVal, int yVal) : x(xVal), y(yVal) {}
// 返回值的副本(对于小对象)
int getX() const { return x; }
int getY() const { return y; }
// 或者提供受控的修改接口
void setX(int newX) { x = newX; }
void setY(int newY) { y = newY; }
private:
int x, y;
};
class Rectangle {
public:
Rectangle(const Point& ul, const Point& lr)
: upperLeft(ul), lowerRight(lr) {}
// ✅ 返回副本,完全安全
Point getUpperLeft() const { return upperLeft; }
Point getLowerRight() const { return lowerRight; }
// 提供受控的修改接口
void setUpperLeft(const Point& p) { upperLeft = p; }
void setLowerRight(const Point& p) { lowerRight = p; }
private:
Point upperLeft;
Point lowerRight;
};
三、const 正确性问题
3.1 问题演示
cpp
class String {
public:
String(const char* str) : data(str) {}
// ❌ 问题:const 成员函数返回了非 const 引用
char& operator[](size_t index) const {
return data[index]; // 返回了内部数据的非 const 引用
}
private:
char* data;
};
// 客户端代码
const String greeting("Hello");
// greeting 是 const,理论上不应该被修改
// 但通过返回的引用,我们可以修改它!
greeting[0] = 'J'; // 现在 greeting 变成了 "Jello"!
// 这违反了 const 的正确性:const 对象被修改了!
3.2 解决方案:const 重载
cpp
// ✅ 正确的做法:提供 const 和非 const 两个版本
class String {
public:
String(const char* str) : data(new char[strlen(str) + 1]) {
strcpy(data, str);
}
~String() { delete[] data; }
// 非 const 版本:返回非 const 引用
char& operator[](size_t index) {
return data[index];
}
// const 版本:返回 const 引用(或值)
const char& operator[](size_t index) const {
return data[index];
}
private:
char* data;
};
// 现在 const 正确性得到了保证
String mutableStr("Hello");
mutableStr[0] = 'J'; // ✅ 正确:非 const 对象可以被修改
const String constStr("Hello");
// constStr[0] = 'J'; // ❌ 编译错误!const 对象不能被修改
3.3 返回 const 引用的情况
对于较大的对象,返回副本可能效率低下。此时可以返回 const 引用,但要确保引用的生命周期:
cpp
class Image {
public:
Image(int w, int h) : width(w), height(h), pixels(w * h) {}
// ✅ 返回 const 引用:对于大对象效率更高
const std::vector<Pixel>& getPixels() const {
return pixels;
}
// ❌ 不要返回非 const 引用
// std::vector<Pixel>& getPixels() { return pixels; }
// 如果需要修改,提供受控接口
void setPixel(int x, int y, const Pixel& p) {
pixels[y * width + x] = p;
}
private:
int width, height;
std::vector<Pixel> pixels;
};
四、悬空 handles(Dangling Handles)
4.1 什么是悬空 handles?
当返回的 handle 所指向的对象被销毁后,该 handle 就变成了"悬空"的,继续使用它会导致未定义行为。
cpp
class Widget {
public:
Widget(int v) : value(v) {}
// ❌ 危险:返回内部成员的引用
int& getValue() { return value; }
private:
int value;
};
// 悬空引用示例
int& getWidgetValue() {
Widget w(42); // 局部对象
return w.getValue(); // 返回局部对象的引用!
} // w 在这里被销毁,返回的引用悬空!
// 使用
int& val = getWidgetValue(); // val 现在是悬空引用!
// std::cout << val; // 未定义行为!可能崩溃、可能输出垃圾值
4.2 更隐蔽的悬空问题
cpp
class Container {
public:
void add(int val) { data.push_back(val); }
// ❌ 返回内部 vector 元素的引用
int& get(size_t index) { return data[index]; }
// 任何可能导致 vector 重新分配的操作
void reserve(size_t n) { data.reserve(n); }
private:
std::vector<int> data;
};
Container c;
c.add(1);
c.add(2);
int& ref = c.get(0); // 获取第一个元素的引用
std::cout << ref << "\n"; // 输出 1
c.reserve(100); // 可能导致 vector 重新分配内存!
// ref 现在可能悬空!因为 vector 可能搬家了
std::cout << ref << "\n"; // 未定义行为!
4.3 字符串处理的经典陷阱
cpp
class Person {
public:
Person(const std::string& name) : name_(name) {}
// ❌ 极其危险:返回内部 string 的 c_str()
const char* getName() const {
return name_.c_str(); // 返回指向内部数据的指针
}
// 任何修改 name_ 的操作
void setName(const std::string& name) { name_ = name; }
private:
std::string name_;
};
Person person("Alice");
const char* name = person.getName();
std::cout << name << "\n"; // 输出 Alice
person.setName("Bob"); // 修改了内部 string
// name 现在可能悬空!string 可能重新分配了内存
std::cout << name << "\n"; // 未定义行为!
五、实际应用场景
场景1:矩阵类的设计
cpp
// ❌ 不好的设计
class Matrix {
public:
Matrix(int rows, int cols) : rows_(rows), cols_(cols), data_(rows * cols) {}
// 危险!返回内部数据的引用
double& at(int row, int col) { return data_[row * cols_ + col]; }
std::vector<double>& getData() { return data_; }
private:
int rows_, cols_;
std::vector<double> data_;
};
// ✅ 好的设计
class Matrix {
public:
Matrix(int rows, int cols) : rows_(rows), cols_(cols), data_(rows * cols) {}
// 返回值的副本(对于单个元素)
double at(int row, int col) const {
return data_[row * cols_ + col];
}
// 提供受控的修改接口
void set(int row, int col, double value) {
data_[row * cols_ + col] = value;
}
// 如果需要批量访问,提供安全的访问器
class RowAccessor {
public:
RowAccessor(Matrix& m, int row) : matrix_(m), row_(row) {}
double operator[](int col) const { return matrix_.at(row_, col); }
void set(int col, double value) { matrix_.set(row_, col, value); }
private:
Matrix& matrix_;
int row_;
};
RowAccessor operator[](int row) { return RowAccessor(*this, row); }
// 返回 const 引用(只读访问)
const std::vector<double>& data() const { return data_; }
private:
int rows_, cols_;
std::vector<double> data_;
};
// 使用
Matrix m(3, 3);
m.set(0, 0, 1.0);
m[0].set(1, 2.0);
double val = m.at(0, 0); // 安全访问
场景2:配置管理器
cpp
// ❌ 不好的设计:返回内部 map 的引用
class Config {
public:
void load(const std::string& file);
// 危险!外部可以直接修改内部配置
std::map<std::string, std::string>& getSettings() { return settings_; }
private:
std::map<std::string, std::string> settings_;
};
// ✅ 好的设计
class Config {
public:
void load(const std::string& file);
// 安全的访问方式
std::string get(const std::string& key, const std::string& defaultVal = "") const {
auto it = settings_.find(key);
return (it != settings_.end()) ? it->second : defaultVal;
}
void set(const std::string& key, const std::string& value) {
settings_[key] = value;
}
bool has(const std::string& key) const {
return settings_.find(key) != settings_.end();
}
// 如果需要遍历,提供受控的迭代器访问
using const_iterator = std::map<std::string, std::string>::const_iterator;
const_iterator begin() const { return settings_.begin(); }
const_iterator end() const { return settings_.end(); }
private:
std::map<std::string, std::string> settings_;
};
场景3:数据库结果集
cpp
// ❌ 不好的设计
class QueryResult {
public:
// 返回内部行的引用,可能导致悬空
Row& getRow(size_t index) { return rows_[index]; }
// 返回内部数据的指针
const char* getValue(size_t row, size_t col) {
return rows_[row][col].c_str();
}
private:
std::vector<Row> rows_;
};
// ✅ 好的设计
class QueryResult {
public:
// 返回值的副本(字符串)
std::string getValue(size_t row, size_t col) const {
return rows_[row][col];
}
// 或者使用 string_view(C++17)进行零拷贝只读访问
std::string_view getValueView(size_t row, size_t col) const {
return rows_[row][col];
}
// 提供安全的遍历接口
void forEachRow(std::function<void(const Row&)> callback) const {
for (const auto& row : rows_) {
callback(row);
}
}
size_t rowCount() const { return rows_.size(); }
size_t colCount() const { return rows_.empty() ? 0 : rows_[0].size(); }
private:
std::vector<Row> rows_;
};
六、例外情况
6.1 operator\[\] 的特殊性
cpp
// 对于容器类,operator[] 通常需要返回引用以支持赋值
class Array {
public:
double& operator[](size_t index) { return data_[index]; }
const double& operator[](size_t index) const { return data_[index]; }
private:
std::vector<double> data_;
};
// 这是合理的,因为:
// 1. 这是容器的标准接口
// 2. 用户期望 arr[i] = 42 能工作
// 3. 提供了 const 版本保证 const 正确性
6.2 智能指针和代理对象
cpp
// 使用代理对象来安全地暴露内部数据
class SafeContainer {
public:
class Proxy {
public:
Proxy(SafeContainer& c, size_t idx) : container_(c), index_(idx) {}
// 支持读取
operator int() const { return container_.data_[index_]; }
// 支持写入(可以添加验证逻辑)
Proxy& operator=(int value) {
if (value < 0) {
throw std::invalid_argument("Negative values not allowed");
}
container_.data_[index_] = value;
return *this;
}
private:
SafeContainer& container_;
size_t index_;
};
Proxy operator[](size_t index) {
return Proxy(*this, index);
}
int get(size_t index) const { return data_[index]; }
private:
std::vector<int> data_;
};
SafeContainer sc;
sc[0] = 42; // 通过代理对象安全赋值
int val = sc[0]; // 通过代理对象读取
6.3 PIMPL 惯用法中的 handles
cpp
// PIMPL(Pointer to Implementation)惯用法中,
// 返回 impl 指针是合理的,因为 impl 的生命周期由外部对象管理
class Widget {
public:
Widget();
~Widget();
// 这是合理的:impl 的生命周期与 Widget 绑定
WidgetImpl* getImpl() { return impl_.get(); }
private:
std::unique_ptr<WidgetImpl> impl_;
};
七、总结与最佳实践
| 原则 | 说明 |
|---|---|
| 避免返回非 const 引用/指针 | 这会破坏封装性,允许外部直接修改内部状态 |
| const 成员函数返回 const 引用/值 | 保持 const 正确性 |
| 警惕悬空 handles | 确保返回的 handle 不会比对象本身活得更长 |
| 提供受控的修改接口 | 通过 setter 方法控制对内部数据的修改 |
| 考虑返回副本 | 对于小对象,返回副本是最安全的选择 |
| 使用代理对象 | 在需要灵活访问时使用代理模式 |
请记住:
- 避免返回 handles(引用、指针、迭代器)指向对象内部。
- 遵守这个条款可增加封装性,帮助 const 成员函数的行为像个 const。
- 将发生"虚吊号码牌"(dangling handles)的可能性降至最低。
参考阅读:
- 《Effective C++》第三版,条款28
- 《C++ Primer》关于封装和 const 正确性的章节
- C++ Core Guidelines: F.16, F.17, Con.1
如果这篇文章对你有帮助,欢迎点赞、收藏和转发!有任何问题欢迎在评论区留言讨论。