Python类型提示完全指南:从入门到工程实践

0 阅读4分钟

摘要:Python 3.5引入类型提示以来,类型系统已经非常成熟。本文从基础语法到高级用法,再到工程实践中的mypy配置和常见模式,帮你写出更健壮的Python代码。

为什么要用类型提示?

# 没有类型提示:猜参数类型全靠文档和运气
def process_data(data, config, callback):
    ...

# 有类型提示:一目了然
def process_data(
    data: list[dict[str, Any]],
    config: ProcessConfig,
    callback: Callable[[str], None] | None = None,
) -> ProcessResult:
    ...

好处:IDE自动补全更准、重构更安全、文档自带、bug更早发现。

基础类型

# Python 3.10+ 可以直接用内置类型
name: str = '张三'
age: int = 25
score: float = 95.5
active: bool = True

# 容器类型
names: list[str] = ['张三', '李四']
scores: dict[str, int] = {'张三': 95, '李四': 88}
unique_ids: set[int] = {1, 2, 3}
point: tuple[float, float] = (1.0, 2.0)

# 可变长度元组
values: tuple[int, ...] = (1, 2, 3, 4, 5)

# None类型
result: str | None = None  # Python 3.10+
# 等价于:Optional[str] = None

函数签名

def greet(name: str, times: int = 1) -> str:
    return f'Hello {name}! ' * times

# 无返回值
def log(message: str) -> None:
    print(message)

# *args 和 **kwargs
def flexible(*args: int, **kwargs: str) -> None:
    ...

# 默认值
def connect(
    host: str = 'localhost',
    port: int = 3306,
    timeout: float | None = None,
) -> Connection:
    ...

高级类型

TypeAlias(类型别名)

from typing import TypeAlias

# 简单别名
UserId: TypeAlias = int
JsonDict: TypeAlias = dict[str, Any]

# 复杂类型简化
Handler: TypeAlias = Callable[[Request], Awaitable[Response]]
Middleware: TypeAlias = Callable[[Handler], Handler]

def add_middleware(app: list[Middleware], mw: Middleware) -> None:
    app.append(mw)

Literal(字面量类型)

from typing import Literal

def set_log_level(level: Literal['DEBUG', 'INFO', 'WARNING', 'ERROR']) -> None:
    ...

set_log_level('INFO')     # ✅
set_log_level('VERBOSE')  # ❌ mypy报错

# 实用场景:限制参数取值
def sort_users(
    by: Literal['name', 'age', 'created_at'] = 'created_at',
    order: Literal['asc', 'desc'] = 'desc',
) -> list[User]:
    ...

TypedDict(字典结构约束)

from typing import TypedDict, NotRequired

class UserDict(TypedDict):
    id: int
    name: str
    email: str
    age: NotRequired[int]  # 可选字段

def create_user(data: UserDict) -> None:
    print(data['name'])   # ✅ IDE知道有name字段
    print(data['phone'])  # ❌ mypy报错:没有phone字段

user: UserDict = {'id': 1, 'name': '张三', 'email': 'z@test.com'}

Protocol(结构化子类型)

from typing import Protocol, runtime_checkable

@runtime_checkable
class Drawable(Protocol):
    def draw(self, x: int, y: int) -> None: ...

class Circle:
    def draw(self, x: int, y: int) -> None:
        print(f'Drawing circle at ({x}, {y})')

class Square:
    def draw(self, x: int, y: int) -> None:
        print(f'Drawing square at ({x}, {y})')

# Circle和Square都满足Drawable协议,不需要显式继承
def render(shape: Drawable) -> None:
    shape.draw(0, 0)

render(Circle())  # ✅
render(Square())  # ✅

# runtime_checkable允许isinstance检查
isinstance(Circle(), Drawable)  # True

Generic(泛型)

from typing import Generic, TypeVar

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()
    
    def peek(self) -> T:
        return self._items[-1]

# 使用时指定类型
int_stack: Stack[int] = Stack()
int_stack.push(1)      # ✅
int_stack.push('abc')  # ❌ mypy报错

str_stack: Stack[str] = Stack()
str_stack.push('hello')  # ✅

Overload(函数重载)

from typing import overload

@overload
def parse(data: str) -> dict: ...
@overload
def parse(data: bytes) -> dict: ...
@overload
def parse(data: str, as_list: Literal[True]) -> list: ...

def parse(data: str | bytes, as_list: bool = False) -> dict | list:
    if isinstance(data, bytes):
        data = data.decode()
    result = json.loads(data)
    if as_list and isinstance(result, dict):
        return list(result.items())
    return result

# mypy能根据参数类型推断返回类型
d = parse('{"a": 1}')        # 推断为dict
l = parse('{"a": 1}', True)  # 推断为list

实战模式

配置类

from dataclasses import dataclass, field

@dataclass
class DatabaseConfig:
    host: str = 'localhost'
    port: int = 3306
    user: str = 'root'
    password: str = ''
    database: str = 'mydb'
    pool_size: int = 5
    
    @property
    def url(self) -> str:
        return f'mysql://{self.user}:{self.password}@{self.host}:{self.port}/{self.database}'

@dataclass
class AppConfig:
    debug: bool = False
    db: DatabaseConfig = field(default_factory=DatabaseConfig)
    allowed_origins: list[str] = field(default_factory=lambda: ['*'])

回调和事件系统

from typing import Callable, TypeVar, ParamSpec

P = ParamSpec('P')
R = TypeVar('R')

class EventEmitter:
    def __init__(self) -> None:
        self._handlers: dict[str, list[Callable[..., None]]] = {}
    
    def on(self, event: str, handler: Callable[..., None]) -> None:
        self._handlers.setdefault(event, []).append(handler)
    
    def emit(self, event: str, *args: Any, **kwargs: Any) -> None:
        for handler in self._handlers.get(event, []):
            handler(*args, **kwargs)

# 类型安全的事件定义
class UserEvents(EventEmitter):
    def on_login(self, handler: Callable[[int, str], None]) -> None:
        self.on('login', handler)
    
    def emit_login(self, user_id: int, ip: str) -> None:
        self.emit('login', user_id, ip)

mypy配置

pyproject.toml

[tool.mypy]
python_version = "3.12"
strict = true
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true

# 第三方库忽略
[[tool.mypy.overrides]]
module = ["requests.*", "bs4.*", "lxml.*"]
ignore_missing_imports = true

常用命令:

# 检查整个项目
mypy src/

# 检查单个文件
mypy app/main.py

# 生成报告
mypy src/ --html-report reports/mypy

渐进式迁移策略

老项目不需要一步到位:

# 第一步:函数签名加类型(收益最大)
def get_user(user_id: int) -> dict[str, Any]:
    ...

# 第二步:关键数据结构加类型
class User(TypedDict):
    id: int
    name: str

# 第三步:逐步开启strict模式
# mypy.ini中按模块配置严格程度

总结

类型提示的投入产出比非常高:

  • 写的时候多花10%时间
  • 省下50%的调试时间和90%的"这个参数是什么类型"的困惑

建议:新代码全部加类型提示,老代码在修改时逐步补上。配合mypy做CI检查,类型错误在提交前就能发现。