はじめに
Swiftの界隈では**Phantom Type(幽霊型)**という、型に状態をエンコードする手法がある。この記事ではまずこのPhantom Typeを用いた手法について解説し、この方法よりも著者がよいと思うLIO
というデータ構造と、それを用いたコードを紹介する。なお、この記事で紹介したコードは下記のリポジトリに置かれている。
この記事を読んで疑問に思った点や改善するべき点を見つけた場合は、気軽にコメントなどで教えて欲しい。
Phantom Typeと状態のエンコード
Phantom Type型とは、Haskellなどの界隈では歴史のある言葉であるが、Swiftでは次のような用途で用いられる1。次のコードはSwift で Phantom Type (幽霊型)より引用した。
class Status{}
class NotReady: Status{}
class Ready: Status{}
class Something<T: Status> {
static func createInstance() -> Something<NotReady> {
return Something<NotReady>()
}
func readify() -> Something<Ready> {
return Something<Ready>()
}
}
extension Something where T: Ready {
func shout() {
print("phantom types are awesome!")
}
}
これは、クラスSomething
の型パラメータT
に状態を表す型を取り、Something<NotReady>
であるインスタンスに対してshout
を呼び出すとコンパイルエラーになるというものである。
let a = Something.createInstance()
a.shout() // コンパイルエラー!
このように、状態を型パラメータにエンコードして管理しようという試みである。
SwiftにおけるPhantom Typeの課題
この実装には次のような課題があると思われる。
-
Something<NotReady>
とSomething<Ready>
のインスタンスを間違えたら、実行時エラーが発生する- この例では
NotReady
→Ready
にしか遷移がないが、たとえばReady
→NotReady
と遷移したにも関わらずReady
のインスタンスを用いれば恐らく実行時エラーとなる
- この例では
- 状態が変化するたびにクラスのインスタンスを作成するので、負荷が高まる恐れがある
これらを解決するために、より汎用的なデータ構造LIO
を考える。
データ構造LIO
LIO
は次のようなデータ構造である。
struct LIO<P, Q, A> {
let run: () -> A
init(_ r: @escaping () -> A) {
self.run = r
}
init(_ a: A) {
self.run = { () -> A in return a }
}
func flatMap<B, R>(_ f: @escaping (A) -> LIO<Q, R, B>) -> LIO<P, R, B> {
return LIO<P, R, B> {
() -> B in f(self.run()).run()
}
}
func map<B>(_ f: @escaping (A) -> B) -> LIO<P, Q, B> {
return LIO<P, Q, B> {
() -> B in f(self.run())
}
}
}
LIO
は3つの型パラメータを持つ。型パラメータP
はrun
を実行する前の状態を表す型であり、型パラメータQ
はrun
を実行した後の状態を表す型である。また、最後の型パラメータA
はrun
の結果の型である。
flatMap
とmap
は、ある二つのLIO
を合成するための関数である。これらの関数がどういった役に立つかは、次の利用例で明らかになる。
LIO
の利用例
いま、ファイルのようにロック状態と非ロック状態があるものを考える。ロック状態であれば書き込みや読み込みができ、そうでなければ読み書きはできないとする。
まず、ロックと非ロックの二つの状態を表わす適当なデータ型を作成する。
struct Lock {
init() {}
}
struct Unlock {
init() {}
}
そして、ロックを行う関数とロックを解除する関数を用意する。この関数はそれぞれ次のように状態を変更する。
- ロックを行う関数は非ロック状態でのみ実行でき、実行すると状態がロック状態に変化する
- ロックを解除する関数はロック状態でのみ実行でき、実行すると状態が非ロック状態に変化する
これを実装する次のようになる。
func lock() -> LIO<Unlock, Lock, Void> {
// 複雑なロック処理があるものとする
return LIO()
}
func unlock() -> LIO<Lock, Unlock, Void> {
// 複雑なロック解除処理があるものとする
return LIO()
}
このように、LIO<P, Q, A>
は状態P
からQ
へ変化すると考えることができるので、このようになる。同じように読み込みと書き込みが行えるものとする。
func put(_ str: String) -> LIO<Lock, Lock, Void> {
// 標準出力に表示する
return LIO(print(str))
}
func get() -> LIO<Lock, Lock, String> {
// 標準入力から受け取る
return LIO(readLine()!)
}
これらput
とget
はロック状態のときのみに実行でき、実行してもロック状態に変化がないので、このようになる。
そして、これらを次のように合成して使うことができる。
let a = lock().flatMap { _ in
put("piyo").flatMap { _ in
put("mofu").flatMap { _ in
get().flatMap { input in
put(input).flatMap { _ in
unlock().map { _ in return }}}}}}
let _ = a.run()
これの結果は次のようになる2。
piyo
mofu
> this is an input
this is an input
たとえば次のようなコードがコンパイルエラーとなる。
// 二重にロックしようとした
let ng1 = lock().flatMap { _ in
lock().map { _ in return }}
// ロックを解除した後に書き込んだ
let ng2 = unlock().flatMap { _ in
put("piyo").map { _ in return }}
これで一見よさそうに見える。
さらなる改良
これで一応できることはできるが、flatMap
が長いので専用の演算子を定義して短くする。
infix operator >>=
func >>=<P, Q, R, A, B>(_ ma: LIO<P, Q, A>, _ f: @escaping (A) -> LIO<Q, R, B>) -> LIO<P, R, B> {
return ma.flatMap(f)
}
二項演算子>>=
はflatMap
を呼び出すだけである。このような演算子を用意することで、次のように書ける。
let b = lock() >>= { _ in
put("piyo") >>= { _ in
put("mofu") >>= { _ in
get() >>= { input in
put(input) >>= { _ in
unlock().map { _ in return }}}}}}
let _ = b.run()
ただしこれでもやや表記が多い。よく見るとput
を使う部分はLIO<P, Q, A>
のA
がVoid
であるので引数を使っておらず、次のput
やget
を直ちに起動している。そこで、次のような二項演算子も定義する。
precedencegroup Right {
associativity: right
}
infix operator >>>: Right
func >>><P, Q, R, A, B>(_ ma: LIO<P, Q, A>, _ mb: LIO<Q, R, B>) -> LIO<P, R, B> {
return ma >>= { _ in return mb }
}
これを用いると、次のように書ける。
let c = lock() >>>
put("piyo") >>>
put("mofu") >>>
get() >>= { input in
put(input) >>> unlock()
}
let _ = c.run()
この結果は先ほどと同じように次のようになる。
piyo
mofu
> this is an input
this is an input
Phantom Type vs LIO
既存のPhantom Typeを用いた手法は、次のような二つの課題があった。
Something<NotReady>
とSomething<Ready>
のインスタンスを間違えたら、実行時エラーが発生する- 状態が変化するたびにクラスのインスタンスを作成するので、負荷が高まる恐れがある
まず、最初の課題についてはLIO
のインスタンスに対しての操作はflatMap
またはmap
、および最後のrun
しかなく、特別なインスタンスを作成しない。よって、インスタンスを間違えたりすることはないと考えられる。
また、後者については、LIO
は非常にシンプルなデータ構造であるのでインスタンスの作成コストは複雑なクラスと比べて少ないと考えられる。ただし、flatMap
の際にrun
をクロージャーで拡張していくので、Phantom Typeを用いた手法と比べて効率が確実に良いかどうかは分からない。これについてはベンチマークなどで検証する必要があると考えられる。
まとめ
このように、LIO
というデータ構造を用いて既存のPhantom Typeを用いた状態の管理に比べてよさそうな方法を提案した。Phantom Typeと呼ばれる手法に比べて全てがよいかどうかはまだ分からないが、ひとつの方法として考えてもよいかもしれない。
この手法はもともとHaskellの世界で生れたものなので、Swiftの世界にHaskellで生れたものを持っていけたらよいかもしれない。