F# や関数型プログラミング全般についてよく聞く意見の一つに、理論と実践のギャップに関する不満があります。 つまり、理論は分かっても、関数型プログラミングの原則を使って実際にアプリケーションを設計・実装するにはどうすればいいのか、ということです。

そこで、ちょっとしたアプリケーションを最初から最後まで設計・実装していく過程をお見せするのが役立つかもしれないと考えました。

これはいわば、注釈付きのライブコーディングセッションのようなものです。ある問題を取り上げ、それをコーディングしていく中で、各段階における私の思考プロセスを説明していきます。 もちろん、私も間違いを犯します。ですから、私がどのようにそれを処理し、手戻りやリファクタリングを行うのかをご覧いただけます。

注意点として、これが本番環境に対応できるコードだと主張しているわけではありません。お見せするコードは、どちらかというと探索的なスケッチのようなもので、 その結果、より重要なコードではやらないような悪いこと(テストをしないなど!)をすることになります。

このシリーズの最初の投稿では、次のようなシンプルな電卓アプリを開発していきます。

Calculator image

私の開発アプローチ

私のソフトウェア開発へのアプローチは折衷的で実用的です。さまざまなテクニックを組み合わせ、トップダウンとボトムアップのアプローチを交互に行うのが好きです。

普段は、要件定義から始めます。要件駆動設計のファンなのです! 理想的には、そのドメインの専門家になることも目指します。

次に、ドメインモデリングに取り組みます。 静的なデータ( DDD用語でいう「集約」 )だけでなく、ドメインイベントに焦点を当てながら( イベントストーミング )、ドメイン駆動設計を行います。

モデリングプロセスの一環として、型ファースト開発を用いて設計のスケッチを作成し、 ドメインのデータ型(「名詞」)とドメインのアクティビティ(「動詞」)の両方を表す型を作成します。

ドメインモデルの最初のドラフトを作成したら、通常は「ボトムアップ」アプローチに切り替え、これまでに定義したモデルを実行する小さなプロトタイプをコーディングします。

この時点で実際のコーディングを行うことは、現実性を確認する役割を果たします。ドメインモデルが実際に理にかなっており、抽象的すぎないことを保証します。 そしてもちろん、多くの場合、要件とドメインモデルに関するさらなる疑問が生じるため、 ステップ1に戻り、洗練とリファクタリングを行い、満足するまでこれを繰り返します。

(もし、大規模なプロジェクトでチームと仕事をしているとしたら、この時点で実際のシステムを段階的に構築し、 ユーザーインターフェース(たとえば、紙のプロトタイプで)にも着手することができます。これらの活動はどちらも、さらに多くの疑問や要件の変更を生み出す可能性が高いため、 プロセス全体がすべてのレベルで循環することになります。)

これが理想的な世界での私のアプローチです。もちろん現実の世界は完璧ではありません。 対処すべき悪い経営、要件の不足、ばかげた締め切りなど、理想的なプロセスを使えることはほとんどありません。

しかし、この例では私がボスなので、結果が気に入らなければ、責めるべきは自分だけです!

はじめに

さあ、始めましょう。まずは何をすべきでしょうか?

通常であれば、要件定義から始めるところですが、電卓のために本当に多くの時間を費やす必要があるのでしょうか?

面倒なので、ここでは「いいえ」とします。代わりに、電卓の仕組みはわかっているという自信を持って、そのまま飛び込んでみましょう。 (後ほどわかりますが、私は間違っていました! 要件をまとめようとするのは良い練習になったはずです。興味深いエッジケースがいくつかあるからです。

それでは、型ファースト設計から始めましょう。

私の設計では、すべてのユースケースは1つの入力と1つの出力を持つ関数です。

この例では、電卓へのパブリックインターフェースを関数としてモデル化する必要があります。関数のシグネチャは次のとおりです。

type Calculate = CalculatorInput -> CalculatorOutput

簡単でしたね! 最初の質問は、他にモデル化する必要があるユースケースがあるかどうかです。 今のところは、ないと考えています。すべての入力を処理する単一のケースから考えていきましょう。

関数の入力と出力の定義

しかし、これで未定義の CalculatorInputCalculatorOutput という2つの新しい型が作成されました (これをF#スクリプトファイルに入力すると、赤い波線が表示されて注意されます)。 これらを定義しておきましょう。

先に進む前に、この関数の入力型と出力型は純粋でクリーンなものになることを明確にしておく必要があります。 ドメインを設計する際には、文字列、プリミティブなデータ型、検証などの面倒な世界を扱うことは決して望ましくありません。

代わりに、通常は、入力時に信頼できない乱雑な世界から素敵な原始的なドメインに変換する検証/変換関数と、 出力時にその逆を行う同様の関数が存在します。

ドメインの入力と出力

では、まず CalculatorInput から見ていきましょう。入力の構造はどうなるでしょうか?

まず、明らかに、キーストローク、またはユーザーの意図を伝えるための何らかの方法が必要です。 しかし、電卓はステートレスなので、状態も渡す必要があります。この状態には、たとえば、これまでにタイプされた数字が含まれます。

出力に関しては、関数は当然、更新された新しい状態を出力する必要があります。

しかし、表示用にフォーマットされた出力を含む構造など、他に必要なものはあるでしょうか? 必要ないと思います。 表示ロジックから分離したいので、 UIに状態を処理させて表示可能なものに変換させます。

エラーはどうでしょうか? 他の投稿 では、エラー処理について多くの時間を割いて説明しました。この場合、エラー処理は必要でしょうか?

この場合は、必要ないと思います。安価なポケット電卓では、エラーはすべてディスプレイに表示されるので、ここではそのアプローチに従います。

関数の新しいバージョンは次のとおりです。

type Calculate = CalculatorInput * CalculatorState -> CalculatorState

CalculatorInput はキーストロークなどを意味し、 CalculatorState は状態です。

この関数を、2つの別々のパラメーター( CalculatorInput -> CalculatorState -> CalculatorState のような形式)ではなく、 タプルCalculatorInput * CalculatorState )を入力として使用して定義していることに注意してください。 両方のパラメーターが常に必要であり、タプルによってそれが明確になるため、このようにしました。たとえば、入力の一部だけを適用することは望ましくありません。

実際、型ファースト設計を行う際には、すべての関数に対してこれを行います。すべての関数は1つの入力と1つの出力を持っています。 これは、後で部分適用を行う可能性がないという意味ではありません。設計段階では、パラメーターを1つだけにするということです。

純粋なドメインの一部ではないもの(構成や接続文字列など)は、この段階では決して表示されないことに注意してください。 ただし、実装時には、もちろん設計を実装する関数に追加されます。

電卓の状態を表す型を定義する

では、CalculatorState について見ていきましょう。現時点で必要なのは、表示する情報を保持する何かだけです。

type Calculate = CalculatorInput * CalculatorState -> CalculatorState 
and CalculatorState = {
    display: CalculatorDisplay
    }

まず、フィールドの値が何に使われるのかを明確にするためのドキュメントとして、 そして、実際にディスプレイが何であるかを後で決めることができるように、CalculatorDisplay という型を定義しました。

では、ディスプレイの型はどうすればよいのでしょうか?float?string?文字のリスト?複数のフィールドを持つレコード?

さて、上で述べたように、エラーを表示する必要があるかもしれないので、string にすることにします。

type Calculate = CalculatorInput * CalculatorState -> CalculatorState 
and CalculatorState = {
    display: CalculatorDisplay
    }
and CalculatorDisplay = string

型定義を繋ぐために and を使用していることに注意してください。なぜでしょうか?

F# は上から下にコンパイルされるので、型を使用する前に定義する必要があります。次のコードはコンパイルされません。

type Calculate = CalculatorInput * CalculatorState -> CalculatorState 
type CalculatorState = {
    display: CalculatorDisplay
    }
type CalculatorDisplay = string

宣言の順序を変更すれば、この問題は解決できますが、 「スケッチ」モードなので、しょっちゅう順序を変更したくはありません。 そのため、新しい宣言を下に追加し、and を使って繋いでいきます。

しかし、最終的な製品コードでは、設計が安定したら、and を使用しないように、これらの型の順序を変更します。 その理由は、and型の循環依存を隠してリファクタリングを妨げる可能性があるからです。

電卓への入力型を定義する

CalculatorInput 型については、電卓のボタンをすべて列挙するだけにします。

// 上記と同じ
and CalculatorInput = 
    | Zero | One | Two | Three | Four 
    | Five | Six | Seven | Eight | Nine
    | DecimalSeparator
    | Add | Subtract | Multiply | Divide
    | Equals | Clear

「なぜ入力を char 型にしないのか?」と言う人もいるかもしれません。しかし、上で説明したように、私のドメインでは理想的なデータのみを扱いたいのです。 このように選択肢を限定することで、予期せぬ入力を処理する必要がなくなります。

また、char ではなく抽象型を使用することの副次的な利点として、DecimalSeparator が "." であると想定されないことが挙げられます。 実際の区切り文字は、最初に現在のカルチャ (System.Globalization.CultureInfo.CurrentCulture) を取得し、 次に CurrentCulture.NumberFormat.CurrencyDecimalSeparator を使用して区切り文字を取得することで得られます。 この実装の詳細を設計から隠すことで、実際に使用される区切り文字を変更しても、コードへの影響を最小限に抑えることができます。

設計の改良: 数字の処理

これで設計の第一段階は完了です。次は、内部処理をいくつか定義してみましょう。

まずは、数字の処理方法について考えてみましょう。

数字キーが押されたら、現在の表示に数字を追加したいとします。それを表す関数型を定義してみましょう。

type UpdateDisplayFromDigit = CalculatorDigit * CalculatorDisplay -> CalculatorDisplay

CalculatorDisplay 型は先ほど定義したものですが、この新しい CalculatorDigit 型は何でしょうか?

明らかに、入力として使用できるすべての数字を表す型が必要です。 AddClear などの他の入力はこの関数には無効です。

type CalculatorDigit = 
    | Zero | One | Two | Three | Four 
    | Five | Six | Seven | Eight | Nine
    | DecimalSeparator

では、次の質問です。どのようにしてこの型の値を取得するのでしょうか?次のように、CalculatorInputCalculatorDigit 型にマッピングする関数が必要なのでしょうか?

let convertInputToDigit (input:CalculatorInput) =
    match input with
        | Zero -> CalculatorDigit.Zero
        | One -> CalculatorDigit.One
        | etc
        | Add -> ???
        | Clear -> ???

多くの場合、これは必要かもしれませんが、このケースではやり過ぎのように思えます。 また、この関数は AddClear などの数字以外の入力をどのように処理するのでしょうか?

そこで、新しい型を直接使用するために、CalculatorInput 型を再定義することにしましょう。

type CalculatorInput = 
    | Digit of CalculatorDigit // 数字
    | Add | Subtract | Multiply | Divide // 加減乗除
    | Equals | Clear // その他の操作

ついでに、他のボタンも分類してみましょう。

Add | Subtract | Multiply | Divide は数学演算に分類します。 Equals | Clear は、適切な言葉がないので、とりあえず「アクション」と呼ぶことにします。

新しい型 CalculatorDigitCalculatorMathOpCalculatorAction を使用した、リファクタリング後の設計の全体像は以下のとおりです。

type Calculate = CalculatorInput * CalculatorState -> CalculatorState 
and CalculatorState = {
    display: CalculatorDisplay
    }
and CalculatorDisplay = string
and CalculatorInput = 
    | Digit of CalculatorDigit
    | Op of CalculatorMathOp
    | Action of CalculatorAction
and CalculatorDigit = 
    | Zero | One | Two | Three | Four 
    | Five | Six | Seven | Eight | Nine
    | DecimalSeparator
and CalculatorMathOp = 
    | Add | Subtract | Multiply | Divide
and CalculatorAction = 
    | Equals | Clear

type UpdateDisplayFromDigit = CalculatorDigit * CalculatorDisplay -> CalculatorDisplay

これは唯一のアプローチではありません。EqualsClear を別々の選択肢として残しておくこともできました。

さて、UpdateDisplayFromDigit をもう一度見てみましょう。他に必要なパラメータはありますか?たとえば、状態の他の部分が必要ですか?

いいえ、他に何も思いつきません。これらの関数を定義するときは、できるだけ最小限にしたいのです。ディスプレイだけが必要なのに、なぜ電卓の状態全体を渡す必要があるのでしょうか?

また、UpdateDisplayFromDigit がエラーを返すことはあるのでしょうか?たとえば、数字を無限に追加することはできません。 許可されていない場合はどうなりますか?また、エラーが発生する可能性のある入力の組み合わせは他にありますか?たとえば、小数点記号だけを入力した場合などはどうなりますか?

この小さなプロジェクトでは、これらのどちらも明示的なエラーを作成せず、代わりに、不正な入力は黙って拒否されると仮定します。 言い換えれば、10 桁入力した後は、他の数字は無視されます。また、最初の小数点記号の後、後続の小数点記号も無視されます。

残念ながら、これらの要件を設計に直接反映させることはできません。 しかし、UpdateDisplayFromDigit が明示的なエラー型を返さないという事実から、少なくともエラーが黙って処理されるということはわかります。

設計の改良: 数学演算

では、数学演算に移りましょう。

これらはすべて二項演算で、2 つの数値を受け取って新しい結果を出力します。

これを表す関数型は次のようになります。

type DoMathOperation = CalculatorMathOp * Number * Number -> Number

1/x のような単項演算もある場合は、それらに別の型が必要になりますが、今回はないので、単純にしておきます。

次の決定です。どのような数値型を使用すればよいのでしょうか?ジェネリックにするべきでしょうか?

ここでも、単純に float を使うことにしましょう。ただし、表現を少し分離するために、Number のエイリアスは残しておきます。更新されたコードは以下のとおりです。

type DoMathOperation = CalculatorMathOp * Number * Number -> Number
and Number = float

さて、上で UpdateDisplayFromDigit について行ったように、DoMathOperation についても考えてみましょう。

質問1: これは最小限のパラメータセットでしょうか?たとえば、状態の他の部分が必要ですか?

回答: いいえ、他に何も思いつきません。

質問2: DoMathOperation がエラーを返すことはありますか?

回答: はい!ゼロで割る場合はどうでしょうか?

では、どのようにエラーを処理すればよいのでしょうか? そこで、数学演算の結果を表す新しい型を作成し、 DoMathOperation の出力にそれを利用することにします。

新しい型 MathOperationResult は、SuccessFailure の 2 つの選択肢(判別共用体)を持つことになります。

type DoMathOperation = CalculatorMathOp * Number * Number -> MathOperationResult 
and Number = float
and MathOperationResult = 
    | Success of Number
    | Failure of MathOperationError
and MathOperationError = 
    | DivideByZero

組み込みのジェネリック型 Choice を使用したり、「鉄道指向プログラミング」のアプローチを本格的に使ったりすることもできましたが、 これは設計のスケッチなので、多くの依存関係を持たずに設計を独立させたいので、ここで具体的な型を定義することにします。

他にエラーはありますか?NaN やアンダーフロー、オーバーフローはどうでしょうか?わかりません。MathOperationError 型があるので、必要に応じて簡単に拡張できます。

数値はどこから来るのか?

DoMathOperation を入力として Number 値を使用するように定義しました。しかし、Number はどこから来るのでしょうか?

それは、入力された数字の並びから来ています。数字を float に変換するのです。

1 つのアプローチは、文字列の表示とともに Number を状態に格納し、数字が入力されるたびに更新することです。

ここでは、より単純なアプローチを採用し、表示から直接数値を取得することにします。言い換えれば、次のような関数が必要です。

type GetDisplayNumber = CalculatorDisplay -> Number

しかし、考えてみると、表示文字列が "error" などになっている可能性があるため、この関数は失敗する可能性があります。そこで、代わりにオプションを返すようにしましょう。

type GetDisplayNumber = CalculatorDisplay -> Number option

同様に、結果が成功した場合は、それを表示したいので、逆方向に動作する関数が必要です。

type SetDisplayNumber = Number -> CalculatorDisplay

この関数がエラーになることはないので(そう願っています)、option は必要ありません。

設計の改良: 数学演算の入力処理

数学演算はまだ終わりではありません。

入力が Add の場合、目に見える効果は何でしょうか?何もありません。

Add イベントは、後から入力される数値と演算を行う必要があるため、 何らかの形で保留状態になり、次の数値の入力を待ちます。

考えてみると、Add イベントを保留しておくだけでなく、入力された最新の数字に加算される準備ができた以前の数字も保持しておく必要があります。

これをどこで追跡すればよいのでしょうか?もちろん CalculatorState です。

新しいフィールドを追加する最初の試みは以下のとおりです。

and CalculatorState = {
    display: CalculatorDisplay
    pendingOp: CalculatorMathOp
    pendingNumber: Number
    }

しかし、保留中の操作がない場合もあるので、オプションにする必要があります。

and CalculatorState = {
    display: CalculatorDisplay
    pendingOp: CalculatorMathOp option
    pendingNumber: Number option
    }

しかし、これも間違っています。pendingNumber なしで pendingOp を持つことはできますか?またはその逆は?いいえ、できません。これらは一緒に存在し、一緒に消滅します。

これは、状態がペアを含み、ペア全体がオプションである必要があることを意味します。

and CalculatorState = {
    display: CalculatorDisplay
    pendingOp: (CalculatorMathOp * Number) option
    }

しかし、まだ足りない部分があります。 演算が保留中として状態に追加された場合、演算はいつ実際に 実行 され、結果が表示されるのでしょうか?

答え: Equals ボタンが押されたとき、または別の数学演算ボタンが押されたときです。これについては後で説明します。

設計の改良: クリアボタンの処理

最後に処理するボタンは、Clear ボタンです。これは何をするのでしょうか?

明らかに、状態をリセットして、ディスプレイを空にし、保留中の操作をすべて削除します。

この関数を "clear" ではなく InitState と呼ぶことにします。そのシグネチャは以下のとおりです。

type InitState = unit -> CalculatorState

サービスの定義

この時点で、ボトムアップ開発に切り替えるために必要なものはすべて揃いました。 Calculate 関数の試作実装を構築して、設計が使い物になるかどうか、何か見落としているものがないかどうかを確認したいと思っています。

しかし、全体を実装せずに試作実装を作成するにはどうすればよいのでしょうか?

ここで、これらの型がすべて役に立ちます。calculate 関数が使用する「サービス」のセットを、実際に実装することなく定義することができます。

どういうことかというと、次のとおりです。

type CalculatorServices = {
    updateDisplayFromDigit: UpdateDisplayFromDigit
    doMathOperation: DoMathOperation
    getDisplayNumber: GetDisplayNumber
    setDisplayNumber: SetDisplayNumber
    initState: InitState
    }

Calculate 関数の実装に注入できるサービスのセットを作成しました。 これらが用意されていれば、すぐに Calculate 関数をコーディングし、サービスの実装は後回しにすることができます。

この時点で、小さなプロジェクトにしてはやり過ぎだと思うかもしれません。

確かに、これを エンタープライズFizzBuzz にしたくはありません。

しかし、ここで私は原則を示しています。「サービス」をコアコードから分離することで、すぐにプロトタイピングを開始できます。 目標は、本番対応のコードベースを作成することではなく、設計上の問題を見つけることです。まだ要件発見の段階にあります。

このアプローチは、オブジェクト指向の原則でよく使われる、 サービスのインターフェースを複数作成し、それらをコアドメインに注入する手法と似ていますので、理解しやすいのではないでしょうか。

レビュー

では、サービスを追加して、初期設計が完成したので、レビューしてみましょう。 ここまでのコードの全体像は以下のとおりです。

type Calculate = CalculatorInput * CalculatorState -> CalculatorState 
and CalculatorState = {
    display: CalculatorDisplay
    pendingOp: (CalculatorMathOp * Number) option
    }
and CalculatorDisplay = string
and CalculatorInput = 
    | Digit of CalculatorDigit
    | Op of CalculatorMathOp
    | Action of CalculatorAction
and CalculatorDigit = 
    | Zero | One | Two | Three | Four 
    | Five | Six | Seven | Eight | Nine
    | DecimalSeparator
and CalculatorMathOp = 
    | Add | Subtract | Multiply | Divide
and CalculatorAction = 
    | Equals | Clear
and UpdateDisplayFromDigit = 
    CalculatorDigit * CalculatorDisplay -> CalculatorDisplay
and DoMathOperation = 
    CalculatorMathOp * Number * Number -> MathOperationResult 
and Number = float
and MathOperationResult = 
    | Success of Number
    | Failure of MathOperationError
and MathOperationError = 
    | DivideByZero

type GetDisplayNumber = 
    CalculatorDisplay -> Number option
type SetDisplayNumber = 
    Number -> CalculatorDisplay 

type InitState = 
    unit -> CalculatorState 

type CalculatorServices = {
    updateDisplayFromDigit: UpdateDisplayFromDigit
    doMathOperation: DoMathOperation
    getDisplayNumber: GetDisplayNumber
    setDisplayNumber: SetDisplayNumber
    initState: InitState
    }

まとめ

これはなかなか良いのではないでしょうか。まだ「本当の」コードは書いていませんが、少し考えただけで、かなり詳細な設計を構築することができました。

次の投稿 では、実装を作成してみることで、この設計をテストします。

この投稿のコードは、GitHub のこの gist で入手できます。

results matching ""

    No results matching ""