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。这个规则背后的逻辑很直白如果父类的方法对外暴露了某个能力子类把它藏起来就破坏了子类是一种父类的契约。里氏替换原则要求子类对象能出现在任何父类对象出现的地方你把方法权限收紧了调用方按父类接口调用就会失败。父类修饰符子类允许的修饰符publicpublicprotectedprotected、public包级私有默认包级私有、protected、publicprivate不能复写父类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。排查了半天才发现问题。从那以后只要复写equalsIDE自动生成的hashCode我一定一起加上。5.3 toString调试利器toString的默认实现返回的是类名加哈希码类似com.example.User1a2b3c调试时基本没用。复写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的基础语法但它连接着多态、动态绑定、设计模式这些更上层的东西。把基础打牢后面学什么都快。