はじめに
Swift 5からはResult
という他のプログラム言語では主にEither
としても知られている型が入った。しかしiOSプログラミングをはじめてみると複数のエラーを同時に集めてきてUIに表示するという必要を感じるようになった。そこでNonEmptyList
というデータ構造を使ってエラーを複数蓄積できるようなResult
であるResultNel
を開発した。この記事の前半ではまずこのResultNel
について解説する。
またiOSプログラミングではUIの作成が必要になる。UI用のフレームワークであるSwiftUIを利用して画面を作る場合、View
というプロトコルを適合した型の値がたとえばテキストや画像といった1つのUIパーツを表現しており、これを束ねて画面全体を構成していくことになる。SwiftUIはSwift 5.1から入った_Opaque Result Type_を使って返り値の型を「なんらかのView
」と表現することが多いが、これは少々自由すぎる。たとえば「Text
かImage
のいずれか1つである」といった制約を与えることができないのでSpacer
といった別のViewを返すことも可能なインターフェースとなってしまう。この記事ではCoproduct
とよばれる特殊なデータ構造を利用して「Text
かImage
のいずれか1つを返す」という制約を型レベルにエンコードするためCoproductView
というデータ構造を自作したためそれを解説する。
なお、このResultNel
とCoproductView
を利用したコードの全体(iOSアプリケーション)は次のGitHubリポジトリーで公開されている。
この記事を疑問や改善すべきところなどがある場合には、気軽にコメントやTwitterなどで教えてほしい。
ResultNel
: エラーを蓄積できるResult
ここでは上記のGitHubリポジトリーのコードと同様に、コーヒーを淹れるためのアプリケーションを開発することを考える1。いま次のようなユーザーの入力をバリデーションして、そのあと入力によりあれこれ計算するようなインターフェースとその実装を考える2。
protocol CalculateBoiledWaterAmountService {
func calculate(
coffeeBeansWeight: Double,
firstBoiledWaterAmount: Double,
numberOf6: Int
) -> ResultNel<BoiledWaterAmount, CoffeeError>
}
ResultNel
はちょっと特殊なResult
型であり、次のように定義されている。
public typealias ResultNel<Success, Failure: Error> =
Result<Success, NonEmptyList<Failure>>
NonEmptyList
: サイズ1以上を強制するリスト
成功かエラーかのいずれかを表現するResult
型の失敗側にNonEmptyList
という「絶対にサイズが1以上のリスト」という型を使ってエラーを束ねている。NonEmptyList
は普通のList
(ここではImmutableList
という名前)3を使って次のようになっている。
public enum ImmutableList<A> {
case Nil
indirect case Cons(A, ImmutableList<A>)
func append(_ l: ImmutableList<A>) -> ImmutableList<A> {
switch self {
case .Cons(let h, let t):
return .Cons(h, t.append(l))
case .Nil:
return l
}
}
}
public struct NonEmptyList<A> {
let head: A
let tail: ImmutableList<A>
}
extension NonEmptyList {
init(_ a: A) {
self.head = a
self.tail = .Nil
}
}
NonEmptyList
は(A, ImmutableList<A>)
というタプルに特別な名前を付けたものである。普通のサイズが0(Nil
)となりえるImmutableList
を使ってResult<A, ImmutableList<Failure>>
としてしまうと、Result.failure(.Nil)
というような「Result
のレベルではエラーだが、エラー値がない!」というハンドルしにくい状態が生じる可能性を型レベルで許可してしまう。このような状態は誰も望んでいないのでNonEmptyList
という長さが1以上(Nil
がない)のリストをあえて作り、それをResult
の型パラメーターFailure
側に入れたものがResultNel
4である。
さて、あとで使うのでNonEmptyList
の結合(append)も作っておく。
precedencegroup AssociativityLeft {
associativity: left
}
infix operator ++: AssociativityLeft
func ++<A>(_ nel1: NonEmptyList<A>, _ nel2: NonEmptyList<A>) -> NonEmptyList<A> {
return NonEmptyList(
head: nel1.head,
tail: nel1.tail.append(.Cons(nel2.head, nel2.tail))
)
}
ResultNel
を連結する演算子|+|
さてこれでエラーをいくつもFailure
側に入れておくことができるようになったので、ついでに次のようなユーティリティー演算子を作っておく5。
precedencegroup AssociativityRight {
associativity: right
}
infix operator |+|: AssociativityRight
public func |+|<A, B, E: Error>(
r1: ResultNel<A, E>,
r2: ResultNel<B, E>
) -> ResultNel<(A, B), E> {
switch (r1, r2) {
case (.success(let a), .success(let b)):
return .success((a, b))
case (.success(_), .failure(let eb)):
return .failure(eb)
case (.failure(let ea), .success(_)):
return .failure(ea)
case (.failure(let ea), .failure(let eb)):
return .failure(ea ++ eb)
}
}
こうすることで、|+|
を使って2つのResultNel
をまとめて1つにできる。それを繰り返していけば何個のResultNel
があっても1つにまとめることができる。
let validatedTuple: ResultNel<(Double, (Double, Int)), CoffeeError> =
validateCoffeeBeansWeight(coffeeBeansWeight) |+|
validateFirstBoiledWaterAmount(
wholeBoiledWaterAmount: wholeBoiledWaterAmount,
firstBoiledWaterAmount: firstBoiledWaterAmount
) |+|
validationNumberOf6(numberOf6)
// ..............
private func validateCoffeeBeansWeight(
_ coffeeBeansWeight: Double
) -> ResultNel<Double, CoffeeError>
private func validateFirstBoiledWaterAmount(
wholeBoiledWaterAmount: Double,
firstBoiledWaterAmount: Double
) -> ResultNel<Double, CoffeeError>
private func validationNumberOf6(
_ numberOf6: Int
) -> ResultNel<Int, CoffeeError>
このように個別に1つ1つのバリデーションをつくって、それを適当に|+|
で繋ぐだけで簡単に全てのバリデーション結果を結合することができる。あとはResult.map
か何かを使ってバリューオブジェクトなどプログラムで使いやすい型にしてやればよい。
CoproductView
: 狙った種類のView
から1つを返す
最初に書いたソースコードをもう一度下記に貼る。
protocol CalculateBoiledWaterAmountService {
func calculate(
coffeeBeansWeight: Double,
firstBoiledWaterAmount: Double,
numberOf6: Int
) -> ResultNel<BoiledWaterAmount, CoffeeError>
}
これでCalculateBoiledWaterAmountService
を実装したクラスがこれらの引数を緻密に検査して、成功すればBoiledWaterAmount
型の値を、もし問題があれば1以上の任意の数のCoffeeError
型のエラーを返すようになった。次の関心は、これをどうやってiOSのUIへ変換するかである。SwiftUIでは画面上のテキストやスペースなどを表すデータ型はView
に適合(conform)している。だがView
はプロトコルであるためそのまま返り値の型に書けない。よって次のようなコードはコンパイルが通らない。
func show(result: ResultNel<BoiledWaterAmount, CoffeeError>) -> View {
switch result {
case .success(_):
return Text("success!")
case .failure(_):
return Image("wood-nata")
}
}
返り値のView
をOpaque Result Typeでsome View
としても、Opaque Result Typeはコンパイル時に型が決定しなければならないため、上記のようにswitch
でランタイムの挙動によって返り値が変わるというコードに使うことはできない。このような場合、AnyView
でラップするという手が使える。
func show(result: ResultNel<BoiledWaterAmount, CoffeeError>) -> AnyView {
switch result {
case .success(_):
return AnyView(Text("success!"))
case .failure(_):
return AnyView(Image("wood-nata"))
}
}
このようにすれば型は通るが、関数show
はText
かImage
という2種類のView
のいずれかしか返さないという情報は型にエンコードされない。あえてエンコードしないことでshow
の柔軟さは増すが、場合によっては特定のView
しか返してほしくないという場合もあるだろう。そこで今回はCoproductView
を利用してこのような制約を型として与える。
Coproduct
とは?
これから紹介するCoproductView
に利用されているCoproduct
というデータ構造は、ある意味でHList
と逆のデータ構造である。HList
は型レベルで要素の型とその位置を保存しており、たとえば次のようなHList
の値があるとする。
let hlist: HCons<Int, HCons<String, HCons<Double, HNil>>>
このとき変数hlist
はInt
, String
, Double
の型を全て持っているような値である。つまりタプルに近い構造で、タプル(Int, String, Double)
はこの3つの型の値を全て必要とすることと似ている。一方でCoproduct
でも同じように型レベルリストを作るが、Coproduct
はその中のどれか1つの値を持っていればよい。つまり、HList
がリストやタプルの拡張だとすると、Coproduct
はEither
やResult
を拡張したような概念だと考えていいと思う。ではこのようなCoproduct
を作れれば、Text
やImage
のうち「どれか1つ」を強制するインターフェースを書くこともできるはずである。
CoproductView
の実装
さっそくCoproductView
を定義していく。
protocol CoproductView: View {}
まずはCoproductView
はどれかしら1つのView
を返すことができるのでView
である。そしてHList
のHCons
とHNil
に対応するCCons
とCNil
を次のように定義する。
struct CNil<E: View>: CoproductView {
var body: E
init (_ body: E) {
self.body = body
}
}
enum CCons<H: View, T: CoproductView>: CoproductView {
struct Inl: CoproductView {
let head: H
init(_ head: H) {
self.head = head
}
}
struct Inr: CoproductView {
let tail: T
init(_ tail: T) {
self.tail = tail
}
}
case inl(Inl)
case inr(Inr)
}
CNil
は、HNil
と違ってView
を適合する型E
の値を取っている。これは少なくともどれか1つを保証するためでありCNil
が引数を取らないと0のパターンを許可してしまうためこうしている。
そして厄介なのはCCons
である。こちらはいろいろな型が含まれているが、inl
とinr
のいずれかというEnumであり、それぞれ内部で定義された構造体Inl
, Inr
の値を取る。CCons
の詳しい説明の前の直観として、たとえば次のようなCoproductView
に適合する型の値があるとする。
let coprouctView: CCons<Text, CCons<Spacer, CNil<Image>>> = ???
この例においてcoprouctView
は「Text
かSpacer
かImage
のどれか1つ」を表している。つまり(1)Text
のとき、(2)Spacer
のとき、(3)Image
のときの表現が???
に入りうるが、それが次のようなイメージになる6。
-
Text
のときCCons<Text, CCons<Spacer, CNil<Image>>>.Inl( Text("a") )
-
Spacer
のときCCons<Text, CCons<Spacer, CNil<Image>>>.Inr( CCons<Spacer, CNil<Image>>.Inl( Spacer) ) )
-
Image
のときCCons<Text, CCons<Spacer, CNil<Image>>>.Inr( CCons<Spacer, CNil<Image>>.Inr( CNil<Image>(Image("wood-nata")) ) )
つまり、いまCCons<Text, CCons<Spacer, CNil<Image>>>
があったとき次のようになる。
-
Inl
ならば左側の型パラメーターの値となるため、Text
である - 一方で
Inr
なら右側の型パラメーターであるCCons<Spacer, CNil<Image>>
となる
このようにCCons
で与えられた2つ型の左または右に入っている(In Left or In Right)ことを表現するのがInl
, Inr
である。
ひとまずこれで「どれか1つ」を型レベルのエンコーディングとして与えることができたが、プロトコルCoproductView
はView
であることを要請されている。View
は次のようなプロトコルである。
public protocol View {
associatedtype Body : View
var body: Self.Body { get }
}
あとはどうにかCCons.Inl
, CCons.Inr
などをView
に適合させていけばいい。
CCons.Inl
をView
に適合させる
こちらは比較的やりやすい。なぜならCCons.Inl
は次のようにView
型に適合した型H
の値head
をもっている。
struct Inl: CoproductView {
let head: H
}
したがって次のようなエクステンションを書けばいい。
extension CCons.Inl: View {
typealias Body = H
var body: H {
get {
return head
}
}
}
CCons.Inr
をView
に適合させる
Inr
は少しややこしいが、CCons
は次のようになるはずである。
- 型パラメーター
T
にはCoproductView
として型レベルリストがはいっているが、Inl
があればそれがView
に適合する値を持っている -
CoproductView
の型レベルリストの末尾となりうるCNil
は、必ずView
に適合する値を持っている-
CNil
自体もView
に適合しているため、CNil
の値は常にbody
を呼ぶことができる
-
したがって次のように型T
の値であるtail
のbody
を呼びだしてそれをそのまま返せばよい。
extension CCons.Inr: View {
typealias Body = T.Body
var body: T.Body {
get {
return tail.body
}
}
}
CCons
をView
に適合させる
最後にCCons
そのものをView
に適合させる。
extension CCons: View {
var body: AnyView {
switch self {
case .inl(let left):
return AnyView(left.body)
case .inr(let right):
return AnyView(right.body)
}
}
}
見てわかるように、ここではAnyView
を使ってしまっている。.inl
と.inr
のケースでそれぞれ帰ってくるView
の型が違うためこうする他ない。CCons
をView
として使うのはアプリケーションのMVVMでいうところのViewになり、プログラマーがここを明示的に呼ぶコードを書く必要はないため、ここの型が分からなくなることで大きな問題はないと考えている。
最後に.inl
とか.inr
とかを手で呼ぶのを少しでも楽にするためのコンスタクターを次のように作ってようやく終了である。
extension CCons {
static func apply<H: View, T: CoproductView>(_ head: H) -> CCons<H, T> {
return CCons<H, T>.inl(CCons<H, T>.Inl(head))
}
static func apply<H: View, T: CoproductView>(_ tail: T) -> CCons<H, T> {
return CCons<H, T>.inr(CCons<H, T>.Inr(tail))
}
}
CoproductView
を使ったインターフェース
それでは最初に紹介したCalculateBoiledWaterAmountService
の結果を使ってCoproductView
をつくるようなBoiledWaterAmountPresenter
を作っていく。まずはプロトコルでこのようなインターフェースを与える。
protocol BoiledWaterAmountPresenter {
associatedtype ResultView: CoproductView
func show(result: ResultNel<BoiledWaterAmount, CoffeeError>) -> ResultView
}
そして、これを次のように実装する。
class BoiledWaterAmountPresenterImpl: BoiledWaterAmountPresenter {
typealias ResultView = CCons<Text, CNil<Image>>
func show(result: ResultNel<BoiledWaterAmount, CoffeeError>) -> CCons<Text,
CNil<Image>> {
switch result {
case .success(let boiledWaterAmount):
return CCons<Text, CNil<Image>>
.apply(Text("Boiled water amounts are " + boiledWaterAmount.toString()))
case .failure(_):
return CCons<Text, CNil<Image>>
.apply(CNil(Image("wood_nata")))
}
}
}
このように.success
のときはText
を、そしてエラーのときはImage
を返している。これをエミュレーターで実行すると次のようになる。
セグメンテーションフォールトなども(少なくともいまのところは)起きていない。
まとめ
ResultNel
で複数のエラーを同時にハンドルしつつ、CoproductView
でText
やSpacer
など狙った型だけを生成する関数を型で制約しつつ作れるようになった。Text
などUIコンポーネントは生成にコストがかかるらしいので、あらかじめ生成しておいてそれを使い回すなど工夫は必要かもしれない。そもそもUIを作るうえでは、エラーメッセージは特別な場所に出すといった特定の範囲のコンポーネントだけを操作しても実は意味がないのかもしれない。とはいえ、SwiftではじめてUIを作っていろいろなことが分かって楽しかった。個人的にはCoproductView
の方がおもしろいと思うが、ResultNel
の方が実用的なのかもしれない。
謝辞
このアプリケーションの開発は@7_6_さんにたくさんの助言を頂いた。MVVMすらよく知らなかった(?)筆者をいろいろサポートしてくれたことなど、たくさんの貢献に感謝したい。
-
あくまでも例なので、コーヒーは飲み物くらいの情報さえ知っていればコーヒーをどうやって作るかの知識はほとんど必要ない。 ↩
-
ScalaやHaskellなどの
Eitehr
における慣習と異なり、SwiftのResult
は左側の型パラメーターが成功の型を表す。 ↩ -
List
という名前にしたかったが、SwiftUIが提供するList
と重複するので名前をImmutableList
とした。 ↩ -
ResultNel
の_Nel_とは“Non Empty List”の頭文字である。 ↩ -
Scalazの
Validation
にあわせて、本当は|@|
という演算子にしたかった。おそらく@
は_applicative_のa
から来ているのではないかと思っている。 ↩ -
これは実際には動作しない。
Inl
やInr
のようなEnum内部の構造体のコンストラクターと、CCons
のコンスタクターである.inl
や.inr
をそれぞれ別に呼ばなければ動作はしない。ただ、ただでさえ複雑な型記述がよりややこしくなるし、かつ.inl
はInl
の値しか取らないし.inr
も同様にInr
の値しか取らず、これらを省略してもイメージとしては十分だと考えて簡単のためにこうした。 ↩