Swift Digest
SE-0166 | Swift Evolution

Swift Archival & Serialization

Proposal
SE-0166
Authors
Itai Ferber, Michael LeHew, Tony Parker
Review Manager
Doug Gregor
Status
Implemented (Swift 4.0)

01 何が問題だったのか

Swift 3 までの時点では、アーカイブやシリアライゼーションのAPIは Foundation の NSCoding / NSJSONSerialization / NSPropertyListSerialization に依存しており、Objective-C の動的な世界観に合わせて設計されていました。そのため、Swift らしい型の使い方と噛み合わない箇所が多く残っていました。

structenumNSCoding に載せられない

NSCoding@objc プロトコルであり、適合できるのは class 型だけでした。Swift では小さな structenum でモデルを表現するのがイディオマティックですが、そうしたモデルをアーカイブしたい場合は、

  • Swift のよさを諦めて class にする
  • 「実際のモデル型」と「アーカイブ用の互換レイヤ型」を二重に持つ

のどちらかを強いられていました。Swift ネイティブの値型をそのままアーカイブできる仕組みがなかったということです。

JSON / plist のシリアライゼーションが型安全でない

JSONSerializationPropertyListSerialization は、結果を Any[String: Any][Any] を含む構造)として返します。実際に使う側は、キャストと Optional アンラップを重ねて、以下のように値を取り出すことになります。

// 典型的な JSONSerialization の使い方(Swift 3まで)
let data: Data = ...
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
guard let farm = json,
      let name = farm["name"] as? String,
      let location = farm["location"] as? [String: Any],
      let latitude = location["latitude"] as? Double,
      let longitude = location["longitude"] as? Double,
      let animals = farm["animals"] as? [Int] else {
    // 何が壊れていたかは分からない
    throw MyError.invalid
}

各階層ごとにキャストが必要で、どこでどう失敗したのかも分かりにくく、Swift の強い型付けの恩恵を受けられませんでした。サードパーティのJSON変換ライブラリもさまざまなアプローチを試みていましたが、いずれもボイラープレートや型安全性とのトレードオフを抱えていました。

NSCoding の設計がSwiftに合わない点

NSCoding には、Swiftのモデルに移植するとさらに扱いにくくなる特徴もありました。

  • キーが String なので、キー名の typo をコンパイラが検出できない。
  • プリミティブの符号化・復号は、型ごとに異なるメソッド名(encodeInt32:forKey: / decodeInt32ForKey: など)を使い分ける必要がある。
  • 継承時の super の符号化が「同じ辞書に平坦に詰める」慣習になっており、キーが衝突する恐れがある。また、親子で異なるコンテナ形状(配列 vs 辞書)を使うこともできない。

Swift 側で値型を自然に扱えて、かつ JSON / plist といった外部表現とも型安全に往復できる、新しいアーカイブ・シリアライゼーションの仕組みが必要でした。

02 どのように解決されるのか

Swift 標準ライブラリに、アーカイブとシリアライゼーションのための新しいプロトコル群を導入します。値型(struct / enum)を含む任意の型が、型安全な形で外部表現との変換に参加できるようになります。これは3段階からなる計画の第1段で、プロトコルの基盤を規定するものです。具体的なエンコーダ・デコーダAPI(JSONEncoder / PropertyListEncoder など)と NSCoding との相互運用は、続く SE-0167 で提案されます。Swift 4.0 で導入されました。

Encodable / Decodable / Codable

型は Encodable / Decodable に適合することで、このシステムに参加します。両方を合わせた typealias Codable = Encodable & Decodable も用意されます。

public struct Location: Codable {
    public let latitude: Double
    public let longitude: Double
}

public enum Animal: Int, Codable {
    case chicken = 1
    case dog
    case turkey
    case cow
}

public struct Farm: Codable {
    public let name: String
    public let location: Location
    public let animals: [Animal]
}

Codable に適合した型の値は、(SE-0167 で導入される)エンコーダ・デコーダによってデータと相互変換できます。

let farm = Farm(
    name: "Old MacDonald's Farm",
    location: Location(latitude: 51.621648, longitude: 0.269273),
    animals: [.chicken, .dog, .cow, .turkey]
)
let payload: Data = try JSONEncoder().encode(farm)

do {
    let decoded = try JSONDecoder().decode(Farm.self, from: payload)
    let coordinates = "\(decoded.location.latitude), \(decoded.location.longitude)"
} catch {
    // 復号エラー
}

コンパイラによる自動合成

すべてのプロパティが Encodable / Decodable であれば、encode(to:)init(from:) の実装はコンパイラが自動生成します。上の Location / Farm / Animal はいずれもメソッド本体を書いていませんが、これで動作します。自動生成で不足な場合だけ、手で実装を書けば置き換えられます。

CodingKey によるキーの型付け

プロパティを辞書状に符号化する際のキーは、String ではなく CodingKey プロトコルに適合した専用の型(通常は enum)で表します。これにより、キーの typo はコンパイル時に検出でき、補完も効きます。

public protocol CodingKey {
    var stringValue: String { get }
    init?(stringValue: String)

    var intValue: Int? { get }
    init?(intValue: Int)
}

プロパティ名とキー名を一致させたい場合は、CodingKeys という名前の enum をコンパイラが自動生成してくれます。キー名だけ変えたい場合は、String をrawValueとして持つ enum を手で書きます。

struct Article: Codable {
    let id: Int
    let title: String
    let body: String

    // プロパティ名とJSONのキー名が違う場合だけ手で書く
    private enum CodingKeys: String, CodingKey {
        case id
        case title
        case body = "content"
    }
}

キーに Int を持たせることもでき、エンコーダは必要に応じて StringInt のどちらを使うかを選べます。

コンテナという抽象

Encoder / Decoder は「コンテナ」を提供し、型はそのコンテナを通じて値を読み書きします。コンテナは3種類あります。

  • keyed container: キーつきで値を並べる(辞書に相当)
  • unkeyed container: キーなしで順に並べる(配列に相当)
  • single value container: 単一の値をそのまま入れる

たとえば Location を手で実装するなら、keyed container を使います。

public struct Location: Codable {
    public let latitude: Double
    public let longitude: Double

    private enum CodingKeys: CodingKey {
        case latitude
        case longitude
    }

    public func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(latitude, forKey: .latitude)
        try container.encode(longitude, forKey: .longitude)
    }

    public init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        latitude = try container.decode(Double.self, forKey: .latitude)
        longitude = try container.decode(Double.self, forKey: .longitude)
    }
}

Bool / Int / Double / String などのプリミティブ型には専用のオーバーロードが用意されており、型安全に書けます。任意の Encodable / Decodable を受け付ける汎用版もあります。

Animal のような raw value 付き enum は single value container を使うのが自然で、こちらも自動合成の対象です。実体として、[.chicken, .dog, .cow][1, 2, 4] として符号化されます。

ネスト

外部フォーマットに合わせて、コンテナ内部にさらにコンテナを入れ子にすることもできます。JSON API が {"id": ..., "properties": {"name": ..., "timestamp": ...}} のような形を要求するとき、Swift 側のフラットな型にマッピングするのに役立ちます。

struct Record: Codable {
    let id: Int
    let name: String
    let timestamp: Double

    private enum Keys: CodingKey {
        case id
        case properties
    }

    private enum PropertiesKeys: CodingKey {
        case name
        case timestamp
    }

    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: Keys.self)
        try container.encode(id, forKey: .id)

        var nested = container.nestedContainer(keyedBy: PropertiesKeys.self, forKey: .properties)
        try nested.encode(name, forKey: .name)
        try nested.encode(timestamp, forKey: .timestamp)
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: Keys.self)
        id = try container.decode(Int.self, forKey: .id)

        let nested = try container.nestedContainer(keyedBy: PropertiesKeys.self, forKey: .properties)
        name = try nested.decode(String.self, forKey: .name)
        timestamp = try nested.decode(Double.self, forKey: .timestamp)
    }
}

継承

class の継承を扱うための仕組みも用意されます。NSCoding の慣習では親子が同じ辞書に値を詰めるためキーが衝突し得ましたが、新APIでは、コンテナ上で superEncoder() / superDecoder() を呼んで親用の入れ子コンテナを取り出し、そこに super を符号化します。

public class MyCodable: SomethingCodable {
    public override func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        // ... 自分のプロパティを符号化

        // super は入れ子のコンテナに符号化する
        try super.encode(to: container.superEncoder())
    }

    public required init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        // ... 自分のプロパティを復号

        try super.init(from: container.superDecoder())
    }
}

こうすることで、親と子が別々のコンテナ形状を選べるようになり、キー衝突の心配もなくなります。

実行時コンテキスト

Encoder / DecoderuserInfo: [CodingUserInfoKey: Any] を公開しており、エンコード・デコード時に呼び出し側から任意のコンテキストを渡せます。「プライベートなフィールドを含めるかどうか」など、同じ型でも状況に応じて振る舞いを変えたいケースで使います。

プリミティブ型への Codable 適合

Bool / Int / Double / String など標準のプリミティブ型はすべて Codable に適合します。加えて、RawValue がプリミティブ型の RawRepresentable には Codable のデフォルト実装が提供されるため、enum MyKind: Int, Codable {} と書くだけで符号化・復号ができるようになります。

まとめ

Codable によって、Swift の値型を含む任意のモデルが、CodingKey という型付きのキーと3種類のコンテナを通じて外部表現と往復できるようになります。多くのケースでは適合を宣言するだけで十分で、独自のキー名やネスト構造が必要な場合にだけ手で実装を差し込みます。これがアーカイブ・シリアライゼーション新APIの土台であり、次段の SE-0167 で具体的なJSON / plistエンコーダ・デコーダが提案されます。