Effective C++ 条款22:将成员变量声明为 private
切记将成员变量声明为 private。这可赋予客户访问数据的一致性、可细微划分访问控制、允诺约束条件获得保证,并提供 class 作者以充分的实现弹性。
一、引言:封装是面向对象的基石
在 C++ 类设计中,有一个看似简单却至关重要的原则:将所有成员变量声明为 private。这不是风格偏好,而是专业素养的分水岭。
很多初学者会问:“public 不是更方便吗?直接访问省去了 getter/setter 的麻烦。” 答案是:今天的便利,会成为明天的枷锁。
二、为什么 public 数据成员是危险的?
2.1 语法一致性的破坏
// ❌ 糟糕的设计:public 数据成员classBadTemperatureSensor{public:doublecurrentTemperature;// 直接暴露doubleminTemperature;doublemaxTemperature;};// 使用方式混乱BadTemperatureSensor sensor;sensor.currentTemperature=36.5;// 直接赋值// 客户需要记住哪些是变量、哪些是函数当类中既有 public 数据成员,又有 public 成员函数时,客户必须记住:访问数据用.不加括号,访问函数用.加括号。这种不一致性增加了使用负担和出错概率。
2.2 实现细节被锁定
// ❌ public 成员锁定了实现classBadStudentRecords{public:Student students_[1000];// 固定数组,public!size_t count=0;};// 一旦要改为 vector,所有客户端代码都要修改!一旦数据成员是 public,任何内部实现的变更都会影响到所有客户端代码。这意味着你失去了优化的自由、重构的自由、演进的自由。
三、private 带来的三大核心价值
3.1 访问控制的一致性
// ✅ 优秀的设计:行为导向接口classGoodTemperatureSensor{public:doublereadTemperature()const{if(!isCalibrated_){throwstd::logic_error("传感器未校准");}returncurrentTemperature_;}boolisInSafeRange()const{returncurrentTemperature_>=minTemperature_&¤tTemperature_<=maxTemperature_;}voidcalibrate(){// 复杂的校准逻辑...isCalibrated_=true;}private:doublecurrentTemperature_;doubleminTemperature_;doublemaxTemperature_;boolisCalibrated_=false;};客户唯一需要记住的是:所有交互都通过成员函数完成。这种一致性大大降低了心智负担。
3.2 精确的读写控制
通过 private + 成员函数,你可以实现细粒度的访问控制:
| 控制级别 | 实现方式 | 应用场景 |
|---|---|---|
| 只读访问 | constgetter | 计算属性、状态查询 |
| 只写访问 | setter(无 getter) | 密码、密钥等敏感数据 |
| 读写验证 | getter + setter | 需要校验的业务数据 |
| 内部计算 | 无直接访问 | 缓存、延迟计算属性 |
classBankAccount{public:// 只读访问——余额不能被直接修改doublegetBalance()const{std::lock_guard<std::mutex>lock(mutex_);returnbalance_;}// 受控写入——业务规则校验voiddeposit(doubleamount){if(amount<=0){throwstd::invalid_argument("存款金额必须为正");}std::lock_guard<std::mutex>lock(mutex_);balance_+=amount;logTransaction("存款",amount);}// 条件操作——封装业务逻辑boolwithdraw(doubleamount){if(amount<=0){throwstd::invalid_argument("取款金额必须为正");}std::lock_guard<std::mutex>lock(mutex_);if(balance_>=amount){balance_-=amount;logTransaction("取款",amount);returntrue;}returnfalse;// 余额不足}// 计算属性——不存储,实时计算boolisOverdrawn()const{returngetBalance()<0;}private:doublebalance_=0.0;mutablestd::mutex mutex_;// mutable 允许在 const 函数中锁定voidlogTransaction(conststd::string&type,doubleamount);};3.3 不变式的维护
classRectangle{public:voidsetWidth(doublew){if(w<=0)throwstd::invalid_argument("宽度必须为正");width_=w;updateArea();// 维护不变式:area = width * height}voidsetHeight(doubleh){if(h<=0)throwstd::invalid_argument("高度必须为正");height_=h;updateArea();}doublegetArea()const{returnarea_;}private:doublewidth_;doubleheight_;doublearea_;// 缓存的面积值,必须始终保持一致voidupdateArea(){area_=width_*height_;}};💡不变式(Invariant):类在任何时候都必须满足的条件。通过 private 成员 + 受控接口,你可以在每次修改时验证并维护这些不变式。
四、protected 并不比 public 好多少
很多开发者认为protected是一个折中方案——比 public 安全,又比 private 灵活。但 Scott Meyers 明确指出:protected 成员几乎和 public 一样缺乏封装性。
// ❌ protected 的封装幻觉classBase{protected:intinternalData_;// 以为比 public 好?std::vector<int>implementationDetails_;};classDerived:publicBase{public:voidmessUp(){internalData_=-999;// 任意修改,破坏不变式implementationDetails_.clear();// 破坏基类假设}};// 问题:一旦修改 Base 的 protected 成员,所有派生类都可能需要修改!// 封装性实际上和 public 差不多差设计原则:如果派生类确实需要访问基类的某些数据,应该通过protected 的成员函数提供受控访问,而非直接暴露数据成员。
// ✅ 更好的设计classWellDesignedBase{public:virtual~WellDesignedBase()=default;protected:// 为派生类提供受控的扩展点virtualvoiddoProcess(intdata){implementationDetail_=data;}// 只读访问conststd::vector<int>&getInternalData()const{returninternalData_;}private:intimplementationDetail_;std::vector<int>internalData_;};五、实际应用场景
5.1 延迟初始化与缓存
classDocumentProcessor{public:voidsetContent(conststd::string&content){content_=content;invalidateCaches();// 状态变化时清理缓存}// 计算属性:看起来像数据访问,实则是计算constWordCount&getWordCount()const{if(!wordCountCache_){wordCountCache_=std::make_unique<WordCount>(analyzeWords());}return*wordCountCache_;}private:std::string content_;mutablestd::unique_ptr<WordCount>wordCountCache_;// mutable 允许延迟初始化mutableboolisAnalyzed_=false;voidinvalidateCaches(){wordCountCache_.reset();isAnalyzed_=false;}WordCountanalyzeWords()const{// 昂贵的分析操作returnWordCount(/* ... */);}};5.2 Pimpl 惯用法:极致封装
// 头文件:接口完全稳定classWellEncapsulatedClass{public:WellEncapsulatedClass(conststd::string&name);~WellEncapsulatedClass();voidperformCalculation(doubleparameter);doublegetAverage()const;boolisValid()const;private:classImpl;// 前向声明std::unique_ptr<Impl>pImpl_;// 实现完全隐藏};// 实现文件:Impl 定义在这里,客户端完全不可见classWellEncapsulatedClass::Impl{public:std::string name_;std::vector<double>measurements_;// ... 任何实现细节变更都不影响头文件};Pimpl(Pointer to Implementation)是 private 封装思想的极致体现:客户端甚至看不到类的成员变量有哪些!
5.3 线程安全封装
classThreadSafeCounter{public:voidincrement(){std::lock_guard<std::mutex>lock(mutex_);++count_;}intget()const{std::lock_guard<std::mutex>lock(mutex_);returncount_;}// 原子性的复合操作intfetchAndAdd(intvalue){std::lock_guard<std::mutex>lock(mutex_);intold=count_;count_+=value;returnold;}private:intcount_=0;mutablestd::mutex mutex_;// mutable 允许 const 方法加锁};六、常见误区
| 误区 | 真相 |
|---|---|
| “为每个字段写 getter/setter 就是封装” | 过度封装等于没有封装,应该提供行为接口而非数据接口 |
| “protected 比 public 安全” | protected 的封装性几乎和 public 一样差 |
| “struct 默认 public,所以用 struct 更方便” | struct 更适合纯数据聚合(POD),class 更适合封装对象 |
| “内联 getter 有性能优势,所以应该暴露数据” | 编译器可以内联访问函数,性能与直接访问相同 |
七、总结
核心原则
- 所有数据成员都应该是 private——无一例外
- 提供行为接口,而非数据接口——表达"做什么"而非"是什么"
- protected 并不比 public 好多少——需要访问时用 protected 成员函数
- 封装的价值在于未来的自由——你可以随时改变实现而不影响客户端
设计检查清单
classWellDesignedClass{public:// 稳定的公有接口voidperformAction();StategetState()const;boolisValid()const;// 计算属性doublegetDerivedValue()const;protected:// 为派生类提供的受控扩展点virtualvoidonStateChanged();private:// 所有数据成员都是 privatestd::string name_;std::vector<double>data_;std::unique_ptr<Implementation>pImpl_;mutablestd::mutex mutex_;};📌记住:封装的价值不在于今天能做什么,而在于明天能改变什么。将成员变量声明为 private,是面向未来软件设计的第一步。
参考与延伸阅读
- 《Effective C++》第三版,Scott Meyers,条款22
- 《C++ Primer》第五版,关于类访问控制的章节
- Sutter’s Mill: GotW #100: Compilation Firewalls
如果这篇文章对你有帮助,欢迎点赞 👍、收藏 ⭐、留言 💬!你的支持是我持续输出的动力!