封装与访问修饰符
本教程共 100 篇 · 第 36 篇 · 更新于 2026-08-05 · 约 6 分钟阅读
36. 封装与访问修饰符
本节目标:理解封装是「把数据藏起来、只留受控接口」,掌握 private、默认、protected、public 四种访问级别,并学会用 getter/setter 安全读写私有属性。
封装是面向对象第一特性,核心就一句:把内部细节藏起来,只给别人必要的入口。就像电视用遥控操作,不用伸手进电路板。
不封装会付出什么代价
先看一个把字段直接暴露出去的类:
public class Student {
public String name;
public int age;
}
```java
写起来是省事,用起来也直接。麻烦在于外面爱怎么改就怎么改:
```java
Student s = new Student();
s.age = -5; // 编译通过,运行正常,数据却是荒谬的
s.age = 99999; // 同样拦不住
s.name = null; // 后面谁调 s.name.length() 就崩
这些代码全都能编译通过。等到线上报出 NullPointerException,你得翻遍整个项目找是谁把 name 设成了 null——因为任何一行代码都有嫌疑。
更糟的是重构。哪天你想把 age 换成 birthYear,实时算年龄,结果发现全项目有两百处直接读 s.age。改不动了。
封装解决的正是这两件事:把非法数据挡在门外,把内部实现的改动圈在类里面。
四种访问修饰符
Java 用修饰符控制「谁能看到这个成员」,一共四档:
| 修饰符 | 同一个类 | 同一个包 | 不同包的子类 | 任意位置 |
|---|---|---|---|---|
private | 可见 | 不可见 | 不可见 | 不可见 |
| 默认(不写) | 可见 | 可见 | 不可见 | 不可见 |
protected | 可见 | 可见 | 可见 | 不可见 |
public | 可见 | 可见 | 可见 | 可见 |
从上到下逐渐放宽。private 最严,只有自己类里能用;public 最松,谁都能用。
有两个细节容易记错。
默认级别不写任何关键字,它的正式名字叫「包级私有」。注意没有 default 这个修饰符可以写在成员前面——default 是接口默认方法用的关键字,两码事。
protected 比默认更宽,不是更窄。 它在包内的可见性和默认级别一样,额外多给了「不同包的子类」这一档。很多人以为 protected 只对子类开放,其实同包的非子类也能访问。
越权访问的真实报错
试着在别的类里碰 private 字段:
public class Student {
private String name;
}
class Test {
void demo() {
Student s = new Student();
s.name = "小明"; // 编译错误
}
}
```java
编译器给出的原文是:
```text
Test.java:8: error: name has private access in Student
s.name = "小明";
^
1 error
这个错误发生在编译期,代码根本生不成 class 文件。这正是访问修饰符的价值——把误用挡在运行之前。
Warning把成员变量设成 public 等于没封装。工程里成员变量几乎清一色 private,再用方法暴露受控的读写口。这不是教条,是被无数次线上事故换来的共识。
getter 与 setter
想让外面能读能改,但又保留把关权,就提供 getter(读)和 setter(写):
public class Student {
private int age;
public int getAge() { // getter:只读,不做校验
return age;
}
public void setAge(int age) { // setter:写入前先把关
if (age < 0 || age > 150) {
throw new IllegalArgumentException("年龄必须在 0 到 150 之间,收到:" + age);
}
this.age = age;
}
}
```bash
逐行看 setter 干了什么:
- 第一步检查范围,`age < 0` 和 `age > 150` 都是非法值。
- 不合法就抛 `IllegalArgumentException`,把错误信息带上实际收到的值,方便排查。
- 通过校验才执行 `this.age = age`,用 `this` 区分成员变量和同名形参。
现在再想塞进 -5 就不可能了,程序会当场抛异常,而不是带着脏数据继续跑。**问题暴露得越早,修起来越便宜**。
> [!TIP]
> 布尔类型的 getter 按约定写成 `isXxx()` 而不是 `getXxx()`,比如 `isAdult()`、`isEmpty()`。这个约定被 Spring 等框架依赖,不遵守可能导致属性识别不到。
## getter/setter 不必成对出现
新手常以为每个字段都要配一对 getter/setter,这是误解。该给什么口子,取决于业务需要什么。
**只读属性**:只给 getter,不给 setter。
```java
public class Order {
private final String orderId; // 订单号一旦生成就不能改
public Order(String orderId) {
this.orderId = orderId;
}
public String getOrderId() { // 只提供读
return orderId;
}
}
派生属性:字段根本不存在,值是算出来的。
public class Person {
private int birthYear;
public void setBirthYear(int year) {
this.birthYear = year;
}
public int getAge() { // 没有 age 字段,实时计算
return 2026 - birthYear;
}
}
```bash
外面调 `getAge()` 拿到年龄,完全不知道内部存的其实是出生年份。哪天你想改成从生日精确计算,只需要动这一个方法,调用方一行都不用改。
这就是封装给你的自由度。
## 保护可变对象
有个坑很隐蔽:getter 直接把内部的可变对象返回出去,等于把门开了。
```java
public class Team {
private List<String> members = new ArrayList<>();
public List<String> getMembers() {
return members; // 危险:外面拿到的是内部那个 List
}
}
调用方拿到这个 List 后,直接 team.getMembers().clear() 就能把内部数据清空,你的所有校验全部绕过。
稳妥的做法是返回一份不可修改的视图:
public List<String> getMembers() {
return List.copyOf(members); // Java 10+,返回不可变副本
}
```bash
`List.copyOf` 是 Java 10 引入的,返回的列表任何修改操作都会抛 `UnsupportedOperationException`。
历史兼容写法是 `Collections.unmodifiableList(members)`,它包装的是原列表的视图,原列表变了视图也跟着变,语义有差别,新代码优先用 `List.copyOf`。
## 类和方法的访问控制
修饰符不只管字段,也管方法和类。
**方法**:只在类内部用的辅助逻辑,标 `private` 更干净。对外暴露的接口越少,别人误用的机会越少,你以后改起来越自由。
**类**:顶层类只能用 `public` 或不写(包级私有),不能用 `private` 或 `protected`。一个 `.java` 文件里 `public` 类最多一个,且文件名必须和它一致。
```java
// 文件名必须是 Student.java
public class Student { }
// 同一个文件里可以有包级私有的类,不能加 public
class Helper { }
record:Java 16 起的新选择
如果一个类纯粹用来装数据,全字段不可变,Java 16 正式引入的 record 能省掉大量样板代码。
public record Point(int x, int y) { }
```java
这一行等价于一个带 private final 字段、全参构造、访问器方法、`equals`、`hashCode`、`toString` 的完整类。
访问器方法名是 `x()` 和 `y()`,不带 `get` 前缀:
```java
Point p = new Point(3, 4);
System.out.println(p.x()); // 3
System.out.println(p); // Point[x=3, y=4]
record 也能加校验,写在紧凑构造器里:
public record Age(int value) {
public Age { // 紧凑构造器,没有参数列表
if (value < 0 || value > 150) {
throw new IllegalArgumentException("年龄非法:" + value);
}
}
}
```bash
> [!NOTE]
> `record` 的字段全是 final,创建后不能改。需要可变对象时还得用传统类加 getter/setter。第 49 章会把 record 讲透。
## 常见疑问
**问:private 成员真的完全访问不到吗?**
正常代码访问不到,编译器会直接拦下。但通过反射调用 `setAccessible(true)` 可以强行突破。所以封装是**编译期的约定**,不是运行期的安全边界。真要防篡改得靠别的手段,日常开发中把它当作约定遵守就够了。
**问:同一个类的两个不同对象,能互相访问对方的 private 字段吗?**
能。`private` 的边界是「类」,不是「对象」。所以下面这种写法完全合法:
```java
public class Money {
private int amount;
public boolean richerThan(Money other) {
return this.amount > other.amount; // 直接读另一个对象的私有字段
}
}
equals 方法能高效比较,靠的就是这个规则。
问:所有字段都加 getter/setter,和直接 public 有区别吗?
有,但比想象中小。无脑生成全套 getter/setter,确实只比 public 多了一层壳。真正的区别在于你保留了随时加校验、改内部结构的权力,而 public 字段一旦被大量引用就动不了了。更好的做法是按需暴露:能不给 setter 就不给,能只读就只读。