11
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

SwiftAdvent Calendar 2015

Day 13

Swift2のJSONパーサ?な Decodable のソースを読む

Last updated at Posted at 2015-12-13

はじめに

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.swiftCastable.swift)
    • NSDictionary,NSArray Dictionary,Array,String,Int,Double,Bool に、スタティックなdecode()関数を足しています。
      • String.decode()やInt.decode()等など。
    • decode()は「AnyObjectを受け取り、その型に変換して返す、変換できなければ例外を投げる」機能を持ちます。
  • parse()関数(Parse.swift)
    • jsonオブジェクトから値を取得し、その値を引数にdecodeを呼び出す所までを担当します。
  • =>オペレータ(Operators.swift)
    • 「何の値を返すのか」という種類毎に定義されている。
    • それぞれの種類毎に、どう処理するのかをセットして、parse()関数を呼び出しています。
  • 例外(DecodeError.swift)
    • 「指定されたキーが存在しない」場合と「値が変換できない」場合に例外が飛びます。この例外を表すenumを定義しています。

これらが組み合わさり、ざっくりと以下の順番で処理が行われます。

  1. =>オペレータで、戻り値の型に応じて、デコードの処理が決定し、その処理とjsonとキーを関数parse()に渡します。
  2. parse()関数では、キーを使ってjsonの中から値を取り出し、その値をデコードの処理に渡します。もしこの時に値が無い場合は、例外が飛びます。
  3. デコードの処理の中で、各型に定義された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?を削っている所」は、型推論に慣れていないので、入力→処理→出力 という流れを逆流しているような、不思議な気分になります。
これらのテクニックが駆使されているので、ソースの重複部分が見当たらず、ものすごくコンパクトになっているんだなとわかりました。

ただ、このようなテクニックを駆使した書き方は、会社のプロジェクトでは正直使いにくいです。新人が発狂してしまいそうです。
自分とメンバーのレベルを上げていかないといけないですね。

11
10
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?