29、面向对象与类型注解:typing模块,泛型(Generics),Protocol(结构化子类型)
说实话,很多Python开发者写了好几年代码,对类型注解的态度都是「可有可无」。我以前也这么想——反正Python是动态语言,写类型注解多麻烦啊。直到有一次,我接手了一个20万行的老项目,没有一行类型注解,一个函数返回什么全靠猜……嗯,那段时间我每天都在跟Bug捉迷藏。
从那时起,我养成了一个习惯:能写类型注解的地方,绝不偷懒。今天我们就来聊聊Python类型系统里最核心的三个概念:typing模块、泛型、以及Protocol。这些东西,说白了就是让你的代码「自文档化」,让IDE和同事都能看懂你的意图。
29.1 typing模块:类型注解的基石
typing模块是Python 3.5引入的,它提供了一套标准化的类型注解工具。你想想看,如果没有这个模块,我们想表达「这个参数是一个整数列表」都只能写注释。有了typing,一切就清晰了。
核心类型一览
| 类型 | 说明 | 示例 |
|---|---|---|
| List[T] | 元素类型为T的列表 | List[int] |
| Dict[K, V] | 键类型K、值类型V的字典 | Dict[str, int] |
| Optional[T] | 可能是T或None | Optional[str] |
| Union[T1, T2] | 可以是T1或T2 | Union[int, float] |
| Tuple[T1, T2] | 固定长度的元组 | Tuple[str, int] |
| Any | 任意类型(放弃检查) | Any |
我在项目中遇到过最典型的场景:一个函数可能返回数据,也可能返回None。以前大家习惯写return None,但调用方根本不知道要处理None的情况。用Optional就一目了然了。
from typing import Optional, List
def find_user(user_id: int) -> Optional[dict]:
"""根据用户ID查找用户,找不到返回None"""
# 模拟数据库查询
users = {1: {"name": "Alice"}, 2: {"name": "Bob"}}
return users.get(user_id)
# 调用方一看就知道要处理None
result = find_user(3)
if result is not None:
print(result["name"])
我的小技巧:在团队协作中,我建议把类型注解当作「最低成本的接口文档」。一个函数只要写了类型注解,别人调用时就不需要翻源码了。
29.2 泛型(Generics):让类型更灵活
泛型是什么?说白了就是「类型参数化」。你写一个函数,它处理int、str、甚至自定义类型,但你又不想为每种类型都写一遍。这时候泛型就派上用场了。
我记得第一次用泛型是在写一个缓存工具类的时候。缓存嘛,什么类型都能存,但取出来的时候我希望保持原来的类型。用TypeVar就能完美解决。
from typing import TypeVar, Generic, List
T = TypeVar('T') # 定义一个类型变量
class Stack(Generic[T]):
"""一个泛型栈,可以存储任意类型"""
def __init__(self) -> None:
self._items: List[T] = []
def push(self, item: T) -> None:
self._items.append(item)
def pop(self) -> T:
return self._items.pop()
# 使用示例
int_stack = Stack[int]()
int_stack.push(1)
int_stack.push(2)
value = int_stack.pop() # value的类型被推断为int
str_stack = Stack[str]()
str_stack.push("hello")
str_stack.push("world")
text = str_stack.pop() # text的类型被推断为str
你可能会问:为什么不直接用Any?嗯,这里有个关键区别——Any会完全关闭类型检查,而泛型会保留类型信息。用Stack[int],IDE就知道pop()返回的是int,你如果拿去当字符串用,它会报错。这就是泛型的价值。
我曾经踩过的坑:在泛型函数里,不要对类型变量T做任何假设。比如你不能写if isinstance(item, T),因为T在运行时已经被擦除了。泛型只在静态类型检查时生效,运行时它就是个普通的类型变量。
29.3 Protocol:鸭子类型的正式化
Python一直推崇「鸭子类型」——如果它走起来像鸭子,叫起来像鸭子,那它就是鸭子。但问题是,你怎么在类型注解里表达「这个参数只要有.read()方法就行」?
在Python 3.8之前,我们只能用抽象基类(ABC)来做这件事。但ABC要求子类必须显式继承,这太死板了。Protocol的出现,让鸭子类型有了正式的「身份证」。
from typing import Protocol
class Readable(Protocol):
"""任何实现了read方法的类型"""
def read(self) -> str: ...
def process_data(source: Readable) -> None:
"""处理可读数据源"""
data = source.read()
print(f"处理数据: {data}")
# 这两个类都没有继承Readable,但都符合协议
class FileReader:
def read(self) -> str:
return "从文件读取的数据"
class NetworkReader:
def read(self) -> str:
return "从网络读取的数据"
# 都可以传入process_data
process_data(FileReader())
process_data(NetworkReader())
我个人习惯用Protocol来定义「行为接口」,而不是「继承关系」。你想想看,在大型项目中,继承链往往又长又脆弱。而Protocol只关心「你有没有这个能力」,不关心「你是谁」。这种松耦合的设计,重构起来轻松多了。
Protocol vs ABC 对比
| 特性 | Protocol | ABC |
|---|---|---|
| 是否需要显式继承 | 不需要(结构子类型) | 需要(名义子类型) |
| 运行时检查 | 不支持(仅静态检查) | 支持(isinstance检查) |
| 灵活性 | 高(任何符合结构的类型) | 低(必须继承) |
| 适用场景 | 接口解耦、第三方库适配 | 需要运行时多态 |
29.4 知识体系总览
下面这张图,是我梳理的本章知识脉络。你可以把它当作一个「类型注解地图」——从基础类型到泛型,再到Protocol,层层递进。
29.5 实战:组合使用
光说不练假把式。我们来看一个真实场景:写一个通用的数据处理器,它要能处理不同类型的数据源,并且返回处理后的结果。
from typing import TypeVar, Protocol, Generic, List
T = TypeVar('T')
R = TypeVar('R')
class DataSource(Protocol[T]):
"""数据源协议:任何能提供数据的对象"""
def fetch(self) -> List[T]: ...
class DataProcessor(Generic[T, R]):
"""泛型数据处理器"""
def __init__(self, source: DataSource[T]) -> None:
self._source = source
def process(self, transform) -> List[R]:
"""处理数据并转换类型"""
raw_data = self._source.fetch()
return [transform(item) for item in raw_data]
# 具体实现
class UserAPI:
def fetch(self) -> List[dict]:
return [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]
# 使用
api = UserAPI()
processor = DataProcessor[dict, str](api)
names = processor.process(lambda u: u["name"])
print(names) # ['Alice', 'Bob']
这个例子把今天讲的三样东西都用上了:Protocol定义了数据源的行为,Generic让处理器可以适配不同类型,typing的基础类型让代码自解释。我在项目中用这种模式重构过一个数据管道,代码量减少了30%,Bug率直线下降。
最后说一句:类型注解不是银弹,它不能保证你的代码没有逻辑错误。但它就像安全网——你写的时候觉得麻烦,但真出问题的时候,你会发现它帮你挡掉了多少低级错误。我个人建议,新项目从一开始就加上类型注解,老项目可以逐步迁移,先从核心模块开始。
公众号:蓝海资料掘金营,微信deep3321