0%

[Python] 《Fluent Python》 - 資料模型 閱讀筆記

前言

Fluent Python

這一篇文章是《Fluent Python》- 第一章「資料模型」的閱讀筆記。

Ch1. Python 資料模型

  • 可以將資料模型想像成 Python 描述成框架的東西,他決定這個語言本身 building blocks 的接口,像是 sequences, functions, iterators, coroutines, classes, context managers…等。
  • 當我們使用任何框架來編寫程式時,會花許多時間來撰寫要被框架呼叫的方法,在使用 Python data model 也是如此,Python interpreter(解譯器)會呼叫特殊的方法來執行基本的物件操作,通常是由特殊的語法觸發。
  • 特殊方法的名稱是以雙底線 “__” 作為開頭與結尾,例如: __getitem__, obj[key] 語法是 __getitem__ 特殊方法提供的。
  • 當我們希望物件可以支持基本的語言結構並對它做操作時,我們可以實作特殊方法,例如:
    • Iteration (including asynchronous iteration using async for) 迭代
    • Collections 集合
    • 存取 Attribute
    • Operator overloading 運算子多載
    • Function and method invocation 函式與方法呼叫
    • String representation and formatting 字串字串表示與格式化
    • Object creation and destruction 物件的建構與解構
    • Managed contexts using the with or async with statements 受管理的情境(即with區塊)
  • 特殊方法俗稱魔術方法,也稱 dunder 方法,例如: __getitem__ 讀成 “dunder-getitem”.

Python 風格的撲克牌組

Example 1-1. 撲克牌序列

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import collections

Card = collections.namedtuple('Card', ['rank', 'suit'])

class FrenchDeck:
ranks = [str(n) for n in range(2, 11)] + list('JQKA')
suits = 'spades diamonds clubs hearts'.split()

def __init__(self):
self._cards = [Card(rank, suit) for suit in self.suits
for rank in self.ranks]

def __len__(self):
return len(self._cards)

def __getitem__(self, position):
return self._cards[position]
  • 使用 collections.namedtuple 來建立簡單的類別,並使用它來表示每張卡牌
    • namedtupletuple 的擴展
      • tuple 是 immutable, 建立後就不可更改
      • 在使用 tuple 時,我們可以利用 index 來存取 tuple 中指定位置的值,但這個 index 只是代表位置,沒有實質的意義,當欄位越來越多時,可讀性也會越來越差,而透過 namedtuple 讓我們可以利用名稱來取代 index 值,增加可讀性
1
2
3
>>> beer_card = Card('7', 'diamonds')
>>> beer_card
Card(rank='7', suit='diamonds')
  • 範例的重點是 FrenchDeck class, 我們可以使用 len 來取得牌組中的卡片數量:
1
2
3
>>> deck = FrenchDeck()
>>> len(deck)
52
  • 讀取牌組中的特定卡牌,也正是 __getitem__ 特殊方法提供的功能:
1
2
3
4
>>> deck[0]
Card(rank='2', suit='spades')
>>> deck[-1]
Card(rank='A', suit='hearts')
  • 隨機選取卡牌: random.choice
1
2
3
4
5
6
7
>>> from random import choice
>>> choice(deck)
Card(rank='3', suit='hearts')
>>> choice(deck)
Card(rank='K', suit='spades')
>>> choice(deck)
Card(rank='2', suit='clubs')

Python data model 優點:

  • 類別的使用者不需要去記各種標準動作的方法名稱
    • 如何取得項目數量? .size() , .length() or … ?
  • 有豐富的 Python 標準函式庫,避免重複造輪子

但他有更多好處。

以上的 __getitem__ 代表 self._cards[] 運算子,所以牌組會自動支援 slicing, for example:

1
2
3
4
5
6
7
# 取得最上面三張牌
>>> deck[:3]
[Card(rank='2', suit='spades'), Card(rank='3', suit='spades'), Card(rank='4', suit='spades')]

# 從 index 為 12 開始取 ACE, 並跳過 13 張牌
>>> deck[12::13]
[Card(rank='A', suit='spades'), Card(rank='A', suit='diamonds'), Card(rank='A', suit='clubs'), Card(rank='A', suit='hearts')]

只要實作 __getitem__ 就可以讓牌組變成可迭代的:

1
2
3
4
5
6
>>> for card in deck: # doctest: +ELLIPSIS 
... print(card)
Card(rank='2', suit='spades')
Card(rank='3', suit='spades')
Card(rank='4', suit='spades')
...

反向迭代:

1
2
3
4
5
6
>>> for card in reversed(deck): # doctest: +ELLIPSIS
... print(card)
Card(rank='A', suit='hearts')
Card(rank='K', suit='hearts')
Card(rank='Q', suit='hearts')
...

如果某個集合沒有 __contains__ 方法, in 運算子就會進行循序掃描。我們可以在 FrenchDeck 中使用 in :

1
2
3
4
>>> Card('Q', 'hearts') in deck
True
>>> Card('7', 'beasts') in deck
False

排序:以下範例是按照規則定義撲克牌大小

1
2
3
4
5
suit_values = dict(spades=3, hearts=2, diamonds=1, clubs=0)

def spades_high(card):
rank_value = FrenchDeck.ranks.index(card.rank)
return rank_value * len(suit_values) + suit_values[card.suit]

按照大小依序列出牌組:(遞增)

1
2
3
4
5
6
7
8
9
>>> for card in sorted(deck, key=spades_high): # doctest: +ELLIPSIS
... print(card)
Card(rank='2', suit='clubs')
Card(rank='2', suit='diamonds')
Card(rank='2', suit='hearts')
... (46 cards omitted)
Card(rank='A', suit='diamonds')
Card(rank='A', suit='hearts')
Card(rank='A', suit='spades')

特殊方法的用法

  • 特殊方法是要讓 Python 解譯器呼叫的,而不是開發者,所以不能寫 my_obj.__len__(),而是要用 len(my_obj). 如果 my_obj 是自定義的類別,Python會呼叫你寫的 __len__ 方法.
  • 一般情況下,不應該直接呼叫特殊方法,而是呼叫相關的內建函式比較好(例如:len, iter, str 等)。通常開發者比較常呼叫的只有 __init__, 目的是呼叫自己寫的 initializer.
  • 解譯器會對內建的型態採取較簡便的做法(例如:list, str, bytearray…等),CPython 的 len() 會回傳 PyVarObject C結構內的 ob_size, 表示記憶體中變數大小的所有內建物件,比呼叫方法快很多。
  • 特殊方法通常是私下呼叫的,例如: for i in x 其實會呼叫 iter(x), 接著可以的話會呼叫 x.__iter__()
  • 要避免使用 dunder function 來任意建立自訂的屬性,因為雖然目前他不是保留字,但未來有可能具有特殊含義。

模擬數值型態

首先定義一個 Vector class:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from math import hypot

class Vector:
def __init__(self, x=0, y=0):
self.x = x
self.y = y

def __repr__(self):
return 'Vector(%r, %r)' % (self.x, self.y)

def __abs__(self):
return hypot(self.x, self.y)

def __bool__(self):
return bool(abs(self))

def __add__(self, other):
x = self.x + other.x
y = self.y + other.y
return Vector(x, y)

def __mul__(self, scalar):
return Vector(self.x * scalar, self.y * scalar)

 vector

1
2
3
4
>>> v1 = Vector(2, 4)
>>> v2 = Vector(2, 1)
>>> v1 + v2
Vector(4, 5)

這裡使用 + 運算子,Python 解譯器會去呼叫 __add__().

1
2
3
>>> v = Vector(3, 4)
>>> abs(v)
5.0

這裡使用 abs(), Python 解譯器會去呼叫 __abs__().

也可以使用 * 來執行向量乘法,Python 解譯器會呼叫 __mul__().

1
2
3
4
5
>>> v * 3
Vector(9, 12)

>>> abs(v * 3)
15.0

字串表示方式

  • __repr__ 特殊方法是讓內建 repr 呼叫的,取得物件的字串表示方式,如果沒有實作這個方法,console 中會顯示成 <Vector object at 0x10e100070>.
  • 互動式的 console 或 debug 程式會對運算式的結果呼叫 repr, 這裡使用 %r 來 format string.
  • __str__ 則是讓 str() 呼叫的,並讓 print() 私下使用,他會回傳方便觀看的格式。
  • 如果只想實作其中一種,可以選擇 __repr__, 因為如果沒有自訂的 __str__, Python 會呼叫 __repr__ 來提供結果。

算術運算子

  • 上面的範例使用到 + and *, 以展示 __add____mul__ 的基本用法。在這個情況下,方法會回傳新的 Vector instance, 不會修改他們的運算元。
  • 在上面的範例中,我們可以將 Vector 乘上數字,但無法將數字乘上 Vector, 這違法乘法的交換特性,會在第十三章的特殊方法 __rmul__ 修正這個問題。

自訂類型的布林值

  • Python 有 bool type, 但他接受任何 Boolean context 的任何 object, 像是控制 if 或 while 的 expression, 或 and, or, not 運算元。
  • bool(x) 只會回傳 True or False.
  • 預設情況下,使用者自訂的 class instance 都會被視為 True, 除非他實作了 __bool____len__.
    • bool(x) 會呼叫 x.__bool__() 並使用它的結果,如果沒有實作 __bool__, Python 就會試著呼叫 x.__len__(), 如果它回傳 0, bool 就會回傳 False.

特殊方法概觀

The Python Language Reference 的 Data model (https://docs.python.org/3/reference/datamodel.html) 中列出了 83 個特殊方法名稱,其中有 47 個的用途是實作算術、位元及比較運算子。

magic_method_1

magic_method_2

magic_method_3

為什麼 len 不是一種方法?

  • 在前面的「特殊方法的用法」有提到:如果 x 是內建的型態,CPython 不需要呼叫任何方法,只需要從 C 結構內的欄位讀取。取得集合的長度是蠻常見的動作,因此必須要有效率的操作這種基本類型或其他類型,例如:str, list 或 memoryview.
  • len 不會被當成方法來呼叫,因為他身為 Python 資料模型的一部份,會受到特殊對待, abs 也是一樣的原理。但是拜特殊方法 __len__ 所賜,我們也可以讓自訂的物件使用 len.

本章摘要

  • 藉由實作特殊方法,讓我們的物件的行為可以像內建類型一樣,讓我們的程式撰寫風格被認為符合 Python 慣例。
  • 特殊方法 __repr__ and __str__, 一個用來進行 debug 和記錄,另一個用來顯示給使用者看,是 Python 物件的基本需求。

References