Blog

Java 的类与对象:构造器、static 和 final

Java 类描述对象持有哪些数据、能做什么,构造器决定每个对象怎样开始。通过运行小程序,学会字段、this、构造器、private、static 和 final。

类描述一种东西:它持有哪些数据,能做什么。对象是这种东西的一个具体实例,用 new 从类创建出来。你读到的 Java 代码,大多是类和对象在打交道,所以创建对象、保护对象的规则处处都用得上。

本文讲字段和方法、thistoString、构造器以及它们怎样互相调用、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() 创建一个新对象,每个对象都有自己的一份 namescore。给 ana 加分,不会影响 ben。新对象的字段从默认值开始:数字是 0booleanfalseString 这类引用是 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() 方法,而默认的那个不是给人看的。打印一个没写 toStringPlayer,你会得到类似 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

对余额的每次修改都要经过 depositwithdraw,而两者都会检查规则。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,但三张票共享同一个 createdsummary() 是静态方法,所以要通过类来调用,写成 Ticket.summary(),而且在任何票存在之前就能调用。IO.printlnMath.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 成员属于类,静态方法没有 thisof(...) 这样的静态工厂方法给构造器起了名字。
  • final 字段只赋值一次。如果一个对象的所有字段都是 final 且不可变,它就无法改变,方法会返回新对象。

构造器决定对象怎样开始,privatefinal 决定之后什么可以改变。

这篇文章对你有帮助吗?

点一颗爱心来评分!

平均评分 0 / 5. 投票总数: 0

还没有人投票。来做第一个评分的人吧。