⛩️

interface の本質と使いどころ (PHP)

2022/09/28に公開約6,700字1件のコメント

この5年ほどで、PHP開発の現場で interface を目にする機会は劇的に増えました。
一方で、interface の理解に苦しんでいる人も、また多いように思います。

文法・使い方は理解できるけど、概念・使い所が理解できない。
これって、開発者にとって大きな壁だと思うのです。

この記事は、interface の本来の概念(と私が思うもの)をお伝えることを目指しています。
かなり主観に寄った(出典オレな)記事になりますが、ご了承ください。

interface の誤解

最近の開発現場で目にする interface は、
リポジトリパターン等に見られるような「依存性逆転」を実現するための文脈か、
「依存性注入(DI)」を目的とした記述ルールであるかのどちらかが、
90%以上を締めており、

若手の開発者は「interface は依存性注入のためのもの」と誤解している節さえあります。

でも本来 interface が生まれた経緯は、決してそうではないはずなのです。

interface 誕生秘話

ECサイトのプログラムにおいて、カートの状態を管理する ShoppingCart クラスと、
商品を表現する Product クラスがあるとします。

細かい実装は考えないでください。あくまでイメージです。

class Product
{
    private int $price;

    /**
     * 価格を返す
     */
    public function getPrice() : int
    {
        return $this->price;
    }
}

class ShoppingCart
{
    private int $total = 0;

    /**
     * 商品をカートに追加
     */
    public function add(Product $product)
    {
        // 合計金額を加算
        $this->total += $product->getPrice();
    }
}

この時点での想定は、Product は当然に価格を持っており、
getPrice() は単純な getter です。

そこに、複数のバリエーションやオプションをもった、
「バリエーション商品」が現れました。
価格の特定にも、少々ややこしいロジックが必要になり、
Product クラスだけでは表現しきなくなります。

そこで開発者は、Product を継承した、
VariableProduct クラスを生み出します。

class VariableProduct extends Product
{
    /**
     * 価格を計算して返す
     */
    public function getPrice() : int
    {
        // ややこしい計算
        $price = $this->calculatePrice();

        return $price;
    }
}

class ShoppingCart
{
    private int $total = 0;

    /**
     * 商品をカートに追加
     */
    public function add(Product $product)
    {
        // 合計金額を加算
        $this->total += $product->getPrice();
    }
}

VariableProduct が Product を継承しているので、
タイプヒンティングに Product と記載しておきさえすれば、
VariableProduct オブジェクトも受入れられます。

また、Product を継承している以上、
int を返す getPrice() メソッドを持っていることも保証されます。

従って、ShoppingCart の実装を変更する必要はありません。
めでたしめでたし。

・・・

ところがどっこい。

「有料のラッピングサービス」もカートに追加したい、
という要件が湧いてきました。

これを表現するために、Wrapping というクラスが産まれます。

しかし、付帯サービスである Wrapping が、
モノである Product を継承するのは、
いくらなんでも無理があります。

そもそも持つべきプロパティが全然違うはずですから。

そこでとうとう、 ShoppingCart に手をいれることになります。

class ShoppingCart
{
    private int $total = 0;

    /**
     * 商品をカートに追加
     */
    public function add(Product|Wrapping $product)
    {
        // 合計金額を加算
        $this->total += $product->getPrice();
    }
}

PHP 8.0 以降では、上記のような union 型が記述できますね。

ひと昔前なら、こうしていたかもしれません。

class ShoppingCart
{
    private int $total = 0;

    /**
     * 商品をカートに追加
     */
    public function add($product)
    {
        // Product でも Wrapping でもなければ、例外をぶん投げる
        if (! $product instanceof Product && ! $product instanceof Wrapping) {
            throw new Exception();
        }

        // 合計金額を加算
        $this->total += $product->getPrice();
    }
}

しかし、今後 Product や Wrapping 以外にも、
「カートに入れたいもの」はどんどん増えてくる可能性があります。

その度に、 ShoppingCart に手を入れるのは、何か嫌です。

手を入れるということは、それを呼び出している箇所も含めて、
テストも必要になるということですから、
多くの箇所で使用されているクラスほど、
変更が発生しにくくすべきです。

じゃあどうするかというと、

class ShoppingCart
{
    private int $total = 0;

    /**
     * 商品をカートに追加
     */
    public function add($product)
    {
        if (!method_exists($product, 'getPrice')) {
            throw new Exception();
        }

        // 合計金額を加算
        $this->total += $product->getPrice();
    }
}

引数 $product が getPrice() を持っていなければエラー。

これなら、今後カートに追加したいものが増えたとしても、
ShoppingCart クラスの変更は必要ないかもしれません。

ただ、ShoppingCart クラスを使う側が困ります。

「getPrice() を持っていなければエラー」というのは完全に「暗黙のルール」であり、
実行時まで発見できない、隠れた不具合になり得るからです。

そこで登場するのが、interface です。

interface CanAddToCart
{
    public function getPrice() : int
}

class ShoppingCart
{
    private int $total = 0;

    /**
     * 商品をカートに追加
     */
    public function add(CanAddToCart $product)
    {
        // 合計金額を加算
        $this->total += $product->getPrice();
    }
}

おわかりでしょうか。

まず、ShoppingCart クラス側からすると、
タイプヒンティングで CanAddToCart を指定しているため、

引数 $product にどんなオブジェクトが渡ってくるかは知らないけれど、
少なくとも int を返す getPrice() メソッドを持っている
ということが保証されているので、
何の気兼ねもなく、$product->getPrice() を呼ぶことができます。

一方、ShoppingCart を使う側からすると、
例えば、新たに「値引きクーポン」を表す Discount クラスをカートに追加したい、
となった場合、

タイプヒンティングに CanAddToCart が指定されているため、
Discount クラスに CanAddToCart を実装 (implements) するより他にありません。

CanAddToCart を実装 (implements) した以上、
getPrice() を実装しないとエラーになるため、
Discount クラスに 否応なく getPrice() を定義させられる ことになります。

つまり、プログラムに「暗黙のルール」が入り込む余地が、
完全になくなるのです。

interface は誰のためのものか

こうして見ると、interface は、
オブジェクトを受け取る側が、渡し手に対して制約を課す という色彩が強いことがわかります。

この「受け取る側」は概して共通モジュール、プロダクトの中でもコアな部分です。

そのため、個々の画面や機能の実装が大多数を占める開発業務の中で、
interface を使わされる側 になることはあっても、
interface を要求する側 になる機会は、圧倒的に少ないのです。

このあたりが、interface の理解がなかなか進まない要因かもしれません。

何なら「色々制約があってめんどくさいもの」という印象を持っている人も多いかもしれませんが、
コアな共通モジュール側にとっては、とても有り難いものなんです。

そして間接的には、そのモジュールを使う側も、
「潜在バグを産まなくて済む」という恩恵を受けているのです。

interface は「性質」

前段でさらっと書いたのですが、

しかし、付帯サービスである Wrapping が、
モノである Product を継承するのは、
いくらなんでも無理があります。

この感覚は、伝わってますでしょうか。

文法的には、 Wrapping extends Product としても、
何ら問題はないのです。

でも、違うんです。

オブジェクト指向とかドメインオブジェクトとか小難しい話を抜きにしても、
「ラッピングサービス」が「商品」の一種であるとは、
(ほとんどの文脈では)いえないと思うんです。

継承 (extends) が、「〇〇の一種」という、
「分類」を表現するものだとすれば、

interface は、「〇〇できるもの」「〇〇する対象である」という、
「性質」を表すものです。

上記の例でいうと、「カートに追加されるもの」という「性質」ですね。

この感覚がつかめれば、「抽象クラス?それともインターフェイスを作る?」と悩むことも、
減るのではないかなぁ、と思います。

interface と SOLID原則

みなさん、SOLID原則はご存知でしょうか。
保守しやすいアプリケーションを作るための、5つの実装指針ですね。

interface といえば、SOLIDの"D"、依存性逆転原則でよく登場しますが、

今回の例で果たしている役割はそれだけではありません。

ShoppingCart::add() のパラメータを interface にすることで、
今後 Product や Wrapping 以外のものが登場しても、ショッピングカートで扱えるようになりました。
しかも、その場合でも、add() は変更する必要がありません。

つまり、拡張に対してオープンで、変更に対してクローズドであるといえます。
これは、SOLID原則の"O"、オープンクローズド原則に合致しています。

また、仮に interface を用いなかったとすれば、
Wrapping に Product を継承させることも視野に入ったかもしれません。

もしそうなった場合、Product には、Wrapping では使用しないメソッド(例えば getSize() など)も存在しているでしょう。

ただ、事実上使用しなくても、継承している以上呼び出せてしまい、不具合の原因になります。

interface にすることで、Wrapping には必要最小限のメソッドを持たせることができます。

これは、SOLID原則の"I"、インターフェース分離の原則に通じる考え方です。

インターフェース分離の原則

インターフェース分離の原則に「通じる」などという
中途半端な書き方をしてしまったので、もう少し補足します。

ShoppingCart クラスには、ユーザがカート内数量を変更する
changeQuantity() というメソッドもあったとします。

このメソッドの引数も、CanAddToCart インターフェースで良いでしょうか?

もしそうした場合、CanAddToCart インターフェースに、
例えば getMaxQuantity() のようなメソッドを定義することになるでしょう。

しかし、カートには追加できるけれど、数量指定はできない Discount のようなクラスが現れた場合、
Discount には数量という概念が存在しないにも関わらず、
getMaxQuantity() メソッドを実装しなくてはいけなくなります。

これは、やはり潜在的な不具合を産む可能性があります。
(本来のインターフェース分離の原則が指摘しているのは、こうした問題です。)

この場合、「カートに入れられる」という性質と、「数量を変更できる」という性質は、
似ているようで、異なる性質だということです。

つまり、CanAddToCart インターフェースとは別に、
CanChangeQuantity インターフェースを作ることが望ましい、ということになります。

なお interface は、別の interface を継承 (extends) することもできるので、
「数量を変更できる」という性質を、ショッピングカート内に限ったものとして考えるのであれば、
CanChangeQuantity extends CanAddToCart とすることも考えられます。

interface と業務要件

上記は、全てのショッピングカートシステムがそうであるというわけではありません。

同じ性質なのか異なる性質なのかは、業務要件に基づいてプロダクトごとに判断する必要があります。

インターフェースは、その性質上、変更したときの影響範囲が一般的なクラスより大きくなることが多いので、
業務要件の将来的な拡張可能性まで、しっかりと吟味して定義していきたいところです。

Discussion

わかりやし説明ありがとうございます。
この記事のおかげでinterfaceの必要性を理解できました。

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