351
337

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.

まだSwiftyJSONで消耗してるの?

Last updated at Posted at 2015-08-10

結論: Argo すげー!!特に Swift 2.0 以降で。

はじめに

Qoncept では週に 2 回、みんなでコードリーディングする時間を設けています。先週から Carthage のコードを読み始めたところ、依存ライブラリの中に Argo という JSON パーサを見つけました。

僕はこれまで JSON のデコードに SwiftyJSON を使っていました。しかし、 Argo の README やソースを読んでみて、これは素晴らしいと思ったので Argo について紹介します。

JSONのデコード

次のような User 型があるとします。

struct User {
    let id: String
    let nickname: String?
    let age: Int
    let admin: Bool
}

JSON をデコードして User のインスタンスを生成する方法を考えましょう。

{
	"id"       : "a082b4fe",
	"nickname" : "Foo",
	"age"      : 30,
	"admin"    : false
}

現実的なケースに近づけるために、下記の条件も加えます。

  • age が負のときはデコード失敗( バリデーションを行う
  • admin が省略されたときは false とする( デフォルト値を設定する

SwiftyJSON

まずは SwiftyJSON でデコードしてみましょう( Swift で直接 NSJSONSerialization を使うのは面倒すぎるので、その選択肢は考えません)。

JSON を受け取り User を返すメソッドを考えます。デコードに失敗することもあるので、失敗したら nil を返すことにしましょう1

extension User {
    static func from(json: JSON) -> User? {
        guard let id = json["id"].string else {
            return nil
        }

        let nickname = json["nickname"].string

        guard let age = json["age"].int where age >= 0 else {
            return nil
        }

        let admin = json["admin"].bool ?? false

        return self.init(id: id, nickname: nickname, age: age, admin: admin)
    }
}

SwiftyJSON(エラー情報付)

上記の方法では、 nil が返ってきたときにデコードに失敗したことはわかりますが、失敗した原因まではわかりません。どこで失敗したかわからないとデバッグが大変だったり、適切なエラー処理ができなかったりします。失敗したときは nil を返すのではなく、エラー情報を付与して返すことにしましょう。

Optional のように失敗を表したい、でも失敗したことだけじゃなくて何らかの情報を付与したい、そんなときに Swift でよく使われるライブラリが Result です。 ResultOptional を拡張したような型で、成功時の値に加え、失敗時のエラー情報も保持できる次のような enum です。

enum Result<T, Error: ErrorType> {
    case Success(T)
    case Failure(Error)
}

Optional の宣言と見比べるとよく似ているのがわかります2。違いは、失敗のときにエラー情報を保持できるかできないかです。

enum Optional<T> {
    case Some(T)
    case None
}

ResultOptional 同様モナドとなるよう設計されており、 mapflatMap などのメソッドを使うことができます。また、 ?? 演算子も用意されており、まさに強化 Optional という感じです。

では、 Result を使って from を書きなおしてみましょう。

extension User {
    static func from(json: JSON) -> Result<User, ParseError> {
        guard let id = json["id"].string else {
            return .Failure(.Unparsable("id"))
        }

        let nickname = json["nickname"].string

        guard let age = json["age"].int where age >= 0 else {
            return .Failure(.Unparsable("age"))
        }

        let admin = json["admin"].bool ?? false

        return .Success(self.init(id: id, nickname: nickname, age: age, admin: admin))
    }
}

enum ParseError: ErrorType {
    case Unparsable(String)
}

Optional のときとほぼ変わらない複雑さでエラー情報を扱えるようになりました。 age が存在しなかった場合と負だった場合はエラーの種類を分けてもいいのですが、例としてはややこしくなるだけなので、ここではまとめて扱います。

Argo

SwiftyJSON が良くないのは、次の 3 点です。

  1. User のプロパティを宣言するときとデコードするときの 計 2 回、型を記述しなければならない
  2. どのキーについてエラーが発生したのか記述しようとすると、値を取得するときとエラー情報を記述するときの 計 2 回、キーを記述しなければならない
  3. イニシャライザにパラメータを渡したいだけ(本来 1 行のコード)なのに、パラメータを一つずつチェックしてエラー処理する必要があるのでコードが長くなる。

1 については、例えば User 宣言時に let id: String であると宣言しているにも関わらず、 id をデコードするときに json["id"].string と、再び String であることを記述しなければならないことを言っています。

2 は、値を取得するときの json["id"] と、エラーだった場合の .Unparsable("id") で同じキーを記述しなければならないということです。

Argo を使えばそれらを解決し、シンプルに記述できます3

extension User: Decodable {
    static func decode(json: JSON) -> Decoded<User> {
        return curry(User.init)
            <^> json <| "id"
            <*> json <|? "nickname"
            <*> (json <| "age" >>- { $0 >= 0 ? pure($0)
                : .customError("age: Out of range (\($0))") })
            <*> (json <|? "admin").map { $0 ?? false }
    }
}

<|, <|?, >>-, <^>, <*> など意味不明ですね!順に見ていきましょう。

なお、 User: Decodable となっているのは Argo の仕様で、 JSON から Argo を使ってデコードできる型は必ず Decodable でなければなりません。 Decodable は上記のような decode メソッドを実装する必要があります。

Decoded は前述の Result のような型で、デコードの成功か失敗かを表します。また、成功の場合は結果(この場合は User のインスタンス)を、失敗の場合はエラー情報を保持します。詳細は後述します。

<|

<| はキーを指定して JSON から値を取得する演算子です。

例えば json <| "id" と書くと、 json から "id" をキーとして値を得ることができます。 SwiftyJSON では json["id"].string のように型の指定が必要だったのに対して、 Argo では型を指定しないことに注目して下さい。 <| はジェネリックな演算子なので、戻り値の型を推論して自動的に型を決定します。そのため、型の記述が必要ないわけです。

JSON のデコードは常に失敗する可能性があるため、生の値( "id" なら String )をそのまま返すわけにはいきません。 <| で得られる値は Decoded でラップされて返されます。 DecodedResult と同じような役割の型ですが、 Argo 専用に設計されています。

enum Decoded<T> {
    case Success(T)
    case Failure(DecodeError)
}

enum DecodeError: ErrorType {
    case TypeMismatch(expected: String, actual: String)
    case MissingKey(String)
    case Custom(String)
}

DecodedResult との違いは次の 2 点です。

  • エラーの型が DecodedError に限定されている。
  • (そのため)型パラメータが T 一つしかない。

エラーとして DecodedError しか持つことができないので Result ほど汎用ではありませんが、 Result<T, Error> のように型パラメータを二つ保持しなくて良いのでコードがすっきりします。 JSON のデコードで起こり得るパターンは決まっているのでそれに特化してしまった方が良いという判断でしょう。

Optional や Result と同じく Decoded もモナドとして設計されており、 mapflatMap が使えます。

次に、 DecodeError の中身に目を向けると .TypeMismatchInt としてデコードしようとしたけど JSON 上は文字列だったなど、型の不整合が起こった場合に返されます。 .MissingKey はそもそも指定されたキーが存在しなかった場合に返されます。 .Custom はその他のエラー、例えば、デコードには成功したけどその値が不適切だった場合などに Argo のユーザーが自分で生成して返すためのものです。

<|?

<|?<| と似た働きの演算子ですが、キーが存在しなかった場合でも .MissingKey になりません。 Optional なプロパティをデコードする場合に .MissingKey になってしまうと困るので、このような演算子が別途用意されています。

プロパティが Optional であることから自動判別してくれれば良いように思えるかもしれませんが、 プロパティが Optional でなくても .MissingKey になっては困る場合があるので <|? は必須ですadmin プロパティはその例です。

管理者権限を持っているかを表す admin プロパティは、 let admin: Bool と宣言されているので nil は許されず、必ず truefalse を指定する必要があります。しかし JSON 上では "admin" というキーは省略することができるということにしたので、省略された場合は false として扱わなければなりません。

そこで、まず <|? を使って "admin" を取得します。 json <|? "admin" の時点では戻り値の型は Decoded<Bool?> となります。これを Decoded<Bool> に変換し、もし Decoded が保持している値が nil だった場合は false にしてしまいたいわけです。

そんなときは map の出番です。 mapArrayOptional でもおなじみですが、 Foo<T>Foo<U> に変換するために使われるメソッドです(詳細は "SwiftのOptional型を極める" )。今は TBool? で UBool となります。

実際に書くと次のようになります。

(json <|? "admin").map { $0 ?? false }

これは Decoded<Bool?> の map なので $0 の型は Bool? です。 ?? 演算子の戻り値の型は Optional が取れるので Bool となり、 map の結果は Decoded<Bool> となります。また、 $0 ?? falsenilfalse に変換されるわけです。

>>-

>>-flatMap の演算子版です。元々は Haskell の >>= から来ているのですが、 Swift では >>= 演算子は複合代入演算子に割り当てられているので、代わりに >>- とするのが一般的4です。

>>- は単に flatMap を演算子にしただけなので、次の二つは等価です。

foo.flatMap { ... }
foo >>- { ... }

では、何のために >>- はあるのでしょうか。僕の推測ですが、おそらく次のような理由からです。

  1. flatMap は Swift 1.2 で導入されたので、それ以前にすでに >>- が流行っていた。
  2. Haskell に慣れた人が似たような記法で書きたかった。
  3. >>- で書いた方が flatMap よりも可読性が良い。

1, 2 はともかく、 3 は今でも Swift で >>- を使う理由になり得ます。実際に flatMap を多用するとわかりますが、コードが非常に読みづらくなるケースがあります。

可読性が低下する原因の一つは、僕はシンタックスハイライトの問題だと考えています。 Trailing Closure を使う場合、意味的には制御構文のような働きをしていることが多いのですが、制御構文と異なりキーワード( flatMap の部分)がハイライトされないので、ぱっと見て構造を把握するまでに時間がかかります(メソッドと制御構文的なものが混ざってしまうので)。 >>- というメソッドの識別子とはまったく違う見た目のもので記述されていると、ひと目で式の構造を読み取れるようになります。これについては、 Trailing Closure を使った場合は制御構文同様にシンタックスハイライトしてくれるようになれば解決するかもしれません。

また、複数の種類のモナド( OptionalArray とか)を混ぜて使う場合に、どっちの flatMap なのかわかりづらくなることもあります。そういう場合は、僕は外側を >>- にして区別することが多いです( >>- で書かれた大きな区切りの中に flatMap による小さな式が入るイメージです)。具体的には、 PromiseKPromise をチェーンする場合に使うことが多いです。内側で Optional の mapflatMap を使うことが多いですが、 >>- で処理をつなぐとそれらと混ざってしまうこともないですし、処理をチェーンして記述する Promise の目的とも相性が良いように感じています。

バリデーションとflatMap

下記の age の取得およびバリデーションについては、 Decoded 関連の一連の処理ということで <| とレベル間を併せておいた方が直感的にわかりやすいので flatMap ではなく >>- を使っています。

json <| "age" >>- {
    $0 >= 0 ? pure($0) // pure は .Success と同じ意味の関数
            : .customError("age: Out of range (\($0))") }
}

flatMapmap 同様 Foo<T> を Foo<U> に変換するために使われます。 map との違いは、 flatMap に渡す関数が U を返すのではなく Foo<U> を返すことです。

例えば、バリデーションのように値の取得自体に成功しても、条件に合わなければ失敗にしたい場合などは map では表せません。 map だと .Success.Success のまま値を変換するだけで、 .Success.Failure にすることはできないのです。 flatMap を使えば Foo<U> そのものを返すことができるので、 .Success の場合でも .Failure にしてしまうことができます。

.customError は Decoded の持つ static メソッドで、 .Failure(.Custom) を生成します。 JSON のデコードという目的を考えると、このケース( "age" が負だった場合)のようにバリデーションの失敗を表すために使うことが多いのではないかと思います。

pure についてはコメントを付けましたが、これも Haskell 由来の関数で、ただ単に値を Decoded などで包むだけの関数です。 Decoded なら .Success に、 Optional なら .Some になります5

<^>, <*>

アプリカティブスタイル

<^> と <*> はアプリカティブスタイルを実現するための関数です。

アプリカティブスタイルの原理を詳しく説明するとそれだけで投稿一本分になってしまうので、ここでは何をしたいかだけを簡単に説明します。

例えば、次のような関数があったとします。単に三つの Int を足すだけのシンプルな関数です。

func add3(a: Int, b: Int, c: Int) -> Int {
    return a + b + c
}

このとき、次の x, y, zadd3 に渡すにはどうすれば良いでしょうか?どれも Optional なのでそのままでは渡せません。

let x: Int? = ...
let y: Int? = ...
let z: Int? = ...

<^><*> 、それに curry 関数を使えば次のように書けます( RunesCurry を使います)。

let result: Int? = curry(add3) <^> x <*> y <*> z

もし、 x = 2, y = 3, z = 5 なら result は Optional(10) になります。 x, y, z の一つでも nil であれば result も nil になります。

このように、 <^><*> を使えば、 引数が Optional でない関数やメソッドに Optional な値を引数として渡すことができます。このような記法を アプリカティブスタイル と呼びます。 Decoded も強化版 Optional のようなものなので同じことができます6

この <^><*> も Haskell 由来の演算子です。 <^> は Haskell では <$> という演算子なんですが、 Swift では $ を含む演算子を作ることができないので <^> とすることが一般的です。

カリー化

curryカリー化 を行う関数です。 <^><*> を適用するには関数がカリー化されている必要があります。カリー化とは N 引数の関数を 1 引数ずつ適用する高階関数に変換する処理です。例えば、次の curriedAdd3 がカリー化された add3 の例です。

func curriedAdd3(a: Int) -> Int -> Int -> Int {
	return { (b: Int) -> Int -> Int in
	    { (c: Int) -> Int in
	        a + b + c
	    }
	}
}

curriedAdd3(2)(3)(5) // 10

まず、 curriedAdd3(2) を実行すると、 Int -> Int -> Int という関数が得られます。これは Int -> (Int -> Int) と考えるとわかりやすいです。つまり、引数として Int を渡せば Int -> Int という関数が戻り値として得られる関数です。

その Int -> (Int -> Int) の関数に (3) を適用して Int -> Int という関数を得、最後にその関数に (5) を適用して戻り値の 10 という Int が得られるわけです。

Swift では、次のように簡単にカリー化された関数を宣言する方法もあります。

func curriedAdd3(a: Int)(_ b: Int)(_ c: Int) -> Int {
    return a + b + c
}

curriedAdd3(2)(3)(5) // 10

Curry を使えば、 16 引数までの関数をカリー化することができます。

Decodedとイニシャライザとアプリカティブスタイル

今やりたいのは、 User のイニシャライザに <|<|? で得られた値を渡すことです。しかし、それらの値は生の値ではなく Decoded でラップされているので、そのまま渡すことはできません。そこで、アプリカティブスタイルです。

curry(User.init) でイニシャライザをカリー化して、それに <^><*> を使って Decoded な値を適用していきます。つまり、構造上は次のようになっています。

curry(User.init) <^> id <*> nickname <*> age <*> admin

こうすれば、一つ一つのパラメータは Decoded であっても、それらをアンラップして中の値を取り出さなくてもイニシャライザに渡すことができます( Optionaladd3 の話と同じです)。

ここまでの話を一つにまとめると、最初に書いた式が得られます。

curry(User.init)
    <^> json <| "id"
    <*> json <|? "nickname"
    <*> (json <| "age" >>- { $0 >= 0 ? pure($0)
        : .customError("age: Out of range (\($0))") })
    <*> (json <|? "admin").map { $0 ?? false }

一見わけのわからない記号のかたまりに見えますが、その意味を理解した上で読めばとてもシンプルな構造になっていることがわかります。単にキーを一つずつ指定し、仕様にそってバリデーションしたりデフォルト値を与えたりしているだけです。

またおもしろいのは、アプリカティブスタイルで書くことで User のイニシャライザの引数の型から <| や <|? の戻り値の型が推論され、 SwiftyJSON の json["id"].string のように型を明示的に指定しなくて良いことです。アプリカティブスタイルなどで型を推論させなければ Argo でも次のように型の指定が必要です。

let id: Decoded<String> = json <| "id"

なお、 Swift 1.2 まではイニシャライザを直接引数に渡すことができなかったので、いちいちイニシャライザ相当の関数を作る必要があり、 Argo を使うのはやや面倒でした。 Swift 2.0 でその点が解決され、 Argo は素晴らしいライブラリになりました。

おまけ

前述の三つの問題のうち 3 については、実は SwiftyJSON でもアプリカティブスタイルで解決できます。

SwiftyJSON(アプリカティブスタイル)

エラー情報を返さず、 nil でエラーを表す場合をアプリカティブスタイルで書いてみましょう。 Optional<^><*> を使うためには Runes を使います。

extension User {
    static func from(json: JSON) -> User? {
        return curry(User.init)
            <^> json["id"].string
            <*> .Some(json["nickname"].string)
            <*> (json["age"].int >>- { $0 >= 0 ? $0 : nil })
            <*> (json["admin"].bool ?? false)
    }
}

エラー情報は扱えないですし、いちいち値の型を指定しなければなりませんが、なかなかシンプルです(これまで僕はこのスタイルで書いてました)。

SwiftyJSON(アプリカティブスタイル、エラー情報付)

次は Result を使ってエラー情報を返す場合をアプリカティブスタイルで書いてみましょう。 Result には残念ながら <^> と <*> がないので自分で実装します。

extension User {
    static func from(json: JSON) -> Result<User, ParseError> {
        return curry(User.init)
            <^> json["id"].string
                .map { .Success($0) } ?? .Failure(.Unparsable("id"))
            <*> .Success(json["nickname"].string)
            <*> (json["age"].int >>- { $0 >= 0 ? $0 : nil })
                .map { .Success($0) } ?? .Failure(.Unparsable("age"))
            <*> .Success(json["admin"].bool ?? false )
    }
}

func <^><T, E: ErrorType, U>(lhs: T -> U, rhs: Result<T, E>)
    -> Result<U, E> {
    return rhs.map(lhs)
}

func <*><T, E: ErrorType, U>(lhs: Result<T -> U, E>, rhs: Result<T, E>)
    -> Result<U, E> {
    return lhs.flatMap { rhs.map($0) }
}

書くことはできますが、これはちょっと辛いですね。 SwiftyJSON はすべてを Optional で返すので、 Optional から Result への変換のために .map { .Success($0) } ?? .Failure(.Unparsable("id")) のようなコードが頻出するのが辛いです。これを関数にしてしまう手もありますが、それだったら最初から Argo を使った方が良いでしょう。

Swift 2.0 での各種ライブラリのインストール

※ Xcode 7 と Swift 2.0 がリリースされて下記のような問題は解消されました。新しい Cartfile などは GitHub のリポジトリを御覧下さい。

本投稿では多くのライブラリを紹介しました。

現時点( 2015 年 8 月 9 日)で、これらのライブラリを Swift 2.0 で使おうとすると、どのバージョンをとってきたらいいのか迷うのではないかと思います。また、 Curry については Prebuilt 版の Framework が配布されているのですが、 Xcode のバージョンが変わると( Beta 版同士でも)エラーになってしまいハマりました7

下記の Cartfile および Cartfile.resolved を使えば、 Swift 2.0 ( Xcode 7 beta 5 )で動作するバージョンを全ライブラリについて取ってくることができます。両ファイルをコピーして、 Carthagecarthage bootstrap すれば OK です( Carthage についてはこちらの投稿の中で説明しています)。

Cartfile
github "thoughtbot/Argo" "td-swift-2"
github "thoughtbot/Curry" "master"
github "thoughtbot/Runes" "master"
github "SwiftyJSON/SwiftyJSON" "xcode7"
github "antitypical/Result" ~> 0.6
Cartfile.resolved
github "thoughtbot/Argo" "c936846acde9d877cdb2352eb2ee206f830a29e5"
github "thoughtbot/Curry" "367f140c27b79305662748a07bf827da4bb64d32"
github "antitypical/Result" "0.6-beta.1"
github "thoughtbot/Runes" "13fc6356b6d82d4cc68f8ca925a1c12bd19c3a1e"
github "SwiftyJSON/SwiftyJSON" "45ca854ce75948858f65e31fbc58cbf214669250"

Xcode 7 の Beta 版でビルドするために、 carthage bootstrap の前に下記コマンドを実行しておきましょう。

sudo xcode-select -s /Applications/Xcode-beta.app/Contents/Developer

なお、上記 Cartfile を含む、本投稿関連の実際に動かせるコードを GitHub に push してあります。うまくライブラリがインストールできないときのご参考にお使い下さい。


  1. 厳密にはバリデーションをイニシャライザで行うべきですが、エラー情報を付与することを考えると複雑になってしまうので、本投稿では JSON デコード時にのみバリデーションを行います。

  2. Result と比較しやすいように、意図的に .Some.None の順番を入れ替えています。

  3. 本投稿で取り上げた SwiftyJSON 版のコードと Argo 版のコードは厳密には等価ではありません。 Argo 版では .TypeMismatch はエラー扱いでしたが、 SwiftyJSON では .TypeMismatch と .MissingKey を区別することができないため、 nicknameadmin.TypeMismatch なときでもエラーとならないからです。どちらが望ましいかは仕様次第だと思います。

  4. SwiftzRunes など。

  5. Optionalpure 関数は Runes で提供されています。

  6. より正確には OptionalDecoded もアプリカティブファンクターだからです。

  7. Prebuilt 版を配布しているのは Curry のビルドが(おそらく複雑な型推論のため)遅いからだと思います。僕が ApplicativeSwift で同じく curry 関数を実装したときにも同じ問題が発生し、最初は 31 引数版まで作っていたのですが、ビルドが終わらないので 15 引数まで減らしました。 Curry も 17 引数からはコメントアウトされており、同様の問題が発生したようです。 Swift は Literal Convertible があるので型推論が複雑で辛そうです。

351
337
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
351
337

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?