主题
界面
主题
这是面向对象编程的第一课,也是整个 Java 学习路线的分水岭。
在此之前,你写的代码都是"一堆变量 + 一堆方法"。从本节开始,你写的每个程序都会由对象组成——对象把"数据"和"操作数据的方法"绑在一起,这是 Java 组织代码的基本单位。后面的封装、继承、多态,全部建立在本节的概念之上。
完成本节后,你应该能够:
new 创建对象;this 在构造方法中解决的歧义问题;== 比较对象时到底比较了什么。int、double、boolean)与它们的内存行为吗?(《变量与数据类型》)假设要管理一个银行账户,用你目前学过的知识会写成这样:
String owner = "张三";
long balance = 1000;
// 存款
balance += 500;
// 取款
if (500 <= balance) {
balance -= 500;
}只有一个账户时没问题。但当你有 1000 个账户时怎么办?定义 1000 组变量?更麻烦的是:余额和操作余额的规则被拆散了。"取款不能超过余额"这条规则写在业务代码里,任何一处忘了判断,就会出现负数余额。
类与对象解决的正是这个问题:把数据和操作数据的规则放在一起,让规则只有一处。
类(class) = 图纸 / 模板 —— 描述"有哪些数据、能做什么操作"
对象(object) = 按图纸造出的实例 —— 实际占用内存、有具体数值BankAccount account = new BankAccount("张三", 1000);
// ↑ 类型 ↑ 变量名(引用) ↑ 调用构造方法创建对象一句话:类是设计,对象是产物。一个类可以创建无数个对象。
public class BankAccount {
private final String owner; // 1. 成员变量(对象的状态)
private long balance;
public BankAccount(String owner, long balance) { // 2. 构造方法(如何造对象)
this.owner = owner;
this.balance = balance;
}
public void deposit(long amount) { // 3. 成员方法(对象的行为)
balance += amount;
}
}构造方法有两个特征:名字与类名相同、没有返回值类型(不是 void,是什么都不写)。
this 解决的是"参数名与成员变量名相同"时的歧义:
public BankAccount(String owner, long balance) {
this.owner = owner; // this.owner 是成员变量,owner 是参数
this.balance = balance;
}this 的准确含义是"当前对象"。构造方法里的 this 就是"正在被创建的那个对象"。
默认构造方法
如果你不写任何构造方法,Java 会送你一个无参的空构造方法。但只要你自己写了任何一个构造方法,这个赠品就消失了——这是导致 new Foo() 编译失败的常见原因。
栈(Stack) 堆(Heap)
┌──────────────┐ ┌────────────────────────┐
│ account ─────┼──────────────▶│ owner = "张三" │
└──────────────┘ │ balance = 1000 │
└────────────────────────┘account 这个变量里存的不是对象本身,而是对象的地址。这就是"引用类型"的含义。
由此推出一个重要结论:
BankAccount a = new BankAccount("张三", 1000);
BankAccount b = a; // 复制的是地址,不是对象
b.deposit(500);
System.out.println(a.getBalance()); // 1500,a 看到了 b 的修改== 比较的是什么 BankAccount a = new BankAccount("张三", 1000);
BankAccount b = new BankAccount("张三", 1000);
BankAccount c = a;
System.out.println(a == b); // false:两个不同的对象,地址不同
System.out.println(a == c); // true :同一个对象,地址相同== 对引用类型比较的是地址。要比较内容是否相等,需要重写 equals,这是 《equals、hashCode 与对象比较》 的主题,也是面试高频题"为什么重写 equals 还要重写 hashCode"的来源。
public class Point {
int x;
int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public String describe() {
return "(" + x + ", " + y + ")";
}
public static void main(String[] args) {
Point origin = new Point(0, 0);
Point target = new Point(3, 4);
System.out.println("起点 " + origin.describe() + ",终点 " + target.describe());
}
}public class BankAccount {
private final String owner;
private long balance;
public BankAccount(String owner, long initialBalance) {
if (initialBalance < 0) {
throw new IllegalArgumentException("初始余额不能为负");
}
this.owner = owner;
this.balance = initialBalance;
}
public void deposit(long amount) {
if (amount <= 0) {
throw new IllegalArgumentException("存款金额必须大于 0");
}
balance += amount;
}
public void withdraw(long amount) {
if (amount <= 0) {
throw new IllegalArgumentException("取款金额必须大于 0");
}
if (amount > balance) {
throw new IllegalStateException("余额不足:当前 " + balance + ",尝试取 " + amount);
}
balance -= amount;
}
public String getOwner() {
return owner;
}
public long getBalance() {
return balance;
}
}注意"规则"写在哪里:金额校验和余额校验都在类内部。外部代码再也无法绕过规则修改余额——这就是把数据和方法放在一起的价值。
public class BadAccount {
public String owner;
public long balance; // 谁都能改,规则形同虚设
}
// 别处代码
BadAccount account = new BadAccount();
account.balance = -99999; // 编译通过,业务规则被绕过如何防止这种情况、为什么不直接写 private 就完事,是 《封装》 的主题。
文件:labs/oop-basics/src/main/java/cn/kb/learn/oop/LessonRunner.javaJava 版本:21(javac --release 21) 依赖:无 运行命令:
javac --release 21 -d out LessonRunner.java BankAccount.java
java -cp out cn.kb.learn.oop.LessonRunnerpackage cn.kb.learn.oop;
public class LessonRunner {
public static void main(String[] args) {
BankAccount account = new BankAccount("张三", 1000);
account.deposit(500);
System.out.println("存款后余额:" + account.getBalance());
account.withdraw(200);
System.out.println("取款后余额:" + account.getBalance());
BankAccount sameAccount = account;
sameAccount.deposit(100);
System.out.println("通过另一个引用操作后,account 看到:" + account.getBalance());
BankAccount another = new BankAccount("张三", 1400);
System.out.println("内容相同但不是同一个对象:" + (account == another));
try {
account.withdraw(999999);
} catch (IllegalStateException exception) {
System.out.println("预期内的失败:" + exception.getMessage());
}
}
}预期输出:
存款后余额:1500
取款后余额:1300
通过另一个引用操作后,account 看到:1400
内容相同但不是同一个对象:false
预期内的失败:余额不足:当前 1400,尝试取 999999在 main 里做四件事:
BankAccount account = new BankAccount("张三", 1000); 打断点,按 Step Into(F7)进入构造方法,观察 this 面板里 owner、balance 的赋值过程,理解 this.owner = owner 左右两边分别是谁;sameAccount.deposit(100); 处打断点,展开 Variables 面板里的 account 和 sameAccount,看 id 是否相同——它们指向同一个对象;another,看它的 id 与其他两个不同,直观感受"内容相同 ≠ 同一个对象";account.withdraw(999999),观察异常抛出时程序跳到 catch 块,getBalance() 返回值没有变化(失败的操作不会改状态)。NullPointerException BankAccount account = null;
account.deposit(100); // NPE:没有对象,调不了方法修复:确认对象是否真的被 new 出来了;方法入口处对参数做非空校验。
== 比较对象内容 if (new String("abc") == new String("abc")) { ... } // false修复:比较内容用 equals:"abc".equals(value)。字符串常量池会让 "abc" == "abc" 恰好为 true,这更容易掩盖错误。
public class User {
private String name;
public User(String name) { this.name = name; }
}
User user = new User(); // 编译错误:找不到无参构造方法修复:如果需要无参构造,显式补上一个(很多框架如 MyBatis、Jackson 也需要它)。
目标:设计一个符合规则的类,并用两个引用验证对象的内存行为。
步骤:
Book 类:字段 title、priceInCents、stock;构造方法校验价格不能为负;buy(int count) 方法:库存不足时抛异常,成功时扣减库存;main 里创建一本书,创建两个引用指向它,用其中一个引用调用两次 buy(1),打印另一个引用看到的库存;== 与 equals 分别比较,记录结果。验收方式:能解释第 3 步的结果为什么说明"两个引用是同一个对象"。
扩展任务:给 Book 加上"总销售额"统计——注意它是属于"这本书"还是"所有书"?想一想该用成员变量还是别的写法(后面的 static 会给出答案)。
基础题
new BankAccount("张三", 1000) 中,new 的作用是 ______。this 的含义是什么?为什么构造方法里需要它?代码阅读题
public class Counter {
private int count;
public Counter(int initial) { this.count = initial; }
public void increment() { count++; }
public int getCount() { return count; }
public static void main(String[] args) {
Counter a = new Counter(0);
Counter b = a;
a.increment();
b.increment();
System.out.println(a.getCount());
}
}编程题
Rectangle 类:字段 width、height;方法 area() 返回面积、perimeter() 返回周长、isSquare() 判断是否为正方形。构造方法里校验宽高必须为正数。综合任务
ShoppingCart 类,支持 add(String name, long priceInCents, int count)、totalInCents()、itemCount()。要求:添加数量必须为正、总价不超过 long 范围,并在 main 里演示三次添加与一次失败添加。提示 1(方向):第 5 题的"正方形"判断是 width == height,注意字段类型选的什么。
提示 2(关键点):第 6 题需要存储多个商品,用两个并行数组会很别扭——这正是集合要解决的问题。先用数组硬写一遍,等学完 《集合》 再回来重构,你会更理解集合的价值。
提示 3(接近答案):第 6 题的 totalInCents() 每次遍历数组重新累加即可,先不要考虑缓存。
this 指"当前对象"。构造方法中参数名与成员变量同名时,this.owner 明确指向成员变量,避免赋值变成"参数赋给参数"。输出 2。
Counter b = a; 复制的是引用,a 和 b 指向同一个对象。a.increment() 和 b.increment() 操作的是同一个 count,所以结果是 2。
package cn.kb.learn.oop;
public class Rectangle {
private final int width;
private final int height;
public Rectangle(int width, int height) {
if (width <= 0 || height <= 0) {
throw new IllegalArgumentException("宽和高必须为正数");
}
this.width = width;
this.height = height;
}
public int area() {
return width * height;
}
public int perimeter() {
return 2 * (width + height);
}
public boolean isSquare() {
return width == height;
}
public static void main(String[] args) {
Rectangle square = new Rectangle(4, 4);
Rectangle rectangle = new Rectangle(3, 5);
System.out.println(square.area() + " " + square.perimeter() + " " + square.isSquare());
System.out.println(rectangle.area() + " " + rectangle.perimeter() + " " + rectangle.isSquare());
}
}预期输出:
16 16 true
15 16 falsepackage cn.kb.learn.oop;
public class ShoppingCart {
private final String[] names = new String[10];
private final long[] prices = new long[10];
private final int[] counts = new int[10];
private int size;
public void add(String name, long priceInCents, int count) {
if (count <= 0) {
throw new IllegalArgumentException("数量必须为正");
}
if (size >= names.length) {
throw new IllegalStateException("购物车已满");
}
names[size] = name;
prices[size] = priceInCents;
counts[size] = count;
size++;
}
public long totalInCents() {
long total = 0;
for (int i = 0; i < size; i++) {
total += prices[i] * counts[i];
}
return total;
}
public int itemCount() {
int total = 0;
for (int i = 0; i < size; i++) {
total += counts[i];
}
return total;
}
public static void main(String[] args) {
ShoppingCart cart = new ShoppingCart();
cart.add("《Head First Java》", 9900, 1);
cart.add("机械键盘", 39900, 1);
cart.add("Type-C 线", 2900, 2);
System.out.println("商品件数:" + cart.itemCount());
System.out.println("总价(分):" + cart.totalInCents());
try {
cart.add("无线鼠标", 12900, 0);
} catch (IllegalArgumentException exception) {
System.out.println("预期内的失败:" + exception.getMessage());
}
}
}为什么要先用数组硬写:你会立刻感受到"容量写死 10"、"三个并行数组要同步维护"的别扭。学完集合后,同样的需求可以用 List<CartItem> 表达,代码会短一半——那时你才算真正理解集合解决的问题。
this 的作用;== 比较的是地址,并知道内容比较该用什么。《封装》 解决本节留下的问题:字段为什么必须是 private、如何设计对外暴露的方法。