Swift Digest
SE-0239 | Swift Evolution

Add Codable conformance to Range types

Proposal
SE-0239
Authors
Dale Buckley, Ben Cohen, Maxim Moiseev
Review Manager
Ted Kremenek
Status
Implemented (Swift 5.0)

01 何が問題だったのか

SE-0167 で標準ライブラリの主な型に Codable 適合が追加されましたが、Range 系の型(Range / ClosedRange / PartialRangeFrom / PartialRangeThrough / PartialRangeUpTo)はその対象から外れていました。また、配列系では ArrayCodable に適合しているのに対し、ContiguousArray だけが同じく取り残されていました。

Range をエンコード・デコードしたい場面は実用上珍しくありません。たとえば、Date を使った「予約できる時間帯」や、Measurement<UnitTemperature> を使った「安全動作温度の範囲」など、クライアント・サーバ間でやり取りしたい値が素直に Range として表現できるケースがあります。標準の適合がないと、利用者は Range をくるむだけのラッパー型を自分で定義して手書きで Codable 適合を書くか、lowerBoundupperBound を別々のプロパティに分解するといった回避策を取らざるを得ませんでした。

ラッパーを各プロジェクトで自作すると、シリアライズ形式がプロジェクトごとにばらつき、共通ライブラリ化もしづらくなります。Range は標準ライブラリの型なので、Codable 適合も標準ライブラリ側で一度だけ用意するのが素直です。

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

次の標準ライブラリの範囲型について、BoundCodable に適合している場合に限り、自身も Codable に適合するようになります。

  • Range
  • ClosedRange
  • PartialRangeFrom
  • PartialRangeThrough
  • PartialRangeUpTo

あわせて、これまで Codable 適合が欠けていた ContiguousArray にも Codable 適合が追加されます。

エンコード形式

RangeClosedRange のように下限と上限の両方を持つ型は、unkeyed container(配列状のコンテナ)に lower / upper の順で要素を並べる形でエンコードされます。PartialRangeFrom / PartialRangeThrough / PartialRangeUpTo のように片側しか境界を持たない型は、同じく unkeyed container にその境界値ひとつを入れた形になります。

import Foundation

let range: Range<Int> = 1..<10
let data = try JSONEncoder().encode(range)
print(String(data: data, encoding: .utf8)!)
// [1,10]

let decoded = try JSONDecoder().decode(Range<Int>.self, from: data)
print(decoded) // 1..<10

BoundDateMeasurement<UnitTemperature> など別の Codable な型でも、同じ仕組みで入れ子になったエンコードがそのまま通るようになります。

struct Reservation: Codable {
    var period: Range<Date>
}

既存の独自 Codable 適合との衝突

これまで RangeClosedRange に対して独自に Codable 適合を書いていたコードは、本提案の実装後は標準ライブラリ側の適合と重複するため、コンパイルエラーになります。もし過去にそのような独自適合でシリアライズしたデータがディスクや DB に残っている場合、標準ライブラリの新しい形式とは互換しないため、そのままでは読み戻せません。

この場合の移行方針として、Range を包むラッパー型を自分で定義し、従来のエンコード形式を読み取る Decodable 適合だけをそのラッパーに移すやり方が案内されています。復号時はまず標準ライブラリの形式で試し、失敗したら旧形式用のラッパーにフォールバックする、という二段構えにすることで、既存データを失わずに新形式へ移行できます。

public struct MyRangeWrapper<Bound: Comparable> {
    public var range: Range<Bound>
}

extension MyRangeWrapper {
    enum CodingKeys: CodingKey {
        case lowerBound
        case upperBound
    }
}

extension MyRangeWrapper: Decodable where Bound: Decodable {
    public init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        let lower = try container.decode(Bound.self, forKey: .lowerBound)
        let upper = try container.decode(Bound.self, forKey: .upperBound)
        self.range = lower..<upper
    }
}

extension JSONDecoder {
    func decodeRange<Bound: Decodable>(
        _ type: Range<Bound>.Type, from data: Data
    ) throws -> Range<Bound> {
        do {
            return try self.decode(Range<Bound>.self, from: data)
        } catch DecodingError.typeMismatch {
            return try self.decode(MyRangeWrapper<Bound>.self, from: data).range
        }
    }
}

新形式への書き換えが済んだあとは、旧形式用のラッパーは不要になります。