Java 类描述对象持有哪些数据、能做什么,构造器决定每个对象怎样开始。通过运行小程序,学会字段、this、构造器、private、static 和 final。
类描述一种东西:它持有哪些数据,能做什么。对象是这种东西的一个具体实例,用 new 从类创建出来。你读到的 Java 代码,大多是类和对象在打交道,所以创建对象、保护对象的规则处处都用得上。
本文讲字段和方法、this、toString、构造器以及它们怎样互相调用、Java 25 允许在 super(...) 之前运行代码的新变化、private 和其他访问级别、static,还有 final。下面每个程序都在 Java 25 上跑过,输出直接从运行结果粘贴而来。想自己运行,就把代码存成 Main.java,再执行 java Main.java。
类是蓝图,new 造出对象
类声明字段和方法。字段存放每个对象的数据,方法操作这些数据。下面是一个有名字和分数的 Player:
class Player {
String name;
int score;
void addPoints(int points) {
score = score + points;
}
}
void main() {
var ana = new Player();
ana.name = "Ana";
ana.addPoints(10);
ana.addPoints(5);
var ben = new Player();
ben.name = "Ben";
ben.addPoints(7);
IO.println(ana.name + " has " + ana.score);
IO.println(ben.name + " has " + ben.score);
}
输出:
Ana has 15
Ben has 7
new Player() 创建一个新对象,每个对象都有自己的一份 name 和 score。给 ana 加分,不会影响 ben。新对象的字段从默认值开始:数字是 0,boolean 是 false,String 这类引用是 null。
在 addPoints 里,score 指的是调用这个方法的那个对象的分数。对 ana.addPoints(10) 来说,就是 Ana 的分数。
this 是调用方法的那个对象
关键字 this 指当前对象。参数和字段同名时,你就需要它。忘了写,代码照样能编译:
class Player {
String name;
void rename(String name) {
name = name;
}
void renameProperly(String name) {
this.name = name;
}
}
void main() {
var p = new Player();
p.rename("Ana");
IO.println("after rename: " + p.name);
p.renameProperly("Ana");
IO.println("after renameProperly: " + p.name);
}
输出:
after rename: null
after renameProperly: Ana
在 rename 里,name = name 两边都是参数。参数遮住了字段,所以这个方法只是把参数赋给参数自己,字段还是 null。连 javac -Xlint:all 都不会对此发出警告。写成 this.name,意思是“这个对象上的字段”,这样才对。
打印对象:重写 toString
打印一个对象时,Java 会调用它的 toString() 方法,而默认的那个不是给人看的。打印一个没写 toString 的 Player,你会得到类似 Main$Player@ 后面跟八位十六进制数的东西。
@ 前面是类名。它显示为 Main$Player,是因为在只有一个裸 void main() 的文件里,你的类会被放进一个名叫 Main 的隐藏类中。讲 private 的那一节还会用到这个细节。@ 后面是对象的标识哈希码(identity hash code),以十六进制显示。它不是内存地址,也不是根据字段算出来的。JVM 在第一次有代码要它时临时生成。
既然是 JVM 临时生成的,你就不能依赖它。我们用 java Main.java 把同一个文件跑了五次,每次打印的十六进制数都一样。用 javac 编译同一个文件再运行这个类,数字就变了。换一个 JVM、换一种启动方式,或者之前多一个对象算过哈希,都可能让它改变。所以本系列的输出里从来不会出现它。
自己写一个 toString,打印就变得有用,而且可以预测:
class Player {
String name;
int score;
Player(String name, int score) {
this.name = name;
this.score = score;
}
@Override
public String toString() {
return "Player[" + name + ", " + score + "]";
}
}
void main() {
var ana = new Player("Ana", 15);
IO.println(ana);
IO.println("winner: " + ana);
}
输出:
Player[Ana, 15]
winner: Player[Ana, 15]
toString 必须是 public,因为它重写的是 Object 里的一个公有方法,而 Object 是所有类都继承的类。@Override 让编译器检查你确实在重写某个方法。把名字错拼成 toSting,构建会失败,而不是悄悄打印哈希值。字符串拼接也会调用 toString,所以 "winner: " + ana 能正常工作。
这个程序还用到了一个构造器:Player(String name, int score)。接下来就讲构造器。
构造器负责初始化新对象
构造器是你写 new 时运行的代码。它和类同名,没有返回类型,任务是让对象进入一个合法的初始状态。
重载,以及用 this(...) 串联构造器
一个类可以有多个构造器,只要参数列表不同就行。这叫重载。一个构造器可以用 this(...) 调用另一个,这样真正的初始化逻辑只写在一处:
class Pizza {
String size;
int slices;
Pizza(String size, int slices) {
this.size = size;
this.slices = slices;
}
Pizza(String size) {
this(size, size.equals("large") ? 12 : 8);
}
Pizza() {
this("medium");
}
@Override
public String toString() {
return size + " pizza, " + slices + " slices";
}
}
void main() {
IO.println(new Pizza("small", 6));
IO.println(new Pizza("large"));
IO.println(new Pizza());
}
输出:
small pizza, 6 slices
large pizza, 12 slices
medium pizza, 8 slices
new Pizza() 调用 this("medium"),它再调用 this("medium", 8),由后者给字段赋值。编译器根据你传的参数选择构造器,和在重载方法之间做选择的方式一样。
自己写了构造器,免费的那个就没了
如果一个类完全没有构造器,Java 会给它一个不带参数的空构造器。第一个程序里 new Player() 能用,就是这个原因。只要你自己写了任何一个构造器,免费的那个就没了:
class Point {
int x;
int y;
Point(int x, int y) {
this.x = x;
this.y = y;
}
}
void main() {
var origin = new Point();
IO.println(origin.x);
}
构建失败,报错:
Main.java:12: error: constructor Point in class Main.Point cannot be applied to given types;
var origin = new Point();
^
javac 还会补充说明:需要 int,int,却没有找到参数。这是有意为之。既然你已经说明 Point 需要两个数,Java 就不会在缺少它们的情况下造一个出来。如果两种都想要,就自己写一个无参构造器,最好写成 this(0, 0)。
在构造器里校验
每个对象都必须经过构造器,所以这里正适合拒绝不合法的输入。抛出 IllegalArgumentException,对象就根本不会被创建:
class Temperature {
double celsius;
Temperature(double celsius) {
if (celsius < -273.15) {
throw new IllegalArgumentException("below absolute zero: " + celsius);
}
this.celsius = celsius;
}
}
void main() {
var room = new Temperature(21.5);
IO.println("room is " + room.celsius);
var impossible = new Temperature(-300);
IO.println("never printed " + impossible.celsius);
}
输出后停止:
room is 21.5
Exception in thread "main" java.lang.IllegalArgumentException: below absolute zero: -300.0
消息里的 -300 变成了 -300.0,因为参数是 double。任何地方都不会出现低于绝对零度的 Temperature,所以其他方法都不用再检查。
super(...) 之前的代码:Java 25 新特性
子类的构造器必须用 super(...) 调用父类的某个构造器。在 Java 历史上的大部分时间里,这个调用必须是第一条语句。Java 25 把灵活的构造器主体(flexible constructor bodies)转为正式特性,现在你可以在它之前运行代码了。
这解决了一个老麻烦。你常常想先检查参数,再把它交给父类,过去只能把检查塞进一个静态辅助方法,在 super(...) 的参数里调用。现在可以直接写:
class Shape {
String name;
Shape(String name) {
this.name = name;
IO.println("Shape constructor for " + name);
}
}
class Square extends Shape {
int side;
Square(int side) {
if (side <= 0) {
throw new IllegalArgumentException("side must be positive: " + side);
}
String label = "square " + side;
super(label);
this.side = side;
IO.println("Square constructor, area " + side * side);
}
}
void main() {
var sq = new Square(3);
IO.println(sq.name + " is ready");
}
输出:
Shape constructor for square 3
Square constructor, area 9
square 3 is ready
先运行检查和局部变量 label,然后 super(label) 运行 Shape 的构造器,最后是 Square 构造器剩下的部分。Square(0) 会在 Shape 参与之前就抛出异常。同样的规则也适用于 this(...)。
我们确认过这确实是新特性。同一个类用 javac --release 24 编译会失败,报 flexible constructors is not supported in -source 24。那次测试用的是传统的 public class Main,因为 --release 24 也不接受裸 void main() 的写法。
在 super(...) 之前仍然不能碰 this
super(...) 之前的代码可以用参数和局部变量,但不能用正在构建的对象。它的父类部分还不存在:
class Shape {
Shape(String name) {
IO.println("Shape " + name);
}
}
class Square extends Shape {
int side = 1;
Square(int side) {
IO.println("old side was " + this.side);
super("square");
this.side = side;
}
}
void main() {
new Square(3);
}
构建失败,报错:
Main.java:11: error: cannot reference this before supertype constructor has been called
IO.println("old side was " + this.side);
^
在 super(...) 之前,读取字段、调用实例方法、把 this 传到别处,都会被拒绝。只有一个例外:你可以在那里给字段赋值,前提是这个字段没有初始化器。这样子类就能在父类构造器看到自己的字段之前,先把它们设好。
private 让规则只在一处维护
Java 有四种访问级别,字段最常用的是 private。私有成员只能在它自己的类里使用。这为什么重要?银行账户的余额永远不该是负数,而如果谁都能写 balance,谁都能破坏这条规则:
class BankAccount {
private int balance;
void deposit(int amount) {
if (amount <= 0) {
throw new IllegalArgumentException("deposit must be positive");
}
balance += amount;
}
boolean withdraw(int amount) {
if (amount <= 0 || amount > balance) {
return false;
}
balance -= amount;
return true;
}
int balance() {
return balance;
}
}
void main() {
var account = new BankAccount();
account.deposit(100);
IO.println("withdraw 30: " + account.withdraw(30));
IO.println("withdraw 500: " + account.withdraw(500));
IO.println("balance: " + account.balance());
}
输出:
withdraw 30: true
withdraw 500: false
balance: 70
对余额的每次修改都要经过 deposit 或 withdraw,而两者都会检查规则。balance() 方法让任何人都能读余额,却不能写。这就是封装:类拥有自己的数据,外界只能通过保证数据合法的方法进来。如果规则变了,比如允许透支,你只需要改一个类。
在普通的 Java 文件里,编译器会强制执行这条规则。下面的程序用传统的 public class Main,所以 BankAccount 是它旁边一个真正的顶层类:
class BankAccount {
private int balance = 70;
}
public class Main {
public static void main(String[] args) {
var account = new BankAccount();
account.balance = -1000;
IO.println(account.balance);
}
}
构建失败,报错:
Main.java:8: error: balance has private access in BankAccount
account.balance = -1000;
^
紧凑源文件里的意外
去掉 public class Main,改用裸 void main(),同样的访问就能编译通过。这让我们很意外,所以实际跑了一下:
class BankAccount {
private int balance = 70;
}
void main() {
var account = new BankAccount();
account.balance = -1000;
IO.println(account.balance);
}
输出:
-1000
原因就是 toString 那一节提到的隐藏类。在紧凑源文件里,Java 会把所有东西,包括 BankAccount,都包进一个隐式类。所以 BankAccount 根本不是顶层类,而是嵌套在这个隐式类里的类,这也是默认 toString 输出以 Main$ 开头的原因。Java 允许同一个顶层类内部任何位置的代码,使用嵌套在其中的所有类的私有成员。
所以在本系列的单文件程序里,private 挡不住 main 伸手进去。但它仍然表明了你的意图,而且类一挪到自己的文件里,它就成了一堵实实在在的墙。继续写它。
包私有和 public
程序有多个文件以后,其他访问级别才开始起作用。不加任何修饰符时,成员是包私有的:同一个包里的任何类都能用它。public 表示任何代码都能用。protected 介于两者之间,讲继承时再说。一个合理的默认做法是:字段设为私有,方法的可见范围只开到需要的程度。
static 属于类,不属于任何对象
static 字段整个类只有一份,大家共享,而不是每个对象一份。统计创建了多少个对象的计数器,就是典型例子:
class Ticket {
static int created = 0;
int number;
Ticket() {
created++;
number = created;
}
static String summary() {
return created + " tickets so far";
}
}
void main() {
IO.println(Ticket.summary());
var a = new Ticket();
var b = new Ticket();
var c = new Ticket();
IO.println("a=" + a.number + " b=" + b.number + " c=" + c.number);
IO.println(Ticket.summary());
}
输出:
0 tickets so far
a=1 b=2 c=3
3 tickets so far
每张票都有自己的 number,但三张票共享同一个 created。summary() 是静态方法,所以要通过类来调用,写成 Ticket.summary(),而且在任何票存在之前就能调用。IO.println 和 Math.max 也是静态方法。
静态方法没有 this
静态方法在没有对象的情况下运行,所以没有 this 可用,也读不到实例字段:
class Ticket {
int number;
static void show() {
IO.println(this.number);
}
}
void main() {
Ticket.show();
}
构建失败,报错:
Main.java:5: error: non-static variable this cannot be referenced from a static context
IO.println(this.number);
^
把 this.number 换成直接写 number,也会以同样的方式失败,报 non-static variable number。要问的问题是“哪张票的号码?”,而静态方法回答不了。
静态工厂方法
返回新对象的静态方法叫静态工厂方法,现代 Java 里用得很多:List.of(...)、Path.of(...)、Duration.ofSeconds(...)。和构造器不同,工厂方法可以起一个说明用途的名字。
在紧凑源文件里,最直接的第一次尝试编译不过,这一点也让我们意外:
class Delay {
static Delay ofSeconds(int seconds) {
return new Delay();
}
}
void main() {
IO.println(Delay.ofSeconds(90) != null);
}
构建失败,报错:
Main.java:3: error: non-static variable this cannot be referenced from a static context
return new Delay();
^
代码里根本没有 this,所以这条消息看起来不对。其实没错。在紧凑源文件里,Delay 嵌套在隐藏的 Main 类中,而普通的嵌套类是内部类:每个 Delay 对象都绑定着一个 Main 对象。你的 void main() 就运行在这样一个对象上,所以在那里 new Delay() 没问题。静态方法没有 Main 对象可以绑定给新的 Delay,javac 说的 this 就是这个缺失的对象。
同一个类放在自己的文件里,或者放在传统的 public class Main 旁边,都能正常编译。在紧凑源文件里,把类标记为 static,它就不再需要外部对象:
static class Delay {
private final int seconds;
private Delay(int seconds) {
this.seconds = seconds;
}
static Delay ofSeconds(int seconds) {
return new Delay(seconds);
}
static Delay ofMinutes(int minutes) {
return new Delay(minutes * 60);
}
@Override
public String toString() {
return seconds + "s";
}
}
void main() {
IO.println(Delay.ofSeconds(90));
IO.println(Delay.ofMinutes(2));
}
输出:
90s
120s
ofSeconds(90) 和 ofMinutes(2) 都只接收一个 int,所以它们不可能是两个构造器:参数列表会完全一样。构造器是私有的,所以在正常的项目里,工厂方法是创建对象的唯一途径。前面的 Ticket 类不需要 static,因为它的静态方法从来不创建 Ticket。
用十岁孩子能懂的话说
类是一个饼干模具。每个对象都是用它压出来的一块饼干。模具决定形状,但每块饼干都是独立的。你可以在一块上撒糖粒,其他的还是原样。这些糖粒就是字段。
构造器就是往下压的那一下。那是饼干诞生的时刻,也是你拒绝做出一块坏饼干的机会。
static 的东西印在模具本身上,不在任何一块饼干上。比如你在模具把手上记数:每压一次画一道。不管做多少块饼干,都只有这一个计数,而且一块饼干都还没做,你就能读它。
准确的说法
类是一种类型。new 在堆上分配这个类型的对象,把每个字段设为默认值,运行字段初始化器和构造器,然后返回指向这个对象的引用。实例字段存在每个对象里。实例方法把对象当作一个隐藏参数接收,这个参数就是 this。
静态字段每个类只有一份,而不是每个对象一份,静态方法也不接收隐藏的对象。静态成员通过类名访问。类本身只在第一次被使用时加载并初始化一次。
这个比喻的局限:饼干模具没法阻止你做出缺了半边的饼干,构造器却可以,办法是抛出异常。另外,饼干是一块块分开的面团,但 Java 变量里装的不是饼干本身,而是指向它的引用。所以两个变量可以指向同一个对象,看到彼此撒上的糖粒。
final 字段只能赋值一次
final 字段必须恰好赋值一次,要么在声明处,要么在每个构造器里,之后就不能再改。之后再去改它,构建就会失败:
class Money {
final long cents;
Money(long cents) {
this.cents = cents;
}
void addTip(long tip) {
cents = cents + tip;
}
}
void main() {
var bill = new Money(1250);
bill.addTip(200);
}
构建失败,报错:
Main.java:9: error: cannot assign a value to final variable cents
cents = cents + tip;
^
不可变对象
如果每个字段都是 final,而且没有一个指向可变的东西,这个对象构造完成后就永远不会改变。这就是不可变对象。方法不去修改它,而是返回一个新对象:
class Money {
private final long cents;
private final String currency;
Money(long cents, String currency) {
if (cents < 0) {
throw new IllegalArgumentException("negative amount: " + cents);
}
this.cents = cents;
this.currency = currency;
}
Money plus(Money other) {
if (!currency.equals(other.currency)) {
throw new IllegalArgumentException("currency mismatch");
}
return new Money(cents + other.cents, currency);
}
@Override
public String toString() {
return String.format("%d.%02d %s", cents / 100, cents % 100, currency);
}
}
void main() {
var price = new Money(1250, "EUR");
var tip = new Money(200, "EUR");
var total = price.plus(tip);
IO.println("price: " + price);
IO.println("tip: " + tip);
IO.println("total: " + total);
}
输出:
price: 12.50 EUR
tip: 2.00 EUR
total: 14.50 EUR
price.plus(tip) 没有动 price,而是返回了第三个对象。这是一个有实际好处的设计选择。你可以把 Money 传给任何方法、存到任何地方,或者在线程之间共享,谁都没法背着你改它。构造器只检查一次规则,这些规则就永远成立。
注意 final 做不到什么:对一个持有列表的字段,它阻止你换掉这个列表,但不阻止你往列表里添加元素。final 固定的是引用,而不是引用指向的对象。
写一个 Money 类,为了两个值用了很多行代码。讲 record 的那一部分会展示 Java 怎样用一行替你写出这个类。
代码的运行顺序
类还可以有静态块和实例初始化块。静态块在类第一次被使用时运行一次,实例初始化块在每次创建新对象时运行。顺序很容易验证:
class Robot {
static int built = 0;
static {
IO.println("1. static block: the class is initialized");
}
String name = "unnamed";
{
IO.println("2. instance block: name is " + name);
}
Robot(String name) {
IO.println("3. constructor: renaming to " + name);
this.name = name;
built++;
}
}
void main() {
IO.println("main starts");
new Robot("R1");
new Robot("R2");
IO.println("built: " + Robot.built);
}
输出:
main starts
1. static block: the class is initialized
2. instance block: name is unnamed
3. constructor: renaming to R1
2. instance block: name is unnamed
3. constructor: renaming to R2
built: 2
静态块只运行了一次,而且是在 Robot 第一次被使用时,那时 main 已经开始了。对每个对象,字段初始化器和实例块按从上到下的顺序运行,然后才是构造器主体。实际代码里很少用实例块,用构造器几乎总是更清楚。
要点
- 类声明字段和方法。
new创建一个对象,它拥有每个实例字段的独立副本。参数和字段同名时,用this.field。 - 重写
toString。默认的Main$Player@…输出是标识哈希码,在不同的运行、启动方式和 JVM 之间都可能变化。 - 一旦你写了任何构造器,免费的无参构造器就没了。用
this(...)串联构造器,在构造器里校验参数。 - 从 Java 25 开始,构造器可以在
super(...)或this(...)之前执行语句,但不能在那里使用this。 - 把字段设为
private,只通过维护规则的方法修改它们。在紧凑源文件里,类嵌套在一个隐藏类中,所以main仍然能访问私有成员。 static成员属于类,静态方法没有this。of(...)这样的静态工厂方法给构造器起了名字。final字段只赋值一次。如果一个对象的所有字段都是 final 且不可变,它就无法改变,方法会返回新对象。
构造器决定对象怎样开始,
private和final决定之后什么可以改变。