类与对象
如果说前五章我们学的是”咖啡豆”——变量、运算符、流程控制、数组,那么从这一章开始,我们要把它们磨成粉、萃取出咖啡,端出一杯完整的饮品。这就是面向对象编程(Object-Oriented Programming,简称 OOP)。
想象一位咖啡师。她不仅知道”咖啡豆是什么”,更知道”如何把豆子变成一杯拿铁”。在面向对象的世界里,我们不再只关注”数据”和”步骤”,而是关注”谁在做什么”——把数据和操作数据的方法揉成一个整体,这就是对象(Object)。
本章是面向对象之旅的起点。我们将从思想出发,理解类与对象的本质,学会定义自己的类型,并最终设计出一个能战斗的”英雄”。
一、面向对象思想:三大特性概述
在 procedural(面向过程)编程的时代,程序是一连串”先做这个、再做那个”的指令。这种思路在小型程序里清晰直观,但随着软件规模膨胀,代码会变成一团乱麻——某个数据被几十个函数随意修改,bug 就像藏在蛛网里的灰尘,难以察觉。
面向对象思想提供了一种全新的组织方式:把程序看作一组协作的对象。每个对象都有自己的数据(状态)和行为(方法),它们通过消息传递来协作。这种方式更贴近人类认知世界的方式——我们看到的是”一辆红色的汽车在加速”,而不是”先设置颜色变量,再调用加速函数”。
面向对象有三大核心特性,它们将贯穿接下来几章的学习:
-
封装(Encapsulation):把数据藏在对象内部,只暴露必要的接口。就像咖啡机的内部线路被金属外壳包裹,你只需按按钮,不必懂电路。封装让对象的状态不被随意篡改,提升安全性。
-
继承(Inheritance):子类可以继承父类的字段和方法,避免重复造轮子。就像拿铁继承了”咖啡+牛奶”的基础配方,又加入了独特的奶泡艺术。
-
多态(Polymorphism):同一个方法调用,不同的对象表现出不同的行为。就像同一句”请出杯”,咖啡师、调酒师、茶艺师会做出截然不同的饮品。
本章我们先聚焦于”类与对象”本身,封装、继承、多态将在后续章节逐一深入。
二、类与对象:蓝图与房子
2.1 一个生动的比喻
想象一位建筑师。她在图纸上画出房子的设计图——几间卧室、几个卫生间、门窗的位置。这张设计图就是类(Class):它是一种”模板”或”蓝图”,描述了某一类事物的共同特征。
而根据设计图盖出来的真实房子,就是对象(Object)——也叫实例(Instance)。同一张图纸可以盖出许多房子,它们结构相同,但里面的家具、住户各不相同。
类(设计图) 对象(实例)
House → House@1a2b(你家的房子)
→ House@3c4d(邻家的房子)
→ House@5e6f(另一栋)
类是抽象的”概念”,对象是具体的”存在”。定义类时不会分配内存,只有 new 出对象时才会在堆内存中创建。
2.2 类的定义
一个 Java 类通常包含三种成员:
- 字段(Field):对象的状态/属性,有时也叫成员变量。
- 方法(Method):对象的行为/功能。
- 构造方法(Constructor):用于创建并初始化对象。
public class Coffee {
// 字段(属性)
String name; // 名字
int size; // 杯型(毫升)
boolean hasMilk; // 是否加奶
// 构造方法:与类同名,无返回值类型
public Coffee(String name, int size, boolean hasMilk) {
this.name = name;
this.size = size;
this.hasMilk = hasMilk;
}
// 方法(行为)
public void describe() {
String milk = hasMilk ? "加奶" : "不加奶";
System.out.println(name + "," + size + "ml," + milk);
}
}
2.3 创建与使用对象
用 new 关键字调用构造方法,就能在堆内存中创建一个对象:
Coffee c1 = new Coffee("拿铁", 350, true); // 创建对象
c1.describe(); // 调用方法:拿铁,350ml,加奶
System.out.println(c1.size); // 访问字段:350
这一行代码做了三件事:
new Coffee(...)在堆(Heap)中分配内存,初始化字段。- 构造方法被调用,对字段赋值。
- 把对象的引用(地址)赋给栈中的变量
c1。
⚠️ 注意:Java 中对象变量存的是”引用”,不是对象本身。
Coffee c1 = c2;不会复制对象,而是让c1和c2指向同一个对象。
让我们把这些都跑起来看看:
三、this 关键字:指向”当前对象”
在构造方法里你可能注意到了 this.name = name; 这样的写法。这里的 this 是什么?
3.1 this 指向当前对象
this 是一个隐式参数,代表”正在调用这个方法的当前对象”。当字段名与参数名相同时,this.name 指字段,name 指参数——this 起到了区分作用。
public Coffee(String name, int size, boolean hasMilk) {
this.name = name; // this.name 是字段,name 是参数
this.size = size;
this.hasMilk = hasMilk;
}
如果不写 this,就会变成 name = name;——把参数赋值给自己,字段仍是默认值(null/0/false)。这是新手常踩的坑。
3.2 this 在普通方法中
在普通方法里,this 指向调用该方法的那个对象:
public void describe() {
// this.name 等同于 name,this 可省略
System.out.println(this.name + "," + this.size + "ml");
}
当字段与局部变量不重名时,this 通常省略;编译器会自动补上。
3.3 this 调用构造器
this(...) 可以在一个构造方法中调用本类的另一个构造方法,避免重复初始化代码。注意:this(...) 必须是构造方法的第一条语句。
public class Coffee {
String name;
int size;
// 主构造方法
public Coffee(String name, int size) {
this.name = name;
this.size = size;
}
// 重载的构造方法:默认 350ml
public Coffee(String name) {
this(name, 350); // 调用上面的构造方法
}
}
四、方法重载(Overload):一名多身
4.1 为什么需要重载
想象咖啡店的点单系统:客人可能说”来杯拿铁”,也可能说”来杯拿铁,大杯”,甚至”来杯拿铁,大杯,少冰”。如果每种说法都要起一个不同的方法名——orderLatte()、orderLatteWithSize()、orderLatteWithSizeAndIce()——名字会爆炸。
方法重载(Method Overloading)允许同一个类中存在多个同名方法,只要它们的参数列表不同(参数个数、类型或顺序不同)。编译器会根据传入的参数自动选择合适的方法。
4.2 重载的规则
- 方法名必须相同。
- 参数列表必须不同(个数、类型、顺序至少一项不同)。
- 返回类型、访问修饰符不影响重载(不能仅靠返回类型区分)。
public class CoffeeMaker {
// 重载 1:无参
public void make() {
System.out.println("做一杯默认咖啡");
}
// 重载 2:一个 String 参数
public void make(String name) {
System.out.println("做一杯" + name);
}
// 重载 3:String + int 参数
public void make(String name, int size) {
System.out.println("做一杯" + size + "ml 的" + name);
}
// 重载 4:int + String(顺序不同,也算重载)
public void make(int size, String name) {
System.out.println(size + "ml 的" + name + "做好了");
}
}
调用时,编译器根据实参类型和数量匹配最合适的方法:
maker.make(); // 调用重载 1
maker.make("拿铁"); // 调用重载 2
maker.make("拿铁", 350); // 调用重载 3
maker.make(350, "拿铁"); // 调用重载 4
💡 重载 vs 重写:重载(Overload)发生在同一个类中,是编译时多态;重写(Override)发生在父子类之间,是运行时多态。两者不要混淆,后续章节会详细讲解重写。
五、可变参数(varargs):参数个数的弹性
5.1 语法
有时我们无法预先知道参数的个数——比如”统计这几天的销量”,可能是 3 天,也可能是 10 天。Java 5 引入了可变参数(Variable Arguments,简称 varargs):
类型... 参数名
例如:int... nums 表示可以接收任意个数(包括 0 个)的 int 参数。在方法内部,nums 被当作数组处理。
public int sum(int... nums) {
int total = 0;
for (int n : nums) {
total += n;
}
return total;
}
调用时可以传入任意数量的参数:
sum(); // 0
sum(1, 2, 3); // 6
sum(10, 20, 30, 40); // 100
sum(new int[]{1, 2}); // 也可以直接传数组
5.2 可变参数的规则
- 一个方法最多只能有一个可变参数。
- 可变参数必须是参数列表的最后一个。
// 正确:可变参数在最后
public void log(String tag, String... messages) { ... }
// 编译错误:可变参数不在最后
// public void log(String... messages, String tag) { ... }
5.3 可变参数的本质
可变参数本质上是语法糖(Syntactic Sugar)。编译器会把它编译成数组,int... nums 在字节码层面就是 int[] nums。sum(1, 2, 3) 实际上等价于 sum(new int[]{1, 2, 3})。许多 Java API 都用了可变参数,比如 String.format、System.out.printf、List.of、Set.of 等。
⚠️ 可变参数与重载的陷阱:当重载方法同时存在可变参数版本和固定参数版本时,编译器优先匹配固定参数版本。例如同时有
make(String name)和make(String... names),调用make("拿铁")会匹配前者。如果只有可变参数版本,make()也能调用(传 0 个参数),这有时会引发意料之外的行为。
六、实战:设计一个英雄类
把所学知识融会贯通,我们来设计一个游戏中的”英雄”类。英雄有名字、血量、攻击力,并能发动攻击。
6.1 设计思路
- 字段:
name(名字)、hp(血量)、attack(攻击力)。 - 构造方法:初始化英雄属性。
- 方法:
attack(Hero target):攻击另一个英雄,扣减其血量。isAlive():判断英雄是否存活。showStatus():显示英雄状态。
- 重载:
rest()(恢复固定血量)和rest(int amount)(恢复指定血量)。
6.2 完整实现
6.3 代码要点回顾
- 构造方法
Hero(String, int, int)完成对象初始化。 this.name、this.hp、this.attack用this区分字段与参数。rest()与rest(int)构成方法重载,编译器根据参数个数选择。attack(Hero target)接收一个Hero对象作为参数——对象也可以作为方法的参数和返回值,传递的是引用。isAlive()返回boolean,体现了”方法封装逻辑”的好处:调用方不需要关心hp > 0的细节。
七、类与对象的内存图景
理解内存布局,能帮你避开许多初学者的陷阱。
Hero arthur = new Hero("亚瑟", 500, 80);
执行后内存中发生了什么?
- 栈(Stack) 中创建局部变量
arthur。 - 堆(Heap) 中分配一块内存存放
Hero对象,字段初始化为name="亚瑟"、hp=500、attack=80。 arthur存储堆中对象的地址(引用)。
栈 堆
arthur ──────────────► Hero对象
name = "亚瑟"
hp = 500
attack = 80
如果执行 Hero luban = arthur;,那么 luban 和 arthur 会指向同一个对象——修改 luban.hp 也会影响 arthur.hp。这正是”引用类型”的核心特征。
⚠️ 字符串的特殊性:从 Java 7 起,
String对象也存在堆中(之前在方法区的字符串常量池)。字符串字面量(如"亚瑟")会被放入常量池复用,而new String("亚瑟")会在堆中新建对象。这些细节后续章节会进一步讨论。
八、本章小结
| 概念 | 要点 |
|---|---|
| 类(Class) | 模板/蓝图,定义字段、方法、构造方法 |
| 对象(Object) | 类的实例,通过 new 创建于堆内存 |
| 构造方法 | 与类同名,无返回值,用于初始化对象 |
this | 指向当前对象;this(...) 调用本类其他构造方法 |
| 方法重载 | 同名方法、参数列表不同,编译时决定调用哪个 |
| 可变参数 | 类型... 名称,本质是数组,必须放在参数列表末尾 |
结语
这一章,我们从”面向过程”的旧世界跨入了”面向对象”的新世界。我们学会了用类来定义自己的类型,用 new 来创造对象,用 this 来指代自己,用重载来让同一个名字承担多种职责。
但目前的 Hero 类有个问题:它的字段是 public 的,任何人都能直接写 arthur.hp = -999,让英雄莫名其妙地”负血”。这种数据的不安全性,正是下一章”封装”要解决的问题。封装,是面向对象三大特性的第一道防线,也是写出健壮代码的基石。
让我们带着”英雄”的故事,继续向封装的世界进发。