1. 从一次编译报错说起:为什么函数复写值得单独拎出来讲
刚接触Java4Android那会儿,我写了一个继承自父类的子类,想当然地给某个方法换了个参数类型,以为这就是"复写"。结果运行起来,程序走的还是父类的逻辑,子类里那段代码跟没写一样。当时盯着屏幕看了半天,百思不得其解——明明方法名一样啊,怎么就不生效呢?
后来才明白,我干的那叫重载(Overload),不是复写(Override)。这两个概念在Java里长得像、名字像,但底层机制完全不同。重载是同一个类里方法名相同、参数列表不同;复写是子类重新定义父类中已有的方法,方法签名必须完全一致。一字之差,行为天壤之别。
这篇内容就是围绕函数的复写展开的。我会把复写的判定规则、运行时到底怎么找到该执行哪个方法、和重载的本质区别、以及实际写Android代码时哪些地方最容易翻车,全部拆开讲清楚。不管你是刚学Java语法的新手,还是已经能写Android界面但说不清@Override到底在干嘛的开发者,这篇都能帮你把这块知识补扎实。
函数复写不是一个孤立的语法点。它背后牵扯到多态、动态绑定、方法表这些核心机制。你把它搞透了,后面学接口回调、抽象类模板方法、Android里各种Listener的实现,都会顺畅很多。反过来,如果只是死记"方法名一样、参数一样就是复写",遇到稍微绕一点的继承链就会懵。
2. 复写的判定标准:编译器到底认什么
2.1 方法签名一致是硬门槛
Java编译器判断一个方法是不是复写,看的是方法签名。方法签名由方法名和参数列表共同组成,参数列表包括参数的类型、个数和顺序。这三样必须和父类中被复写的方法完全一致,差一个都不行。
class Parent { public void doWork(int count, String name) { System.out.println("Parent doWork"); } } class Child extends Parent { // 这是复写:方法名、参数类型、个数、顺序完全一致 @Override public void doWork(int count, String name) { System.out.println("Child doWork"); } }如果你把int count改成long count,或者把两个参数的顺序调换,编译器就不再认为这是复写了。这时候@Override注解会直接报错,帮你拦住这个错误。这也是我强烈建议**只要打算复写就加上@Override**的原因——它相当于让编译器帮你做一次签名校验,写错了立刻发现,而不是等到运行时才发现行为不对。
2.2 返回值类型:协变返回是允许的
很多人以为返回值类型也必须一模一样,其实不完全对。从Java 5开始,**协变返回类型(covariant return type)**是被允许的:子类复写方法的返回值类型可以是父类方法返回值类型的子类型。
class Animal { public Animal create() { return new Animal(); } } class Dog extends Animal { // 返回值是Animal的子类Dog,这是合法的复写 @Override public Dog create() { return new Dog(); } }这个特性在实际开发中挺有用,尤其是写工厂方法或者建造者模式的时候,子类可以返回更具体的类型,调用方就不用再做强制类型转换了。但要注意,反过来不行——父类返回Dog,子类返回Animal,编译器直接拒绝。
2.3 访问权限只能放宽不能收紧
复写方法时,子类的访问修饰符不能比父类更严格。父类是protected,子类可以是protected或public,但不能是private或包级私有。父类是public,子类必须是public。
这个规则背后的逻辑很直白:如果父类的方法对外暴露了某个能力,子类把它藏起来,就破坏了"子类是一种父类"的契约。里氏替换原则要求子类对象能出现在任何父类对象出现的地方,你把方法权限收紧了,调用方按父类接口调用就会失败。
| 父类修饰符 | 子类允许的修饰符 |
|---|---|
| public | public |
| protected | protected、public |
| 包级私有(默认) | 包级私有、protected、public |
| private | 不能复写(父类private方法子类不可见) |
2.4 异常:不能抛出更宽的检查型异常
如果父类方法声明了检查型异常(checked exception),子类复写时只能抛出相同或更窄的异常,或者干脆不抛。不能抛出新的、更宽的检查型异常。
class Parent { public void load() throws IOException { } } class Child extends Parent { // 合法:不抛异常 @Override public void load() { } // 合法:抛更窄的异常 // public void load() throws FileNotFoundException {} // 非法:抛更宽的异常 // public void load() throws Exception {} }运行时异常(RuntimeException及其子类)不受这个限制,随便抛。因为运行时异常不需要调用方强制处理,编译器不管。
2.5 static方法、final方法、private方法不能被复写
这三类方法有个共同点:它们在编译期就已经确定了调用目标,不参与运行时的动态绑定。
- static方法:属于类而不属于对象,子类可以定义同名同参的static方法,但那叫隐藏(hiding),不叫复写。调用哪个方法取决于引用变量的声明类型,而不是实际对象类型。
- final方法:明确禁止子类修改,编译器直接报错。
- private方法:子类根本看不见,定义同名方法只是碰巧同名而已,跟复写没关系。
这里有个容易踩的坑:父类的private方法,子类写了一个一模一样签名的方法,还加了
@Override,编译器会报错说"方法未覆盖父类方法"。去掉@Override就能编译通过,但这时候两个方法毫无关系,各走各的。
3. 运行时怎么找到该执行哪个方法:动态绑定拆解
3.1 编译看左边,运行看右边
这句话是理解Java多态和复写的口诀。编译阶段,编译器检查的是引用变量的声明类型,看这个类型里有没有你要调用的方法;运行阶段,JVM根据实际对象的类型来决定执行哪个版本的方法。
Parent p = new Child(); p.doWork(1, "test"); // 实际执行的是Child的doWork编译时,编译器看p是Parent类型,检查Parent里有没有doWork(int, String),有就通过。运行时,JVM发现p指向的是一个Child对象,于是调用Child复写后的doWork。
这个机制叫动态绑定(dynamic binding),也叫晚期绑定(late binding)。它是多态的核心实现方式。
3.2 方法表:JVM内部的查找逻辑
JVM在每个类的方法区里维护了一张方法表(method table),也叫虚方法表(vtable)。这张表里记录了该类所有可被动态绑定的方法及其实际入口地址。
当子类复写了父类的方法,子类方法表中对应位置的条目就指向子类自己的实现;没有复写的方法,条目仍然指向父类的实现。调用方法时,JVM通过对象头找到它的类元数据,再查方法表,直接定位到要执行的方法入口。
这种设计让方法调用的开销很小——只是一次表查找,不需要沿着继承链一层层往上搜索。所以复写带来的性能损耗几乎可以忽略不计。
3.3 字段没有多态,方法才有
一个特别容易搞混的点:字段的访问不参与动态绑定。也就是说,通过父类引用访问字段,拿到的永远是父类中定义的那个字段,哪怕实际对象是子类。
class Parent { String name = "Parent"; } class Child extends Parent { String name = "Child"; } Parent p = new Child(); System.out.println(p.name); // 输出 Parent,不是 Child这个行为经常让初学者困惑。记住:字段看声明类型,方法看实际类型。实际开发中,字段一般都用private修饰,通过getter方法访问,而getter方法是参与动态绑定的,所以不会遇到这个问题。这也是为什么面向对象设计强调"字段私有、方法公开"的原因之一。
3.4 super关键字:显式调用父类实现
子类复写了父类方法之后,如果还想调用父类的原始实现,用super关键字。
class Child extends Parent { @Override public void doWork(int count, String name) { super.doWork(count, name); // 先执行父类逻辑 System.out.println("Child extra work"); } }super调用是静态绑定的,编译期就确定了要调用父类的哪个方法,不参与动态绑定。这个特性在模板方法模式里用得非常多——父类定义骨架流程,子类复写某个步骤时先调super保留父类的默认行为,再追加自己的逻辑。
Android开发里典型的例子是Activity的生命周期方法。你复写onCreate时第一件事就是super.onCreate(savedInstanceState),让父类完成必要的初始化工作,然后再执行自己的代码。如果不调super,框架层的初始化逻辑就断了,各种奇怪的问题都会冒出来。
4. 复写和重载:一对容易混淆的兄弟
4.1 一张表看清本质区别
| 对比维度 | 复写(Override) | 重载(Overload) |
|---|---|---|
| 发生位置 | 子类和父类之间 | 同一个类中 |
| 方法名 | 必须相同 | 必须相同 |
| 参数列表 | 必须完全相同 | 必须不同 |
| 返回值 | 相同或协变 | 可以任意不同 |
| 绑定时机 | 运行时动态绑定 | 编译时静态绑定 |
| 多态表现 | 运行时多态 | 编译时多态 |
| 访问权限 | 不能比父类更严格 | 无限制 |
| 异常 | 不能更宽 | 无限制 |
4.2 一个例子同时展示两者
class Calculator { // 重载:同一个类中,方法名相同,参数不同 public int add(int a, int b) { return a + b; } public int add(int a, int b, int c) { return a + b + c; } public double add(double a, double b) { return a + b; } } class ScientificCalculator extends Calculator { // 复写:子类重新定义父类的add(int, int) @Override public int add(int a, int b) { System.out.println("Scientific add"); return a + b; } }Calculator类里三个add方法是重载关系,编译器根据实参的类型和个数来决定调哪个。ScientificCalculator里的add(int, int)是对父类同名方法的复写,运行时根据对象实际类型决定调哪个。
4.3 重载决议在编译期完成
重载方法的调用在编译期就确定了。编译器根据引用变量的声明类型和实参的静态类型来挑选最匹配的方法。
Calculator calc = new ScientificCalculator(); calc.add(1, 2); // 编译期选中add(int, int),运行时执行ScientificCalculator的版本 calc.add(1.0, 2.0); // 编译期选中add(double, double),ScientificCalculator没复写,执行父类版本注意第二行:ScientificCalculator只复写了add(int, int),没有复写add(double, double),所以calc.add(1.0, 2.0)走的还是父类实现。这就是动态绑定和静态绑定配合工作的结果——先编译期选方法,再运行期选实现。
4.4 实际开发中的选择建议
什么时候用复写,什么时候用重载?我的经验是:
- 复写用在"子类需要改变父类某个行为的实现"时。比如自定义
View时复写onDraw来画自己的内容,复写onTouchEvent来处理触摸事件。 - 重载用在"同一个操作有多种输入形式"时。比如一个
show方法,可以接收字符串、可以接收资源ID、可以接收字符串加时长,对外提供统一的语义接口。
不要为了重载而重载。如果一个方法的参数组合超过三四种,考虑用建造者模式或者拆成不同名字的方法,可读性会更好。
5. Android开发中复写的高频场景与踩坑记录
5.1 生命周期方法:super调用不能忘
Android的Activity、Fragment、Service、Application都有生命周期方法需要复写。这些方法有一个共同特点:父类实现里做了框架层面的初始化工作,你必须先调super。
@Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); // 必须第一行调用 setContentView(R.layout.activity_main); // 自己的初始化逻辑 }我见过有同事把super.onCreate写在setContentView后面,结果在某些设备上出现莫名其妙的崩溃。原因是父类在onCreate里初始化了一些setContentView依赖的内部状态,顺序反了就会出问题。虽然大部分情况下Android框架比较宽容,但养成"先super再自己的逻辑"的习惯,能避免很多隐蔽的bug。
onPause、onStop、onDestroy这些方法里,super调用的位置相对灵活,但一般也建议放在最后或者最前,保持一致性。
5.2 equals和hashCode:必须成对复写
Object类定义了equals和hashCode,它们的默认实现是基于对象内存地址的。如果你自定义了一个类,想按内容来比较相等性,就必须同时复写这两个方法。
public class User { private String id; private String name; @Override public boolean equals(Object obj) { if (this == obj) return true; if (obj == null || getClass() != obj.getClass()) return false; User other = (User) obj; return Objects.equals(id, other.id) && Objects.equals(name, other.name); } @Override public int hashCode() { return Objects.hash(id, name); } }为什么必须成对复写?因为Java的集合类(HashMap、HashSet)在存储元素时,先用hashCode定位桶,再用equals在桶内查找。如果只复写equals不复写hashCode,两个内容相同的对象可能算出不同的哈希值,被放到不同的桶里,导致HashSet里出现重复元素,或者HashMap用相等的key取不到值。
踩坑记录:有一次我用自定义对象作为
HashMap的key,只复写了equals忘了hashCode,结果map.get(key)一直返回null。排查了半天才发现问题。从那以后,只要复写equals,IDE自动生成的hashCode我一定一起加上。
5.3 toString:调试利器
toString的默认实现返回的是类名加哈希码,类似com.example.User@1a2b3c,调试时基本没用。复写toString返回对象的字段内容,打日志的时候一眼就能看清对象状态。
@Override public String toString() { return "User{id='" + id + "', name='" + name + "'}"; }这个习惯看起来小,但在排查问题时能省大量时间。尤其是打印集合的时候,如果集合元素都复写了toString,一行日志就能看到所有元素的内容。
5.4 自定义View的onDraw和onMeasure
写自定义控件时,onDraw和onMeasure是复写频率最高的两个方法。
@Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); // 绘制自己的内容 canvas.drawCircle(cx, cy, radius, paint); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { // 根据测量模式和期望尺寸计算实际大小 int width = resolveSize(desiredWidth, widthMeasureSpec); int height = resolveSize(desiredHeight, heightMeasureSpec); setMeasuredDimension(width, height); }onDraw里调super.onDraw会执行父类的绘制逻辑(比如绘制背景),如果你的控件不需要父类绘制任何东西,可以不调。但onMeasure里如果不调super.onMeasure,就必须自己调setMeasuredDimension,否则控件尺寸会是0,完全不显示。
5.5 匿名内部类里的复写
Android里设置监听器时大量使用匿名内部类,本质上也是在复写接口或抽象类的方法。
button.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { // 处理点击 } });这里的onClick是对OnClickListener接口方法的实现。接口的方法默认是public abstract的,实现类必须提供具体实现。匿名内部类只是省去了显式定义一个类的步骤,复写的规则完全一样。
6. 复写相关的几个边界问题与验证方法
6.1 构造方法不能被复写
构造方法的名字必须和类名相同,子类类名和父类类名不一样,所以构造方法天然不可能构成复写。子类构造方法里调super(...)是在调用父类构造方法,不是复写。
class Parent { public Parent(int value) { System.out.println("Parent constructor"); } } class Child extends Parent { public Child() { super(10); // 调用父类构造方法,不是复写 System.out.println("Child constructor"); } }如果子类构造方法里没有显式调super,编译器会自动插入对父类无参构造方法的调用。如果父类没有无参构造方法,子类又没显式调super(...),编译就报错。
6.2 抽象方法的复写
抽象方法只有声明没有实现,子类(非抽象类)必须复写所有抽象方法,否则子类也得声明为抽象类。
abstract class Shape { public abstract double area(); } class Circle extends Shape { private double radius; @Override public double area() { return Math.PI * radius * radius; } }抽象方法的复写规则和普通方法一样,只是父类没有提供实现,子类没有super可调。
6.3 接口默认方法的复写
Java 8引入了接口默认方法,接口可以提供方法实现。实现类可以复写默认方法,也可以直接继承。
interface Greeting { default void sayHello() { System.out.println("Hello from interface"); } } class ChineseGreeting implements Greeting { @Override public void sayHello() { System.out.println("你好"); } }如果一个类实现了两个接口,两个接口有相同的默认方法,编译器会要求该类必须复写这个方法来解决冲突,否则报错。
6.4 怎么验证复写是否生效
最直接的方法就是写个测试跑一下。
public class OverrideTest { public static void main(String[] args) { Parent p = new Child(); p.doWork(1, "test"); // 看输出是Parent还是Child } }如果输出是Child doWork,说明复写生效了。如果输出Parent doWork,检查方法签名是否完全一致、有没有加@Override、访问权限有没有问题。
另一个方法是利用反射:
Method method = Child.class.getMethod("doWork", int.class, String.class); System.out.println(method.getDeclaringClass().getName());如果输出Child,说明Child类里确实定义了这个方法。如果输出Parent,说明Child没有复写,用的是继承来的方法。
6.5 常见编译错误速查
| 错误提示 | 原因 | 解决 |
|---|---|---|
| method does not override or implement a method from a supertype | 签名不匹配或父类没有此方法 | 检查方法名、参数、返回值 |
| cannot reduce the visibility of the inherited method | 访问权限比父类严格 | 放宽子类方法权限 |
| overridden method does not throw XXXException | 抛出了更宽的检查型异常 | 缩小异常范围或不抛 |
| cannot override the final method from XXX | 父类方法是final | 去掉final或换方法名 |
7. 我个人的几条实操心得
复写这块知识,光看语法规则很容易觉得"就这?",但真正写代码时踩的坑往往不在语法层面,而在对运行时行为的理解偏差上。分享几条我自己总结的经验。
第一条:@Override注解不是可选项,是必选项。它不改变程序行为,但能在编译期帮你抓住签名写错的低级错误。我见过太多因为参数类型写错导致复写失效、排查半天的案例。加上注解,编译器就是你的第一道防线。
第二条:复写equals时永远同时复写hashCode。这条规则说了无数遍,但还是有人忘。用IDE的自动生成功能,两个方法一起生成,不要手动只写一个。
第三条:生命周期方法的super调用顺序要记牢。onCreate、onAttach、onCreateView这些初始化类的方法,super放第一行;onDestroy、onDetach这些清理类的方法,super放最后一行。这个规律不是绝对的,但能覆盖大部分场景。
第四条:不确定复写有没有生效时,用日志验证。在父类和子类的方法里各打一条日志,跑一下看输出哪条。比盯着代码猜要快得多。
第五条:接口默认方法冲突时,用InterfaceName.super.method()显式指定。如果一个类实现了两个接口,两个接口有同名默认方法,可以在复写时用Greeting.super.sayHello()来调用指定接口的实现。
class MyGreeting implements Greeting, Farewell { @Override public void sayHello() { Greeting.super.sayHello(); // 显式调用Greeting的默认实现 } }这些经验都是在实际项目中一点点积累的。函数复写看起来是Java的基础语法,但它连接着多态、动态绑定、设计模式这些更上层的东西。把基础打牢,后面学什么都快。