Designing Pythonic library APIs

Ben Hoyt

PythonらしいライブラリAPIの設計

原文は Ben Hoyt により に公開されました。 このブログを購読する

要約:この記事では、構造、命名、エラー処理、型アノテーションなど、優れたPythonライブラリのAPI設計に役立つと私が感じたいくつかの原則について解説します。これは2023年6月にクライストチャーチのPythonミートアップで行ったトークを文章化したものです。

まずはコードから見てみましょう。このスニペットは何をしていると思いますか?

manager = urllib.request.HTTPPasswordMgrWithDefaultRealm()
manager.add_password(None, 'https://httpbin.org/', 'usr', 'pwd')
handler = urllib.request.HTTPBasicAuthHandler(manager)
opener = urllib.request.build_opener(handler)
response = opener.open('https://httpbin.org/basic-auth/usr/pwd')
print(response.status)

おそらくお察しのとおり、ユーザー名とパスワードを使ってhttpbin.orgのURLにHTTPリクエストを送っています。

しかし、urllib.requestは良いAPIでしょうか?

いいえ、ひどいものです!「マネージャー」や「ハンドラー」や「オープナー」について学ばなければなりません。HTTPPasswordMgrWithDefaultRealm(なんと長い名前でしょう!)とHTTPBasicAuthHandlerという、とてつもなく長い名前が2つもあります。

こういうコードがあるからこそ、2011年にRequestsライブラリが生まれたのです。実際、Requestsのドキュメントを見たことがあれば、この例がドキュメント冒頭でリンクされている比較(公式ハウツーをもとに更新されたもの)から取られたものだと気づくでしょう。

同じHTTP呼び出しをRequestsのAPIで書くと、こうなります:

response = requests.get('https://httpbin.org/basic-auth/usr/pwd',
                        auth=('usr', 'pwd'))
print(response.status_code)

これこそ、良いAPIです。

標準ライブラリ版では、ボイラープレートやJava風のクラスが何層にも重なり、5行のコードが必要です。Requests版はauth引数を伴うシンプルな1回の関数呼び出しで済みます。

標準ライブラリでももう少しシンプルに書く方法はありますが、そのためにはurllib.requestのBasic認証用APIを迂回して、Authorizationヘッダーを自分で追加しなければなりません:

request = urllib.request.Request('https://httpbin.org/basic-auth/usr/pwd')
encoded_auth = base64.b64encode(username.encode() + b':' + password.encode())
request.add_header('Authorization', b'Basic ' + encoded_auth)
response = urllib.request.urlopen(request)
print(response.status)

本来まさにそのタスクのために用意されたはずの高水準APIよりも、低水準APIの方が使いやすいというのは、何かがおかしいと言わざるを得ません。

使いやすいAPIこそが、2011年にRequestsをヒットさせた理由であり、今も支持され続けている理由です。この記事ではRequestsを何度も例として取り上げますが、ほぼ常に良い例として紹介します。Python標準のHTTP周りの機能は少しは良くなりましたが、大した改善ではありません。

この記事ではいくつかの「要点(Takeaway)」を紹介しますが、全体を貫くテーマはこれです:

要点:良いAPI設計はユーザーにとって非常に重要です。

Requestsも最初から完璧で完成された状態で始まったわけではありません。RequestsのGitリポジトリで最初のコミットまで遡ってみると、最初のまともなコミットは次のようなものでした(フォーマットは少し整えています):

import urllib2

class Request(object):
    """The :class:`Request` object. It's awesome."""

class Response(object):
    """The :class:`Request` object. It's awesome."""

class AuthObject(object):
    """The :class:`AuthObject` is a simple HTTP Authentication token. ..."""
    def __init__(self, username, password):
        self.username = username
        self.password = password

def get():    pass
def post():   pass
def put():    pass
def delete(): pass

作者のKenneth Reitzには、明らかにまだ実装すべきことが山積みでした。しかし、基本的なAPIの骨組みはすでにそこに見て取れます。RequestResponseオブジェクト、そしてシンプルなHTTPメソッドの関数群です。

その後数週間の怒涛のコミットで、彼はライブラリの最初のバージョンを実装し、APIを洗練させていきました。

要点:ライブラリを作るときは、良い土台から始めて反復的に改善しましょう。

この記事では、Pythonでよく設計されたライブラリAPIを作るためのいくつかの原則を見ていきます。その前に、そもそもPythonらしい(Pythonic)APIとは何を意味するのか、簡単に考えてみましょう。

「Pythonらしい」とはどういう意味か

これは厄介な問いです。というのも、人によってこの言葉の意味するところが違うからです。私が「Pythonらしい」と言うときは、Python開発者の間でかなり標準的になっている、関数やクラスの設計手法のことを指しています。

出発点として、公式のPythonスタイルガイド(PEP 8)に従うのが良いでしょう。コードの書式、importの書き方、コメント、命名、そしてさまざまなプログラミング上の推奨事項がカバーされています。Pythonを書く人なら誰でも読んで従うべきだと思います。特にライブラリを書く人はなおさらです。

次に「The Zen of Python」があります。これは、良いPythonとはどうあるべきかについての、ややユーモラスで禅のような一連の言葉です。ただし、禅仏教と同じく、多くの逆説(矛盾すら)を含んでいます。

The Zen of Pythonの冒頭の数行を引用します:

美しいことは醜いことより良い。
明示的なことは暗黙的なことより良い。
シンプルなことは複雑なことより良い。
複雑なことは入り組んだことより良い。
平坦なことは入れ子より良い。
間隔が空いていることは詰め込まれているより良い。
可読性は重要だ。

これらはすべて重要ですが、良いライブラリAPIを設計するうえで特に重要だと思う2つを太字にしました。

「Pythonらしい」についてもう一つ言えるのは、それがPythonであるということです。当たり前のことのように聞こえますが、(たとえば)Java出身で、Pythonが使えるのにいつまでもJava風のコードを書き続ける人が驚くほど多いのです。

Pythonでは物事をシンプルに保つことを好みます。また、キーワード引数(これについては後述します)をはじめ、良いAPIを作るための多くの道具が使えます。

要点:PEP 8に従い、PEP 20を体得しましょう。これが王道です。

先に進む前に、Python標準ライブラリを少し見てみましょう。果たしてそれはPythonらしいでしょうか?

これはちょっと面白い問いです。Pythonに標準で付属しているものが最もPythonらしいはずだと思いがちですが、実際にはそうとは限らないのです!

Python標準ライブラリは何十年もの歳月をかけて、多くの異なる人々によって設計されてきました。そのため、今日ではPythonらしいとは言えないようなものも少なからず含まれています。

標準ライブラリにあるイマイチなAPIの例をいくつか挙げます:

  • emailパッケージ、例えばemail.mime.multipart.MIMEMultipartがそうです。The Zen of Pythonでは「平坦なことは入れ子より良い」と言っているのに、この名前は4階層もの入れ子になっています!なぜemail.MultipartMessageや、単にemail.Multipartではいけなかったのでしょうか?
  • threading.Threadは私が個人的に気に入らない例です。Threadクラスの最初のパラメータであるgroupは使われていません。ドキュメントには「将来の拡張のために予約されている」とありますが、その「予約」は26年間も続いているのです!
  • unittest:大きな問題ではありませんが、PEP 8に反する命名の良い例です。例えば関数名はassertEqualではなくassert_equalとなるはずです。また、unittestでは単にassert a == bと言えば済むところを、まったく新しい関数群を覚える必要があります(pytestはこれをシンプルにしています)。
  • urllib.request:これがどれほど美しくないかは、すでに見たとおりです。

一方で、よく設計されたモジュールもたくさんあります:

  • collections:これらのクラスはとても使いやすいものです。私が標準ライブラリで特にお気に入りの2つは、defaultdictCounterです。
  • csv:名前のとおりのことをしてくれて、使いやすいモジュールです。DictReaderをまだ試したことがなければ、ぜひチェックしてみてください!
  • datetime:決して完璧ではありません(完璧な時刻ライブラリなどあるでしょうか?)が、全体としてかなり良い型のセットを持ち、演算子のオーバーロードも上手に使われています。
  • json:使い方は非常にシンプルで、dumploadでほとんどの要件がまかなえ、設定が必要なときには豊富なキーワード引数が用意されています。
  • その他にもたくさんあります…

要点:標準ライブラリが常にお手本になるとは限りません。

モジュールとパッケージの構成

まずはPythonの用語を整理しておきましょう:

  • モジュール:API(関数やクラスなど)を公開する単一の.pyファイル
  • パッケージ:__init__.pyを伴うディレクトリにまとめられたモジュールの集まり

では、ファイルやモジュール、パッケージはどのように構成すべきでしょうか?

Requestsに話を戻しましょう。Requestsを使う人は誰もが単にimport requestsと書いて、requests.get()requests.Sessionなどを使います。get()関数は実際にはapi.pyで、Sessionsessions.pyで実装されていますが、そのことを知る必要も、サブモジュールをimportする必要もありません。

それは次のように実現されています:

# requests/__init__.py
from .api import delete, get, post, put, ...
from .sessions import Session

# requests/api.py
def get(url, params=None, **kwargs):
    return request("get", url, params=params, **kwargs)

# requests/sessions.py
class Session:
    ...

私は常に単一の.pyファイルから始めることをお勧めします。シンプルで見通しが良いからです。中規模のライブラリでもこの方法で作られているものがあります。例えばWebフレームワークのBottleは、単一のbottle.pyファイルで実装されていることで有名です。

フィッシュ&チップスの注文ライブラリを設計するとしましょう。(この例はたくさん使います――だからこそ、この記事にはtakeaways(持ち帰り)があるのです!)最初のバージョンを単一ファイルで書くとこうなります:

# fishnchips.py (library code)
class Shop:
    ...

def order(chips=None, fish=None):
    ...

# app.py (user code)
import fishnchips
fishnchips.order(chips=1, fish=2)

Shopクラス(おそらく多数のメソッドを持つ)と、トップレベルのorder関数があります。ユーザーはapp.pyに示すように、単にimport fishnchipsしてfishnchips.order()を使うだけです。

しかし、ライブラリが非常に大きくなり、実装を複数ファイルに分割したくなったらどうでしょう?次のように、パッケージ(__init__.pyを持つディレクトリ)に変更します:

# fishnchips/__init__.py
from .api import order
from .shop import Shop

# fishnchips/api.py
def order(chips=None, fish=None):
    ...

# fishnchips/shop.py
class Shop:
    ...

これでライブラリは3つのファイルになりましたが、__init__.pyで必要な要素をトップレベルにimportしているため、ユーザーは以前と同様にimport fishnchipsとしてfishnchips.order()を使うことができます。

import fishnchips.apiimport fishnchips.shopとする必要はありません。新しいバージョンは、古い単一ファイルの実装と完全に後方互換性があります。コードのレイアウトは完全に変えたのに、APIは変わっていないのです!

要点:クリーンなAPIを公開しましょう。ファイル構成は実装の詳細に過ぎません。

パッケージ設計のもう一つの側面は、入れ子の深さです。すでにemail.mime.multipart.MIMEMultipartについて触れましたが、DjangoのAPIの多くも同じ問題を抱えています。Djangoのドキュメントからほぼランダムに拾った例を一つ紹介します:

from django.core.files import File
from django.core.files.images import ImageFile

file = File(f)
image = ImageFile(f)

ここで示したImageFileのimportは、なんと4階層もの深さです! django dot core dot files dot images dot ImageFile というわけです。

もしかするとDjangoには何種類かのImageFileがあって、深い名前空間で整理したいのかと思いました。しかし、Djangoのソースをgrepしてみると、そんなものは一つしかありません。Fileも同様です。つまり、4~5階層もの名前空間は完全に不要で、django.ImageFileで十分だったはずです。

Djangoは多くのコンポーネントを持つ巨大なライブラリなので、2階層程度の入れ子なら合理的かもしれませんが、さすがに4つや5つはやりすぎでしょう。故Aaron Swartzも2005年のブログ記事「Rewriting Reddit」でDjangoについて同様の指摘をしています――「Another Django goal」で始まる段落を参照してください。

最近、私が仕事でメンテナンスしているライブラリでは、サブモジュールで定義されていた要素をすべて__init__.pyでトップレベルに引き上げる変更を行い、クラス名を格段に見つけやすくしました。ユーザーは「うーん、ActiveStatusops.charmops.modelのどちらのサブモジュールだったっけ?」と推測する必要がなくなり、単にimport opsしてops.ActiveStatusとすることを推奨するようになりました。ユーザーが求めるクラスはすべてすぐそこにあるのです。

要点:平坦なことは入れ子より良い。

ここで皆さんに質問です。次のどちらのコードがより良いと思いますか:

from fishnchips import order

...

def eat_takeaways():
    meal = order(chips=1)
    ingest(meal)

def ingest(meal):
    ...

それともこちらのコードでしょうか?違いに気づくまで少し待ちます。

import fishnchips

...

def eat_takeaways():
    meal = fishnchips.order(chips=1)
    ingest(meal)

def ingest(meal):
    ...

私は、ライブラリを2つ目の例のように使われるように設計することを強くお勧めします。

1つ目のバージョンでは、order()が呼ばれたときに、どこに注文しているのかわかりません。マクドナルドでしょうか、それともフィッシュ&チップスでしょうか?orderはこのファイルで定義された関数なのか、それともimportしたものなのかもわかりません。

2つ目のバージョンなら、ファイルの先頭までスクロールしてどこからimportされたかを確認しなくても、fishnchipsモジュールを使って注文していることが一目でわかります。

このように使えるようにするには、モジュール設計に少し工夫が必要です。例えば、関数をorder_fishnchipsと名付けてはいけません。なぜならfishnchipsはすでにモジュール名に入っているからです。人々にfishnchips.order_fishnchips()のように冗長なことをさせたくはないでしょう。

Requestsも同様に設計されていることがわかります。requests.get()は読みやすく、意味も通ります。from requests import getとすることを意図していません。なぜならモジュール名という文脈なしのget()だけでは、十分な意味を持たないからです。

要点:ライブラリはfrom lib import LibThing ... LibThing()ではなく、import lib ... lib.Thing()という形で使われるように設計しましょう。

グローバルな設定と状態を避ける

まず、グローバルな設定とは何を意味するのでしょうか?

例えば、fishnchipsライブラリがWeb APIを使って注文するとしましょう。当然タイムアウトを設定したくなります。では、デフォルトのタイムアウトはどうすべきでしょうか?そこで「定数」を追加します:

# fishnchips.py
DEFAULT_TIMEOUT = 10

def order(..., timeout=None):
    if timeout is None:
        timeout = DEFAULT_TIMEOUT
    ...

誰かがタイムアウトを指定せずにorder()を呼び出すと、デフォルトの10秒が適用されます。

しかし、チームで開発していて、プログラム内で他の誰かもfishnchipsモジュールを使っているとします。その人はフィッシュ&チップス店のすぐ隣に住んでいるので、fishnchips.DEFAULT_TIMEOUT = 1と設定しました。すると、あなたのorder()呼び出しもすべて、その低いデフォルト値になってしまい、期待した動作ではなくなります。

DEFAULT_TIMEOUTのようなグローバルな設定を公開する代わりに、_default_timeoutのようにプライベートにして、モジュール外で使うべきでないことを明確にしましょう。あるいは、そもそも変更できないように、定数をデフォルト値とするキーワード引数を使う手もあります:

def order(..., timeout=10):
    ...

誰かが別のタイムアウトで注文したいなら、単にorder(..., timeout=1)と明示的に呼び出すか、自分の関数でラップすればよいのです。functools.partialを使って特定の引数を「固定」する方法もあります:

import functools

order_fast = functools.partial(fishnchips.order, timeout=1)

# calls fishnchips.order with supplied arguments, and timeout=1
order_fast(...)

そうすれば、fishnchips.orderを使っている他の人も、期待どおりのタイムアウトを得られます。functools.partialを使った方法は基本的に次のコードと同等ですが、冗長な*args**kwargsを避けられます:

def order_fast(*args, **kwargs):
    return fishnchips.order(*args, **kwargs, timeout=1)

要点:グローバルな設定は避け、適切なデフォルトを用意してユーザーに上書きさせましょう。

もう一つ避けるべきなのはグローバルな状態です。これは、ライブラリを使いやすくしようとして、モジュールレベルの変数にいろいろなものを溜め込んでしまうことです。

例えば、URLルーティングライブラリroutaを作っていて、URLルートを追加するadd()関数があるとします:

# routa.py
_urls = []

def add(pattern, func):
    _urls.append((pattern, func))

def match(url):
    ...

# app.py
routa.add('/', home_page)
routa.add('/contact', contact_page)

ユーザーがルーターを1つだけ必要とするならこれはとても手軽です。しかし、突然2つ必要になったらどうでしょう。HTMLページ用とJSON API用で分けたい場合です。_urlsリストが1つしかないため、add()呼び出しが互いに干渉してしまいます。

では、どう直せばよいでしょうか?賢明な方法は、Routerクラスを追加することです。

# routa.py
class Router:
    def __init__(self):
        self._urls = []

    def add(self, pattern, func):
        self._urls.append((pattern, func))

    def match(self, url):
        ...

# app.py
router = routa.Router()
router.add('/', home_page)
router.add('/contact', contact_page)

ライブラリ側では数行コードが増えますが、ユーザーのアプリ側ではたった1行増えるだけです。しかし、これでライブラリはずっと使いやすくなり、ユーザーは互いに干渉することなく、好きなだけルーターを持つことができます。

Jack Diederichによる「Stop Writing Classes」という非常に興味深いトークがあります。彼の指摘の一つは、Pythonではクラスがしばしば不要なボイラープレートになるということです――特に、メソッドが2つだけで、そのうち1つが__init__のようなクラスではなおさらです!

しかし、ライブラリが関数呼び出しの間で何かを記憶するための状態を必要とする場合、クラスは通常、それに適した道具です。再びRequestsを例に考えてみましょう:

session = requests.Session()
session.auth = ('usr', 'pwd')
session.headers = {'User-Agent': 'mylib/1.0'}

session.get('https://example.com/foo')
session.get('https://example.com/bar')

このコードはrequests.get()を直接使うのではなく、まずrequests.Sessionを作成します。これには2つの利点があります:

  1. カスタム認証やカスタムヘッダーをすべてのリクエストに付けられること。もっとも、それだけであればfunctools.partialやラッパー関数でも実現できます。
  2. SessionがTCP接続を再利用するため、同じドメインへの複数のリクエストが大幅に高速化されること。

TLS(https://のURL)では――今やほとんどがそうですが――これは非常に大きな意味を持ちます。以前、ニュージーランドからイギリスへのリクエストで試したところ、Requestsが毎回TLS接続を確立する必要がなくなったため、2回目以降のリクエストが1回あたり1秒から約0.3秒に高速化しました。

要点:グローバルな状態は避け、代わりにクラスを使いましょう。

命名

命名についてはすでに少し触れましたが、命名は難しいものです!

変数に名前を付けるとき、短すぎる名前を使うことがあります。しかし、APIの関数に名前を付けるときは、逆に長すぎる名前を使うことが多いと私は感じています。

見てきたように、APIにおける名前はすでにモジュール名という接頭辞の文脈を持っています。例を見てみましょう。

大学を出たばかりのボブがRequestsを設計していて、講師の一人が「何をするかわかるように関数に名前を付けなさい」と言ったと想像してください。彼はそれを「やることすべてがわかるように関数に名前を付けなさい」と聞き間違えて、こんなコードを書きました:

# requests.py
def send_get_request_and_receive_response():
    ...

何が問題でしょうか?余計な言葉は何も付け加えていないのです。むしろ明確さを損ねています。requests.get()は見やすく読みやすいのに対し、長い名前では重要な部分――それが「get」メソッドであるという事実――が名前の真ん中に埋もれてしまいます。

「request」という単語が、モジュール名と関数名で2回出てきます。さらに「send」や「receive response」もあります。しかし、ライブラリの存在意義そのものがリクエストを送りレスポンスを受け取ることなのですから、それらも不要です。

残るのは、短くて簡潔なrequests.get――GETリクエストを実行する――明確で簡潔です。これ以上短くはできません。もちろんrequests.gにすることはできますが――それはただの冗談です。

私たちのfishnchipsライブラリを思い出してください。order関数をorder_foodorder_mealと呼ぶこともできましたが、これはフィッシュ&チップスのライブラリなのですから、もちろん食事のための食べ物に決まっています。だから単にfishnchips.orderで十分なのです。

要点:名前は、明確さを保てる範囲でできるだけ短くすべきです。

ここで少し脱線します。関数名は動詞であるべきだと聞いたことがあるでしょう。なぜなら関数は何かをするものだからです。getpostのように(これらはHTTPの動詞、すなわちメソッドです)。プロパティ名やクラス名は名詞であるべきです。なぜならそれらはものだからです。SessionResponseのように。これは良い経験則です。

しかし、時には判断が難しいこともあります。「shop」は名詞でしょうか、動詞でしょうか?「order」はどうでしょう?

そのとおり――どちらでもあるのです!英語の短い単語の多くは動詞でも名詞でもあるので、会話と同じように文脈から判断しなければなりません。

しかしPythonでは、大文字・小文字を使って意味を加えることもできます。shop関数があればsは小文字ですし、OrderクラスがあればOは大文字です。これはPEP 8から直接来ている、なかなか良い区別方法です。

要点:関数名は動詞、クラス名は名詞が望ましいですが、こだわりすぎないようにしましょう。

少しプライバシーについて話しましょう。

Pythonでは、_privateのようにアンダースコアで始まる名前は、慣習的にプライベートとされます。ユーザーは依然としてそれにアクセスできますが、それは「これは良くないとわかっているけど、それでも_privateにアクセスするんだ」という意思表示になります。ライブラリを書く側としては、新しいバージョンで_privateを変更・削除しても構いません――たとえその新しいバージョンが後方互換であるべきだとしてもです。ユーザーが_privateにアクセスしているなら、それは自己責任です。

二重アンダースコア接頭辞の__extra_privateのように、「よりプライベート」にしようとする必要はありません。Pythonはこの場合に非常に単純な名前マングリングを行いますが、問題を解決するよりも引き起こすことの方が多く、やりたい人は簡単に回避できるため、結局本当にプライベートになるわけではありません。単一のアンダースコアで十分です。

プライベートな部分に二重アンダースコアを使うのは紛らわしくもあります。マジックメソッドのように見えますが、そうではないからです(Pythonのマジックメソッドはすべて__init__のように二重アンダースコアを持ちます)。

要点:_privateで十分です。__extra_privacyは不要です。

エラーと例外

Pythonでは、エラーはほぼ常に例外として送出すべきです。

まず、TypeErrorValueErrorのようなコアなプログラミングエラーには、使える大規模な組み込み例外階層があります。例えば、fishnchips.order関数に範囲チェックを追加できます:

def order(chips=None, fish=None):
    if chips is None and fish is None:
        raise ValueError('nothing to order!')
    if chips <= 0:
        raise ValueError('"chips" must be greater than zero')
    if fish <= 0:
        raise ValueError('"fish" must be greater than zero')
    ...

ここでValueErrorを使うのは適切です――カスタム例外型を定義する価値はありません。

ファイルシステムライブラリのように標準的な操作にうまく対応するものを作る場合は、FileNotFoundErrorPermissionErrorのようなOSErrorのサブクラスを再利用したくなるかもしれません。

しかし、新しいライブラリを構築する際には、通常、ライブラリのすべての例外のためのカスタム基底クラスを作成し、その意味のあるサブクラスを送出するのが最善です。基底クラスはExceptionを継承すべきです。例えば、フィッシュ&チップスライブラリなら次のようにするでしょう:

# fishnchips.py

class Error(Exception):  # will be used as fishnchips.Error
    """Base class for all of this library's exceptions."""

class NetworkError(Error):
    """Low-level networking error."""

class APIError(Error):
    """Error talking to the shop's API."""

これにより、ライブラリのユーザーは送出されうるあらゆる例外を簡単に捕捉できます。例えば、ライブラリがHTTP APIを呼び出す際にssl.SSLErrorを送出する可能性がある場合、それを捕捉してfishnchips.NetworkErrorとして再送出するのがおそらく最善です。

例外を、自身の例外基底クラスと標準ライブラリの例外の両方から継承させることさえできます。例えばRequestsには例外階層があり、InvalidURLのような例外はRequestException基底クラスと組み込みのValueErrorの両方を継承しています:

class InvalidURL(RequestException, ValueError):
    """The URL provided was somehow invalid."""

カスタム例外を作るもう一つの良い理由は、追加情報を提供することです。例えば、HTTP 4xxレスポンスからの情報を提供するOrderErrorを追加できます:

class OrderError(Error):
    def __init__(self, code, chef_name, message):
        self.code = code
        self.chef_name = chef_name
        self.message = message

def order(chips=None, fish=None):
    quantities = {'chips': chips, 'fish': fish}
    try:
        response = requests.post(_shop_url, json=quantities)
    except requests.RequestException as e:
        raise NetworkError(f'Network error: {e}')
    if 500 <= response.status_code <= 599:
        raise APIError(f'API Error {response.status_code}: {response.text}')
    if 400 <= response.status_code <= 499:
        data = response.json()
        raise OrderError(
            code=response.status_code,
            chef_name=data['chef_name'],
            message=data['error_message'],
        )

もしrequests.Timeoutのような低水準の例外があれば、except requests.RequestExceptionブロックで捕捉され、NetworkErrorとして再送出されます。

Web APIとの通信でHTTP 5xxのサーバーエラーがあれば、APIErrorを送出します。

そしてWeb APIとの通信でHTTP 4xxのクライアントエラーがあれば、私たちの新しいカスタム例外型であるOrderErrorを送出します。これには3つの属性があります。HTTPステータスコード、シェフの名前、そしてメッセージです(2つのフィールドはレスポンスのJSONから来ています)。

これでユーザーはOrderErrorを捕捉し、詳細を使って親切なエラーメッセージを出力するといった役立つことができます:

try:
    fishnchips.order(chips=1, fish=2)
except fishnchips.OrderError as e:
    print(f'Error with order: {e.chef_name} said {e.message}', file=sys.stderr)
    sys.exit(1)
except fishnchips.Error as e:
    print(f'Unexpected error, please contact us: {e}', file=sys.stderr)
    sys.exit(1)

エラーが例外ではないという興味深いケースが、実はRequestsにあります:

>>> response = requests.get('https://benhoyt.com/resume/')
>>> response.status_code
404
>>> response.raise_for_status()
Traceback (most recent call last):
  ...
requests.exceptions.HTTPError: 404 Client Error: ...

このページは存在しないので404エラーです――私の履歴書は/resume/ではなく/cv/にあります。しかしRequestsは、明示的にresponse.raise_for_status()を呼び出さない限り、例外を送出しません。

おそらく彼らは早い段階で、HTTPリクエストが完了したなら――たとえそれがHTTPレベルのエラーであっても――Requestsライブラリはそれを成功として扱うべきだという決定を下したのでしょう。

実際、これはRequestsのAPI設計における数少ない設計上の欠陥の一つだと思います。HTTPの4xxや5xxステータスコードはエラーなのですから、例外であるべきです。現在のアプローチでは、次のようなことを簡単にやってしまいます:

response = requests.get('https://benhoyt.com/resume/')
pathlib.Path('resume.html').write_text(response.text)

response.status_codeのチェックを忘れているため、このコードは私の404ページのHTMLを黙ってresume.htmlに書き込んでしまいます。status_code属性をチェックしたりraise_for_statusを呼び出したりすることで「修正」はできますが、APIの設計上、忘れやすいのです。APIは間違いを犯しにくいように設計されるべきです。

要点:エラーが発生したらカスタム例外を送出しましょう。適切であれば組み込み例外を使います。

バージョニングと後方互換性

ライブラリの変更をPython Package Index(PyPI)で公開する場合は、常にバージョン番号を上げ、何が変わったかについてのリリースノートを書くべきです。しかし、そこには多くのニュアンスがあります。

おそらくセマンティックバージョニング、略して「semver」を聞いたことがあるでしょう。これは1.2.3のようなバージョン番号で、1がメジャーバージョン、2がマイナーバージョン、3がパッチバージョンを表します。

semverの仕様では、次のようにバージョンを上げるべきとされています:

  • APIに互換性のない変更を加えたときはメジャーバージョンを上げる
  • 後方互換性を保ったまま機能を追加したときはマイナーバージョンを上げる
  • 後方互換性を保ったままバグを修正したときはパッチバージョンを上げる

これはとても簡単です。まずバージョンを1.0.0にして、そこから始めればよいのです。新しいクラスや関数を追加したら1.1.0、次は1.2.0というように上げていきます。真ん中の数字が9を超え、1.42.0や1.365.0のように非常に大きくなるのはよくあることです。

APIはユーザーのために作っていることを忘れないでください。だからメジャーバージョンの更新は非常に慎重に行うべきです。ユーザーが新しいメジャーバージョンにアップグレードしたい場合、おそらくコードを変更しなければなりません。これは本当に面倒なことなので、頻繁に行わないようにしましょう!

これは少し独断的ですが、新しいメジャーバージョンを出すのは、APIの構造を完全に変えるときだけにすべきだと思います。

要点:APIを全面的に刷新する場合にのみ、後方互換性を壊しましょう。

嬉しいことに、Pythonには後方互換性を保つための素晴らしい機能がいくつかあります。主なものは2つ、キーワード引数動的型付けです。

フィッシュ&チップスライブラリに話を戻しましょう。こちらがバージョン1です――チップスのスクープ数と魚の数を注文できます:

def order(chips=None, fish=None):
    """Place an order.

    Args:
        chips: number of scoops of chips
        fish: number of fish
    """

しかし、その後フィッシュ&チップス店が凝りだして、パン粉をまぶした(crumbed)魚も提供し始めたとしましょう。そうなると魚には2種類あることになります。衣付き(battered)とパン粉付き(crumbed)です。

ユーザーが魚の種類を指定できるようにAPIを変更する必要があります。最も簡単な方法はキーワード引数を追加することです。デフォルトは依然としてbatteredですが、呼び出し側は必要ならfish_type='crumbed'と指定できます:

def order(chips=None, fish=None, fish_type='battered'):
    """Place an order.

    Args:
        chips: number of scoops of chips
        fish: number of fish
        fish_type: type of fish, 'battered' or 'crumbed'
    """

# example usage:
fishnchips.order(chips=1, fish=2, fish_type='crumbed')

キーワード引数の素晴らしいところは、ライブラリ作者として好きなだけ追加でき、それぞれに適切なデフォルトを持たせられることです。ですからtimeout引数や、onion_ringshotdogsのような新しい食べ物をいくつでも追加できます。

魚の種類にはFishType.CRUMBEDのようなenumを使うこともできます。少し冗長になりますが、スペルミスがあればインタプリタが検出してくれます。

別のアプローチとして、動的型付けを活用して、fishが整数か、あるいは(数量、種類)のタプルのいずれかを取れるようにする方法もあります。そうすれば「パン粉付きの魚を2つ」と頼むことができます:

def order(chips=None, fish=None):
    """Place an order.

    Args:
        chips: number of scoops of chips
        fish: either the number of fish, or a tuple of (quantity, type),
            where type is 'battered' or 'crumbed'
    """

# example usage:
fishnchips.order(chips=1, fish=(2, 'crumbed'))

このAPIの方が少し良いと私は思います。しかし、お母さんはcrumbedが良くて、お父さんはbatteredが良い場合はどうでしょう?タプルのリストも許容するようにして、それぞれが好みの種類の魚を持てるようにしましょう:

def order(chips=None, fish=None):
    """Place an order.

    Args:
        chips: number of scoops of chips
        fish: either the number of fish,
           a tuple of (quantity, type),
           or a list of such tuples
    """

# example usage:
fishnchips.order(chips=1, fish=[(1, 'crumbed'), (1, 'battered')])

少し複雑なシグネチャですが、この種のことはPythonではかなり一般的で、簡単なことを簡単に、難しいことも可能にします。これらの変更はすべて後方互換であり、既存のユーザーにとって素晴らしいことです。

要点:キーワード引数と動的型付けは、後方互換性にとって素晴らしい手段です。

型アノテーション

最初に断っておきますが、私はPythonの型アノテーションについて複雑な気持ちを抱いています。まずは否定的な点から始めましょう:

  • それらは後付けで追加されたもので、それは明らかです。最初のType Hints PEPは、Pythonが作られてから23年も経ってから登場しました。
  • Pythonでよくある動的型付けの引数に対して、扱いにくいシグネチャになってしまいます。
  • アノテーションの構文は絶えず更新されています。
  • 複数の型チェッカーが存在しますが、どれも完全には一致しません。

どれだけすぐに扱いにくくなるかの例として、先ほどの「魚の種類」の例を見てみましょう。オプションのfish引数が整数、タプル、あるいはタプルのリストになり得るケースです。シグネチャは次のようになります:

def order(
        chips: int | None = None,
        fish: int | tuple[int, str] | list[tuple[int, str]] | None = None,
    ):

かなりシンプルなものに対して、これは(えーと)たくさんのタイピングが必要です!

Pythonのサブレディットに「Why Type Hinting Sucks!」という、かなり劇的なタイトルの素晴らしい投稿があります。そこでは、ダックタイピングを前に正しい型シグネチャを書こうとすると、どれだけ複雑に絡み合ってしまうかが示されています。著者は、2つの数を足すだけのシンプルな関数にアノテーションを付ける10回の試みを、だんだんと馬鹿げたものにしていきながら紹介しています。

上の例は、構文がどれだけ速く変わるかも示しています。上記では共用体(union)の新しいx | y構文(Python 3.10で追加)や、typing.List[T]の代わりのlist[T]構文(Python 3.9で追加)を使っています。ほんの数バージョン前なら、次のように書かなければなりませんでした:

from typing import List, Optional, Tuple, Union

def order(
        chips: Optional[int] = None,
        fish: Optional[Union[int, Tuple[int, str], List[Tuple[int, str]]]] = None,
    ):

これを書いている最中にも、型パラメータ(ジェネリック型)の新しい構文を提案するPEP 695についての投稿を見かけました。これはPython 3.12に含まれる予定で、メソッドのオーバーライドTypedDict**kwargsに使うための新しい構文を導入する他の2つの提案と一緒です。

しかし、公平を期さなければなりません――型アノテーションには確かに良い面もあります:

  • バグの発見に役立ちます。型チェックがあれば、必要なテストが少なくなります。
  • リファクタリングがより簡単かつ安全になります。
  • どのような型が許容されるかを文書化してくれます。
  • IDEがより良いナビゲーションや自動補完を提供するのに役立ちます。

特に最後の2点は、ライブラリのユーザーにとって有用です。

総合的に考えて、2023年においてライブラリに型アノテーションを付けて出荷するのは、間違いなく正しいことだと思います。

そしてもちろん、使うだけでなく、PyrightMyPyをコミットごとにライブラリのコードに対して実行しましょう。(現時点では、どちらかを強く推す理由は特にありません)。

要点:少なくとも公開APIには型アノテーションを使いましょう。ユーザーは感謝してくれるはずです。

Pythonの型や型アノテーションについては語るべきことが山ほどありますが、ここではもう一つのモダンなPythonツールであるデータクラスについてだけ触れておきます。

私たちは皆、次のような「データクラス」を書いたことがあるはずです:

class User:
    def __init__(self, username, display_name, active):
        self.username = username
        self.display_name = display_name
        self.active = active

そしてcollections.namedtupleを発見し、代わりにそれを使うようになりました:

User = collections.namedtuple('User', ['username', 'display_name', 'active'])

しかし、私たちが本当に欲しかったのはタプル(そのフィールドはインデックス可能でイテラブルです)ではなく、「構造体」や「プレーンなデータクラス」でした。さて、Python 3.7からはdataclassesモジュールがあります。使い方は次のとおりです:

from dataclasses import dataclass

@dataclass
class User:
    username: str
    display_name: str
    active: bool

これは普通のクラスより短くてすっきりしているだけでなく、多くのものをタダで手に入れられます。フィールドへの型アノテーション、自動生成される__init____repr____eq__、さらにはパターンマッチングのための__match_args__まで付いてきます。

ですから、ほとんどがデータであるクラスがあるなら、ぜひdataclassesモジュールをチェックしてみてください。メソッドを追加することを妨げるものは何もありません。

要点:データが中心のクラスには@dataclassを使いましょう。

Pythonの表現力:注意しましょう!

最後にもう一つ考えを残しておきたいと思います。Pythonはほぼ無限に柔軟です。a+ba[b]のような演算子をオーバーロードしたり、単純な属性アクセスで誰かのハードドライブを消去したり、実行時に名前でパッケージを動的にimportしたり、自分にしか読めないドメイン固有言語を作ったり――きりがありません。

しかし、できるからといってやるべきだというわけではありません!プログラミングは魔法かもしれませんが、魔法が多すぎると混乱し、推論するのが難しくなります。

私の経験則をいくつか紹介します:

  • 数学的な演算子a+bのようなものをオーバーライドするのは、数値型を作っているときだけにしましょう。
  • インデックス演算子a[b]のようなものをオーバーライドするのは、インデックス可能なコレクションを作っているときだけにしましょう。
  • プロパティのゲッターやセッターは「安そう」に見えるので、実際に安くあるべきです。例えば、I/Oを行ったり例外を送出したりしないようにしましょう。
  • 型シグネチャを書くのが難しすぎると感じたら、それは悪いアイデアかもしれません。

要点:Pythonの表現力は無限です。使いすぎないようにしましょう!

まとめ:要点一覧

語るべきことはまだまだたくさんあります。実際、API設計については一冊の本が書けるほどです。しかし、今回はここまでにしましょう!

最後に、すべての要点を一箇所にまとめて締めくくります:

  • 良いAPI設計はユーザーにとって非常に重要です。
  • ライブラリを作るときは、良い土台から始めて反復的に改善しましょう。
  • PEP 8に従い、PEP 20を体得しましょう。これが王道です。
  • 標準ライブラリが常にお手本になるとは限りません。
  • クリーンなAPIを公開しましょう。ファイル構成は実装の詳細に過ぎません。
  • 平坦なことは入れ子より良い。
  • ライブラリはfrom lib import LibThing ... LibThing()ではなく、import lib ... lib.Thing()という形で使われるように設計しましょう。
  • グローバルな設定は避け、適切なデフォルトを用意してユーザーに上書きさせましょう。
  • グローバルな状態は避け、代わりにクラスを使いましょう。
  • 名前は、明確さを保てる範囲でできるだけ短くすべきです。
  • 関数名は動詞、クラス名は名詞が望ましいですが、こだわりすぎないようにしましょう。
  • _privateで十分です。__extra_privacyは不要です。
  • エラーが発生したらカスタム例外を送出しましょう。適切であれば組み込み例外を使います。
  • APIを全面的に刷新する場合にのみ、後方互換性を壊しましょう。
  • キーワード引数と動的型付けは、後方互換性にとって素晴らしい手段です。
  • 少なくとも公開APIには型アノテーションを使いましょう。ユーザーは感謝してくれるはずです。
  • データが中心のクラスには@dataclassを使いましょう。
  • Pythonの表現力は無限です。使いすぎないようにしましょう!

楽しい設計を!皆さん自身のAPI設計のアイデアや、その他のフィードバックもぜひお寄せください。

この記事は「muse-spark-1.2-contributor」を使用して翻訳されました。

コメント