結論: 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 です。 Result
は Optional
を拡張したような型で、成功時の値に加え、失敗時のエラー情報も保持できる次のような enum
です。
enum Result<T, Error: ErrorType> {
case Success(T)
case Failure(Error)
}
Optional
の宣言と見比べるとよく似ているのがわかります2。違いは、失敗のときにエラー情報を保持できるかできないかです。
enum Optional<T> {
case Some(T)
case None
}
Result
は Optional
同様モナドとなるよう設計されており、 map
や flatMap
などのメソッドを使うことができます。また、 ??
演算子も用意されており、まさに強化 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 点です。
-
User
のプロパティを宣言するときとデコードするときの 計 2 回、型を記述しなければならない。 - どのキーについてエラーが発生したのか記述しようとすると、値を取得するときとエラー情報を記述するときの 計 2 回、キーを記述しなければならない。
- イニシャライザにパラメータを渡したいだけ(本来 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
でラップされて返されます。 Decoded
は Result
と同じような役割の型ですが、 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)
}
Decoded
と Result
との違いは次の 2 点です。
- エラーの型が
DecodedError
に限定されている。 - (そのため)型パラメータが
T
一つしかない。
エラーとして DecodedError
しか持つことができないので Result
ほど汎用ではありませんが、 Result<T, Error>
のように型パラメータを二つ保持しなくて良いのでコードがすっきりします。 JSON のデコードで起こり得るパターンは決まっているのでそれに特化してしまった方が良いという判断でしょう。
Optional
や Result
と同じく Decoded
もモナドとして設計されており、 map
や flatMap
が使えます。
次に、 DecodeError
の中身に目を向けると .TypeMismatch
は Int
としてデコードしようとしたけど JSON 上は文字列だったなど、型の不整合が起こった場合に返されます。 .MissingKey
はそもそも指定されたキーが存在しなかった場合に返されます。 .Custom
はその他のエラー、例えば、デコードには成功したけどその値が不適切だった場合などに Argo のユーザーが自分で生成して返すためのものです。
<|?
<|?
は <|
と似た働きの演算子ですが、キーが存在しなかった場合でも .MissingKey
になりません。 Optional
なプロパティをデコードする場合に .MissingKey
になってしまうと困るので、このような演算子が別途用意されています。
プロパティが Optional
であることから自動判別してくれれば良いように思えるかもしれませんが、 プロパティが Optional
でなくても .MissingKey
になっては困る場合があるので <|?
は必須です。 admin
プロパティはその例です。
管理者権限を持っているかを表す admin
プロパティは、 let admin: Bool
と宣言されているので nil
は許されず、必ず true
か false
を指定する必要があります。しかし JSON 上では "admin"
というキーは省略することができるということにしたので、省略された場合は false
として扱わなければなりません。
そこで、まず <|?
を使って "admin"
を取得します。 json <|? "admin"
の時点では戻り値の型は Decoded<Bool?>
となります。これを Decoded<Bool>
に変換し、もし Decoded
が保持している値が nil
だった場合は false
にしてしまいたいわけです。
そんなときは map
の出番です。 map
は Array
や Optional
でもおなじみですが、 Foo<T>
を Foo<U>
に変換するために使われるメソッドです(詳細は "SwiftのOptional型を極める" )。今は T
が Bool?
で U
が Bool
となります。
実際に書くと次のようになります。
(json <|? "admin").map { $0 ?? false }
これは Decoded<Bool?>
の map
なので $0
の型は Bool?
です。 ??
演算子の戻り値の型は Optional
が取れるので Bool
となり、 map
の結果は Decoded<Bool>
となります。また、 $0 ?? false
で nil
は false
に変換されるわけです。
>>-
>>-
は flatMap
の演算子版です。元々は Haskell の >>=
から来ているのですが、 Swift では >>=
演算子は複合代入演算子に割り当てられているので、代わりに >>-
とするのが一般的4です。
>>-
は単に flatMap
を演算子にしただけなので、次の二つは等価です。
foo.flatMap { ... }
foo >>- { ... }
では、何のために >>-
はあるのでしょうか。僕の推測ですが、おそらく次のような理由からです。
-
flatMap
は Swift 1.2 で導入されたので、それ以前にすでに>>-
が流行っていた。 - Haskell に慣れた人が似たような記法で書きたかった。
-
>>-
で書いた方がflatMap
よりも可読性が良い。
1, 2 はともかく、 3 は今でも Swift で >>-
を使う理由になり得ます。実際に flatMap
を多用するとわかりますが、コードが非常に読みづらくなるケースがあります。
可読性が低下する原因の一つは、僕はシンタックスハイライトの問題だと考えています。 Trailing Closure を使う場合、意味的には制御構文のような働きをしていることが多いのですが、制御構文と異なりキーワード( flatMap
の部分)がハイライトされないので、ぱっと見て構造を把握するまでに時間がかかります(メソッドと制御構文的なものが混ざってしまうので)。 >>-
というメソッドの識別子とはまったく違う見た目のもので記述されていると、ひと目で式の構造を読み取れるようになります。これについては、 Trailing Closure を使った場合は制御構文同様にシンタックスハイライトしてくれるようになれば解決するかもしれません。
また、複数の種類のモナド( Optional
と Array
とか)を混ぜて使う場合に、どっちの flatMap
なのかわかりづらくなることもあります。そういう場合は、僕は外側を >>-
にして区別することが多いです( >>-
で書かれた大きな区切りの中に flatMap
による小さな式が入るイメージです)。具体的には、 PromiseK で Promise
をチェーンする場合に使うことが多いです。内側で Optional
の map
や flatMap
を使うことが多いですが、 >>-
で処理をつなぐとそれらと混ざってしまうこともないですし、処理をチェーンして記述する Promise
の目的とも相性が良いように感じています。
バリデーションとflatMap
下記の age
の取得およびバリデーションについては、 Decoded
関連の一連の処理ということで <|
とレベル間を併せておいた方が直感的にわかりやすいので flatMap
ではなく >>-
を使っています。
json <| "age" >>- {
$0 >= 0 ? pure($0) // pure は .Success と同じ意味の関数
: .customError("age: Out of range (\($0))") }
}
flatMap
も map
同様 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
, z
を add3
に渡すにはどうすれば良いでしょうか?どれも Optional
なのでそのままでは渡せません。
let x: Int? = ...
let y: Int? = ...
let z: Int? = ...
<^>
と <*>
、それに curry
関数を使えば次のように書けます( Runes と Curry を使います)。
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
であっても、それらをアンラップして中の値を取り出さなくてもイニシャライザに渡すことができます( Optional
と add3
の話と同じです)。
ここまでの話を一つにまとめると、最初に書いた式が得られます。
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 )で動作するバージョンを全ライブラリについて取ってくることができます。両ファイルをコピーして、 Carthage で carthage bootstrap
すれば OK です( Carthage についてはこちらの投稿の中で説明しています)。
github "thoughtbot/Argo" "td-swift-2"
github "thoughtbot/Curry" "master"
github "thoughtbot/Runes" "master"
github "SwiftyJSON/SwiftyJSON" "xcode7"
github "antitypical/Result" ~> 0.6
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 してあります。うまくライブラリがインストールできないときのご参考にお使い下さい。
-
厳密にはバリデーションをイニシャライザで行うべきですが、エラー情報を付与することを考えると複雑になってしまうので、本投稿では JSON デコード時にのみバリデーションを行います。 ↩
-
Result
と比較しやすいように、意図的に.Some
と.None
の順番を入れ替えています。 ↩ -
本投稿で取り上げた SwiftyJSON 版のコードと Argo 版のコードは厳密には等価ではありません。 Argo 版では
.TypeMismatch
はエラー扱いでしたが、 SwiftyJSON では.TypeMismatch
と.MissingKey
を区別することができないため、nickname
やadmin
が.TypeMismatch
なときでもエラーとならないからです。どちらが望ましいかは仕様次第だと思います。 ↩ -
より正確には
Optional
もDecoded
もアプリカティブファンクターだからです。 ↩ -
Prebuilt 版を配布しているのは Curry のビルドが(おそらく複雑な型推論のため)遅いからだと思います。僕が ApplicativeSwift で同じく
curry
関数を実装したときにも同じ問題が発生し、最初は 31 引数版まで作っていたのですが、ビルドが終わらないので 15 引数まで減らしました。 Curry も 17 引数からはコメントアウトされており、同様の問題が発生したようです。 Swift は Literal Convertible があるので型推論が複雑で辛そうです。 ↩