11
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

エラーを蓄積できる“ResultNel”と数種類のViewから1つを返す型“CoproductView”を作る

Last updated at Posted at 2020-04-30

はじめに

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」と表現することが多いが、これは少々自由すぎる。たとえば「TextImageのいずれか1つである」といった制約を与えることができないのでSpacerといった別のViewを返すことも可能なインターフェースとなってしまう。この記事ではCoproductとよばれる特殊なデータ構造を利用して「TextImageのいずれか1つを返す」という制約を型レベルにエンコードするためCoproductViewというデータ構造を自作したためそれを解説する。
なお、このResultNelCoproductViewを利用したコードの全体(iOSアプリケーション)は次のGitHubリポジトリーで公開されている。

この記事を疑問や改善すべきところなどがある場合には、気軽にコメントやTwitterなどで教えてほしい。

ResultNel: エラーを蓄積できるResult

ここでは上記のGitHubリポジトリーのコードと同様に、コーヒーを淹れるためのアプリケーションを開発することを考える1。いま次のようなユーザーの入力をバリデーションして、そのあと入力によりあれこれ計算するようなインターフェースとその実装を考える2

CalculateBoiledWaterAmountService.swift
protocol CalculateBoiledWaterAmountService {
    func calculate(
        coffeeBeansWeight: Double,
        firstBoiledWaterAmount: Double,
        numberOf6: Int
    ) -> ResultNel<BoiledWaterAmount, CoffeeError>
}

ResultNelはちょっと特殊なResult型であり、次のように定義されている。

ResultNel.swift
public typealias ResultNel<Success, Failure: Error> =
    Result<Success, NonEmptyList<Failure>>

NonEmptyList: サイズ1以上を強制するリスト

成功かエラーかのいずれかを表現するResult型の失敗側にNonEmptyListという「絶対にサイズが1以上のリスト」という型を使ってエラーを束ねている。NonEmptyListは普通のList(ここではImmutableListという名前)3を使って次のようになっている。

ImmutableList
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
        }
    }
}
NonEmptyList.swift
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側に入れたものがResultNel4である。
さて、あとで使うのでNonEmptyListの結合(append)も作っておく。

Associativity.swift
precedencegroup AssociativityLeft {
    associativity: left
}

NonEmptyList.swift
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

Associativity.swift
precedencegroup AssociativityRight {
    associativity: right
}
ResultNel.swift
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つにまとめることができる。

ValidateInputService.swift
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つを返す

最初に書いたソースコードをもう一度下記に貼る。

CalculateBoiledWaterAmountService.swift
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"))
    }
}

このようにすれば型は通るが、関数showTextImageという2種類のViewのいずれかしか返さないという情報は型にエンコードされない。あえてエンコードしないことでshowの柔軟さは増すが、場合によっては特定のViewしか返してほしくないという場合もあるだろう。そこで今回はCoproductViewを利用してこのような制約を型として与える。

Coproductとは?

これから紹介するCoproductViewに利用されているCoproductというデータ構造は、ある意味でHListのデータ構造である。HListは型レベルで要素の型とその位置を保存しており、たとえば次のようなHListの値があるとする。

let hlist: HCons<Int, HCons<String, HCons<Double, HNil>>>

このとき変数hlistInt, String, Doubleの型を全て持っているような値である。つまりタプルに近い構造で、タプル(Int, String, Double)はこの3つの型の値を全て必要とすることと似ている。一方でCoproductでも同じように型レベルリストを作るが、Coproductはその中のどれか1つの値を持っていればよい。つまり、HListがリストやタプルの拡張だとすると、CoproductEitherResultを拡張したような概念だと考えていいと思う。ではこのようなCoproductを作れれば、TextImageのうち「どれか1つ」を強制するインターフェースを書くこともできるはずである。

CoproductViewの実装

さっそくCoproductViewを定義していく。

CoproductView.swift
protocol CoproductView: View {}

まずはCoproductViewはどれかしら1つのViewを返すことができるのでViewである。そしてHListHConsHNilに対応するCConsCNilを次のように定義する。

CoproductView.swift
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である。こちらはいろいろな型が含まれているが、inlinrのいずれかというEnumであり、それぞれ内部で定義された構造体Inl, Inrの値を取る。CConsの詳しい説明の前の直観として、たとえば次のようなCoproductViewに適合する型の値があるとする。

let coprouctView: CCons<Text, CCons<Spacer, CNil<Image>>> = ???

この例においてcoprouctViewは「TextSpacerImageのどれか1つ」を表している。つまり(1)Textのとき、(2)Spacerのとき、(3)Imageのときの表現が???に入りうるが、それが次のようなイメージになる6

  1. Textのとき

    CCons<Text, CCons<Spacer, CNil<Image>>>.Inl( Text("a") )
    
  2. Spacerのとき

    CCons<Text, CCons<Spacer, CNil<Image>>>.Inr(
        CCons<Spacer, CNil<Image>>.Inl( Spacer) )
    )
    
  3. 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つ」を型レベルのエンコーディングとして与えることができたが、プロトコルCoproductViewViewであることを要請されている。Viewは次のようなプロトコルである。

SwiftUI
public protocol View {
    associatedtype Body : View
    var body: Self.Body { get }
}

あとはどうにかCCons.Inl, CCons.InrなどをViewに適合させていけばいい。

CCons.InlViewに適合させる

こちらは比較的やりやすい。なぜならCCons.Inlは次のようにView型に適合した型Hの値headをもっている。

struct Inl: CoproductView {
    let head: H
}

したがって次のようなエクステンションを書けばいい。

CoproductView.swift
extension CCons.Inl: View {
    typealias Body = H
    
    var body: H {
        get {
            return head
        }
    }
}

CCons.InrViewに適合させる

Inrは少しややこしいが、CConsは次のようになるはずである。

  • 型パラメーターTにはCoproductViewとして型レベルリストがはいっているが、InlがあればそれがViewに適合する値を持っている
  • CoproductViewの型レベルリストの末尾となりうるCNilは、必ずViewに適合する値を持っている
    • CNil自体もViewに適合しているため、CNilの値は常にbodyを呼ぶことができる

したがって次のように型Tの値であるtailbodyを呼びだしてそれをそのまま返せばよい。

CoproductView.swift
extension CCons.Inr: View {
    typealias Body = T.Body
    
    var body: T.Body {
        get {
            return tail.body
        }
    }
}

CConsViewに適合させる

最後にCConsそのものをViewに適合させる。

CoproductView.swift
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の型が違うためこうする他ない。CConsViewとして使うのはアプリケーションのMVVMでいうところのViewになり、プログラマーがここを明示的に呼ぶコードを書く必要はないため、ここの型が分からなくなることで大きな問題はないと考えている。
最後に.inlとか.inrとかを手で呼ぶのを少しでも楽にするためのコンスタクターを次のように作ってようやく終了である。

CoproductView.swift
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を作っていく。まずはプロトコルでこのようなインターフェースを与える。

BoiledWaterAmountPresenter.swift
protocol BoiledWaterAmountPresenter {
    associatedtype ResultView: CoproductView

    func show(result: ResultNel<BoiledWaterAmount, CoffeeError>) -> ResultView
}

そして、これを次のように実装する。

BoiledWaterAmountPresenter.swift
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で複数のエラーを同時にハンドルしつつ、CoproductViewTextSpacerなど狙った型だけを生成する関数を型で制約しつつ作れるようになった。TextなどUIコンポーネントは生成にコストがかかるらしいので、あらかじめ生成しておいてそれを使い回すなど工夫は必要かもしれない。そもそもUIを作るうえでは、エラーメッセージは特別な場所に出すといった特定の範囲のコンポーネントだけを操作しても実は意味がないのかもしれない。とはいえ、SwiftではじめてUIを作っていろいろなことが分かって楽しかった。個人的にはCoproductViewの方がおもしろいと思うが、ResultNelの方が実用的なのかもしれない。

謝辞

このアプリケーションの開発は@7_6_さんにたくさんの助言を頂いた。MVVMすらよく知らなかった(?)筆者をいろいろサポートしてくれたことなど、たくさんの貢献に感謝したい。

  1. あくまでも例なので、コーヒーは飲み物くらいの情報さえ知っていればコーヒーをどうやって作るかの知識はほとんど必要ない。

  2. ScalaやHaskellなどのEitehrにおける慣習と異なり、SwiftのResultは左側の型パラメーターが成功の型を表す。

  3. Listという名前にしたかったが、SwiftUIが提供するListと重複するので名前をImmutableListとした。

  4. ResultNelの_Nel_とは“Non Empty List”の頭文字である。

  5. ScalazValidationにあわせて、本当は|@|という演算子にしたかった。おそらく@は_applicative_のaから来ているのではないかと思っている。

  6. これは実際には動作しない。InlInrのようなEnum内部の構造体のコンストラクターと、CConsのコンスタクターである.inl.inrをそれぞれ別に呼ばなければ動作はしない。ただ、ただでさえ複雑な型記述がよりややこしくなるし、かつ.inlInlの値しか取らないし.inrも同様にInrの値しか取らず、これらを省略してもイメージとしては十分だと考えて簡単のためにこうした。

11
5
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
11
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?