C#のResult、Railway Oriented ProgrammingライブラリをResultBoxにリネームしました
株式会社ジェイテックジャパン CTOの高丘 @tomohisaです。この記事では、C#で開発を行うにあたり、Result
型やRailway Oriented Programming
簡単に使うことのできるOSSライブラリ、SingleValueResultsを先週公開したのですが、リファクタリングの過程で基本的な設計変更があり、リネームして ResultBoxesとして公開したのでご紹介します。
C#のプロジェクトに以下のnugetパッケージをインストールすることによって使用可能です。
dotnet add package ResultBoxes
基本コンセプトの1つ - [型の合成] CombineValue
元々の SingleValueResult
を作る時に、個人的な使用パターンとして作ったのは、関数を遷移していくにあたり、複数のクラスや情報をいろいろな関数から取得して保持して、その保持した情報を組み合わせて後の処理を実行したいというコンセプトでした。
イメージとしては以下のようなC#コードです。
public IEvent CreateStudentCommand(Guid classId, string Name)
{
var classData = ClassFinder(classId);
if (classData is Null) {
throw new ClassNotFoundException(classId);
}
var teacherData = TeacherFinder(classData.TeacherId);
if (teacherData is Null) {
throw new TeacherNotFoundException(classData.TeacherId);
}
return new StudentCreated()
{
ClassId = classId,
ClassName = classData.Name,
TeacherId = classData.TeacherId,
TeacherName = teacherData.TeacherName,
Name = Name
};
}
簡単なプログラムですが、ポイントは通常の宣言型プログラムで記述する時に、一時変数に残すのをできるだけ無くしたいと考えて、Result型を使って、以下のように書きたいと考えています。
public ResultBox<IEvent> CreateStudentCommand(Guid classId, string Name)
=> ClassFinderWithResult(classId)
.CombineValue(classData => TeacherFinderWithResult(classData.TeacherId))
.Railway((classData, teacherData) => new StudentCreated(){
ClassId = classId,
ClassName = classData.Name,
TeacherId = teacherData.TeacherId,
TeacherName = teacherData.TeacherName,
Name = Name
});
上記はResultBoxを使って書く方法の概念コードですが、実際には以下の要素も考慮する必要があります。
-
ClassFinderWithResult
とTeacherFinderWithResult
がResult型に対応している必要がある、していない場合は、Result型の変換コードを書く必要がある - 通常
Finder
クラスは非同期なので、非同期処理に対応するコードが入ってくる(ResultBoxは非同期処理に対応しています。await などがいろいろ入ってくるので若干慣れが必要なコードになります。)
処理の流れとしては以下のようになります。
- まず
ClassFinderWithResult
でクラスIDからクラスを取得しにいく。なかったらExceptionを入れて返すので次の処理以降は飛ばされる - 次に
classData
が取れた時だけTeacherFinderWithResult
を実行する。classData
が存在する時だけなので、nullチェックは不要 -
teacherData
が取得成功したら次に行く、失敗もしくは存在しない場合はそのエラーを入れて返すので次の処理以降は飛ばされる -
TeacherFinderWithResult
の結果をCombineValue
に送るが、CombineValue
は受け取ったデータに新しくできたデータを追加した型を作成するので、次の処理にはclassData
と、teacherData
を両方渡す - Railwayで次の
(classData, teacherData) =>
に行くのはclassData
と、teacherData
が両方入っている場合で、最初のパラメーターはclassData、2つ目のパラメーターはteacherDataとなる。 - 成功した値なのでnullチェックなしでデータを使ってイベントを作成して、リターンする
これがRailway Oriented Programming
の特徴となります。文章で書くと長いですが、エラーだと次の正常系の処理は行われないという制約が入っているため、正常系の処理の中で、nullチェックをする必要がないのが大きな利点となります。
5番の処理で CombineValue
で合成された型を受け取っていますがこの順番がclassData
、teacherData
となっているのが順番に依存しているのでバグを生み出す可能性は確かにあります。複雑なデータや同じ型を複数取るときなどは中間型をRecordで作成して、ClassAndTeacher
型のような形でデータを作り、CombineValue
を使わずに各処理の最後で中間クラスをりたーするようにしてRailway
で次に渡すことによって、シンプルなRailway Oriented Programmingとすることができます。
個人的には2つのデータが取れるといっても通常型が違い、その型はIDEでも見えるので、CombineValue
を使ってシンプルに型を合成する機能をこれから多く使っていくのではないかと考えています。(使ってみて、他の人がどれだけ理解できるかにより、どれくらい使うかを決めていきたいと考えています。)
コンセプト図は以下のようになります。
最初のライブラリでも上記の機能が実装されていました。ただ後から考えてコンセプト的に変更が必要と考えたため、変更しました。
複数の値と例外をどのようにデータで表現するか
実際には多くの細かな機能があるのでシンプルに表現しますが、旧ライブラリは以下のように複数データを表現していました。
public record SingleValueResult<T>(T? Value, Exception? Exception);
public record TwoValuesResult<T1, T2>(T1? Value, T2? Value, Exception? Exception);
public record ThreeValuesResult<T1, T2, T3>(T1? Value1, T2? Value2, T3 Value3, Exception? Exception);
// 以下 FourValueResult, FiveValueResultとつづく
このように定義することにより、CombineValue
の機能を実行できたのですが、それぞれの型ごとに多くのif やswitchを書く必要があり、ライブラリのコードが多くなってしまっていました。
そんな中、先週のTsKaigi というTypeScriptのカンファレンスを見ていると、多くの登壇でResult型、関数型プログラミング、Railway Oriented Programmingについて扱われていました。
特にどの部分ということはないのですが、自分のコードと他の方のコードを見て、以下のように変えられるのではないかと考えて変更してみたところ、全体的にいろいろシンプルに描けるようになってきました。
public record SingleValueResult<T>(T? Value, Exception? Exception);
public record TwoValues<T1, T2>(T1 Value, T2 Value);
public record ThreeValues<T1, T2, T3>(T1 Value1, T2 Value2, T3 Value3);
// 以下 FourValue, FiveValueとつづく
// 以前のTwoValuesResultは以下のように表現する
public record SingleValueResult<TwoValues<T1, T2>>(TwoValues<T1, T2>? Values, Exception? Exception);
大きな変更点はResultクラスに複数の値のResultを定義するのをやめて、Valueクラスの表現として、Result型ではない、TwoValues
, ThreeValues
を作ったことです。
そして、TwoValues
, ThreeValues
に関してはnullを許容しないため、必ず値が入るように設計されています。
SingleValueResult
の中ではT?
となっているので、TwoValues
, ThreeValues
の場合は、すべてのデータがなく、nullか、すべてのデータがあり、not nullかという形で現状が正常系なのかエラー系なのかを表現できるようになります。
そうした時にSingleValueResult<TwoValues<T1, T2>>
という名前が1つの値という名前で2つの値を持っているのでおかしくなるため、名前変更を決定しました。
名前を変更する
ライブラリの基本の名前をつけるのは簡単ではありません。今回は以下の条件を考慮して名前を考えました。
-
Result
という単体の名前は多くのライブラリで使われていて、名前のコンフリクトが起きやすいため避ける(usingやnamespaceの追記で調整できるものの、調整はできるだけしたくない。以前使っていた外部ライブラリで名前が被って調整が面倒だった) - 1つの型に複数の値が入っているかもしれないという意味をつけたい
- C#の値型、参照型の両方をデータとして返せるため、Valueという名前をつけることにより、値型(プリミティブ型)もしくはstructに限定される印象を与えたくない
- C#、もしくは他の有名ライブラリと被らない
- googleで検索、Githubで検索した時に見つけやすい。
- ライブラリ感があり、語呂が良い
- 長すぎない
- etc...
それでいろいろ考えたところ、
ResultBox
という名前に決まりました。
以下のようなコードとなります。
public record ResultBox<T>(T? Value, Exception? Exception);
public record TwoValues<T1, T2>(T1 Value, T2 Value);
public record ThreeValues<T1, T2, T3>(T1 Value1, T2 Value2, T3 Value3);
// 以下 FourValue, FiveValueとつづく
// 以前のTwoValuesResultは以下のように表現する
public record ResultBox<TwoValues<T1, T2>>(TwoValues<T1, T2>? Values, Exception? Exception);
短くて、『箱』に複数の値型、参照型のデータを入れて運べるなどのいみがよく表れていると感じ、気に入っています。
リポジトリは以下のものです。
また、使い方などをこのブログで説明していきたいと思います。
結論
今になって考えてみると、Result型とTwoValuesResultが別になるといろいろ複雑になってくるのがよくわかります。
C#はExtensionメソッドの定義ができるので、ResultBox<TwoValues<T1, T2>>
ときにだけできる処理などを追加することができるので、ユーザーとして使いやすいシンプルな記述をするためのメソッドを作ることができました。
型をシンプルに定義して、それぞれの型ごとにできる機能を定義してそれを使うことにより、多くのコードを削減できたのが利点の一つです。
以下のコードのスクリーンショットは、ライブラリ内のコードですが、何度もパターンマッチをしてエラーと正常と分岐していたコードが Handle
メソッドを定義して、正常系の時の処理だけを定義できるようにしたりしたことにより、かなり効率的に変更されたのがわかると思います。
関数型プログラミングの利点としては、理解ができて記述した時にコードがシンプルで無駄のないものになることですね。ただ、理解度が浅い時にいきなり正解のコードを書くのが難しいという問題があるので、どのように他の開発者も理解して便利に使っていくように教える方法についても考えていきたいと思います。
Discussion