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或NoneOptional[str]
Union[T1, T2]可以是T1或T2Union[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 对比

特性ProtocolABC
是否需要显式继承不需要(结构子类型)需要(名义子类型)
运行时检查不支持(仅静态检查)支持(isinstance检查)
灵活性高(任何符合结构的类型)低(必须继承)
适用场景接口解耦、第三方库适配需要运行时多态

29.4 知识体系总览

下面这张图,是我梳理的本章知识脉络。你可以把它当作一个「类型注解地图」——从基础类型到泛型,再到Protocol,层层递进。

面向对象与类型注解知识体系 typing模块 基础类型注解工具 泛型 Generics 类型参数化 Protocol 结构化子类型 基础 → 进阶 进阶 → 设计 List / Dict / Optional Union / Tuple / Any TypeVar 类型变量 Generic[T] 泛型类 泛型函数 定义行为协议 结构子类型 鸭子类型正式化 核心思想:用类型注解表达意图,用泛型提升复用,用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