Pythonハッカーガイドブック -達人が教えるデプロイ、スケーラビリティ、テストのコツ-

マイナビ出版 さんから献本していただきました。ありがとうございます!

book.mynavi.jp

本書は初学者向けではなく、Python である程度プログラミングができるようになった後にスキルアップするための、中級者向けの書籍になります。昨今の流行りから言うと、例えば、データサイエンスに関わるデータサイエンティストやプログラマーがより実践的で効率のよいプログラムを書くことや、Python を使ったプロジェクトをうまくまわすための手助けになるでしょう。

本書では Python プログラミングでよく知られたプラクティス、ドキュメント作成、パッケージング、テスト、パフォーマンスの最適化とアーキテクチャなどが紹介されています。

著者の Julien Danjou 氏は、OpenStack という、巨大な Python プロジェクトのコントリビューターであり、チームリーダーを務めているようです。冒頭の「はじめに」のところでは OpenStack は900万行もの Python のコードで構成されているとあります。

単なる Python のテクニックのみを集めた書籍というわけではなく、著者の経験による、(ある程度の規模の) Python プロジェクトを通して Python をどのように活用してきたか、あるいは活用すればよいかの示唆を与えるような、目次からもそういう意図が伺えます。

本書からいくつか抜粋して私の興味・関心のある章を紹介します。私はこれまで何冊か Python の書籍を読んできましたが、その中で他の書籍ではあまりみたことがない内容を取り上げてみます。

第4章 タイムスタンプとタイムゾーンの処理

頻繁に扱う処理ではないものの、日時間の差を求めたいときなど、タイムゾーンはどうやって扱うのだったかな?と検索することが私は稀によくあります。もう同じことを10回以上は調べた気がします。本章でタイムゾーンをどう扱えばいいのかを学ぶことができます。

余談ですが、日時を扱う処理は本質的に複雑です。

自分が言えるのは、日時処理はそもそも本質的に複雑だという経験です。ざっと思いつくだけでも日時処理が複雑になる要因として次のような思いつきます。

  • そもそも一貫性がない(月ごとに日数が異なる)
  • 様々な基数(12進数、60進数、7進数?(曜日))
  • 何の法則もない祝日
  • 何の法則もない年号
  • 複雑さに輪をかけるタイムゾーン
  • 複雑さに輪をかけるうるう年
  • 複雑さに輪をかける夏時間

Java8の日付および時刻処理(Date/Time API(JSR-310))の紹介と利用指針 | ありえるえりあ

プログラミングを教えるときなど、本質的複雑さとはなんぞやを説明するのに日時データを扱う処理をイメージするとわかりやすいので説明にもよく引用します。業務においても、こういった法則性もなく、なんらかの背景により条件が課せられるといったことは頻繁にあります。

閑話休題。まず Python の標準ライブラリで datetime オブジェクトを取得するとタイムゾーンの情報はもっていません。

>>> from datetime import datetime
>>> datetime.utcnow().tzinfo is None
True

タイムゾーンの情報を扱うには python-dateutil というサードパーティのライブラリを追加でインストールして使うのが一般的だと思います。

>>> from dateutil import tz
>>> timezone = tz.gettz('Asia/Tokyo')
>>> datetime.now(tz=timezone)
datetime.datetime(2020, 5, 29, 20, 31, 26, 444650, tzinfo=tzfile('/usr/share/zoneinfo/Asia/Tokyo'))

例えば、サマータイムが終了するときはローカル時間では同じ時間が2回やってきます。1回目の時刻と2回目の同じ時刻を判別できるように fold という属性が Python 3.6 以降で追加されています。うろ覚えで見かけた気はしますが、自分でコードを書いたことがなかったので今回初めて fold を使ったコードを学びました。

>>> berlin = tz.gettz('Europe/Berlin')
>>> confusing = datetime(2020, 10, 25, 2, tzinfo=berlin)
>>> confusing.replace(fold=0).astimezone(timezone)
datetime.datetime(2020, 10, 25, 9, 0, tzinfo=tzfile('/usr/share/zoneinfo/Asia/Tokyo'))
>>> confusing.replace(fold=1).astimezone(timezone)
datetime.datetime(2020, 10, 25, 10, 0, tzinfo=tzfile('/usr/share/zoneinfo/Asia/Tokyo'))

日本はサマータイムがないのでこういった処理を実装することは滅多にないでしょうが、知っておくと海外の開発者と一緒にお仕事をするときに役立つかもしれません。

第9章 AST、HYLisp ライクな属性

私の記憶では ast モジュール を説明している Python の書籍は滅多にありません。私自身、過去に ast モジュールの使い方がわからなくて調べたときがありました。

公式ドキュメントを読んでもモジュール内のクラスやメソッドの説明しか書いていないため、どういった用途に使えるものかよくわからなかったからです。

幅広い分野で利用されている Python の用途からすると、ast モジュールを使う機会は相対的に限定的であると言わざるをえないでしょう。したがって ast モジュールを説明する書籍は稀だと私は思います。

第9章の冒頭では、まさに PythonAST は十分に文書化されていないという背景から本章を設けたことが述べられています。そのため、本章は ast モジュールの使い方から Python の AST オブジェクトの解説も行われています。そして、ast モジュールの典型的な実用例としてコードの静的解析ツールである flake8 を拡張するサンプルコードが紹介されています。

最後に Hy という Python の AST を使って実装された Lisp ライクな言語を紹介しています。著者は Lisp 愛好家のようです。

github.com

Hy の話題は本当におもしろい取り組みだと私は考えていて、この言語は PythonDSL (マクロ) をどう実装するかの、実際に動く事例の1つであるからです。

$ pip install hy
$ hy
hy 0.18.0 using CPython(default) 3.8.1 on Linux
=> (defn hello [name]
... (print (% "Hello %s!" name)))
=> (hello "t2y")
Hello t2y!

Hy の紹介を終えた後にその開発者である Paul Tagliamonte 氏のインタビューも掲載されています。このインタビューも示唆に富んでいて私は楽しめました。Python の AST はプライベートでもパブリックでもない曖昧な仕様であること、Python の実装やバージョン間によって AST が異なる可能性があること、HyPython の相互運用性は双方向にインポートできることから驚くほど高いことなどが述べられています。

現実の業務では、ast モジュールや Python の AST を操作するようなことは、静的解析やコード生成、特殊な事情における最適化など、ほとんどの開発者にはあまり関心のない話題かもしれません。しかし、言語処理系やプログラミングのことをより深く学ぶにはよい取っ掛かりでおもしろい題材になると私は思います。

本書は全体として実践的な内容をまとめたものです。しかし第9章だけは、どちらかと言うと、ハックを刺激する内容になっています。そのため、プログラミングが好きな開発者向けに楽しめる章ではないかと私は思います。

第11章 スケーリングとアーキテクチャ

ページ数は10ページ強と少ないのですが、アーキテクチャの話題を Python に特化した内容で解説しているのがよいと私は思います。プログラミング一般でもてはやされるアーキテクチャがその言語で必ずしもよい選択肢とは限りません。他言語のプラクティスをそのまま導入してもうまくいかないこともあるでしょう。

私はプログラミング言語の特性や、これまでの経緯を踏まえてそのプログラミング言語にあったアーキテクチャを採用するのがよいと最近よく思うようになりました。世の中の流行りを追いかけるよりも、その言語で培われた実績の積み重ねの方が優れている場合があるからです。

本章では歴史的な経緯も踏まえ、次の順番で説明されています。

マルチスレッディングの背景や制限を説明した後に、マルチプロセッシングや asyncio の話題に移っていきます。

asyncio が導入された背景として、それまで存在していた Python の非同期フレームワークやライブラリにおいて、各々における互換性や相互運用性を確保する標準的なイベントループインターフェースを提供することが狙いであったと解説されています。したがって、asyncio はアプリケーション開発者が直接使うというよりも、どちらかと言えば、フレームワーク/ライブラリの開発者が利用するモジュールのようにみえます。

そうとはいえ、asyncio とともに導入された asyncawait のキーワードや ネイティブコルーチン の概念など、アプリケーション開発者にとっても学ぶべきパラダイムはあります。

最後は ZeroMQ というメッセージキューを使って、マルチプロセス間での通信を行うサンプルコードも紹介しています。非同期/並行処理は、人間にとって難しく、なるべく言語機能やフレームワーク/ライブラリの機能を使いつつ目的の処理を実装するのが品質の高いアプリケーションを開発する戦略と言えるでしょう。

第13章 コーディングを減らしてコードを増やす

なんとなくおもしろい章のタイトルですよね。コーディング減らせばコードは減るはずなのに増やそうというわけです。これはどういうことでしょうか?

ある機能を汎用的に使えるようにしたり、ボイラープレート (繰り返し記述するようなものを指す) コードを減らしたりして、応用コードではそれらを再利用することで全体のコーディング量は減らしつつ、応用コードを増やすということを意図したタイトルなのでしょうかね?この章では次の内容を解説しています。

  1. sixを使ってPython 2とPython 3をサポートする
  2. PythonLispのように使ってシングルディスパッチャを作成する
  3. コンテキストマネージャ
  4. attrsを使って決まりきったコードを減らす

これからは Python 2 の話は忘れてよいので six を使う場面はどんどんなくなっていくでしょう。コンテキストマネージャももう十分によく使われていますし、私もよくユーザー定義のコンテキストマネージャーを実装します。また監訳注にも補足がありますが、Python 3.7 で導入された dataclass は attrs からインスパイアされたものです。

masahito.hatenablog.com

消去法というわけでもありませんが、Python 3.4 で導入された ジェネリック関数 を定義するシングルディスパッチについて紹介してみます。導入当初は記事をよくみかけましたが、それから時間が経って最近はあまりみかけない気もします。

第9章でも述べられていたように、著者は Lisp 愛好家のようなので CLOS (Common Lisp Object System) と同様の方法でジェネリック関数をディスパッチする方法を定義しているシングルディスパッチの仕組みを Lisp の考え方と共に Lisp コードも交えて説明しています。Lispジェネリックな関数をどう扱うのかの雰囲気もわかっておもしろいです。

シングルディスパッチと関数のオーバーロード

シングルディスパッチができて何が嬉しいかを私なりに考えてみましたが、簡潔に言語化することは私には難しかったです。そこで考察したことをそのまま書いてみます。

この節では functools.singledispatch を扱う次のようなサンプルコードが紹介されています。これは実践的に意味のない、ただのサンプルコードでしかないのであまりよい例とは言えませんが、比較しやすいようにこのサンプルコードをベースに説明を続けます。

次の場所にここで説明するサンプルコード一式を置いてあります。

import functools

class SnareDrum: pass
class Cymbal: pass
class Stick: pass
class Brushes: pass

@functools.singledispatch
def play(instrument, accessory):
    raise NotImplementedError('Cannot play these')

@play.register(SnareDrum)
def _(instrument, accessory):
    if isinstance(accessory, Stick):
        return 'POC!'
    if isinstance(accessory, Brushes):
        return 'SHHHH!'
    raise NotImplementedError('Cannot play these')

@play.register(Cymbal)
def _(instrument, accessory):
    if isinstance(accessory, Brushes):
        return 'FRCCCHHT!'
    raise NotImplementedError('Cannot play these')

print(play(SnareDrum(), Stick()))
print(play(SnareDrum(), Brushes()))
print(play(Cymbal(), Stick()))
print(play(SnareDrum, Cymbal()))

play(instrument, accessory) という関数の第一引数 instrument によって呼び出す関数をディスパッチするようなサンプルコードです。このサンプルコードを別のアプローチで実装すると次のようなコードになります。関数型プログラミングオブジェクト指向プログラミングで実装の違いはあれど、どちらも同じ機能は提供できます。どちらがよいという話ではありません。

class Instrument:
    def play(self, accessory):
        raise NotImplementedError('Cannot play these')

class SnareDrum(Instrument):
    def play(self, accessory):
        if isinstance(accessory, Stick):
            return 'POC!'
        if isinstance(accessory, Brushes):
            return 'SHHHH!'
        raise NotImplementedError('Cannot play these')
...

def play(instrument, accessory):
    return instrument.play(accessory)

print(play(SnareDrum(), Stick()))
print(play(SnareDrum(), Brushes()))

どちらのコードでも play(instrument, accessory) という同じ関数名を異なるインスタンスからも使えることに意義があります。ここではこのことをジェネリック関数と呼んでいます。後者のコードはポリモルフィズムと呼ばれたりします。

ここでそれぞれのインスタンスが受け取る引数が異なるパターンも考えてみます。説明をわかりやすくする意図で引数の数が異なる場合を考えます。

@play.register(Cymbal)
def _(instrument, accessory, other):
    if isinstance(accessory, Brushes) and isinstance(other, Stick):
        return 'FRCCCHHT!'
    raise NotImplementedError('Cannot play these')

...

print(play(Cymbal(), Brushes(), Stick()))

ジェネリック関数の定義では instrumentaccessory の2つの引数しか受け取っていませんが、 Cymbal に紐づく関数でさらに other という3番目の引数を受け取るように変更してみます。結論から言うと、SnareDrum に紐づく関数では2つの引数を、 Cymbal に紐づく関数では3つの引数を受け取って意図したように動きます。

@functools.singledispatch の公式ドキュメントには型の違いによる、関数のオーバーロード の説明がされているので受け取る引数の数が異なっても動くのはただの実装依存かもしれませんが、ここでは関数のオーバーロードの説明をわかりやすくする意図で引数の数が異なるケースで続けます。

例えば、次のようなコードを考えます。

def add(x, y):
    print(f'{x=} + {y=} = {x + y}')

def add(x, y, z):
    print(f'{x=} + {y=} + {z=} = {x + y + z}')

add(1, 2, 3)
add(1, 2)  # この呼び出しはエラーになる

add() という同じ関数名で異なる引数を受け取る関数を定義したい場合、Python では関数のオーバーロードをサポートしないため、このコードでは後優先で3つの引数を受け取る後者の関数しか使えません。Python に限らず、動的型付け (ダックタイピング) の言語では実行時に型チェックすることから、関数のオーバーロードができるメリットよりも、呼び出し側が引数を間違えてしまったり、引数違いで同じ関数の実装が複数あることによる、コードの見通しの悪さといったデメリットの方が上回るからでしょう。

関数のオーバーロードがなくても、例えば、デフォルト引数を使うことでその目的は果たせます。これは引数が2つでも3つでも意図したように振る舞います。

def add(x, y, z=0):
    print(f'{x=} + {y=} + {z=} = {x + y + z}')

add(1, 2, 3)
add(1, 2)

しかし、デフォルト引数だと、受け取る引数のパターンが増えるとややこしくなるかもしれません。やっぱり関数のオーバーロードっぽいことがしたくなったとしても、例えば、次のようなデコレーターを使って簡単に実装することもできます。

import inspect

class Overload:

    def __init__(self):
        self.namespace = {}

    def __call__(self, func):
        spec = inspect.getfullargspec(func)
        self.namespace[len(spec.args)] = func
        def wrapped(*args):
            f = self.namespace.get(len(args))
            if f is None:
                raise NotImplementedError(f'Not defined for {len(args)}')
            return f(*args)
        return wrapped

overload = Overload()

@overload
def add(x, y):
    print(f'{x=} + {y=} = {x + y}')

@overload
def add(x, y, z):
    print(f'{x=} + {y=} + {z=} = {x + y + z}')

@overload
def add(a, b, c, d, e, f, g):
    print(f'{a + b + c + d + e + f + g}')

このサンプルコードは引数の数だけに着目した関数のオーバーロードのデコーレーター実装ですが、これを汎用的にしたものが @functools.singledispatch という見方もできます。もちろん第1引数の型を使ってディスパッチする機能も使えるわけですが。

シングルディスパッチかポリモルフィズムかという議論は、複数のプログラミングの話題の背景を含んでいるように私は思います。

私は型システムに明るくないので適切な言い方ではないかもしれませんが、プログラマーの設計の考え方によってシングルディスパッチを好むスタイルがあることも理解できます。

余談ですが、私も過去に技術考察の一環で enum の定数固有メソッドを考察したときにオーバーロードの仕組みを実装してみたこともあります。用途によっては、関数のオーバーロードにより、コードの見通しがよくなるケースもあるのではないかと個人的には思います。

qiita.com

シングルディスパッチについての、私の結論としては、コーディングスタイルや設計の好みで使い分ければいいのではないでしょうか。

まとめ

私の興味・関心をもとに他の Python の書籍ではあまり出てこないような話題を取り上げてみました。

著者が Lisp 愛好家なので関数型プログラミングのプラクティスや話題が全体を通して出てくるところも特徴的かもしれません。本稿では紹介しませんでしたが、第8章は「関数型プログラミング」というタイトルですし、上述したように第9章では ast モジュールや Hy を取り上げていました。

Python は手続き型のプログラミング言語ではあるものの、関数型プログラミングの機能 (ライブラリ) もいくつか備えています。関数型プログラミングの考え方やアプローチを学ぶ上でも本書はよい入門になるように思います。

2020-06-13 追記

勉強会の LT で第8章「関数型プログラミング」の一部を紹介しました。

docs.google.com