Python で interface を扱う
インタフェース
インタフェースはクラスと異なり実体を持たず、動作の一覧だけを列挙したものです。インスタンス化できないので定義するだけ無駄な印象も受けますが、正しく利用すれば以下に示す利点を享受できます。
- 開発が滞ることがありません。 インタフェースの取り決めさえしておけば、中身ができていなくても暫定的にテスト用のクラスを注入して開発を継続できます。もはや開発が遅い同僚の完成を待つ必要はありません。たとえ最終的にあなたが中身を実装したとしてもです。インタフェースの呼び出し側と実装側で開発を分担できるので、複数人での開発にも向きます。
- 実装を気にしません。 入力・出力・するべき動作が正しければ実装はどうなっていようと構いません。この特性はしばしばデバッグで役立ちます。とりあえず動く実装を作っておいて、あとから修正しても構わないのです。加えて中身の実装について気にしないので細かいことを考えなくてよくなるのでビジネスロジックに集中できます。もちろんなにを入力するとなにを実行してなにを返すかは明確に決めておく必要があります。
- コードが機能に依存せず変更に強くなります。 インタフェースを使わないコードを書いていると特定の機能依存のコードになり、保守性が悪化し、変更の際に改修範囲が広くなります。インタフェースを使用すると今ある実装が使えなくなったとしても、別の実装を用意できれば簡単にすげ替えることができます。しかしこれはインタフェースが絶対書き変わらないことを保証するものではありません。実装によっては必要なパラメータも増えてインタフェースのシグネチャを変更する必要が出てくるでしょう。
- 似た概念を集約できます。 特に兄弟関係にある概念を抽象的に扱うことに向いています。一部例外としてインタフェースを使った上で親子関係を構築する例も示します。
- モデリング力を鍛えられます。 インタフェースを組めるということは物事の概念や仕組みを理解して的確かつ抽象的に機能を分割できていることにほかなりません。よりよいドメインモデルを構築するためにインタフェースを取り入れませんか?
どういう概念をインタフェースとして定義するか?
インタフェースを理解し始めると、ありとあらゆる操作をインタフェースで定義できるような気がしてきますし、実際その感覚は正しいです。ただコード量も増えるのでやりすぎは避けたいところです。なににインタフェースを用意してなにに用意しないのかを決めるのはドメイン固有の問題なので一概には言えませんが、次のような場合にインタフェースを使うと効果的です。
- 外部のリソースやシステムと連携する場合。 ファイルの読み書き・データベース接続・ネットワーク通信・ローレベルな操作がそれに該当します。
-
兄弟関係にある概念をまとめる場合。 特定の概念に複数の実装が考えられる場合です。これは後述の
IColor
インタフェースで具体的に取り扱います。
インタフェースの定義の仕方
Python にインタフェースを定義するための固有の機能はありませんが、同じことをクラスを使って実現できます。ここではメール送信を行うためのインタフェースである IMailSender
を例にとります。
IMailSender
は唯一のメソッド send()
を持ち、これは送信情報 Mail
(これについて考える必要はありません)を与えるとメールを送信し、None
を返します。これをインタフェースに書き起こすとこうなります。
import abc
class IMailSender(metaclass=abc.ABCMeta):
@abc.abstractmethod
def send(self, mail: Mail) -> None:
raise NotImplementedError()
-
metaclass
にabc.ABCMeta
を渡します。これでIMailSender
は抽象基底クラス扱いになります。 - 定義すべきメソッドに
@abc.abstractmethod
デコレータを付加します。IMailSender
を継承した実装クラスで実装できていないとインスタンス化できなくなります。 - メソッドの中身を
raise NotImplementedError()
とします。インタフェースなのでpass
を使うよりもこちらのほうが妥当でしょう。
IMailSender
を継承した実装クラスは必ず send()
メソッドを実装しなければなりません。架空の日本産メールサービス HogeMailer の SDK を使って実装した HogeMailSender
を実装するときも、海外最大手の FooBarMailer の Web API を使って実装した FooBarMailSender
を実装するときも、必ず send()
メソッドだけは必要です。
一方でそれ以外は自由です。コンストラクタ __init__()
を定義してもいいですし、send()
のためのヘルパーメソッド _format_message()
があっても構いません。例えば HogeMailSender
の実装はこうなるでしょう。
class HogeMailSender(IMailSender):
def __init__(self):
self._sdk = HogeMailerSDK()
def send(self, mail: Mail) -> None:
self._sdk.send(mail)
- abc にインタフェースを定義するための一通りの機能が用意されています。
- 実装クラスではインタフェースの全メソッドを実装しなければなりません。
- インタフェースに書かれていないメソッドの定義は自由です。
インタフェースの例
動作の模倣
内部的に HTTP リクエストを想定しているインタフェースを実装した FetchRequest
クラスがあるとします。万が一タイムアウトしたときに後続の処理が滞らないように処理を打ち切りたいという要求が生まれたとします。しかし実環境では一瞬でリクエストが済んでしまいますし、サービス稼働率も 100% に近くなかなかその試験ができない状況にあります。
そうした場合に、ただスレッドを待機して例外を送出する実装クラスを書けば HTTP リクエストの失敗を模倣できます。以下の実装 DummyFetchRequest
の request()
では 10 秒間サーバーからのレスポンスがなく、最終的に HTTP クライアントが例外を送出する動作を模倣しています。
import abc
import http
import time
class IFetchRequest(metaclass=abc.ABCMeta):
@abc.abstractmethod
def request(self, options: FetchOptions) -> FetchResult
raise NotImplementedError()
class DummyFetchRequest(IFetchRequest):
def request(self, options: FetchOptions) -> FetchResult
time.sleep(10)
raise http.client.HTTPException()
- 現在の実装に手を入れることなく別の実装を作ることができます。
- 開発初期のハリボテ実装やデバッグ・テストに向いています。
IColor インタフェース
コンピュータ上では赤・緑・青の強さで色を表現する RGB 色空間を使う機会がほとんどですが、これを表現する ColorRGB
クラスを考えてみます。
class ColorRGB:
def __init__(self, red: float, green: float, blue: float) -> None:
self.red = red
self.green = green
self.blue = blue
RGB はよく使われる色空間である一方、デザインの面では明度や彩度を固定したまま色相だけを変えることができないので不向きです。そこで利便性のために HSL 色空間を表現する ColorHSL
を同じように定義することにしました。
class ColorHSL:
def __init__(self, hue: float, saturation: float, lightness: float) -> None:
self.hue = hue
self.saturation = saturation
self.lightness = lightness
ですが、ここで悩みが生まれました。今回のプロジェクトでは色を表現するクラスとして ColorRGB
と ColorHSL
のどちらを使えばいいのかという問題です。どちらも同列に語れる色ですが、一般的には RGB 色空間が多く使われているので RGB にしたい気持ちもありつつも HSL も無視できません。
強引に感じるかも知れませんが苦肉の策として ColorRGB
にクラスメソッド from_hsl()
を追加してもいいかも、と思い始めました。しかしこのモヤモヤを綺麗に解決できるのがインタフェースです。
抽象的な色である IColor
を定義すれば、実体は RGB色空間だろうと HSL 色空間だろうと関係なく、どちらも同じ色として扱えます。さらに色空間を相互変換するためのメソッドを用意すれば必要なときに必要な色空間で取り出せるようになります。
class IColor(metaclass=abc.ABCMeta):
@property
@abc.abstractmethod
def components(self) -> tuple[float, float, float]:
raise NotImplementedError()
@abc.abstractmethod
def to_rgb(self) -> 'ColorRGB':
raise NotImplementedError()
@abc.abstractmethod
def to_hsl(self) -> 'ColorHSL':
raise NotImplementedError()
class ColorRGB(IColor):
def __init__(self, red: float, green: float, blue: float):
self.red = red
self.green = green
self.blue = blue
@property
def components(self) -> tuple[float, float, float]:
return self.red, self.green, self.blue
def to_rgb(self) -> 'ColorRGB':
return self
def to_hsl(self) -> 'ColorHSL':
h, l, s = colorsys.rgb_to_hls(self.red, self.green, self.blue)
return ColorHSL(h, s, l)
# ColorHSLは割愛
- 兄弟関係にある概念をまとめて扱うことができます。
- 兄弟関係にある概念の相互変換・実体を返すメソッドを用意することで、使う側から見たときに内部状態に注意を払わなくてもよくなります。
- Pythonではプロパティもインタフェースにできますが、言語によってはできないこともあります。
- 実際にはファクトリがあったほうが便利なケースが多いので、後述の抽象クラスとして定義したほうがいいかもしれません。
Price クラス
商品の価格を日本円で ¥100 や ¥1,890 のように表現する Price
クラスを考えてみます。Price
は Price
同士で足し引きができ、値引額や値引率を設定でき、購入時に付与される 1% のポイントも算出できる優れものです。
しかしオブジェクト指向に則るならば Price
は日本円なので、日本円を表す JapaneseYen
のサブクラスであるべきだと考えました。JapaneseYen
は日本円同士の足し引きだけを知っていて、値引額やポイント算出のことは知りませんし、知っているべきではありません。
さらに発想を飛ばすと、日本円だけが通貨なのではありません。通貨の計算をグローバルに適用するとなると抽象的な通貨を表現する ICurrency
が必要になります。ICurrency
は足し引きのインタフェースを持ちますが、両替や計算時の通貨単位の確認はそれぞれの通貨実装クラスやドメインサービスに一任しています。
これら Price
, JapaneseYen
, ICurrency
3クラスの構造をまとめると次のようになります。
- ICurrency (通貨計算のインタフェースを定義)
- JapaneseYen (日本円での計算を実装)
- Price (+商品の販売に必要な計算を定義)
- JapaneseYen (日本円での計算を実装)
ここで実装クラス JapaneseYen
と Price
には親子関係があります。Price
は JapaneseYen
ですが、逆は成り立ちませんし、同列に語れる概念でもありません。ドメインモデルによっては直接インタフェースを実装するだけではなく、段階ごとに機能や概念を分けて実装していくことも重要です。
- 実装クラスをさらに継承してサブクラスを定義することもあり得ます。
- 特定の概念がなにを基に成り立っているか考えることで実装から遡ってインタフェースを導き出すことができます。
- ひとつのクラスに概念を詰め込みすぎないようにするとよいモデルになります。
抽象クラスとの違い
ここまでインタフェースについて論じてきましたが、似たような概念に抽象クラスがあります。インタフェースとの違いには以下のようなものがあります。
- 継承したクラスのインスタンスを生成するファクトリがある
- 既定のメソッドがある
- サブクラスで定義する予定のメソッドの結果を使うメソッドがある
- 抽象クラスのコンストラクタで引数を要求する
このリストを確認して、インタフェースで定義することに疑問を感じたらそれはもしかしたら抽象クラスとして定義するべきかもしれません。実際のプログラミングにおいては必ずしもインタフェースにする必要はなく、状況に応じて抽象クラスとインタフェースを使い分けて、その使いどころを見極める力を養うことが重要です。
リスト中にある「サブクラスで定義する予定のメソッドの結果を使うメソッドがある」というのは少々わかりにくいかもしれません。理解を助けるためにコードで書くと次のようになります。
class Service(metaclass=abc.ABCMeta):
@abc.absractmethod
def handle(self, **kwargs: object) -> dict[str, object]:
raise NotImplementedError()
def try_handle(self, **kwargs: object) -> dict[str, object]:
try:
return self.handle(**kwargs)
except Exception as e:
traceback.print_exception(e)
return {'error': list(e.args)}
handle()
は不特定のパラメータを受け取ってなにかを処理して dict を返却するメソッドです。このメソッド内では例外が発生することがあるかもしれません。そうしたときにいちいち handle()
の呼び出し元でエラーを捕捉するのはコード量も増えますし、注意を払っていないと同じ処理をしていたと思っていてもそうでなくなっている可能性もあります。
そこで try_handle()
を定義して、handle()
を包みました。これによってエラー発生時に共通的な処理ができるようになりました。この追加によって Service
には既定のメソッドがあることにもなるので抽象クラスとして扱うほうが妥当ということになります。
抽象クラスの命名規則には MailSenderBase
のように基底クラスであることを示すBaseをつけるもの、AbstractMailSender
のように抽象クラスであることを示すAbstractをつけるもののほかに、MailSender
のようになにもつけないものがありますが、ファクトリがある場合には最後のものが使われることが多い印象です。
依存性の注入
ユーザー情報の読み書きをするための IUserRepository
インタフェースがあり、これを使う実装があったとします。この実装クラスとして DatabaseUserRepository
と XMLUserRepository
があったとしても、コード上ではこれらの実装に依存しないのでコードがクリーンな状態に保たれます。
しかし最終的には実装クラスが必要になります。そこで実行時に自動的に実装クラスを差し込むのが依存性の注入 (Dependency Injection) という機能です。依存性の注入はインタフェースクラスのインスタンス化が要求されたときに、あらかじめ登録された実装クラスのインスタンスを返すように動作します。
Injector
injector は依存関係注入用のライブラリで癖もなく使いやすく、Pure Pythonで書かれているので特定の環境に依存しません。2022年03月現在でも10年以上に渡ってメンテナンスが続けられている息の長いライブラリです。
依存性を注入してみる
ネコの動作を定義したインタフェース ICat
の実装を考えてみます。このインタフェースは walk()
メソッドだけを持ち、方向を与えるとその方向へ移動することを想定しています。
class ICat(metaclass=abc.ABCMeta):
@abc.abstractmethod
def walk(self, direction: Direction) -> None:
raise NotImplementedError()
ここで ICat
にふたつの実装クラスを定義しました。
-
普通のネコ
Cat
。walk()
の実装だけでなく、可愛らしい声でなくためのmeow()
メソッドが新たに追加されています。 -
強化装甲持ちのネコ
EnhancedCat
。 ビームライフルを発射するためのbeamrifle()
メソッドが追加されています。
これをコードに落とすとそれぞれ以下のようになります。
class Cat(ICat):
def walk(self, direction: Direction) -> None:
pass
def meow(self, target: Target) -> None:
pass
class EnhancedCat(ICat):
def walk(self, direction: Direction) -> None:
pass
def beamrifle(self, target: Target) -> None:
pass
基本的にネコは気ままに歩いたり鳴いたりするものですが、稀にビームを発射するネコもいます。そのため2022年現在では Cat
と EnhancedCat
を使い分ける必要がありますが、西暦3XXX年の宇宙戦争時代に突入した地球ではネコはすべて戦闘員としての活躍を期待され EnhancedCat
しか使われなくなったとします。
つまり、ICat
のインスタンスが欲しくなったときに決め打ちで EnhancedCat
を返して欲しいという要求が生まれたのです。そういうコードは injector
を使えばこう書けます。
import injector
def bind(binder) -> None:
# ICatが要求されたときにEnhancedCatを返す様にする
binder.bind(ICat, to=EnhancedCat)
inj = injector.Injector(bind)
cat = inj.get(ICat) # EnhancedCatのインスタンスを取得
injector
を使った依存性の注入にはどのインタフェースでどの実装クラスを返せばいいのかあらかじめバインドしておく必要があり、injector.Injector()
にそのためのメソッドを指定します。その後 get()
メソッドにインタフェースを与えて呼び出すことで実装クラスのインスタンスを得ることができます。
これが最低限のコードですが、業務レベルでのコーディングではもう少しまとまりのある次のようなコードのほうが好まれるかもしれません。
import injector
class DependencyBuilder:
def __init__(self):
# 依存性注入の初期化はconfigureに移譲
self._injector = injector.Injector(self.__class__.configure)
@classmethod
def configure(self, binder: injector.Binder) -> None:
# ICatにEnhancedCatを紐つける
binder.bind(ICat, to=EnhancedCat)
def __getitem__(self, klass: Type[TypeVar('T')]) -> Callable:
# 与えられたインタフェースに応じて実体クラスを返す
return lambda: self._injector.get(klass)
Dependency = DependencyBuilder()
cat = Dependency[ICat]()
西暦4XXX年には宇宙戦争が終結し、宇宙中央政府が樹立。争いはなくなりました。そうしたときに ICat
の実装クラスとして Cat
を返したくなった場合にあちこちのコードを直す必要はなく、 bind()
メソッドの定義を書き換えるだけです。
-
injector
を使うと簡単に依存性の注入ができます。 - 依存性の注入をするには注入したいインタフェースに実装クラスを登録します。
- 令和のデ・ジ・キャラットは 2022 年秋放映予定です。
__init__()
のパラメータに依存性を注入したい
EnhancedCat
はビームライフルを撃てますが、このビームライフルをより攻撃力の高いプラズマキャノンに変更したいという要求が発生しました。つまり、装備を自由に変更できるようにしたいということです。これは EnhancedCat
に __init__()
を定義して光学兵器を渡すようにすればよさそうです。ついでに beamrifle()
メソッドは shoot()
に改名しておきます。
class EnhancedCat(ICat):
def __init__(self, weapon: IThermalWeapon):
self._weapon = weapon
def walk(self, direction: Direction) -> None:
pass
def shoot(self, target: Target) -> None:
self._weapon.shoot(target)
IThermalWeapon
の実装クラスは BeamRifle
や PlasmaCannon
があることが想定されます。宇宙戦争初期ではどちらも需要がありましたが、後期にはプラズマキャノンしか使われなくなりました。そこで __init__()
の weapon
に自動的に PlasmaCannon
を入れてくれたら楽だなあ…と感じてきました。
それを解決するのが @injector.inject
デコレータです。この力を得るために 2 箇所だけコードを修正します。
class DependencyBuilder:
@classmethod
def configure(self, binder: injector.Binder) -> None:
binder.bind(ICat, to=EnhancedCat)
binder.bind(IThermalWeapon, to=PlasmaCannon) # 追加
class EnhancedCat(ICat):
@injector.inject # 追加
def __init__(self, weapon: IThermalWeapon): # 型アノテーションは必須
self._weapon = weapon
# 以下略
Dependency = DependencyBuilder()
cat = Dependency[ICat]()
こうすることで EnhancedCat
を injector
経由で取得した場合には必ずプラズマキャノンを装備した強化装甲ネコが手に入るようになります。
-
@injector.inject
で__init__()
のパラメータにも依存性を注入できます。- パラメータ数はいくつでも構いません。
-
@injector.inject
は型アノテーションがないと動作しません。
特定のパラメータでインスタンスを生成する
これまで EnhancedCat
の装備を変更する際は担当者であるあなたがプログラムを修正することで対応してきましたが、地球軍総司令部から EnhancedCat
の装備を JSON ファイルに基づいて変更したいという要求が来ました。
こういう場合には、bind()
メソッドに lambda
のような Callable なオブジェクトを指定します。injector
は Callable なオブジェクトを指定すると、get()
を呼び出すたびにそれを実行します。
class DependencyBuilder:
@classmethod
def configure(self, binder: injector.Binder) -> None:
binder.bind(ICat, to=lambda: EnhancedCat.from_json('loadout.json'))
binder.bind(IThermalWeapon, to=PlasmaCannon)
class EnhancedCat(ICat):
@injector.inject
def __init__(self, weapon: IThermalWeapon):
self._weapon = weapon
@classmethod
def from_json(cls, path: str) -> 'EnhancedCat':
with open(path) as f:
data = json.load(f)
weapon = ThermalWeapon.from_string(data['name'])
return cls(weapon)
# 以下略
-
bind()
メソッドのto
には Callable オブジェクトも指定できます。 - 環境変数などのパラメータを
__init__()
に指定したい場合に有効です。
シングルトンを生成したい
injector
は get()
メソッドを呼び出すたびに異なるインスタンスを返す NoScope
生成規則がデフォルトになっています。
ただ、複数のインスタンスがあることを想定しておらず、これが迷惑な場合もあります。こういったように毎回同じインスタンスを返して欲しい場合には @injector.singleton
デコレータをクラスに指定します。
@injector.singleton
class EnhancedCat(ICat):
# 俺!総勢一名参陣!
pass
あるいはバインド時にオプション引数 scope
を指定します。こちらのほうがひとつのファイルで依存性注入のパラメータがわかりやすいのでおすすめできます。
class DependencyBuilder:
@classmethod
def configure(self, binder: injector.Binder) -> None:
binder.bind(
ICat,
to=lambda: EnhancedCat.from_json('loadout.json'),
scope=injector.SingletonScope
)
binder.bind(IThermalWeapon, to=PlasmaCannon)
-
@injector.singleton
は同時にひとつしか存在して欲しくないインスタンスを作るのに役立ちます。
実際のところは
筆者自身、開発においてはインタフェースと依存性の注入を多用していますが、実装クラスがふたつ以上になることはあまりありません。それでも特定の実装に依存しないことはコードをクリーンに保てますし、なにより機能をインタフェースとして切り出すこと自体がモデリング力を鍛えることに繋がります。
また経験的に業務レベルだと複数の実装クラスを持つことが難しくなることがあり、それは次のように実装すべきメソッドが多いときです。
class IUserRepository:
@abc.abstractmethod
def find(self, user_id: str) -> User:
raise NotImplementedError()
# :
# いくつものメソッド
# :
ここで実装クラスの find()
メソッドのパフォーマンスが悪く改善を希望されたとしましょう。残念なことに find()
メソッドは使用箇所が多く、現在パフォーマンスを除いてはちゃんと動いているコードなので、下手にいじって既存の業務プロセスを破壊する危険を犯すよりも安定稼働をとりたいという気持ちが勝ります。そのため find2()
メソッドを定義して、部分的に find2()
を呼び出すようにする……。など現実にはインタフェースの特長である実装のすげ替えが難しい場面も多々あります。
本来ならば新しい実装クラスを定義するべきですが、そうするとパフォーマンス改善した find()
が正しく動いていることを保証した上で、一度にすべてをすげ替えることになり、たとえデバッグビルドで正常動作を確認していたとしても実際にはあまりやりたくないというのが本音です。もし破壊的変更を加えていいときであれば問題はそれほど大きくならないと考えられます。
- 実装クラスがふたつ以上になることは少ないです。
- 現場レベルだと理想のインタフェース実装にならないこともあります。
- しかしインタフェースを意識したコーディングはモデリング力の向上に繋がります。
Discussion