はじめに
https://github.com/Anviking/Decodable は、NSDictionaryやNSArrayからオブジェクトを取得するのに便利なライブラリです。
githubのDescriptionやpod searchで見ると、
Swift 2 JSON parsing done (more) right
とあるので、JSONのパース処理での利用が想定されているようです。ただ実際には、NSJSONSerializationで取得できるNSDictionaryやNSArrayから、簡単に値を取得する点に特化しています。はたしてこれがJSONのパーサなのか?とは思います。
このDecodableを使うと、NSDictionaryやNSArrayからの値の取得が、大変楽になります。
このDecodableは、5ファイルで合計300行程度と、とてもコンパクトな構成になっています。この中身はどうなっているのか?どうやって実現しているのか?と思い調べてみました。
このソース、読めば読むほど頭が混乱する、とても歯ごたえがありました。この記事は、今現在での理解をそのまま書いてみた、というものです。
そのため、Decodableのインストール方法や使い方は割愛します。また、自分も勘違いして間違った事も書いているかもしれません。その点はご容赦ください。
Decodableの使用例
Decodableの特徴は、NSDictionaryからオブジェクトを生成する場面で、as?
によるキャストが無くなる点と、値の取得に=>
オペレータを使う点です。
これは、=>
オペレータの戻り値の型情報から、型推論により、どの=>
オペレータが動くのかが決まっているようです。その結果、as?
によるキャストが不要となっています。
let dict: NSDictionary = [
"str": "hoge",
"int":123,
"dic":["1":1,"2":2,"3":3],
"ary":["xxx","yyy","zzz"]
]
// ↑から
// ↓のインスタンスを作りたい。
truct MyClass{
let str:String
let int:Int
let dic:[String:Int]
let ary:[String]
let opt:String?
}
// Decodableを使わない場合
var myObj:MyClass?
if let s = dict["str"] as? String, // ここで、
i = dict["int"] as? Int, // 「何にキャストするのか」
d = dict["dic"] as? [String:Int], // をずらずら書く
a = dict["ary"] as? [String] // 必要がある
{
myObj = MyClass(
str: s,
int: i,
dic: d,
ary: a,
opt: let o = dict["opt"] as? String) // Optionalは別になってしまう
}
print(myObj)
// Decodableを使った場合
// コンストラクタの型情報から、
// 型推論によって「何にキャストするのか」を
// 省略できてスッキリしている
let myObj2:MyClass? = try? MyClass(
str:dict => "str",
int:dict => "int",
dic:dict => "dic",
ary:dict => "ary",
opt:try? dict => "opt") // Optionalはtry?をつける
print( myObj2 )
Decodableの全体をざっくり解説
大まかに見ると、Decodableは以下のコンポーネントで構成されています。
- 既存クラスで拡張される
decode()
関数(Decodable.swiftとCastable.swift)- NSDictionary,NSArray Dictionary,Array,String,Int,Double,Bool に、スタティックな
decode()
関数を足しています。- String.decode()やInt.decode()等など。
-
decode()
は「AnyObjectを受け取り、その型に変換して返す、変換できなければ例外を投げる」機能を持ちます。
- NSDictionary,NSArray Dictionary,Array,String,Int,Double,Bool に、スタティックな
-
parse()
関数(Parse.swift)- jsonオブジェクトから値を取得し、その値を引数に
decode
を呼び出す所までを担当します。
- jsonオブジェクトから値を取得し、その値を引数に
-
=>
オペレータ(Operators.swift)- 「何の値を返すのか」という種類毎に定義されている。
- それぞれの種類毎に、どう処理するのかをセットして、
parse()
関数を呼び出しています。
- 例外(DecodeError.swift)
- 「指定されたキーが存在しない」場合と「値が変換できない」場合に例外が飛びます。この例外を表すenumを定義しています。
これらが組み合わさり、ざっくりと以下の順番で処理が行われます。
-
=>
オペレータで、戻り値の型に応じて、デコードの処理が決定し、その処理とjsonとキーを関数parse()
に渡します。 -
parse()
関数では、キーを使ってjsonの中から値を取り出し、その値をデコードの処理に渡します。もしこの時に値が無い場合は、例外が飛びます。 - デコードの処理の中で、各型に定義された
decode()
関数が呼び出され、AnyObjectから指定された型に変換されます。もしこの時に型が変換できない場合は、例外が飛びます。
ここまでの大枠は、ソース量も少ないので、ざっくり把握できました。
それぞれのソースを見ていく
ここから、ソースの書き方を細かく見ていきます。ここからが頭が混乱した所ですし、Swiftでこんな事が出来るんだ!と思った所です。
Operators.swiftのcatchNull()
ここで定義されているcatchNull()ですが、ぱっと見ても意味がわかりませんでした。多少書き換えて以下に引用します:
func catchNull<T>(decodeClosure: (AnyObject throws -> T)) -> (AnyObject throws -> T? ){
return { json in
if json is NSNull {
return nil
} else {
return try decodeClosure(json)
}
}
}
関数catchNull()は、「「関数」を返す関数」のようです。なんだかよくわからないですよね。
{ xxx in ...}
で、引数を変数xxxに割り当てて、「新しい関数」を定義しています。最後にこの「新しい関数」そのものがreturn
で戻り値として返されます。
catchNull()が返す「新しい関数」は、以下の挙動となります。
- 引数がNSNullの場合は、nilを返す
- そうでない場合は、decodeClosureで指定された関数を呼び出し、結果を返す
decodeClosureで指定された関数に「新しくNSNullチェックを追加した」新しい関数を作って返しているようです。NSNullの時にはnilが返るようになるので、戻り値はOptional型(T?)となります。
また、catchNull()関数が呼ばれた時には、引数の関数は実行されず、「新しい関数」を作って返すだけのようです。引数で渡された関数は、新しく作った関数を呼び出した時に、初めて呼ばれます。
どんな戻り値になっているのかわかりやすくするため、くどいぐらいに型を明記してテストしてみました。
// String.decodeの型情報をはっきりさせるために、一度変数f1に格納する。
// f1(=String.decode関数)は、「AnyObjectを受け取ってStringを返す」関数
let f1:(AnyObject throws -> String) = String.decode // ここではString.decodeは実行されていない
do{
let v1_1:String = try f1("123")
print(v1_1) //"123\n"
let v1_2:String = try f1(NSNull()) // NSNull()が来ると、Stringに変換できないので例外が飛ぶ
print(v1_2) // ここは実行されない。
}catch let error{
print(error) // r1_2 の所の例外がここに来る
}
// catchNull()は、「新しい関数を作って返す」関数なので、戻り値が関数になっている
// f2(=catchNull(f1)の戻り値)は、「AnyObjectを受け取ってStringかnil(=Stringのオプショナル型)を返す」関数
let f2:(AnyObject throws -> String?) = catchNull(f1) // ここではf1は実行されていない
do{
let v2_1:String? = try f2("123")
print(v2_1) //"Optional("123")\n"
let v2_2:String? = try f2(NSNull()) // NSNull()がf2の中でチェックされ、f1が呼ばれる前にnilを返している
print(v2_2) //"nil\n"
}catch let error{
print(error) // この場合例外が発生しない。
}
Decodable.swiftのdecodeArray()
関数decodeArray()も、「「関数」を返す関数」ですが、書き方がちょっと変わっています。
public func decodeArray<T>(elementDecodeClosure: AnyObject throws -> T)(json: AnyObject) throws -> [T] {
return try NSArray.decode(json).map { try elementDecodeClosure($0) }
}
引数部分がカッコ2つセットとなっています。
func decodeArray(引数1)(引数2)-> 戻り値
この書き方は、「カリー化」と呼ばれるもののようです。Swiftで関数のカリー化(currying)入門が参考になるかと思います。
decodeArrayの場合、引数1が関数、引数2が値(AnyObject)となっています。
Operator.swiftでは、以下のように呼び出されています。
return try parse(lhs, path: rhs, decode: decodeArray(T.decode))
ここでは引数1(=関数)でしか呼び出していないため、戻り値は「引数にAnyObjectを取り値を返す関数」です。decodeArray()も、「「関数」を返す関数」になっています。
このカリー化の書き方ですが、Swift3では廃止されるようです。Swiftがオープンソース化されたのでSwift2.2/Swift3.0の変更を眺めてみる
もしもこのカリー化の書き方が無くなった場合には、(自信はないのですが)↓のようになるかな?と思います。catchNull()の書き方に似てますね。
public func decodeArray2<T>(elementDecodeClosure: AnyObject throws -> T) -> (AnyObject throws -> [T]) {
return{ json in
try NSArray.decode(json).map { try elementDecodeClosure($0) }
}
}
Decodable.swiftのdecodeDictionary()
decodeDictionary()は、「「「関数」を返す関数」を返す関数」と、さらにややこしくなっています。
public func decodeDictionary<K,V>(keyDecodeClosure: AnyObject throws -> K)(elementDecodeClosure: AnyObject throws -> V)(json: AnyObject) throws -> [K: V] {
var dict = [K: V]()
for (key, value) in try NSDictionary.decode(json) {
try dict[keyDecodeClosure(key)] = elementDecodeClosure(value)
}
return dict
}
このdecodeDictionaryも、Operator.swiftで使われています。
return try parse(lhs, path: rhs, decode: decodeDictionary(String.decode)(elementDecodeClosure: T.decode))
decodeDictionary(String.decode)
で、新しい関数が作られ、さらに(elementDecodeClosure: T.decode)
を重ねることで、さらに新しい関数を作って返しているようです。
このdecodeDictionaryも、カリー化の記法をつかわずに書き換えてみます。こちらもこれで正しいのか自信はありません。
public func decodeDictionary2<K,V>(keyDecodeClosure:(AnyObject throws -> K)) ->((AnyObject throws -> V) -> ( AnyObject throws -> [K: V])) {
return { elementDecodeClosure in
return { json in
var dict = [K: V]()
for (key, value) in try NSDictionary.decode(json) {
try dict[keyDecodeClosure(key)] = elementDecodeClosure(value)
}
return dict
}
}
}
Decodable.swiftのArray.decode()
上段で似た名前の関数 decodeArray()がでていますが、これは別物です。
Array.decode()関数は、他のdeode()関数と違って、引数を2つ取る事が出来ます。
public static func decode(j: AnyObject, ignoreInvalidObjects: Bool = false) throws -> [Element] {
二つ目の引数 ignoreInvalidObjectsは、「変換できないオブジェクトが含まれていた時」の挙動のフラグです。
ignoreInvalidObjectsがfalse(デフォルト)の場合は、T.decode()で変換できない場合は例外が投げられます。
ignoreInvalidObjectsがtrueの場合、変換できなかった要素は無視されます。
if ignoreInvalidObjects {
return try decodeArray { try? Element.decode($0) }(json: j).flatMap {$0}
} else {
return try decodeArray(Element.decode)(json: j)
}
{try? Element.decode($0)}
によって、新たな関数が生成されています。この関数は「Element.decode()
の値を返す、もしも例外が発生したら(=変換できなかったら)nilを返す」という挙動になります。この場合、declodeArrayの戻り値の型は、[Optional(Element)]
となり、nilを含む可能性が出てきます。これを、.flatMap($0)
で、nilを除去しています。
この流れで、「変換できなかったら無視する」という挙動になっています。
Parse.swiftのparse()
この関数では、jsonオブジェクトから指定されたパスの値を取り出して、引数decodeで指定された「関数」を実行しています。
処理が多いように見えますが、ほとんどは「jsonオブジェクトから指定されたパスの値を取り出す」処理に費やされています。
コメントを追記した形で、以下に引用します:
public func parse<T>(json: AnyObject, path: [String], decode: (AnyObject throws -> T)) throws -> T {
var object = json
// ここのifのブロックで、「指定されたpathの値の取得」をしている
if let lastKey = path.last {
var path = path
path.removeLast()
var currentDict = try NSDictionary.decode(json)
var currentPath: [String] = []
// 関数内で関数を定義している
func objectForKey(dictionary: NSDictionary, key: String) throws -> AnyObject {
guard let result = dictionary[NSString(string: key)] else {
let info = DecodingError.Info(object: dictionary, rootObject: json, path: currentPath)
throw DecodingError.MissingKey(key: key, info: info)
}
return result
}
// ここのループで、取得した値が含まれるcurrentDictを取得
for key in path {
currentDict = try NSDictionary.decode(objectForKey(currentDict, key: key))
currentPath.append(key)
}
// 最終的に変換する値(object)を取得
object = try objectForKey(currentDict, key: lastKey)
}
// 取得した値(object)を、指定された関数(decode)に渡している。
// catchAndRethrow()は、「例外が発生した時に、引数のjsonとpathを付けて投げ直す」ための関数です。
return try catchAndRethrow(json, path) { try decode(object) }
}
catchAndRethrow()関数は、引数を3つ取り値を返す関数です。
func catchAndRethrow<T>(json: AnyObject, _ path: [String], block: Void throws -> T) throws -> T {
ですが、使う時には「引数2つと関数」を指定しているように見えます。
これを「引数3つ(値2つと関数1つ)」と書くのが関数呼び出し的には普通に感じますが、これ、Array.map等を使う時の書き方に似てるように思います。この辺のルールはまだよく理解できていません。
return try catchAndRethrow(json, path) { try decode(object) }
// return try catchAndRethrow(json, path, block: { try decode(object) }) // こうとも書ける
// [1,2,3].map{ print($0) } // ←これに似ている気がする
ソースを読んだ感想
Decodableでは、関数型言語の考え方が多数含まれていました。特に「関数を返す関数」で、新しい関数を作るパターンが多数見られます。
この新しい関数を作る、というのは、Objective-Cから来た自分としては、頭が混乱するとともに面白い所でした。
また、「型推論を使って無駄なas?を削っている所」は、型推論に慣れていないので、入力→処理→出力 という流れを逆流しているような、不思議な気分になります。
これらのテクニックが駆使されているので、ソースの重複部分が見当たらず、ものすごくコンパクトになっているんだなとわかりました。
ただ、このようなテクニックを駆使した書き方は、会社のプロジェクトでは正直使いにくいです。新人が発狂してしまいそうです。
自分とメンバーのレベルを上げていかないといけないですね。