开篇:从基础到进阶,构建面向对象的思维大厦
上一篇我们学习了类与对象的基础概念,理解了对象在内存中的存在形式,掌握了方法调用机制和构造器的使用。如果把面向对象编程比作建造一座大厦,那么上一篇我们打下了地基——学会了如何创建砖块(对象)并理解它们的基本属性。
而今天,我们将开始搭建这座大厦的主体结构。我们会深入探讨Java面向对象的三大核心特征:封装、继承和多态,学习如何通过包来组织代码,如何用访问修饰符控制可见性,以及理解Java灵魂般的动态绑定机制。这些知识将真正带你进入Java编程的核心殿堂。
1. 包:代码的“文件夹”与命名空间
1.1 包的本质是什么?
想象一下,你的电脑里有成千上万个文件,如果没有文件夹来分类管理,找文件将是一场噩梦。 包(Package) 就是Java中用于组织代码的“文件夹”。
从本质上讲,包解决了两个核心问题:
- 组织代码:将相关的类放在一起,形成逻辑模块
- 命名空间管理:防止类名冲突。比如你和同事都定义了一个
User类,但只要放在不同的包里,就能和平共处
1.2 包的命名规范
为了保证包名的全球唯一性,Java采用了逆域名命名法:
// 域名是 example.com → 包名是 com.example.项目名.模块名
package com.example.shop.user;
package org.apache.commons.lang;
命名规则:
- 只能包含数字、字母、下划线、小圆点.,但不能用数字开头、不能是关键字或保留字
- 用点号分隔层级
- 不能以点号开头或结尾
- 通常以组织域名的倒序开头
1.3 Java常用包一览
Java提供了丰富的标准库,都以包的形式组织:
| 包名 | 作用 | 使用频率 |
|---|---|---|
java.lang | 语言基础类(String、System、Object) | ⭐⭐⭐⭐⭐ 自动导入 |
java.util | 工具类(集合、日期、随机数) | ⭐⭐⭐⭐⭐ |
java.io | 输入输出流 | ⭐⭐⭐⭐ |
java.net | 网络编程 | ⭐⭐⭐ |
java.sql | 数据库操作 | ⭐⭐⭐ |
java.awt/javax.swing | 图形界面、GUI | ⭐⭐ |
特别注意:java.lang包是自动导入的,不需要写 import 语句。
1.4 包的导入
要在代码中使用其他包的类,有两种方式:
// 方式一:导入具体类(推荐)
import java.util.ArrayList;
import java.util.Scanner;
// 方式二:导入包下所有类(不推荐,降低可读性)
import java.util.*;
1.5 包的使用细节
关键规则:package 声明必须在文件第一行(注释除外),且一个文件最多只能有一个包声明。import语句放在package声明后面, 在类定义前面,可以有多条且无顺序要求。
// 正确的顺序
package com.example.demo; // 第1行:包声明
import java.util.List; // 之后:导入语句
import java.util.ArrayList;
public class MyClass { // 最后:类定义
// ...
}
同名类的处理:如果同时用到两个不同包中的同名类,必须使用全限定名来区分:
import java.util.Date; // java.util.Date
// 如果想同时使用 java.sql.Date,不能用import,必须全限定名
public class Test {
Date utilDate = new Date(); // java.util.Date
java.sql.Date sqlDate = new java.sql.Date(System.currentTimeMillis()); // java.sql.Date
}
2. 访问修饰符:控制可见性的“权限锁”
2.1 四种访问级别
修饰符可以用来修饰类中的属性,成员方法以及类。 访问修饰符决定了类、方法、变量能被哪些地方访问。 Java提供了四种访问级别,从严格到宽松依次是:
| 修饰符 | 同类中 | 同包中 | 子类中 | 任何地方 |
|---|---|---|---|---|
private | ✅ | ❌ | ❌ | ❌ |
| 默认(无修饰符) | ✅ | ✅ | ❌ | ❌ |
protected | ✅ | ✅ | ✅ | ❌ |
public | ✅ | ✅ | ✅ | ✅ |
2.2 生动的记忆法
想象你在写遗嘱,要把自己的遗产分给不同的人:
- private(私人物品):只有你自己能看(个人日记)
- 默认(家人共享):只有家人在场才能看(家庭相册)
- protected(留给后代):家人和后代都能继承(家族房产)
- public(公之于众):所有人都能看(回忆录)
2.3 访问修饰符的细节
类的访问修饰符:外部类只能用 public 或默认,不能是 private 或 protected。成员方法的访问规则和属性完全一样。
成员变量的最佳实践:通常将成员变量设为 private,通过 public 的 getter/setter 访问,这正是封装思想的体现。
public class Student {
private String name; // 隐藏细节
private int age;
public String getName() { // 提供公共访问接口
return name;
}
public void setName(String name) {
this.name = name;
}
}
3. 面向对象三大特征之一:封装
3.1 什么是封装?
封装就是将对象的状态(属性)和行为(方法)绑定在一起,并对外隐藏内部实现细节,仅公开有限的访问接口。
现实中的例子:电视机。你只需要用遥控器(公开接口)来操作,而不需要知道内部的电路如何工作(隐藏细节)。
3.2 封装的实现步骤
- 属性私有化:使用
private修饰成员变量 - 提供公共访问方法:为每个属性编写
getter和setter方法 - 在方法中添加逻辑控制:可以在
setter中加入验证逻辑
public class BankAccount {
private String accountNumber;
private double balance;
// 构造器
public BankAccount(String accountNumber, double balance) {
this.accountNumber = accountNumber;
this.balance = balance;
}
// 公共接口方法 - 存款
public void deposit(double amount) {
if (amount > 0) {
balance += amount;
System.out.println("成功存入:" + amount);
} else {
System.out.println("存款金额必须大于0");
}
}
// 公共接口方法 - 取款(带控制逻辑)
public boolean withdraw(double amount) {
if (amount > 0 && amount <= balance) {
balance -= amount;
return true;
}
return false;
}
// 只提供getter,不提供setter,余额只能通过存款取款改变
public double getBalance() {
return balance;
}
}
3.3 封装的好处
- 安全性:防止外部直接修改内部数据,可以加入验证逻辑
- 隔离变化:内部实现改变不影响外部调用
- 可复用性:封装好的类可以在多处使用
- 简化调用:使用者只需关注公开接口,无需理解内部实现
3.4 构造器与 setter 结合
在构造器中直接调用 setter 方法,可以复用验证逻辑:
public class Person {
private String name;
private int age;
public Person(String name, int age) {
setName(name); // 调用setter,复用验证逻辑
setAge(age);
}
public void setName(String name) {
if (name != null && name.length() >= 2) {
this.name = name;
} else {
throw new IllegalArgumentException("姓名至少2个字符");
}
}
public void setAge(int age) {
if (age >= 0 && age <= 150) {
this.age = age;
} else {
throw new IllegalArgumentException("年龄不合法");
}
}
}
4. 面向对象三大特征之二:继承
4.1 为什么需要继承?
现实世界中,事物之间存在着“is-a”的关系。比如:“学生是人”、“猫是动物”。这种关系在编程中通过继承来体现。
继承允许我们基于一个已有的类创建新类,新类可以复用父类的属性和方法,并在此基础上进行扩展。
4.2 继承的基本语法
// 父类(基类、超类)
class Animal {
protected String name; // protected 让子类可以访问
public Animal(String name) {
this.name = name;
}
public void eat() {
System.out.println(name + "正在吃东西");
}
public void sleep() {
System.out.println(name + "正在睡觉");
}
}
// 子类(派生类)
class Dog extends Animal {
public Dog(String name) {
super(name); // 调用父类构造器,必须放在第一行
}
// 新增方法
public void bark() {
System.out.println(name + "汪汪叫");
}
// 重写(Override)父类方法
@Override
public void eat() {
System.out.println(name + "正在啃骨头");
}
}
4.3 继承的本质与内存图
当我们执行 Dog dog = new Dog("旺财"); 时,内存中发生了什么?
堆内存中的 Dog 对象
+----------------------------------+
| 从 Animal 继承的部分 |
| name = "旺财" (引用指向字符串常量池) |
| Dog 自己的部分 |
| (没有新增属性) |
+----------------------------------+
栈内存
+------------------+
| dog 引用 (0x1234) | -----> 指向堆中的 Dog 对象
+------------------+
继承的本质:子类对象包含一个完整的父类对象子对象。可以理解为子类对象在堆内存中为父类的所有属性都分配了空间。
4.4 继承的细节与规则
1. 单继承限制:Java 只支持单继承,一个类只能有一个直接父类。
2. 传递性:继承具有传递性,class C extends B,class B extends A,那么 C 拥有 A 和 B 的所有非私有成员。
3. 构造器的调用链:创建子类对象时,一定会调用父类的构造器,最终会调用到 Object 类的构造器。
class A {
public A() {
System.out.println("A构造器");
}
}
class B extends A {
public B() {
// 这里隐含了 super()
System.out.println("B构造器");
}
}
class C extends B {
public C() {
// 隐含 super()
System.out.println("C构造器");
}
}
// 执行 new C(); 输出:
// A构造器
// B构造器
// C构造器
4. 父类私有成员:子类拥有父类的私有成员,但不能直接访问,需要通过公共的 getter/setter。
5. 创建子类对象时,内存中只有一个对象,而不是多个对象叠加。
5. super 关键字:指向父类的引用
5.1 super 是什么?
super 是一个关键字,代表当前对象的父类部分的引用。它和 this 类似,但 this 指向当前对象本身,而 super 指向当前对象中的父类部分。
5.2 super 的三种用法
1. 调用父类的属性:当子类有同名属性时,用 super.属性 访问父类的属性
class Parent {
String name = "Parent";
}
class Child extends Parent {
String name = "Child";
public void printName() {
System.out.println(name); // 输出 Child
System.out.println(super.name); // 输出 Parent
}
}
2. 调用父类的方法:当子类重写了父类方法,用 super.方法() 调用父类被重写的方法
class Parent {
public void show() {
System.out.println("Parent show");
}
}
class Child extends Parent {
@Override
public void show() {
super.show(); // 先调用父类的show
System.out.println("Child show"); // 再执行自己的逻辑
}
}
3. 调用父类的构造器:用 super(参数) 调用父类指定的构造器,必须放在子类构造器的第一行
5.3 super 与 this 的对比
| 对比项 | this | super |
|---|---|---|
| 指向 | 当前对象的引用 | 当前对象中父类部分的引用 |
| 查找范围 | 先找本类,找不到再找父类 | 直接找父类 |
| 特殊要求 | 无 | 调用构造器时必须放在第一行 |
| 能否同时使用 | 不能与 super 同时出现在构造器第一行 | 不能与 this 同时出现在构造器第一行 |
核心规则:在构造器中,this(…) 和 super(…) 只能二选一,且必须放在第一行。
6. 方法重写
6.1 什么是方法重写?
子类对父类中允许访问的方法重新实现,方法的签名(名称+参数列表)保持不变,称为方法重写(Override)。
6.2 重写的规则
必须满足的条件:
- 方法名、参数列表必须完全相同
- 返回值类型:如果是基本类型,必须相同;如果是引用类型,可以是原返回类型的子类型(称为协变返回类型)
- 访问权限:不能比父类更严格(可以相同或更宽松)
- 不能重写
private、static、final方法
class Animal {
protected Animal getAnimal() {
return new Animal();
}
}
class Dog extends Animal {
// 返回值类型可以是 Animal 的子类 Dog
@Override
public Dog getAnimal() { // 访问权限从 protected 提升为 public
return new Dog();
}
}
6.3 重写 vs 重载
| 对比项 | 重写 | 重载 |
|---|---|---|
| 发生范围 | 父子类之间 | 同一个类中 |
| 方法名 | 必须相同 | 必须相同 |
| 参数列表 | 必须相同 | 必须不同 |
| 返回值 | 相同或子类型 | 无关 |
| 访问权限 | 不能更严格 | 无关 |
| 目的 | 改变行为,实现多态 | 提供更多调用方式 |
7. 面向对象三大特征之三:多态
7.1 什么是多态?
多态(Polymorphism)是指“同一个接口,不同的实现”。通俗地说,就是父类的引用指向子类的对象,调用同一个方法时,表现出不同的行为。
7.2 多态的实现条件
在Java中,实现多态需要满足三个条件:
- 继承:存在父子类关系
- 重写:子类重写父类的方法
- 父类引用指向子类对象:
Animal a = new Dog();
7.3 多态的具体体现
class Animal {
public void speak() {
System.out.println("动物发出声音");
}
}
class Dog extends Animal {
@Override
public void speak() {
System.out.println("汪汪汪");
}
public void wagTail() {
System.out.println("摇尾巴");
}
}
class Cat extends Animal {
@Override
public void speak() {
System.out.println("喵喵喵");
}
}
public class Test {
public static void main(String[] args) {
// 多态:父类引用指向子类对象
Animal a1 = new Dog();
Animal a2 = new Cat();
a1.speak(); // 输出:汪汪汪
a2.speak(); // 输出:喵喵喵
// a1.wagTail(); // 编译错误!Animal类型没有wagTail方法
}
}
7.4 多态的细节
1. 编译时看左边,运行时看右边
- 编译时,编译器检查左边引用的类型是否有该方法
- 运行时,JVM实际调用右边对象的方法
2. 不能调用子类特有的方法:父类引用只能调用父类中声明的方法,不能调用子类特有的方法。
3. 属性的访问:属性没有多态性!访问属性时,看左边引用的类型。
class Parent {
String name = "Parent";
}
class Child extends Parent {
String name = "Child";
}
public class Test {
public static void main(String[] args) {
Parent p = new Child();
System.out.println(p.name); // 输出 Parent,不是 Child!
}
}
7.5 动态绑定机制(核心原理)
这是Java多态的灵魂所在!
动态绑定:在运行时,根据对象的实际类型来确定调用哪个方法,而不是根据引用变量的类型。
原理揭秘:
- 每个类在方法区中都有一个方法表,存储了该类的所有方法入口地址
- 当调用虚方法(非private、static、final方法)时,JVM通过对象的实际类型找到对应的方法表
- 从方法表中获取方法的实际入口地址进行调用
为什么属性没有多态? 因为属性在编译期就已经确定了访问哪个,不需要动态绑定。
7.6 多态的应用
1. 方法参数的多态:编写一个方法,接收父类类型,实际可以传入任意子类对象
public void animalSpeak(Animal a) { // 多态参数
a.speak(); // 根据实际传入的对象,调用不同的speak
}
animalSpeak(new Dog()); // 汪汪汪
animalSpeak(new Cat()); // 喵喵喵
2. 数组/集合的多态:可以创建父类类型的数组,存放各种子类对象
Animal[] animals = new Animal[3];
animals[0] = new Dog();
animals[1] = new Cat();
animals[2] = new Dog();
for (Animal a : animals) {
a.speak(); // 各自发出不同的声音
}
3. 强制类型转换(向下转型):当需要调用子类特有方法时,可以强制转换
Animal a = new Dog();
if (a instanceof Dog) { // 先判断类型,避免 ClassCastException
Dog d = (Dog) a;
d.wagTail(); // 现在可以调用子类特有方法
}
8. Object 类详解
8.1 Object 是什么?
Object 类是Java中所有类的根父类。每个类都直接或间接继承自 Object。
8.2 equals() 方法
默认实现:比较两个对象的内存地址,相当于 ==。
// Object 类中的默认实现
public boolean equals(Object obj) {
return (this == obj);
}
重写原则:当我们需要根据对象的内容判断是否相等时(比如两个Person对象的id相同就算相等),就需要重写 equals。
public class Person {
private String id;
private String name;
@Override
public boolean equals(Object o) {
if (this == o) return true; // 同一对象
if (o == null || getClass() != o.getClass()) return false; // 类型检查
Person person = (Person) o;
return Objects.equals(id, person.id); // 根据id判断相等
}
}
equals 方法的契约:
- 自反性:x.equals(x) 必须为 true
- 对称性:x.equals(y) 与 y.equals(x) 结果相同
- 传递性:x.equals(y) 且 y.equals(z),则 x.equals(z)
- 一致性:多次调用结果一致(前提是参与比较的内容没变)
- 非空性:x.equals(null) 必须为 false
8.3 hashCode() 方法
hashCode 返回对象的哈希码,是一个整数,主要用于哈希表(如 HashMap、HashSet)。
重要契约:如果两个对象通过 equals 比较相等,那么它们的 hashCode 必须相等。
@Override
public int hashCode() {
return Objects.hash(id); // 和 equals 用相同的字段
}
常见错误:重写了 equals 但不重写 hashCode,会导致对象无法在 HashSet/HashMap 中正常工作!
8.4 toString() 方法
默认实现:类名@十六进制哈希码,例如 Person@4eec7777。
重写目的:提供更有意义的对象描述信息,方便调试和日志输出。
@Override
public String toString() {
return "Person{id='" + id + "', name='" + name + "'}";
}
8.5 finalize() 方法(已过时)
注意:从 Java 9 开始,finalize() 已被标记为过时(deprecated),不建议使用。
淘汰原因:
- 执行时机不确定(依赖 GC)
- 性能影响严重
- 可能导致资源泄漏
替代方案:使用 try-with-resources 或显式编写 close() 方法。
9. 断点调试:定位问题的“显微镜”
9.1 为什么需要断点调试?
当程序运行结果不符合预期时,我们需要“看”到程序执行的过程——变量如何变化,代码走哪个分支,方法如何调用。断点调试就是让我们暂停程序,逐行执行,观察内部状态的利器。
9.2 基本调试操作(以 IntelliJ IDEA 为例)
1. 设置断点:点击代码行号左侧的空白区域,出现红色圆点
2. 启动调试:点击“Debug”按钮(小虫子图标)或按 Shift+F9
3. 调试控制按钮:
| 按钮 | 快捷键 | 作用 |
|---|---|---|
| Step Over | F8 | 执行当前行,不进入方法内部 |
| Step Into | F7 | 进入当前行调用的方法内部 |
| Step Out | Shift+F8 | 跳出当前方法,返回调用处 |
| Resume Program | F9 | 继续执行到下一个断点或结束 |
| Evaluate Expression | Alt+F8 | 计算表达式,临时查看或修改变量 |
9.3 调试实战:观察动态绑定
创建一个调试场景,观察多态的动态绑定过程:
public class DebugDemo {
public static void main(String[] args) {
Animal a = new Dog(); // 在这里设置断点
a.speak(); // 观察实际调用哪个方法
}
}
调试步骤:
- 在
a.speak();行设置断点 - Debug 运行,程序停在该行
- 按 F7(Step Into),观察进入哪个类的 speak 方法
- 查看 Variables 窗口,观察
a的实际类型是 Dog
动手实践
练习一:包与访问修饰符
目标:创建 com.study.bank 包,在其中定义 Account 类(属性:账户号、密码、余额,全部 private)。提供公共的存款、取款方法。在另一个包 com.study.test 中创建测试类,尝试访问 Account 的私有属性(应该失败),通过公共方法操作账户。
练习二:封装实践
目标:设计 Employee 类,包含私有属性 name、salary、bonus。提供公共的 getter/setter,在 setSalary 中加入验证(必须大于0)。编写一个方法 calculateYearlyIncome() 计算年收入(工资+奖金)。使用构造器初始化对象。
练习三:继承与 super
目标:创建 Vehicle(交通工具)父类,属性 brand、speed,方法 run() 输出“正在行驶”。创建子类 Car,增加属性 fuelType,重写 run() 方法,先调用父类的 run,再输出“使用燃油:xx”。使用 super 调用父类构造器。
练习四:多态与动态绑定
目标:创建接口 Shape,包含方法 double area()。实现三个类 Circle、Rectangle、Triangle,分别实现 area 方法。编写一个方法 printArea(Shape s),打印面积。在 main 中创建 Shape 数组,存放不同图形对象,遍历调用 printArea。
练习五:Object 方法重写
目标:创建 Book 类,属性 isbn(唯一编号)、title、price。重写 equals(根据 isbn 判断相等)、hashCode(使用 isbn 生成)、toString(返回完整信息)。创建 HashSet 存放 Book 对象,验证重复的 isbn 不会被加入。
练习六:断点调试练习
目标:故意编写一个有逻辑错误的递归方法(比如求斐波那契数列但递归条件写错),使用断点调试观察递归调用过程,找出问题所在。练习使用 Step Into 进入递归,观察栈帧变化。
总结
本篇我们深入学习了Java面向对象编程的核心知识:
| 知识点 | 核心要点 |
|---|---|
| 包 | 组织代码、命名空间、import 规则 |
| 访问修饰符 | private → 默认 → protected → public,控制可见性 |
| 封装 | 属性私有、方法公开、隐藏实现细节 |
| 继承 | is-a 关系、代码复用、构造器调用链 |
| super | 调用父类成员,必须放在构造器第一行 |
| 重写 | 子类重新实现父类方法,遵循规则 |
| 多态 | 父类引用指向子类对象、动态绑定、instanceof |
| Object类 | 所有类的根,equals、hashCode、toString 需成对重写 |
| 断点调试 | 观察程序执行过程,定位问题 |
面向对象编程不仅仅是一种语法,更是一种思维方式。掌握好这些基础知识,你就能用Java构建出结构清晰、易于维护的软件系统。 下一篇文章我们将学习抽象类、接口和内部类,继续深入面向对象的世界!