技术 Python

Python运行时的身份危机:一份靠谱的类型自查指南

有没有觉得被Python的类型提示‘背叛’过?本篇和你聊聊如何在Pydantic泛型和typing各种花式类型中,破案出真正的运行时类型。亲测好用,值得收藏!

谎言从“类型安全”开始

曾经我自信满满地认为,自己的代码是“钢铁防线”。mypy全绿,IDE小绿勾,Pydantic模型类型注解清清楚楚。我一边写一边默念:Message[int],妥妥的,肯定就是装int的嘛!类型都写在那儿了,还能错?

直到有一天,我在运行时问模型:你到底装的是什么类型?Python回了我一个迷之微笑——它表示“我也不知道”。

这才明白,原来有两个世界:静态类型检查时的秩序井然,和运行时的混沌江湖。类型检查器手里的地图,代码跑起来早就扔一边了。想知道“你到底是谁”,得靠我们自己当侦探,手绘新地图!

两个世界:蓝图vs.现实

类型提示,其实就像盖房子的蓝图。list[int]这张图纸写得明明白白:“这里要装整数”。mypy和IDE像验房师,发现你往里塞个str立马报警。

可代码真正跑起来的时候,Python人家可不管你图纸咋画的。它就站在房子里,看到的只是个list。至于[int]?早丢到角落吃灰去了。

所以,我们这些开发者,得在“房子”里找线索,倒推出原来设计要装什么。这,就是运行时侦探的由来。

第一招:直接问本人——type()

最直接的办法,当然是问“你是谁”。Python的type()函数就是直男型选手,谁用谁知道。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# 简单例子:5到底是啥?
print(type(5))  # 输出:<class 'int'>

# Pydantic场景下
from pydantic import BaseModel

class User(BaseModel):
    name: str
    age: int

user = User(name="Alice", age=30)
print(type(user.age))  # 输出:<class 'int'>

有了值,type(值)永远不会忽悠你 —— 这就是铁证如山的事实。

可问题来了:有时候你还没值,比如你要写个函数,参数可能是个空list,你想知道“应该”装啥类型。这时光靠type()就不灵了,得深挖。

第二招:翻翻typing小抄——get_originget_args

好消息是,Python有时候没把蓝图全扔。有些typing模块的类型会留点“小抄”,可以用get_originget_args来偷看。

  • get_origin(some_type):问“你这容器本体是啥?”比如listdict
  • get_args(some_type):问“你里面藏的都是什么类型?”比如intstr

来一波操作:

1
2
3
4
5
6
7
8
9
from typing import get_origin, get_args, Optional, Dict

int_list_type = list[int]
print(f"Origin: {get_origin(int_list_type)}")  # --> <class 'list'>
print(f"Args: {get_args(int_list_type)}")      # --> (<class 'int'>,)

user_data_type = Dict[str, Optional[int]]
print(f"Origin: {get_origin(user_data_type)}") # --> <class 'dict'>
print(f"Args: {get_args(user_data_type)}")     # --> (<class 'str'>, typing.Optional[int])

是不是感觉掌握了黑科技?但很快你会遇到“陷阱”。

**陷阱来了:**换成普通类试试:

1
2
3
4
5
class Message:
    ...

print(get_origin(Message)) # --> None
print(get_args(Message))   # --> ()

啥也没有!原来这套工具只认typing家族的“特殊类”,普通类一脸懵。而Pydantic的泛型,比如Message[int],实际跟普通类更亲——这就让人头大了。

终极Boss:Pydantic v2的“易容术”

你写MyGenericModel[int]时,Pydantic不是简单存个int,而是“现场”生成了一个新类,这个类专门为int量身定制。

很酷,但这下get_originget_args彻底歇菜。你拿到的是个真·类,不是typing注解。我曾经折腾了半天,差点怀疑人生:为什么就是扒不出Message[int]里的int?

其实Pydantic悄悄给我们留了线索,关键就看你会不会找:

  1. 神秘的私房小字条: 类属性__pydantic_generic_metadata__,这里面明明白白写着这个泛型到底“特化”成什么了。
  2. 公开的字段注解: Pydantic会把字段的annotation同步成特化类型,比如Message[int]里的content字段,注解就直接变成了int。

大侦探三件套:万用型类型自查方案

怎么把这些线索串成一条龙服务?我们写个小方法,优雅地一层层查找(从最精确到最笼统)。

首先,来个类型美化小助手,让类型名看起来顺眼点:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
from typing import Any, get_origin, get_args

def pretty_type_name(tp: Any) -> str:
    """把类型名变得人见人爱"""
    if hasattr(tp, "__name__"):
        return tp.__name__
    origin = get_origin(tp)
    if origin:
        inner = ", ".join(pretty_type_name(a) for a in get_args(tp))
        base = getattr(origin, "__name__", str(origin))
        return f"{base}[{inner}]"
    return str(tp)

然后,在我们的泛型Pydantic模型里,加上大侦探方法。用@computed_field,让外部一看就明白:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
from typing import Generic, TypeVar
from pydantic import BaseModel, computed_field

T = TypeVar("T")

class Message(BaseModel, Generic[T]):
    content: T

    @computed_field
    @property
    def param_type(self) -> str:
        """
        设计时类型:泛型到底被特化成了啥?
        """
        # 1. 优先查Pydantic的私房字条
        meta = getattr(self.__class__, "__pydantic_generic_metadata__", None)
        if meta and meta.get("args"):
            return pretty_type_name(meta["args"][0])

        # 2. 没有的话,看字段注解
        field_annotation = self.__class__.model_fields["content"].annotation
        if field_annotation is not T:
            return pretty_type_name(field_annotation)

        # 3. 实在查不到,只能认栽,看实际存的值
        return self.runtime_type

    @computed_field
    @property
    def runtime_type(self) -> str:
        """运行时类型:现在content里实际是什么?"""
        return pretty_type_name(type(self.content))

实战一下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# 先来个int专用版
IntMessage = Message[int]
msg1 = IntMessage(content=123)
print(f"Param Type: {msg1.param_type}")       # -> "int"
print(f"Runtime Type: {msg1.runtime_type}")   # -> "int"

# 来个啥都能装的Message
msg2 = Message(content="hello")
print(f"Param Type: {msg2.param_type}")       # -> "str"(查不到设计时类型,退回运行时)
print(f"Runtime Type: {msg2.runtime_type}")   # -> "str"

完美!这套三层方案,优先用最可靠的设计时信息,实在没法查就认准运行时的铁证,灵活又扎实。

番外篇:Forward Ref和循环引用的生存法则

有时候你得定义互相引用的模型,比如ORM或者复杂API schema。

1
2
3
4
5
class A(BaseModel):
    b: 'B'  # B还没定义,只能先写个字符串

class B(BaseModel):
    a: 'A'

这就像“薛定谔的类型”:A要知道B,B又要知道A。字符串‘B’其实就是个IOU(暂欠条),但等你真正要用的时候,Python得知道去哪兑现。

如果你的模型定义在函数内部,这些名字只在本地作用域里有。换个地方找,Python就懵了。

解决办法就是:把本地命名空间(就是locals字典)传给类型解析函数,当地图用。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
from typing import get_type_hints

def create_circular_models():
    class A(BaseModel):
        b: 'B'
    
    class B(BaseModel):
        a: A

    local_namespace = locals()
    hints_A = get_type_hints(A, localns=local_namespace)
    print(hints_A['b'])  # --> <class '__main__.create_circular_models.<locals>.B'>

create_circular_models()

如果模型都写在模块顶层,其实Python全局作用域就够用。但只要你进函数里折腾,这招localns救你狗命。

最后的心法

别再问“为啥Python不给我类型?”而要换个思路:

你到底在查啥?(蓝图、类、还是实际值?)你有没有带对地图(作用域)?

明白了这个,类型自省就像侦探破案一样,小心排查,步步为营。Pydantic泛型再也不是黑盒,而是可控可查的得力工具!

(由人类创作,AI润色助力。)