LoginSignup
43
37

More than 5 years have passed since last update.

はじめに

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>のインスタンスを間違えたら、実行時エラーが発生する
    • この例ではNotReadyReadyにしか遷移がないが、たとえばReadyNotReadyと遷移したにも関わらずReadyのインスタンスを用いれば恐らく実行時エラーとなる
  • 状態が変化するたびにクラスのインスタンスを作成するので、負荷が高まる恐れがある

これらを解決するために、より汎用的なデータ構造LIOを考える。

データ構造LIO

LIOは次のようなデータ構造である。

LIO.swift
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つの型パラメータを持つ。型パラメータPrunを実行する前の状態を表す型であり、型パラメータQrunを実行した後の状態を表す型である。また、最後の型パラメータArunの結果の型である。
flatMapmapは、ある二つのLIOを合成するための関数である。これらの関数がどういった役に立つかは、次の利用例で明らかになる。

LIOの利用例

いま、ファイルのようにロック状態と非ロック状態があるものを考える。ロック状態であれば書き込みや読み込みができ、そうでなければ読み書きはできないとする。
まず、ロックと非ロックの二つの状態を表わす適当なデータ型を作成する。

State.swift
struct Lock {
    init() {}
}

struct Unlock {
    init() {}
}

そして、ロックを行う関数とロックを解除する関数を用意する。この関数はそれぞれ次のように状態を変更する。

  • ロックを行う関数は非ロック状態でのみ実行でき、実行すると状態がロック状態に変化する
  • ロックを解除する関数はロック状態でのみ実行でき、実行すると状態が非ロック状態に変化する

これを実装する次のようになる。

main.swift
func lock() -> LIO<Unlock, Lock, Void> {
    // 複雑なロック処理があるものとする
    return LIO()
}

func unlock() -> LIO<Lock, Unlock, Void> {
    // 複雑なロック解除処理があるものとする
    return LIO()
}

このように、LIO<P, Q, A>は状態PからQへ変化すると考えることができるので、このようになる。同じように読み込みと書き込みが行えるものとする。

main.swift
func put(_ str: String) -> LIO<Lock, Lock, Void> {
    // 標準出力に表示する
    return LIO(print(str))
}

func get() -> LIO<Lock, Lock, String> {
    // 標準入力から受け取る
    return LIO(readLine()!)
}

これらputgetはロック状態のときのみに実行でき、実行してもロック状態に変化がないので、このようになる。
そして、これらを次のように合成して使うことができる。

main.swift
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が長いので専用の演算子を定義して短くする。

LIO.swift
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を呼び出すだけである。このような演算子を用意することで、次のように書ける。

main.swift
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>AVoidであるので引数を使っておらず、次のputgetを直ちに起動している。そこで、次のような二項演算子も定義する。

LIO.swift
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 }
}

これを用いると、次のように書ける。

main.swift
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で生れたものを持っていけたらよいかもしれない。

参考文献


  1. このように書いたように、Haskellなどの文脈で用いられるPhantom TypeとSwiftの文脈で用いられるPhantom Typeの間には差があるように感じられるが、ここでは微妙な定義論は行わず両方ともPhantom Typeと呼ぶ。 

  2. ただし、>が付いている行は標準入力で与えた文字列である。 

43
37
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
43
37