みーのぺーじ

みーが趣味でやっているPCやソフトウェアについて.Python, Javascript, Processing, Unityなど.

Fluent Python, 2nd Edition を読了した

Fluent Python 2nd Edition を読了しました.

初版は2015年に発売され情報が古くなっていたのですが,2022年4月に第2版が発売されたのでさっそく読んでみました.新しい本なので,最新のPython 3.10に対応しています.2022/06/27現在,日本語版は販売されていません.

一通りPythonを扱える人が,より短く,速く,読みやすいソースコードを書けるように,Python3の機能を説明している本なので,難易度は少し高めに感じました.

また,約1000ページあるらしく,読むのに時間がかかります*1

本に登場するソースコードは,以下の著者のレポジトリに公開されています.

GitHub - fluentpython/example-code-2e: Example code for Fluent Python, 2nd edition (O'Reilly 2022)

以下に内容を日本語訳でまとめます.

Part I. Data Structures

Chapter 1. The Python Data Model

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]

Python には特殊メソッド(special methods)が存在する.ユーザーがlen(my_object)と呼び出すと,自動的にmy_object.__len__()が呼ばれる.汎用的に定義された組み込み関数を使用する.

Collection APIでは,Iterable (for, unpackingなどで使用),Sized (組み込み関数len()で使用),Container (in 演算子で使用)が用意されており,Collectionはこれら3個を継承している.Collectionを継承したものに,Sequence (liststr),Mapping (dict),Set (set) がある.

__str____repr__は両方ともオブジェクトの文字表現を返す.__repr__は一義的な文字表現を,__str__は人間に読みやすい文字表現を返すべきである.とりあえず__repr__を実装し,必要性を感じたら__str__を実装する.なぜならば,__str__が実装されていなければ__repr__が呼び出されるから.*2

Chapter 2. An Array of Sequences

リスト内包表記とジェネレーター式の紹介.デカルト積 (Cartesian Products)を生成するには,2重のリスト内包表記を用いる.以下の例では[(color, size) for color in colors]が,for size in sizesでループされる.

>>> colors = ['black', 'white']
>>> sizes = ['S', 'M', 'L']
>>> [(color, size) for color in colors for size in sizes]
[('black', 'S'), ('black', 'M'), ('black', 'L'), ('white', 'S'), ('white', 'M'), ('white', 'L')]

タプルは,変更不可能なリストとしても,レコードとして使用できる.

パターンマッチング構文の紹介.(Python 3.10より導入)

array.arrayは同じ型の配列を高速に扱える.listは異なる型の配列を扱える.

memoryviewはデータを複製せずにメモリーを共有できるので,大きなデータを扱うのに適している.

dequeは,最初と最後から,挿入と削除の操作を高速にできる.

Chapter 3. Dictionaries and Sets

dictcollections.OrderedDict (後方互換のため),collections.ChainMapcollections.Counterの紹介.setの紹介.

ハッシュテーブルを利用して高速に値を呼び出せるように実装されている.

Chapter 4. Unicode Text Versus Bytes

bytesはエンコードを考慮しなければならないので,文字列を扱う時は,入力時にbytesからstrにデコードし,strのみを処理し,出力時にstrからbytesにエンコードする (The Unicode sandwich)とよい.

BOMはファイルの先頭にb'\xef\xbb\xbf'がついたもの.utf-8-sigを指定すれば簡単に扱えるが,utf-8ならばそもそもBOMは不要*3

正規表現をbytesで生成すると\d\wなどのパターンはASCII文字のみ,strで生成するとASCII文字以外のUnicode文字にも一致する.

Chapter 5. Data Class Builders

dataclassesの紹介.生成するにはcollections.namedtupletyping.NamedTuple@dataclasses.dataclassのいずれかを使用する.

型を指定する機能があるが,Pythonは動的言語なので,実行時には無視される.あくまでもIDEの支援を受けるための注釈である.以下の例のように,floatを指定したlatにstrの値を指定してもエラーにはならずそのまま処理される.mypyを使用すればエラーとなる.

>>> class Coordinate(typing.NamedTuple):
...     lat: float
...     lon: float
... 
>>> Coordinate('Ni!', None)
Coordinate(lat='Ni!', lon=None)

dataclassesは一見便利に見えるが,fields, getting and setting methods for fields のみで構成され,それ以外に機能がない.このようなclassはデータを保持するだけであって,他のclassにデータを操作されることが多い.オブジェクト指向プログラミングは,動作とデータを同じ場所に記述するというアイディアであり,dataclassesは異なる発想にある.

dataclassesは,ひとまず作成するソースコードの最初の足がかりや,JSONでdictをImmutableに扱うようなデータ処理をする場合に用いるべきである.

Chapter 6. Object References, Mutability, and Recycling

「変数は箱である」という隠喩で,オブジェクト指向言語の参照変数が理解しにくくなる.以下のb = aの動作を説明するには,「オブジェクトに付箋をつける」と考えるべき.

>>> a = [1, 2, 3]
>>> b = a
>>> a.append(4)
>>> a
[1, 2, 3, 4]
>>> b
[1, 2, 3, 4]

オブジェクトが作られた後でしか変数で参照できない.Gizmo(機器) オブジェクトを生成した時にidを出力するclassを定義し,10を掛けたものを変数yに代入しようとしても,10を掛ける演算が定義されていないのでTypeErrorとなり,変数yそのものが作成されない.

>>> class Gizmo:
...     def __init__(self):
...         print(f'Gizmo id: {id(self)}')
... 
>>> 
>>> x = Gizmo()
Gizmo id: 4341720032
>>> y = Gizmo() * 10
Gizmo id: 4341723344
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: unsupported operand type(s) for *: 'Gizmo' and 'int'
>>> dir()
['Gizmo', '__annotations__', '__builtins__', '__doc__', '__loader__', '__name__', '__package__', '__spec__', 'x']

is演算子はidentity (id()関数の結果) が同じかどうかを返す.==演算子は変数が参照するデータが等しいかどうかを返す.Noneはシングルトンオブジェクトなので,is演算子で比較する.(x is Nonex is not None). ==演算子の動作は__eq__関数に定義する.

shallow copy (copy.copy()) とdeep copy (copy.deepcopy())の違い.関数の呼び出しの引数は参照渡しなので,mutableなdefault parameterは避けるべき.

del文はオブジェクトそのものではなく,参照を削除する.del文を実行すると参照が減るので,参照がなくなればガーベッジコレクタがオブジェクトを削除するかもしれない.

__del__をユーザーが呼び出すべきではない.ガーベッジコレクタの実装にもよるが,CPythonならばオブジェクトを削除する直前に__del__を呼び出し,その後メモリーを開放する.

参照がなくなった時の動作を実施するにはweakref.finalize関数を使用する.以下の例ではs2 = 'spam'が実行されると{1, 2, 3}への参照がなくなるので,"bye"と出力される.

>>> import weakref
>>> s1 = {1, 2, 3}
>>> s2 = s1  
>>> ender = weakref.finalize(s1, lambda :print("bye"))
>>> ender.alive
True
>>> del s1
>>> ender.alive
True
>>> s2 = 'spam'
bye
>>> ender.alive
False

weak referenceは参照カウントを増やさないので,それ自身によってガーベッジコレクタで開放されなくなるを妨げない.キャッシュの実装などに有用である.

Javaでは==演算子は参照を比較する.値を比較するにはa.equals(b)を実行するが,anullの場合 NullPointerException が発生する.Pythonでは==演算子は値を比較し,参照を比較するにはis演算子を用いる.Javaのようにnullを比較しても例外は発生しない.Pythonは正しい方法で比較してくれる.

Part II. Functions as Objects

Chapter 7. Functions as First-Class Objects

第一級オブジェクト(first-class object)とは,実行時に生成可能であり,変数・データ構造に格納可能であり,関数の引数や戻り値として受け渡しが行えるもののことである.Pythonでは,関数は第一級オブジェクトである.

高階関数 (higher-order function)は,引数に関数を渡したり,戻り値として関数を受け取る関数のこと.mapfilterreducesortなど.

all(iterable)はiterableにfalsyな要素が存在しなければTrueを返す.all([])Trueを返す.

any(iterable)はiterableにtruthyな要素が存在すればTrueを返す.any([])Falseを返す.

無名関数 (anonymous function) はlambdaを使う.

operatorモジュールには,よく用いられる関数が利用できる.lambda a, b: a*bと書くならば,from operator import mulを用いる.lambda fields: fields[1]ならばitemgetter(1)を用いる.同様にattrgetter('coord.lat')hyphenate = methodcaller('replace', ' ', '-')なども有用である.

引数を固定するにはfunctools.partialを用いる.例えばpartial(mul, 3)functools.partial(unicodedata.normalize, 'NFC')などが有用である.

Chapter 8. Type Hints in Functions

型ヒントは追加の作業が増えるが,静的解析によりバグを減らしたりIDEの支援を充実させるのに役立つ.実行時にエラーにはならず,パフォーマンスも変わらない.

mypyを使う.

関数の定義は,def show_count(count: int, word: str) -> str: などと表現する.

オプション引数を含む関数の定義は,def show_count(count: int, singular: str, plural: str = '') -> str: などと表現する.

ソースコードのスタイルや自動整形はとても便利で,型ヒントも見やすくなるので,flake8やblueを使用するべき.(著者はblackよりblueがお好みらしい)

flake8 · PyPI

blue · PyPI

Duck typing では,オブジェクトに型があるが,変数には型がない.Nominal typingは,オブジェクトと変数に型がある.実行する前のビルドやIDEで入力中にもバグを発見できるメリットがあるが,型の自由度が少なくなる.

birds.py

class Bird:
    pass

class Duck(Bird):
    def quack(self):
        print('Quack!')

def alert(birdie):
    birdie.quack()

def alert_duck(birdie: Duck) -> None:
    birdie.quack()

def alert_bird(birdie: Bird) -> None:
    birdie.quack()

これをmypyで解析するとエラーとなる.変数に型が指定されているからである.

$ mypy s.py
birds.py:15: error: "Bird" has no attribute "quack"
Found 1 error in 1 file (checked 1 source file)

しかし,実行できる.実行時は変数に指定された型は無視される.

>>> from birds import *
>>> daffy = Duck()
>>> alert(daffy)
Quack!
>>> alert_duck(daffy)
Quack!
>>> alert_bird(daffy)
Quack!

短いソースコードでは型ヒントの価値は少ないが,コードベースが大きくなるにつれて利益が増える.

Anyは特別で,型の最上位かつ最下位である.以下の例で,Anyは問題ないが,objectは掛け算が定義されていないのでエラーになる.型が一般的であるほど,インターフェイスは狭くなるにも関わらず,Anyは全ての型であり,最も多くのインターフェイスがある.

def double(x: Any) -> Any:
    return x * 2

def double(x: object) -> object:
    return x * 2

Optional[str]Union[str, None]と同義だが,Python 3.10以降はstr | None と書けるようになったので,OptionalやUnionをインポートしなくてよい.

tupleならば,tuple[str, float, str]などと型ヒントを記載する.

dictで型ヒントを活用するには,TypedDictを用いる.

typing --- 型ヒントのサポート — Python 3.10.4 ドキュメント

引数の型は緩く指定し,戻り値の型は厳密に指定する.そのために,collections.abc (Abstract Base Classes)が存在する.

以下の例では,1個目の関数ならばcolor_mapにdict, defaultdict, ChainMap, UserDictのサブクラスなど幅広い型のインスタンスが指定できるが,2個目の関数ならばdictまたはそのサブタイプのみに限定されてしまい不便である.

from collections.abc import Mapping

def name2hex(name: str, color_map: Mapping[str, int]) -> str:
    pass

def name2hex(name: str, color_map: dict[str, int]) -> str:
    pass

listの型ヒントについて,引数に用いるべき型ヒントは Iterable (count関係なし),Sequence (countが確定しなければならない) であり,戻り値に用いるべき型ヒントはIteratorである.(Chapter 17参照)

TypeVarを使えば,型パラメータ (type parameter) を宣言できる.これにより,引数に指定した型 Tを通じて,引数と同じ型の戻り値であることを示せる.

from collections.abc import Iterable
from typing import TypeVar

T = TypeVar('T')

def mode(data: Iterable[T]) -> T:
    pass

型パラメータの型制限するには,NumberT = TypeVar('NumberT', float, Decimal, Fraction)と記載する.boundを指定して,HashableT = TypeVar('HashableT', bound=Hashable)と記載もできる.Protocol型を使ってもよい.

from typing import Protocol, Any

class SupportsLessThan(Protocol):
    def __lt__(self, other: Any) -> bool:
        pass

関数の型ヒントは,Callable[[ParamType1, ParamType2], ReturnType]と記載する.

型ヒントには以下のような欠点がある.

  • 偽陽性: ツールが正しいコードでエラーとなる.
  • 偽陰性: ツールが誤ったコードでエラーにならない.
  • config(**settings)のように引数を展開するなど,一部の便利な機能で静的解析が難しい.
  • 一部のPythonの機能がサポートされていない場合がある.
  • 型ヒントのチェッカーはPythonのリリースよりも遅延するので,新しい言語機能が使えなかったりエラーになったりする.

このため,静的型解析は道具の一つとして利用するべきである.

実際,有名なライブラリであるrequestsの管理者は,型ヒントを作成しないことに決めた*4SQLAlchemyも素晴らしいライブラリだが,型ヒントと相性が悪い.これらのライブラリが偉大なのは,Pythonの動的な性質をうまく利用しているからである.

型ヒントは必要な部分にだけ,上手に使うべきである.

Chapter 9. Decorators and Closures

デコレーターは,別の関数を引数とするcallableであり,引数に応じて別の関数やcallableを返す.

変数のスコープの規則を理解するべき.以下の例では変数bはlocal変数であるため,以下を実行すると,NameError: name 'b' is not definedとエラーになる.

def f1(a):
    print(a)
    print(b)

f1(3)

これに対して,変数bに代入すれば,変数bはglobal変数となるため,実行できる.

b = 6
def f1(a):
    print(a)
    print(b)

f1(3)

さらに,関数中で変数bに代入すると,新たなlocal変数bが作成されるため,以下を実行すると,UnboundLocalError: local variable 'b' referenced before assignmentとエラーになる.

b = 6
def f2(a):
    print(a)
    print(b)
    b = 9

f2(3)

これはバグではなく,言語設計上の選択である.Pythonでは,変数を宣言しなくてよいが,関数内で代入された変数はlocal変数とみなされる.Javascriptでは,変数を宣言しなくてよいが,変数を宣言し忘れると勝手にglobal変数と解釈されてしまうので,間違えに気づきにくい.

もし関数中でglobal変数に代入したいならば,globalを追加する.

b = 6
def f2(a):
    global b
    print(a)
    print(b)
    b = 9

f2(3)

Pythonがソースコードをどのように解釈したかを確認するには,disモジュールを使う.

dis — Disassembler for Python bytecode — Python 3.10.5 documentation

>>> from dis import dis
>>> dis(f2)
  4           0 LOAD_GLOBAL              0 (print)
              2 LOAD_FAST                0 (a)
              4 CALL_FUNCTION            1
              6 POP_TOP

  5           8 LOAD_GLOBAL              0 (print)
             10 LOAD_GLOBAL              1 (b)
             12 CALL_FUNCTION            1
             14 POP_TOP

  6          16 LOAD_CONST               1 (9)
             18 STORE_GLOBAL             1 (b)
             20 LOAD_CONST               0 (None)
             22 RETURN_VALUE

クロージャー (closure) とは,関数と,その関数が作られた環境の組み合わせである.関数内の関数で代入する変数がlocal変数でないことを示すために,nonlocalを使用する.

以下の例では,make_averager()関数内がクロージャーであり,count変数とtotal変数が自由変数(free variable)である.

def make_averager():
    count = 0
    total = 0

    def averager(new_value):
        nonlocal count, total
        count += 1
        total += new_value
        return total / count

    return averager

これを使用すると以下のように自由変数counttotalを参照できていることが分かる.

>>> avg = make_averager()
>>> avg(1)
1.0
>>> avg(3)
2.0
>>> avg(5)
3.0
>>> avg(7)
4.0

Pythonにおいて変数は以下のように解釈される.

  • global xと宣言されているならば,xはglobal変数となる.
  • nonlocal xと宣言されているならば,周囲で定義されているlocal変数xとなる.
  • 変数xが引数,または関数内で代入されていれば,xはlocal変数となる.
  • 変数xが参照されていても,引数でもなく代入されなければ,
    • 周囲の関数内で定義されているか検索する.
    • 周囲のスコープで見つからなければ,global scopeを検索する.
    • global scopeになければ__builtins__.__dict__を検索する.

関数の実行時間を計測する単純なデコレータは以下の通りである.

import time

def clock(func):
    def clocked(*args): 
        t0 = time.perf_counter()
        result = func(*args)
        elapsed = time.perf_counter() - t0
        name = func.__name__
        arg_str = ', '.join(repr(arg) for arg in args)
        print(f'[{elapsed:0.8f}s] {name}({arg_str}) -> {result!r}')
        return result
    return clocked

例えばtime.sleep関数の呼び出し時間を計測すると分かりやすい.

>>> @clock
... def snooze(seconds):
...     time.sleep(seconds)
... 
>>> snooze(.123)
[0.12803992s] snooze(0.123) -> None

デコレータを作成する時は,functools.wrapsデコレータを使用すると,引数の指定やドキュメンテーション文字列の設定などを適切に処理できる.

https://docs.python.org/ja/3/library/functools.html#functools.wraps

def clock(func):
    @functools.wraps(func)
    def clocked(*args, **kwargs):
        t0 = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - t0
        name = func.__name__
        arg_lst = [repr(arg) for arg in args]
        arg_lst.extend(f'{k}={v!r}' for k, v in kwargs.items())
        arg_str = ', '.join(arg_lst)
        print(f'[{elapsed:0.8f}s] {name}({arg_str}) -> {result!r}')
        return result
    return clocked

Pythonにはデコレータとして使用するビルトイン関数 propertyclassmethodstaticmethod が用意されている.

メモ化にはfunctools.cache (lru_cache(maxsize=None)と同じ.Python 3.9以降),functools.lru_cacheが便利である.

https://docs.python.org/ja/3/library/functools.html#functools.cache

ジェネリック関数のような,引数の型に応じて別の関数を登録して,外部からは1個の関数のように振る舞う関数を作成するには@singledispatchx.registerを使用する.

https://peps.python.org/pep-0443/

動的スコープ(dynamic scope)とは,呼び出された環境によって評価される仕組みであり,静的スコープ(lexical scope)とは,定義された環境によって評価される仕組みである.動的スコープの方が実装が簡単ではあるが,プログラマーのミスを招きやすいため注意が必要である.現在は,ソースコードが読みやすいため,静的スコープがよく用いられているが,プログラミング言語を実装する時に,クロージャーに対応しなければならないため実装が大変である.

Chapter 10. Design Patterns with First-Class Functions

Strategy パターンとは,予め用意した複数のアルゴリズムを実行時に選択することができるデザインパターンである.

Command パターンとは,操作する手順の定義と,その実行を分けるデザインパターンである,操作を記録できるので,取り消し・やり直しなど実装するのに用いられる.

これらをPythonで実装する時は,第一級オブジェクトである関数を使うことで,重複するソースコードを減らすことができる.

Part III. Classes and Protocols

Chapter 11. A Pythonic Object

オブジェクトを示す文字列を取得する方法が2個ある.repr()は開発者が見たい情報の文字列,str()はユーザーが見たい情報の文字列を返す.それぞれ__repr____str__で実装する.

bytes()はオブジェクトの情報を示すbytesを返す.format()はf文字列 (f-string) のための文字列を返す.それぞれ__bytes____format__で実装する.

以下のVector2dクラスは上記のような表現を実装した例である.

from array import array
import math

class Vector2d:
    typecode = 'd'  

    def __init__(self, x, y):
        self.x = float(x)    
        self.y = float(y)

    def __iter__(self):
        return (i for i in (self.x, self.y))  

    def __repr__(self):
        class_name = type(self).__name__
        return '{}({!r}, {!r})'.format(class_name, *self)  

    def __str__(self):
        return str(tuple(self))  

    def __bytes__(self):
        return (bytes([ord(self.typecode)]) +  
                bytes(array(self.typecode, self)))  

    def __eq__(self, other):
        return tuple(self) == tuple(other)  

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

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

これは以下のように振る舞う.

>>> v1 = Vector2d(3, 4)
>>> print(v1.x, v1.y)
3.0 4.0
>>> x, y = v1
>>> x, y
(3.0, 4.0)
>>> v1
Vector2d(3.0, 4.0)
>>> v1_clone = eval(repr(v1))
>>> v1 == v1_clone
True
>>> print(v1)
(3.0, 4.0)
>>> octets = bytes(v1) 
>>> octets
b'd\x00\x00\x00\x00\x00\x00\x08@\x00\x00\x00\x00\x00\x00\x10@'
>>> abs(v1)
5.0
>>> bool(v1), bool(Vector2d(0, 0))
(True, False)

classmethodデコレータとstaticmethodデコレータが存在する.classmethodデコレータは有用だが,わざわざstaticmethodデコレータを使わなければならない状況は珍しい.classそのものには触れないけれどもclassにとても関係している関数に使用するのだが,そのような場合は,そのclassの直前か直後に同じモジュールの関数として定義すればよい.

さらに機能を追加した.

from array import array
import math

class Vector2d:
    __match_args__ = ('x', 'y')

    typecode = 'd'

    def __init__(self, x, y):
        self.__x = float(x)
        self.__y = float(y)

    @property
    def x(self):
        return self.__x

    @property
    def y(self):
        return self.__y

    def __iter__(self):
        return (i for i in (self.x, self.y))

    def __repr__(self):
        class_name = type(self).__name__
        return '{}({!r}, {!r})'.format(class_name, *self)

    def __str__(self):
        return str(tuple(self))

    def __bytes__(self):
        return (bytes([ord(self.typecode)]) +
                bytes(array(self.typecode, self)))

    def __eq__(self, other):
        return tuple(self) == tuple(other)

    def __hash__(self):
        return hash((self.x, self.y))

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

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

    def angle(self):
        return math.atan2(self.y, self.x)

    def __format__(self, fmt_spec=''):
        if fmt_spec.endswith('p'):
            fmt_spec = fmt_spec[:-1]
            coords = (abs(self), self.angle())
            outer_fmt = '<{}, {}>'
        else:
            coords = self
            outer_fmt = '({}, {})'
        components = (format(c, fmt_spec) for c in coords)
        return outer_fmt.format(*components)

    @classmethod
    def frombytes(cls, octets):
        typecode = chr(octets[0])
        memv = memoryview(octets[1:]).cast(typecode)
        return cls(*memv)

hash()に対応したので以下のように振る舞う.

>>> v1 = Vector2d(3, 4)
>>> v2 = Vector2d(3.1, 4.2)
>>> hash(v1), hash(v2)
(1079245023883434373, 1994163070182233067)
>>> {v1, v2}
{Vector2d(3.1, 4.2), Vector2d(3.0, 4.0)}
>>> v1.x
3.0
>>> v1.y
4.0

propertyデコレータを使って,read-onlyになる.

>>> v1 = Vector2d(3, 4)
>>> v1.x, v1.y
(3.0, 4.0)
>>> v1.x = 123
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: can't set attribute 'x'

format()はこのように動作する.

>>> format(Vector2d(1, 1), 'p')
'<1.4142135623730951, 0.7853981633974483>'
>>> format(Vector2d(1, 1), '.3ep')
'<1.414e+00, 7.854e-01>'
>>> format(Vector2d(1, 1), '0.5fp')
'<1.41421, 0.78540>'

angle()はこのように動作する.

>>> Vector2d(0, 0).angle()
0.0
>>> Vector2d(1, 0).angle()
0.0
>>> epsilon = 10**-8
>>> abs(Vector2d(0, 1).angle() - math.pi/2) < epsilon
True
>>> abs(Vector2d(1, 1).angle() - math.pi/4) < epsilon
True

frombytes() を使えばシリアライズできる.

>>> v1_clone = Vector2d.frombytes(bytes(v1))
>>> v1_clone
Vector2d(3.0, 4.0)
>>> v1 == v1_clone
True

名前の最初に,"_" (アンダースコア)を2個つけると,名前修飾 (name mangling) により外部からアクセスしにくくなり,private修飾子のようになる.しかしアクセスしようと思えばできるので,Javaのような完全なprivate修飾子ではない.

>>> v1 = Vector2d(3, 4)
>>> v1.__dict__
{'_Vector2d__x': 3.0, '_Vector2d__y': 4.0}
>>> v1._Vector2d__x
3.0

__slots__を使用すると,アトリビュートを__dict__に保持しなくなるので,メモリー使用量を減らすことができる.ただし,とてもたくさんの数値を扱うならば,NumPyarrayを使うべきである.

Chapter 12. Special Methods for Sequences

このVectorクラスは任意の数の次元に対応しているが,説明用のものであり,実際はNumpyを使うこと.

from array import array
import reprlib
import math
import functools
import operator
import itertools  


class Vector:
    typecode = 'd'

    def __init__(self, components):
        self._components = array(self.typecode, components)

    def __iter__(self):
        return iter(self._components)

    def __repr__(self):
        components = reprlib.repr(self._components)
        components = components[components.find('['):-1]
        return f'Vector({components})'

    def __str__(self):
        return str(tuple(self))

    def __bytes__(self):
        return (bytes([ord(self.typecode)]) +
                bytes(self._components))

    def __eq__(self, other):
        return (len(self) == len(other) and
                all(a == b for a, b in zip(self, other)))

    def __hash__(self):
        hashes = (hash(x) for x in self)
        return functools.reduce(operator.xor, hashes, 0)

    def __abs__(self):
        return math.hypot(*self)

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

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

    def __getitem__(self, key):
        if isinstance(key, slice):
            cls = type(self)
            return cls(self._components[key])
        index = operator.index(key)
        return self._components[index]

    __match_args__ = ('x', 'y', 'z', 't')

    def __getattr__(self, name):
        cls = type(self)
        try:
            pos = cls.__match_args__.index(name)
        except ValueError:
            pos = -1
        if 0 <= pos < len(self._components):
            return self._components[pos]
        msg = f'{cls.__name__!r} object has no attribute {name!r}'
        raise AttributeError(msg)

    def angle(self, n):  
        r = math.hypot(*self[n:])
        a = math.atan2(r, self[n-1])
        if (n == len(self) - 1) and (self[-1] < 0):
            return math.pi * 2 - a
        else:
            return a

    def angles(self):  
        return (self.angle(n) for n in range(1, len(self)))

    def __format__(self, fmt_spec=''):
        if fmt_spec.endswith('h'):  # hyperspherical coordinates
            fmt_spec = fmt_spec[:-1]
            coords = itertools.chain([abs(self)],
                                     self.angles()) 
            outer_fmt = '<{}>'  
        else:
            coords = self
            outer_fmt = '({})'  
        components = (format(c, fmt_spec) for c in coords)  
        return outer_fmt.format(', '.join(components))  

    @classmethod
    def frombytes(cls, octets):
        typecode = chr(octets[0])
        memv = memoryview(octets[1:]).cast(typecode)
        return cls(memv)

任意の次元の動作

>>> Vector([3.1, 4.2])
Vector([3.1, 4.2])
>>> Vector((3, 4, 5))
Vector([3.0, 4.0, 5.0])
>>> Vector(range(10))
Vector([0.0, 1.0, 2.0, 3.0, 4.0, ...])

3次元データの動作

>>> v1 = Vector([3, 4, 5])
>>> x, y, z = v1
>>> x, y, z
(3.0, 4.0, 5.0)
>>> v1
Vector([3.0, 4.0, 5.0])
>>> v1_clone = eval(repr(v1))
>>> v1 == v1_clone
True
>>> print(v1)
(3.0, 4.0, 5.0)
>>> abs(v1)
7.0710678118654755
>>> bool(v1), bool(Vector([0, 0, 0]))
(True, False)

Sequenceとしての動作.__len____getitem__が必要.

>>> v1 = Vector([3, 4, 5])
>>> len(v1)
3
>>> v1[0], v1[len(v1)-1], v1[-1]
(3.0, 5.0, 5.0)

Sequenceなので,スライスできる.

>>> v7 = Vector(range(7))
>>> v7[-1]
6.0
>>> v7[1:4]
Vector([1.0, 2.0, 3.0])
>>> v7[-1:]
Vector([6.0])

__getattr__を実装すれば,動的なアトリビュートが動作する.

>>> v7 = Vector(range(10))
>>> v7.x
0.0
>>> v7.y, v7.z, v7.t
(1.0, 2.0, 3.0)

Chapter 13. Interfaces, Protocols, and ABCs

Python3.8以降で可能な4個の型

  • Duck typing: Pythonのデフォルトの方法
  • Goose typing: ランタイムでabstract base class (ABC)に対してオブジェクトをisinstanceで確認する方法
  • Static typing: typingモジュールを利用して型ヒントを利用する方法
  • Static duck typing: typing.Protocolのサブクラスを利用して型ヒントを利用する方法

モンキーパッチ (Monkey patch)は実行時にモジュールやクラス・関数を変更することで,機能を追加したりバグを修正する方法である.

プログラムの修正を容易にするために,なるべく早く例外を発生させる(fail fast)のがよい.例えば,以下の例では引数がiterableでなければTypeErrorとなる.

    def __init__(self, iterable):
        self._balls = list(iterable)

ただし,データが大きかったり変更してはならないデータの場合は,isinstance(x, abc.MutableSequence)などで確認する.

よく使われるABCは以下の通りである.

  • Iterable, Container, Sized
  • Collection
  • Sequence, Mapping, Set
  • MappingView (.items(), .keys(), .values()より,ItemsView, KeysView, ValuesView)
  • Iterator
  • Callable, Hashable

typing.Protocolを利用していても,@runtime_checkableを使えば,実行時にisinstanceを利用できる.

4個の方法はそれぞれ長所と短所があるので,場合に合わせて使用するのがよい.ともあれ,PythonはDuck typingのみをサポートしていた頃には人気になっていたわけであり,Duck typingはシンプルで強力な方法である.

Chapter 14. Inheritance: For Better or for Worse

ビルトイン関数super()を使用して,サブクラスからスーパークラスの関数を呼び出す.Javaはスーパークラスのコンストラクタを自動で呼び出すが,Pythonにはその機能がないので,以下のように明記する.

    def __init__(self, a, b) :
        super().__init__(a, b)
        ...  # さらなる初期化の処理

super()ではなく直接スーパークラスの名前を指定してもよいが,コードを修正した時に修正し忘れるのを防ぐことと,多重継承した時に追加の処理が必要になるため,推奨されない.super()に引数を指定してもよいが,省略してもほとんどの場合問題ない.

dict, list, strのようなビルトインのサブクラスを直接作成すると問題が発生するので,UserDictなどを使用する.

多重継承する時は,method resolution order (MRO)を考慮するが,super()によって処理される.

ミックスイン(mixin) クラスは多重継承される前提で設計されたクラスである.Djangoのclass-based view APIなどで用いられている.

最近は継承を避ける傾向にあるが,継承がうまくいく場合もある.もし継承しなくてもよい方法があれば,避ければよい.継承を使わざるを得ない場合もある.

Chapter 15. More About Type Hints

@typing.overloadを使えば,異なる引数の組み合わせを表現できる.

JSONのような動的なデータ構造を扱う時に,TypedDictを用いてエラーを防ぎたくなるが,JSONはランタイムで処理させるものなので,型ヒントでは扱えないものである.pydanticのようなパッケージを用いるべきである.

pydantic

TypedDictdictをクラスのような構文で表現するものであり,それぞれのフィールドの型ヒントを定義すること,コンストラクタが型チェックのヒントになることを可能にする.しかし,ランタイムでは単なるdictであり,型チェックをしない.json.loads()のような動的なデータ構造に対して,型チェックは無意味である.

typing.cast()はランタイムでは全く何もしない関数であるが,型チェックにおける型の修正を可能にする.便利ではあるが,多用する必要があるならば,型ヒントを誤って使用していたり,コードベースに質の低い依存があるかもしれない.

typing.castで修正できない場合は,# type: ignoreAnyを使用する.そもそも型ヒントを関数につけなくてもよいかもしれない.

型ヒントはランタイムに影響がないと述べたが,実はインポート時に__annotations__にそれらの情報が保存される.そのため,型ヒントを多用すると,インポート時にCPUとメモリーを余計に使用したり,未定義の型を参照するには実際の型ではなくて名前の文字列を指定しなければならない,という2個の問題が発生する.以下の例では,Rectangleクラスを定義する途中で,戻り値がRectangleであるという記載をするために,文字列で記載している.ランタイムでも名前の文字列を解決しなければならない.

class Rectangle:
    def stretch(self, factor: float) -> 'Rectangle':
        return Rectangle(width=self.width * factor)

これらの問題をどのように解決するかは,現在議論されている段階ではあるが,inspect.get_annotationsを使用するのがよいだろう.

inspect --- 活動中のオブジェクトの情報を取得する — Python 3.10.4 ドキュメント

型ヒントの使い方については,以下のドキュメントを読むべきであるが,Python 3.11で更新される可能性がある.

Annotations Best Practices — Python 3.10.5 documentation

ジェネリッククラスを作成するには,T = TypeVar('T')を宣言してから,Generic[T]を基底クラスに指定する.サブタイプに対応するには,T_co = TypeVar('T_co', covariant=True)や,T_contra = TypeVar('T_contra', contravariant=True)などを利用する.

Chapter 16. Operator Overloading

Pythonは中置演算子を採用しており,1 + rateのように書く.演算子をオーバーロードできるので,変数をintからdecimal.Decimalに置き換えてもそのまま動作する.Javaのように演算子がプリミティブ型にしか対応していない場合は,演算子ではなく専用の関数を使用して書き直すことになり面倒である.

演算子にはいくつかの制限があり,ビルトイン型の演算子の意味は変更できないし,新しい演算子を定義できないし,そもそもis, or, and, notはオーバーロードできない.

単項演算子(unary operator)は,- (__neg__で実装),+ (__pos__で実装),~ (__invert__で実装)の3個であり,1個のおbジェクトしか関与しないので,実装は簡単である.

Vectorの加算のために+をオーバーロードするには,__add__とともに__radd__も定義する必要がある.交換法則が成り立つならば,__radd__はたいてい__add__と同じなので,以下のように簡単に書ける.

    def __add__(self, other):
        try:
            pairs = itertools.zip_longest(self, other, fillvalue=0.0)
            return Vector(a + b for a, b in pairs)
        except TypeError:
            return NotImplemented

    def __radd__(self, other):
        return self + other

Vectorの乗算のために*をオーバーロードするには,__mul____rmul__を実装する.

    def __mul__(self, scalar):
        try:
            factor = float(scalar)
        except TypeError:  1
            return NotImplemented  2
        return Vector(n * factor for n in self)

    def __rmul__(self, scalar):
        return self * scalar 

@はデコレータに用いる記号ではあるが,行列式の乗算の演算子としても使用できる.__matmul____rmatmul____imatmul__を実装する.

演算子の種類とそれを実装するためのメソッドの一覧は以下に記載されている.

3. データモデル — Python 3.10.4 ドキュメント

比較演算子には,==!=><>=<=が存在する.__eq__は前後が逆でも同じ結果だが,大小を評価する場合は前後の順番が逆になれば結果も逆になることに注意して実装する.

immutableな型の代入演算子は自動的に対応されることが多いが,mutableな型などで必要があれば__iadd__などを実装する.

演算子のオーバーロードをうまく活用している例として,pathlibがある./演算子をパスの結合に使用している.とはいえ過剰な使用は控えるべきである.

Part IV. Control Flow

Chapter 17. Iterators, Generators, and Classic Coroutines

Iterator デザインパターンをPythonで使うにはiterableを利用する.

ビルトイン関数iter(x)は,オブジェクトが__iter__を実装しれいればそれを呼び出し,__getitem__を実装していれば0から始まるインデックスでiteratorを作成し,いずれもなければTypeErrorとなる.そのためisinstance(x, abc.Iterable)は不正確であり,iter(x)を呼び出してTypeErrorを処理するべきである.

iter()の第2引数を利用すれば,例えばファイルから64バイトずつ読み込むことができる.

from functools import partial

with open('mydata.db', 'rb') as f:
    read64 = partial(f.read, 64)
    for block in iter(read64, b''):
        process_block(block)

iterableとiteratorを明確に区別するべきである.Pythonはiterableからiteratorを得る.例えばstrはiterableであり,forを使えばiteratorを気にすることなく繰り返し処理ができる.これを明瞭にするためにwhileを使って同等の処理を記述すると以下のようになる.

>>> s = "ABC"
>>> it = iter(s)
>>> it
<str_iterator object at 0x1004ab0a0>
>>> while True:
...     try:
...             print(next(it))
...     except StopIteration:
...             del it
...             break
... 
A
B
C

Iteratorはnext()から呼び出されると要素を返すが,要素がなければStopIterationとなる.forはこれらの処理を暗黙に行ってくれる.

iteratorはリセットできないので,再度iter()を用いて作成する.

yieldキーワードが存在する関数はgenerator関数である.以下の例ではgen_ABというgenerator関数から作成されたgeneratorに対してforで反復処理をしており,yieldキーワードによって処理が中断されながら進んでいく様子がよく分かる.

>>> def gen_AB():
...     print('start')
...     yield 'A'
...     print('continue')
...     yield 'B'
...     print('end.')
... 
>>> for c in gen_AB(): 
...     print('-->', c)
... 
start
--> A
continue
--> B
end.

これにより,必要な値を可能な限り遅い時に評価するので,メモリー使用量を減らし,無駄なCPU時間を削減できる.

ジェネレータ式 (generator expression) を利用して,(match.group() for match in RE_WORD.finditer(text))のようにも書けるが,数行に渡ると読みにくくなるので,その場合はジェネレーター関数で書くべきである.

ジェネレーター式が単一の引数として関数に渡される場合は,例えばVector(n * scalar for n in self)のように,少括弧()は省略できるが,引数が2個以上の場合は省略できない.

itertoolsモジュールにはたくさんの便利な関数があるため,積極的に利用するべきである.

itertools --- 効率的なループ実行のためのイテレータ生成関数 — Python 3.10.4 ドキュメント

os.walkも便利なジェネレーター関数である.

os — Miscellaneous operating system interfaces — Python 3.10.5 documentation

具体的な関数の使用例を列挙する.

filter

>>> def vowel(c):
...     return c.lower() in 'aeiou'
... 
>>> list(filter(vowel, 'Aardvark'))
['A', 'a', 'a']
>>> import itertools
>>> list(itertools.filterfalse(vowel, 'Aardvark'))
['r', 'd', 'v', 'r', 'k']
>>> list(itertools.dropwhile(vowel, 'Aardvark'))
['r', 'd', 'v', 'a', 'r', 'k']
>>> list(itertools.takewhile(vowel, 'Aardvark'))
['A', 'a']
>>> list(itertools.compress('Aardvark', (1, 0, 1, 1, 0, 1)))
['A', 'r', 'd', 'a']
>>> list(itertools.islice('Aardvark', 4))
['A', 'a', 'r', 'd']
>>> list(itertools.islice('Aardvark', 4, 7))
['v', 'a', 'r']
>>> list(itertools.islice('Aardvark', 1, 7, 2))
['a', 'd', 'a']

itertools.accumulate

>>> sample = [5, 4, 2, 8, 7, 6, 3, 0, 9, 1]
>>> import itertools
>>> list(itertools.accumulate(sample))
[5, 9, 11, 19, 26, 32, 35, 35, 44, 45]
>>> list(itertools.accumulate(sample, min))
[5, 4, 2, 2, 2, 2, 2, 0, 0, 0]
>>> list(itertools.accumulate(sample, max))
[5, 5, 5, 8, 8, 8, 8, 8, 9, 9]
>>> import operator
>>> list(itertools.accumulate(sample, operator.mul))
[5, 20, 40, 320, 2240, 13440, 40320, 0, 0, 0]
>>> list(itertools.accumulate(range(1, 11), operator.mul))
[1, 2, 6, 24, 120, 720, 5040, 40320, 362880, 3628800]

enumerate, map

>>> list(enumerate('albatroz', 1))
[(1, 'a'), (2, 'l'), (3, 'b'), (4, 'a'), (5, 't'), (6, 'r'), (7, 'o'), (8, 'z')]
>>> import operator
>>> list(map(operator.mul, range(11), range(11)))
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81, 100]
>>> list(map(operator.mul, range(11), [2, 4, 8]))
[0, 4, 16]
>>> list(map(lambda a, b: (a, b), range(11), [2, 4, 8]))
[(0, 2), (1, 4), (2, 8)]
>>> import itertools
>>> list(itertools.starmap(operator.mul, enumerate('albatroz', 1)))
['a', 'll', 'bbb', 'aaaa', 'ttttt', 'rrrrrr', 'ooooooo', 'zzzzzzzz']
>>> sample = [5, 4, 2, 8, 7, 6, 3, 0, 9, 1]
>>> list(itertools.starmap(lambda a, b: b / a, enumerate(itertools.accumulate(sample), 1)))
[5.0, 4.5, 3.6666666666666665, 4.75, 5.2, 5.333333333333333, 5.0, 4.375, 4.888888888888889, 4.5]

itertools.chain, zip

>>> import itertools
>>> list(itertools.chain('ABC', range(2)))
['A', 'B', 'C', 0, 1]
>>> list(itertools.chain(enumerate('ABC')))
[(0, 'A'), (1, 'B'), (2, 'C')]
>>> list(itertools.chain.from_iterable(enumerate('ABC')))
[0, 'A', 1, 'B', 2, 'C']
>>> list(zip('ABC', range(5), [10, 20, 30, 40]))
[('A', 0, 10), ('B', 1, 20), ('C', 2, 30)]
>>> list(itertools.zip_longest('ABC', range(5)))
[('A', 0), ('B', 1), ('C', 2), (None, 3), (None, 4)]
>>> list(itertools.zip_longest('ABC', range(5), fillvalue='?'))
[('A', 0), ('B', 1), ('C', 2), ('?', 3), ('?', 4)]

itertools.product

>>> import itertools
>>> list(itertools.product('ABC', range(2)))
[('A', 0), ('A', 1), ('B', 0), ('B', 1), ('C', 0), ('C', 1)]
>>> suits = 'spades hearts diamonds clubs'.split()
>>> list(itertools.product('AK', suits))
[('A', 'spades'), ('A', 'hearts'), ('A', 'diamonds'), ('A', 'clubs'), ('K', 'spades'), ('K', 'hearts'), ('K', 'diamonds'), ('K', 'clubs')]
>>> list(itertools.product('ABC'))
[('A',), ('B',), ('C',)]
>>> list(itertools.product('ABC', repeat=2))
[('A', 'A'), ('A', 'B'), ('A', 'C'), ('B', 'A'), ('B', 'B'), ('B', 'C'), ('C', 'A'), ('C', 'B'), ('C', 'C')]
>>> list(itertools.product(range(2), repeat=3))
[(0, 0, 0), (0, 0, 1), (0, 1, 0), (0, 1, 1), (1, 0, 0), (1, 0, 1), (1, 1, 0), (1, 1, 1)]
>>> rows = itertools.product('AB', range(2), repeat=2)
>>> for row in rows: print(row)
... 
('A', 0, 'A', 0)
('A', 0, 'A', 1)
('A', 0, 'B', 0)
('A', 0, 'B', 1)
('A', 1, 'A', 0)
('A', 1, 'A', 1)
('A', 1, 'B', 0)
('A', 1, 'B', 1)
('B', 0, 'A', 0)
('B', 0, 'A', 1)
('B', 0, 'B', 0)
('B', 0, 'B', 1)
('B', 1, 'A', 0)
('B', 1, 'A', 1)
('B', 1, 'B', 0)
('B', 1, 'B', 1)

count, cycle, pairwise, repeat

>>> import itertools
>>> ct = itertools.count()
>>> next(ct)
0
>>> next(ct), next(ct), next(ct)
(1, 2, 3)
>>> list(itertools.islice(itertools.count(1, .3), 3))
[1, 1.3, 1.6]
>>> cy = itertools.cycle('ABC')
>>> next(cy)
'A'
>>> list(itertools.islice(cy, 7))
['B', 'C', 'A', 'B', 'C', 'A', 'B']
>>> list(itertools.pairwise(range(7)))
[(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (5, 6)]
>>> rp = itertools.repeat(7)
>>> next(rp), next(rp)
(7, 7)
>>> list(itertools.repeat(8, 4))
[8, 8, 8, 8]
>>> import operator
>>> list(map(operator.mul, range(11), itertools.repeat(5)))
[0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50]

組み合わせ

>>> import itertools
>>> list(itertools.combinations('ABC', 2))
[('A', 'B'), ('A', 'C'), ('B', 'C')]
>>> list(itertools.combinations_with_replacement('ABC', 2))
[('A', 'A'), ('A', 'B'), ('A', 'C'), ('B', 'B'), ('B', 'C'), ('C', 'C')]
>>> list(itertools.permutations('ABC', 2))
[('A', 'B'), ('A', 'C'), ('B', 'A'), ('B', 'C'), ('C', 'A'), ('C', 'B')]
>>> list(itertools.product('ABC', repeat=2))
[('A', 'A'), ('A', 'B'), ('A', 'C'), ('B', 'A'), ('B', 'B'), ('B', 'C'), ('C', 'A'), ('C', 'B'), ('C', 'C')]

itertools.groupby

同じキーをもつような要素からなる iterable 中のグループに対して,キーとグループを返すようなイテレータを作成する.

>>> import itertools
>>> for char, group in itertools.groupby('LLLLAAAGG'):
...     print(char, '->', list(group))
... 
L -> ['L', 'L', 'L', 'L']
A -> ['A', 'A', 'A']
G -> ['G', 'G']

itertools.tee

一つの iterable から n 個の独立したイテレータを返す.

>>> import itertools
>>> g1, g2, g3 = itertools.tee('ABC', 3)
>>> next(g1)
'A'
>>> next(g2)
'A'
>>> next(g2)
'B'
>>> list(g1)
['B', 'C']
>>> list(g2)
['C']
>>> list(g3)
['A', 'B', 'C']

generatorを引数として受け取ってgeneratorを返すこれらの関数を組み合わせることで,たくさんの用途に使える.さらなる関数はmore-itertoolsパッケージに収録されている.

More Itertools — more-itertools 8.13.0 documentation

yield from でこのようなことができる.

>>> def sub_gen():
...     yield 1.1
...     yield 1.2
... 
>>> def gen():
...     yield 1
...     yield from sub_gen()
...     yield 2
... 
>>> for x in gen():
...     print(x)
... 
1
1.1
1.2
2

generator関数の中でyieldとともにreturnを用いた場合は以下のように動作する.

>>> def sub_gen():
...     yield 1.1
...     yield 1.2
...     return 'Done!'
... 
>>> def gen():
...     yield 1
...     result = yield from sub_gen()
...     print('<--', result)
...     yield 2
... 
>>> for x in gen():
...     print(x)
... 
1
1.1
1.2
<-- Done!
2

型ヒントを使うには,Iterableは関数の引数に使用し,戻り値にはIteratorを使用する.Generator[T, None, None]を使用してもよいが,単にIteratorとして使用するだけならばIterator[T]の方が簡単である.

Classic Coroutine は Generator[YieldType, SendType, ReturnType]と表現される単なるgeneratorであるが,term = yield average などと記載することで,.send()で値を渡して評価することができる.しかし,Python 3.5からnative coroutineが導入されたので,asyncioでは使用されなくなった.

generatorを活用すれば,例えば巨大なデータベースのレコードをストリームとして処理しながら書き出すことで,データのサイズに関係なくメモリ使用量を低く抑えることができる.

Chapter 18. with, match, and else Blocks

context managerでは,withを使って,コードの実行が完了した後に必ず行うべき処理を記述できる.例えばsqlite3のトランザクションの管理や,threadingのロックなど,様々な場所で使用されている.

context managerは,__enter____exit__で定義され,withの最初で__enter__が,最後に__exit__が呼ばれる.

contextlibには,よくあるタスクに対応するcontext managerがあるので,自分で作る前にこれらが利用できないか確認するべきである.

contextlib — Utilities for with-statement contexts — Python 3.10.5 documentation

lis.pyは,PythonでLispのインタープリターを扱うライブラリである.lis.pyは,Python特有の特徴があるコードの例であり,言語デザインとしてとても単純な構造になっている.このソースコードはとても短く,プログラム言語をより深く理解するのに役立つ.この中でmatch/caseが効果的に用いられているので参考になる.

(How to Write a (Lisp) Interpreter (in Python))

Chapter 19. Concurrency Models in Python

並行処理 (concurrency) は,一度にたくさんのことに対応することであり,並列処理 (parallelism) は,一度にたくさんのことをすることである.同じではないが,関連している.前者は構造について,後者は実行についてである.並列処理でも対応できるかもしれない問題を,並行処理で解決することができる.

この章では,一度にたくさんのおとをする方法について解説する.具体的には,Pythonのthreadingmultiprocessingasyncioを取り上げる.

  • 並行処理 (concurrency): 複数の未処理のタスクを処理できること.
  • 並列処理 (parallelism): 同時に複数の計算ができること.
  • 実行ユニット (execution unit): コードを並列処理で実行するオブジェクトを意味する一般名詞のこと.Pythonでは標準で プロセス,スレッド,コルーチンに対応している.
  • プロセス (process): コンピュータープログラムの実行インスタンスのこと.プロセス毎に専用のメモリー領域が割り当てられる.パイプやソケット,共有メモリ (memory-mapped file)により情報をやり取りするが,これらはバイナリーデータしか扱えないので,Pythonでバイナリーデータにシリアライズしなければならない.この処理はコストがかかる.割り込み型マルチタスキング(preemptive multitasking)とは,OSのスケジューラーが実行中のプロセスを強制的に中断させて他のプロセスを実行することである.これにより,1個のプロセスが応答しない場合でも,通常はシステム全体が応答しなくなることはない.
  • スレッド (thread): 1個のプロセスで用いられる実行ユニットのこと.プロセスを起動すると,メインスレッドという名前の1個のスレッドを使用する.1個のプロセスは,OSのAPIを通じて追加のスレッドを作成できる.同じプロセスのスレッドはメモリーを共有するので,Pythonのオブジェクトをそのまま扱える.スレッド間ではデータのやり取りは簡単だが,同じオブジェクトを並行して複数のスレッドが変更するとデータが破損する場合がある.プロセスと同様に,スレッドも割り込み型マルチタスキングに対応している.同じ処理をするならば,プロセスよりもスレッドの方が必要なリソースは少ない.
  • コルーチン (corutine): 中断して後で再開できる関数のこと.Pythonではclassic coroutineはジェネレーター関数により作成され,native coroutineはasync defで定義される.Pythonのコルーチンは,通常,同じスレッドのevent loopで管理される.コルーチンは共同マルチタスキングであり,それぞれのコルーチンで明示的にyieldawaitキーワードを示して,他のコルーチンに実行を譲らなければならない.つまり,コルーチンの中に1個でも阻害するコードがあれば,event loopと他のコルーチンの実行が阻害される.同じ処理をするならば,プロセスやスレッドよりもコルーチンの方が必要なリソースは少ない.
  • キュー (queue): 通常は first in, first out (FIFO) の順番でデータを入れたり出したりできるデータ構造のこと.別の実行ユニットからデータをやり取りしたりメッセージを管理できる.キューは並行処理のモデルに応じて実装されるので,Pythonの標準ライブラリ queue はスレッドの対応キュークラスを提供しているが,multiprocessingasyncioパッケージには独自のキュークラスが含まれる.queueasyncioパッケージには,LifoQueuePriorityQueueなどFIFOではないキューも含まれる.
  • ロック (lock): 複数の実行ユニットが,データを破壊することなく,同期して処理できるようにするためのオブジェクトのこと.共有データ構造を変更する時は,実行コードは関連するロックをするべきである.これにより,他の部分のプログラムは,その共有データ構造にアクセスする前に,ロックが開放されるまで待機する.ロックの最も簡単なものはミューテックス (mutex)という名前でも知られている.並行処理のモデルに応じて,ロックは実装される
  • 競合 (contention): 制限されたリソース巡る争いのこと.リソース競合は,複数の実行ユニットが,ロックや記憶域などの共有リソースにアクセスしようとする時に発生する.CPU競合は,計算負荷が高いプロセスやスレッドが,CPU時間を得るためにOSのスケジューラーを待たなければならない時に発生する.

これらの専門用語をPythonに当てはめると,以下のようになる.

  1. Pythonインタープリターのインスタンスはプロセスである.multiprocessingconcurrent.futuresライブラリを使えば追加のPythonプロセスを起動できる.
  2. Pythonインタープリターはユーザーのプログラムやガーベッジコレクタを実行するのに1個のスレッドを使う.threadingconcurrent.futuresライブラリを使えば追加のPythonスレッドを起動できる.
  3. オブジェクトへの参照や他のインタープリター内部の状態へのアクセスは,Global Interpreter Lock (GIL)と呼ばれるロックによって 管理されている.いかなる時も1個のスレッドのみがGILを使える.つまり,CPUのコア数に関わらず,Pythonコードを実行できるスレッドは1個だけである.
  4. Pythonスレッドが無期限にGILを使わないようにするために,Pythonインタープリターは初期設定で5ミリ秒毎にスレッドを一時停止しGILを開放する.そのスレッドはGILを再度使用することを試みることはできるが,他のスレッドが待機しているならば,OSのスケジューラーはその中の1個のスレッドを選択して処理を再開させる.
  5. Pythonコードを作成するにあたり,我々はGILについてどうすることもできない.しかし,ビルトイン関数やC言語で書かれた拡張,その他の言語でPython/C APIのレベルのインターフェイスを提供しているものは,時間がかかる処理をする時にGILを開放できる.
  6. ディスクのI/O,ネットワークのI/O,time.sleep()など,システムコールを使うPython標準ライブラリの関数は,GILを開放する.NumPy/SciPyライブラルやzlibbz2モジュールなどの CPUに負荷を書ける関数もGILを開放する.
  7. Python/C APIのレベルで機能を提供する拡張は,GILに影響を及ぼさない非Pythonスレッドを起動できる.このようなGILが関与しないスレッドは,Pythonのオブジェクトを変更できないが, bytearrayarray.array,Numpy Arrayなどのbuffer protocolに対応したオブジェクトを保存したメモリーの読み書きはできる.
  8. ネットワークを使用するプログラムに対するGILの影響は比較的少ない.なぜならば,I/O関数はGILを開放するからである.また,メモリーの読み書きと比べて,ネットワークのレイテンシーは高いからである.その結果として,スレッドは多くの時間待機する.全体のスループットを考慮する時にGILの影響が出にくい.このため,David Beazleyは「Pythonスレッドは何もしないという点において素晴らしい」と述べた.
  9. GILの競合により,計算負荷が高いPythonスレッドは遅くなる.計算負荷が高い場合,順次的に実行されるシングルスレッドのコードの方が単純で速度も速くなる.
  10. CPU負荷が高いPythonコードを複数のCPUコアで実行するには,複数のPythonプロセスを起動しなければならない.

以下のソースコードではスレッドを使用しており,実行すると,/ thinking!とアニメーションが3秒間表示され,Answer: 42と表示されて終了する.

import itertools
import time
from threading import Thread, Event

def spin(msg: str, done: Event) -> None: 
    for char in itertools.cycle(r'\|/-'):  
        status = f'\r{char} {msg}'  
        print(status, end='', flush=True)
        if done.wait(.1):  
            break  
    blanks = ' ' * len(status)
    print(f'\r{blanks}\r', end='')  

def slow() -> int:
    time.sleep(3)  
    return 42

def supervisor() -> int:  
    done = Event()  
    spinner = Thread(target=spin, args=('thinking!', done))  
    print(f'spinner object: {spinner}')  
    spinner.start()  
    result = slow()  
    done.set()  
    spinner.join() 
    return result

def main() -> None:
    result = supervisor()  
    print(f'Answer: {result}')

if __name__ == '__main__':
    main()

以下のソースコードではプロセスを使用しており,同じような動作をする.

import itertools
import time
from multiprocessing import Process, Event
from multiprocessing import synchronize 

def spin(msg: str, done: synchronize.Event) -> None: 
    for char in itertools.cycle(r'\|/-'):  
        status = f'\r{char} {msg}'  
        print(status, end='', flush=True)
        if done.wait(.1):  
            break  
    blanks = ' ' * len(status)
    print(f'\r{blanks}\r', end='')  

def slow() -> int:
    time.sleep(3)  
    return 42

def supervisor() -> int:
    done = Event()
    spinner = Process(target=spin, args=('thinking!', done))
    print(f'spinner object: {spinner}')          
    spinner.start()
    result = slow()
    done.set()
    spinner.join()
    return result

def main() -> None:
    result = supervisor()  
    print(f'Answer: {result}')

if __name__ == '__main__':
    main()

以下のソースコードではコルーチンを使用しており,同じような動作をする.

import asyncio
import itertools

async def spin(msg: str) -> None:  
    for char in itertools.cycle(r'\|/-'):
        status = f'\r{char} {msg}'
        print(status, flush=True, end='')
        try:
            await asyncio.sleep(.1)  
        except asyncio.CancelledError:  
            break
    blanks = ' ' * len(status)
    print(f'\r{blanks}\r', end='')

async def slow() -> int:
    await asyncio.sleep(3)  
    return 42

def main() -> None:  
    result = asyncio.run(supervisor())  
    print(f'Answer: {result}')

async def supervisor() -> int:  
    spinner = asyncio.create_task(spin('thinking!'))  
    print(f'spinner object: {spinner}')  
    result = await slow()  
    spinner.cancel()  
    return result

if __name__ == '__main__':
    main()

time.sleep()ではなく,await asyncio.sleep()を使用しなければならない点に注意する.

sleep()を,素数を判定するなどのCPUに負荷がかかる関数に置き換えた場合,スピナーは表示されるだろうか?プロセスを用いる場合は,プロセスが異なるためスピナーは表示される.スレッドを用いる場合は,GILの影響でスピナーは表示されなくなると思うかもしれないが,GILはデフォルトで5ミリ秒毎に開放されるので,スピナーは実は表示される.コルーチンを用いる場合は,スピナーは表示されない.

素数を判定する関数を実行するのに,1コアのCPUで処理するのと,6コアのCPUでmultiprocessingを用いて処理するのを比較したところ,本の著者の環境では4.2倍高速になった.キューを使用したりデータをシリアライズするなどのオーバーヘッドが生じるので,CPUコア数倍にはならない.

PythonにGILが存在するためマルチコアCPUの性能を出すには工夫が必要だが,最近はマルチコアや分散コンピューティングに対応したライブラリが充実している.

  • システム管理: Ansible, Salt, Fabric
  • データサイエンス: Jupyer, TensorFlow PyTorch, Dask
  • WSGI: nod_wsgi, uWSSGI, Gunicorn, NGINX Unit
  • 分散タスクキュー: Celery, RQ

Webアプリケーションを作成する場合は,これらを利用した分散コンピューティングによってスケールアウトするよう設計可能であり,PythonはGILが存在しても十分高速に動作する.

Chapter 20. Concurrent Executors

この章では,独立した複数のスレッドを起動して,キューに結果を集める動作を実現するための,concurrent.futures.Executorクラスについて述べる.

ウェブから並行してファイルをダウンロードする作業は一つの例である.順番にダウンロードすると遅いので,concurrent.futuresを使って実装する.

import time
from pathlib import Path
from typing import Callable
from concurrent import futures

import httpx

POP20_CC = ('CN IN US ID BR PK NG BD RU JP MX PH VN ET EG DE IR TR CD FR').split()  

BASE_URL = 'https://www.fluentpython.com/data/flags'  
DEST_DIR = Path('downloaded')

def save_flag(img: bytes, filename: str) -> None:     
    (DEST_DIR / filename).write_bytes(img)

def get_flag(cc: str) -> bytes:  
    url = f'{BASE_URL}/{cc}/{cc}.gif'.lower()
    resp = httpx.get(url, timeout=6.1, follow_redirects=True)  
    resp.raise_for_status()  
    return resp.content

def main(downloader: Callable[[list[str]], int]) -> None:  
    DEST_DIR.mkdir(exist_ok=True)                          
    t0 = time.perf_counter()                               
    count = downloader(POP20_CC)
    elapsed = time.perf_counter() - t0
    print(f'\n{count} downloads in {elapsed:.2f}s')

def download_one(cc: str):  
    image = get_flag(cc)
    save_flag(image, f'{cc}.gif')
    print(cc, end=' ', flush=True)
    return cc

def download_many(cc_list: list[str]) -> int:
    with futures.ThreadPoolExecutor() as executor:         
        res = executor.map(download_one, sorted(cc_list))  

    return len(list(res))                                  

if __name__ == '__main__':
    main(download_many)

ここでhttpxライブラリを使用したのは,同期処理と非同期処理に対応しているからである.標準ライブラリではないため,別途pipでインストールする.

HTTPX

executor.mapexecutor.submitfutures.as_completedに置き換えてもよい.

import time
from pathlib import Path
from typing import Callable

import httpx

POP20_CC = ('CN IN US ID BR PK NG BD RU JP MX PH VN ET EG DE IR TR CD FR').split()  

BASE_URL = 'https://www.fluentpython.com/data/flags'  
DEST_DIR = Path('downloaded')

def save_flag(img: bytes, filename: str) -> None:     
    (DEST_DIR / filename).write_bytes(img)

def get_flag(cc: str) -> bytes:  
    url = f'{BASE_URL}/{cc}/{cc}.gif'.lower()
    resp = httpx.get(url, timeout=6.1, follow_redirects=True)  
    resp.raise_for_status()  
    return resp.content

def main(downloader: Callable[[list[str]], int]) -> None:  
    DEST_DIR.mkdir(exist_ok=True)                          
    t0 = time.perf_counter()                               
    count = downloader(POP20_CC)
    elapsed = time.perf_counter() - t0
    print(f'\n{count} downloads in {elapsed:.2f}s')
from concurrent import futures

def download_one(cc: str):  
    image = get_flag(cc)
    save_flag(image, f'{cc}.gif')
    print(cc, end=' ', flush=True)
    return cc

def download_many(cc_list: list[str]) -> int:
    with futures.ThreadPoolExecutor(max_workers=3) as executor:  
        to_do: list[futures.Future] = []
        for cc in sorted(cc_list):  
            future = executor.submit(download_one, cc)  
            to_do.append(future)  
            print(f'Scheduled for {cc}: {future}')  

        for count, future in enumerate(futures.as_completed(to_do), 1):  
            res: str = future.result()  
            print(f'{future} result: {res!r}')  

    return count
if __name__ == '__main__':
    main(download_many)

max_workers=3を5に増やせば並行処理が増え,1にすれば順番に処理される.何度か実行すると結果の順番が変わるのが興味深い.

上記の例ではネットワークの待機時間を有効活用するためにThreadPoolExecutorを使用したが,CPUに負荷をかける処理では意味がないので,代わりにProcessPoolExecutorを使用する.

なお,公開されているWebサーバーに多数のリクエストを送るとdenial-of-service (DoS)攻撃になるので,時間あたりのリクエスト数を適切に制限してから実行するべきである.

処理の進捗を表示するには,tqdmライブラリが便利である.

並行処理は計算機科学の中でも最も難しい話題の1つなので,可能であれば使用を避けるべきであるが,上述の方法を用いれば比較的簡単に並列処理を実現できる.

Chapter 21. Asynchronous Programming

この章では非同期処理について述べる.Pythonでは,async def, await, async withおよび async forで宣言し,native coroutineとcontext managerの非同期用クラス,iterable, generator, 内包表記が対応している.asyncioや他の非同期処理用のライブラリを用いる.

  • Native coroutine: async defで定義されるコルーチン関数のこと.
  • Classic coroutine: send()でデータを受け取るジェネレーター関数のこと.現在ではasyncioで使用できない.関数内でawaitは使えない.
  • Generator-based coroutine: @types.coroutineデコレータがついたジェネレーター関数のこと.関数内でawaitを使える.
  • Asynchronous generator: async defで定義され,関数内でyieldを使用するジェネレーター関数のこと.

非同期処理は便利だが,全てのコードが処理を遮らないように書き換えなければならず,できなければ時間が無駄になる.(“You rewrite all your code so none of it blocks or you’re just wasting your time.”)

Semaphoreを使えば同時実行数を制限できる.

    async with semaphore:
            image = await get_flag(client, base_url, cc)

node.jsは全てのI/Oの非同期APIが提供されているが,Pythonはそうではないので注意が必要である.ディスクにファイルを保存する時は,await asyncio.to_thread(save_flag, image, f'{cc}.gif')のようにする.

FastAPIを使うと簡単に非同期のWebサーバーが作成できる.

FastAPI

Part V. Metaprogramming

Chapter 22. Dynamic Attributes and Properties

JSONデータを扱う時にプロパティを簡単に扱うなど,プロパティを動的に計算する場合は,functools.cached_propertyが便利である.

    @cached_property
    def venue(self):
        key = f'venue.{self.venue_serial}'
        return self.__class__.fetch(key)

@property@cacheを併用してもよい.

    @property 
    @cache 
    def speakers(self):
        spkr_serials = self.__dict__['speakers']
        fetch = self.__class__.fetch
        return [fetch(f'speaker.{key}')
                for key in spkr_serials]

値を変更するときに検証するには,setterを使う.

    @property 
    def weight(self):  
        return self.__weight  

    @weight.setter  
    def weight(self, value):
        if value > 0:
            self.__weight = value  
        else:
            raise ValueError('value must be > 0')

これはweight = property(get_weight, set_weight)とも書ける.

アトリビュートを扱う上で重要なアトリビュート: * __class__: オブジェクトのクラスへの参照.obj.__class__type(obj)は同じ. * __dict__: オブジェクトやクラスの書き換え可能なアトリビュートのマッピング. * __slots__: クラスで定義可能なアトリビュートで,メモリー使用量を削減するために使用する.

アトリビュートを扱うためのビルトイン関数: * dir([object]): オブジェクトのほとんどのアトリビュートのリストを返す.対話モードでの使用が意図されている. * getattr(object, name[, default]): objectnameアトリビュートを返す. * hasattr(object, name): objectnameアトリビュートが存在すれば,Trueを返す. * setattr(object, name, value): objectnameアトリビュートの値を指定する.新しいアトリビュートが作られるか,既存のアトリビュートが上書きされる. * vars([object]): object__dict__を返す.

Chapter 23. Attribute Descriptors

ディスクリプタにより,複数のアトリビュートにアクセスするための同一のロジックを再利用できる.例えばORMのフィールドの型は,Pythonオブジェクトをデータベースのレコードに格納する方法を管理しており,ディスクリプタである.Pythonのクラスで,__get____set____delete__が実装されていれば,ディスクリプタである.

ディスクリプタを実装する際は以下の点に注意する.

  • 単純に保つためにpropertyを使う.
  • 読み込み専用のディスクリプタにはAttributeErrorを発生させる__set__を実装する.
  • 検証のディスクリプタは,__set__とのみ動作する.
  • キャッシュは__get__とのみ効果的に動作する.
  • 特別でないメソッドはインスタンスのアトリビュートによりシャドーイングされるかもしれない.

Chapter 24. Class Metaprogramming

クラスメタプログラミングは,実行時にクラスを作成したり変更する技術である.Pythonのクラスは第一級オブジェクトなので,classキーワードを使用せずに新しいクラスを作成する関数が実現できる.同様にクラスデコレータもクラスを作成したり置き換えたりできる.

クラスには以下のアトリビュートがある.

  • cls.__bases__: クラスの基底クラスのタプル.
  • cls.__qualname__: グローバルスコープからクラスの定義までの階層をドット区切りで表した,クラスや関数の修飾名.
  • cls.__subclasses__(): クラスのサブクラスのリスト.
  • cls.mro(): クラスの__mro__アトリビュートに保存されたスーパークラスのタプルを取得する.

type(my_object)を実行すればmy_object.__class__が取得できるので,type()はオブジェクトのクラスを取得する関数だと思いがちだが,3個の引数を指定すれば新しいクラスを作成する関数となる.nameはクラスの識別子を,basesは基底クラスのタプルか(object, )dictはアトリビュートのマッピングを指定する.

collections.namedtupleからヒントを得て,クラスを作成するファクトリ関数を実装してみる.

from typing import Union, Any
from collections.abc import Iterable, Iterator

FieldNames = Union[str, Iterable[str]]  

def record_factory(cls_name: str, field_names: FieldNames) -> type[tuple]:  

    slots = parse_identifiers(field_names)  

    def __init__(self, *args, **kwargs) -> None:  
        attrs = dict(zip(self.__slots__, args))
        attrs.update(kwargs)
        for name, value in attrs.items():
            setattr(self, name, value)

    def __iter__(self) -> Iterator[Any]:  
        for name in self.__slots__:
            yield getattr(self, name)

    def __repr__(self):  
        values = ', '.join(f'{name}={value!r}'
            for name, value in zip(self.__slots__, self))
        cls_name = self.__class__.__name__
        return f'{cls_name}({values})'

    cls_attrs = dict(  
        __slots__=slots,
        __init__=__init__,
        __iter__=__iter__,
        __repr__=__repr__,
    )

    return type(cls_name, (object,), cls_attrs)  


def parse_identifiers(names: FieldNames) -> tuple[str, ...]:
    if isinstance(names, str):
        names = names.replace(',', ' ').split()  
    if not all(s.isidentifier() for s in names):
        raise ValueError('names must all be valid identifiers')
    return tuple(names)

指定された識別子からreprなどを自動的に生成するので,とても便利である.

>>> Dog = record_factory('Dog', 'name weight owner')
>>> rex = Dog('Rex', 30, 'Bob')
>>> rex
Dog(name='Rex', weight=30, owner='Bob')
>>> rex.weight = 32
>>> rex
Dog(name='Rex', weight=32, owner='Bob')
>>> "{2}'s dog weighs {1}kg".format(*rex)
"Bob's dog weighs 32kg"
>>> Dog.__mro__
(<class 'h.Dog'>, <class 'object'>)

__init_subclass__は定義されたクラスが継承された時に呼び出される.

Pythonを使うプログラマーは,「インポート時」と「実行時」を対比することがあるが,厳密に定義された用語ではなく,境界が曖昧である.インタープリターはインポート時に,ソースコード(.py)を上から下まで構文解析し,解析に失敗すればSyntaxErrorを発生させ,バイトコードにコンパイルし,コンパイルしたモジュールの最上部のコードを実行する.もし,最新の.pycファイルが__pycache__ディレクトリに存在すれば,コンパイルは省略される.

構文解析やコンパイルは明らかに「インポート時」の動作であるが,同時に別のことも生じる.なぜならば,Pythonのほぼ全ての文は実行可能であり,ユーザーのプログラムの状態を変更してしまう可能性があるからである.

特に,import文は単なる宣言ではなく,処理の最初でインポートされた時にモジュールの最上部のコードを実行する.同じモジュールが再度インポートされるならば,キャッシュが利用され,インポートを実行したモジュールで名前空間が利用できるようになる.その最上部のコードはどんなことも実行可能であり,「実行時」に処理されるようなこと,例えばログを出力したり,データベースに接続したりする,なども想定しうる.このことから,「インポート時」と「実行時」は曖昧である.逆に,「インポート時」は「実行時」の奥深くでも発生しうる.なぜならば,import文は普通の関数内でも実行可能だからである.

これらの事実を実際のソースコード(builderlib.py)で確認してみる.

print('@ builderlib module start')

class Builder:  
    print('@ Builder body')

    def __init_subclass__(cls):  
        print(f'@ Builder.__init_subclass__({cls!r})')

        def inner_0(self):  
            print(f'@ SuperA.__init_subclass__:inner_0({self!r})')

        cls.method_a = inner_0

    def __init__(self):
        super().__init__()
        print(f'@ Builder.__init__({self!r})')


def deco(cls):  
    print(f'@ deco({cls!r})')

    def inner_1(self):  
        print(f'@ deco:inner_1({self!r})')

    cls.method_b = inner_1
    return cls 

class Descriptor:  
    print('@ Descriptor body')

    def __init__(self):  
        print(f'@ Descriptor.__init__({self!r})')

    def __set_name__(self, owner, name):  
        args = (self, owner, name)
        print(f'@ Descriptor.__set_name__{args!r}')

    def __set__(self, instance, value):  
        args = (self, instance, value)
        print(f'@ Descriptor.__set__{args!r}')

    def __repr__(self):
        return '<Descriptor instance>'


print('@ builderlib module end')

これをPython対話モードでインポートすると以下のように出力される.

>>> import builderlib
@ builderlib module start
@ Builder body
@ Descriptor body
@ builderlib module end

別のファイル(evaldemo.py)からインポートして使用する.

from builderlib import Builder, deco, Descriptor

print('# evaldemo module start')

@deco  
class Klass(Builder):  
    print('# Klass body')

    attr = Descriptor()  

    def __init__(self):
        super().__init__()
        print(f'# Klass.__init__({self!r})')

    def __repr__(self):
        return '<Klass instance>'


def main():  
    obj = Klass()
    obj.method_a()
    obj.method_b()
    obj.attr = 999

if __name__ == '__main__':
    main()

print('# evaldemo module end')

対話モードでインポートすると以下のように出力される.

>>> import evaldemo
@ builderlib module start
@ Builder body
@ Descriptor body
@ builderlib module end
# evaldemo module start
# Klass body
@ Descriptor.__init__(<Descriptor instance>)
@ Descriptor.__set_name__(<Descriptor instance>, <class 'evaldemo.Klass'>, 'attr')
@ Builder.__init_subclass__(<class 'evaldemo.Klass'>)
@ deco(<class 'evaldemo.Klass'>)
# evaldemo module end

@と#で2個のソースコードのどちらの部分かが区別できるので確認してほしい.また,builderlib.pyを直接インポートした時と,別のファイルでインポートして使用した時で,実行されるコードの部分が異なることを比較して確認してほしい.

メタクラス(metaclass)はクラスのファクトリである.record_factory()の例を紹介したが,それとは異なり,メタクラスはクラスとして書かれる.つまり,メタクラスはインスタンスがクラスであるクラスのことである.

メタクラスは強力だが,扱いにくい.メタクラスとして実装するまえに,以下の事項を検討するべきである.

  • Class decoratorsを使う.
  • __set_name__を使う.
  • __init_subclass__を使う.
  • ビルトインのdictを使ってキーの挿入順番を保持する.

著者はメタクラスよりもこれらの機能を使用するのに賛成である.仕事が不必要に複雑になった例を見てきたからである.メタクラスは複雑さへの入り口である.

(メタクラスの具体的については力尽きたため,やむなく省略)

メタクラスは2002年に公開されたPython 2.2から導入された機能であり,Python 3.9でも同じように動作するぐらい安定した言語機能である.そして,メタクラスに代表される強力なツールは,もともとライブラリやフレームワークを開発するために容易されたものであり,アプリケーションを開発する場合は,メタクラスを利用して作られたライブラリやフレームワークを利用するべきである.

例えばDjangoやSQLAlchemyなどのフレームワークが便利なものとして成功したのは,メタクラスのおかげであるとも言える.

"Simply Scheme: Introducing Computer Science (MIT Press)"という本では以下のように述べられている.

計算機科学を指導する学校の考え方は2通りあり,以下のように表現できる.

  1. 保守的な見方: コンピュータープログラムは人間の頭脳で網羅するにはあまりにも膨大で複雑である.したがって,計算機科学の教育の仕事とは,500人の平凡なプログラマーが協力して,製品の仕様を正しく満たすようなプログラムを実装できるように,生徒を教育することである.

  2. 急進的な見方: コンピュータープログラムは人間の頭脳で網羅するにはあまりにも膨大で複雑である.したがって,計算機科学の教育の仕事とは,自明の知識ではなく,よりたくさんの広範囲で強力で柔軟な知識を教えることで,プログラムがプログラマーの頭脳に収まるように,頭脳を拡張するべく生徒を教育することである.

Pythonは後者の精神に基づいて設計されていると思われる.

この本を読んだ読者が,Pythonのコミュニティとエコシステムに貢献するのを楽しみにしている.

あとがき

2006年に公開された,"PEP 3099 Things that will Not Change in Python 3000" には,Pythonの現在の仕様が定まった理由がかかれている.

PEP 3099 – Things that will Not Change in Python 3000 | peps.python.org

奥付 (Colophon)

Fluent Pythonの表紙に描かれている動物は,Namaqua sand lizard (Pedioplanis namaquensis)である.

オライリーの本の表紙に描かれている動物の多くは絶滅危惧種である.

著者の略歴

Luciano Ramalhoは,1995年にNetspace社が新規株式を公募するまではウェブ開発者であり,1998年までにPerlからJava,Pythonと使用する言語を変えた.2015年にThoughtworks社に入社し,サンパウロ事務所で主任 コンサルタントを勤めた.アメリカやヨーロッパ,アジアのPythonのイベントで,スライドや公演,個別指導を行ったり,GoやElixirのカンファエレンスで言語デザインについての発表をした.Pythonソフトウェア財団の特別会員であり,ブラジルで始めてのハッカースペースであるGaroa Hacker Clubeの共同創立者である.

感想

内容が多く,読了するのに時間がかかり大変でした.これにより,まとめの長さは過去最長になりました.

どの手法をどういった状況で用いるべきか,用いるべきでないかを明確に記述しており,技術の選択を系統的に行うための助言が多く含まれており,参考になりました.

随所に有用なライブラリが紹介されており,勉強になりました.みーは,pythonのHTTPクライアントとして長らくrequestsを使っていたのですが,この本でhttpxが紹介されており,非同期処理やHTTP2に対応しているので乗り換えを検討しています.

オライリーの本の表紙には動物が書いてあるのは,ジャポニカ学習帳と同じような慣習みたいです*5.小学生の頃の記憶が蘇り,オライリーの本に親しみを感じるようになりました.

*1:Fluent Python: Clear, Concise, and Effective Programmingによると,ペーパーバックで983ページだそうです.

*2:python - What is the difference between __str__ and __repr__? - Stack Overflow

*3:codecs — Codec registry and base classes — Python 3.10.5 documentation

*4:Re: Type hints -- a mediocre programmer's reaction [LWN.net]

*5:Animal Menagerie - O'Reilly Mediaには,歴代のオライリーの本に記載された動物のデータベースが公開されています. ジャポニカ学習帳の表紙については,ジャポニカ学習帳のこだわりで熱く語られています.