浅谈Python对复合数据的注解和应用案例

文章浅谈Python中的注解和类型提示简单介绍了Python从注解类型提示的引入背景,并结合简单的案例介绍了注解类型提示的语法,但文章中仅涉及了对基本数据类型如strfloat以及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

关于上述代码,有几点需要说明的是:

  • # 1:通过列表推导式的方式生成一副52张4花色的牌;
  • # 2:通过列表切片的方式模拟4人摸牌,将一副牌均分为4份,以元组形式返回,元组中的每个元素又是一个具有13张牌的列表;
  • # 3:通过zip()函数创建一个元组迭代器,其中第i个元组包含两个元素,分别是playersdeal_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]stroptions["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. 注解函数

需要注意的是,有别于strfloatbool等简单类型数据,上述案例中的函数参数和返回值也都是复合类型数据,如:一副牌即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 __getitem__() special method and defines a __len__() method that returns the length of the sequence.
    一个可迭代对象,该可迭代对象实现魔法方法__getitem__()并定义一个返回序列长度的__len__()方法,可以支持使用整数索引高效的访问其中的元素。
  • Some built-in sequence types are list, str, tuple, and bytes.
    一些內置的序列类型有liststrtuple以及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

如果我们希望完善文章开头的案例,使得游戏可以:

  • 首先,随机选择一个先出牌的玩家;
  • 然后,每个玩家根据顺序依次随机出一张牌,直到每个玩家手中的牌都出光。

则有如下代码:

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,并指定其可且仅可为strCard,但实际上程序依然会正常执行,只是在带有类型检查器的IDE(如此处使用的PyCharm)或第三方类型检查器中(如大名鼎鼎的mypy),才会以提示或错误的形式显现出来。

如下图所示,在PyCharm中,当为choose()函数传入元素为整型的列表,则得到提示信息:

Expected type ‘Sequence’ (matched generic type ‘Sequence[Chooseable]’), got ‘List[int]’ instead
期望类型是’Sequence’,但传入了’List[int]’。

在这里插入图片描述

上述提示的好处在于:程序员可以不运行代码的情况下就发现可能因传入非期望类型数据而导致程序后续的崩溃

1.4 类型Optional

在Python中,经常会遇到使用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并且使你的代码架构更加清晰。

六、参考资料

猜你喜欢

转载自blog.csdn.net/weixin_37780776/article/details/106678001