☄️

Swift: 非オプショナルな変数のキーがなくてもデフォルト値で初期化してくれるCodable

2022/10/27に公開約3,000字

Codable (Encodable & Decodable)を使えば、階層構造を持つデータ(JSONオブジェクトなど)を簡単に扱うことができます。しかし、JSON文字列からSwiftのデータオブジェクトにするとき、少し気をつけないといけない点があります。

オプショナルな変数の場合、JSONオブジェクトの中にそのKeyとValueがなくても、nilでデコードしてくれますが、非オプショナルな変数の場合は、変数に初期値を与えていてもデコードに失敗します。

class Hoge: Codable {
    var piyo: String = "none"
    var meu: String?
}

let str1 = """
{
  "piyo": "hello"
}
"""

if let jsonData = str1.data(using: .utf8),
   let json = try? JSONDecoder().decode(Hoge.self, from: jsonData) {
    print(json.piyo) // デコードに成功する
    // => hello nil
}

let str2 = "{}" // piyoのkeyとvalueがない

if let jsonData = str2.data(using: .utf8),
   let json = try? JSONDecoder().decode(Hoge.self, from: jsonData) {
    print(json.piyo) // デコードに失敗する
    // => 出力されない
}

これをデコード失敗しないようにするには、CodingKeyを定義してrequired init(from decoder: Decoder) throws {} を実装すれば良いです。このとき、キーが存在しない可能性を考慮する場合はdecodeIfPresent()を使います。

上記の例を改良
class Hoge: Codable {
    var piyo: String = "none"
    var meu: String?

    enum CodingKeys: String, CodingKey {
        case piyo
        case meu
    }

    required init(from decoder: Decoder) throws {
        let values = try decoder.container(keyedBy: CodingKeys.self)
        piyo = try values.decodeIfPresent(String.self, forKey: .piyo) ?? piyo
        meu = try? values.decodeIfPresent(String.self, forKey: .meu)
    }
}

let str = "{}" // piyoのkeyとvalueがない

if let jsonData = str.data(using: .utf8),
   let json = try? JSONDecoder().decode(Hoge.self, from: jsonData) {
    print(json.piyo, json.meu) // デコードに成功する
    // => none nil
}

ただ、この対応策はCodableclassstructのそれぞれで実装する必要があり、毎回 変数名 = try values.decodeIfPresent(型.self, forKey: キー) ?? 変数名と決まった書き方を繰り返すのは手間です。

そこで、キーが見つからなくてデコードに失敗する場合はデフォルト値を使って初期化できる方法を考えました。

public protocol EmptyInitializable {
    init()
}

public typealias EmptyCodable = Decodable & Encodable & EmptyInitializable

public extension Encodable {
    func toJSONString() -> String? {
        guard let jsonData = try? JSONEncoder().encode(self) else {
            return nil
        }
        return String(data: jsonData, encoding: .utf8)
    }
}

public extension Decodable {
    init?(jsonString: String) {
        guard let jsonData = jsonString.data(using: .utf8),
              let json = try? JSONDecoder().decode(Self.self, from: jsonData) else {
            if let type = Self.self as? EmptyCodable.Type {
                self = type.init() as! Self
                return
            } else {
                return nil
            }
        }
        self = json
    }
}

class Hoge: EmptyCodable {
    var piyo: String = "none"
    var meu: String?
    required init() {}
}

※ Encodableの実装はおまけです。

要点

  • init()だけをもつprotocol = EmptyCodableを定義します。
  • EmptyCodableDecodableEncodableをかけあわせたprotocoltypealiasを定義します。
  • Decodableを拡張して、JSON文字列からデータオブジェクトをデコードするイニシャライザを定義します。
    • デコードに失敗したが、EmptyCodableに準拠している場合は、そのイニシャライザを利用するようにします。

これで、もしもJSONオブジェクトが{}のように空っぽだったとしても、初期値を持つデータオブジェクトに変換できます。留意点として、複数の変数があってそのうちのいくつかのキーが見つからなかった時の個々の処理には対応できていません。その場合はやはり個々にデコード処理を定義する必要があると思います。

Discussion

ログインするとコメントできます