文章目录
- 一、准备游戏案例
- 二、注解复合类型数据
- 三、注解函数参数和返回值
-
- 1. 注解函数
-
- 1.1 数据别名
- 1.2 类型`Any`
- 1.3 类型变量
- 1.4 类型`Optional`
- 四、注解方法及类作为类型
-
- 1. 注解方法
- 2. 类作为类型
- 五、总结
- 六、参考资料
文章浅谈Python中的注解和类型提示简单介绍了Python从注解到类型提示的引入背景,并结合简单的案例介绍了注解和类型提示的语法,但文章中仅涉及了对基本数据类型如
str
、
float
以及
bool
等使用类型提示,而且也未发现此时使用类型提示的明显优势。
然而,Python支持更加复杂的数据类型,即复合类型数据。针对复合数据类型,应用类型提示的一些特性所能实现的功能更加强大。
因此,为了能更好地说清楚Python针对复合数据使用类型提示的特性以及优势,本文将结合一个Python版的纸牌游戏来进行讲解。
一、准备游戏案例
下面代码实现了将一副52张的扑克牌发到4名玩家手中的功能,后续关于对复合数据类型使用类型提示的介绍都将围绕这个案例:
import random
SUITS = "♠ ♡ ♢ ♣".split()
RANKS = "2 3 4 5 6 7 8 9 10 J Q K A".split()
def create_deck(shuffle=False):
"""创建一副牌"""
deck = [(suit, rank) for suit in SUITS for rank in RANKS] # 1
if shuffle:
random.shuffle(deck)
return deck
def deal_hands(deck):
"""模拟4人抓牌的方式将一副牌平均分成4份"""
return deck[0::4], deck[1::4], deck[2::4], deck[3::4] # 2
def play():
"""将分好的4份牌和4名玩家关联起来,使具备玩牌条件"""
deck = create_deck()
players = "P1 P2 P3 P4".split()
hands = {name: hand for name, hand in zip(players, deal_hands(deck))} # 3
for name, cards in hands.items():
card_str = " ".join((f"{suit}{rank}" for (suit, rank) in cards)) # 4
print(f"{name}: {card_str}")
def main():
play()
if __name__ == '__main__':
main()
运行上述代码的结果为:
P1: ♠2 ♠6 ♠10 ♠A ♡5 ♡9 ♡K ♢4 ♢8 ♢Q ♣3 ♣7 ♣J
P2: ♠3 ♠7 ♠J ♡2 ♡6 ♡10 ♡A ♢5 ♢9 ♢K ♣4 ♣8 ♣Q
P3: ♠4 ♠8 ♠Q ♡3 ♡7 ♡J ♢2 ♢6 ♢10 ♢A ♣5 ♣9 ♣K
P4: ♠5 ♠9 ♠K ♡4 ♡8 ♡Q ♢3 ♢7 ♢J ♣2 ♣6 ♣10 ♣A
关于上述代码,有几点需要说明的是:
-
:通过列表推导式的方式生成一副52张4花色的牌;# 1
-
:通过列表切片的方式模拟4人摸牌,将一副牌均分为4份,以元组形式返回,元组中的每个元素又是一个具有13张牌的列表;# 2
-
:通过# 3
函数创建一个元组迭代器,其中第zip()
个元组包含两个元素,分别是i
和players
返回值中的第deal_hands(deck)
个元素;i
-
:通过生成器表达式# 4
返回一个迭代器作为字符串对象(f"{suit}{rank}" for (suit, rank) in cards)
方法的参数。join()
关于迭代器、生成器表达式、列表推导式等术语,请见Python中for循环运行机制探究以及可迭代对象、迭代器详解和Python中的yield关键字及表达式、生成器、生成器迭代器、生成器表达式详解两篇文章。
二、注解复合类型数据
实际上,对于列表、元组等复合类型数据,为其添加类型提示,和为简单类型数据添加类型提示基本一致,如:
import sys
names: list = ["Guido", "Jukka", "Ivan"]
version: tuple = (3, 7, 1)
options: dict = {"centered": False, "capitalize": True}
print(sys.modules[__name__].__annotations__)
上述代码的运行结果为:
{‘names’: <class ‘list’>, ‘version’: <class ‘tuple’>, ‘options’: <class ‘dict’>}
问题在于,虽然通过上述案例的具体代码我们可以很容易知道复合数据各个元素的数据类型,如
names[2]
是
str
,
options["centered"]
是
bool
,但是如果变量
names
中的元素也是通过其他变量来表示,则仅通过上述类型提示就无法获知如
names[2]
的数据类型。
为了解决上述注解复合数据类型所遇到的问题,需要用到
typing
模块中的一些特殊类型,这些特殊类型可以通过类型提示指定复合数据中元素的类型,如:
import sys
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}
print(sys.modules[__name__].__annotations__)
上述代码的运行结果为:
{‘names’: typing.List[str], ‘version’: typing.Tuple[int, int, int], ‘options’: typing.Dict[str, bool]}
需要注意的是,上述来自
typing
模块中的各复合类型都是大写字母开头,且通过方括号指定元素的类型:
-
是一个元素为names
的列表;str
-
是一个元素为version
的元组;int
-
是一个键为options
,值为str
的字典。bool
三、注解函数参数和返回值
下面需要对上述案例中的函数
create_deck()
、
deal_hands()
和
play()
进行注解,即添加类型提示。
1. 注解函数
需要注意的是,有别于
str
、
float
、
bool
等简单类型数据,上述案例中的函数参数和返回值也都是复合类型数据,如:一副牌即
deck
是一个列表,且列表的每一个元素(即一张牌)又都是一个元组,而元组的两个元素(花色和数字)又都是一个字符串。因此:
- 一张牌可以表示为
;Tuple[str, str]
- 一副牌可以表示为
。List[Tuple[str, str]]
因此,对于函数create_deck()的注解可以为:
import random
from typing import Tuple, List
SUITS = "♠ ♡ ♢ ♣".split()
RANKS = "2 3 4 5 6 7 8 9 10 J Q K A".split()
def create_deck(shuffle: bool = False) -> List[Tuple[str, str]]:
"""创建一副牌"""
deck = [(suit, rank) for suit in SUITS for rank in RANKS]
if shuffle:
random.shuffle(deck)
return deck
def main():
print(create_deck.__annotations__)
if __name__ == '__main__':
main()
上述代码的运行结果为:
{‘shuffle’: <class ‘bool’>, ‘return’: typing.List[typing.Tuple[str, str]]}
很多时候,一个函数可能只期望接收的参数是一个序列(
sequence
)就可以了,而并不关心其究竟是一个列表还是一个元组,这时候你就可以使用
typing.Sequence
来注解函数的参数。如:
from typing import List, Sequence
def square(elems: Sequence[float]) -> List[float]:
return [x**2 for x in elems]
实际上,使用
typing.Sequence
注解sequence类型时就是一个使用鸭子类型的案例,因为根据Python官方定义,
sequence
并不特指某一特定的数据类型,而仅是:
- An iterable which supports efficient element access using integer indices via the
special method and defines a
__getitem__()
__len__()
method that returns the length of the sequence.
一个可迭代对象,该可迭代对象实现魔法方法
并定义一个返回序列长度的
__getitem__()
方法,可以支持使用整数索引高效的访问其中的元素。
__len__()
- Some built-in sequence types are
,
list
,
str
, and
tuple
bytes
.
一些內置的序列类型有
、
list
、
str
以及
tuple
。
bytes
需要注意的是,尽管字典也支持
__getitem__()
和
__len__()
,但是字典是一个映射而非序列,因为字典使用任意不可变的键而不是整数进行查找。
1.1 数据别名
当类型提示发生嵌套时就会变得非常晦涩,这和使用类型提示想要达到的目的相悖,比如你需要看很久才知道用于注解一副牌的类型提示
List[Tuple[str, str]]
是什么含义。再例如,如果对上述
deal_hands()
函数进行注解,则有:
from typing import List, Tuple
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]],
]:
"""模拟4人抓牌的方式将一副牌平均分成4份"""
return deck[0::4], deck[1::4], deck[2::4], deck[3::4]
def main():
print(deal_hands.__annotations__)
if __name__ == '__main__':
main()
上述代码的运行结果为:
{‘deck’: typing.List[typing.Tuple[str, str]], ‘return’: typing.Tuple[typing.List[typing.Tuple[str, str]], typing.List[typing.Tuple[str, str]], typing.List[typing.Tuple[str, str]], typing.List[typing.Tuple[str, str]]]}
由上述代码和其运行结果可知,其注解的类型提示十分晦涩难懂。针对该问题的解决方案也很简单,可以为上述冗长的类型提示起别名,如:
from typing import List, Tuple
Card = Tuple[str, str]
Deck = List[Card]
def deal_hands(deck: Deck) -> Tuple[Deck, Deck, Deck, Deck]:
"""模拟4人抓牌的方式将一副牌平均分成4份"""
return deck[0::4], deck[1::4], deck[2::4], deck[3::4]
def main():
print(Card)
print(Deck)
print(deal_hands.__annotations__)
if __name__ == '__main__':
main()
上述代码的运行结果为:
typing.Tuple[str, str]
typing.List[typing.Tuple[str, str]]
{‘deck’: typing.List[typing.Tuple[str, str]], ‘return’: typing.Tuple[typing.List[typing.Tuple[str, str]], typing.List[typing.Tuple[str, str]], typing.List[typing.Tuple[str, str]], typing.List[typing.Tuple[str, str]]]}
结合上述代码的运行结果可知:
- 为复合数据的类型提示起别名可以增加代码的可读性;
- 打印复合数据的别名时,其真实的复合数据类型保持不变。
1.2 类型 Any
Any
如果我们希望完善文章开头的案例,使得游戏可以:
- 首先,随机选择一个先出牌的玩家;
- 然后,每个玩家根据顺序依次随机出一张牌,直到每个玩家手中的牌都出光。
则有如下代码:
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: # 2
deck = [(suit, rank) for suit in SUITS for rank in RANKS]
if shuffle:
random.shuffle(deck)
return deck
def deal_hands(deck: Deck) -> Tuple[Deck, Deck, Deck, Deck]: # 4
"""模拟4人抓牌的方式将一副牌平均分成4份"""
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) -> List[str]: # 7
"""旋转玩家顺序,使得start_player第一个出牌"""
if start is None:
start = choose(names)
start_idx = names.index(start)
return names[start_idx:] + names[:start_idx]
def play() -> None:
"""4名玩家使用1副扑克牌"""
deck = create_deck(shuffle=True) # 1
names = "P1 P2 P3 P4".split()
hands = {name: hand for name, hand in zip(names, deal_hands(deck))} # 3
start_player = choose(names) # 5
turn_over = player_order(names, start=start_player) # 6
# 4名玩家按顺序随机出牌直到每名玩家手中无牌
while hands[start_player]: # 8
for name in turn_over:
card = choose(hands[name])
hands[name].remove(card)
# print(f"{name}: {card[0] + card[1]!r}", end=" ")
print(f"{name}: {card[0] + card[1]:<3} ", end="")
print()
def main():
play()
if __name__ == '__main__':
main()
在上述代码中,我们除了修改了
play()
函数,还增加了两个新的函数
choose()
和
player_order()
,你可能会发现我们在代码中只是增加了对于
player_order()
函数的注解。这是因为对于
choose()
函数,在代码中的使用分为两类:
- 接收类型提示为
的参数,返回类型提示为List[str]
的结果;str
- 接收类型提示为
的参数,返回类型提示为List[Tuple[str, str]]
的结果。Tuple[str, str]
即无法使用一个确定的方式来注解函数
choose()
,但在上述两种情况下,该函数都必须接收一个序列,至于序列中是任何类型的数据都可以,函数的返回值也和序列中的数据一样,是任何类型都可以。
事实上,在
typing
模块中,有一个名为
Any
的类型恰好可以用来注解无法确定的任意类型。因此,对于
choose()
函数,可以注解如下:
import random
from typing import Any, Sequence
def choose(items: Sequence[Any]) -> Any:
return random.choice(items)
def main():
print(choose.__annotations__)
if __name__ == '__main__':
main()
上述代码的运行结果为:
{‘items’: typing.Sequence[typing.Any], ‘return’: typing.Any}
1.3 类型变量
事实上,使用上述
Any
类型的意义不大,因为就
choose()
函数来说,虽然我们能从
Sequence[Any]
中至少能获悉
items
参数的类型最起码应该为一个序列,但是从返回值
Any
中我们无法获知任何有价值信息。
实际上,上述
choose()
函数的参数和返回值类型十分类似如Java、C++等语言中的泛型概念。类似地,在Python中,使用
typing
模块中
TypeVar
类可以实现类似泛型的功能,即开发者可以使用该类创建自定义类型对象,且在创建该对象时指定名称和若干已知个类型,使该类型在代码类型检查(type checking)及运行时(runtime)可且仅可为此若干个指定类型。
基于上述讨论,对于choose()函数,可以进一步注解如下:
import random
from typing import Tuple, Sequence, TypeVar
Card = Tuple[str, str]
Chooseable = TypeVar("Chooseable", str, Card)
def choose(items: Sequence[Chooseable]) -> Chooseable:
return random.choice(items)
def main():
choose([("♠", "A"), ("♡", "K")])
choose(["P1", "P2", "P3", "4"])
choose([1, 2, 3, 4])
print(choose.__annotations__)
if __name__ == '__main__':
main()
上述代码运行结果如下:
(‘♠’, ‘A’)
P2
2
{‘items’: typing.Sequence[~Chooseable], ‘return’: ~Chooseable}
实际上,虽然我们在程序中通过
TypeVar
创建了一个自定义类型
Chooseable
,并指定其可且仅可为
str
或
Card
,但实际上程序依然会正常执行,只是在带有类型检查器的IDE(如此处使用的PyCharm)或第三方类型检查器中(如大名鼎鼎的mypy),才会以提示或错误的形式显现出来。
如下图所示,在PyCharm中,当为choose()函数传入元素为整型的列表,则得到提示信息:
Expected type ‘Sequence’ (matched generic type ‘Sequence[Chooseable]’), got ‘List[int]’ instead
期望类型是’Sequence’,但传入了’List[int]’。

上述提示的好处在于:程序员可以不运行代码的情况下就发现可能因传入非期望类型数据而导致程序后续的崩溃。
1.4 类型 Optional
Optional
在Python中,经常会遇到使用
None
作为函数形参的情景:
- 使用None作为函数形参默认值以避免使用可变类型作为默认值可能导致的问题;
- 使用
作为一个标记值(sentinel value)来标识函数的特定行为。None
对于上述第二种情形,例如:在函数
player_order()
中,使用None作为一个标记值,使得如果调用函数时不指定第一个出牌的玩家,那么将进行随机选取。
这就给为start变量添加类型提示创造了困难,因为通常变量
start
都应该是一个字符串,但是其也可以是一个非字符串值
None
。
为了对
start
变量添加类型提示,可以使用
typing
模块中的
Optional
类型:
import random
from typing import List, Tuple, Sequence, TypeVar, Optional
Card = Tuple[str, str]
Chooseable = TypeVar("Chooseable", str, Card)
def choose(items: Sequence[Chooseable]) -> Chooseable:
return random.choice(items)
def player_order(names: List[str], start: Optional[str] = None) -> List[str]:
"""旋转玩家顺序,使得start_player第一个出牌"""
if start is None:
start = choose(names)
start_idx = names.index(start)
return names[start_idx:] + names[:start_idx]
def main():
print(player_order.__annotations__)
if __name__ == '__main__':
main()
实际上,
Optional[None, str]
即等价于
Union[None, str]
。
四、注解方法及类作为类型
为了了解如何注解类中定义的方法,首先我们将上述扑克牌游戏的代码进行面向对象封装。经过简单分析可知,可以从从上述面向过程的代码中抽象出四个类:单张扑克牌
Card
、一副牌
Deck
、4名玩家
Player
以及游戏
Game
:
# game.py
import random
import sys
class Card(object):
SUITS = "♠ ♡ ♢ ♣".split()
RANKS = "2 3 4 5 6 7 8 9 10 J Q K A".split()
def __init__(self, suit, rank):
self.suit = suit
self.rank = rank
def __str__(self):
return f"{self.suit}{self.rank}"
def __repr__(self):
return f"{self.__class__.__name__}("\
f"{self.suit!r}, {self.rank!r})"
class Deck(object):
def __init__(self, cards): # 8、12
self.cards = cards
@classmethod
def create(cls, shuffle=False): # 5
"""创建一副总数为52张的扑克牌"""
cards = [Card(suit, rank) for suit in Card.SUITS for rank in Card.RANKS] # 6
if shuffle:
random.shuffle(cards)
return cls(cards) # 7
def deal(self, num_hands): # 10
"""将一副牌中52张全部发到指定数目的玩家手中"""
cls = self.__class__
return tuple(cls(self.cards[i::num_hands]) for i in range(num_hands)) # 11
class Player(object):
def __init__(self, name, hand):
self.name = name
self.hand = hand
def play_card(self): # 18
"""玩家从手中任意打出一张牌"""
card = random.choice(self.hand.cards)
self.hand.cards.remove(card)
print(f"{self.name}: {card!r:<3}", end="")
return card
class Game(object):
def __init__(self, *player_names): # 3
"""设置一副牌并将该副牌发到4名玩家手中"""
deck = Deck.create(shuffle=True) # 4
self.names = list(player_names)
self.hands = {name: Player(name, hand) for name, hand in zip(self.names, deck.deal(4))} # 9
def play(self): # 14
"""玩扑克牌"""
start_player = random.choice(self.names)
turn_order = self.player_order(start_player=start_player) # 15
# 每名玩家依次出牌,知道每名玩家手中牌数为零
while self.hands[start_player].hand.cards:
for name in turn_order:
self.hands[name].play_card() # 17
print()
def player_order(self, start_player=None): # 16
"""旋转玩家顺序,使得start_player第一个出牌"""
if start_player is None:
start_player = random.choice(self.names)
start_idx = self.names.index(start_player)
return self.names[start_idx:] + self.names[:start_idx]
def main():
# 从命令行读取玩家姓名
player_names = sys.argv[1:] # 1
game = Game(*player_names) # 2
game.play() # 13
if __name__ == '__main__':
main()
经过面向对象封装后得到上述代码(代码中以
#
开头的数字表示程序执行的顺序),通过命令
python3 game.py P1 P2 P3 P4
运行上述代码的结果为:
P3: ♢8 P4: ♡8 P1: ♢A P2: ♠10
P3: ♡5 P4: ♡6 P1: ♡4 P2: ♢5
P3: ♢10P4: ♢6 P1: ♠6 P2: ♠5
P3: ♠3 P4: ♢3 P1: ♡J P2: ♢7
P3: ♣A P4: ♡K P1: ♡10P2: ♣8
P3: ♣9 P4: ♡3 P1: ♢2 P2: ♣2
P3: ♣6 P4: ♢K P1: ♠A P2: ♣J
P3: ♠8 P4: ♡7 P1: ♡Q P2: ♠Q
P3: ♠2 P4: ♠J P1: ♢J P2: ♡9
P3: ♣3 P4: ♠7 P1: ♢4 P2: ♣10
P3: ♠K P4: ♣Q P1: ♣7 P2: ♠4
P3: ♣5 P4: ♣4 P1: ♠9 P2: ♡2
P3: ♢Q P4: ♢9 P1: ♣K P2: ♡A
1. 注解方法
实际上注解方法的即为方法添加类型提示的语法和注解函数十分类似,如对于
Card
类中的方法,有:
from typing import List
class Card(object):
SUITS: List[str] = "♠ ♡ ♢ ♣".split()
RANKS: List[str] = "2 3 4 5 6 7 8 9 10 J Q K A".split()
def __init__(self, suit: str, rank: str) -> None:
self.suit = suit
self.rank = rank
def __str__(self) -> str:
return f"{self.suit}{self.rank}"
def __repr__(self) -> str:
return f"{self.__class__.__name__}(" \
f"{self.suit!r}, {self.rank!r})"
需要注意的是,
__init__
方法的返回值类型永远都是
None
。
2. 类作为类型
在为
Deck
类的方法添加类型提示的时候,对于类方法
Deck.create()
,由于其返回值是类型为
Deck
的对象,但此时
Deck
类还未被完全定义,此时可以使用字符串字面量
"Deck"
来完成注解,如下列代码所示:
class Deck(object):
def __init__(self, cards: List[Card]) -> None:
self.cards = cards
@classmethod
def create(cls, shuffle: bool = False) -> "Deck":
"""创建一副总数为52张的扑克牌"""
cards = [Card(suit, rank) for suit in Card.SUITS for rank in Card.RANKS]
if shuffle:
random.shuffle(cards)
return cls(cards)
def deal(self, num_hands: int) -> List["Deck"]:
"""将一副牌中52张全部发到指定数目的玩家手中"""
cls = self.__class__
return [cls(self.cards[i::num_hands]) for i in range(num_hands)]
虽然Player类中也引用了Deck类,此时直接使用Deck类注解形参hand是没问题的,因为Deck类已经在之前被定义了,即:
class Player(object):
def __init__(self, name: str, hand: Deck) -> None:
self.name = name
self.hand = hand
def play_card(self) -> Card:
"""玩家从手中任意打出一张牌"""
card = random.choice(self.hand.cards)
self.hand.cards.remove(card)
print(f"{self.name}: {card!r}", end="\t")
return card
五、总结
虽然Python中的类型提示是其一个非必须的特性,有和没有这个特性你都可以写出任何代码,但是在你的代码中使用类型提示可以使你的代码更具有可读性、更容易查找隐藏的bug并且使你的代码架构更加清晰。
六、参考资料
- [1] Python Type Checking (Guide)
- [2] PEP 484 – Type Hints