try! Swift: Type Erasureのユースケースを考えてみた話 | サイバーエージェント 公式エンジニアブログ

こんにちは
Ameba事業本部でiOSエンジニアをしている @tasanobu です。

 

先日(3/2~4)、弊社にて try! Swift を開催しました。

try! Swift
世界中のSwiftデベロッパーが一堂に会し、知識や技術を互いに共有し高め合うことを目的としたカンファレンスです。

 

各セッションは国内外の著名なエンジニアがスピーカーを務めたこともあり、深い話が多く、満足度が高かったです。

反面、一度聞いただけでは使いどころがイメージできないものがありました。

 

そこで、このエントリではGwendolyn Westonさんによる Keep Calm and Type Erase On にて解説されていた Type Erasure を題材とし、具体的なユースケースに当てはめて理解を深めていきたいと思います。

Type Erasureとは?

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

/// 通常のプロトコル
protocol Animal {
    func eat()
}

let animal: Animal // 変数の型として指定可能

 

一方、 associated type を持つプロトコルは変数の型として指定することができません。

/// associated type を持つプロトコル
protocol Animal {
    associatedtype Food

    func eat(food: Food)
}

let animal: Animal

/// error: protocol 'Animal' can only be used as a generic constraint 
/// because it has Self or associated type requirements

 

下記の言語仕様の通り、プロコトルが適用されるまで、 associated type  として使う実際の型が決定しないためです。

 

The actual type to use for that associated type is not specified until the protocol is adopted. 

 

The Swift Programming Language (Swift 2.2) - Associated Types

 

Type Erasureとは、プロトコルを実際の型に適用し、この言語仕様を回避する手法です。

(Swiftの標準ライブラリでは、 AnySequence などでこの手法が使われています。)

struct AnyAnimal<A: Animal>: Animal {
    typealias Food = A.Food

    private let _eat: (Food) -> ()

    init<Inner: Animal where Food == Inner.Food>(_ inner: Inner) {
        _eat = inner.eat
    }

    func eat(food: Food) {
       _eat(food)
    }
}

/// Food
struct Grass {}

/// Concrete Animal
struct Cow: Animal {
    typealias Food = Grass

    func eat(food: Food) {
        /// do something...
    }
}

let animal: AnyAnimal<Cow>! // 変数の型として指定可能

 

ここまでが端的なType Erasureの説明です。

抽象的すぎて使いどころが今ひとつイメージしにくいのではないでしょうか???

ユースケース: NSUserDefaults

写真撮影機能を持つアプリには、大抵 "カメラロールに保存するか?" "保存時のサイズ" といった設定があると思います。

今回、NSUserDefaults (以降、UD) で " カメラロール保存フラグ"  " 保存サイズ"  をプロパティとして持つ構造体を管理するユースケースを考えてみます。

struct CameraDefault {

    enum Size: Int {
        case Large, Medium, Small
    }

    let saveToCameraRoll: Bool
    let size: Size
}

要件

  • UDに保存する設定
    • CameraConfig  以外に複数ある
    • 個々の設定値(" カメラロール保存フラグ" や" 保存サイズ" )に対してUDのKey/Valueを設定するのではなく、設定を構造化して管理したい
    • 設定はType Safeに扱いたい(AnyObjectは極力使いたくない)
  • その他
    • テスト時はUDではなくモックを使いたい

設定用プロトコル

CameraConfig  を例にしますが、実際のプロダクトでは別の設定もUDに保存する必要があります。

そのため、データ型を抽象化する目的でプロトコルを作ります。

protocol DefaultConvertible {

    /// UDへの保存時に使うキー文字列
    static var key: String { get }

    /// UDから取得した値をデータ型に変換
    init?(_ object: AnyObject)

    /// UDへはAnyObjectで保存するのでAnyObjectに変換
    func serialize() -> AnyObject
}

CameraConfig  にプロコトルを適用します。

struct CameraConfig: DefaultConvertible {

    enum Size: Int {
        case Large, Medium, Small
    }

    let saveToCameraRoll: Bool
    let size: Size

    static let key = "CameraConfig"

    init?(_ object: AnyObject) {
        guard let dict = object as? [String: AnyObject] else { return nil }
        self.saveToCameraRoll = dict["cameraRoll"] as? Bool ?? true
        
        if let rawSize = dict["size"] as? Int, let size = Size(rawValue: rawSize) {
            self.size = size
        } else {
            self.size = .Medium
        }
    }

    func serialize() -> AnyObject {
        return ["cameraRoll": saveToCameraRoll, "size": size.rawValue]
    }
}

データストア用プロトコル

テスト時はUDを使いたくありません。

抽象化のためにデータストア用のプロトコルを用意します。

protocol DefaultStoreType {

    associatedtype Default: DefaultConvertible

    func set(value: Default)
    func get() -> Default?
    func remove()
}

このプロトコルを用いてUDをラップした型を作ります。

final class PersistentStore<Default: DefaultConvertible>: DefaultStoreType {
    private let defaults = NSUserDefaults.standardUserDefaults()

    init() {}

    func set(value: Default) {
        let obj = value.serialize()
        defaults.setObject(obj, forKey: Default.key)
    }

    func get() -> Default? {
        guard let obj = defaults.objectForKey(Default.key) else { return nil }
        return Default(obj)
    }

    func remove() {
        defaults.removeObjectForKey(Default.key)
    }
}

また、UDの代わりにテスト用途で使う型も作ります。
設定値はメモリ上のDictionaryに保持するだけなので、永続化されることはなくテスト毎に破棄され、便利です。

final class InMemoryStore<Default: DefaultConvertible>: DefaultStoreType {

    private var dictionary: [String: AnyObject] = [:]

    init() {}

    func set(value: Default) {
        let obj = value.serialize()
        dictionary[Default.key] = obj
    }

    func get() -> Default? {
        guard let obj = dictionary[Default.key] else { return nil }
        return Default(obj)
    }

    func remove() {
        dictionary[Default.key] = nil
    }
}

ここでDefaultStoreTypeの変数を作ってみます。

しかし、前述の言語仕様により、コンパイルエラーになります。

let defaults: DefaultStoreType<CameraConfig>! = PersistentStore<CameraConfig>()

/// error: protocol 'DefaultStoreType' can only be used as a generic constraint 
/// because it has Self or associated type requirements

Type Erasureを用いたデータストア型

PersistentStore  を変数に保存できるようにするため、Type Erasureを用いて新たな型を作ります。

final class AnyStore<D: DefaultConvertible>: DefaultStoreType {

    typealias Default = D

    private let _set: Default -> ()
    private let _get: () -> Default?
    private let _remove: () -> ()

    init<Inner: DefaultStoreType where Inner.Default == D>(_ inner: Inner) {
        _set = inner.set
        _get = inner.get
        _remove = inner.remove
    }

    func set(value: Default) {
        _set(value)
    }

    func get() -> Default? {
        return _get()
    }

    func remove() {
        _remove()
    }
}

AnyStore 型ならば変数に指定可能です。

let ds = PersistentStore<CameraConfig>()
let defaults: AnyStore<CameraConfig>! = AnyStore(ds)

 

プロダクトにおいてViewControllerなどで使う場合は次のように書けます。

/// アプリのコード
class CameraViewController: UIViewController {

    lazy var config: AnyStore<CameraConfig> = {
        let ds = PersistentStore<CameraConfig>()
        return AnyStore(ds)
    }()

    ...
}

また、テストの場合は  PersistentStore  ではなく  InMemoryStore  に差し替えることが可能です。

/// テストコード
class CameraViewControllerTests: XCTestCase {

    var viewController: CameraViewController!

    override func setUp() {
        viewController = CameraViewController()
        let defaultConfig = CameraConfig([:])!
        let ds = InMemoryStore<CameraConfig>()
        ds.set(defaultConfig) //
        viewController.config = AnyStore(ds)
    }
}

まとめ

NSUserDefaultsをユースケースとして、Type Erasureを復習してみました。 Type Erasure自体はとても抽象的な手法だと思うのですが、意外にアプリ開発の現場で結構使いどころがありそうですね。

今回の記事で使ったコードは、下記のライブラリとしてGithubに公開しました。 もしよかったら、使ってみて下さい。

 

GitHub

tasanobu/TypedDefaults

Contribute to TypedDefaults development by creating an account on GitHub.

 

改善の余地は色々あると思うので、PRやIssueでフィードバック頂けると幸いです。