113
79

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Swiftでenum, class, protocolのどれを使うかの判断材料の比較

Last updated at Posted at 2019-02-26

導入

Swiftにおいて、ある共通の性質を持つ複数の個別の値を表そうとする場合、その共通の性質を型で表すために、言語機能としてenum, class, protocolのいずれかを使うことができます。この記事ではその選択の判断材料となる性質の違いを比較して整理します。また、その向いていない点であっても、それをカバーする実装パターンがある場合、それを紹介します。なお、この記事では「個別の型」と「個別の値」の語を意識して使い分けています。

性質の違い

この章では、様々な性質について、3つの方式を比較します。

網羅性

共通の型としての値があるとき、実際の個別の値に応じて分岐する処理を書く際、全ての可能性を網羅しているか、コンパイラで静的に検査できると便利です。

enumは言語機能としてコンパイラによる網羅検査が提供されています。

// じゃんけん
enum Janken {
    case guu
    case choki
    case paa
}

let hand = Janken.guu
// error: Switch must be exhaustive
switch hand {
case .guu: print("グー")
case .choki: print("チョキ")
}

例えば上記のコードでは、paaの分岐を忘れている事をコンパイラが指摘しています。

classとprotocolでは、そのような網羅性の検査機能が、対策としてSwitcherパターンを利用することで、実用上はほぼ問題ありません。Switcherパターンについてはこちらで解説しています。

個別の型

個別の値に個別の型を与えたい場合があります。

classとprotocolにおいては個別の型があります。

enumについてはcaseには型がありません。対策として、caseごとに専用のassociated valueを与えるという方法があります。

// 素朴な実装
enum Pet {
    case cat(name: String, cuteness: Int)
    case dog(name: String, strength: Int)
    case ferret(name: String, length: Int)
}

// caseごとの型を与える
enum Pet {
    struct Cat {
        var name: String
        var cuteness: Int
    }
    
    struct Dog {
        var name: String
        var strength: Int
    }
    
    struct Ferret {
        var name: String
        var length: Int
    }
    
    case cat(Cat)
    case dog(Dog)
    case ferret(Ferret)
}

caseごとの型を与えた場合、その個別の型から共通の型への変換において、一度caseにラップして変換する必要があります。この変換は型ごとに異なるので面倒です。

addPet(.cat(Pet.Cat(name: "tama", cuteness: 100)))

let cat = Pet.Cat(name: "tama", cuteness: 100)
addPet(.cat(cat))

let dog = Pet.Dog(name: "pochi", strength: 100)
addPet(.dog(dog))

対策として、直接生成する場合に関しては、enum自体にstaticメソッドとしてコンストラクタを実装すると記述が簡単になります。

extension Pet {
    static func cat(name: String, cuteness: Int) -> Pet {
        return .cat(Cat(name: name, cuteness: cuteness))
    }
}

addPet(.cat(name: "tama", cuteness: 100))

このパターンは、SwiftPMのPackage.Dependency.Requirementでも使われています。

変換に関しては、共通のメソッドを与える事で簡単になります。

extension Pet.Cat {
    func asPet() -> Pet { return .cat(self) }
}
extension Pet.Dog {
    func asPet() -> Pet { return .dog(self) }
}

let cat = Pet.Cat(name: "tama", cuteness: 100)
addPet(cat.asPet())

let dog = Pet.Dog(name: "pochi", strength: 100)
addPet(dog.asPet())

class, protocolでは、個別の型から共通の型への変換に記述は不要です。

class Pet {
    final class Cat : Pet {
        let name: String
        let cuteness: Int
        init(name: String, cuteness: Int) {
            self.name = name
            self.cuteness = cuteness
        }
    }
    final class Dog : Pet { ... }
    final class Ferret : Pet { ... }
}

let cat = Pet.Cat(name: "tama", cuteness: 100)
addPet(cat)
protocol Pet { }

struct Cat : Pet {
    var name: String
    var cuteness: Int
}
struct Dog : Pet { ... }
struct Ferret : Pet { ... }

let cat = Cat(name: "tama", cuteness: 100)
addPet(cat)

ネームスペース

これまでの例で示しているように、enumとclassは個別の値を共通の型の内部のネームスペースで宣言する事ができます。

protocolはできません。

ジェネリクス

enumとclassはジェネリックパラメータを取る事ができます。

protocolはできません。

// ここではPetAssetに着目してください
enum PetAsset<T: Pet> {
    case food(calory: Int)
    case toy(size: Int)
}

protocol Pet {}
struct Cat : Pet {
    func take(asset: PetAsset<Cat>) {}
}
struct Dog : Pet {
    func take(asset: PetAsset<Dog>) {}
}

let cat = Cat()
cat.take(asset: PetAsset<Cat>.food(calory: 100))

protocolでやろうとした場合、associated typeを与える事になりますが、そうするとexistentialが使えなくなるので、type erasureが必要になります。そうすると、個別の型からtype erasureへの変換が必要になります。

protocol PetAsset {
    associatedtype TargetPet : Pet
}
struct PetFood<T: Pet> : PetAsset {
    typealias TargetPet = T
    var calory: Int
}
struct PetToy<T: Pet> : PetAsset {
    typealias TargetPet = T
    var size: Int
}
struct AnyPetAsset<T: Pet> { ... }

struct Cat : Pet {
    func take(asset: AnyPetAsset<Cat>) {}
}
struct Dog : Pet {
    func take(asset: AnyPetAsset<Dog>) {}
}

let cat = Cat()
cat.take(asset: AnyPetAsset(PetFood<Cat>(calory: 100))

type erasureの実装方法についてはこちらに書いています。
https://qiita.com/omochimetaru/items/5d26b95eb21e022106f0

値型で扱う

その型を値型として扱いたいとします。

enumとprotocolは値型として扱われます。

ただし、protocolの場合は、個別の型がclassで実装されていた場合、参照型として振る舞うので注意が必要です。

protocol Pet {}
class Cat : Pet {
    var name: String
    init(name: String) {
         self.name = name
    }
}

func addPet(_ pet: Pet) {
    (pet as? Cat)?.name = "たまちゃま"
}

let cat: Cat = Cat(name: "tama")
let pet: Pet = cat
addPet(pet)
print(cat.name) // たまちゃま

classは参照型になってしまいます。対策として、値型でラップしてcopy on writeを実装することで、実装本体にclassを使いつつ値型にすることができます。

copy on writeの実装方法についてはこちらに書いています。
https://qiita.com/omochimetaru/items/f32d81eaa4e9750293cd

また、enumではありませんが、copy on writeを実装した連結リストと順序付き辞書の自作のライブラリがあるので、参考にしてください。
https://github.com/omochi/OrderedDictionary/tree/master/Sources/OrderedDictionary

参照型で扱う

その型を参照型として扱いたいとします。

classは参照型として扱われます。

protocolは、AnyObject制約をつけることで参照型として扱われます。

protocol Pet : AnyObject {}
// error: Non-class type 'Cat' cannot conform to class protocol 'Pet'
struct Cat : Pet {}

上記の例では、個別の型を誤って値型にしようとした事をコンパイラが指摘しています。

enumは値型になってしまいます。対策として、classでラップしてpropertyで持たせる事ができます。

// 元のenum実装
// (説明のためcaseを簡略化しています)
enum JSON {
    case string(String)
    case array([JSON])
    case object([String: JSON])
}

// classでラップして参照型化した実装
class BoxedJSON {
    enum Value {
        case string(String)
        case array([BoxedJSON])
        case object([String: BoxedJSON])
    }
    
    var value: Value
    init(value: Value) {
        self.value = value
    }
}

この値型のJSONと参照型のJSONのペアは、私が作ったライブラリFineJSONで実際に使用しているので参考にしてください。
https://github.com/omochi/FineJSON/blob/master/Sources/FineJSON/BoxedJSON.swift

外部拡張性

個別の値をユーザが追加できるようにしたい場合があります。

classとprotocolでは自然に追加可能です。

enumでは個別の型を追加することはできません。対策として、custom caseを始めから入れておいて、必要であればそれを使って個別の値を追加する事ができます。

Foundation.Data.Deallocatorなどで使われています。

共通のフィールド

全ての個別の値で共通なフィールド(stored property)を持ちたい場合があります。

classでは親クラスで定義する事で実現できます。

class Pet {
    var name: String
    init(name: String) {
        self.name = name
    }
}
final class Cat : Pet {}
final class Dog : Pet {}

let cat = Cat(name: "tama")
cat.name = "mike"

protocolではproperty制約を書く事ができますが、個別の型で実装が必要です。

protocol Pet {
    var name: String { get set }
}
struct Cat : Pet {
    var name: String
}
var cat = Cat(name: "tama")
cat.name = "mike"

enumの場合、共通のフィールドを定義する直接の機能はありませんが、computed propertyを使って個別のcaseごとに対応する事で実現できます。

enum Pet {
    case cat(name: String, cuteness: Int)
    case dog(name: String, strength: Int)
    case ferret(name: String, length: Int)
}

extension Pet {
    var name: String {
        get {
            switch self {
            case .cat(name: let name, cuteness: _): return name
            case .dog(name: let name, strength: _): return name
            case .ferret(name: let name, length: _): return name
            }
        }
        set {
            switch self {
            case .cat(name: _, cuteness: let cuteness): self = .cat(name: newValue, cuteness: cuteness)
            case .dog(name: _, strength: let strength): self = .dog(name: newValue, strength: strength)
            case .ferret(name: _, length: let length): self = .ferret(name: newValue, length: length)
            }
        }
    }
}

しかし、共通でないassociated valueがあると、このようにとても冗長なコードになってしまいます。共通のフィールドが複数ある場合も考えると、caseごとの型を定義するパターンと合わせて、共通フィールドも型にまとめる書き方もできます。

enum Pet {
    struct Base {
        var name: String
        var weight: Int
    }
    
    struct Cat {
        var base: Base
        var cuteness: Int
        func asPet() -> Pet { return .cat(self) }
    }
    
    struct Dog {
        var base: Base
        var strength: Int
        func asPet() -> Pet { return .dog(self) }
    }
    
    struct Ferret {
        var base: Base
        var length: Int
        func asPet() -> Pet { return .ferret(self) }
    }
    
    case cat(Cat)
    case dog(Dog)
    case ferret(Ferret)
}

extension Pet {
    var base: Base {
        get {
            switch self {
            case .cat(let x): return x.base
            case .dog(let x): return x.base
            case .ferret(let x): return x.base
            }
        }
        set {
            switch self {
            case .cat(var x):
                x.base = newValue
                self = x.asPet()
            case .dog(var x):
                x.base = newValue
                self = x.asPet()
            case .ferret(var x):
                x.base = newValue
                self = x.asPet()
            }
        }
    }
    
    var name: String {
        get {
            return base.name
        }
        set {
            base.name = newValue
        }
    }
    
    var weight: Int {
        get {
            return base.weight
        }
        set {
            base.weight = newValue
        }
    }
}

var pet: Pet = Pet.Cat(base: .init(name: "tama", weight: 1), cuteness: 100).asPet()
pet.name = "kuro"

共通のメソッド

全ての個別の値で共通のメソッドを持ちたい場合があります。

protocolの場合、func制約を記述することで、個別の型での実装を強制する事ができます。

classの場合、親クラスで実装する事で実現できます。個別の型によって処理が異なる場合は、オーバーライドする事もできます。共通のフィールドや共通のメソッドの数が多い場合は、この親クラスに書くパターンが最も完結に記述できると思います。

ただ、オーバーライドするためには親クラスでの実装が必須なため、逆に言うと、必要な個別の実装をするのを忘れてしまい、親クラスの実装そのままになってしまうリスクがあります。

Swift以外の言語だと、abstract classという言語機能によって、サブクラスでの実装を強制する事ができる事が多いのですが、Swiftでは3年前にその機能が提案されてから今の所進捗がありません。

enumの場合、funcを実装して、その中でswitch selfして実装を与えることになります。そのようなメソッドがたくさんある場合、それぞれのfuncの中で毎回switchで分岐する形になるので、実装をcaseごとに局所化する事ができず、caseそれぞれの特性が捉えづらいソースコードになりがちに思います。観点の問題であって、関心が共通の型のenumの方にあると考える事もできると思います。

まとめ

ここまでの観点を表にまとめます。参考になると幸いです。

enum class protocol
網羅性
個別の型
ネームスペース ×
ジェネリクス ×
値型で扱う 1
参照型で扱う
外部拡張性 2
共通のフィールド
共通のメソッド
  1. 対策はありますが現実味が薄いので△にしました。

  2. 対策はありますが向いていないと言えるので△にしました。

113
79
0

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
113
79

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?