C++成员变量为何要private:封装、getter/setter与重构实践

发布时间:2026/10/10 11:34:07
C++成员变量为何要private:封装、getter/setter与重构实践 去年给渲染模块做代码评审看到一个GameSettings类把分辨率、垂直同步、gamma 值全摊在 public 区调用方随手写settings.width 1920。我当时就在评审意见里写了一句《Effective C》条款二十二早就把这事讲透了——成员变量请声明为 private。这条规则表面谈的是访问级别实际谈的是“你的类到底要承受多大的变更风险”。这篇总结会把条款里的几层论证拆开讲清楚再附上我在真实项目里从 public 走到 private 的完整复盘对正在写业务类、为 getter/setter 数量困扰、或者刚被 public 成员变量坑过的 C 开发者来说应该比单纯翻条款更有参考价值。1. 条款二十二在维护什么语法一致性、访问粒度与封装自由条款二十二常被简化成一句口号“把成员变量都变 private。”但真正读条款原文会发现Scott Meyers 是给了一串完整的论证链条而不是一个简单的命令。把每个论点拆开看才能理解它为什么配得上 “effective” 这个前缀。1.1 语法一致性少记一次括号的价值先从最不起眼的论点说起。如果width是 public 成员访问它要写cfg.width后来有人给它加了 getter就变成cfg.width()。同一个语义两种语法。用户被迫记住“哪些字段有括号、哪些没有”。代码规模一大这种记忆负担就会累积成持续的认知消耗你不再关注业务语义而是在脑内维护一张“谁要带括号、谁不带”的对照表。把一切状态访问都统一成成员函数之后规则就变成一条“读取对象状态就是调用函数。”IDE 补全弹出的是width()编译不过就是接口不存在不用再猜它到底是变量还是函数。听起来是小事但我在一个十几年历史的老 C 工程里见过太多新人在obj.enabled和obj.enabled()之间反复试错编译器在你犯错时也只会给一句“它不是函数”或者“它不是数据成员”错误信息对刚上手的人非常不友好。统一规则之后这类错误从根上消失。语法一致性不是为优雅是为降低长期维护的认知成本。这一点在团队越大、人员流动越快时越明显。1.2 访问粒度read-only、read-write、write-only 从哪来public 成员变量有个硬伤它把读和写的能力一起给出去了你没法表达“这个状态外部只能读不能写”。而通过成员函数粒度可以控制得很细。class AccessLevels { public: int readOnly() const { return readOnly_; } int readWrite() const { return readWrite_; } void setReadWrite(int value) { readWrite_ value; } void setWriteOnly(int value) { writeOnly_ value; } private: int readOnly_ 0; int readWrite_ 0; int writeOnly_ 0; };readOnly_只暴露了读取函数外部任何写入企图都编译不过writeOnly_只暴露了写入函数外部连查看状态都做不到。这是 public 成员根本给不了的粒度也是类作者对“状态边界”的完整控制权。实际项目中 write-only 的字段不常见但确实存在比如某个本地配置模块需要从外部下发令牌、但不允许业务代码回读这时候只有一个 setter 的接口设计就非常有用。访问粒度还体现在“以什么形态暴露”上。同样是读字符串可以返回const std::string可以返回std::string_view也可以返回普通值。这些选择完全属于接口设计者可以随时调整成员一旦 public内部存储类型就被永久暴露给了所有人。再配合 const 正确性看只读函数是 const 的const对象可以正常调用如果你想做惰性计算缓存这种“读时改变内部表示”的事也只有成员函数配合mutable才能实现public 成员直接把这扇门堵死了。1.3 封装自由改实现而不破坏接口条款里最有分量的例子是那个平均速度采集类。class SpeedDataCollection { public: void addValue(int speed); // 添加一次速度样本 double averageSoFar() const; // 返回到目前为止的平均速度 private: std::vectorint speeds_; };averageSoFar()的客户端完全不需要知道内部是怎么算均值的。它可以每次现算可以维护一个累计和变量可以在数据量变大之后换成加权采样甚至可以把std::vectorint换成std::dequeint。无论内部怎么变addValue和averageSoFar这两个函数的外形不变所有调用方一行都不用改。这才叫封装在不破坏使用方代码的前提下拥有改变实现的自由。public 成员变量恰恰把这个自由彻底剥夺了。字段一旦公开存储位置、存储类型、数据布局全部被钉死想改就得跑到全社会调用方那里评估影响面而调用方往往比你想象的还要多。用生活化的说法public 成员等于把家门钥匙挂在门口任何人都能进卧室改装修通过成员函数访问至少意味着“凡是进来的都经过门卫”门卫规则你随时可以收紧。2. protected 成员变量为什么比想象中更危险条款二十二的后半段经常被人读完就忘它说 protected 从封装的角度看并不比 public 好到哪去。这是全条款里最反直觉的部分也是工程实践中踩得最深的坑。2.1 破坏面不可控派生类的数量是未知数很多人的心智模型里访问级别是一条数轴public 最开放private 最封闭protected 居中。这个模型最大的问题是把“对外部世界的开放度”和“封装性”混为一谈了。封装性衡量的不是谁看得见而是“改变实现时会被谁破坏”。public 成员的破坏面是所有直接读它的业务代码protected 成员的破坏面则是所有派生类——包括你根本没见过的、三年之后另一个团队为了扩展功能而写的派生类。两者的共同点正是 Scott Meyers 那句话的核心你永远不知道会有多少代码依赖它因此无法评估变更风险。说得直白一点public 是钥匙挂在门口门口路过多少人你大概心里有数protected 是钥匙复制了无数份发给所有远方亲戚问题在于亲戚名单永远在增长你根本管理不过来。private 则把钥匙收回保险柜只留几个固定的取物窗口窗口规则由你定也只有你能定。2.2 一个具体演示protected 数据成员把升级路堵死看一个很典型的小例子。class WidgetBase { protected: std::string title_; uint32_t flags_ 0; }; class Button : public WidgetBase { public: void setHighlighted(bool on) { if (on) flags_ | 0x1; else flags_ ~0x1; } };Button直接操作flags_看起来高效直接。可一旦WidgetBase要升级麻烦就来了把flags_从uint32_t换成std::bitset32或者把title_换成引用计数句柄所有像Button这样直接读写的派生类全部编译失败。更麻烦的是想给 flags 的修改统一加日志、加合法性校验比如某些位不允许派生类置位你没有任何统一入口想在派生类构造期间阻止title_被误读也做不到。正确做法不是把 protected 撤掉而是“即使是给派生类用的内部状态也以成员函数形式提供”。class WidgetBase { protected: void setTitle(const std::string title); const std::string title() const; void setFlag(uint32_t bit, bool on); private: std::string title_; uint32_t flags_ 0; };这样派生类依然能从基类获得能力但基类对内部状态保留最终控制权。protected 的语义应该是“扩展点”不是“储物间”。你想让派生类定制的是行为而不是让它们直接操作基类的内脏。2.3 真正的二元分类private 之外都是无封装从“能否安全变更实现”这个角度做判断事情一下就简化了只有 private 提供封装其余访问级别都在不同程度上把实现细节暴露给了你无法完全控制的群体。有人会争辩说 protected 至少挡掉了外部世界。问题是挡住一部分不等于封装。真正需要稳定的是“对不可控客户群的接口承诺”。外部业务代码你还能通过代码评审控制派生类往往来自你想象不到的团队、平台、扩展版本。对不可控的东西暴露内部状态本质上是在给自己埋雷。这里有个分寸要把握protected 成员函数、protected virtual 扩展点这些是合理的它们暴露的是行为约定不是内存布局。条款二十二反对的是 protected 数据成员不是反对一切 protected。理解到这一层才算把条款读完整。3. getter/setter 的正确姿势从机械映射到行为接口把成员变 private 只是起点。之后怎么写访问接口才是条款在工程里真正考验人的地方。我见过太多把 private 做成了“换个语法的 public”的反面教材这种封装是假的。3.1 别写 getField/setField访问函数应该有业务语义机械地为每个字段配一个getField()、setField()等于只是把 public 成员换了个语法继续裸奔。关键区别在于访问函数表达的是不是业务行为。class BankAccount { private: Money balance_; public: Money balance() const; void deposit(Money amount); void withdraw(Money amount); };deposit()、withdraw()是行为它们内部可以做负数校验、记录流水、触发通知。如果写成setBalance()外部就可以直接把余额改成任意值类的所有业务规则都成了摆设。你需要的不是“设置余额”而是“完成一笔存款”。这个语义差异决定了封装到底是保护业务规则还是只是换了一身马甲。反过来有一种“伪封装”同样常见有些类把所有成员都 private 之后一对一写了 getter/setter类内部却不维护任何不变量。这种类和struct Point { double x, y; }相比没有任何优势反而代码多一倍。与其如此不如老老实实公开成员至少调用方读起来清爽。这正好呼应 C 核心指南的 C.2如果类需要维护不变量用 class 并把成员私有如果成员可以独立变化、组合总是合法用 struct 公开反而更诚实。3.2 只暴露你需要的那一档能力成员 private 之后真正的问题是外部对这个状态到底需要什么能力列出来就是四个档位可读不可写只提供读函数写路径彻底封闭可写不可读只提供写函数比如本地只能下发配置、不允许回读的敏感令牌读写皆需提供一对语义正确的函数完全不需要外部感知连访问函数都不给。最后一档最容易漏。很多字段只是类的内部实现细节外部不需要看也不需要碰。比如计时器class Timer { public: void start(); void stop(); double elapsed() const; private: std::chrono::steady_clock::time_point startTime_; };startTime_是什么类型、怎么存储外部完全不必知道。它甚至可以从time_point换成整型 tick 计数调用方无感。这个类连 getter 都不需要为startTime_准备这才是封装带来的自由——你保有的不只是字段的访问权还有字段存在方式本身。3.3 边界情况值对象、性能热点与结构化绑定条款二十二不该被当成不可违抗的戒律我在实践中确实会按场景放开场景建议核心理由纯数据聚合Point、RGB、std::pair公开成员任何字段组合都是合法状态没有不变量要维护内部实现结构体仅实现文件可见按需即可不构成对外接口破坏面可控性能热点渲染、数值计算最内层循环先 profile 再决定getter 通常会被内联成零开销但极端场景要按数据说话希望支持 C17 结构化绑定需权衡直接公开成员最省事但会永久锁死布局结构化绑定值得单独说。想让auto [x, y] point;直接可用最常见的手段就是把成员公开。如果希望保持封装可以走std::tuple_size、std::tuple_element、getI()这套协议但模板样板代码不少。值对象选前者没问题实体对象就要慎重公开成员等于把布局写进了接口承诺将来想再加业务逻辑会非常被动。4. public 到 private 的一次真实重构复盘讲完原理说点实操。我在图形引擎项目里维护过一个RendererConfig它曾经是一个典型的坏味道示范。4.1 背景一个被到处直接赋值的配置类最初的实现人畜无害地长这样struct RendererConfig { int width 1280; int height 720; bool vsync true; float gamma 2.2f; std::string shaderCachePath cache/; };因为它太方便引擎里各个模块都来直接赋值渲染初始化改分辨率资源管理改缓存路径某个调试工具顺手改 gamma。直到我想加两条规则——分辨率必须为正数、gamma 的变化来源需要被记录——我发现自己根本没有一个统一入口去拦截这些操作。所有约束只能靠大家自觉而“自觉”在大型工程里约等于没有。更糟的是有些代码读 width 只是为了算 aspect ratio但我搜索下来发现至少有五个模块在重复实现同一段除法逻辑谁也不敢动因为 width 本身被直接暴露。4.2 重构步骤先读后写、语义化方法、彻底私有我走得很保守分三步推进避免一次改动让编译错误炸成一片。第一步先统一读操作。成员改名成width_、height_再提供width()、height()、gamma()等只读函数。此时因为成员只是改了名所有直接读的位置会全部编译报错这个报错列表就是所有需要收敛的读取方清单。第二步把写操作升级成语义化方法。这里有个血泪教训分辨率这种“必须成对设置”的状态绝对不要拆成setWidth()和setHeight()两个 setter否则必然出现先改一半、状态不一致的窗口期。我直接给一个原子接口void setResolution(int w, int h) { if (w 0 || h 0) { throw std::invalid_argument(resolution must be positive); } width_ w; height_ h; } void setGamma(float g, GammaSource src) { gamma_ g; gammaSource_ src; // 记录来源方便后期审计 }第三步确认所有读写点都收敛到函数之后把成员彻底改成 private。此时“哪些代码真的需要直接碰状态”已经被前两步的编译错误暴露完毕收尾非常干净。整个过程中vsync我甚至没给它单独写 setter——引擎里所有切换垂直同步的逻辑都应当收敛到同一个初始化流程里外部要的只是它当前的开关状态。4.3 踩过的坑与最终验证聚合初始化代码全部报废。RendererConfig cfg{1920, 1080, true, 2.4f, cache/};在成员 private 后不再合法。这是预期代价补一个构造函数或静态工厂函数即可。二进制序列化代码当场崩溃。有模块用memcpy按内存布局打包整个结构体私有化之后这类代码必须重写。这其实是好事它逼你承认这个类已经不是 POD序列化应该走显式方法。如果一开始就遵守条款二十二这个坑根本不会存在。编译错误一开始会有几百条。不要试图一次改完按模块分批推进先读后写每次只处理一类错误。改之前给核心行为补几条单元测试是验证重构没有改变行为的最有效手段。我当时的做法是把分辨率校验、gamma 赋值、默认值读取各写了一条测试重构后全绿才敢合入。复盘下来的体会只有一句条款二十二讲的不是一个访问修饰符而是一种前置策略。把成员藏进函数本质上是提前把“以后想怎么改的自由”买下来而成本只是多写几个访问函数。我对条款二十二的态度经历过从“这是教条”到“这是工程通货”的转变。真正让我改观的不是书而是两次“想改一个 public 字段却不知道该不该动”的窘境一次是换存储类型一次是加校验逻辑两次都因为调用方太多而拖延了好几天。如果你正在设计一个新类不妨问自己一个问题这个字段半年后如果要换表示方式我会不会想打三个月前的自己答案如果是“会”趁早把成员变量声明为 private。它是少数几条“现在多花几分钟以后省几天”的规则。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询