天天看點

淺談Python對複合資料的注解和應用案例一、準備遊戲案例二、注解複合類型資料三、注解函數參數和傳回值四、注解方法及類作為類型五、總結六、參考資料

文章目錄

  • 一、準備遊戲案例
  • 二、注解複合類型資料
  • 三、注解函數參數和傳回值
    • 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

關于上述代碼,有幾點需要說明的是:

  • # 1

    :通過清單推導式的方式生成一副52張4花色的牌;
  • # 2

    :通過清單切片的方式模拟4人摸牌,将一副牌均分為4份,以元組形式傳回,元組中的每個元素又是一個具有13張牌的清單;
  • # 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

    __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

    .

    一些內置的序列類型有

    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

如果我們希望完善文章開頭的案例,使得遊戲可以:

  • 首先,随機選擇一個先出牌的玩家;
  • 然後,每個玩家根據順序依次随機出一張牌,直到每個玩家手中的牌都出光。

則有如下代碼:

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]’。

淺談Python對複合資料的注解和應用案例一、準備遊戲案例二、注解複合類型資料三、注解函數參數和傳回值四、注解方法及類作為類型五、總結六、參考資料

上述提示的好處在于:程式員可以不運作代碼的情況下就發現可能因傳入非期望類型資料而導緻程式後續的崩潰。

1.4 類型

Optional

在Python中,經常會遇到使用

None

作為函數形參的情景:

  • 使用None作為函數形參預設值以避免使用可變類型作為預設值可能導緻的問題;
  • 使用

    None

    作為一個标記值(sentinel value)來辨別函數的特定行為。

對于上述第二種情形,例如:在函數

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