はじめに
@NonchalantさんのFactoryProviderというSwiftのテスト支援ライブラリーを見て、もしかしたらデータの作成をもうちょっと面白くできるのではないかと考えて、彼のライブラリーをStateモナドとHListを利用して改造してみたのでそれをここに示す。この記事ではHListとStateモナドについて軽く解説し、どのようにこれらを利用しているかについて説明する。なお、この記事の完全なソースコードは下記のリポジトリにある。
この記事を読んで分かないことや質問などがある場合には、気軽にコメントなどで教えて欲しい。
使い方
作成したライブラリーの詳しい説明する前に、これがどのように使えるのかについて説明する。たとえば次のようにテスト用のデータを作ることができる。
let a =
String.provide() >>>
Bool.provide() >>>
Optional<Int>.provide() >>>
end()
let (b, _) = a.run(0)
print(b.head, b.tail.head, b.tail.tail.head as Any)
これを実行すると次のように表示される。
0 false Optional(2)
たとえば同じInt
を連続で生成しても、違う値となるようになっている。
let c =
Int.provide() >>>
Int.provide() >>>
Int.provide() >>>
end()
let (d, _) = c.run(0)
print(d.head, d.tail.head, d.tail.tail.head)
これは次のようになる。
0 1 2
これがどのようなトリックで作られているかについて解説する。ちなみに再代入可能な値をグローバルに更新している、といったものではない。
状態を用いたテストデータの生成
ここではInt
のprovide
メソッドを例とするが、もしInt.provide
が引数を取ってよいとしたら、これは簡単に実装できそうだ。
protocol Providable {
associatedtype S
static func provide(s: S) -> (Self, S)
}
プロトコルProvidable
はそれに準拠した型の値を、状態を表す型S
を利用して生成する。ただ、結果は自身の型であるSelf
と次の状態を返すようになっている。たとえばProvidable
の状態をInt
、そして生成したい型もInt
で実装すると次のようになる。
extension Int: Providable {
typealias S = Int
static func provide(s: Int) -> (Int, Int) {
return (s, s + 1)
}
}
これを使えば次のように書ける。
let (i1, s1) = Int.provide(s: 0)
let (i2, s2) = Int.provide(s: s1)
let (i3, s3) = Int.provide(s: s2)
print(i1, i2, i3)
すると次のようになる。
0 1 2
このようにすることで、別々となるようなInt
型のデータを作ることができる。
Stateモナド
たしかにこれでもよいといえばよいが、変数がたくさんあって大変である。これを解決させるためにStateモナドを利用する。Stateモナドとは次のようなデータ構造である。
public struct State<S, A> {
public let run : (S) -> (A, S)
public init(_ run : @escaping (S) -> (A, S)) {
self.run = run
}
func flatMap<B>(_ f: @escaping (A) -> State<S, B>) -> State<S, B> {
return State<S, B> {
(s: S) -> (B, S) in
let (a, s1) = self.run(s)
return f(a).run(s1)
}
}
func map<B>(_ f: @escaping (A) -> B) -> State<S, B> {
return State<S, B> {
(s: S) -> (B, S) in
let (a, s1) = self.run(s)
return (f(a), s1)
}
}
}
構造体State
の第1型パラメータS
は状態の型であり、そして第2型パラメータA
は生成される結果の型である。これを利用して、プロトコルProvidable
を次のように書きかえることができる。
public protocol Providable {
associatedtype S
static func provide() -> State<S, Self>
}
つまり、メソッドprovide
は型S
の状態を利用してプロトコルを実装した型を返すので、さきほどの引数を取る実装とそれほど変っていない。同じようにInt
で実装すると次のようになる。
extension Int: Providable {
public typealias S = Int
public static func provide() -> State<Int, Int> {
return State<Int, Int>{
(s: Int) -> (Int, Int) in (s, s + 1)
}
}
}
provide
メソッドが状態を取るかわりに、構造体State
のラムダ式1として状態が引数で渡されて結果と次の状態を返すという構造は変っていない。これを利用して次のように書くことができる。
let ((i1, i2, i3), _) =
Int.provide().flatMap { i1 in
Int.provide().flatMap { i2 in
Int.provide().map { i3 in (i1, i2, i3) }
}
}.run(0)
print(i1, i2, i3)
0 1 2
これで最初にあった変数がたくさん必要となる問題は解決した。
HListと>>>
演算子
関数がネストして大変なことになっている。そこでHListを利用してこれを解決する。HListとは次のようなプロトコルである。
public protocol HList { }
public struct HNil: HList {
init() { }
}
public struct HCons<H, T: HList>: HList {
public let head: H
public let tail: T
public init(_ h: H, _ t: T) {
self.head = h
self.tail = t
}
}
プロトコルHList
は2つの構造体HNil
とHCons
を持つ。これは、端的に言えば次のような特徴を持つ。
- 一般のリストとは違い、どのような型のデータも挿入することができる
- どの型がリスト上のどの位置(インデックス)にあるかを型レベルで記憶している
たとえばHCons<Int, HCons<String, HCons<Bool, HNil>>>
とは、先頭の値の型がInt
であり、かつ2番目の値の型がString
であり、そして3番目の値の型がBool
であることを示している。これを用いて>>>
演算子を次のように定義する。
precedencegroup Right {
associativity: right
}
infix operator >>>: Right
public func >>><S, A, T: HList>(_ ma: State<S, A>, _ mb: State<S, T>) -> State<S, HCons<A, T>> {
return ma.flatMap {
(a: A) -> State<S, HCons<A, T>> in
mb.map {
(t: T) -> HCons<A, T> in HCons<A, T>(a, t)
}
}
}
public func end<S>() -> State<S, HNil> {
return State<S, HNil> {
s -> (HNil, S) in (HNil(), s)
}
}
>>>
演算子は左側に型A
を作るStateモナドをとり、左側にはなんらかのHListであるT
を作るStateモナドを取り、結果としてHCons<A, T>
という型の値を返すStateモナドを返す。そして関数end
はHNil
を作るStateモナドを返す。これにより、最初の例のような方法でネストを抑えながら次々に値を生成できる。
let c =
Int.provide() >>>
Int.provide() >>>
Int.provide() >>>
end()
let (d, _) = c.run(0)
print(d.head, d.tail.head, d.tail.tail.head)
ちなみに型情報も失われていないので、IDEできちんと表示される。
まとめ
StateモナドとHListを利用することで、ネストが異常な量になるなどといったシンタクッス上の綺麗さも保ちつつ、しかし常に同じ値ではなく値を更新しながら作るといったことができるようになった。たとえば常に同じ値にしたい場合は、状態の型S
をVoid
といった型にしてしまって、状態を使わずに作成することで達成できそうである。
-
ラムダ計算の用語に則るならば、ここでは「ラムダ抽象」が適切ではあるが一般に浸透した言葉としてここでは「ラムダ式」とした。 ↩