1. 访问修饰符到底是什么:从一个“失控”的类讲起
先别急着背定义。我直接说一个很多初级工程师都干过的事:写了一个User类,字段全部用public,然后业务代码里到处直接操作字段,比如user.age = -1、user.passwordHash = "whatever"。等上了生产环境,数据烂掉了,你想约束这些赋值行为,结果所有地方都在改,牵一发动全身,根本不敢动。
这个场景我见过太多次了。说白了,访问修饰符(Access Modifiers)不是语法层面的“装饰品”,它是Java、C++、C#这类面向对象语言里,用来控制类成员“谁可以碰、谁不能碰”的一套边界系统。它解决的核心问题,不是“防止别人恶意攻击”,而是“防止你自己在三个月后手滑,或者团队里其他人误用你写的类”。
这篇文章适合谁看?如果你是刚学完对象、继承、多态,正要进入“怎么设计一个像样的类”阶段的初学者,那正合适;如果你写了两年业务代码,但对protected和默认修饰符的区别始终模模糊糊,也可以当一次系统性的复习。我会用Java为主来讲解,同时把C++、Python、C#这些常用语言的差异放在一起对比——毕竟现在很多人是跨语言开发的,只懂一套规则很容易踩坑。
1.1 先看一个没有访问修饰符的“事故现场”
假设我们写一个BankAccount(银行账户)类,用来记录余额。如果完全不设访问控制:
public class BankAccount { public double balance; public String ownerName; }然后业务代码里这么写:
BankAccount account = new BankAccount(); account.balance = -500; // 直接赋负值,没有任何校验这段代码能编译、能运行,但显然不合理。余额可以是负数,但在真实的银行系统里,提现、透支的规则应该是明确的业务逻辑,而不是一个裸字段随便让人改。一旦这样的代码上线,后续任何一次需求变更——比如“余额不能低于-1000”——你都得满项目去搜索哪个地方改了balance,然后一个一个改。
这就是没有访问修饰符的代价:所有字段像广场一样,任何人、任何时候都能进来踩一脚。访问修饰符的第一层意义,就是“把字段关起来”,强制要求外部代码通过方法(行为)来操作数据,把数据校验、状态流转的规则收敛到类内部。
1.2 核心价值:封装、契约、安全
往深一层说,访问修饰符四个字背后,站着的是面向对象三大特性里的“封装”。封装不是让你把代码藏起来不给人看,而是明确两个东西:
- 类对外提供什么——这是公共契约,别人能调用你的公开方法;
- 类内部保留什么——这是实现细节,将来怎么改都不会影响调用方。
公共契约一旦定了,你内部怎么做都行。比如BankAccount.deposit(double amount)方法对外承诺“存钱”,内部是存数据库还是写文件,外部不关心。但如果外部可以直接修改balance字段,那么“存钱”这个行为逻辑就被架空了,契约形同虚设。
从安全角度讲,访问修饰符还能防止一些危险操作,比如把passwordHash声明为public,导致任何代码都能读甚至改密码哈希;把init()这种一次性初始化方法声明为public,导致运行途中别人能随时再调一次,状态被重置。这些都不是“黑客攻击”问题,而是“自己人误操作”问题——在实际工程中,后者才是主要矛盾。
1.3 先把四个级别的关系图刻在脑子里
Java的访问修饰符从小到大排列:
| 访问级别 | 同包同类 | 同包子类 | 同包非子类 | 异包子类 | 异包非子类 |
|---|---|---|---|---|---|
private | 可以 | 不可以 | 不可以 | 不可以 | 不可以 |
| 默认(包私有) | 可以 | 可以 | 可以 | 不可以 | 不可以 |
protected | 可以 | 可以 | 可以 | 可以 | 不可以 |
public | 可以 | 可以 | 可以 | 可以 | 可以 |
建议把这张表抄在笔记本上,这是整个访问修饰符体系最核心的一张表。后面所有疑问,几乎都可以回到这张表来推导。注意关键差异点:
private:只有定义这个成员的类自己能用。- 默认(也叫包私有):同一个包内的所有类都能用,出了包就不行了。
protected:同包随便用,异包的子类也能用。public:谁都能用。
这四个等级,本质上是从最封闭到最开放的连续光谱。设计类的时候,你的任务就是找对每个成员在这个光谱上的位置。
2. 四个访问修饰符逐一拆解:从最开放到最封闭
2.1 public:开放的公共接口
public是四个修饰符里最“大方”的一个,声明了它,就意味着这个成员是类对外的接口面。正常情况下,应该只用在下列场合:
- 对外提供服务的业务方法,比如
deposit()、withdraw()、getBalance(); - 类本身要能被外部实例化,所以构造函数通常也是
public; - 实现接口的方法,接口方法默认就是
public abstract,你实现时也只能是public。
但有一个高频误用:把字段直接声明为public。除非是常量,比如public static final int MAX_AMOUNT = 50000;,否则字段设计成public基本都是设计缺陷。原因我在前面说过:字段没法做校验,也没法在赋值时触发额外逻辑。你可能会想“我加个注释让别人别乱改不就行了”——想法太天真,人的记忆是不可靠的,总有一天会有人绕过你的注释。
另外,从演进角度看,public是代价最高的修饰符。一旦你发布了这个类,外界已经有人在你public方法的基础上写代码了,那么这个方法后续想改签名、改行为,就都受限制了。Java生态里有个著名的“破坏性变更”现象,很多库升级一个小版本,就因为一个public方法的行为微调,导致下游大面积编译失败。你暴露出去的public越多,你未来重构的自由度就越低。
2.2 private:类内专属,谁也别想碰
private是四个里最严格的,只有声明它的类自己能访问,子类、同包兄弟类、外部类统统不行。
它为“封装”提供了最强的保障。我们通常要求:
- 字段用
private:除非有特殊理由,所有实例字段都应该是private,外部只能通过getter/setter或业务方法间接访问。 - 辅助方法用
private:有些方法是纯内部实现细节,比如拆分计算过程、格式化内部数据结构,这些方法没理由暴露出去,应该private。 - 常量可以例外:
private static final你在类内部用,目的是不让外部依赖这个常量值,未来改值不影响外部。
有一个细节经常被忽略:private成员在同类内部是随便访问的,包括“同类不同对象”。这句话什么意思?看代码:
public class Person { private String name; public boolean sameName(Person other) { return this.name.equals(other.name); } }sameName方法里,this.name是私有的,没问题;other.name同样是private字段,但因为访问方是Person类自身,所以照样能拿。很多初学者以为“私有字段只有当前这个对象能访问”,其实是“只有当前这个类能访问”——不管对象是this还是另一个实例,只要是这个类的代码,就能访问private成员。这个理解偏差在实际代码里经常引发困惑。
2.3 protected:为继承而生的“半开放”
protected的设计初衷很明确:让子类能继承和使用父类的部分内部能力,但对外部世界保持封闭。
典型的使用场景是:
- 模板方法模式:父类定义骨架方法,其中某个步骤是
protected,子类重写这个步骤来改变算法细节。 - 子类需要访问父类的内部状态:比如父类有一个
computeBaseSalary()方法,子类要基于它做扩展,这个方法就可以声明为protected。 - 让子类重写某个方法:如果父类的方法不想让外部任何人直接调用,但希望子类可以重写,用
protected正好。
引用我们前面的表格,protected比较特殊的一点是:它比默认修饰符“更开放”,但不完全开放。同包的类能用它,异包的子类也能用它。这意味着,protected成员实际上会跟随继承关系暴露到另一个包里去——这也是它和默认修饰符最大的区别。
但是注意,protected成员在异包子类中访问时,还有一个限制:通过子类类型的引用来访问是允许的,但通过父类类型的引用来访问,却不一定允许。这其实是在问:你在哪个包的哪个类里写代码。比如你在子类SavingsAccount里,可以写this.balance(假设balance是父类protected字段),因为SavingsAccount是BankAccount的子类;但你如果写BankAccount other = new SavingsAccount(); other.balance;,这在异包环境下会编译失败,因为访问balance的代码在BankAccount类的外部,而且此时引用类型是BankAccount,不是SavingsAccount。
这说起来有点绕,实际工程里建议记住最简单的一条策略:如果某个成员只是“给子类用的能力”,优先考虑protected;如果你只是想在同包内共享,考虑默认修饰符。关于protected和默认到底怎么取舍,我在第5.2节再展开。
2.4 默认(包私有):Java里最容易被忽略的“第五种”
很多人学Java的时候,只记住了public、private、protected,却忘了还有一个“默认”级别。当一个成员前面不写任何访问修饰符时,它不是public,也不是private,而是“包私有”(package-private),也就是只有同一个包下的类能访问。
这个级别很微妙。它的用途是:
- 同一包内的类之间共享细节:比如一个
model包里的多个类,彼此需要协作,但又不希望这些协作细节暴露给包外的代码。 - 单元测试的便利:很多团队会把测试类和被测类放在同一个包下,比如
com.example.service,测试类就能直接访问被测类的包私有成员,免去写public getter的麻烦。 - 包级别的“内部工具类”:一个
util包内部可能有几个辅助类,它们之间互相调用,但不需要对外发布,用默认修饰符就能控制住边界。
不过,默认修饰符有一个很现实的问题:在Java里,“包”这个粒度对工程结构来说,有时候太大了。如果你把一大坨类全塞在同一个包下,包私有几乎等于公开。所以在大型项目中,我见过很多团队会明确要求:除了测试,统一用private和public,默认修饰符要申请审批。原因是包边界不好维护,类一多,包私有就成了“半个public”,而且是看不见的public,容易失控。
3. 实操环节:设计一个可维护的BankAccount类
理论说完了,现在动手。我用一个真实的BankAccount类来演示,怎么一步步给每个成员选对访问修饰符。
3.1 需求拆解与修饰符规划
假设需求如下:
- 账户有账号、余额、开户日期、利率;
- 可以存钱、取钱、查询余额;
- 取钱时余额不能为负;
- 每个月由系统批量结算利息,利息金额要支持审计日志记录;
- 账户类型分为储蓄账户和信用账户,信用账户允许透支一定额度。
先别急着写代码,把问题拆开:
- 字段给谁用:账号、余额、开户日期属于“账户自身状态”,外部只能读,不能随便改,所以私有字段 + 公开getter是稳妥的。利率这个东西,子类可能要自己调整逻辑,所以字段可以
private,但提供protected的方法给子类用。 - 行为给谁用:存钱、取钱是外部操作,
public;余额查询public;利息结算这个动作是系统内部批量任务在调,但又需要子类可能重写,所以是protected。审计日志是内部辅助,private。 - 构造策略:用户要能创建账户,所以构造函数
public;但为了让工厂方法统一管理,也可以把构造设为private,对外提供public static工厂方法——这是设计模式里“静态工厂”的玩法,后面会提到。
3.2 逐行实现:为什么每个修饰符这么放
下面是完整代码,我加了很多注释来说明修饰符的取舍。
package com.example.bank; import java.math.BigDecimal; import java.time.LocalDate; public class BankAccount { // 字段全部私有:外部只能通过方法间接操作,状态不会被绕过校验 private final String accountNo; private BigDecimal balance; private final LocalDate openDate; private BigDecimal annualInterestRate; // 构造函数public:允许外部直接创建账户 public BankAccount(String accountNo, BigDecimal initialBalance, LocalDate openDate, BigDecimal annualInterestRate) { if (accountNo == null || accountNo.isBlank()) { throw new IllegalArgumentException("账号不能为空"); } this.accountNo = accountNo; setBalanceInternal(initialBalance); this.openDate = openDate; setAnnualInterestRate(annualInterestRate); } // 公开业务方法:存钱 public void deposit(BigDecimal amount) { if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) { throw new IllegalArgumentException("存款金额必须大于0"); } setBalanceInternal(this.balance.add(amount)); } // 公开业务方法:取钱,余额不能为负 public void withdraw(BigDecimal amount) { if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) { throw new IllegalArgumentException("取款金额必须大于0"); } if (this.balance.subtract(amount).compareTo(BigDecimal.ZERO) < 0) { throw new IllegalStateException("余额不足,无法完成取款"); } setBalanceInternal(this.balance.subtract(amount)); } // 公开方法:查询余额 public BigDecimal getBalance() { return balance; } // 公开方法:查询账号,因为账号是final且初始化后不再变化,直接返回即可 public String getAccountNo() { return accountNo; } // protected方法:允许子类(储蓄账户/信用账户)在结算利息时重写 protected BigDecimal calculateMonthlyInterest() { return this.balance.multiply(annualInterestRate) .divide(BigDecimal.valueOf(12), 2, BigDecimal.ROUND_HALF_UP); } // protected方法:月结,允许同包系统任务调用,也允许子类扩展 protected void applyMonthlySettlement() { BigDecimal interest = calculateMonthlyInterest(); setBalanceInternal(this.balance.add(interest)); recordAuditLog("月结利息: " + interest); } // protected方法:子类需要调整利率时使用 protected void setAnnualInterestRate(BigDecimal annualInterestRate) { if (annualInterestRate == null || annualInterestRate.compareTo(BigDecimal.ZERO) < 0) { throw new IllegalArgumentException("年利率不能为负数"); } this.annualInterestRate = annualInterestRate; } // private辅助方法:统一走这个入口改余额,方便打日志和做约束 private void setBalanceInternal(BigDecimal newBalance) { this.balance = newBalance; } // private辅助方法:审计日志,仅供类内部调用 private void recordAuditLog(String message) { // 实际工程中这里可以写入日志文件或审计表 System.out.println("[" + LocalDate.now() + "] " + accountNo + " " + message); } // 包私有方法:给同一个包内的AuditService调用,不对外暴露 void exportAuditData() { System.out.println("账号: " + accountNo + ", 余额: " + balance); } }这里有几个关键选择,值得单独说明:
- 为什么
balance不用public,甚至不用protected?因为一旦子类或外部直接能改balance,那deposit、withdraw里的校验就全白写了。子类要改余额,应该通过父类提供的protected方法(比如applyMonthlySettlement)来调度,而不是直接碰字段。 applyMonthlySettlement为什么是protected而不是public?因为外部调用方应该只需要deposit、withdraw这样明确的业务动作,而不需要知道“月结”这个内部流程。但同一个包下的MonthlyBatchJob类也要能触发它,所以protected天然满足“同包可用、异包只有子类可用”这个需求。recordAuditLog为什么是private而不是protected?因为审计日志的记录格式是内部实现细节,子类不需要也不应该重写。如果哪天要把日志从控制台改成写文件,只要保持private,改动范围就在这一个类内部,不会波及子类。
3.3 用一段测试代码验证“访问边界”
现在写一个同包测试类和异包测试类,验证哪些能编译、哪些不能。
package com.example.bank; public class SamePackageTester { public void test(BankAccount account) { account.getBalance(); // 编译通过,public account.deposit(BigDecimal.TEN); // 编译通过,public // account.balance = ...; // 编译失败,private字段 account.exportAuditData(); // 编译通过,包私有,SamePackageTester和BankAccount同包 // account.calculateMonthlyInterest(); // 编译失败,protected,且SamePackageTester不是子类 } }package com.example.other; import com.example.bank.BankAccount; public class OtherPackageTester { public void test(BankAccount account) { account.getBalance(); // 编译通过,public account.deposit(BigDecimal.TEN); // 编译通过,public // account.exportAuditData(); // 编译失败,包私有成员异包不可见 // account.calculateMonthlyInterest(); // 编译失败,protected,非子类不可见 } }从这段测试可以直观看到:public跨包畅通无阻;包私有成员出了包就消失;protected在异包非子类眼里也等于不存在。这也验证了前面那张关系表。
实际开发中,我建议每写一个新的类,就顺手写一个同包测试类验证边界——不要只在脑子里面推演,编译器的报错信息会帮你确认你的设计是否符合预期。
4. 跨语言对比:Java、C++、C#、Python 有什么不一样
很多人只学一门语言的时候,对访问修饰符的认知是“理所当然”的。但一旦切到另一门语言,就各种不适应:Python里没有private关键字,C++的protected和Java还不完全一样,C#默认的访问级别和Java也不同。这一节我把几门主流语言的差异梳理一遍。
4.1 各语言访问控制体系横向对比
| 语言 | 关键字/机制 | 默认访问级别 | 典型坑 |
|---|---|---|---|
| Java | public/protected/ 默认 /private | 包私有 | 默认级别容易被人忽略 |
| C++ | public/protected/private(可在类内分段标注) | private | 默认私有,struct默认公有,容易混淆 |
| C# | public/protected/internal/protected internal/private | 默认private | internal对应“程序集可见”,概念和Java包私有不完全一样 |
| Python | 约定式:_name受保护,__name名字修饰 | 默认公有 | 没有真正的强制访问控制,靠自觉 |
| TypeScript | public/protected/private | 默认public | 只做编译期检查,运行时无强制 |
这里重点展开几个容易出问题的点。
C++的struct/class默认级别。在C++里,struct和class唯一的区别就是默认访问级别:struct默认是public,class默认是private。很多从C转过来的人喜欢写struct,结果里面全成了public。我的建议是:明确写出访问级别,不要依赖默认行为,这样代码审阅的人一眼就能看到边界在哪里。
Python的“下划线人文约定”。Python没有真正的访问修饰符,所有的成员默认都是公开的,外部都能访问。但业界约定俗成:
_name:表示“受保护”,子类可以用,外部代码别碰;__name:会发生名字修饰(name mangling),类外访问会变成_ClassName__name,但这只是挡君子不挡小人,本质还是能访问到的。
在这种没有强约束的语言里,访问控制的意识尤其重要。你写_internal_cache这个字段,如果你不遵守约定去外部访问它,以后别人的代码就会依赖它——然后你重构的时候,就是一场灾难。所以Python项目里,制定并遵守访问约定比语言本身提供的语法更重要。
C#的internal。C#有一个Java没有的internal级别,描述的是“同一个程序集内可见”,比“同一个包内可见”的粒度更大一些。很多大型.NET项目用internal来隐藏内部实现,再配合InternalsVisibleTo给测试程序集开白名单。这个思路其实比Java的“包私有”更实用,因为程序集边界和代码仓库结构往往更清晰地对应。
4.2 从Java切到其他语言时的三个坑
第一,不要把Java的“默认包私有”思维带到C#里。C#成员默认是private,不是像Java那样包私有,所以你在C#里不写修饰符,外部类直接就没法访问了。这个差异很隐蔽,容易导致“我明明没写public,怎么同事说访问不到”。
第二,不要把C++的protected理解成Java的protected。在C++里,protected成员在子类中也是有访问限制的,而且它和Java一样,也允许“通过基类对象访问”的规则差异,整体语法约束比Java严格,更容易编译报错,反而更不容易踩坑。
第三,不要以为TypeScript的private是安全边界。TypeScript的private只是编译期检查,编译成JavaScript之后,运行时没有任何强制手段,属性照样可以访问。所以如果你要做真正的数据保护,在TS里得靠闭包、WeakMap或者Symbol之类的机制,但绝大多数项目并不会走这个极端——注意这是“约定”和“协作”层面的边界,不是“安全”层面的边界。如果你和团队成员都能遵守约定,TS的private已经够用了。
4.3 按项目场景怎么选
- Java/C#后端业务系统:字段一律
private,对外方法一律public,少用默认级别,protected只给真正需要扩展的“扩展点”用。 - C++底层库:能
private就private,需要子类扩展的用protected,尽量少暴露public;还要注意面向接口编程,而不是直接暴露class。 - Python内部工具脚本:约定优于语法,
_name表示内部实现,不要依赖访问控制来解决架构问题,依赖代码评审和约定更现实。 - TypeScript前端项目:用
private和protected明确表达设计意图,但心里要清楚这只是“编码契约”,不是“运行时保护”。
5. 常见问题与排查技巧实录
这部分我整理了几个我在实际开发中经常见到、也经常被问的问题,按“问题-原因-排查建议”的方式列出来。每一个都是真实踩过的坑,不是教科书里的假想场景。
5.1 为什么子类访问不了父类的private字段?
这是最常见的问题,连工作两三年的开发也偶尔会绕进去。你写了一个父类:
public class Animal { private String name; public Animal(String name) { this.name = name; } } public class Dog extends Animal { public void printName() { System.out.println(name); // 编译失败:name在Animal中是private } }为什么不行?因为private的设计语义就是“只在声明它的类内部可见”。子类不是父类本身,虽然它是父类的扩展,但并不能因此访问父类的私有内脏。这是Java严格封装的一个体现:子类只能继承父类“允许你继承”的部分。
解决方案有四个:
- 把字段改成
protected——如果确实需要子类直接访问; - 提供
protected或public的getter/setter——更推荐,字段保持private,行为可控; - 提供
protected的业务方法——如果你想让子类操作父类状态,但必须走父类约束好的逻辑; - 重新审视设计——子类经常需要直接操作父类的
private字段,可能意味着父类的职责划分有问题,字段应该下沉到子类,或者通过组合而不是继承。
5.2 protected和默认到底差在哪?
表格再拿出来看一遍:
| 场景 | protected | 默认(包私有) |
|---|---|---|
| 同包同类 | 可见 | 可见 |
| 同包子类 | 可见 | 可见 |
| 同包非子类 | 可见 | 可见 |
| 异包子类 | 可见 | 不可见 |
| 异包非子类 | 不可见 | 不可见 |
唯一的区别,就在“异包子类”这一行。如果你的类不会被跨包继承,那么protected和默认在实践中没有任何区别。因此取舍标准就很简单:这个成员需要被“另一个包里的子类”使用吗?
- 你在写一个公共库,你的
BaseParser想允许“别人家的子类”重写tokenize()方法,那必须protected。因为别人家的子类在另一个包,默认级别会完全阻挡他们。 - 你只是在同一个模块的多个包下内部协作,子类都在同一个包内,那用默认级别可以让边界更紧——将来就算有人乱继承,也访问不到。
我个人的经验是:默认级别适合“同包内部协作的临时细节”,比如一个service包里的两个类需要分享一个包级工具方法;而protected是“面向继承的API”,它本身就暗示了“我允许你来扩展”。所以从可读性角度,protected比默认级别更有“表达力”,看代码的人一眼就知道“这里允许子类介入”。
5.3 字段到底该用private还是protected?
我的原则非常简单:字段默认private,除非你有一个很具体的、非用protected不可的理由,否则一律private。
为什么这么固执?因为字段是“状态”,状态是类最核心的部分。一旦你允许子类直接访问一个字段,就等于允许子类绕过父类的方法来改变状态。父类维护的所有不变量(比如“余额永远非负”“状态机只能按顺序流转”)都会在子类这里失效。
那什么时候可以破例?当你明确知道这个字段“就是给子类用的,而且子类应该直接操作它”的时候。比如一个框架类,子类是用户自定义扩展点,你要让他们直接往一个protected的集合里添加元素,因为每次调用方法都要过一遍权限检查,性能无法接受——这种性能或便利性上的强烈诉求,才是用protected字段的合理理由。
另外,有一个折中方案推荐大家使用:字段private,方法protected。这样父类把操作字段的入口收拢在一个方法里,子类通过重写方法来改变行为,而数据本身始终由父类控制。这是模板方法模式的精髓,也是我写可扩展类时最常用的一种设计。
5.4 设计层面容易犯的错:修饰符分配的逻辑混乱
实际写代码中,很多人给修饰符的分配是“随手”的,比如哪个方法报错了就把private改成public,哪个字段外部调不到了就改成protected。这种“逐步放宽”的路径非常危险——每一次放宽,都是在增加未来重构的负担。
我建议的分配顺序是:
- 先全部声明为
private; - 外部需要调用的方法,改成
public,但要想清楚公开后是否便于演进; - 子类需要重写或访问的,改成
protected; - 默认级别只在“同包协作类之间明确共享”时使用,且要有注释说明原因;
- 写完后,再问自己一次:这个成员真的有必要比
private更开放吗?
这个过程走完,你写出来的类的边界就会清晰很多。如果你发现一个类有一大堆protected成员,可能是你在过度设计继承结构;如果有一大堆public方法,可能是这个类的职责太大,该拆分了。
6. 一些个人的实操心得
最后分享几点从项目里总结出来的经验,不算系统理论,但很实用。
6.1 访问修饰符不是用来“防黑客”的
我一直和团队讲:不要指望靠private来防恶意攻击,反射可以绕过访问检查,序列化可以绕过构造函数,这些都非常容易。访问修饰符的真正价值是建立团队成员之间的协作契约——它告诉后来人:我设计这个类的时候,默认你只需要用这些public方法,其他都是可以随时变化的内部细节。
明白了这一点,你就不会在“这个字段要不要设成私有”上犹豫半天了。优先级很清晰:先确保它能保护不变量,再考虑使用便利性。如果一个公开的getter会导致某个内部状态被外部依赖,那我们宁可先不暴露它,直到确实有需求出现。
6.2 写类的时候,从“外部视角”反推修饰符
很多初学者是“写字段 -> 写方法 -> 顺手标修饰符”,这样往往会把所有字段都标成public。我个人的习惯是“先定义外部该干什么,再定义内部怎么隐藏”。
具体做法:拿张纸(或者在注释里)写清楚这个类的使用手册:
- 外部调用方允许做什么?比如
deposit、withdraw、getBalance。 - 哪些是内部实现细节?比如余额校验、日志记录、利率换算。
- 哪些是子类扩展点?比如月结利息计算。
把这三个清单列出来,修饰符就自动浮现了:使用手册上的标public,内部细节标private,扩展点标protected。这个过程不需要考虑太久,关键在于先想清楚设计意图,不要再纠结语法。
6.3 配套使用:public API越少,越好
我工作这么多年,一个非常深刻的体会是:类的public方法数量是衡量其设计质量的最直观指标之一。public方法越多,类的表面积越大,和你这个类耦合的代码就越多,将来重构就越痛苦。
因此,每次加一个public方法之前,我都会先问自己三个问题:
- 这个方法的调用方是谁?现在和将来都有明确的需求吗?
- 有没有可能用更少的公开方法满足同样需求?
- 这个方法改名或者改参数,会造成多大的影响范围?
这三个问题问完,很多原本想公开的方法最终都变成了private或者直接删掉。这让我写的类的接口越来越小、越来越稳定,后续需求变化时,不需要整天改别人的调用代码。
6.4 小技巧:利用编译器的报错反向检查设计
最后分享一个我经常用的小技巧。写完一个类之后,我会尝试在另外一个包里写一段测试代码,故意访问这个类的各个成员。编译器会明确报出哪些不可见,这些报错信息等价于一张“访问边界检查报告”。
比如,我本来想让calculateMonthlyInterest()被子类重写,但忘了加protected,它默认是包私有的。那么异包子类重写时必然编译报错,编译器会提醒我“method does not override or implement a method from a supertype”。看到这个报错,我就会意识到:哦,这个方法是意图成为扩展点的,应该改成protected。
这种“用编译器当设计审查工具”的做法,几乎零成本,却能帮我在写代码的时候就发现访问级别的失误,不用等到代码评审阶段再被同事抓包。你们下次写完类,可以试试。
访问修饰符这东西,说难不难,说简单也不简单。它背后牵涉的是面向对象设计中最核心的封装思想。你越早理解“访问修饰符不是语法负担,而是设计工具”,写出来的代码就越容易维护。希望这篇文章能帮你把这个工具用顺手。