🐈

Python で interface を扱う

2022/03/06に公開約13,300字

インタフェース

インタフェースはクラスと異なり実体を持たず、動作の一覧だけを列挙したものです。インスタンス化できないので定義するだけ無駄な印象も受けますが、正しく利用すれば以下に示す利点を享受できます。

  • 開発が滞ることがありません。 インタフェースの取り決めさえしておけば、中身ができていなくても暫定的にテスト用のクラスを注入して開発を継続できます。もはや開発が遅い同僚の完成を待つ必要はありません。たとえ最終的にあなたが中身を実装したとしてもです。インタフェースの呼び出し側と実装側で開発を分担できるので、複数人での開発にも向きます。
  • 実装を気にしません。 入力・出力・するべき動作が正しければ実装はどうなっていようと構いません。この特性はしばしばデバッグで役立ちます。とりあえず動く実装を作っておいて、あとから修正しても構わないのです。加えて中身の実装について気にしないので細かいことを考えなくてよくなるのでビジネスロジックに集中できます。もちろんなにを入力するとなにを実行してなにを返すかは明確に決めておく必要があります。
  • コードが機能に依存せず変更に強くなります。 インタフェースを使わないコードを書いていると特定の機能依存のコードになり、保守性が悪化し、変更の際に改修範囲が広くなります。インタフェースを使用すると今ある実装が使えなくなったとしても、別の実装を用意できれば簡単にすげ替えることができます。しかしこれはインタフェースが絶対書き変わらないことを保証するものではありません。実装によっては必要なパラメータも増えてインタフェースのシグネチャを変更する必要が出てくるでしょう。
  • 似た概念を集約できます。 特に兄弟関係にある概念を抽象的に扱うことに向いています。一部例外としてインタフェースを使った上で親子関係を構築する例も示します。
  • モデリング力を鍛えられます。 インタフェースを組めるということは物事の概念や仕組みを理解して的確かつ抽象的に機能を分割できていることにほかなりません。よりよいドメインモデルを構築するためにインタフェースを取り入れませんか?

どういう概念をインタフェースとして定義するか?

インタフェースを理解し始めると、ありとあらゆる操作をインタフェースで定義できるような気がしてきますし、実際その感覚は正しいです。ただコード量も増えるのでやりすぎは避けたいところです。なににインタフェースを用意してなにに用意しないのかを決めるのはドメイン固有の問題なので一概には言えませんが、次のような場合にインタフェースを使うと効果的です。

  • 外部のリソースやシステムと連携する場合。 ファイルの読み書き・データベース接続・ネットワーク通信・ローレベルな操作がそれに該当します。
  • 兄弟関係にある概念をまとめる場合。 特定の概念に複数の実装が考えられる場合です。これは後述の IColor インタフェースで具体的に取り扱います。

インタフェースの定義の仕方

Python にインタフェースを定義するための固有の機能はありませんが、同じことをクラスを使って実現できます。ここではメール送信を行うためのインタフェースである IMailSender を例にとります。

IMailSender は唯一のメソッド send() を持ち、これは送信情報 Mail (これについて考える必要はありません)を与えるとメールを送信し、None を返します。これをインタフェースに書き起こすとこうなります。

import abc

class IMailSender(metaclass=abc.ABCMeta):
    @abc.abstractmethod
    def send(self, mail: Mail) -> None:
        raise NotImplementedError()
  1. metaclassabc.ABCMeta を渡します。これで IMailSender は抽象基底クラス扱いになります。
  2. 定義すべきメソッドに @abc.abstractmethod デコレータを付加します。IMailSender を継承した実装クラスで実装できていないとインスタンス化できなくなります。
  3. メソッドの中身を 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 リクエストの失敗を模倣できます。以下の実装 DummyFetchRequestrequest() では 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):
        self.red = red
        self.green = green
        self.blue = blue

RGB はよく使われる色空間である一方、デザインの面では明度や彩度を固定したまま色相だけを変えることができないので不向きです。そこで利便性のために HSL 色空間を表現する ColorHSL を同じように定義することにしました。

class ColorHSL:
    def __init__(self, hue: float, saturation: float, lightness: float):
        self.hue = hue
        self.saturation = saturation
        self.lightness = lightness

ですが、ここで悩みが生まれました。今回のプロジェクトでは色を表現するクラスとして ColorRGBColorHSL のどちらを使えばいいのかという問題です。どちらも同列に語れる色ですが、一般的には RGB 色空間が多く使われているので RGB にしたい気持ちもありつつも HSL も無視できません。

強引に感じるかも知れませんが苦肉の策として ColorRGB にクラスメソッド from_hsl() を追加してもいいかも、と思い始めました。しかしこのモヤモヤを綺麗に解決できるのがインタフェースです。

抽象的な色である IColor を定義すれば、実体は RGB色空間だろうと HSL 色空間だろうと関係なく、どちらも同じ色として扱えます。さらに色空間を相互変換するためのメソッドを用意すれば必要なときに必要な色空間で取り出せるようになります。

class IColor(metaclass=abc.ABCMeta):
    @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
    
    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は割愛
  • 兄弟関係にある概念をまとめて扱うことができます。
  • 兄弟関係にある概念の相互変換・実体を返すメソッドを用意することで、使う側から見たときに内部状態に注意を払わなくてもよくなります。

Price クラス

商品の価格を日本円で ¥100 や ¥1,890 のように表現する Price クラスを考えてみます。PricePrice 同士で足し引きができ、値引額や値引率を設定でき、購入時に付与される 1% のポイントも算出できる優れものです。

しかしオブジェクト指向に則るならば Price は日本円なので、日本円を表す JapaneseYen のサブクラスであるべきだと考えました。JapaneseYen は日本円同士の足し引きだけを知っていて、値引額やポイント算出のことは知りませんし、知っているべきではありません。

さらに発想を飛ばすと、日本円だけが通貨なのではありません。通貨の計算をグローバルに適用するとなると抽象的な通貨を表現する ICurrency が必要になります。ICurrency は足し引きのインタフェースを持ちますが、両替や計算時の通貨単位の確認はそれぞれの通貨実装クラスやドメインサービスに一任しています。

これら Price, JapaneseYen, ICurrency 3クラスの構造をまとめると次のようになります。

  • ICurrency (通貨計算のインタフェースを定義)
    • JapaneseYen (日本円での計算を実装)
      • Price (+商品の販売に必要な計算を定義)

ここで実装クラス JapaneseYenPrice には親子関係があります。PriceJapaneseYen ですが、逆は成り立ちませんし、同列に語れる概念でもありません。ドメインモデルによっては直接インタフェースを実装するだけではなく、段階ごとに機能や概念を分けて実装していくことも重要です。

  • 実装クラスをさらに継承してサブクラスを定義することもあり得ます。
  • 特定の概念がなにを基に成り立っているか考えることで実装から遡ってインタフェースを導き出すことができます。
  • ひとつのクラスに概念を詰め込みすぎないようにするとよいモデルになります。

依存性の注入

ユーザー情報の読み書きをするための IUserRepository インタフェースがあり、これを使う実装があったとします。この実装クラスとして DatabaseUserRepositoryXMLUserRepository があったとしても、コード上ではこれらの実装に依存しないのでコードがクリーンな状態に保たれます。

しかし最終的には実装クラスが必要になります。そこで実行時に自動的に実装クラスを差し込むのが依存性の注入 (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年現在では CatEnhancedCat を使い分ける必要がありますが、西暦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 の実装クラスは BeamRiflePlasmaCannon があることが想定されます。宇宙戦争初期ではどちらも需要がありましたが、後期にはプラズマキャノンしか使われなくなりました。そこで __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]()

こうすることで EnhancedCatinjector 経由で取得した場合には必ずプラズマキャノンを装備した強化装甲ネコが手に入るようになります。

  • @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__() に指定したい場合に有効です。

シングルトンを生成したい

injectorget() メソッドを呼び出すたびに異なるインスタンスを返す 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

ログインするとコメントできます