Swift 2.0 が今週にもリリースされそうというタイミングで気が早いですが、 Swift 3.0 (になるかわからないけど 2.0 の次の Swift )で追加されそうな Either について説明し、どんなものになるか考えてみます。
次期 Swift で Either が追加されそうだというのは次のツイート12からです。
@NachoSoto @jspahrsummers Didn't make it for WWDC, but we plan on having standard funcs for wrapping and releasing errors into an Either.
このツイートをした Joe Groff さんは Apple の Swift チームのメンバーです。
本投稿では、 Either とは何か、 Swift に Either が追加されるとしたらどういうものになるか、どういう形が望ましいか考えてみたことを書きます。(英語でも簡単にまとめました。)
Eitherとは
まず初めに、 Either とは何かを簡単に説明します。
Either とはその名の通り「どちらか」を表す型です。例えば、 Either<Int, String> は Int か String の「どちらか」を表す型となります。
そんなものが何の役に立つのかと思うかもしれません。 一番わかりやすい用途は、ある関数(やメソッド)が成功したら結果を、失敗したらエラー情報を返すような場合です。 そのような場合には、 Either が結果かエラー情報かの「どちらか」を表す値となります。
実装
Either の実装は色々なバリエーションが考えられますが、概ね次のような感じになります。
enum Either<T, U> {
case Left(T)
case Right(U)
}
サードパーティの実装としては、 robrix/Either や Swiftz および Swiftx の Either 、 @gfx さんによる国産の gfx/Swift-SwiftEither などがあります。
使い方
例として、得点が文字列で書かれていて、それをパースして整数に変換するケースを考えてみましょう(テキストフィールドにユーザーが入力した場合や、 CSV ファイルや XML ファイルに書かれている場合など、よくあるシチュエーションです)。得点は、最低点を 0 点、最高点を 100 点とします。
String で与えられた得点を Int の得点に変換する関数 toScore を実装します。ただし、変換に成功した場合には Int を、失敗した場合にはエラー情報を返すようにしたいです。 "abc" のような文字列が与えられた場合や、たとえ整数としてパースできても 0 から 100 の範囲外であればエラーとします。
Stringでエラーメッセージを表す場合
ます、シンプルに失敗した場合は String でエラーメッセージを返すことにしてみましょう。エラーメッセージは、整数としてパースできたけど範囲外だった場合と、そもそも整数としてパースできなかった場合に分けるものとします。
なお、 Either をエラー処理に使う場合、 Left でエラーを、 Right で値を表すことが多いので、 toScore の戻り値の型は Either<String, Int> となります。以下、本投稿ではすべてエラーを前に書くスタイルで統一しますが、 Swift に追加される Either がそうなるかはわかりません。
func toScore(string: String) -> Either<String, Int> {
switch Int(string) {
case .Some(let score) where 0...100 ~= score: // 成功
return .Right(score)
case .Some(let score): // 失敗:整数としてパースできたが範囲外の場合
return .Left("Out of range: \(score)")
case .None: // 失敗:整数としてパースできなかった場合
return .Left("Failed to parse as an integer: \"\(string)\"")
}
}
上記のコードの簡単な説明は下記です。
-
Int(string)でStringをIntに変換 -
Intのinit(_ text: String)は Failable Initializer なのでパースに失敗するとnilを返す(戻り値はIntではなくInt?) -
switch文のパターンマッチで場合分けして値を返す
これを使うコードは次のようになります。
let result: Either<String, Int> = toScore("42")
switch result {
case .Left(let errorMessage):
// エラー処理
print(errorMessage)
case .Right(let score):
// score を使った処理
...
}
ErrorTypeでエラーを表す場合
エラーメッセージを直接 Either で扱うのは良いアイデアではありません。
例えば、エラーメッセージを多言語化する場合を考えて下さい。 toScore の中にすべてのエラーメッセージを実装しようとすると toScore がどんどん肥大化してしまいます。 toScore のロジックを実装する人と、エラーメッセージを実装する人は別の人かもしれません。役割を明確化し、 toScore はエラーメッセージそのものを返すよりもエラーの原因を特定できるだけの情報を返すに留めておいた方が良いでしょう。エラーメッセージを実装する人は、その情報を使って実装すれば良いのです。
せっかく Swift 2.0 で ErrorType が追加されたので、ここではエラー情報を ErrorType として実装してみましょう。
まず、 toScore が整数のパースには成功したものの、範囲外になってしまった場合を考えます。この場合は、範囲外になってしまったことに加えて、一体その整数が何だったのかをエラー情報として保持したいです。
次に、そもそも与えられた文字列を整数としてパースできなかった場合には、パースに失敗したことに加えて、その文字列が何だったのかをエラー情報として保持したいです。
これをまとめると、次のような実装になります。
enum ScoreFormatError: ErrorType {
case OutOfRange(Int) // 整数としてパースできたが範囲外の場合
case NotInteger(String) // 整数としてパースできなかった場合
}
これを使うと toScore は次のようになります。
func toScore(string: String) -> Either<ScoreFormatError, Int> {
switch Int(string) {
case .Some(let score) where 0...100 ~= score: // 成功
return .Right(score)
case .Some(let score): // 失敗:整数としてパースできたが範囲外の場合
return .Left(.OutOfRange(score))
case .None: // 失敗:整数としてパースできなかった場合
return .Left(.NotInteger(string))
}
}
toScore を使うコードは次のようになります。
let result: Either<ScoreFormatError, Int> = toScore("42")
switch result {
case .Left(.OutOfRange(let score)):
// このように score の値でエラーメッセージを出し分けるのも簡単
if score < 0 {
print("値が小さすぎます: \(score)")
} else {
print("値が大きすぎます: \(score)")
}
case .Left(.NotInteger(let string)):
print("整数ではありません: \(string)")
case .Right(let score):
// score を使った処理
...
}
Optionalとの比較
Either と似た型として Optional があります。
Optional はこれまで、 Swift の標準ライブラリで成功か失敗かを表す型として使われてきました。例えば、 Failable Initializer の戻り値は Optional で、失敗したときに nil を返します。
let number: Int? = Int("42") // パースに失敗したら nil
Either を成功か失敗かを表す型として使う場合、 Optional の違いは エラー情報を含むことができるかどうか です。
例えば、 SwiftyJSON は Optional を多用していますが、次のようなケースで nil が返って来たときにその原因を知ることはできませんでした。
let json: JSON = JSON(data: data)
let bar: Int? = json["foo"]["bar"].int
bar が nil だった場合、次のような理由が考えられます。
- JSON に
"foo"という要素がなかった -
"foo"はあったけど"bar"がなかった -
"bar"もあったがIntではなかった - そもそも JSON のパースに失敗した
しかし、 nil から上記のいずれであるかを知ることはできません。失敗の原因がわかればデバッグがはかどりますし、原因によって処理を変えることもできます(ユーザーに提示するメッセージを出し分けるとか、 "bar" がなかった場合だけデフォルト値に置き換えるとか)。
String を Int に変換するというように失敗の原因が明らかなときは Optional で十分ですが、様々な原因が考えられる場合には Either であってほしいところです。
モナドとしてのEither
Optional 同様、 Either も モナド として実装されることが多いです。つまり、 map や flatMap を持つということです。
Either が モナド であることで Optional 同様 map や flatMap を使って便利に取り回すことができます。
let score1: Either<ScoreFormatError, Int> = toScore("42")
let score2: Either<ScoreFormatError, Int> = toScore("97")
// Either に包んだまま score1 の得点の二乗を計算
let square: Either<ScoreFormatError, Int> = score1.map { $0 * $0 }
// Either に包んだまま score1 と score2 の平均を計算
let mean: Either<ScoreFormatError, Int> = score1.flatMap { s1 in score2.flatMap { s2 in .Right((s1 + s2) / 2) } }
Either の map や flatMap は次のように実装できます3。あと、 モナド の条件である、値を包むだけの イニシャライザ も併せて実装します。
extension Either {
init(_ value: U) {
self = .Right(value)
}
func map<V>(transform: U -> V) -> Either<T, V> {
return flatMap { .Right(transform($0)) }
}
func flatMap<V>(transform: U -> Either<T, V>) -> Either<T, V> {
switch self {
case .Left(let left):
return .Left(left)
case .Right(let right):
return transform(right)
}
}
}
do-try-catchとの比較
Eitherとdo-try-catchの類似性
Either ができることは do-try-catch とほぼ同等です。
例として、 toScore を do-try-catch で実装してみましょう。
func toScore(string: String) throws -> Int {
switch Int(string) {
case .Some(let score) where 0...100 ~= score: // 成功
return score
case .Some(let score): // 失敗:整数としてパースできたが範囲外の場合
throw ScoreFormatError.OutOfRange(score)
case .None: // 失敗:整数としてパースできなかった場合
throw ScoreFormatError.NotInteger(string)
}
}
do {
let score = try toScore("42")
// score を使った処理
...
} catch ScoreFormatError.OutOfRange(let score) {
if score < 0 {
print("値が小さすぎます: \(score)")
} else {
print("値が大きすぎます: \(score)")
}
} catch ScoreFormatError.NotInteger(let string) {
print("整数ではありません: \(string)")
}
Either 版のコードと同じことができているのがわかると思います。
現状のSwiftにおけるdo-try-catchの問題
ただし、将来的には解消されると思いますが(というかされてほしい)、 Xcode 7 beta 6 の時点では、トップレベルスコープ以外で上記のコードを実行すると、 catch が網羅的でないとして次のようなコンパイルエラーになります。
Errors thrown from here are not handled because the enclosing catch is not exhaustive
Java と違って Swift の throws 句にはエラーの型を記述することができないので、関数やメソッドが throw し得るエラーの型を特定することができません。初めは推論されるのかと思っていましたが、どうも今のところそういうわけではなさそうです。もしこのままの仕様であれば次のようなコードとなり、タイプセーフを重視している Swift が台無しなのでどうにかしてほしいところです。個人的には、面倒でも throws 句に型が記述できるようになるのがいいと思います(推論はあってもいいけど、実装を変更しただけでシグネチャが勝手に変わるのは微妙)。
do {
let score = try toScore("42")
// score を使った処理
...
} catch ScoreFormatError.OutOfRange(let score) {
...
} catch ScoreFormatError.NotInteger(let string) {
...
} catch {
// ここには絶対に到達しないが毎回これを書かないといけない
fatalError("Never reaches here.")
}
最後の catch 節は本来不要なのにコンパイルを通すために必須というのは面倒ですし、誤ってその他のエラーを throw してしまうとクラッシュしてしまいます。これまでのタイプセーフな Swift を踏襲して、コンパイル時に型の制約でそのような事態を防げることが望まれます。
Eitherとdo-try-catchとの使い分け
話がそれましたが、 Either と do-try-catch で表現できることが変わらなくても、 Either で失敗を扱うことで次のような利点があります。
- 失敗を値として取り回せるので、その場で処理をする必要がない
-
mapやflatMapを使ったり、後述の便利な構文を使うことができる(!一つでエラーを無視するとか) - 関数型のスタイルと相性がいい
一方で、 Either にはできないこともあります。それは、 副作用 を持つ(戻り値を返すだけでなく状態を書き換える)関数やメソッドにエラー処理を強制することができないことです。
func setScore(score: Int) throws {
switch score {
case 0...100:
self.score = score // 副作用
default:
throw OutOfRangeError(range: 0...100, value: score)
}
}
上記の setScore を使う場合にはエラー処理を強制できますが、 Either ではそれができません。無理やり Either で書くなら
func setScore(score: Int) -> Either<OutOfRangeError, Void>
となりますが、これなら Optional の方がスマートです。
func setScore(score: Int) -> OutOfRangeError?
いずれにせよ、戻り値を無視されれば(もしくは忘れていたら)エラーが無視されてしまいます。
ちなみに Either は元々関数型言語で使われていたものですが、純粋関数型言語ではそもそも副作用が存在しないのでそのような問題も存在しません。
以上をまとめると、次のような指針で Either と do-try-catch を使い分けるのが良いでしょう。
- 副作用がない関数やメソッドでは
Eitherを使う - 副作用がある関数やメソッドでは do-try-catch を使う
do-try-catchのEitherへの変換
このように、 do-try-catch ではなく Either でエラー処理をしたいニーズがあるため、冒頭の Groff さんのツイートにつながります。 Groff さんが言っているのは、 throws 付きの関数(やメソッド)をコールするときに、それを do-try-catch で扱うのではなく、代わりに Either でラップして受け取れるようにする関数を標準ライブラリに追加するという話だと思います。
仮にこの関数を either 関数 と呼ぶことにしましょう。 either 関数は次のように実装できます。
func either<T, U>(@autoclosure f: () throws -> U) -> Either<T, U> {
do {
return .Right(try f())
} catch let error as T {
return .Left(error)
}
}
この関数のキモは @autoclosure です。 @autoclosure によって、この関数は次のように try 演算子を使った式を囲んで使うことができます。
let result: Either<ScoreFormatError, Int> = either(try toScore("42"))
なお、前述の throws の型が特定されない問題があるので実際には次のように書かなければコンパイルエラーになってしまいます。
func either<T, U>(@autoclosure f: () throws -> U) -> Either<T, U> {
do {
return .Right(try f())
} catch let error as T {
return .Left(error)
} catch {
fatalError("Never reaches here.")
}
}
どんな仕様が採用されるか
一般的には前節で見たようなものを Either と言いますが、 Swift の Either がそのような仕様になるとは限りません。例えば、 Swift では一般的に ラムダ式 や 匿名関数 などと呼ばれるものを クロージャ と呼んでおり、他の言語における クロージャ と意味合いが違います。
そのように、同じ名前のものが言語間で少しずつ異なるということはよくあることです。前節で挙げた Either の実装の他に、 Swift の Either として採用されそうなものを挙げてみます。
Result
現在 Swift で Either 的なものとしてデファクトになっているのが antitypical/Result です。
前節で Either の一番わかりやすい用途は、ある関数(やメソッド)が成功したら結果を、失敗したらエラー情報を返すような場合だと書きましたが、それならはじめから片方はエラー情報にしてしまうおうと考えたのが Result です。 Result は成功か失敗かに特化した Either と考えることができます。
Result の実装をシンプルにすると次のような感じです。
enum Result<T, Error: ErrorType> {
case Success(T)
case Failure(Error)
}
これを使って toScore を実装すると次のようになります。 Left が Failure に、 Right が Success に変わっただけです。成功か失敗かのために Either を使う分には Result と変わりません。
func toScore(string: String) -> Result<Int, ScoreFormatError> {
switch Int(string) {
case .Some(let score) where 0...100 ~= score: // 成功
return .Success(score)
case .Some(let score): // 失敗:整数としてパースできたが範囲外の場合
return .Failure(.OutOfRange(score))
case .None: // 失敗:整数としてパースできなかった場合
return .Failure(.NotInteger(string))
}
}
この toScore を使うコードも Either のときとほとんど変わらないので省略します。
Groff さんのツイートでは Either についてエラー処理の文脈で言及しているので、 Swift の Either は Result のようにエラー処理専用の型となる可能性も考えられます。
また、 Swift が大きな影響を受けた言語 Rust の標準ライブラリには Result があり、 Swift でも Either ではなく Result という名前で追加される可能性もあると思います。
タプル & Optional
Either 代わりのお手軽な代替手段として用いられることが多いのがタプルと Optional の組み合わせです。 Either<T, U> を (T?, U?) または (left: T?, right: U?) として表すのです。 T が値を持つときは U を nil に、 U が値を持つときは T を nil にすることで Either のようなことができます。 Go 言語ではよくこれと似たような方法が採られます。
この方法だと toScore は次のように実装できます。
func toScore(string: String) -> (ScoreFormatError?, Int?) {
switch Int(string) {
case .Some(let score) where 0...100 ~= score: // 成功
return (nil, score)
case .Some(let score): // 失敗:整数としてパースできたが範囲外の場合
return (.OutOfRange(score), nil)
case .None: // 失敗:整数としてパースできなかった場合
return (.NotInteger(string), nil)
}
}
これなら 新しく Swift の標準ライブラリに Either を追加しなくても、戻り値として (T?, U?) を持った either 関数を追加するだけで済ませられます。
しかし、僕は この方法は望ましくない と思います。この方法には致命的な欠陥があります。それは、 (T?, U?) は型として T か U のどちらかだけが値を持つことを保証しない ということです。 T も U も値を持つかもしれませんし、両方 nil かもしれません。関数の仕様として、必ずどちらかが値を持ちどちらかは nil と決めてしまうことはできても、それはタイプセーフな Swift の考え方とはマッチしません。
例えば、上記の toScore を使おうとすると次のようなコードを書かなければなりません。
let result: (ScoreFormatError?, Int?) = toScore("42")
switch result {
case (let error, nil):
// エラー処理
...
case (nil, let score):
// score を使った処理
...
default:
// ここには絶対に到達しないが毎回これを書かないといけない
fatalError("Never reaches here.")
}
最後の default 節は不要なのにコンパイルを通すために必須ですし、万が一どちらも値が入ってしまったりどちらも nil になった場合はクラッシュしてしまいます。コンパイル時に型の制約でそのような事態を防げる Either や Result の方が優れていると言えるでしょう。
Failable
Swift の Either は Result をさらにシンプルにし、 @kanotomo さんの "Swift 2でエラーをthrowする関数からEitherを作る" の中で述べられている Failable のような実装になるかもしれません。
enum Failable<T> {
case Success(T)
case Failure(ErrorType)
}
この Failable は Failure が取れる値を汎用的な ErrorType とすることで、 Result から第二の型パラメータを消したものです。型パラメータが一つになるので見た目にはすっきりします。
func toScore(string: String) -> Failable<Int> {
... // 実装は同じなので省略
}
しかし、エラーの型情報が消えてしまっているため、利用時には面倒ですしタイプセーフではありません。"タプル & Optional" の項で述べたのと同じ状況です。
let result: Failable<Int> = toScore("42")
switch result {
case .Failure(ScoreFormatError.OutOfRange(let score)):
print("値が範囲外です: \(score)")
case .Failure(ScoreFormatError.NotInteger(let string)):
print("整数ではありません: \(string)")
case .Success(let score):
// score を使った処理
...
default:
// ここには絶対に到達しないが毎回これを書かないといけない
fatalError("Never reaches here.")
}
標準ライブラリに Failable 相当の Either を追加しなければならず、しかもタイプセーフでもないのであれば、このような Either にする理由がないように見えます。
しかし、このような仕様になるかもしれないと僕が思うのは、 "現状のSwiftにおけるdo-try-catchの問題" で述べたように、現状では throws がついた関数(やメソッド)はすべて汎用的な ErrorType を throw することになっているようだからです。
関数が throw するものが汎用的な ErrorType なら、 either 関数が返す Either のエラーの型を決定することができません。もしこのままの仕様が続くようであれば、 either 関数の戻り値として採用される Either が Failable 相当のものになる可能性もあるでしょう。
そのようなことになるのは望ましくない です。 throw されるエラーの型が推論されるようにするか、 throws 句で型を記述できる仕様にしてほしいところです。
なお、本項で述べた Failable のようなものを一般的に Failable と呼ぶかはわかりません。僕も "Swift 2.0 の try, catch ファーストインプレッション" の中で Failable を作っていますがこれは Result 相当のものでしたし、 Failable と名付けたのは Optional と語感や形容詞であることをそろえたかったのと、 Failable Initializer という用語から考えても適切な名前だろうと考えたからです。 @kanotomo さんがどういう意図で Failable と名付けたのかや、一般的に Failable と呼ばれるものがあるのかはわかりません。
Union Type (直和型)
Swift 以外の言語に目を向ければ、 Either と似たものに Union Type があります。
Union Type とは多くの場合 T|U のように書かれる型で、 T か U のどちらかを表します。そう書くとまさに Either と同じように思えますが次のような違いがあります。
まず、 Union Type では T や U が T|U の派生型として扱われるため、 T や U のインスタンスを直接 T|U 型の変数に代入することができます。
let union: Int|String = 42 // OK
// let either: Either<Int, String> = 42 // コンパイルエラー
let either: Either<Int, String> = .Left(42) // Either で包む必要あり
一方で、その裏返しとして Int|Int は Int と同じものになってしまい Either<Int, Int> でなら表せるものが表せません4。
let union: Int|Int = 42 // 左と右の Int を区別できない
let either1: Either<Int, Int> = .Left(42) // 左の Int
let either2: Either<Int, Int> = .Right(42) // 右の Int
Swift には Union Type はありませんが、 TypeScript や Ceylon 、 Flow で採用されるなど最近のプログラミング言語のトレンドなので、次期 Swift で追加されるかもしれません。 その場合、 either 関数の戻り値の型は Union Type T|U で表される可能性が高いでしょう。
Uniton Type を使えば、 toScore は次のように書けます。戻り値を Either で包む必要がないのでとてもシンプルです。
func toScore(string: String) -> ScoreFormatError|Int {
switch Int(string) {
case .Some(let score) where 0...100 ~= score: // 成功
return score
case .Some(let score): // 失敗:整数としてパースできたが範囲外の場合
return .OutOfRange(score)
case .None: // 失敗:整数としてパースできなかった場合
return .NotInteger(string)
}
}
使うときは次のようになります。こちらもシンプルです。
let result: ScoreFormatError|Int = toScore("42")
switch result {
case .OutOfRange(let score):
print("値が範囲外です: \(score)")
case .NotInteger(let string):
print("整数ではありません: \(string)")
case let score as Int:
// score を使った処理
...
}
これはタイプセーフですし、シンプルという点では素晴らしいです。ただ、個人的には Swift の文化とはなじまない ように思います。 Optional との整合性なども考えると、やはり enum で Either を実装する方が Swift らしく、一貫性があるでしょう。
ただし、次節で述べるように、 Either を enum で実装しながらも Union Type っぽく振る舞わせるのはありだと思います。
Either の言語レベルでの特別なサポート
ただ Either を導入するだけならライブラリを入れなくていいというだけで、使い勝手は大きくは変わりません。標準ライブラリでサポートするからには Optional のように便利な手段を色々と提供してほしいものです。
型の記述のシンタックスシュガー
正直、 Either<Foo, Bar> などと記述するのは面倒です。 Optional<Foo> を Foo? 、 Array<Foo> を [Foo] 、 Dictionary<Foo, Bar> を [Foo: Bar] と記述できるのと同じように、 Either にも型を記述する際の便利なシンタックスシュガーがほしいです。
その候補として、僕は Union Type の項で見たような Foo|Bar という記法で書けるのがいいんじゃないかと思います。シンプルでわかりやすく書きやすいですし、意味的にも Union Type との共通点があります。他の言語で Union Type が記述できてることからも、コンパイラがコードをパースする上での問題も少なそうです。
これは、 Either を Union Type で表そうということではなくて、 Optional の実体が Optional<T> という enum だけど T? の形で書けるように、 Either も実体は Either<T, U> という enum だけど T|U の形で書けるようにしようということです。
なお、この場合 Foo|Bar|Baz が Either<Either<Foo, Bar>, Baz> となるか Either<Foo, Either<Bar, Baz>> となるか(つまり、 | が左結合か右結合か)は検討の余地があります。 ErrorA|ErrorB|Int のような使い方が多いのであれば前者の方が便利ですし、 Error|Int|String のような使い方が多いのであれば後者の方が便利です( map, flatMap する場合や、後述の Either Chaining, Forced Unwrapping, ?? 演算子などを使う場合を考えて)。 Either のエラーを後ろに書くスタイルの場合、議論がすべて反転するので気を付けて下さい。いずれにせよ (Foo|Bar)|Baz のように結合の優先順位を指定できる必要があるでしょう。
(追記: 2015-12-08 ) Swift がオープンソース化されたので、試しに Int|String のような構文が使えるようにコンパイラを改造してみました。
暗黙の型変換
Optional は enum でありながら次のような代入が可能です。
let a: Int? = 42
これは本来であれば次のように書かなければならないはずです。
let a: Int? = .Some(42)
Int と Int? は別の型であり、本来的には直接代入することはできません。これができるということは、 Int から Int? への 暗黙の型変換 が行われたということです。シンタックスシュガーを使わず次のように書けばその違和感に気付くと思います。
let a: Optional<Int> = 42 // Optional<Int> に Int を代入
暗黙の型変換は様々な問題を引き起こしやすく、 Swift では暗黙の型変換は厳しく取り締まられています。初期の Swift から Swift 1.0 になる過程で暗黙の型変換を定義する文法は削除されましたし、 Float から Double など、他の多くの言語で認められているようなものでも暗黙の型変換が認められていません。さらに、 Swift 1.2 では String と NSString などの間の暗黙の型変換も禁じられました。
そのような中で Optional の例外扱いは特別なものです。さすがに Optional で暗黙の型変換を禁止するとコードを書くのが面倒すぎるということなのでしょう。
Either は Optional の兄弟のようなものなので、 Either においても、暗黙の型変換が適用されれば使い勝手が良くなるのではないかと思います(ただ、前述のように暗黙の型変換は問題を起こしやすいので、本当にそれがいいかはわかりません)。
let a: Either<Int, String> = 42 // 明示的に Either に包む必要がない
これと Foo|Bar のようなシンタックスシュガーが組み合わされば、 Either は enum でありながら相当 Union Type っぽく振る舞えることになります。
let a: Int|String = 42 // Union Type っぽい
Subtyping
Optional にはさらに驚くべき特別扱いがあります。なんと、 Int は Int? の派生型として扱われるのです。これはどのようにすれば確かめられるでしょう?
次のように書いても、前項で書いたように暗黙の型変換だと考えることができます。
let a: Int? = 42
しかし、次のコードがコンパイルできることは暗黙の型変換で説明することはできません。
class A {
func foo() -> Int? {
return nil
}
}
class B: A {
// override して戻り値の型を Int に変更
override func foo() -> Int {
return 42
}
}
foo の戻り値の型に注目して下さい。元々 Int? だったのが、オーバーライドされたときに Int に変更されています。
一般的に、オブジェクト指向言語では継承してメソッドをオーバーライドするときに時に戻り値の型を狭める(派生型にする)ことができます。これは、 Animal を返すメソッドをオーバーライドして Cat を返すようにしても、 Cat は Animal なのでスーパークラスとしてインスタンスを扱っているときにも常に Animal を返していることになり何も困らないからです。
class A {
func bar() -> Animal {
return Animal()
}
}
class B: A {
// override して戻り値の型を Cat に変更
override func bar() -> Cat {
return Cat()
}
}
let b: B = B()
let a: A = b
// b の bar() が呼ばれ Cat が返るが Animal として振る舞える
let animal: Animal = a.bar()
前述の foo のケースも、 Int? を Animal に、 Int を Cat に読みかえれば全く同じ関係になっているのがわかると思います。つまり、 Swift では Int は Int? の派生型なのです。
Optional は次のような enum です。
enum Optional<T> {
case None
case Some(T)
}
しかし、同じような enum を作っても Optional のような派生関係は生まれませんし、それを実現する構文も今のところありません。 Int が Int? になるのは、 Optional だけに与えられた特別な仕様なのです。
これは、前項で挙げた暗黙の型変換が Optional だけに許されている理由の説明にもなります。つまり、 暗黙の型変換が行われているのではなく、型に派生関係があるので代入できているだけと解釈することができるのです。
同じことを Either にも許すと、次のようなことができるようになります(前述の Foo|Bar スタイルのシンタックスシュガーを使って書きます)。
class A {
func foo() -> Int|String {
return "abc"
}
}
class B: A {
// override して戻り値の型を Int に変更
override func foo() -> Int {
return 42
}
}
ここまで来ると Union Type にしか見えません。
Either Chaining
Optional には Optional Chaining という便利な構文があります。 Swift では Optional を使ってタイプセーフなコードが書ける代わりにすぐに Optional だらけになってしまうので、このような便利な構文は重宝します。
let foo: Foo? = ...
let baz: Baz? = foo?.bar?.baz // foo か bar か baz のどれかが nil なら nil
Either でも同じことが言えます。次のように Optional Chaining のような構文があれば便利です。
let foo: Error|Foo = ...
let baz: Error|Baz = foo?.bar?.baz // foo か bar か baz のどれかが Error なら Error
Forced Unwrapping
エラーを無視するために Forced Unwrapping も欲しい所です。
let foo: Foo? = ...
let qux: Qux = foo!.qux // foo が nil でないことを確信、 nil ならクラッシュ
let foo: Error|Foo = ...
let qux: Qux = foo!.qux // foo が nil でないことを確信、 nil ならクラッシュ
Either Binding
Optional Binding も Optional だけに許されている便利構文です。
let fooOrNil: Foo? = ...
if let foo = fooOrNil {
// foo を使った処理
...
}
これも Either でできれば便利です。
let fooOrError: Error|Foo = ...
if let foo = fooOrError {
// foo を使った処理
...
}
?? 演算子
Optional には ?? という便利な演算子があります。
let fooOrNil: Foo? = ...
let foo: Foo = fooOrNil ?? Foo() // nil のときは Foo()
Either だと次のようになります。
let fooOrError: Error|Foo = ...
let foo: Foo = fooOrNil ?? Foo() // Error のときは Foo()
なお、 ?? はただの演算子なので自分で実装することもできます。そのため、 gfx/Swift-SwiftEither や antitypical/Result では実装されています。
init|
Swift の イニシャライザ は、インスタンスの構築に失敗したときに nil を返せるように Failable Initilizer という仕組みがあります。次の init? は Int の イニシャライザ ですが、その戻り値の型は Int ではなく Int? です。
struct Int {
// Int としてのパースに失敗したら nil を return する
init?(_ text:String) {
...
}
}
イニシャライザ の失敗を考えるなら、当然エラー情報を返したいこともあるでしょう。現状では イニシャライザ の戻り値の型はその型そのものか Optional だけですが、 Either も返す手段ができればうれしいです。そのときには init? と一貫性があるように init| というキーワードを使うのがいいんじゃないでしょうか。
init|(foo: Foo, bar: Bar) {
// 失敗した場合はエラー情報を Either に渡して return する
...
}
ただ、 Either のエラーの型を推論させるのでなければ、何かしらの方法で指定する方法が必要になります。
try| 演算子
try? 演算子を使えば、 do-try-catch を使わなくても簡単に throws 付の関数を扱うことができます。これは、 ErrorType の詳細はどうでも良くて、ただ失敗したことを知りたいだけの場合には重宝します。
例えば、前述の
func toScore(string: String) throws -> Int
の場合には
do {
let score = try toScore("42")
// score を使った処理
...
} catch {
// エラー処理
...
}
となるところを、
if let score = try? toScore("42") {
// score を使った処理
...
} else {
// エラー処理
...
}
というように書けます。これだけだと大した違いが内容に思えますが、
let scoreOrNil: Int? = try? toScore("42")
として、その場でエラー処理をせずに値を取り回したり、式の途中で使って Optional Chaining するなどできます。
同じことは Either にも言えます。そもそも Groff さんが述べているような either 関数が欲しいのは、 "Eitherとdo-try-catchの使い分け" でも述べたように do-try-catch ではなく Either としてエラー処理がしたいからです。 Either であれば、値として取り回せる利便性を享受しながら、 Optional のようにエラー情報を捨ててしまうこともなく理想的です。
either 関数でもいいのですが、せっかくだったら try? 演算子と一貫性を持たせて try| 演算子とするのが望ましいと思います。
let scoreOrError: ScoreFormatError|Int = try| toScore("42")
まとめ
Swift に Either が追加されそうなので、それがどのようなものになるか、どのようなものであれば望ましいかを考えてみました。
Swift の Optional が強力なのは、タイプセーフであることに加えて、その反面の面倒さを軽減するための様々な言語サポートが提供されているからです。 Either にも同様の言語サポートを提供することで使い勝手の良いものとなるでしょう。そして、それらは自然で( Foo|Bar など)、これまでのものと一貫性のあるもの( init| や try| など)であってほしいです。
-
Joe Groff: https://twitter.com/jckarter/status/608002431578865664 ↩
-
ちなみに、このツイートを見つけたきっかけは Argo の issue を眺めていたことです。 ↩
-
本当は
@noescapeがあった方がいいですが、本題とは関係ないので省略します。あと、mapの実装で.Right(transform($0))となっている部分を本当はEither(transform($0))としたかったのですが、コンパイラがクラッシュしてしまったので諦めました。 ↩ -
これと同種の話として、 Swift の
OptionalはInt??でOptional<Optional<Int>>という二重のOptionalを表せますが、 Kotlin の Nullable Type ではInt??はInt?と同じになってしまうという話があります。 ↩