コンピュテーション式の導入
from Implementation: Working with Errors
LT;DR
F# の コンピュテーション式 を用いることで、Result でラップしていることを意識せずに記述できる
これにより、bind や map 、深くネストした Result 生成関数を扱う必要がなくなる
コンピュテーション式は、F# で モナド や アプリカティブ を表現するためのツール
ちょっと解像度が低い ?? radish-miyazaki.icon
Result を無視した実装からの移行コストも軽減できる
ただし、Result のコンピュテーション式は公式で提供されていないので、独自実装が必要
コンピュテーション式の導入#66b06bdf75d04f0000b45b37
リストを検証する際には、Result<..., ...> list を Result<... list, ...> を変換する必要が出てくる
この場合、以下のような prepend 関数を用いた sequence で実現する
prepend: 1 つの Result と Result のリストを受け取り、 リストの先頭要素に追加(cons)する関数
code:fsharp
let prepend firstR restR =
match firstR, restR with
| Ok first, Ok rest -> Ok (first::rest)
| Error err1, Ok _ -> Error err1
| Ok _, Error err2 -> Error err2
| Error err1, Error err2 -> Error err1
sequence: prepend をリストの各要素に適用する
code:fsharp
let sequence aListOfResults =
let initialiValue = Ok []
List.foldBack prepend aListofResults initialValue
上の実装では単一のエラーしか保存できないが、アプリカティブ を用いることで複数エラーに対応できる
https://fsharpforfunandprofit.com/posts/elevated-world-3/#validation-using-applicative-style
一般的に、prepend と sequence は Result モジュールで定義する
code:fsharp
module Result =
let prepend firstR restR = ...
let sequence aListOfResults = ...
hr.icon
なぜコンピュテーション式が必要か
Result を生成する関数の連鎖(bind と errorMap、map) のコードが複雑になると、色々と手間が増える
e.g. Result.bind や map を何回も呼び出す、深くネストした Result 生成関数を扱う...
複雑さ は隠蔽したい
F# の場合、コンピュテーション式 を用いれば解決できる
しかし、Result の コンピュテーション式は公式で提供されていない
https://learn.microsoft.com/ja-jp/dotnet/fsharp/language-reference/computation-expressions#built-in-computation-expressions
そのため、独自実装が必要
https://learn.microsoft.com/ja-jp/dotnet/fsharp/language-reference/computation-expressions#creating-a-new-type-of-computation-expression
今回は モナド として扱うため、Bind と Return のみを実装する
code:fsharp
type ResultBuilder() =
member this.Return(x) = Ok x
member this.Bind(x, f) = Result.bind f x
let result = ResultBuilder()
どう変わるか?
導入前
code:fsharp
let placeOrder unvalidatedOrder =
unvalidatedOrder
|> Result.bind priceOrderAdapted
|> Result.map acknowledgeOrder
|> Result.map createEvents
Result を返す validateOrder の出力と priceOrder の入力を接続している
導入後
code:fsharp
let placeOrder unvalidatedOrder =
result {
let! validatedOrder =
validateOrder unvalidatedOrder
|> Result.mapError PlaceOrderError.Validation
let! pricedOrder =
priceOrder validatedOrder
|> Result.mapError PlaceOrderError.Pricing
let acknowledgeOrder =
acknowledgeOrder pricedOrder
let events =
createEvents pricedOrder acknowledgementOption
return events
}
全体を result コンピュテーション式でラップし、bind を let! に置き換える
それ以外は通常の構文を利用する(Result.map は不要)
これにより、validateOrder や priceOrderの出力を、Result を意識することなく直接扱えるように
構文の詳細
let!: 結果を アンラップ して内部値を取得する
https://learn.microsoft.com/ja-jp/dotnet/fsharp/language-reference/computation-expressions#let
たとえば validatedOrder は pricedOrder に直接渡せる通常の値
エラー型はブロック全体で統一されている必要があるため、Result.mapError で 型を持ち上げる
return: 値を ラップ して呼び出し元に返す
https://learn.microsoft.com/ja-jp/dotnet/fsharp/language-reference/computation-expressions#return
コンピュテーション式の合成
コンピュテーション式 の魅力の 1 つは 合成可能 である点
e.g.
以下のような 2 つのコンピュテーション式があったとする
code:fsharp
let validatedOrder input = result {
let! validatedOrder = ...
return validatedOrder
}
let priceOrder input = result {
let! pricedOrder = ...
return pricedOrder
}
これらは、より大きな result 式のなかで利用できる
code:fsharp
let placeOrder unvalidatedOrder = result {
let! validatedOrder = validatedOrder unvalidatedOrder
let! pricedOrder = priceOrder validatedOrder
...
}
既存の関数を置き換える
Result を無視した「検証のステップ」の実装に Result を適用する
最初のステップの実装#66a79ab075d04f00007cc284
すべてのヘルパ関数が Result を返すようになると、このコードは機能しなくなる
e.g. OrderId.create が Result<OrderId, string> を返す
しかし、result コンピュテーション式を用いれば、実装の構造をほとんど変えずに適用できる
code:fsharp
let validateOrder: ValidateOrder =
fun checkProductCodeExists checkAddressExists unvalidatedOrder ->
result {
let! orderId =
unvalidatedOrder.OrderId
|> OrderId.create
|> Result.mapError ValidationError
let! customerInfo =
unvalidatedOrder.CustomerInfo
|> toCustomerInfo
let! shippingAddress =
unvalidatedOrder.ShippingAddress
|> toAddress checkAddressExists
let! billingAddress = ...
let! lines = ...
// すべてのフィールドの準備ができたら、それを元にレコード型を作成して返す
let validatedOrder: ValidatedOrder = {
OrderId = orderId
CustomerInfo = customerInfo
ShippingAddress = shippingAddress
BillingAddress = billingAddress
OrderLines = lines
}
return validatedOrder
}
変更箇所
コード全体を result コンピュテーションでラップ
let を let! に
Result.mapError でエラーを共通の 選択型 に 型を持ち上げる
ただし、toCustomerInfo と toAddress のエラー型は ValidationError であると仮定して、mapError を呼び出していない
Result のリストを扱う
Result を無視して明細行をチェックする場合、List.map を呼び出すだけで良かった
最初のステップの実装#66a8ae6f75d04f0000c06b24
toValidatedOrderLine ヘルパ関数が Result を返すようになると、このコードは機能しなくなる
リストの Result(Result<ValidatedOrderLine list, ValidationError> )が欲しいが、得られるのは Result のリスト( Result<ValidatedOrder, ValidationError> list)
code:fsharp
let validateOrder unvalidatedOrder =
...
let lines = // Result のリスト
unvalidatedOrder.Lines
|> List.map (toValidatedOrderLine checkAddressExists)
let validatedOrder = {
...
Lines = lines // 欲しいのはリストの Result なのでコンパイルエラー
}
解決するには、Result のリスト を リストの Result に変換する関数が必要
実装方針
Result のリストをループし、1 つでも失敗があれば全体の結果はエラーとなる
すべて成功すれば、全体の結果は成功値のリストとなる
どう実装する?
F# のリストは 連結リスト であることを意識する
1 つの Result と Result のリストを受け取り、 リストの先頭要素に追加(cons)する関数を実装する
両方 が Ok であれば、中身を cons し、できたリストを再び Result でラップする
それ以外の場合は、エラーを返す
code:fsharp
// val prepend:
// firstR: Result<'a,'b> -> restR: Result<'a list,'b> -> Result<'a list,'b>
let prepend firstR restR =
match firstR, restR with
| Ok first, Ok rest -> Ok (first::rest)
| Error err1, Ok _ -> Error err1
| Ok _, Error err2 -> Error err2
| Error err1, Error err2 -> Error err1
prepend を使うと、Result のリスト を リストの Result に変換する関数 を実装できる
code:fsharp
module Result =
// val sequence: aListOfResults: Result<'a,'b> list -> Result<'a list,'b>
let sequence aListOfResults =
let initialValue = Ok []
List.foldBack prepend aListOfResults initialValue
List.foldBack で最後から順に反復処理し、リストの Result に cons していく
bind や map 、 mapError などと同様に Result モジュールに実装する
呼び出し方
成功時
code:fsharp
let IntOrError = Result<int, string>
let listOfSuccesses: IntOrError list = Ok 1; Ok 2
let successResult =
Result.sequence listOfSuccesses // Ok 1; 2
失敗時
code:fsharp
let listOfErrors: IntOrError list = Error "bad"; Error "terrible"
let errorResult =
Result.sequence listOfSuccesses // Error "bad"
すべてのエラーを保存したい場合は アプリカティブ を用いれば実現できる
https://fsharpforfunandprofit.com/posts/elevated-world-3/#validation
code:fsharp
// val apply: fResult: Result<('a -> 'b)> -> xResult: Result<'a> -> Result<'b>
let apply fResult xResult =
match fResult,xResult with
| Success f, Success x ->
Success (f x)
| Failure errs, Success x ->
Failure errs
| Success f, Failure errs ->
Failure errs
| Failure errs1, Failure errs2 ->
Failure (List.concat errs1; errs2)
validateOrder に適用する
code:fsharp
let validateOrder: ValidateOrder =
fun checkProductCodeExists checkAddressExists unvalidatedOrder ->
result {
let! orderId = ...
let! customerInfo = ...
let! pricedOrder = ...
let acknowledgeOrder = ...
let! lines =
unvalidatedOrder.Lines
|> List.map (toValidatedOrderLine checkAddressExists)
|> Result.sequence
let validatedOrder: ValidatedOrder = {
...
Lines = lines
}
return validatedOrder
}
パフォーマンスが気になる場合、List.map と Result.sequence をまとめれば良い
一般的に traverse と呼ばれる関数で実現できる
Haskell でも Traversable(データ構造を走査する) のメソッドとして提供している radish-miyazaki.icon
code:haskell
traverse :: (Traversable t, Applicative f) => (a -> f b) -> t a -> f (t b)
以下のような型シグネチャの関数を作成するイメージ
code:fsharp
(UnvalidatedOrderLine -> Result<ValidatedOrderLine, ValidationError>)
-> UnvalidatedOrderLine list
-> Result<ValidatedOrderLine list, ...>
https://fsharpforfunandprofit.com/posts/elevated-world-4/