零基础学Java|第九篇:面向对象编程的类与对象(进阶)

11 阅读17分钟

开篇:从基础到进阶,构建面向对象的思维大厦

上一篇我们学习了类与对象的基础概念,理解了对象在内存中的存在形式,掌握了方法调用机制和构造器的使用。如果把面向对象编程比作建造一座大厦,那么上一篇我们打下了地基——学会了如何创建砖块(对象)并理解它们的基本属性。

而今天,我们将开始搭建这座大厦的主体结构。我们会深入探讨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 或默认,不能是 privateprotected。成员方法的访问规则和属性完全一样。

成员变量的最佳实践:通常将成员变量设为 private,通过 publicgetter/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 封装的实现步骤

  1. 属性私有化:使用 private 修饰成员变量
  2. 提供公共访问方法:为每个属性编写 gettersetter 方法
  3. 在方法中添加逻辑控制:可以在 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 Bclass 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 的对比

对比项thissuper
指向当前对象的引用当前对象中父类部分的引用
查找范围先找本类,找不到再找父类直接找父类
特殊要求调用构造器时必须放在第一行
能否同时使用不能与 super 同时出现在构造器第一行不能与 this 同时出现在构造器第一行

核心规则:在构造器中,this(…)super(…) 只能二选一,且必须放在第一行。


6. 方法重写

6.1 什么是方法重写?

子类对父类中允许访问的方法重新实现,方法的签名(名称+参数列表)保持不变,称为方法重写(Override)。

6.2 重写的规则

必须满足的条件

  • 方法名、参数列表必须完全相同
  • 返回值类型:如果是基本类型,必须相同;如果是引用类型,可以是原返回类型的子类型(称为协变返回类型
  • 访问权限:不能比父类更严格(可以相同或更宽松)
  • 不能重写 privatestaticfinal 方法
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中,实现多态需要满足三个条件:

  1. 继承:存在父子类关系
  2. 重写:子类重写父类的方法
  3. 父类引用指向子类对象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多态的灵魂所在!

动态绑定:在运行时,根据对象的实际类型来确定调用哪个方法,而不是根据引用变量的类型。

原理揭秘

  1. 每个类在方法区中都有一个方法表,存储了该类的所有方法入口地址
  2. 当调用虚方法(非private、static、final方法)时,JVM通过对象的实际类型找到对应的方法表
  3. 从方法表中获取方法的实际入口地址进行调用

为什么属性没有多态? 因为属性在编译期就已经确定了访问哪个,不需要动态绑定。

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 OverF8执行当前行,不进入方法内部
Step IntoF7进入当前行调用的方法内部
Step OutShift+F8跳出当前方法,返回调用处
Resume ProgramF9继续执行到下一个断点或结束
Evaluate ExpressionAlt+F8计算表达式,临时查看或修改变量

9.3 调试实战:观察动态绑定

创建一个调试场景,观察多态的动态绑定过程:

public class DebugDemo {
    public static void main(String[] args) {
        Animal a = new Dog();    // 在这里设置断点
        a.speak();                // 观察实际调用哪个方法
    }
}

调试步骤

  1. a.speak(); 行设置断点
  2. Debug 运行,程序停在该行
  3. 按 F7(Step Into),观察进入哪个类的 speak 方法
  4. 查看 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()。实现三个类 CircleRectangleTriangle,分别实现 area 方法。编写一个方法 printArea(Shape s),打印面积。在 main 中创建 Shape 数组,存放不同图形对象,遍历调用 printArea。

练习五:Object 方法重写

目标:创建 Book 类,属性 isbn(唯一编号)、titleprice。重写 equals(根据 isbn 判断相等)、hashCode(使用 isbn 生成)、toString(返回完整信息)。创建 HashSet 存放 Book 对象,验证重复的 isbn 不会被加入。

练习六:断点调试练习

目标:故意编写一个有逻辑错误的递归方法(比如求斐波那契数列但递归条件写错),使用断点调试观察递归调用过程,找出问题所在。练习使用 Step Into 进入递归,观察栈帧变化。


总结

本篇我们深入学习了Java面向对象编程的核心知识:

知识点核心要点
组织代码、命名空间、import 规则
访问修饰符private → 默认 → protected → public,控制可见性
封装属性私有、方法公开、隐藏实现细节
继承is-a 关系、代码复用、构造器调用链
super调用父类成员,必须放在构造器第一行
重写子类重新实现父类方法,遵循规则
多态父类引用指向子类对象、动态绑定、instanceof
Object类所有类的根,equals、hashCode、toString 需成对重写
断点调试观察程序执行过程,定位问题

面向对象编程不仅仅是一种语法,更是一种思维方式。掌握好这些基础知识,你就能用Java构建出结构清晰、易于维护的软件系统。 下一篇文章我们将学习抽象类、接口和内部类,继续深入面向对象的世界!