Swift Digest
SE-0295 | Swift Evolution

Codable synthesis for enums with associated values

Proposal
SE-0295
Authors
Dario Rexin
Review Manager
Saleem Abdulrasool
Status
Implemented (Swift 5.5)

01 何が問題だったのか

SE-0166 で導入された Codable は、Encodable / Decodable への適合をコンパイラが自動合成してくれる仕組みを備えています。ただしその対象は、プロパティがすべて Codable に適合している classstruct、および RawRepresentable に適合した enum に限られていました。

associated value を持つ enum は対象外だったため、次のような型は利用者自身が encode(to:)init(from:) を書かなければならず、Codable の使いやすさが大きく損なわれていました。

enum Command: Codable {
  case load(key: String)
  case store(key: String, value: Int)
}

associated value つき enum の自動合成については以前から議論されていましたが、エンコード後の構造をどう決めるかで合意が取れず、サポートが見送られてきた経緯があります。Codable の適用範囲を広げ、associated value を含む日常的な enum でもボイラープレートなしに使えるようにすることが、このProposalの目的です。

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

associated value を持つ enum について、Encodable / Decodable 適合の自動合成をサポートします。エンコード後の構造は、case 名をキーとする最上位コンテナの中に、associated value を struct と同じ形で入れ子にした keyed container を置く、という形に決められました。

エンコード後の構造

次の enum を考えます。

enum Command: Codable {
  case load(key: String)
  case store(key: String, value: Int)
}

このとき、.load(key: "MyKey") は次のようにエンコードされます。

{
  "load": {
    "key": "MyKey"
  }
}

.store(key: "MyKey", value: 42) は次の通りです。

{
  "store": {
    "key": "MyKey",
    "value": 42
  }
}

最上位には「case 名」をキーとする単一エントリが置かれ、その値として associated value をまとめた keyed container が入ります。この形は struct / class のエンコード結果と揃っており、case を識別するラッパーを被せただけの構造になっています。

ラベルのない associated value

ラベルが付いていない associated value には、0 起点のインデックスを使った _0, _1, … という識別子が自動生成されます。UnkeyedContainer ではなくキー付きにすることで、後からパラメータを追加する等のモデル変更がしやすくなります。

enum Command: Codable {
  case load(String)
  case store(key: String, Int)
}

は、それぞれ次のようにエンコードされます。

{
  "load": {
    "_0": "MyKey"
  }
}
{
  "store": {
    "key": "MyKey",
    "_1": 42
  }
}

ユーザー定義のパラメータ名が _0 のような自動生成名と衝突する場合は、コンパイラが診断を出します。

associated value を持たない case

associated value を持たない case も、空の keyed container としてエンコードされます。

enum Command: Codable {
  case dumpToDisk
}

は次のようになります。

{
  "dumpToDisk": {}
}

こうしておくことで、後から associated value を追加しても互換性を保てます。

合成されるコード

enum 用の自動合成では、CodingKeys が複数導入されます。最上位の case 識別用の CodingKeys に加えて、各 case の associated value 用に <Case>CodingKeys(case 名を先頭大文字化したもの)が生成されます。

// 全caseのキー
enum CodingKeys: CodingKey {
  case load
  case store
}

// case load の associated value 用
enum LoadCodingKeys: CodingKey {
  case key
}

// case store の associated value 用
enum StoreCodingKeys: CodingKey {
  case key
  case value
}

encode(to:) は、現在の case に応じて nested container を開き、associated value を書き込む形で合成されます。

public func encode(to encoder: Encoder) throws {
  var container = encoder.container(keyedBy: CodingKeys.self)
  switch self {
  case let .load(key):
    var nestedContainer = container.nestedContainer(keyedBy: LoadCodingKeys.self, forKey: .load)
    try nestedContainer.encode(key, forKey: .key)
  case let .store(key, value):
    var nestedContainer = container.nestedContainer(keyedBy: StoreCodingKeys.self, forKey: .store)
    try nestedContainer.encode(key, forKey: .key)
    try nestedContainer.encode(value, forKey: .value)
  }
}

init(from:) は、最上位コンテナに含まれるキーがちょうど 1 つであることを確認したうえで、対応する case を復元します。

public init(from decoder: Decoder) throws {
  let container = try decoder.container(keyedBy: CodingKeys.self)
  if container.allKeys.count != 1 {
    let context = DecodingError.Context(
      codingPath: container.codingPath,
      debugDescription: "Invalid number of keys found, expected one.")
    throw DecodingError.typeMismatch(Command.self, context)
  }

  switch container.allKeys.first.unsafelyUnwrapped {
  case .load:
    let nestedContainer = try container.nestedContainer(keyedBy: LoadCodingKeys.self, forKey: .load)
    self = .load(
      key: try nestedContainer.decode(String.self, forKey: .key))
  case .store:
    let nestedContainer = try container.nestedContainer(keyedBy: StoreCodingKeys.self, forKey: .store)
    self = .store(
      key: try nestedContainer.decode(String.self, forKey: .key),
      value: try nestedContainer.decode(Int.self, forKey: .value))
  }
}

カスタマイズ

struct / class の場合と同じく、利用者が独自の CodingKeys を定義すれば自動合成をカスタマイズできます。

一部の case だけをエンコード対象にしたい場合は、最上位の CodingKeys からその case を除外します。除外された case をエンコード・デコードしようとすると実行時にエラーが投げられます。

enum Command: Codable {
  case load(key: String)
  case store(key: String, value: Int)
  case dumpToDisk

  enum CodingKeys: CodingKey {
    case load
    case store
    // dumpToDisk は含めない
  }
}

associated value 単位で含める値を選びたい場合は、<Case>CodingKeys を独自に書きます。Decodable を合成するなら、除外する値にはデフォルト値が必要です(Encodable だけを合成する場合はこの制約はありません)。

enum Command: Codable {
  case load(key: String, someLocalInfo: Int = 0)

  // someLocalInfo にデフォルト値があるので妥当
  enum LoadCodingKeys: CodingKey {
    case key
  }
}

<Case>CodingKeys に、実在しないパラメータ名を書くことはできません。

キー名を別の文字列に差し替えたい場合は、RawRepresentable として String を持つ enum にして raw value を指定します。

enum Command: Codable {
  case load(key: String)
  case store(key: String, Int)

  enum CodingKeys: String, CodingKey {
    case load = "lade"
  }

  enum LoadCodingKeys: String, CodingKey {
    case key = "schluessel"
  }
}

これは次のようにエンコードされます。

{
  "lade": {
    "schluessel": "MyKey"
  }
}

モデルの進化

case のシェイプは、struct / class と同じ感覚で進化させられます。optional な値を足したり減らしたりするのは互換性を保ったまま行えます(既存 case の識別子を変えなければOK)。Swift の言語レベルでは associated value の追加・削除はソース・バイナリ互換性を壊しますが、このエンコード方式を採用している限り、アプリケーションや社内向けサービス、internal な型のようにソース・バイナリ互換性が問題にならない場面では、自由にモデルを進化させられます。バイナリ互換性が必要な文脈では、associated value をすべてまとめた単一の struct / class を持たせる設計にしてください。

自動合成がサポートされないケース

オーバーロードされた case 名(同じ case 名で別シグネチャのもの)を含む enum は自動合成の対象外です。case 名をキーとして使う以上、オーバーロードを区別する手段がなく、無理に対応しようとすると進化のしやすさや曖昧性の点で深刻な問題が出るためです。たとえば、

enum Test: Codable {
  case x(y: Int)
  case x(y: Int, z: String?)
}

のような定義では、{ "x": { "y": 42 } } がどちらの case に対応するか決められません。パラメータ名の順序を入れ替えただけのオーバーロードでも、キー順を保証しないフォーマットで同様の曖昧性が発生します。こうしたケースは自動合成の対象から外し、必要であれば利用者自身が encode(to:) / init(from:) を書く形になります。