
我们组最近重构了一个库存模块代码量从四千多行缩到两千出头核心改动只有一个删掉了几乎所有私有变量。听起来像是我在鼓励大家破坏封装恰恰相反我在代码评审里照样会拦下滥用公开字段的提交。这个标题的意思是别把私有变量当成默认选项它是一个应该在特定场景下才启用的武器而不是每个类都要背的盔甲。如果你写过几年业务代码大概已经见过这两种截然相反的代码库一种是所有字段一律private然后配套三四十个 getter/setter类和说明书一样厚另一种是字段直接public状态全靠自觉维护改着改着就乱了。两种都让人头疼但第一种更隐蔽因为它自带我很规范的光环让人不好意思批评。这篇就聊聊我为什么从善用封装转向慎用私有变量以及具体在什么场景下我仍然会坚持私有化。1. 从一行代码开始那次调试让我开始怀疑下划线1.1 一个典型的私有变量受害者现场事情发生在接手一个写了三年的物流运费计算服务时。我当时要加一个阶梯运费功能需求本身不复杂首重3公斤内收起步价超过后每0.5公斤累加。打开最核心的FreightCalculator类我沉默了。这个类有11个私有字段每个字段都有配套的 getter/setterpublic class FreightCalculator { private double basePrice; private double weightStep; private double stepPrice; private double freeThreshold; private double tierPrice; private String regionCode; private int priority; // ... 还有四个区域配置相关的私有字段 public double getBasePrice() { return basePrice; } public void setBasePrice(double basePrice) { this.basePrice basePrice; } // 每个字段都是这样 }我寻思着要加阶梯规则得先搞清楚tierPrice和stepPrice的区别。翻遍整个项目发现这个类被六个地方调用每个调用方都是这样初始化的FreightCalculator calc new FreightCalculator(); calc.setBasePrice(15.0); calc.setWeightStep(0.5); calc.setStepPrice(2.0); calc.setRegionCode(CN); calc.setPriority(1); // 再 set 四个我还没看懂的字段说好的封装呢字段是私有的但 getter/setter 完全公开私有这个修饰符除了让代码多写三行之外没有拦住任何东西。任何一个调用方都能setRegionCode(XX)把区域配置改掉字段私有并没有降低耦合只降低了可读性。1.2 排查到最后发现答案是直接改我费了三个小时在FreightCalculator里和它的子类之间跳来跳去。要命的是这个类还有个子类InternationalFreightCalculator父类私有字段在子类里完全不可见子类重新定义了一套同名配置导致两个类的配置在运行时错位。最后我发现问题不在tierPrice还是stepPrice的语义上而在于这些字段本来就不该被六个调用方各自配置。正确的做法是让这个类的配置内聚构造时一次性传入内部自己维护状态。而当我把所有private字段改为package-private去掉修饰符之后整个类的结构一下子清楚了——哪些字段是子类需要的哪些是内部状态哪些是纯粹多余的中间缓存一目了然。这个经历让我第一次意识到私有变量方便了别人看不见但也方便了作者不思考。反正外面看不到字段想怎么加就怎么加最终私有字段变成了一堆没有约束的杂物间。2. 当数据隐藏变成思维枷锁私有化背后的三个隐性成本2.1 成本一测试代码被迫绕路我见过最多的场景需求方要求某个计算结果必须精确到小数点后四位测试需要断言中间状态——比如运费在达到重量阈值前后的变化。但是中间状态字段是private的测试里没法直接读于是大家开始各显神通。第一种解决方案是用反射。这是最典型的 Java 测试代码Test public void testTierThreshold() throws Exception { FreightCalculator calc new FreightCalculator(); Field field FreightCalculator.class.getDeclaredField(currentWeight); field.setAccessible(true); field.set(calc, 3.1); // 断言结果 }每次写这种测试我都有一种在警察眼皮底下撬锁的荒谬感。而且反射测试有个致命弱点字段一旦改个名字编译期完全不会报错只有运行到那一行才炸。一个重构把十个测试全弄挂排查下来发现只是字段名从currentWeight改成了accumulatedWeight。第二种解决方案是给私有字段加一个专供测试使用的公开方法。这个方案更糟相当于为了测试的方便把私有字段的写权限开放给所有人public void setCurrentWeightForTest(double weight) { this.currentWeight weight; }如果私有字段真这么怕外部接触那这个测试方法应该也是私有的但它是公开的——这就等于告诉大家这个字段实际上不是私有的只是我们给它贴了个标签。后来我把那几个关键字段改成包内可见Java 的默认访问权限测试类和被测类放在同一个包下所有测试直接通过calc.currentWeight访问测试代码简洁且直白重构时字段改名编译器直接告诉我哪里漏了。这个改动没有增加任何外部耦合因为本来除了测试和子类就没有别的地方碰这些字段。2.2 成本二Mock 和继承反而更麻烦私有变量对单元测试的另外一个坑出现在 Mock 场景。假设我有个PaymentService内部依赖PaymentGateway我测试时想 mock 掉网关但网关实例是构造方法里new出来的私有字段存着引用public class PaymentService { private PaymentGateway gateway; public PaymentService() { this.gateway new PaymentGateway(); } public boolean pay(double amount) { return gateway.process(amount); } }想 mock 的同事只好引入 Mockito 的reflectionTestUtils或者干脆改代码。但真正的问题是这个私有字段暴露了这个类的亲生父母——它告诉我们这个类自己负责创建依赖而不是由外部注入。但私有字段的存在让这个设计问题被藏起来了调用方根本看不到这个类还藏着个网关。我把这种私有字段叫隐藏的构造依赖。它会直接导致两件事要么测试写不下去要么引入一个更糟的全局 mock 框架。而如果改用构造器注入字段根本不需要私有化依赖关系一目了然测试也能直接传 mock 对象。继承场景更麻烦。父类有个私有字段_logLevel子类想在某个方法里改变日志级别但私有字段不可见子类只能自己再定义一个_logLevel于是父类的_logLevel和子类的_logLevel同时存在各用各的调试时发现日志级别明明改成了 DEBUG但父类打印的还是 INFO——因为改的是子类那份。2.3 成本三序列化与日志的 状态缺失这是我认为最隐蔽的坑。很多对象需要被序列化保存、发送到消息队列、或者在日志里输出完整状态。比如type Order struct { orderID string // 私有字段 amount float64 status string }序列化库encoding/json默认只能输出导出字段。如果orderID是私有的序列化得到的 JSON 里根本没有订单号。查日志时只看到{amount:199.0,status:paid}完全不知道这是哪一笔订单。有人会反驳可以给私有字段专门写一个toJSON()方法。没错但那意味着每次新增一个私有字段都得记得改序列化方法漏一个就是线上事故。而如果字段是公开的序列化库自动处理日志也天然完整。Java 里同样的道理Jackson 默认反射访问字段但私有字段需要额外配置日志框架打印对象状态时私有字段只能靠自己写toString()常常写到一半就忘了加新字段。不是说私有字段不能存在而是如果你选择了私有字段就必须同时承担序列化、日志、测试、mock 这四套额外维护成本。很多人只看到了私有字段保护数据的好没意识到它带来的维护税。3. 语言陷阱各有各的坑Python、JS/TS、Java 里的假私有3.1 Python 的双下划线名称改写不是私有Python 的__var双下划线有个经典陷阱。它并不是真正的私有而是触发了名称改写name mangling解释器会把__var改写成_ClassName__var。这意味着class Order: def __init__(self): self.__total 0 order Order() # 直接访问会报错 # print(order.__total) # AttributeError # 但实际上它是存在的 print(order._Order__total) # 0可以看出Python 的双下划线只是让意外访问变麻烦不会真正阻止任何有意图的访问。更麻烦的是继承场景class BaseOrder: def __init__(self): self.__fee 10 class SubOrder(BaseOrder): def __init__(self): super().__init__() self.__fee 20你以为子类的__fee覆盖了父类的__fee并没有实际存在_BaseOrder__fee和_SubOrder__fee两个字段。任何对覆盖字段的直觉判断在双下划线面前都会失效。我在项目里见过排查了半天的 bug最后发现是两个类各自的__fee各自为政互相看不到也改不到。所以我现在的建议是Python 里尽量不用双下划线用单下划线_fee表示内部使用约定就够了。单下划线不会触发名称改写子类可以继承访问测试也可以访问但它仍然能提醒其他开发者这不是公开 API。3.2 JS 的闭包和 TS 的 private运行时形同虚设JavaScript 有三种私有变量的做法每一种都有自己的代价。第一种是构造函数里的闭包变量function Order() { let total 0; this.add function(price) { total price; }; }闭包变量是真的无法从外部访问但每次实例化都创建一套新的函数而且调试工具根本看不到total的值只能靠打印日志。序列化更是完全无能为力。第二种是 TypeScript 的private关键字class Order { private total: number 0; }这个private在编译期强制检查但运行时order[total]照样能访问。而且很多 JSON 反序列化的场景直接用Object.assign(order, plainData)就能把total塞进去编译器根本管不到。第三种是 ES2022 的#私有字段class Order { #total 0; }这个在运行时也是真私有代价是它不参与任何序列化机制structuredClone 复制不了测试无法直接设置状态和上面 Python 双下划线一样面临测试、序列化的成本。所以我的结论是JavaScript/TypeScript 里的私有字段要么是假的运行时能访问要么是贵的真私有但其他工具一概不认。大部分业务代码承担不起后面的维护成本选个下划线约定反而是性价比最高的方案。3.3 Java/C 的 private编译期的唯一真实约束对比之下Java 的private是少数真正在编译期就把访问拦死的机制。这也是为什么 Java 开发者最信任它。但信任带来一个问题很多人无脑把所有字段标记为private然后配上一堆 getter/setter类变成了一个没有入口的房间。Java 里private真正适合的场景是字段代表不变量且必须由类自身维护。比如订单状态status外部能通过cancel()方法改变它但不能直接setStatus(CANCELLED)。这种情况下私有字段加公开行为方法是合理的。但如果字段只是一个数据载体比如配置值、计算结果那么 package-private 或者 protected 往往更务实。Java 的包内可见性是个被低估的好东西——它让同包下的类协作而不必绕道同时对外部包仍然是隐藏的。C 的private情况和 Java 类似但 C 还有一个特性friend类和friend函数。当你确实需要让某些外部函数访问私有状态时friend比反射干净得多。但friend也容易滥用我见过一个类声明了十多个friend这等于把私有当成了摆设。4. 你真正想要的是稳定接口不是私有数据4.1 核心思路封装状态转变规则而不是封装数据本身我花了很长时间才想明白一件事封装真正要保护的不是数据本身而是数据的变化规则。银行的balance字段是不是私有不重要重要的是外部无论如何都不能直接把balance改成负数。要做到这一点有两种路径一是把balance私有化只提供deposit和withdraw方法二是把balance设为公开只读字段所有写入走构造器和内部方法。对于第一种路径如果哪天deposit方法内部把balance balance amount写成了balance amount私有字段照样不能防止错误。对于第二种路径Python 里可以用property设置只读访问器Java 里可以暴露一个不可变的快照或使用record。所以核心问题不是数据能不能被看到而是状态改变的时候能不能经过同一套校验逻辑。下面的例子可能更直观// 方式 A所有字段私有但 setter 可以任意改 public class Order { private String status; public String getStatus() { return status; } public void setStatus(String status) { this.status status; } } // 方式 B包内可见字段但业务行为方法统一校验 public class Order { String status; // package-private public void cancel() { if (PAID.equals(status) || PENDING.equals(status)) { this.status CANCELLED; } else { throw new IllegalStateException(订单已完成无法取消); } } }方式 B 里字段虽然不是私有但外部想改变状态只能调cancel()等行为方法在包外连字段都看不到Java 的 package-private 对外包是隐藏的。这比方式 A 的伪私有 公开 setter要安全得多。很多团队坚持方式 A然后安慰自己我设置了私有变量很安全实际上 setter 一公开安全就不存在了。4.2 替代方案一公开 final/const 字段配合构造注入当一个字段在对象生命周期内不会变化时最好的方案是公开finalJava、constC、readonlyTS。这个方案不仅没有破坏封装反而比私有字段加 getter 提供了更强的保证public class ShippingRule { public final double basePrice; public final double stepPrice; public final double freeThreshold; public ShippingRule(double basePrice, double stepPrice, double freeThreshold) { this.basePrice basePrice; this.stepPrice stepPrice; this.freeThreshold freeThreshold; } }这个类里没有任何 getter调用方直接读rule.basePrice也无需担心被修改final 约束。相比私有字段加 getter代码短了一半安全性不打折序列化工具天然支持测试也能直接构造对象再断言字段值。这个模式在 DDD 里经常被叫作值对象我在项目里把大量配置类都改成了这种写法效果非常好。4.3 替代方案二命名约定代替强制私有Python 和 JavaScript 社区里单下划线前缀已经成了广泛接受的内部使用约定。我现在的项目里也引入了这个规则下划线结尾或开头的标识符表示不要从外部依赖它但我不限制你读取。这个约定比语言层面的 private 更务实因为它传达了一个准确的信息这个字段可能在未来版本变化但如果你想调试、想写测试、想在子类里复用你有权访问它。它把选择权交还给调用方而不是用编译器的强约束把调用方挡在外面。实际上很多语言社区已经开始反思强制私有。Python 的 PEP 8 明确不建议用双下划线除非为了避免子类命名冲突TypeScript 的风格指南里也倾向于_前缀而不是private关键字Go 语言干脆就没有类和私有字段的概念只有包级别的导出和非导出标识符。Go 设计者的原话大意是你想限制的应该是一个包对另一个包的依赖而不是一个 struct 内部的字段读写。5. 别把话说绝四个仍然值得使用私有变量的边界场景5.1 场景一对外开放的 SDK 或不希望被继承的框架代码如果你的代码是要发布给第三方使用的 SDK事情就不一样了。SDK 的 API 一旦定下来用户就会基于它写业务逻辑。此时内部字段如果公开用户就可能直接依赖它导致你下个版本连status字段名都不敢改。这种情况下私有字段加稳定的公开方法是正确的设计。我做过一个对外输出的数据同步工具包里面的ConnectionPool类所有内部状态字段都是private final同时只公开open()、close()、flush()三个方法。因为我知道那些字段一旦暴露用户就会开始聪明地直接操作连接池状态后面的兼容性维护会变成噩梦。5.2 场景二缓存与懒加载这类非持久状态如果一个字段只是用来保存中间计算结果比如懒加载的缓存那么它值得被私有化——因为缓存的存在与否、缓存的数据结构完全不应该暴露给调用方。这个字段的存在对业务逻辑毫无意义暴露了只会让人困惑。public class PriceCache { private MapString, Double cache new ConcurrentHashMap(); public double getPrice(String sku) { return cache.computeIfAbsent(sku, this::loadPriceFromDb); } private double loadPriceFromDb(String sku) { // 加载逻辑 } }这里的cache字段就应该私有因为它是实现细节不是业务状态。如果你公开了它调用方可能会绕过getPrice直接操作cache缓存的一致性和并发安全就全毁了。5.3 场景三防止外部破坏对象不变量的最后防线有些对象存在唯一性约束或内部一致性要求。比如一个Account对象它的id和balance必须满足某种数学关系balance 0 balance creditLimit。如果调用方可以直接修改这些字段校验逻辑就会被打穿这是真正的私有化理由。但我要补充一点这种情况下私有化是最后一道防线不是唯一防线。第一道防线应该是构造器和行为方法里的显式校验。如果不变量需要保证私有字段加校验方法才是合理组合。光是加个private就以为万事大吉是最危险的自欺欺人。5.4 场景四线程安全状态多线程环境下一个字段正被锁保护着外部如果直接读写这个字段极容易破坏锁的一致性。比如public class Counter { private long count; private final Object lock new Object(); public void increment() { synchronized (lock) { count; } } }这里的count必须是私有的。如果公开外部可以直接counter.count完全没有锁保护并发下数据就会错乱。这也是私有字段最有说服力的场景。但即便在这里更好的设计是干脆把count封装成独立的原子类型比如AtomicLong然后把整个对象设计成不可变的快照形式。6. 我是怎么在团队里推动少用私有变量的6.1 代码评审中的三个必问题我现在做代码评审时看到新增的private字段会问三个问题这个字段在对象生命周期内会变吗如果不会它就是final/const/readonly不需要 private。测试代码或子类需要直接访问它吗如果需要private 只会逼大家写反射或者增加一个测试专用方法。这个字段的存在会让调用方困惑吗如果调用方根本不知道这个字段存在反而更危险——因为这意味着它们可能在不知情的情况下依赖了这个字段的副作用。这三个问题问完大概有一半的 private 会被移除。不是惩罚写代码的同学而是让他们重新考虑这个字段真的需要保护吗还是只是习惯性加了private。6.2 一套具体的改写模板我总结了一个简单的决策模板团队成员可以直接套用字段类型推荐可见性原因常量、配置值public final / const不可变直接读即可子类需要重用的状态或工具方法protected / 包内可见避免子类重复定义同名字段测试需要断言的中间状态包内可见测试直接访问避免反射缓存、懒加载结果private实现细节不应暴露线程安全状态private synchronized/原子类型防止破坏锁一致性对外 SDK 的内部实现privateAPI 稳定优于一切这套模板只花了一个下午讨论之后团队提交里新增 private 字段前都会对照一下。不是说必须按表执行而是让决策变得有意识——每个 private 字段都应该是深思熟虑的结果而不是编辑器自动生成的习惯。6.3 效果重构频率和测试质量的真实变化推行这个改造半年后团队的数据让我比较欣慰代码评审中被点名要求补充测试的情况减少了大约三分之一因为很多逻辑不再需要为了测一个私有状态而专门造反射用例重构时字段改名导致的编译失败大幅减少因为编译器能直接发现所有引用点而不是等运行期反射炸掉还有一点比较意外的是新人上手业务的阅读时间明显缩短了因为他们不需要再通过 getter/setter 去猜某个字段的用途。当然也有人一开始抗拒最典型的理由是万一别人改了怎么办。我的回答是代码评审和测试才是保障private挡不住一个特意绕过它的同事也挡不住一个不知道这个字段意义的调用方。真正的防线是字段的状态变化必须经过公开的行为方法而行为方法内部的校验逻辑才是能让数据保持正确的钥匙。我个人现在写业务代码时字段默认是包内可见或者公开只读只有上面表格里的四类场景才会明确加 private。偶尔有人翻我的提交记录问这里怎么能直接用字段而不加 getter我会把这个决策模板发给他们。这个行业里评判代码质量的最终标准不是有多少 private 修饰符而是改需求时能不能快速下手出问题时能不能快速定位。