派生型プロパティを Decodable で扱う
- Apple
- 開発
最近の Swift では Decodable に準拠したオブジェクトを簡単に JSON から変換できる。
struct Root: Decodable
{
let texts: [String]
let isFine: Bool
}
let root = try JSONDecoder().decode(Root.self, from: """
{
"texts": ["hello", "again"],
"isFine": true
}
""".data(using: .utf8)!)
root.texts //["hello", "again"]
root.isFine //true
上のようにあらかじめかっちりと定義した通りのデータが与えられるときは自動でやってくれて便利だけど、実際にはそうもいかなくて難しい。
例えば、次のようにバリエーションが複数ある場合:
protocol Parameter: Decodable
{
var type: ParameterType { get }
}
struct BoolParameter: Parameter
{
let type: ParameterType
let defaultValue: Bool
}
struct NumberParameter: Parameter
{
let type: ParameterType
let defaultValue: CGFloat
let minValue: CGFloat
let maxValue: CGFloat
}
enum ParameterType: String, Decodable
{
case bool
case number
}
BoolParameter と NumberParameter は同列な存在でどちらも Parameter に準拠させている。JSON では独自の型情報を定義できないので Parameter.type を用意して区別することにする。
面倒
これをプロパティに持つ場合の Root はこんな感じだろうか:
struct Root: Decodable
{
var parameter: Parameter
}
だがこれはエラーが出てコンパイルできない。
note: cannot automatically synthesize 'Decodable' because 'Parameter' does not conform to 'Decodable'
var parameter: Parameter
^
Parameter プロトコルでなく直接 BoolParameter や NumberParameter を指定すれば動いてくれるものの、どちらか一方にしか使えなくなってしまう。
こういう場合、Root の Decodable を自力で実装するのがよくある解決策のようだ。具体的には:
Rootの持つプロパティのうちDecodableを適用させたい対象の一覧をCodingKeyに準拠したenumで定義Root.init(from decoder:)の中で 1 のenumをDecoder.container(keyedBy:)に与えて、全キーの値を持つKeyedDecodingContainerを取得- 各キーに対して
KeyedDecodingContainer.decode(_,forKey:)で値を取得してRootを初期化していく
...面倒! 今回の例では単純化しているけど実際には Root に大量のプロパティがあってもおかしくない。ほんの一部のプロパティで凝ったことをするだけでも、途端にすべてを手動で触らなければいけなくなってしまうなんて。
解決策:Property Wrapper を使おう
そこでたどり着いた解決策は Property Wrapper だ。それを Decodable に準拠させて派生型への分岐処理をここに詰め込み、各々の init(from coder:) で生成したものを wrappedValue としている。
@propertyWrapper struct CustomDecodedParameter: Decodable
{
public var wrappedValue: Parameter
enum ParameterCommonKey: CodingKey
{
case type
}
public init(from decoder: Decoder) throws
{
let container = try decoder.container(keyedBy: ParameterCommonKey.self)
switch try? container.decode(ParameterType.self, forKey: .type) {
case .bool:
wrappedValue = try BoolParameter(from: decoder) as Parameter
break
case .number:
wrappedValue = try NumberParameter(from: decoder) as Parameter
break
default:
fatalError()
}
}
}
すると Root はこんなにシンプルになる:
struct Root: Decodable
{
@CustomDecodedParameter var parameter: Parameter
}
let root = try JSONDecoder().decode(Root.self, from: """
{
"parameter": {
"type": "bool",
"defaultValue": false
}
}
""".data(using: .utf8)!)
assert((root.parameter as! BoolParameter).defaultValue == false)
SwiftUI の印象が強かった Property Wrapper だけど、思ったよりも幅広く役立ちそうだ。
おまけ:複数の場合
プロパティの型が単体の Parameter ではなく [Parameter] だったら...?
@propertyWrapper struct CustomDecodedParameters: Decodable
{
public var wrappedValue: [Parameter]
enum ParameterCommonKey: CodingKey
{
case type
}
public init(from decoder: Decoder) throws
{
var result: [Parameter] = []
var arrayContainerForType = try decoder.unkeyedContainer()
var arrayContainerForDecoding = arrayContainerForType
while !arrayContainerForType.isAtEnd {
let container = try arrayContainerForType.nestedContainer(keyedBy: ParameterCommonKey.self)
switch try? container.decode(ParameterType.self, forKey: .type) {
case .bool:
try result.append(arrayContainerForDecoding.decode(BoolParameter.self))
break
case .number:
try result.append(arrayContainerForDecoding.decode(NumberParameter.self))
break
default:
fatalError()
}
}
wrappedValue = result
}
}
先ほどと同じような Property Wrapper を作るのだけど、unkeyedContainer を 2 つ用意する点に注意。どうやら内部に現在のパース位置というかカーソルみたいな情報を持っているらしく、type を調べるときとデコードするときの両方でそれが増加してしまうためだ。
struct Root: Decodable
{
@CustomDecodedParameters var parameters: [Parameter]
}
let root = try JSONDecoder().decode(Root.self, from: """
{
"parameters": [
{
"type": "number",
"defaultValue": 1.5,
"minValue": 0.0,
"maxValue": 2.0
},
{
"type": "bool",
"defaultValue": false
}
]
}
""".data(using: .utf8)!)
assert((root.parameters.first as! NumberParameter).defaultValue == 1.5)
assert((root.parameters.last as! BoolParameter).defaultValue == false)
2020.7.17 追記
takasek さんがより汎用化を目指したコードを書いてくれた!
元コードから変わった点
- 型情報を失いたくないのでデータの派生はenumで表現
- CustomDecodedParameter をデータごとにいちいち作らなくていいようドメイン固有の処理を切り出し、共通部分をprotocol extensionに
- typeが想定外だったときにfatalErrorだと失敗時にアプリごと死ぬので、適切にCodingErrorをthrow
- 単体でもArrayでも同じように扱えるように工夫していたらPropertyWrapperが消滅してしまいました…
なるほど、同じ目的の違うアプローチを見るのはとても勉強になる。
Share
リンクも共有もお気軽に。記事を書くモチベーションの向上に役立てます。