Quantumleap
3271 words
16 minutes
プロトコルの準拠とその罠について

プロトコルとは#

プロトコルとはそのプロトコルに準拠しているクラスや構造体に対して共通のルールを設定するものです。

感覚としてはジェネリクスに近いのでまずはジェネリクスの例から考えてみます。

ジェネリクスの考え方#

例えば、二つの入力された整数の積を計算する関数を考えます。

import SwiftUI

print(multiple(10, 5)) // 50

func multiple(_ a: Int, _ b: Int) -> Int {
    return a * b
}

すると計算結果として正しく 50 を得ることができます。

ただ、これだと整数同士での掛け算にしか対応していません。もし次のように実数同士を引数に与えると型が違うので計算できないと言われてしまいます。

print(multiple(10.0, 5.0)) // Cannot convert value of type 'Double' to exptected argument type 'Int'

それでは困るので「実数同士」でも計算できるようにしてみます。

Swift は関数のオーバーロードに対応しているので、全く同じ関数名でも引数が少し違えば定義可能です。

import SwiftUI

print(multiple(10, 5))
print(multiple(10.0, 5.0))

func multiple(_ a: Int, _ b: Int) -> Int {
    return a * b
}

func multiple(_ a: Double, _ b: Double) -> Double {
    return a * b
}

しかし、よく考えるとこの実装方法は愚直であることに気付きます。一方だけがIntのときや、型がCGFlaotのときなどありとあらゆるパターンを考えていると関数の定義だけがどんどん増えてしまうからです。

そこで任意の型を引数にとれるように関数自体を改良します。

func multiple<T>(_ a: T, _ b: T) -> T {
    return a * b
}

というわけで、任意の型であるTを引数としてとり、その結果として型Tを返す関数に改良しました。

が、これはそのままではコンパイルエラーが発生します。

というのも、計算式の途中にあるa * bが計算可能であるためにはTが掛け算可能な型である必要があるためです。なので、Tは単なる「任意の型」ではなく「任意の掛け算可能な型」として再定義します。

func multiple<T: Numeric>(_ a: T, _ b: T) -> T {
    return a * b
}

掛け算可能な型であることを明示するためにはTNumericに準拠させればよいです。FloatingPointに準拠させても同様の処理は可能ですが、FloatingPointは実数であることを前提としているので返り値も必ず実数になってしまいます。

整数同士での計算は整数で返したいのでこの場合はNumericの方が良いでしょう。

算術プロトコル#

算術プロトコルにはたくさんあるのですが、まあとりあえず以下の三つがよく出てきます。

プロトコル加算減算乗算除算
AdditiveArithmeticYESYES--
NumericYESYESYES-
FloatingPointYESYESYESYES

除算までサポートしようとすると FloatingPoint を利用する必要があるわけですね。

そこで、二つの数を引数にとってa / bの値を返すdivide()の関数を以下のようにつくります。

func divide<T: FloatingPoint>(_ a: T, _ b: T) -> T {
    return a / b
}

これはこれで別に問題なく動作するのですが、整数型同士で計算した場合少し違和感があります。

print(10/5)             // 2
print(divide(10, 5))    // 2.0

というのも単に整数型同士で除算した場合は、計算結果も整数になるのに対して、divide()を利用した場合は返り値がFloatingPoint型のために必ず実数で出力されてしまうという点です。

また、Int型はFloatinPointに準拠していないため以下のコードのように変数の型を明示してしまうとコンパイルエラーが発生してしまいます。

let a: Int = 10
let b: Int = 5

print(divide(a, b)) // Global function 'divide' requires that 'Int' conform to 'FloatingPoint'

暫定処置#

FloatingPointIntに互換性がない以上は一つの関数で処理するのは難しそうなので以下のようにコードを改良するのが一つの手ではあります。

それか、引数に代入するときにDoubleCGFloatなどにキャストします。もっと上手い解決策がありそうなのですが、わからなかったのでとりあえずこれで対応しています。

func divide<T: FloatingPoint>(_ a: T, _ b: T) -> T {
    return a / b
}

func divide(_ a: Int, _ b: Int) -> Int {
    return a / b
}

プロトコルを型として利用する#

さて、ここまでの話はプロトコルを使って変数の引数を柔軟に扱おうという話でした。

ここからは更に一方進んでプロトコルに準拠したクラスや構造体をつくり、それらを変数として扱いたい場合を考えます。

話がややこしいので具体例を出します。例えば Dog クラスと Cat クラスを作成し、プロパティとして名前をもたせるとします。

class Dog {
    let name: String
}

class Cat {
    let name: String
}

そして、次に飼い主のクラスを作成します。愚直に書くと以下のようになります。

猫を飼っている人がいるかも知れませんし、犬を買っている人がいるかも知れないので犬と猫のどちらもプロパティにもつ必要があります。

class Person {
    let cats: [Cat]
    let dogs: [Dog]
}

ここで問題になるのは、動物の種類が増えるとプロパティ名が無数に増えていってしまい可読性が低下するという点です。

プロトコルで解決する#

そこで、犬と猫をどちらも一括で扱えるようなAnimalプロトコルを作成します。

protocol Animal {
    var name: String { get } // Required
}

class Dog: Animal {
    var name: String // Required
}

class Cat: Animal {
    var name: String // Required
}
class Person {
    let animals: [Animal]
}

イニシャライザを定義する#

このままだとわかりにくいのでイニシャライザをつけてコンパイルが通るようにします。

プロトコルで設定されている変数や関数は必ずそのプロトコルを準拠するクラスなどでは宣言しなければいけません。変数の場合はそのままかけばいいのですが、イニシャライザの場合はrequiredとつけてプロトコルの準拠のために必要であることを明示する必要があります。

import SwiftUI

protocol Animal {
    var name: String { get } // Required
    init(name: String)
}

class Dog: Animal {
    var name: String // Required

    required init(name: String) { // Required
        self.name = name
    }
}

class Cat: Animal {
    required init(name: String) { // Required
        self.name = name
    }

    var name: String // Required
}

class Person {
    var animals: [Animal]

    init(animals: [Animal] = []) {
        self.animals = animals
    }
}

サンプルコード#

let mike: Cat = Cat(name: "Mike")
let nike: Dog = Dog(name: "Nike")

let tom = Person(animals: [mike, nike])

for animal in tom.animals {
    print(animal.name) // Mike, Nike
}

For 文の中でそれぞれ異なるクラスのオブジェクトをループさせているのにanimal.nameで名前を呼び出せるのは、animalAnimalプロトコルに準拠しており、必ずnameのプロパティを持っていることが担保されているためです。

protocol Animal {
    var name: String { get } // <- Required
    init(name: String)
}

もしここでこの行をコメントアウトするとValue of type 'Animal' has no member 'name'とコンパイルエラーが表示されます。

プロトコルに準拠した Enum を作成する#

今回考えて悩んだのはここでした。

いまネットワーク系のライブラリを作成しているのですが、そのライブラリは通信が失敗した際にはエラーを返します。ここではそのライブラリが返すエラーはAPIErrorAというEnumだとします。

そして仮にエラーの種類が二種類しかないとすると、次のように定義すれば良いわけです。

enum APIErrorA: Error {
    case forbidden
    case invalid
}

そして次にそのライブラリを使用するアプリを考えてみます。アプリは基本的にはこのAPIErrorAを使ってエラーを表示すれば良いのですが、エラーを更に細分化したい場合があります。

例えば、ある XXX というエンドポイントを叩いてinvalidが返ってきた場合にはinvalidXXX, YYY というエンドポイントの場合はinvalidYYYという具合です。

ライブラリ側に追加すればそれはそれで解決なのですが、アクセスするエンドポイント名ごとに Enum を増やしていてはほとんどの人は使わない無意味なcaseがライブラリに組み込まれてしまいます。

そのような定義はライブラリではなくアプリ側で実装すべきです。

// コンパイルエラー
extension APIErrorA {
    case invalidXXX
    case invalidYYY
}

という風に Extension で追加できれば良いのですが、実は Extension を使って Enum の case を追加することは不可能です。

となれば新たにエラーのクラスを作成するしかありません。

enum APIErrorB: Error {
    case invalidXXX
    case invalidYYY
}

こうすれば実装はできるのですが、利用する上で大変不便です。

何故なら、ライブラリはAPIErrorAの Enum で返してくるので当然受け取る側の変数もlet error: APIErrorAのようにAPIErrorA型であることを明示しなければなりませんが、こうなるとアプリが返してくるはずのAPIErrorBのエラーを受け取れないからです。

エラーを受け取る変数を二つ用意すればいいのですが、それをやると先程の動物の例と同じように冗長なコードになってしまいます。

そこでライブラリ側にはエラーの拡張を許すようにプロトコルを使ってエラーを定義します。

// ライブラリ
protocol PlatformError: LocalizedError { }

enum APIError: PlatformError {
    case forbidden
    case invalid
}

// ライブラリを利用するアプリ
enum APPError: PlatformError {
    case invalidXXX
    case invalidYYY
}

今回はプロトコルに準拠させるだけなので別にプロトコル内には何も書かなくて大丈夫です。

このようにプロトコルに何も特別なことを書かない場合は簡単に利用することができます。

プロトコルの罠#

ところが、PlatformErrorLocalizedErrorだけでなくIdentifiableにも準拠させるとコンパイルエラーが発生します。

protocol PlatformError: LocalizedError, Identifiable {
    var id: String { get }
}

let errors: [PlatformError] = [APIError.forbidden, APPError.change] // Protocol 'PlatformError' can only be used as a generic nostraint because it has Self or associated type requirements

コンパイルエラーを読むと「プロトコルがassociated typeの要件を持っているから」とあります。

ここでIdentifiableプロトコルのドキュメントを読んでみると、

::: tip Identifiable

associatedtype ID

A type representing the stable identity of the entity associated with an instance.

Required

:::

と書いておりIdentifiableに準拠したことでassosiated typeが要件に加わり、そのためにコンパイルエラーが発生したことがわかります。

Swift のジェネリックなプロトコルの変数はなぜ作れないのか、コンパイル後の中間言語を見て考えたにもあるように、

::: tip 導入

Swift では通常のプロトコルは変数の型として使用することができますが、

型パラメータ(associated type)を持つジェネリックなプロトコルの変数は作れません。

:::

とあるように、プロトコルをIdentifiable準拠にした段階でプロトコルを変数の型として利用することができなくなってしまうのです。

また、Identifiableでなくてもassociatedtypeをプロトコル内に書いた段階で変数の型としては利用できなくなります。

Enum + CaseIterable#

Identifaibleに準拠させてしまうとめんどくさいことはわかりましたが、CaseIterableはどうでしょうか?

調べてみるとドキュメントには次のようにあります。

static var allCases: Self.AllCases
    A collection of all values of this type.
    Required.
associatedtype AllCases
    A type that can represent a collection of all values of this type.
    Required.

つまり、CaseIterable に準拠させるとassociatedtypeが設定されるので変数としてはプロトコルを指定できないことになります。

よって、アプリとライブラリのエラーを全て一括で配列にするPlatformError.allCasesは利用できないということになります。

まあでもプロトコルには適応できないというだけであって、それぞれの Enum に対してCaseIterable準拠させれば似たようなことはできます。

// サンプルコード
import SwiftUI

protocol PlatformError: LocalizedError {
    var rawValue: String { get }
}

enum APIError: String, PlatformError, CaseIterable {
    case forbidden
}

enum APPError: String, PlatformError, CaseIterable {
    case change
}

class ErrorTypeList {
    var errors: [PlatformError]

    init(errors: [PlatformError]) {
        self.errors = errors
    }
}

let errorType = ErrorTypeList(errors: (APPError.allCases + APIError.allCases))

for error in errorType.errors {
    print(error.rawValue) // -> chnage, forbidden
}

まとめ#

プロトコルにプロトコルを準拠させる時はassociatedtypeがついているか気をつけようね!!!

プロトコルの準拠とその罠について
https://fuwari.vercel.app/posts/2021/06/protocol/
Author
tkgling
Published at
2021-06-18