ty 类型检查器中的 `invalid-explicit-override` 规则:如何用 `@override` 声明并校验方法覆盖

原创2026-09-09 17:56:521,357 阅读
文章标签:开发工具代码质量Lint格式化静态分析CLI

ty 类型检查器中的 invalid-explicit-override 规则:如何用 @override 声明并校验方法覆盖

导读

本文围绕 Ruff 仓库内置类型检查器 ty(crates/ty_python_semantic)中的 invalid-explicit-override 规则展开,讲解它如何检测"被 @override 装饰却并未真正覆盖任何父类方法"的代码。读完本文,你将掌握 typing.override 的正确使用方式、该规则的触发与豁免场景、其背后的源码实现原理,以及它与 missing-override-decorator、invalid-method-override(Liskov 替换原则)等规则的协作方式。

规则要做什么:@override 声明的双向校验

What it does

invalid-explicit-override 检查的是:被 @override 装饰器装饰、但实际上没有覆盖任何父类(superclass)方法的方法。

该规则的官方文档位于 crates/ty_python_semantic/resources/lint_docs/invalid-explicit-override.md,对应诊断定义位于 crates/ty_python_semantic/src/types/diagnostic.rs:

declare_lint! {
    #[doc = include_str!("../../resources/lint_docs/invalid-explicit-override.md")]
    pub(crate) static INVALID_EXPLICIT_OVERRIDE = {
        summary: "detects methods that are decorated with `@override` but do not override any method in a superclass",
        status: LintStatus::stable("0.0.1-alpha.28"),
        default_level: Level::Error,
    }
}

从定义可以看出三个关键事实:

  • 默认级别为 error:只要代码被该规则命中,ty 默认直接报错,而不是警告;
  • 稳定版本号为 0.0.1-alpha.28:该规则从 ty 早期版本起就已存在并保持稳定;
  • 规则文档直接内嵌于源码:include_str! 表明 lint_docs 目录下的 Markdown 就是该规则唯一的权威说明,生成规则参考页(crates/ty/docs/rules.md)时也复用了同一份内容。

Why is this bad

给方法加上 @override 装饰器,本质上是向类型检查器(以及阅读代码的人)声明"这个方法是我刻意覆盖父类同名成员的"。typing.override 的语义见 crates/ty_python_semantic/resources/mdtest/override.md 开头的测试说明:

Decorating a method with typing.override decorator is an explicit indication to a type checker that the method is intended to override a method on a superclass. If the decorated method does not in fact override anything, a type checker should report a diagnostic on that method.

当这一声明与实际不符(父类里根本没有同名成员)时,通常意味着:

  • 方法名拼写错误(例如把 foo 写成 ffooo),而开发者原本期望覆盖;
  • 父类重构后删除了该方法,子类残留了多余的 @override;
  • 开发者误以为继承关系存在,实际基类并不包含该成员。

这些问题都应在开发阶段被尽早发现,而不是等到运行时才暴露。

完整示例:错误与正确的边界

ty 规则文档给出的示例非常直观,它同时覆盖了错误与正确两种情况。先看完整配置与代码:

[environment]
python-version = "3.12"
from typing import override


class A:
    @override
    def foo(self): ...  # error


class B(A):
    @override
    def ffooo(self): ...  # error


class C:
    @override
    def __repr__(self): ...  # fine: overrides `object.__repr__`


class D(A):
    @override
    def foo(self): ...  # fine: overrides `A.foo`

逐条解读:

  • A.foo(error):类 A 本身没有父类成员 foo 可覆盖(未显式继承任何类时,ty 仍会考虑 object 的成员,但 foo 不在其中),所以 @override 是无效声明;
  • B.ffooo(error):类 B 虽然继承自 A,但 A 中定义的是 foo 而非 ffooo,这是典型的方法名拼写错误场景,@override 恰好帮助 ty 将其暴露出来;
  • C.__repr__(fine):类 C 未显式继承任何类,但 __repr__ 是 object 的内置方法,所有类都隐式继承 object,因此它确实覆盖了 object.__repr__;
  • D.foo(fine):D 继承自 A,且 A.foo 存在,覆盖成立。

注意第一个要点:检查对象是"隐式基类 object 的成员也会被纳入覆盖判定"。这是该规则的一个隐含前提——__repr__、__str__、__eq__ 等魔术方法即使在不写 class C(object) 的现代 Python 代码中也始终可被覆盖。

为什么示例中要指定 python-version = "3.12"

示例配置显式声明了 [environment] python-version = "3.12"。原因是 typing.override 是 Python 3.12 才进入标准库 typing 模块的装饰器;在更早的 Python 版本中需要从 typing_extensions 导入。ty 的实现会在给出修复建议时按版本切换推荐导入来源,见 crates/ty_python_semantic/src/types/overrides.rs:

let override_module =
    if context.program_environment().python_version(db) >= PythonVersion::PY312 {
        "typing"
    } else {
        "typing_extensions"
    };

也就是说,在 < 3.12 的环境下,代码应写作 from typing_extensions import override。

源码实现:检查流程与触发条件

该规则的检查逻辑集中在 crates/ty_python_semantic/src/types/overrides.rs,模块开头就说明了职责范围——检查子类中的非法方法覆盖,包括(但不限于)违反 Liskov 替换原则(LSP) 的情况。

规则开关如何映射到检查配置

ty 用位标志(bitflags)聚合一组覆盖相关规则。在 overrides.rs 的 OverrideRulesConfig::from 中,INVALID_EXPLICIT_OVERRIDE 对应 OverrideRulesConfig::EXPLICIT_OVERRIDE:

if rule_selection.is_enabled(LintId::of(&INVALID_EXPLICIT_OVERRIDE)) {
    config |= OverrideRulesConfig::EXPLICIT_OVERRIDE;
}

如果所有覆盖相关规则都被关闭,no_rules_enabled() 会提前返回,避免无谓遍历(见 overrides.rs 的 check_class 入口)。

触发条件:只在"确实没覆盖任何父类成员"时报错

在 overrides.rs 中,check_explicit_overrides 的调用受到严格前置条件约束:

if !subclass_overrides_superclass_declaration
    && !has_dynamic_superclass
    && (
        first_reachable_definition.kind(db).is_function_def()
    )
{
    check_explicit_overrides(context, member, class_scope, class);
}

三个条件缺一不可:

  1. !subclass_overrides_superclass_declaration:在 MRO(方法解析顺序)中找不到任何被覆盖的父类声明时才检查;
  2. !has_dynamic_superclass:如果类有动态基类(无法静态解析的基类),ty 放弃检查——因为无法确定父类是否真的有同名成员,避免误报。代码中还注释了"TODO: Support dynamic class literals"(overrides.rs),表明对动态类字面量的支持仍在计划中;
  3. 成员必须是函数定义(is_function_def):@override 只应作用于方法,ty 在属性等非函数成员上不触发本规则。

诊断如何产生

check_explicit_overrides 的实际报错逻辑在 overrides.rs:

let Some(builder) = context.report_lint(&INVALID_EXPLICIT_OVERRIDE, definition.focus_range)
else {
    return;
};
let mut diagnostic = builder.into_diagnostic(format_args!(
    "Method `{}` is decorated with `@override` but does not override anything",
    member.name
));
if let Some(decorator_span) = definition.focus_override_decorator_span {
    diagnostic.annotate(Annotation::secondary(decorator_span));
}
diagnostic.info(format_args!(
    "No `{member}` definitions were found on any superclasses of `{class}`",
    member = member.name,
    class = class.name(db)
));

生成的诊断由两部分组成:

  • 主诊断指向方法名所在位置(focus_range),消息为"Method X is decorated with @override but does not override anything";
  • 辅助标注(Annotation::secondary)指向 @override 装饰器本身,帮助开发者快速定位到出问题的装饰器;
  • 附加信息(info)补充说明"在 Class 的所有父类上都找不到 member 的定义",明确给出判定依据。

边界情况:哪些 @override 是合法的

@override 的合法性远不止"普通方法覆盖普通方法"。ty 通过 mdtest 用例(crates/ty_python_semantic/resources/mdtest/override.md)系统验证了多种组合,其快照见 crates/ty_python_semantic/resources/mdtest/snapshots/override.md_-typing.override-Basics(b7c220f8171f11f0).snap.snap)。从这些用例可以归纳出以下判定规则:

1. 覆盖 object 的内置方法

即使类没有显式父类,覆盖 object 成员也是合法的:

class A:
    @override
    def __repr__(self): ...  # fine: overrides `object.__repr__`

2. @property 与装饰器顺序无关

@override 放在 @property 之上或之下都是合法的,ty 对二者一视同仁:

class Parent:
    @property
    def my_property1(self) -> int: ...
    @property
    def my_property2(self) -> int: ...

class Child(Parent):
    @property
    @override
    def my_property1(self) -> int: ...  # fine

    @override
    @property
    def my_property2(self) -> int: ...  # fine

3. classmethod / staticmethod

类方法与静态方法同样可以合法覆盖父类的对应成员:

class Child(Parent):
    @classmethod
    @override
    def class_method1(cls) -> int: ...  # fine

    @staticmethod
    @override
    def static_method1() -> int: ...  # fine

    @override
    @classmethod
    def class_method2(cls) -> int: ...  # fine

    @override
    @staticmethod
    def static_method2() -> int: ...  # fine

4. 覆盖普通类属性(属性遮蔽)

即使父类成员是普通赋值属性(baz = None),子类用方法覆盖它同样被视为合法覆盖:

class Parent:
    baz = None

class Child(Parent):
    @override
    def baz(self): ...  # fine: overrides `Parent.baz`

5. 签名被"有损装饰器"擦除的方法

如果父类方法被一个会抹掉原签名的装饰器(如 @lossy_decorator(fn: Callable[..., Any]) -> Callable[..., Any])包裹,子类仍可覆盖。有趣的是,无论 @override 与 @lossy_decorator 的相对顺序如何,ty 都能正确识别:

class Child(Parent):
    @override
    def decorated_1(self): ...  # fine

    @override
    @lossy_decorator
    def decorated_2(self): ...  # fine

    @lossy_decorator
    @override
    def decorated_3(self): ...  # fine

6. 多重继承与 MRO

对于多重继承产生的中间类,ty 同样能沿着 MRO 找到被覆盖的方法。Grandchild(OtherChild) 继承自 OtherChild(Parent),只要 Parent 定义了同名成员,孙类上的 @override 依然合法(测试用例见 override.md 后续部分)。

7. 重载(overload)函数:任何一处带 @override 都算

Python 的 @typing.overload 会把一个逻辑函数拆成多个声明 + 一个实现。LocalOverrideDefinition 结构体(overrides.rs)专门处理了这种情况:

  • any_definition_has_override_decorator:只要任一重载或实现带 @override,invalid-explicit-override 就认为声明有效(即使 @override 落在"非焦点"的重载上);
  • focus_definition_has_override_decorator:missing-override-decorator 规则用的是这个更严格的字段,保证"@override 放错重载位置"时仍会在实现上报告缺装饰器;
  • focus_override_decorator_span:记录焦点定义上装饰器源码区间,用于诊断中的辅助标注。

焦点定义的选择由 overrides.rs 的 overriding_definition 决定:在源码文件(非 stub)中优先指向实现(implementation),在 .pyi stub 中指向第一个重载。这与类型检查器"stub 中重载即契约、源码中实现才生效"的惯例一致。

与相邻规则的协作:覆盖检查的完整拼图

invalid-explicit-override 并非孤立规则,它与同文件(diagnostic.rs)声明的一组覆盖相关规则共同构成完整的覆盖校验体系:

规则 检查方向 默认级别
invalid-explicit-override 有 @override 但没覆盖任何东西(多标) error
missing-override-decorator 覆盖了父类成员却没写 @override(漏标) ignore
invalid-method-override 覆盖了但签名违反 Liskov 替换原则 error
invalid-attribute-override 属性覆盖不兼容 error
override-of-final-method / override-of-final-variable 覆盖了 @final 成员 error

其中 invalid-explicit-override 与 missing-override-decorator 是一对"反向"规则:前者防止 @override 被误用,后者鼓励对真实的覆盖显式标注。二者在 overrides.rs 中共享 extract_local_override_definitions 提取逻辑,只是筛选条件相反。

与 Ruff 既有规则(如 ARG002)的相互作用

ty 的 FAQ 文档 专门讨论了 @override 与 Ruff 静态规则的关系:Ruff 的 ARG002(未使用的方法参数)建议删除或重命名未使用的参数,但这样的修改可能使子类方法不再接受父类方法的全部参数组合,从而触发 ty 的 invalid-method-override(LSP 违规)。解决方式正是给方法加上 @typing.override——Ruff 识别到该装饰器后,会认为该方法有明确的覆盖意图,从而对这类方法跳过 ARG002 等建议;而 @override 本身的正确使用则由 ty 的 invalid-explicit-override 兜底校验。这正是"显式覆盖声明"在双工具协作中的价值所在。

如何在 ty 中启用与配置

invalid-explicit-override 的默认级别就是 error,因此开箱即用,无需显式配置。需要调整行为时,可在 ty 的规则选择(rule selection)中控制,例如在配置文件中按需设置:

[tool.ty.rules]
# 保持默认 error 级别,也可显式写出
"invalid-explicit-override" = "error"

# 临时放行可改为 ignore
# "invalid-explicit-override" = "ignore"

规则的启用/关闭通过 rule_selection.is_enabled 查询(见 overrides.rs),它支持全局规则表以及针对特定文件路径的 [[tool.ty.overrides]] 覆盖段(后者在 crates/ty/tests/cli/rule_selection.rs 中有完整的 CLI 测试覆盖,包括通配符 include/exclude、优先级与继承语义)。

配置时还需注意两点:

  1. Python 版本:使用标准库的 typing.override 需要环境声明 python-version = "3.12"(或更高);更早版本请从 typing_extensions 导入;
  2. 动态基类豁免:当父类无法静态解析时(has_dynamic_superclass 为真),ty 会跳过本检查,这是有意为之的保守策略,避免在信息不完整时误报。

小结

invalid-explicit-override 是 ty 覆盖检查体系中对"过度声明"的把关者:它以 error 级别的默认行为,确保每一个 @override 都名实相符。结合源码可以看到,它的实现覆盖了 object 内置方法、属性遮蔽、classmethod/staticmethod/@property 的任意装饰器顺序、多重继承 MRO、重载函数与 stub 文件等多种真实场景,并且与 missing-override-decorator(反向规则)、invalid-method-override(LSP 校验)以及 Ruff 的 ARG002 形成协同。对使用 ty 进行类型检查的开发者而言,正确运用 @override 不仅能让覆盖意图显式化,更能让拼写错误、父类重构遗留等隐蔽问题在检查阶段就无所遁形。

登录后查看全文
ruff