深入了解Python中类型检查的终极指南
作者:SunnyRivers
在本指南中,你将深入了解 Python 的类型检查机制。传统上,Python 解释器以一种灵活但隐式的方式来处理类型。而近年来的 Python 版本允许你显式地添加类型提示(type hints),这些提示可以被各种工具利用,从而帮助你更高效地开发代码。
在本教程中,你将学习以下内容:
- 类型注解(Type annotations)与类型提示(type hints)
- 如何为代码(包括你自己的代码和他人的代码)添加静态类型
- 如何运行静态类型检查器
- 如何在运行时强制执行类型约束
这是一份内容详尽的指南,涵盖范围较广。如果你只是想快速了解 Python 中类型提示的基本用法,并判断类型检查是否值得引入到你的项目中,那么你并不需要通读全文。其中“初识类型(Hello Types)”和“优缺点分析(Pros and Cons)”这两个章节,就能让你初步体会到类型检查的工作方式,并为你提供何时使用它更为合适的建议。
类型系统(Type Systems)
所有编程语言都包含某种形式的类型系统,用于形式化地规定该语言可以处理哪些类别的对象,以及如何对待这些类别。例如,一个类型系统可以定义“数值类型”,而数字 42 就是数值类型对象的一个具体实例。
动态类型(Dynamic Typing)
Python 是一门动态类型语言。这意味着 Python 解释器仅在代码运行时才进行类型检查,并且变量的类型在其生命周期内是可以改变的。以下两个简单示例展示了 Python 的动态类型特性:
>>> if False: ... 1 + "two" # 这一行永远不会执行,因此不会抛出 TypeError ... else: ... 1 + 2 ... 3 >>> 1 + "two" # 现在这行会被执行并进行类型检查,于是抛出 TypeError TypeError: unsupported operand type(s) for +: 'int' and 'str'
在第一个例子中,分支 1 + “two” 永远不会被执行,因此也永远不会被类型检查。第二个例子则表明,当表达式 1 + “two” 被求值时,会抛出 TypeError,因为在 Python 中不能将整数和字符串相加。
接下来,我们看看变量是否可以改变其类型:
>>> thing = "Hello" >>> type(thing) <class 'str'> >>> thing = 28.1 >>> type(thing) <class 'float'>
type() 函数返回一个对象的类型。上述例子清楚地表明,变量 thing 的类型是可以改变的,而且 Python 能够在类型变化时正确推断出当前的类型。
静态类型(Static Typing)
与动态类型相对的是静态类型。静态类型检查是在程序运行之前进行的,通常在编译阶段完成。在大多数静态类型语言(如 C 和 Java)中,类型检查正是在编译过程中完成的。
在静态类型语言中,变量通常不允许在后续更改其类型,尽管某些语言可能提供类型转换(casting)机制,允许将变量显式转换为其他类型。
让我们看一个静态类型语言的简短示例。以下是 Java 中的一段代码:
String thing; thing = "Hello";
第一行声明了变量名 thing 在编译时就被绑定到 String 类型。此后,该名称不能再被重新绑定到其他类型。第二行给 thing 赋了一个值,这个值必须是一个 String 对象。例如,如果你之后写 thing = 28.1f,编译器就会报错,因为浮点数与 String 类型不兼容。
Python 始终会是一门动态类型语言。不过,PEP 484 引入了类型提示(type hints),使得对 Python 代码进行静态类型检查成为可能。
需要特别注意的是:与大多数静态类型语言不同,Python 中的类型提示本身不会让解释器强制执行类型约束。正如其名所示,类型提示只是“提示”——它们并不改变 Python 的运行时行为。真正执行静态类型检查的是其他工具(稍后你会看到),这些工具会利用类型提示来分析代码。
鸭子类型(Duck Typing)
在讨论 Python 时,另一个经常出现的概念是“鸭子类型”。这一说法源自一句谚语:“如果它走起来像鸭子,叫起来也像鸭子,那它就是一只鸭子”(或其各种变体)。
鸭子类型是一种与动态类型相关的理念:对象的具体类型或所属类并不重要,重要的是它提供了哪些方法。使用鸭子类型时,你完全不需要检查类型,而是检查对象是否具有某个特定的方法或属性。
举个例子,你可以在任何定义了 .len() 方法的 Python 对象上调用 len():
>>> class TheHobbit: ... def __len__(self): ... return 95022 ... >>> the_hobbit = TheHobbit() >>> len(the_hobbit) 95022
注意,对 len() 的调用实际上返回的是 .len() 方法的返回值。事实上,len() 的内部实现本质上等价于:
def len(obj):
return obj.__len__()
因此,要调用 len(obj),对 obj 唯一真正的约束就是它必须定义了 .len() 方法。除此之外,obj 可以是任意类型——无论是 str、list、dict,还是我们自定义的 TheHobbit 类。
在对 Python 代码进行静态类型检查时,鸭子类型可以通过结构化子类型(structural subtyping) 得到一定程度的支持。关于鸭子类型的更多内容,我们将在后文进一步探讨。
初识类型(Hello Types)
在本节中,你将学习如何为函数添加类型提示。下面这个函数的作用是将一段文本字符串转换成标题形式:它会正确地进行首字母大写,并添加一条装饰性的分隔线:
def headline(text, align=True):
if align:
return f"{text.title()}\n{'-' * len(text)}"
else:
return f" {text.title()} ".center(50, "o")
默认情况下,该函数返回一个左对齐的标题,并在其下方加上一条由连字符组成的下划线。如果将 align 参数设为 False,则标题会被居中显示,并用字母 o 构成上下包围的装饰线:
>>> print(headline("python type checking"))
Python Type Checking
--------------------
>>> print(headline("python type checking", align=False))
oooooooooooooo Python Type Checking oooooooooooooo
现在,是时候添加我们的第一个类型提示了!要为函数补充类型信息,只需对其参数和返回值进行注解即可:
def headline(text: str, align: bool = True) -> str:
...
这里的 text: str 表示参数 text 应该是 str 类型;同样,可选参数 align 应为 bool 类型,默认值为 True;而 -> str 则说明 headline() 函数将返回一个字符串。
关于代码风格,PEP 8 建议如下:
- 冒号前后遵循常规规则:冒号前无空格,冒号后有一个空格,例如 text: str。
- 当参数注解与默认值同时出现时,在等号 = 两侧加空格:align: bool = True。
- 在返回类型箭头 -> 两侧也应有空格:def headline(…) -> str。
需要强调的是,像这样添加类型提示不会对程序运行时产生任何影响——它们仅仅是提示,Python 解释器本身并不会强制执行这些类型约束。例如,如果我们给(命名不太恰当的)align 参数传入一个错误的类型,代码依然能正常运行,没有任何报错或警告:
>>> print(headline("python type checking", align="left"))
Python Type Checking
--------------------
注意:这段代码看似“有效”,是因为字符串 “left” 在布尔上下文中被视为真值(truthy)。但如果你传入 align=“center”,虽然 “center” 同样是真值,却无法实现你预期的居中效果,反而会造成逻辑混淆。
要捕获这类错误,就需要使用静态类型检查器——即一种在不实际运行代码的情况下分析代码类型是否正确的工具。
你可能已经在编辑器中内置了这样的检查器。例如,PyCharm 会立即给出警告:
Expected type 'bool', got 'str' instead
不过,最常用的类型检查工具是 mypy。稍后你会看到 mypy 的简要介绍,后续章节还会深入讲解其工作原理。
如果你系统中尚未安装 mypy,可以通过 pip 安装:
pip install mypy
接下来,将以下代码保存到名为 headlines.py 的文件中:
# headlines.py
def headline(text: str, align: bool = True) -> str:
if align:
return f"{text.title()}\n{'-' * len(text)}"
else:
return f" {text.title()} ".center(50, "o")
print(headline("python type checking"))
print(headline("use mypy", align="center"))
这基本上就是前面展示过的代码:包含 headline() 的定义以及两次调用示例。
现在,用 mypy 检查这段代码:
$ mypy headlines.py
headlines.py:10: error: Argument "align" to "headline" has incompatible
type "str"; expected "bool"
根据类型提示,mypy 明确指出:第 10 行传给 headline 的 align 参数类型不匹配——你传入了 str,但函数期望的是 bool。
要修复这个问题,你需要修改传入 align 的值。此外,也可以将参数名 align 改为更清晰、不易误解的名字,比如 centered:
# headlines.py
def headline(text: str, centered: bool = False) -> str:
if not centered:
return f"{text.title()}\n{'-' * len(text)}"
else:
return f" {text.title()} ".center(50, "o")
print(headline("python type checking"))
print(headline("use mypy", centered=True))
这里我们将 align 改为 centered,并在调用时正确传入布尔值 True。现在再运行 mypy:
$ mypy headlines.py
Success: no issues found in 1 source file
这条成功消息表明:mypy 未检测到任何类型错误。(旧版本的 mypy 在无错误时会直接静默输出,不显示任何内容。)
最后,运行程序本身,你会看到预期的输出:
$ python headlines.py
Python Type Checking
--------------------
oooooooooooooooooooo Use Mypy oooooooooooooooooooo
第一个标题左对齐,第二个标题居中显示——一切如预期般工作。
优缺点分析(Pros and Cons)
上一节让你初步体验了 Python 中类型检查的实际效果。你也看到了为代码添加类型的一个明显优势:类型提示有助于捕获某些错误。除此之外,还有其他几项重要优点:
- 类型提示有助于文档化你的代码:传统上,如果你希望说明函数参数的预期类型,通常会使用文档字符串(docstrings)。虽然这可行,但由于 PEP 257 并未为 docstring 中的类型描述建立统一标准,这类信息难以被工具自动解析和用于静态检查。
- 类型提示能显著提升 IDE 和代码检查工具(linter)的能力:它们让工具更容易对代码进行静态推理。例如,有了类型注解后,PyCharm 就知道 text 是一个字符串,从而提供更精准的代码补全建议.
- 类型提示有助于构建和维护更清晰的代码架构:编写类型提示的过程会促使你主动思考程序中各部分的数据类型。尽管 Python 的动态特性是其强大之处,但有意识地审视自己是否过度依赖鸭子类型、方法重载或多类型返回值,是一种良好的工程习惯。
当然,静态类型检查并非完美无缺,也存在一些需要权衡的缺点:
- 添加类型提示需要额外的开发时间和精力:虽然长远来看可能减少调试时间,但在编写代码时确实会增加输入成本。
- 类型提示在较新的 Python 版本中效果最佳:类型注解最早在 Python 3.0 引入,Python 2.7 可通过类型注释(type comments)实现有限支持。但像变量注解(variable annotations)和类型提示的延迟求值(postponed evaluation)等关键改进,直到 Python 3.6 甚至 3.7 才真正成熟。因此,在旧版本中使用类型提示体验会打折扣。
- 类型提示会带来轻微的启动性能开销:如果你在代码中导入了 typing 模块,其导入时间可能较为显著——尤其在短小脚本中更为明显。
那么,你是否应该在自己的项目中使用静态类型检查呢?其实,这并不是一个“全有或全无”的选择。幸运的是,Python 支持渐进式类型(gradual typing) 的理念:你可以逐步为代码添加类型提示。静态类型检查器会忽略没有类型注解的部分,因此你可以先从关键模块开始引入类型,只要它对你有价值,就继续推进。
回顾上述优缺点列表,你会发现:添加类型提示对程序的运行行为和最终用户完全没有任何影响。类型检查的唯一目的,就是让你作为开发者的工作更轻松、更高效。
以下是一些实用的经验法则,帮助你判断是否应在项目中使用类型提示:
- 如果你刚开始学习 Python,完全可以暂缓使用类型提示,等积累更多经验后再考虑。
- 对于一次性使用的短脚本,类型提示带来的价值非常有限。
- 对于会被他人使用的库(尤其是发布到 PyPI 的库),类型提示极具价值。其他项目在使用你的库时,依赖这些类型提示才能进行完整的类型检查。已采用类型提示的知名项目包括 cursive_re、black、Real Python 自家的 Reader 应用,以及 mypy 本身。
- 在大型项目中,类型提示能帮助你理清类型在代码中的流动逻辑,强烈推荐使用;如果项目涉及多人协作,其价值则更加突出。
正如 Bernát Gábor 在其精彩文章《The State of Type Hints in Python》中所建议的:“只要值得编写单元测试的地方,就应该使用类型提示。” 事实上,类型提示在代码中扮演的角色与测试类似——它们都是帮助你写出更健壮、更可维护代码的辅助手段。
希望你现在对 Python 中的类型检查机制有了清晰的理解,并能判断它是否适合你的项目。
注解(Annotations)
注解(Annotations)最早在 Python 3.0 中引入,最初并没有特定用途,仅仅是一种将任意表达式与函数参数和返回值关联起来的机制。
多年后,PEP 484 基于 Jukka Lehtosalo 在其博士项目(即 mypy)中的工作,正式定义了如何在 Python 代码中添加类型提示(type hints)。而实现类型提示的主要方式,正是通过注解。随着类型检查日益普及,注解如今也应主要保留用于类型提示这一目的。
接下来的几节将详细解释在类型提示上下文中,注解是如何工作的。
函数注解(Function Annotations)
对于函数,你可以为参数和返回值添加注解。语法如下:
def func(arg: arg_type, optarg: arg_type = default) -> return_type:
...
- 参数注解使用 参数名: 注解 的形式;
- 返回值注解则使用 -> 注解 的形式。
需要注意的是,注解必须是合法的 Python 表达式。
下面是一个简单示例,为计算圆周长的函数添加了类型注解:
import math
def circumference(radius: float) -> float:
return 2 * math.pi * radius
运行代码时,你还可以查看这些注解。它们被存储在函数的一个特殊属性 annotations 中:
>>> circumference(1.23)
7.728317927830891
>>> circumference.__annotations__
{'radius': <class 'float'>, 'return': <class 'float'>}
有时你可能会疑惑 mypy 是如何理解你的类型提示的。为此,mypy 提供了两个特殊的调试工具:reveal_type() 和 reveal_locals()。你可以在运行 mypy 前将它们插入代码中,mypy 会如实报告它所推断出的类型。
例如,将以下代码保存为 reveal.py:
# reveal.py import math reveal_type(math.pi) radius = 1 circumference = 2 * math.pi * radius reveal_locals()
然后用 mypy 运行它:
$ mypy reveal.py reveal.py:4: error: Revealed type is 'builtins.float' reveal.py:8: error: Revealed local types are: reveal.py:8: error: circumference: builtins.float reveal.py:8: error: radius: builtins.int
即使没有任何显式注解,mypy 也能正确推断出内置常量 math.pi 以及局部变量 radius 和 circumference 的类型。
变量注解(Variable Annotations)
在上一节的 circumference() 函数中,我们只注解了参数和返回值,并未在函数体内添加任何变量注解——这通常已经足够。
但有时,类型检查器也需要帮助来推断变量的类型。为此,PEP 526 在 Python 3.6 中引入了变量注解,其语法与函数参数注解一致:
pi: float = 3.142
def circumference(radius: float) -> float:
return 2 * pi * radius
这里,变量 pi 被注解为 float 类型。
注意:静态类型检查器完全可以从字面量 3.142 推断出它是 float,因此这个例子中的注解并非必需。随着你对 Python 类型系统了解加深,会遇到更多真正需要变量注解的场景。
变量注解会被存储在模块级别的 annotations 字典中:
>>> circumference(1)
6.284
>>> __annotations__
{'pi': <class 'float'>}
你甚至可以只声明注解而不赋值。此时,注解会被加入 annotations,但变量本身并未定义:
>>> nothing: str
>>> nothing
NameError: name 'nothing' is not defined
>>> __annotations__
{'nothing': <class 'str'>}
由于没有给 nothing 赋值,该名称在运行时并不存在。
类型注释(Type Comments)
如前所述,注解是在 Python 3 中引入的,并未向后兼容到 Python 2。因此,如果你的代码需要支持旧版 Python(如 2.7),就无法使用注解。
这时可以使用类型注释(type comments) ——一种特殊格式的注释,用于在旧代码中添加类型提示。
例如,为函数添加类型注释的方式如下:
import math
def circumference(radius):
# type: (float) -> float
return 2 * math.pi * radius
类型注释本质上只是普通注释,因此可在任何 Python 版本中使用。
但请注意:类型注释由类型检查器直接处理,不会出现在 annotations 字典中:
>>> circumference.__annotations__
{}
类型注释必须以 # type: 开头,并且必须位于函数定义的同一行或下一行。如果函数有多个参数,用逗号分隔各类型:
def headline(text, width=80, fill_char="-"):
# type: (str, int, str) -> str
return f" {text.title()} ".center(width, fill_char)
print(headline("type comments work", width=40))
你也可以为每个参数单独写一行注释:
# headlines.py
def headline(
text, # type: str
width=80, # type: int
fill_char="-", # type: str
): # type: (...) -> str
return f" {text.title()} ".center(width, fill_char)
print(headline("type comments work", width=40))
分别用 Python 和 mypy 运行:
$ python headlines.py ---------- Type Comments Work ---------- $ mypy headlines.py Success: no issues found in 1 source file
如果出现类型错误(例如第 10 行调用 headline() 时传入 width=“full”),mypy 会准确报错:
$ mypy headline.py
headline.py:10: error: Argument "width" to "headline" has incompatible
type "str"; expected "int"
变量也可以使用类型注释:
pi = 3.142 # type: float
这样,pi 就会被类型检查器视为 float 类型。
那么,该用注解还是类型注释
当你为自己的代码添加类型提示时,应该选择注解还是类型注释?
简短回答:能用注解就用注解,只有在必须兼容旧版本时才用类型注释。
- 注解语法更简洁,将类型信息紧贴代码,可读性更强。它们是官方推荐的类型提示方式,未来也会持续得到支持和改进。
- 类型注释则更冗长,还可能与代码中的其他注释(如 linter 指令)产生冲突。但它们适用于不支持注解的旧代码库。
玩转 Python 类型(第一部分)
到目前为止,你只在类型提示中使用了像 str、float 和 bool 这样的基本类型。但实际上,Python 的类型系统非常强大,支持许多更复杂的类型。这是必要的——因为 Python 本质上是动态的、基于鸭子类型的语言,类型系统必须能够合理地建模这种灵活性。
在本节中,你将通过实现一个简单的纸牌游戏,深入学习 Python 的类型系统。你会看到如何指定:
- 序列(如元组、列表)和映射(如字典)的类型
- 让代码更易读的类型别名(type aliases)
- 表示函数或方法不返回任何值的方式
- 表示对象可以是任意类型的机制
在简要探讨一些类型理论之后,你还会看到更多在 Python 中指定类型的方法。本节的代码示例可在此处找到。
示例:一副纸牌
下面的示例实现了一副扑克牌:
# game.py
import random
SUITS = "♠ ♡ ♢ ♣".split()
RANKS = "2 3 4 5 6 7 8 9 10 J Q K A".split()
def create_deck(shuffle=False):
"""创建一副新的 52 张牌"""
deck = [(s, r) for r in RANKS for s in SUITS]
if shuffle:
random.shuffle(deck)
return deck
def deal_hands(deck):
"""将牌平均发给四位玩家"""
return (deck[0::4], deck[1::4], deck[2::4], deck[3::4])
def play():
"""运行一个四人纸牌游戏"""
deck = create_deck(shuffle=True)
names = "P1 P2 P3 P4".split()
hands = {n: h for n, h in zip(names, deal_hands(deck))}
for name, cards in hands.items():
card_str = " ".join(f"{s}{r}" for (s, r) in cards)
print(f"{name}: {card_str}")
if __name__ == "__main__":
play()
- 每张牌用一个包含两个字符串的元组表示:(花色, 点数)。
- 整副牌是一个牌的列表。
- create_deck() 创建一副 52 张的标准牌,并可选择是否洗牌。
- deal_hands() 将牌平均分给四位玩家。
- play() 目前只是准备游戏:洗牌并分发,尚未实现具体玩法。
典型输出如下:
$ python game.py
P4: ♣9 ♢9 ♡2 ♢7 ♡7 ♣A ♠6 ♡K ♡5 ♢6 ♢3 ♣3 ♣Q
P1: ♡A ♠2 ♠10 ♢J ♣10 ♣4 ♠5 ♡Q ♢5 ♣6 ♠A ♣5 ♢4
P2: ♢2 ♠7 ♡8 ♢K ♠3 ♡3 ♣K ♠J ♢A ♣7 ♡6 ♡10 ♠K
P3: ♣2 ♣8 ♠8 ♣J ♢Q ♡9 ♡J ♠4 ♢8 ♢10 ♠9 ♡4 ♠Q
序列与映射(Sequences and Mappings)
现在,我们为纸牌游戏添加类型提示,即为 create_deck()、deal_hands() 和 play() 添加注解。
第一个挑战是:如何注解复合类型?比如表示整副牌的列表,以及表示单张牌的元组。
对于基本类型,注解很简单:
>>> name: str = "Guido" >>> pi: float = 3.142 >>> centered: bool = False
对复合类型,你也可以直接使用类型本身:
>>> names: list = ["Guido", "Jukka", "Ivan"]
>>> version: tuple = (3, 7, 1)
>>> options: dict = {"centered": False, "capitalize": True}
但这样写信息不足:我们无法知道 names[2] 是 str、version[0] 是 int、options[“centered”] 是 bool。这些信息对类型检查器来说是缺失的。
因此,应使用 typing 模块中定义的泛型类型:
>>> from typing import Dict, List, Tuple
>>> names: List[str] = ["Guido", "Jukka", "Ivan"]
>>> version: Tuple[int, int, int] = (3, 7, 1)
>>> options: Dict[str, bool] = {"centered": False, "capitalize": True}
注意:
- 这些类型首字母大写;
- 使用方括号指定元素类型。
具体含义:
- names 是一个字符串列表;
- version 是一个包含三个整数的三元组;
- options 是一个从字符串映射到布尔值的字典。
typing 模块还提供了更多复合类型,如 Counter、Deque、FrozenSet、NamedTuple、Set 等。
回到纸牌游戏:一张牌是 (str, str) 元组,整副牌是 List[Tuple[str, str]]。因此,create_deck() 可注解为:
from typing import List, Tuple
def create_deck(shuffle: bool = False) -> List[Tuple[str, str]]:
"""创建一副新的 52 张牌"""
deck = [(s, r) for r in RANKS for s in SUITS]
if shuffle:
random.shuffle(deck)
return deck
很多时候,函数只关心参数是“某种序列”,而不关心是列表还是元组。此时应使用 typing.Sequence:
from typing import List, Sequence
def square(elems: Sequence[float]) -> List[float]:
return [x**2 for x in elems]
这体现了鸭子类型思想:只要对象支持 len() 和 .getitem(),就是 Sequence。
类型别名(Type Aliases)
当处理嵌套类型(如 List[Tuple[str, str]])时,类型提示会变得晦涩难懂。想象一下 deal_hands() 的原始注解:
def deal_hands(
deck: List[Tuple[str, str]]
) -> Tuple[
List[Tuple[str, str]],
List[Tuple[str, str]],
List[Tuple[str, str]],
List[Tuple[str, str]],
]:
...
简直难以阅读!
由于类型注解是合法的 Python 表达式,你可以定义类型别名:
from typing import List, Tuple Card = Tuple[str, str] Deck = List[Card]
现在,deal_hands() 的注解变得清晰:
def deal_hands(deck: Deck) -> Tuple[Deck, Deck, Deck, Deck]:
"""将牌平均发给四位玩家"""
return (deck[0::4], deck[1::4], deck[2::4], deck[3::4])
类型别名不仅提升可读性,还能被检查其真实含义:
>>> Deck typing.List[typing.Tuple[str, str]]
无返回值的函数(Functions Without Return Values)
没有显式 return 的函数实际上返回 None:
>>> def play(player_name):
... print(f"{player_name} plays")
...
>>> ret_val = play("Jacob")
Jacob plays
>>> print(ret_val)
None
虽然技术上返回了 None,但这个值通常无意义。因此,应明确标注返回类型为 None:
# play.py
def play(player_name: str) -> None:
print(f"{player_name} plays")
ret_val = play("Filip")
这样,mypy 能捕获误用返回值的错误:
$ mypy play.py play.py:6: error: "play" does not return a value
如果不加 -> None,mypy 无法推断返回类型,也就不会报错。
进阶情况:对于永远不会正常返回的函数(如总是抛异常),应使用 NoReturn:
from typing import NoReturn
def black_hole() -> NoReturn:
raise Exception("There is no going back ...")
示例:开始出牌
下面是改进版的纸牌游戏:分发手牌后,随机选择起始玩家,然后轮流随机出牌(暂无规则):
# game.py
import random
from typing import List, Tuple
SUITS = "♠ ♡ ♢ ♣".split()
RANKS = "2 3 4 5 6 7 8 9 10 J Q K A".split()
Card = Tuple[str, str]
Deck = List[Card]
def create_deck(shuffle: bool = False) -> Deck:
deck = [(s, r) for r in RANKS for s in SUITS]
if shuffle:
random.shuffle(deck)
return deck
def deal_hands(deck: Deck) -> Tuple[Deck, Deck, Deck, Deck]:
return (deck[0::4], deck[1::4], deck[2::4], deck[3::4])
def choose(items):
"""随机选择并返回一个元素"""
return random.choice(items)
def player_order(names, start=None):
"""调整玩家顺序,使 start 玩家先出"""
if start is None:
start = choose(names)
start_idx = names.index(start)
return names[start_idx:] + names[:start_idx]
def play() -> None:
deck = create_deck(shuffle=True)
names = "P1 P2 P3 P4".split()
hands = {n: h for n, h in zip(names, deal_hands(deck))}
start_player = choose(names)
turn_order = player_order(names, start=start_player)
while hands[start_player]: # 只要起始玩家还有牌
for name in turn_order:
card = choose(hands[name])
hands[name].remove(card)
print(f"{name}: {card[0] + card[1]:<3} ", end="")
print()
if __name__ == "__main__":
play()
现在,我们需要为新函数 choose() 和 player_order() 添加类型提示。
Any 类型
choose() 既可用于玩家名列表,也可用于手牌列表(或其他任何序列)。一种写法是:
from typing import Any, Sequence
def choose(items: Sequence[Any]) -> Any:
return random.choice(items)
但这会丢失类型信息。例如:
# choose.py names = ["Guido", "Jukka", "Ivan"] name = choose(names)
mypy 会推断 names 是 List[str],但 name 的类型却变成 Any:
$ mypy choose.py choose.py:10: error: Revealed type is 'builtins.list[builtins.str*]' choose.py:13: error: Revealed type is 'Any'
这意味着后续无法对 name 做字符串操作的类型检查。
玩转 Python 类型(第二部分)
让我们回到实践。你还记得之前尝试为通用的 choose() 函数添加类型注解时遇到的问题:
import random
from typing import Any, Sequence
def choose(items: Sequence[Any]) -> Any:
return random.choice(items)
使用 Any 的问题是:你无谓地丢失了类型信息。你知道,如果传入一个字符串列表,choose() 应该返回一个字符串。下面我们将学习如何用类型变量(Type Variables)来精确表达这种关系,并进一步探讨:
- 鸭子类型与协议(Protocols)
- 默认值为 None 的参数
- 类方法的类型注解
- 自定义类作为类型
- 可变数量参数(*args, **kwargs)
- 可调用对象(Callables)
类型变量(Type Variables)
类型变量是一种特殊的变量,它可以根据上下文“取”任意类型。
我们来为 choose() 创建一个类型变量,以准确捕获其行为:
# choose.py
import random
from typing import Sequence, TypeVar
Choosable = TypeVar("Choosable")
def choose(items: Sequence[Choosable]) -> Choosable:
return random.choice(items)
names = ["Guido", "Jukka", "Ivan"]
reveal_type(names)
name = choose(names)
reveal_type(name)
- 类型变量必须通过 typing.TypeVar 定义。
- 它会根据实际传入的参数“推断”最具体的类型。
运行 mypy:
$ mypy choose.py choose.py:12: error: Revealed type is 'builtins.list[builtins.str*]' choose.py:15: error: Revealed type is 'builtins.str*'
name 现在被正确推断为 str!
再看几个例子:
# choose_examples.py from choose import choose reveal_type(choose(["Guido", "Jukka"])) # str reveal_type(choose([1, 2, 3])) # int reveal_type(choose([True, 42, 3.14])) # float reveal_type(choose(["Python", 3, 7])) # object
mypy 输出:
builtins.str* builtins.int* builtins.float* # 因为 bool ⊆ int ⊆ float builtins.object* # str 和 int 无共同子类型
注意:最后两个例子没有报错,但类型信息变得模糊。
约束类型变量
如果你希望 choose() 只接受同一种类型的序列(比如只接受全字符串或全数字),可以约束类型变量:
Choosable = TypeVar("Choosable", str, float)
def choose(items: Sequence[Choosable]) -> Choosable:
...
现在:
- choose([1, 2, 3]) → float(因为 int 是 float 的子类型)
- choose([“Python”, 3, 7]) → ❌ 类型错误!
mypy 会报错:
Value of type variable "Choosable" cannot be "object"
在纸牌游戏中,我们可以这样约束:
Choosable = TypeVar("Choosable", str, Card)
这样,choose() 要么处理玩家名(str),要么处理手牌(Card),但不会混合。
总结
typing 的引入是为了让 Python 在保持动态语言灵活性的同时,获得静态类型语言的安全性和工程化优势
常见使用场景
- 为函数参数和返回值指定预期类型
- 用精确的类型信息定义复杂的数据结构
- 提高代码的可读性与可维护性
- 协助 mypy 等静态类型检查工具识别潜在错误
以上就是深入了解Python中类型检查的终极指南的详细内容,更多关于Python类型检查的资料请关注脚本之家其它相关文章!
