前言
面向对象的三大特性——封装、继承、多态,是每个Java开发者绕不开的基础。但我们如果只看理论,很容易"一看就会,一写就废"。
今天我打算用一个学生-老师的场景,把这几个核心概念串起来讲清楚。
我们先简单看一下今天各个主角的定义:
接口(Interface)——定义规范
继承(Extends)——复用代码
方法重写(Override)——子类改造父类行为
向上转型 / 向下转型——类型的转换规则
多态(Polymorphism)——同一个调用,不同行为
一、接口:只定规范,不写实现
public interface IStudent { // ========== 常量 ========== // 等价于 public static final String NAME = "杨某人"; // static → 类变量,内存中只有一份,所有实现类共享 // final → 不可修改,赋值后永远是这个值 // 接口中不写修饰符,编译器也会自动补上这三样 String NAME = "杨某人"; // ========== 抽象方法 ========== // 等价于 public abstract void study(); // 没有方法体 { },直接以分号结尾 // 谁实现这个接口,谁就必须补上方法体 void study(); }接口是什么? 我们可以打个形象的比方:接口就是一份"合同"或"规范"。它只规定"你必须会什么",但不关心"你怎么做到"。
就像现实中的"学生"这个概念:只要是学生,就必须会"学习"。但小学生学习和大学生学习的方式完全不同——接口只管"你得有study() 这个方法",具体怎么学,由各个实现类自己决定。
接口中的常量:String NAME = "杨某人",看起来只是一个普通变量,但编译器会自动给它加上 public static final三个修饰符:
static → 属于接口本身,不依赖任何对象,全局只有一份
final → 不可修改,赋值一次后永远不变
接口不能 new:new IStudent() 会直接编译报错。因为接口只有方法声明,没有方法体,new 出来也没法用。
二、实现类:给接口的方法"填身体"
public class Student implements IStudent { // ========== 属性 ========== private int score; // 私有属性,子类继承但不可直接访问 // ========== 构造方法 ========== // 无参构造 — 子类默认调用这个 // 实际上这里隐含了 super(),即调用 Object 类的构造方法 public Student() { System.out.println(" [Student 无参构造] 创建了一个普通学生对象"); } // 有参构造 public Student(int score) { this.score = score; System.out.println(" [Student 有参构造] 创建学生,学分 = " + score); } // ========== getter / setter ========== // 私有属性 score 只能通过这些公开方法访问 public void setScore(int score) { this.score = score; } public int getScore() { return score; } // ========== 实现接口中的抽象方法(必须实现!) ========== // 这是对接口 study() 的第一次"填身体" @Override public void study() { System.out.println(NAME + "正在学习!(来自 Student 的实现)"); } }implements 关键字的含义:"我,Student 类,承诺实现 IStudent 接口规定的所有方法。如果少实现一个,编译器直接报错。"
封装体现在哪里?score 属性用 private 修饰,外部不能直接 obj.score 这样访问,必须通过 getScore() / setScore()方法。这就是封装——数据藏起来,通过方法控制访问。
@Override
注解:告诉编译器"我是重写接口/父类的方法,不是新方法"。加了这个注解后,如果你不小心把方法名写错了(比如写成stdudy()),编译器会立刻报错——帮你避免低级错误。
三、继承:子类拿到父类的全部,还能加自己的
public class UNStudent extends Student { // ========== 构造方法 ========== // 无参构造 public UNStudent() { // 这里隐式调用了 super() → Student 的无参构造 // 然后再调用 Object 的无参构造 // 调用链:UNStudent() → Student() → Object() System.out.println(" [UNStudent 无参构造] 创建了一个大学生对象"); } public UNStudent(int score) { super(score); // 显式调用父类 Student 的有参构造 System.out.println(" [UNStudent 有参构造] 创建大学生,学分 = " + score); } // ========== 子类特有方法 ========== // 这个方法父类没有,只有 UNStudent 有 // —— 用于演示向下转型 public void print() { // getScore() 是父类的 public 方法,子类可以直接用 // 但如果直接写 score,就报错了(score 是 private 的) System.out.println("学分:" + getScore()); } // ========== 方法重写(Override) ========== // 重写条件: // ① 有继承关系(UNStudent extends Student) // ② 方法名、参数、返回值类型完全一致 // ③ 访问权限不能比父类更严格 @Override public void study() { // 调用父类的 study(),保留父类原有功能 super.study(); // 在父类基础上,新增子类独有的功能 System.out.println(" 然后——大学生去图书馆深入自习!(UNStudent 新增的功能)"); } }构造方法链——先父后子:创建 new UNStudent(89) 时,构造方法调用顺序是:
Object() → Student(score) → UNStudent(score)
就像建房子,先打好地基(Object),再盖一楼(Student),最后盖二楼(UNStudent)。每层都在上一层的基础上建造。
super.study() 的精髓:很多人写重写时直接覆盖父类方法,把父类的逻辑全丢了。更好的做法是先用 super.study()保留父类已有的功能,再在后面加上子类独有的逻辑。就像大学生学习——普通学生的学习内容(上课听讲)仍然要做,只是额外增加了"去图书馆自习"。
方法重写的三个条件:
1. 有继承关系(UNStudent extends Student)
2. 方法名、参数、返回值类型完全一致
3. 访问权限不能比父类更严格(父类是 public,子类不能改成 private)
四、转型:类型的"升职"与"降职"
向上转型(自动)
Student s1 = new UNStudent(93); //子类对象赋值给父类引用,自动发生 s1.study(); // 执行的是 UNStudent 重写后的版本! // s1.print(); // 编译错误!s1 的类型是 Student,没有 print() 方法
向上转型就像:大学生(UNStudent)当然可以被称为"学生"(Student),这是天经地义的,不需要任何强制手段——编译器自动处理。
但代价是视野变窄:s1 的类型声明是 Student,所以只能调用 Student 类中定义过的方法。UNStudent 独有的 print()方法虽然对象里有,但编译器"看不到"——因为编译器只看声明类型,不看实际类型。
向下转型(强制)
UNStudent un2 = (UNStudent) s1; // 强制转型,前提:s1 本质上就是 UNStudent un2.print(); // 现在可以调用 UNStudent 特有的 print()方法了
向下转型就像:这个"学生"身份(s1),你得确认他真的就是大学生,才能恢复他大学生的身份。
危险:如果对象本质上不是那个类型,强转会抛 ClassCastException:
Student s2 = new Student(); UNStudent un3 = (UNStudent) s2; // 运行时报错!s2 本质不是 UNStudent
就像你不能把一个普通学生强行说成是大学生——他没有大学生的特有属性和行为。
五、多态:同一个方法,千姿百态
public void teach(Student s) { System.out.print("老师开始教 → "); s.study(); //运行时多态:实际调用哪个 study(),取决于 s 的真实类型 } /** * 教学(接口版):更进一步,只认 IStudent 规范 * 连是不是 Student 类都不管了——只要你实现了 IStudent 接口,我就能教 */ public void teachByInterface(IStudent s) { System.out.print("老师(按接口)开始教 → "); s.study(); } }测试代码:
Teacher t = new Teacher(); // 同一个 teach 方法,传入不同类型的对象 t.teach(new Student(91)); // 输出:Student 版本的 study() t.teach(new UNStudent(95)); // 输出:UNStudent 版本的 study()(加了图书馆自习)
多态的核心:teach() 方法的参数类型是Student,但它不关心你传进来的是普通学生还是大学生——只要你是个学生,我就能教。至于具体怎么学习(study()执行哪个版本),由运行时对象的真实类型决定。
这种"同一个方法调用,不同对象产生不同行为"的机制,就是多态。
多态的好处:将来新增一个 HighSchoolStudent extends Student,Teacher.teach() 一行代码都不用改——直接传进去就能教。这就是对扩展开放,对修改关闭。
六、完整测试类
public class Test { public static void main(String[] args) { // 【1】接口常量 System.out.println("接口常量 IStudent.NAME = " + IStudent.NAME); // 【2】构造方法链 Student stu1 = new Student(86); stu1.study(); UNStudent un1 = new UNStudent(89); // 构造链:Object → Student → UNStudent(先父后子) un1.study(); // 输出:父类版本 + 子类新增内容 // 【3】向上转型 Student s1 = new UNStudent(93); // 自动转型 s1.study(); // 运行时执行 UNStudent 版本 // 【4】向下转型 UNStudent un2 = (UNStudent) s1; // 强制转型 un2.print(); // 恢复子类特有方法的访问 // 【5】多态 Teacher t = new Teacher(); t.teach(new Student(91)); // Student 版本 t.teach(new UNStudent(95)); // UNStudent 版本 // 【6】接口引用 IStudent is1 = new Student(); IStudent is2 = new UNStudent(); t.teachByInterface(is1); // 只认接口,不关心具体类 t.teachByInterface(is2); } } }七:总结
我说句实话,这个学生-老师类是我觉得最好上手写的代码。当然,我相信没人能否定,无论是你听了一节课,或者说看了一篇文章,你还是得亲自去写代码,你才能真正弄懂每个知识点。所以,我希望看完这篇文章的朋友,快快去写代码吧,在写代码的过程中,真的你会有许多新的体验和想法,所以,这就是我们CS的学生一直强调的代码量的问题,哪怕现在是AI,Agent时代。(建议:第一遍可以照着写,后面大家可以自己创建不同的类,按自己的想法写)