类把状态和行为放在一起,泛型把类型也变成参数。先判断两个类型之间是“是一个”还是“有一个”再决定继承还是组合,然后把允许的类型范围用泛型收口,错误挡在编译期。
一、类与对象:蓝图、状态与行为
类描述对象拥有哪些字段、能做哪些动作;对象是按这个蓝图造出来的具体实例。状态是这一刻的数据,行为是能执行的操作,两者放回同一个对象,相关逻辑才不会散落到各处。
final class Fan {
private final String make;
private final double radius;
private boolean on;
private int speed;
Fan(String make, double radius) {
this.make = make;
this.radius = radius;
}
void switchOn() { on = true; }
void switchOff() { on = false; speed = 0; } // 规则留在对象内部
void setSpeed(int speed) { this.speed = speed; }
int speed() { return speed; }
}switchOff() 顺手把速度归零,这条规则如果交给调用者,就得写 fan.switchOff(); fan.setSpeed(0);——两步之间存在一个“已关但速度还没清”的中间状态。把不变量封装进行为,比暴露 setter 更可靠。
new 两次得到两个不同对象,即使字段完全相同。对象身份与逻辑相等是两个问题,后者由 equals 定义。
final class RGB {
private int r, g, b;
RGB(int r, int g, int b) {
if (r < 0 || r > 255 || g < 0 || g > 255 || b < 0 || b > 255) {
throw new IllegalArgumentException("component must be 0..255");
}
this.r = r; this.g = g; this.b = b;
}
void invert() { r = 255 - r; g = 255 - g; b = 255 - b; }
}提示:RGB 这类取值受限的对象应该在构造时就校验。对象从诞生起就合法,后续逻辑才不必反复防御。
二、引用、内存与按值传递
初学时的内存模型可以这样用:方法调用形成栈帧,基本类型局部变量把值直接存在栈帧里,new 出来的对象通常在堆上,引用变量保存的是“能找到这个对象”的引用值。
栈帧
int x ──────────────→ 5
堆
RGB 对象
↑
引用变量 rgb ────────→ RGB 对象这是便于理解的模型,JIT 与逃逸分析会让实际存放更复杂。语言层面稳定成立的结论只有两条:基本类型变量保存值,引用变量保存引用值。
由此推出最重要的一条:Java 只有值传递,没有引用传递。 传对象时复制的是那个引用值。
static void rename(Person p) { p.name = "Bob"; } // 改的是双方共同指向的对象
static void replace(Person p) { p = new Person("New"); } // 只重绑方法内的引用副本
Person person = new Person("Alice");
rename(person); // person.name == "Bob"
replace(person); // person 仍指向原对象赋值同样是复制引用,于是出现别名现象:
Person a = new Person("Alice");
Person b = a;
b.name = "Bob";
// a.name 也变成 Bob数组和集合同样如此。两种办法切断别名:传递时复制一份(List.copyOf、数组 copyOf),或者把元素本身设计成不可变。不可变对象能从根上消除这类意外,因为没有可改的内部状态。
警告:
==比较引用(是不是同一个对象),equals比较内容。别把“能通过引用改到对方”当成“拥有它”。
三、构造器与初始化顺序
构造器负责在对象诞生时建立合法初始状态:没有返回类型,名称与类名完全相同,可以重载。创建时已经知道的必要信息应当通过构造器传入,而不是 new 出来再连调一串 setter。
默认构造器的陷阱:类里没写任何构造器时,编译器会补一个无参构造器;一旦自己写了任意一个构造器,这个无参版本就不再自动提供。
class Fan {
Fan(String make) { } // 写了带参构造器
}
new Fan(); // 编译错误
new Fan("IBM"); // 正确确实需要两种创建方式时,得自己把无参构造器补出来。
初始化的执行顺序是固定的,父类永远先于子类:
- 分配对象空间,此时字段是语言默认值(数值
0、引用null); - 执行父类构造链:父类字段初始化器 → 父类实例初始化块 → 父类构造体;
- 回到子类,按源码文本顺序执行字段初始化器与实例初始化块;
- 执行子类构造体。
class Child extends Parent {
int value = log("子类字段");
{ log("子类实例块"); }
Child() {
super(); // 必须是第一条显式构造调用
log("子类构造体");
}
}
// 输出:父类构造体 → 子类字段 → 子类实例块 → 子类构造体字段初始化器与实例块交错出现时严格按文本顺序执行。构造体里再次赋值会覆盖字段初始化器的值。
this(...) 委托同类的另一个构造器,super(...) 调直接父类构造器。两者都必须是构造器中的第一条显式构造调用,且不能同时出现——对象不能在自身构造完成前执行初始化逻辑,父类状态也必须先建立。
Child() { this(); } // 委托给无参构造器
Child(int value) { this(); this.value = value; }警告:不要在字段初始化器或构造器里调用可重写的实例方法。父类构造期间子类的字段可能还没初始化完成,此刻调用子类重写方法会读到未就绪的状态。
除了默认构造器,另两种值得认识:拷贝构造器接受同类对象用于复制语义,私有构造器用于禁止外部实例化(工具类、单例式写法)。
四、静态成员
static 表示成员属于类而不是某个实例:一份类数据,所有实例共享;静态方法没有隐含的 this,因此不能直接访问实例字段或调用实例方法。
final class Player {
private static int createdCount;
public static final int MAX_NAME_LENGTH = 40;
static { createdCount = 0; } // 类初始化时执行一次
private final String name;
Player(String name) { this.name = name; createdCount++; }
static int createdCount() { return createdCount; }
}静态块在类初始化时运行,不是每建一个对象就运行一次;类初始化通常发生在第一次主动使用该类时。工具类只有静态方法,不需要实例,应当把构造器设为私有以阻止无意义实例化。
适合静态方法的场景:纯工具方法(Math.sqrt)、工厂方法、操作真正属于类级别的状态、程序入口 main。
警告:静态方法之间是隐藏而非重写,子类的同签名静态方法按编译时类型选择,不参与运行时多态。
static final也不等于深度不可变——static final List<String> ITEMS = new ArrayList<>()的引用固定了,列表内容照样能改。静态可变状态在多线程下还会互相污染。
五、封装与可见性
封装有三层:把数据与操作组织成职责单元、限制外部直接改内部状态、把校验与状态转换集中到一处。银行账户不该允许 setBalance(-1000) 这种调用,更该提供的是行为:
final class BankAccount {
private long balance;
BankAccount(long openingBalance) {
if (openingBalance < 0) {
throw new IllegalArgumentException("balance cannot be negative");
}
this.balance = openingBalance;
}
void withdraw(long amount) {
if (amount <= 0 || amount > balance) {
throw new IllegalArgumentException("invalid withdrawal: " + amount);
}
balance -= amount;
}
long balance() { return balance; }
}访问修饰符从宽到严:
| 修饰符 | 可见范围 |
|---|---|
public |
任意可访问该类型的代码 |
protected |
同包代码,以及子类 |
| 不写(包访问) | 仅同一个包,子包不算 |
private |
仅声明它的类型内部 |
提示:字段默认
private,公共方法只暴露真正需要的能力,子类扩展点才用protected。另外,别把getItems()直接返回内部可变集合——外部拿到就能破坏内部状态,返回只读视图或防御性副本更安全。访问修饰符防的是合法代码误用,不是安全沙箱。
六、继承
继承表达“是一个”且可以替换的关系:凡是能当 Animal 用的地方,Dog 都要能当用。
class Animal {
protected final String name;
Animal(String name) { this.name = name; }
void sound() { System.out.println("……"); }
}
final class Dog extends Animal {
Dog(String name) { super(name); }
@Override
void sound() { System.out.println("汪"); }
}Java 中类只能直接继承一个类,但可以实现多个接口。父类的 private 成员仍是父类状态的一部分,只是子类不能直接访问。构造子类对象时父类必须先完成初始化,所以子类构造器的第一条是 super(...)。
继承用来复用结构与表达类型关系;仅仅想复用几行代码就引入继承,往往得不偿失:
- 父类的所有状态与行为都会暴露给子类,包括本不该被继承的;
- 父类一改,所有子类跟着受影响,这叫脆弱基类;
- 继承树越深,调用链与初始化顺序越难追踪。
七、多态
多态就是“同一段调用代码,按实际对象执行不同实现”:
Animal animal = new Dog(); // 变量的静态类型是 Animal
animal.sound(); // 执行 Dog.sound()变量类型只是编译期看到的声明,真正决定执行哪个方法的是运行时的实际对象,这叫动态方法分派。因此不要按声明类型猜输出。
static void run(GamingConsole game) { // 声明是接口
game.up(); game.down(); game.right();
}
run(new MarioGame()); // jump / go into pipe / move forward
run(new ChessGame()); // move piece up / down / rightsuper.method() 可以在重写里复用父类实现:
@Override
void send() {
super.send(); // 先做通用逻辑
System.out.println("已通过邮件发送");
}类型判断用模式匹配(instanceof 正式定稿于 Java 16),判断与转换一步完成:
if (p instanceof Student s) {
s.setCollege("MyCollege");
}getClass() != other.getClass() 只承认完全相同的运行时类,other instanceof Person 则也接受子类实例。选哪个取决于领域规则:子类新增字段也应参与相等判断就用 getClass(),子类只是同一类型的扩展就可以用 instanceof。父子两处判定方式不一致会破坏对称性。
八、接口、抽象类与标记接口
接口描述能力或角色,实现由实现类提供;接口中的普通抽象方法隐式 public abstract,字段隐式 public static final(所以接口不能保存实例状态)。
interface Flyable {
void fly();
default String description() { return "flying object"; }
static Flyable named(String name) { return () -> System.out.println(name + " flies"); }
}default 方法的重要价值是向后兼容:往已有接口加抽象方法会迫使所有实现类跟着改,加一个有合理默认行为的 default 方法则不会。static 方法只属于接口本身,不被实现类继承。
抽象类适合“确实是一族类型、且要共享实现或状态”的场景,典型是固定流程的模板:
abstract class Recipe {
final void execute() { prepare(); cook(); cleanUp(); } // 流程固定,步骤可变
abstract void prepare();
abstract void cook();
abstract void cleanUp();
}标记接口不定义任何方法,只给类型打一个运行时可判别的标签:
interface SecureResource { } // 用 instanceof 决定是否执行额外安全检查Serializable、RandomAccess 都是标记接口。如果只是编译期元数据而不需要 instanceof,注解通常比标记接口更合适。
| 需求 | 选择 |
|---|---|
| 描述跨类型能力,一个类可以有多个角色 | 接口 |
| 一族类型共享状态、构造过程或具体算法 | 抽象类 |
| 只是打个类型标签 | 标记接口 |
提示:多个接口提供同签名
default方法会冲突,通常由类中的实现或最具体的接口胜出,无法自动解决时必须显式重写。另外接口从 Java 9 起可以有private辅助方法。
九、内部类
定义在另一个类里的类统称嵌套类:不带 static 的叫内部类(成员形式),带 static 的叫静态嵌套类。
class Report {
private int currentRow;
final class Cursor { // 成员内部类
int current() { return currentRow; } // 直接访问外部实例状态
}
static final class Formatter { // 静态嵌套类
static String header(String title) { return "[" + title + "]"; }
}
}
Report report = new Report();
Report.Cursor cursor = report.new Cursor(); // 内部类需要外部实例
Report.Formatter.header("标题"); // 静态嵌套类直接创建内部类持有外部实例的隐式引用,因此不能在外部对象之外独立创建;它里面的 this 指内部类自身,外部实例要写 Report.this。静态嵌套类不捕获外部实例,适合作为成组组织但相对独立的辅助类型。
警告:内部类会延长外部对象的生命周期。异步任务、监听器或缓存如果意外保存了内部类实例,可能阻止外部对象被回收。
方法内定义的类叫局部类;没有显式名字、只用一次的可以写成匿名类:
Runnable task = new Runnable() {
@Override public void run() { System.out.println("一次性实现"); }
};实现单个函数式接口时,Lambda 或方法引用通常更简洁。
十、组合、内聚与耦合
组合表示“有一个”,并把工作委托过去:
interface Engine { void start(); }
final class Car {
private final Engine engine;
Car(Engine engine) { this.engine = Objects.requireNonNull(engine); }
void drive() { engine.start(); }
}Car has an Engine 不能因为想复用 Engine.start() 就写成 Car extends Engine——那会错误地表达“车是一台发动机”,并把发动机的全部行为一并继承过来。
两个衡量维度的分工很清晰:内聚看单个类(职责是否彼此相关),耦合看类之间(依赖对方实现细节的程度)。
低内聚的反例是一个类同时做下载、解析、写库;改进的做法是拆成 InternetDownloader、DataParser、DatabaseStorer,每个类只有一个清晰的变化理由。紧耦合的标志是类内部直接 new 具体实现:
final class OrderProcessor {
private final PaymentMethod payment;
OrderProcessor(PaymentMethod payment) { this.payment = payment; }
}依赖通过构造器从外部传入后,换一个实现不必改处理器。接口只有在配合依赖注入时,才真正降低实现耦合。
提示:抽象与封装不是同义词。抽象是“向使用者展示必须知道什么、隐藏怎么做到”,封装是“控制内部数据如何保存与修改”。
List.add隐藏了扩容与容量检查,两者兼有但侧重点不同。
十一、密封类
需要精确控制继承体系、又不想禁止扩展时,用密封类(Java 17):
public sealed interface Shape permits Circle, Rectangle, Square {}
public record Circle(double radius) implements Shape {}
public record Rectangle(double width, double height) implements Shape {}permits 名单之外的类无法实现,每个直接实现类必须显式选择 final(到此终止)、sealed(继续限制下一层)或 non-sealed(重新开放)。permits 名单要一次想全,名单外的继承会在编译期被拦下。
十二、泛型:为什么需要
没有泛型时容易走向两个极端。要么过度僵化:StringList 只能装 String,换成 Integer 就得复制一个类;要么过度灵活:ObjectList 任何类型都能加,但取出时静态类型只是 Object,强转失败要等到运行时才抛 ClassCastException。
泛型同时给出复用、类型安全与更少的强转:
static class MyCustomList<T> {
private final List<T> list = new ArrayList<>();
public void addElement(T element) { list.add(element); }
public T get(int index) { return list.get(index); }
}
MyCustomList<String> names = new MyCustomList<>();
// names.addElement(42); 编译期就报错泛型也可以只作用于单个方法,写法是返回类型之前声明类型参数:
static <T> T identity(T value) { return value; }只有一组方法共享同一类型关系时用泛型类;只有一个操作需要泛型时用泛型方法。
提示:泛型是不变的——
List<String>不能赋给List<Object>,否则调用方可能写入 Double 破坏原约束。原始类型(List list = new ArrayList())会丢掉类型安全并产生警告,不要用它绕开编译器。
十三、上界与泛型方法
只收数字时写上界 T extends Number,字符串在编译期就被拦下,同时可以直接调用 doubleValue:
static class NumberList<T extends Number> {
private final List<T> list = new ArrayList<>();
void add(T value) { list.add(value); }
double sum() {
double total = 0.0;
for (T value : list) total += value.doubleValue(); // 因上界而可用
return total;
}
}上界也可以交叉,用来同时要求“是某类型”和“具备某行为”:
static <T extends Document & Printable> void printAll(List<T> items) { ... }界限应当服务于真正要调用的方法,而不只是形式上的限制。
十四、通配符:读用 extends,写用 super
List<Integer> 传不到 List<Number> 位置,正是为了防止调用方写入 Double 破坏约束。通配符把读写方向分开管理,记忆法是 PECS——Producer Extends, Consumer Super:
static double sum(List<? extends Number> values) { // 生产者:只安全地读
double total = 0.0;
for (Number n : values) total += n.doubleValue();
return total;
}
static void addNumbers(List<? super Integer> values) { // 消费者:只安全地写
values.add(1);
values.add(2);
}| 写法 | 读出类型 | 能否写入 |
|---|---|---|
List<? extends Number> |
Number | 不能写入具体元素(可删除或写 null) |
List<? super Integer> |
只能当 Object | 可写 Integer |
List<Integer> |
Integer | 可读可写 |
? extends 列表内部仍可删除元素或置 null——禁止的只是添加新的元素。两种方向组合起来就是标准的复制写法:
static <T> void copy(List<? super T> destination, List<? extends T> source) {
for (T value : source) destination.add(value);
}不要为了“反正不读”就到处加下界通配符,多数普通方法直接用 List<T> 更清晰。
十五、类型擦除的代价
泛型主要由编译器实现,编译后类型参数大部分被抹掉:无界 T 擦除为 Object,有界 T extends Number 擦除为它的上界 Number。所以 Box<String> 与 Box<Integer> 在运行时是同一个 Box 类。
System.out.println(new ArrayList<String>().getClass()
== new ArrayList<Integer>().getClass()); // true
new T(); // 非法:不能实例化类型参数
new T[10]; // 非法:不能创建泛型数组
static T data; // 非法:静态环境不能引用类的类型参数
obj instanceof List<String> // 非法:运行时没有这个类型信息因此运行时只能写 obj instanceof List<?>。编译器会插入必要的类型转换和桥接方法,保证重写后的多态在擦除前提下依然正确。用 javac -Xlint:unchecked 查堆污染,javap -c -v 可以看到桥接方法与 Signature 属性。
十六、易错点总表
| 现象 | 原因 | 处理 |
|---|---|---|
new Fan() 编译失败 |
写了带参构造器,默认版本不再生成 | 需要无参时自己补 |
| 方法内改字段外部看不到 | 重新给形参赋值,只换了引用副本 | 改对象内容,或返回值 |
| 别名意外互相影响 | 复制的是引用 | 传参时复制一份,或用不可变对象 |
| 父类构造期子类字段为空 | 构造器里调用了可重写方法 | 避免,交给工厂或显式初始化 |
| 工具类被实例化 | 构造器是 public | 私有构造器 |
| 子类“改不到”父类行为 | 同名静态方法是隐藏不是重写 | 按类型调用,别指望多态 |
equals 相等但集合找不到 |
只重写了 equals 未重写 hashCode |
两个一起重写 |
HashMap 行为异常 |
可变对象做键导致哈希位置改变 | 键用不可变对象 |
子类访问 protected 失败 |
包访问不含子包 | 调整包划分或提供公开方法 |
| 父类一改子类全崩 | 为复用少量代码而继承 | 改用组合委托 |
List<String> 赋给 List<Object> |
泛型不变 | 用通配符或拷贝方法 |
instanceof List<String> 报错 |
类型擦除 | 只能 instanceof List<?> |
permits 名单外无法实现 |
密封类受限 | 名单一次想全或用 non-sealed |
面向对象管的是状态与行为的组织方式——先判“是一个”还是“有一个”,再决定继承、组合还是接口;泛型管的是类型安全的复用——先定读写方向,再套上界或通配符。集合体系在这两套机制之上搭建数据处理的流水线。
← 返回文章