Python的类型系统在过去几个版本中变化很快,但泛型的声明方式一直不够简洁。定义一个泛型类需要先用TypeVar声明类型变量,然后再在类名后使用,写起来啰嗦,读起来也要跳来跳去。Python 3.12实现的PEP 695引入了一种全新的语法:type参数块。现在可以直接在类或函数定义里用类似类型变量声明的方式写泛型,代码量和清晰度都有了明显改进。
这篇文章会先快速对比新旧写法,然后通过一个实际的数据ETL管道案例——从JSON响应中抽取数据、校验、转换并存入一个泛型仓库——把type参数的用法完整串起来。文中所有代码都可以直接在Python 3.12以上运行。
一、旧写法与新写法的直观对比
在Python 3.12之前,定义一个泛型栈需要这样写:
from typing import TypeVar, Generic
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 | None:
return self.items.pop() if self.items else None
现在用PEP 695的语法:
class Stack[T]:
def __init__(self) -> None:
self.items: list[T] = []
def push(self, item: T) -> None:
self.items.append(item)
def pop(self) -> T | None:
return self.items.pop() if self.items else None
类型变量直接被放在类名后面的方括号里,并且可以直接在多个地方复用。同样的简化也适用于函数:
# 旧写法
from typing import TypeVar
T = TypeVar('T')
def first(items: list[T]) -> T | None: ...
# 新写法
def first[T](items: list[T]) -> T | None: ...
简单来说,TypeVar和Generic那一套仍然可用,但新语法更贴近TypeScript和许多现代语言的习惯,阅读和书写都少了跳跃感。
二、实战:构建一个类型安全的ETL管道
假设我们要从某个订购系统API拉取订单数据,格式如下:
{
"order_id": "ord-1234",
"customer": "张三",
"items": [
{"sku": "SKU-001", "quantity": 2, "price": 99.99},
{"sku": "SKU-002", "quantity": 1, "price": 199.99}
],
"status": "confirmed",
"created_at": "2025-01-15T10:30:00"
}
我们的任务分成三步:抽取、转换、加载。抽取阶段拿到原始字典,转换阶段将它变成明确的业务对象,加载阶段把对象存入一个泛型仓库(可能是内存、数据库或文件)。要求整个过程有完善的类型约束,让错误在写代码阶段就被捕获,而不是等到运行。
2.1 定义数据模型(TypedDict + 业务类)
先给原始JSON定义明确的字典结构:
from typing import TypedDict, Literal
class OrderItemDict(TypedDict):
sku: str
quantity: int
price: float
class OrderDict(TypedDict):
order_id: str
customer: str
items: list[OrderItemDict]
status: Literal['confirmed', 'shipped', 'cancelled']
created_at: str
再定义一个业务对象,它负责内部流转,不直接暴露字典:
from datetime import datetime
class OrderItem:
def __init__(self, sku: str, quantity: int, price: float) -> None:
self.sku = sku
self.quantity = quantity
self.price = price
class Order:
def __init__(
self,
order_id: str,
customer: str,
items: list[OrderItem],
status: str,
created_at: datetime
) -> None:
self.order_id = order_id
self.customer = customer
self.items = items
self.status = status
self.created_at = created_at
2.2 抽取器(Extractor)
抽取器负责接收一个原始字典并返回OrderDict,这里不做数据清理,只做基本的存在性检查:
class Extractor:
def extract(self, raw: dict) -> OrderDict:
# 这里可以加入键存在检查,但TypeDict本身只是提示,运行时仍需手动校验关键字段
if 'order_id' not in raw:
raise ValueError("缺少 order_id")
return raw # type: ignore[return-value] # 实际项目中会显式构造或使用校验库
实际项目中通常会搭配pydantic这样的校验库,但为了聚焦泛型,我们假设外部调用已经保证了数据形状。
三、用泛型约束转换器和加载器
转换和加载是两个可以通用的步骤:任何ETL都可能需要把原始记录转换成业务对象,然后存入某种存储。我们设计一个泛型接口来描述这两种操作。
3.1 定义泛型转换器接口
使用新的type参数语法定义一个协议,它能够将一种类型的数据转换成另一种:
from typing import Protocol
class Transformer[From, To](Protocol):
def transform(self, source: From) -> To:
...
注意这里直接在class后使用[From, To],这两个类型参数在后面的方法签名中可以直接引用。不再需要提前写From = TypeVar('From')。
用这个协议实现订单转换器:
class OrderTransformer:
def transform(self, source: OrderDict) -> Order:
items = [
OrderItem(sku=it['sku'], quantity=it['quantity'], price=it['price'])
for it in source['items']
]
return Order(
order_id=source['order_id'],
customer=source['customer'],
items=items,
status=source['status'],
created_at=datetime.fromisoformat(source['created_at'])
)
3.2 定义泛型仓库接口
class Repository[T](Protocol):
def save(self, entity: T) -> None:
...
def get_all(self) -> list[T]:
...
然后提供一个内存实现用于测试:
class InMemoryRepository[T]:
def __init__(self) -> None:
self.entities: list[T] = []
def save(self, entity: T) -> None:
self.entities.append(entity)
def get_all(self) -> list[T]:
return self.entities
3.3 组合管道
ETL管道本身也可以泛型化,明确描述输入、中间态和输出:
class ETLPipeline[Raw, Business]:
def __init__(
self,
extractor: Extractor,
transformer: Transformer[Raw, Business],
repository: Repository[Business]
) -> None:
self.extractor = extractor
self.transformer = transformer
self.repository = repository
def run(self, raw: dict) -> Business:
extracted = self.extractor.extract(raw) # type: Raw
business_obj = self.transformer.transform(extracted) # type: Business
self.repository.save(business_obj)
return business_obj
使用时,一切类型都自然推导:
extractor = Extractor()
transformer = OrderTransformer()
repo: InMemoryRepository[Order] = InMemoryRepository[Order]()
pipeline = ETLPipeline[OrderDict, Order](extractor, transformer, repo)
raw = {
"order_id": "ord-9999",
"customer": "李四",
"items": [{"sku": "SKU-003", "quantity": 3, "price": 49.99}],
"status": "confirmed",
"created_at": "2025-01-20T14:00:00"
}
order = pipeline.run(raw)
print(order.customer) # 李四
print(repo.get_all()) # 列表中有一个Order对象
如果我们在实例化pipeline时误传了类型参数,比如将repository的类型写成InMemoryRepository[str],但transformer返回的是Order,则mypy会立刻报类型不匹配。这个检查在编码阶段发生,不会拖到运行时。
四、类型参数的高级约束:边界与别名
新的type语法同样支持给类型参数加约束。比如,我们要求仓库的实体必须有一个id属性,可以用Protocol约束:
class HasId(Protocol):
id: str
class TypedRepository[T: HasId](Protocol):
def find_by_id(self, entity_id: str) -> T | None:
...
def save(self, entity: T) -> None:
...
这里T: HasId表示T必须满足HasId协议。用法和以前TypeVar('T', bound=HasId)一样,但写起来更紧凑。
也可以使用类型别名配合type参数:
type OrderRepo = InMemoryRepository[Order] # 直接起别名,便于后续引用
五、新语法的限制和现状
- 仅在Python 3.12+可用:如果需要向下兼容,仍然必须用
TypeVar。 - 类型清除:和所有typing特性一样,这些类型参数在运行时被擦除,不能用于isinstance检查。
- 工具支持:mypy 1.6+、PyCharm 2023.3+、pyright已经完整支持新语法,开发体验很好。
- 不能和旧的TypeVar混用在一个定义里:要么都写进方括号,要么都用TypeVar,语法不混合。
对于新启动的项目或者已经运行在3.12上的代码库,直接使用新语法可以减少样板代码,让类型标注的意图更直接。
六、总结
PEP 695带来的type参数块是Python泛型书写的一次重要进化。它没有新增什么强大的能力,但把原本分散在TypeVar和Generic上的泛型声明整合到了同一个地方,减少了定义量,也让类型参数的可见范围更清晰。上面这个ETL管道案例从抽取、转换到存储,全程可以用强类型约束起来,配合静态检查能在早期消灭大部分数据形状错误。
如果你的项目里有很多泛型类或者泛型函数,升级到Python 3.12后,不妨花一两个小时把TypeVar那段重复代码换成新写法,你会觉得清爽很多。

