派生型プロパティを 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
リンクも共有もお気軽に。記事を書くモチベーションの向上に役立てます。