上一篇我们把“工序”打包成了方法,同一段逻辑换个参数就能反复用。但方法只打包了动作。动作要处理的数据呢?还是散落在 main 里的一个个变量。
假设要记录两个银行账户的余额:
1int balanceA = 100;
2int balanceB = 250;现在想给 A 存进 50 元。因为上一篇讲的按值传递,方法改不动外面的变量,只能靠返回值:
1public static int deposit(int balance, int amount) {
2 return balance + amount;
3}
4
5balanceA = deposit(balanceA, 50);这段能跑,但很别扭。“存钱”这件事没法表达成“某个账户存钱”,只能写成“把余额和金额算一算,结果再赋回某个变量”。而且账户一旦不止余额一个属性(还有个户名),变量就会成倍增加,编译器完全不知道 balanceA 和 ownerA 其实是同一个账户的两个属性——对编译器来说,它们只是两个碰巧一起出现的独立变量。
类(class)就是来解决这件事的:把描述同一个东西的数据,和操作这些数据的方法,写进同一个花括号里。
一个类长什么样
1public class Account {
2 String owner; // 字段:户名
3 int balance; // 字段:余额
4
5 void deposit(int amount) {
6 balance = balance + amount;
7 }
8
9 int getBalance() {
10 return balance;
11 }
12}拆开看:
- 最外层是类声明,类名首字母按惯例大写。之前所有代码都塞在
main里,从现在起,一个程序通常由好几个类组成,每个类写一个文件,文件名和public的类名一致。 - 花括号里第一类东西是 字段(field)。学过变量之后,它就是“声明在类里、而不是声明在方法里”的变量,用来装这个对象的状态。它还有个别名叫 实例变量(instance variable),原因下面马上说。
- 第二类东西是方法。注意
deposit里直接写了balance,既没有把它当参数传进来,也没有static关键字。Oracle 把类体的三样内容说得很清楚:字段负责状态,方法负责行为,构造器负责初始化新对象 1、4。
上一篇说“public static 现在照抄就行,含义等学完类与对象再说”。现在可以说清楚了:static 表示这个方法属于类本身,不需要任何对象就能调用,main 就是这样——程序刚启动时,一个对象都还没造出来。不带 static 的方法叫 实例方法(instance method),它必须通过某个具体的对象来调用。
类只是图纸,对象才是实物
上面那个类写完、编译好,内存里一个账户也没有。类 是图纸:它规定“账户有户名和余额,并且能存钱”。对象(也叫 实例,instance)才是按图纸造出来的实物,每个对象各有一份自己的 owner、自己的 balance。
造实物用 new:
1Account a = new Account();
2a.owner = "张三";
3a.deposit(100);
4
5Account b = new Account();
6b.owner = "李四";
7b.deposit(50);a.deposit(100) 里的点号读作“的”:让 a 这个对象执行它的 deposit 方法。这正好解释了 deposit 里为什么能直接写 balance——点号前面那个对象,就是这次调用要操作的对象,方法体里的 balance 指的就是它的余额字段。
同一段 deposit 代码,a 调用时改的是 a 的余额,b 调用时改的是 b 的余额。代码只有一份,字段每个对象一份,两边互不干扰 4。
new 到底做了什么
Account a = new Account(); 这一行包含两半:左边 Account a 声明了一个类型为 Account 的变量;右边 new Account() 负责造对象。new 执行时分四步 2、6:
- 在 堆(heap)上划出一块内存,大小刚好放得下这个对象的所有字段。
- 把这块内存里每个字段设成该类型的 默认值:
int是 0,double是 0.0,boolean是 false,引用类型是null。这是字段和局部变量的一个重要区别——方法里的局部变量不赋初值就不能用,字段不写也有默认值 5。 - 调用 构造器(constructor),把字段设成你想要的初始值。
- 返回一个 引用(reference),相当于这块内存的门牌号。
也就是说,a 这个变量里装的不是一个完整的账户,而是一个门牌号(引用)。这个区分马上能解释一件反直觉的事:
1Account a = new Account();
2a.deposit(100);
3
4Account b = a; // 复制的只是引用
5b.deposit(50);
6
7System.out.println(a.getBalance()); // 打印 150Account b = a; 并没有造出第二个账户,只是把门牌号抄了一份。a 和 b 指向堆上的同一个对象,通过谁改都是同一个 balance,所以结果是 150 而不是 100。
这也能和上一篇的按值传递接上:Java 永远按值传递,只不过当那个值是引用类型时,复制的那份值就是引用,而不是对象本身。所以把对象传进方法、在方法里改它的字段,外面看得见;而传一个 int 进去改它,外面看不见——这是同一套规则在两个地方的结果。
让对象一出生就带好数据:构造器
new Account() 里那对空括号,其实是在调用一个不带参数的构造器。你没写,编译器会替你补一个。构造器的语法特点很好认:名字必须和类名一样,而且没有返回类型(这点和方法不同)3。
如果想让账户一造出来就带着户名和余额,就自己写一个:
1public class Account {
2 String owner;
3 int balance;
4
5 public Account(String who, int start) {
6 owner = who;
7 balance = start;
8 }
9
10 void deposit(int amount) {
11 balance = balance + amount;
12 }
13 // getBalance 等同前
14}1Account a = new Account("张三", 100);这一行的执行顺序是:先划内存、把字段清成默认值,再跑这个构造器把 owner、balance 设好,最后才把引用交回给 a 2。等 new 那一行执行完,a 指向的对象已经是完整可用的,不可能拿到一个“余额还没设好”的半成品。
(上面参数特意取名 who、start:如果参数名和字段重名,方法体里的名字会优先指参数,处理办法留到下一篇。)
回过头看
现在三个概念可以串起来了:类 是图纸,对象 是照图纸造出来的实物,new 是“造一个”这个动作——划分内存、设默认值、跑构造器、交回引用。字段和实例方法都挂在对象上:字段属于每个对象自己,实例方法只有一份代码,但调用时靠点号前面那个对象决定“给谁干活”。
之前那种“余额和方法分开、靠参数和返回值来回倒腾”的写法,到这里就换成了一句 a.deposit(50)。数据和操作它的方法终于待在了一起。