方法重写是契约下的精准替换,须严守签名一致、异常声明不扩大、访问权限不收紧、行为语义不违背,并通过LSP测试套件验证兼容性。

方法重写不是自由发挥,而是契约下的精准替换。关键不在于“能不能改”,而在于“改了之后调用方是否还敢放心用”。父类变量(即父类引用)所承载的契约,是调用方唯一依赖的依据;子类若在重写中悄悄打破它,多态就变成陷阱。
守住方法签名与返回值的契约底线
签名一致是重写的前提,更是契约的第一道防线。名称、参数类型(含泛型擦除后原始类型)、参数顺序必须完全相同;否则不是重写,只是同名新方法,多态调用根本进不去。
- 父类声明
void save(List<string> data)</string>,子类写成void save(ArrayList<string> data)</string>—— 这是重载,父类引用调用时永远执行父类逻辑 - 父类返回
Number,子类可返回Integer(协变允许),但不能返回String或int(基本类型不支持协变) - 泛型参数不能作为区分依据:父类
<t> T get()</t>和子类<e> E get()</e>签名仍一致;但若父类是get(String),子类写get(Object)就破坏了参数类型一致性
严控异常声明:不让调用方措手不及
异常是契约的重要组成部分。父类没声明检查异常,子类绝不能新增;父类声明 throws IOException,子类最多只能缩小为 throws FileNotFoundException。否则,已有代码只捕获 IOException,面对子类抛出的 SQLException 就会崩溃。
- 编译器只看引用类型:
Parent p = new Child(); p.method();—— 能否catch SQLException,取决于Parent.method()是否声明,和Child实际抛什么无关 - 运行时异常(
RuntimeException及其子类)可自由增减,因调用方本就不强制处理 - 真正需要扩展错误语义时,优先用自定义运行时异常(如
ValidationFailedException)或返回Result<t e></t>类型,避免绕过检查异常约束带来的隐性风险
尊重访问权限与行为语义的隐性约定
访问修饰符不能更严格(protected 父类方法,子类不能降为 private 或包级),否则外部调用直接失效;更关键的是行为语义——父类文档说“线程安全”“接受 null 输入”“不修改入参对象”,子类就必须照做。
- 父类
process(List items)明确支持空列表,子类重写后对空列表抛NullPointerException,就违反 LSP - 父类
setLength(int)承诺“仅设置长度,不影响宽度”,子类若顺手把宽度也重置为和长度相等,调用方按独立设置逻辑写的代码就会出错 - 用
super.xxx()复用父类逻辑时,注意调用位置:前置校验放开头,资源清理放结尾,避免遗漏(如漏掉super.close()导致句柄泄漏)
用测试驱动验证契约守约
文档会过时,人会疏忽,但测试不会。针对父类接口写一套完整单元测试(覆盖正常输入、边界值、null、异常路径),然后把所有测试对象从父类实例换成子类实例再跑一遍。全通过,才说明子类真正守约。
- 重点比对:相同输入下输出是否一致、是否多抛/少抛异常、对象内部状态变更是否符合预期(比如父类承诺“调用后 size 不变”,子类就不能偷偷清空集合)
- 某测试在子类下失败,先别急着改测试——要反问:是父类契约本身模糊(比如没写明 null 处理),还是子类越界了?
- 将这套“LSP 合规性测试套件”纳入 CI 流水线,防止后续迭代无意中破坏兼容性

















