LoginSignup
16
8

More than 3 years have passed since last update.

Swiftで実装提供用のプロトコルを継承する際にそのためのrequirementsが意図せず露出する問題を回避するための実装パターン

Posted at

概要

Swiftである型CがあるプロトコルPにconformするとき、Pのrequirementsを与えるCのメンバの可視性は、C自身の可視性と同じかより緩くなければなりません。この仕様は、プロトコルPの実装を提供するプロトコルPBaseなどを利用する際に、期待しない過剰な実装の露出を引き起こす場合があります。この記事では、そのような場合に実装を再利用する利便性を維持しつつも可視性の過剰な露出を回避する実装パターンを紹介します。

導入: 実装提供プロトコル継承パターンの紹介

以降では、下記のサンプルコードを元に説明します。まず、下記のように、あるプロトコルMediaSinkがあったとします。

public protocol MediaSink: AnyObject {
    associatedtype InputMedia

    func didConnect<X: MediaSourceConnection>(connection: X)

    func close()

    func send<X: MediaSourceConnection>(event: MediaEvent<InputMedia>,
                                        connection: X) -> Bool
}

このプロトコルは、とあるメディアストリーム処理のライブラリに登場します。このライブラリのコンセプトは、プルベースの非同期ストリーム処理です。Sourceが送信側、Sinkが受信側、SourceとSinkの接続がそれぞれに対応するConnectionオブジェクトで表現されます。例えば、SinkにはSource側を表すSourceConnectionが与えられます。SourceとSinkはこのConnectionオブジェクトを介して通信を行います。MediaEventはnext, complete, errorの3種類のcaseを持つenumです。MediaSinkのメソッドはそれぞれ、didConnectが確立した接続の通知、closeが接続の切断、sendがSource側から見たイベントの送信(Sink側から見たイベントの受信)を意味します。

このライブラリにおいて、Sinkを実装する際には、そのSinkに対する操作をスレッドセーフにすること、確立した接続を保持すること、その接続に対して機能を呼び出すこと、流れてきたイベントと接続の対応をチェックすることなど、決まった作法があります。このような決まった作法がある場合には、個別の実装においてその作法を守るために実装を共通化したくなります。そこで、プロトコルとプロトコルエクステンションを用いて、共通の実装と個別の実装部分を与えることにします。それが下記のMediaSinkBaseです。

public protocol MediaSinkBase: MediaSink {
    var queue: DispatchQueue { get }

    var _sourceConnection: WeakAnyMediaSourceConnection { get set }

    func _send(event: MediaEvent<InputMedia>,
               readyAfterDone: inout Bool)
}

extension MediaSinkBase {
    public func didConnect<X: MediaSourceConnection>(connection: X) {
        queue.sync {
            // (略)
        }
    }

    public func close() {
        queue.syncAndRunAfter {
            // (略)
        }
    }

    public func send<X: MediaSourceConnection>(event: MediaEvent<InputMedia>,
                                               connection: X) -> Bool
    {
        return queue.sync {
            // (略)
        }
    }
}

MediaSinkを実装したい型があるとき、このMediaSinkBaseを継承することによって、queue, _sourceConnection, _sendを実装するだけで、スレッドセーフなMediaSinkの実装が得られるようになります。

下記のVideoDisplaySinkは、MediaSinkBaseを継承してMediaSinkを実装した例です。

public final class VideoDisplaySink<Frame: VideoFrameProtocol>:
    MediaSinkBase
{
    public typealias InputMedia = Frame

    public let queue = DispatchQueue(label: "VideoDisplaySink.queue")
    public var _sourceConnection = WeakAnyMediaSourceConnection()
    public func _send(event: MediaEvent<Frame>, readyAfterDone: inout Bool) {
        // (略)
    }

    // (略)
}

このパターンは複雑なプロトコルに共通の実装を与えたい場合に便利です。実際のコーディングにおいては、プロコトルの継承まで記述してからコンパイラのAutoFixを利用することにより、自分で実装しなければならないメンバの宣言が自動的にソースに入力され、後はその中身だけコーディングすれば作業が完了します。ここで型検査とAutoFixが効くので、ミスする可能性を低くできます。このパターンを便宜上、実装提供プロトコル継承パターンと呼ぶことにします。

動機: 実装提供プロトコル継承パターンの問題

上述したとおり、実装提供プロトコル継承パターンは便利ですが、問題があります。それは、この実装提供用のプロトコルのrequirementsに対応するメンバの可視性が、その型自身の可視性と同じかより緩くなければならないことです。VideoDisplaySinkの例においては、queue, _sourceConnection, _sendの可視性がpublicになっている点のことです。もしこれらの可視性をinternalprivateに制限した場合、コンパイルエラーとなり、露出する事が強制されています。このような露出はバグの原因となり得ます。例えばVideoDisplaySinkにおいては、queueにアクセスする事ができるため、間違って使用すればデッドロックが生じます。他にも、本来queueで同期する事を想定している_sourceConnection_sendに直接アクセスする事によって、マルチスレッドバグが生じえます。この記事では、このような過剰露出の問題を回避する実装パターンを紹介します。

途中解: 実装内部化の手動実装

この問題の解決策として、実装を提供するプロトコルをインナータイプで継承することで、余計な露出を隠蔽する方法があります。いわゆるis-aからhas-aへの書き換えと言えます。下記に例を示します。

public final class VideoDisplaySink<Frame: VideoFrameProtocol>:
    MediaSink
{
    public final class Impl : MediaSinkBase {
        public typealias InputMedia = Frame

        public weak var wrapper: VideoDisplaySink<Frame>!
        public let queue = DispatchQueue(label: "VideoDisplaySink.queue")
        public var _sourceConnection = WeakAnyMediaSourceConnection()
        private var _time: CMTime

        public var time: CMTime {
            get { return queue.sync { _time } }
            set {
                queue.sync {
                    // 略
                }
            }
        }

        public func _send(event: MediaEvent<Frame>, readyAfterDone: inout Bool) {
            // 略
        }
    }

    public typealias InputMedia = Frame

    private let impl: Impl

    public init(time: CMTime) {
        impl = Impl(time: time)
        impl.wrapper = self
    }

    public func didConnect<X>(connection: X) where X : MediaSourceConnection {
        impl.didConnect(connection: connection)
    }

    public func close() {
        impl.close()
    }

    public func send<X>(event: MediaEvent<Frame>, connection: X) -> Bool where X : MediaSourceConnection {
        impl.send(event: event, connection: connection)
    }

    public var time: CMTime {
        get { return impl.time }
        set { impl.time = newValue }
    }
}

上記のコードの構造を解説します。まず、MediaSinkBaseを継承してVideoDisplaySinkの実装を与えるクラスを、インナークラスのImplとして定義します。そして、外側のVideoDisplaySinkはそのImplのインスタンスをimplプロパティで保持するようにします。そして、ただのMediaSinkを継承します。AutoFixでrequirementsのテンプレートを生成したら、その実装は全てimplプロパティに転送するようにします。またこの例では、VideoDisplaySink固有の機能であるtimeプロパティの実装例も示しています。ポイントとして、この例のようにtimeの実装もImplの中で完全に実装して、外側のプロパティは転送するだけにしています。うっかり外側に実装を持ってしまうと、内側と外側の行き来が発生してコードの見通しが悪くなりがちなので避けましょう。さらに、場合によっては外部の関数に自分自身であるVideoDisplaySinkを渡したい場合があります。しかし、Impl自身は異なる型なので、Implselfは使えません。そこで、外側のVideoDisplaySinkへの参照プロパティのweak var wrapper: VideoDisplaySink<Frame>!を定義して、外側のVideoDisplaySinkImplを生成した後にselfを渡しています。この外側と内側の型は常にペアで使用するため、内側から外側への参照の型は弱参照のIUOで良いでしょう。

提案: 実装転送用クラス継承パターン

しかし、上記の方法は若干面倒です。プロトコルを実装するたびにimplに実装を転送する固定のコードを書かなければなりません。それに、プロトコルのrequirementsの数と同じだけ、この転送も書かなければなりません。そこで今回提案したいのが、実装を転送するための継承用のクラスを用意する方法です。下記にコード例を示します。

open class MediaSinkInheritanceProxy<Impl: MediaSink> : MediaSink {
    public typealias InputMedia = Impl.InputMedia

    private let impl: Impl

    public init(impl: Impl) {
        self.impl = impl
    }

    open func didConnect<X>(connection: X) where X : MediaSourceConnection {
        impl.didConnect(connection: connection)
    }

    open func close() {
        impl.close()
    }

    open func send<X>(event: MediaEvent<InputMedia>, connection: X) -> Bool
        where X : MediaSourceConnection
    {
        impl.send(event: event, connection: connection)
    }
}

MediaSinkInheritanceProxyは、型パラメータImplの型のインスタンスをinitで受け取ってimplプロパティとして保持し、自身のMediaSinkの実装はそのimplに転送するだけのクラスです。別モジュールからでもプロトコルとセットで再利用できるように可視性はopenにしてあります。このクラスを継承することで、VideoDisplaySinkの実装において転送のコードを書く必要がなくなります。下記に例を示します。

public final class VideoDisplaySink<Frame: VideoFrameProtocol>:
    MediaSinkInheritanceProxy<VideoDisplaySink<Frame>.Impl>
{
    public final class Impl : MediaSinkBase {
        public typealias InputMedia = Frame

        public weak var wrapper: VideoDisplaySink<Frame>!
        public let queue = DispatchQueue(label: "VideoDisplaySink.queue")
        public var _sourceConnection = WeakAnyMediaSourceConnection()
        private var _time: CMTime

        public var time: CMTime {
            get { return queue.sync { _time } }
            set {
                queue.sync {
                    // 略
                }
            }
        }

        public func _send(event: MediaEvent<Frame>, readyAfterDone: inout Bool) {
            // 略
        }
    }

    private let impl: Impl

    public init(time: CMTime) {
        impl = Impl(time: time)
        super.init(impl: impl)
        impl.wrapper = self
    }

    public var time: CMTime {
        get { return impl.time }
        set { impl.time = newValue }
    }
}

このようになります。VideoDisplaySinkMediaSinkInheritanceProxyを継承しており、自身のインナークラスであるImplを型パラメータとして渡しています。ここで、<Impl>と書けたら嬉しいのですが、駄目みたいなので完全名で<VideoDisplaySink<Frame>.Impl>と書いています。initにおいてsuper.init(impl:)の呼び出しが追加されていますが、その代わりに全てのプロトコルrequirementsのメンバ実装が省略できました。associatedtypeであるInputMediaについてのtypealiasも親から継承しているので不要になっています。このように継承するだけで転送できるクラスを用意することで、プロトコルを実装する型全てで共通の転送実装を利用できるため、先述の方法と比べてかなり楽になります。プロトコルの定義が拡大した場合も、転送用のクラスのみ修正すれば良いので、転送部分の変更が生じません。もちろん、定義が拡大した時は各Impl側の修正は必要ですが。このパターンを実装転送用クラス継承パターンと呼ぶことにします。

Type erasureとの類似性

この実装パターンはType erasureの実装パターンの継承box方式と似ています。構造として、継承box方式における内部boxサブクラスが、型パラメータにeraseしたいコンクリートな型を焼き込んでいるところが同じ仕組みになっています。erasureの場合は元のコンクリートな型を完全に消去するために、型情報の無い親クラスにサブタイピングさせていますが、今回のproxyの場合は別にそこを秘匿する必要はないので型パラメータをそのまま露出しています。検証はしていませんが、こうしておくことで真の型が露出するため最適化が期待できます。

類似解: 実装転送用プロトコル継承パターン

上記の方法はクラス継承を利用しているので、同時に複数のプロトコルを実装しつつそれらを転送したい場合には使用することができません。そこで代替案として、転送機能をクラスではなくプロトコルで提供するパターンがあります。下記に例を示します。

public protocol MediaSinkProxyProtocol : MediaSink {
    associatedtype Impl: MediaSink where Impl.InputMedia == Self.InputMedia

    var impl: Impl { get }
}

extension MediaSinkProxyProtocol {
    public func didConnect<X>(connection: X) where X : MediaSourceConnection {
        impl.didConnect(connection: connection)
    }

    public func close() {
        impl.close()
    }

    public func send<X>(event: MediaEvent<InputMedia>, connection: X) -> Bool where X : MediaSourceConnection {
        impl.send(event: event, connection: connection)
    }
}

元の方式では型パラメータImplとプロパティimplでしたが、associatedtype Implとプロパティ制約implになっています。これを使ってVideoDisplaySinkを実装すると下記のようになります。

public final class VideoDisplaySink<Frame: VideoFrameProtocol>:
    MediaSinkProtocolProxy
{
    public final class Impl : MediaSinkBase {
        public typealias InputMedia = Frame

        public weak var wrapper: VideoDisplaySink<Frame>!
        public let queue = DispatchQueue(label: "VideoDisplaySink.queue")
        public var _sourceConnection = WeakAnyMediaSourceConnection()
        private var _time: CMTime

        public var time: CMTime {
            get { return queue.sync { _time } }
            set {
                queue.sync {
                    // 略
                }
            }
        }

        public func _send(event: MediaEvent<Frame>, readyAfterDone: inout Bool) {
            // 略
        }
    }

    public typealias InputMedia = Frame

    public let impl: Impl

    public init(time: CMTime) {
        impl = Impl(time: time)
        impl.wrapper = self
    }

    public var time: CMTime {
        get { return impl.time }
        set { impl.time = newValue }
    }
}

継承する親を書く部分では、型パラメータの指定が不要になるため簡潔になります。また、親クラスが無いためsuper.initの呼び出しも不要です。associatedtype Implの割り当ては、インナークラスの型名と同一名になっているのでそこもこのまま接続できます。一方、typealias InputMediaを書く必要があります。総じてこの方式のほうが楽に見えます、しかし、一つ致命的な問題点があります。それは、public let impl: Implプロパティの可視性がpublicになってしまった事です。その理由は実装提供用プロトコルの問題のときと同じです。実装提供用プロトコルのrequirementsが露出する問題を解決したかったはずなのに、implプロパティ経由でそれを呼び出せるようになってしまうので、元々の問題が解決できなくなってしまいます。

ただ、この点は考え方次第で採用しても良いと思います。元々の状態では、実装用のメンバ全てが直接対象の型から露出していたのに対して、あくまで露出がimplプロパティ経由だけに限定されたからです。元々の状態ではたとえ「実装用のメンバを触ってはいけない」と理解しているプログラマでもあっても、「外部利用のためのメンバと間違えて使う」ミスが考えられます。これは実装提供用プロトコルの仕様を完全に把握していないと見分けられない事なので、将来的に変化する要素でもあり、人間が気をつけて達成するのは難しいと思います。一方「implプロパティに触ってはいけない」という1つの固定のルールであれば運用上意識して守る事はそんなに難しくないように思います。便宜上こちらには実装転送用プロトコル継承パターンという名前を付けておくので、好みの方を採用すると良いと思います。

お願い

最後まで読んでくれてありがとうございました。コメントが貰えると嬉しいのでお願いします。

コメントの例

  • もっと良い方法があるので共有します。
  • 他の方法があるので共有します。
  • 私も同じ方法を使っています。
  • 同じ方法のコードをこちらで見かけたので共有します。
  • 同じ問題で困っていたので今度使ってみようと思います。
  • この問題に出会ったことはないけど今度使ってみようと思います。
  • 実装提供用のプロトコルを書いた事が無かったので今度やってみます。
  • 実装提供用のプロトコルは使うけど可視性の露出する問題を知りませんでした。
  • 最後まで読みました。
16
8
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
16
8