Python 工程代码之 typing 的使用 、Protocol 实现“结构化子类型”

这其实少个 typing 的常见实践的总结,,,

1. 类型标注(type annotation):

是指在 Python 代码里显式标出变量、函数参数或返回值的类型

Python 在 3.5 之后支持了这种写法,但注意:类型标注不会影响运行

def add(a: int, b: int) -> int:
    return a + b

这里注意一个情况: 在 mypy检查时,标注一个变量的数据类型后,之后该变量的数据类型不可以改变了,否则会报错

Python 是动态类型语言,这意味着你可以随时改变一个变量的类型。类型标注并不会强制执行类型检查或阻止你在不同的时间给同一个变量赋予不同类型的值。

即下述代码 运行时 是 不会报错的,但是,假设我们保存以下代码到文件 mypy尝试.py:,你使用  mypy mypy尝试.py  命令,那么,mypy 将会报告类似以下对应注释的错误:分别是不可以重复对同一个变量进行重复类型标注,或者对已经标注类型的变量赋值其他类型的数值

a : int = 3
a : float = 5.5
# mypy尝试.py:2: error: Name "a" already defined on line 1  [no-redef] , mypy 默认禁止对同一个变量 重复使用 变量标注语法 a: type
# Found 1 error in 1 file (checked 1 source file)

a = 5.5
# mypy尝试.py:6: error: Incompatible types in assignment (expression has type "float", variable has type "int")  [assignment]
# Found 1 error in 1 file (checked 1 source file)

 2. 静态类型检查(static type checking)

静态类型检查是指:在程序运行之前,通过 工具 来检查你的变量和函数调用有没有类型错误。

在运行前查错,可以提高代码健壮性

Python 是动态语言,本来是不会做静态类型检查的。但你用类型标注之后,可以借助像 mypypyrightpyre 等工具来做“静态检查”。

3. 鸭子类型

而对于 Python 和其他动态语言来说,有一个思想是 ,鸭子类型:

“如果它像鸭子、走路像鸭子、叫声也像鸭子,那它就是鸭子。” ==》“行为决定类型” 的哲学

就是说对于一个变量,我们 不关心该变量(实例) 的 数据类型 或 该类型的继承关系,只要 该实例 有 我需要的方法,就能用。

一个经典的例子:

class Duck:
    def quack(self):
        print("quack")

class Person:
    def quack(self):
        print("I'm pretending to be a duck")

def make_it_quack(x):
    x.quack()  # 只要你有 quack 方法,我就用你

make_it_quack(Duck())    # ✅
make_it_quack(Person())  # ✅

这在 Java/C++ 里是完全不可能的,你必须继承一个接口才行。但 Python 不管你是不是 声明了继承自 Duck也不管你是什么数据类型,只要你有 quack() 方法就能顺利执行

鸭子类型 是 Python 的运行时行为哲学——你不用显式继承,只要你“长得像”就可以用了;

4. protocol :

       Python 的鸭子类型本质上是运行时机制。但是在大型工程里,我们往往希望有 静态类型检查工具(如 mypy、pyright) 来提前帮我们发现类型错误。而 鸭子类型很好用,但太自由,工具没法检查;要检查,就得指定“哪些行为是必需的”。

      于是,Python 3.8 引入了 typing.Protocol(PEP 544),让我们能定义一个“行为规范”,只要一个对象实现了规范中的方法/属性,就可以当成这个类型用。

      在 Python 中,当一个类继承自 Protocol,通常这种类被称作 “协议类” 或者直接称为 “协议”。这是因为这些类实际上定义了一组方法和属性的结构,任何符合这组结构(即实现了这些方法和属性)的类或对象都可以被认为实现了该协议,即便它们没有显式地继承这个协议类。这种机制 允许开发者定义一种接口形式的行为约定,而不强制要求使用特定的基类。(而且,Python并不像 C++ Java Go 那样,有接口关键字 ,Python 中的接口 要么是用 虚拟基类 ABC 要么 是 protocol 实现的)

例如:

from typing import Protocol

class Flyer(Protocol):
    def fly(self) -> None: ...

 
def make_it_fly(x: Flyer):   # 这个参数 类型标注 提示 静态检查器,传入的参数应该是一个实现 Flyer 类 各个方法和属性的 实例
    x.fly()

只要你传入的对象有 fly() 方法,mypy 会认为你符合 Flyer 协议,即使你没有显式继承它。(当然,你也可以选择 class Bird(Flyer) 这样显式声明)

如果在定义 继承protocol 的 类时候,加上 @runtime_checkable,你还能在运行时用 isinstance() 判断:

from typing import runtime_checkable

@runtime_checkable
class Flyer(Protocol):
    def fly(self) -> None: ...

assert isinstance(some_obj, Flyer)  # 运行时判断

5. 常用 符号 ... 和 关键字 pass

1.  符号 ...  (Ellipsis) :

       你在 Protocol 类中写方法时,... 常被用来表示抽象方法,即 “声明这个方法的存在”,但在此处你 不会提供 实际实现。这个方法必须由实现者去提供,我这里只是定义接口规范。”

       当你看到 ... 时,通常理解为该处应该有实现,但现在被故意留空了。

且注意:

  • 虽然可以直接在 Protocol 中定义具体的方法实现,语法上可行但及其不推荐,这样做可能会导致混淆,使得开发者误以为实现了该协议的类会自动继承这些实现,但实际上这并不是 Protocol 的本意。

  • 推荐做法:使用 ... 来表示方法体,明确指出这是一个需要由实现该协议的具体类来填充的抽象方法。

class AA(Protocol):
    def a(self) -> None: ...  # 表示“必须实现 a() 方法”

       不能用 pass 替代 ...,否则就不是“纯接口”了。 

2. pass 

pass 是一个空操作语句。适用于类定义、函数体、条件语句等需要语句但暂时不执行任何操作的地方。它明确表示 “这里有意为空”,可以用来作为占位符,以便后续添加代码。

pass 适用于描述是表示 “这个类体是空的” ,没有新的方法或属性。

例如,当创建一个组合协议时,比如:

class AABB(AA, BB, Protocol):
    pass

你只是把 AABB 的方法合并进来,不需要声明新的方法,也不需要定义新的行为,只是 表示 “这个类体是空的” ,没有新的方法或属性。你也可以在这种场合写 ...,Python 解释器也能接受,但这里写 pass 更清晰,也更常见,因为这个时候你不是在声明一个方法,而是在定义一个空类体。

6. 想表示一个 对象 同时拥有多个 Protocol 所规定的能力

最佳实践:

根据 想拥有的多个protocol 联合定义一个组合接口 :

class AA(Protocol):
    def a(self) -> None: ...

class BB(Protocol):
    def b(self) -> None: ...

class AABB(AA, BB, Protocol):
    pass

def func(x: AABB):
    x.a()
    x.b()

AABB 是一个“组合能力接口”,表示“一个同时拥有 AABB 能力的对象”。

这个写法最清晰、最静态安全,也是 mypy / pyright / IDE 都能完全支持的方式,也体现了 Python接口系统的组合能力

7. 为什么说 Python 非常适合写接口?

1. 首先介绍一下 继承 和 接口 的 意义区别:
特征类继承(基类)接口(Protocol 或 Interface)
关注点是什么(is-a)能做什么(can-do)
是否共享状态(属性)是,共享父类的属性和部分实现否,接口中通常不包含属性
是否共享行为(方法)是,继承方法并可覆盖是,要求实现接口声明的方法
是纵向还是横向结构纵向继承结构(树状,子类扩展父类)横向能力结构(多维组合,不涉及“谁是子类”)
多继承风险有潜在冲突(C++/Python 多继承复杂)通常无冲突(多个接口能自由组合)
Python中的实现class Dog(Animal)class Something(Protocol) 或结构鸭子类型支持
适合用来表示什么角色、对象类型、拥有统一状态的子类对象动作、功能、能力,比如“可排序”、“可飞”、“可迭代”

继承类强调“本体和血缘”——我是什么,继承了什么属性与本能;
接口强调“能力和资格”——我能做什么,不在乎我来自哪里。 

实际开发中的例子举几个帮你固定理解:

  • class Dog(Animal) 是继承,强调:Dog 是 Animal,能跑、有腿、有名字

  • class Dog(CanSwim, CanBark) 是接口组合,强调:Dog 会游泳、会叫,但这些能力你给谁都可以,不用是 Animal

2. Python 非常适合写接口的原因:

(1)鸭子类型天然支持接口抽象

Python 的接口从一开始就不是“类型绑定式”的,而是“行为表现式”的。你不需要写“这个类必须继承谁”,只要你行为对就行。这种 零束缚的能力组合机制,就是接口设计的终极形态。

(2)Protocol 提供了类型检查的支撑

传统鸭子类型没法做静态分析,Protocol 让类型检查工具(如 mypy、pyright)可以识别出“你确实有这些方法”,增强了代码的可靠性,特别适合大型项目或多人协作。

(3)组合灵活性极高

Python 的接口设计不是单一继承限制的,而是任意组合的。

你可以把各种能力抽象成独立的 Protocol,然后灵活组合成你需要的接口模型,这在 Java/C++ 里是要靠接口继承和设计模式才能实现的。

例如:

class A(Protocol): ...
class B(Protocol): ...
class C(A, B): ...

3. 对比一下各个语言的接口实现:

语言接口设计机制灵活性静态安全实现复杂度
Java显式 interface,必须实现中等较重
C++抽象基类/虚函数机制中等高(多继承复杂)
Go隐式接口(行为决定归属)简洁
Python鸭子类型 + Protocol极高高(需配合工具)最轻量

在 C++ 中 实现 同样的 让一个函数接受一个“能 quack 的对象”,你必须通过继承某个接口类(通常是抽象类)来实现。比如: 

8. Python 用 协议类实现接口 的 语法:

Protocol 中,声明 (可读写的)字段 ,一般使用类型标注的形式:

声明 (只读的)属性 用 @property def id(self) -> int: ...Protocol 中定义

        对于 属性 ,实现类必须提供匹配的 @property,字段或 setter 属性都不算匹配。

from typing import Protocol

class HasNameId(Protocol):  # 只定义 属性 和 字段 的协议
    name: str

    @property   # 用 @property 定义只读属性
    def id(self) -> str: ...

class PeopleEntity(Protocol):    # 属性 + 字段 + 方法同时定义的协议
    name: str

    @property
    def id(self) -> int: ...

    def greet(self) -> str: ...

写一个类来实现这个接口 ,并定义个 函数 say_hi ,要求满足协议的实例作为参数 :

class Person:
    def __init__(self, name: str, id: int):
        self.name = name
        self._id = id

    @property
    def id(self) -> int:
        return self._id

    def greet(self) -> str:
        return f"Hello, I'm {self.name}, and my id is {self.id}"  # 更推荐使用 self.id 而不是 self._id, 因为 self._id 是裸值,
        # 而 id(self) 函数中可能不只有返回值,还包括一些合理性校验,直接调用裸值会绕过校验,容易引起错误


def say_hi(entity: PeopleEntity):
    print(entity.greet())

say_hi(Person("AA", 101))

p1 = Person("BB", 102)
print(p1.id)  # 使用@property 修饰的方法时,就像访问普通属性一样,不加括号
              # id 是 @property 修饰的,所以 p.id 会自动调用那个 def id(self) 方法,
              # 但你不需要写括号。这个语法糖是 @property 的目的:把方法“伪装成属性”。

执行 mypy XX.py 后,输出:Success: no issues found in 1 source file

执行 python XX.py后,输出:Hello, I'm AA

这里注意:将实例函数写为 def greet(self):   合法,但不推荐,因为没有明确 返回类型

推荐 写上 -> str,方便于 类型检查工具(如 mypy / pyright)就 静态判断返回值类型

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值