このブログ記事はシリーズの第2弾です。

前回の記事では、再帰型に対して関数を作成するための手法である「カタモーフィズム」を紹介しました。 また、カタモーフィズムを機械的に実装するためのいくつかのルールも列挙しました。 今回は、これらのルールを使って、別のドメインに対するカタモーフィズムを実装してみましょう。

シリーズの内容

シリーズの内容は次の通りです。


カタモーフィズム作成のルール

前回の記事で見たように、カタモーフィズムの作成は機械的なプロセスであり、以下のルールに従って行えます。

  • データ構造の各ケースを処理するための関数パラメータを作成する。
  • 再帰的でないケースについては、そのケースに関連するすべてのデータを関数パラメータに渡す。
  • 再帰的なケースについては、以下の2ステップを実行する。
    • まず、ネストされた値に対してカタモーフィズムを再帰的に呼び出す。
    • 次に、そのケースに関連するすべてのデータを関数ハンドラーに渡す。ただし、ネストされた値の部分は、カタモーフィズムの結果で置き換える。

それでは、これらのルールを適用して、他のドメインでカタモーフィズムを作成できるかどうか見てみましょう。


カタモーフィズムの例: ファイルシステムドメイン

非常に単純なファイルシステムモデルから始めましょう。

  • ファイルには名前とサイズがあります。
  • ディレクトリには名前、サイズ、およびサブアイテムのリストがあります。

次のようにモデル化できます。

type FileSystemItem =
    | File of File
    | Directory of Directory
and File = {name:string; fileSize:int}
and Directory = {name:string; dirSize:int; subitems:FileSystemItem list}

正直、非常に粗いモデルですが、今回の例には十分です!

それでは、サンプルのファイルとディレクトリを見てみましょう。

let readme = File {name="readme.txt"; fileSize=1}
let config = File {name="config.xml"; fileSize=2}
let build  = File {name="build.bat"; fileSize=3}
let src = Directory {name="src"; dirSize=10; subitems=[readme; config; build]}
let bin = Directory {name="bin"; dirSize=10; subitems=[]}
let root = Directory {name="root"; dirSize=5; subitems=[src; bin]}

カタモーフィズムを作成する時が来ました。

まずはシグネチャを見て、必要なものを確認しましょう。

File コンストラクタは File を取り、 FileSystemItem を返します。 上記のガイドラインに従うと、 File ケースのハンドラーは File -> 'r というシグネチャを持つ必要があります。

// ケースコンストラクタ
File  : File -> FileSystemItem

// Fileケースを処理する関数パラメータ
fFile : File -> 'r

これは簡単ですね。 cataFS (と呼ぶことにします) の初期スケルトンを組み立ててみましょう。

let rec cataFS fFile fDir item :'r = 
    let recurse = cataFS fFile fDir 
    match item with
    | File file -> 
        fFile file
    | Directory dir -> 
        // 実装予定

Directory ケースはもう少し複雑です。 上記のガイドラインを単純に適用すると、Directory ケースのハンドラーは Directory -> 'r というシグネチャを持ちますが、これは間違っています。 なぜなら、Directory レコード自体が FileSystemItem を含んでおり、それも 'r で置き換える必要があるからです。どうすればよいでしょうか?

1つの方法は、Directory レコードを (string, int, FileSystemItem list) というタプルに「展開」し、その中で FileSystemItem'r で置き換えることです。

つまり、次のような変換の流れになります。

// ケースコンストラクタ(Directoryをレコードとして)
Directory : Directory -> FileSystemItem

// ケースコンストラクタ(Directoryをタプルとして展開)
Directory : (string, int, FileSystemItem list) -> FileSystemItem
//   'rに置き換える ===> ~~~~~~~~~~~~~~          ~~~~~~~~~~~~~~

// Directoryケースを処理する関数パラメータ
fDir :      (string, int, 'r list)             -> 'r

もう1つの問題は、Directory ケースに関連するデータが FileSystemItemリスト であることです。これを 'r のリストに変換するにはどうすればよいでしょうか?

recurse ヘルパーは FileSystemItem'r に変換するので、 List.map にマッピング関数として recurse を渡せば、必要な 'r のリストが得られます。

すべてをまとめると、次のような実装になります。

let rec cataFS fFile fDir item :'r = 
    let recurse = cataFS fFile fDir 
    match item with
    | File file -> 
        fFile file
    | Directory dir -> 
        let listOfRs = dir.subitems |> List.map recurse 
        fDir (dir.name,dir.dirSize,listOfRs)

型シグネチャを見ると、まさに望んでいたものになっています。

val cataFS :
    fFile : (File -> 'r) ->
    fDir  : (string * int * 'r list -> 'r) -> 
    // 入力値
    FileSystemItem -> 
    // 戻り値
    'r

これで完成です。設定には少し手間がかかりますが、一度構築すれば、他の多くの関数の基盤となる、便利で再利用可能な関数ができあがります。

ファイルシステムドメイン: totalSize の例

それでは、実際に使ってみましょう。

まず、アイテムとそのすべてのサブアイテムの合計サイズを返す totalSize 関数を簡単に定義できます。

let totalSize fileSystemItem =
    let fFile (file:File) = 
        file.fileSize
    let fDir (name,size,subsizes) = 
        (List.sum subsizes) + size
    cataFS fFile fDir fileSystemItem

結果は次のようになります。

readme |> totalSize  // 1
src |> totalSize     // 16 = 10 + (1 + 2 + 3)
root |> totalSize    // 31 = 5 + 16 + 10

ファイルシステムドメイン: largestFile の例

「ツリー内の最大のファイルは何か?」のような、もう少し複雑な関数を考えてみましょう。

まず、何を返すべきか考えてみましょう。つまり、 'r は何になるでしょうか?

単なる File だと思うかもしれません。しかし、サブディレクトリが空で、ファイルが存在しない場合はどうでしょうか?

そこで、'rFile option にしましょう。

File ケースの関数は、 Some file を返すはずです。

let fFile (file:File) = 
    Some file

Directory ケースの関数は、もう少し考える必要があります。

  • サブファイルのリストが空の場合、None を返す
  • サブファイルのリストが空でない場合、最大のものを返す
let fDir (name,size,subfiles) = 
    match subfiles with
    | [] -> 
        None  // 空のディレクトリ
    | subfiles -> 
        // 最大のものを返す

しかし、 'rFile ではなく File option です。つまり、subfiles はファイルのリストではなく、File option のリストです。

では、これらの中で最大のものをどうやって見つけるのでしょうか?おそらく List.maxBy を使って、サイズを渡したいでしょう。しかし、File option のサイズとは何でしょうか?

File option のサイズを提供するヘルパー関数を書いてみましょう。次のロジックを使います。

  • File optionNone の場合、0 を返す
  • そうでない場合、オプション内のファイルのサイズを返す

コードは次のようになります。

// 欠落している場合のデフォルト値を提供するヘルパー
let ifNone deflt opt =
    defaultArg opt deflt 

// オプションのファイルサイズを取得する    
let fileSize fileOpt = 
    fileOpt 
    |> Option.map (fun file -> file.fileSize)
    |> ifNone 0

すべてまとめると、largestFile 関数が完成します。

let largestFile fileSystemItem =

    // 欠落している場合のデフォルト値を提供するヘルパー
    let ifNone deflt opt =
        defaultArg opt deflt 

    // File optionのサイズを取得するヘルパー
    let fileSize fileOpt = 
        fileOpt 
        |> Option.map (fun file -> file.fileSize)
        |> ifNone 0

    // Fileケースを処理する        
    let fFile (file:File) = 
        Some file

    // Directoryケースを処理する        
    let fDir (name,size,subfiles) = 
        match subfiles with
        | [] -> 
            None  // 空のディレクトリ
        | subfiles -> 
            // ヘルパーを使用して最大のFile optionを見つける
            subfiles 
            |> List.maxBy fileSize  

    // カタモーフィズムを呼び出す
    cataFS fFile fDir fileSystemItem

テストすると、期待通りの結果が得られます。

readme |> largestFile  
// Some {name = "readme.txt"; fileSize = 1}

src |> largestFile     
// Some {name = "build.bat"; fileSize = 3}

bin |> largestFile     
// None

root |> largestFile    
// Some {name = "build.bat"; fileSize = 3}

設定は少し面倒ですが、カタモーフィズムを全く使わずにゼロから書く場合と比べて、それほど手間はかかりません。


カタモーフィズムの例: 製品ドメイン

次は少し複雑なドメインを考えてみましょう。何らかの製品を製造・販売していると想像してください。

  • 一部の製品は購入品で、オプションでベンダーがあります。
  • 一部の製品は自社で製造される製品で、サブコンポーネントから組み立てられます。 サブコンポーネントとは、別の製品をある数量使ったものです。

ドメインは次のように型で表現できます。

type Product =
    | Bought of BoughtProduct 
    | Made of MadeProduct 
and BoughtProduct = {
    name : string 
    weight : int 
    vendor : string option }
and MadeProduct = {
    name : string 
    weight : int 
    components:Component list }
and Component = {
    qty : int
    product : Product }

これらの型は相互に再帰的です。 Product 型は MadeProduct 型を参照し、 MadeProduct 型は Component 型を参照し、 Component 型は再び Product 型を参照します。

製品の例をいくつか示します。

let label = 
    Bought {name="label"; weight=1; vendor=Some "ACME"}
let bottle = 
    Bought {name="bottle"; weight=2; vendor=Some "ACME"}
let formulation = 
    Bought {name="formulation"; weight=3; vendor=None}

let shampoo = 
    Made {name="shampoo"; weight=10; components=
    [
    {qty=1; product=formulation}
    {qty=1; product=bottle}
    {qty=2; product=label}
    ]}

let twoPack = 
    Made {name="twoPack"; weight=5; components=
    [
    {qty=2; product=shampoo}
    ]}

カタモーフィズムを設計するには、すべてのコンストラクタで Product 型を 'r に置き換える必要があります。

前の例と同様に、Bought ケースは簡単です。

// ケースコンストラクタ
Bought  : BoughtProduct -> Product

// Boughtケースを処理する関数パラメータ 
fBought : BoughtProduct -> 'r

Made ケースは少し複雑です。MadeProduct をタプルに展開する必要があります。タプルには Component が含まれているので、それも展開する必要があります。 最終的に、内側の Product に到達し、これを機械的に 'r に置き換えることができます。

変換の流れは次のとおりです。

// ケースコンストラクタ
Made  : MadeProduct -> Product

// ケースコンストラクタ(MadeProductをタプルとして展開)
Made  : (string,int,Component list) -> Product

// ケースコンストラクタ(Componentをタプルとして展開)
Made  : (string,int,(int,Product) list) -> Product
//  'rに置き換える ===> ~~~~~~~           ~~~~~~~

// Madeケースを処理する関数パラメータ 
fMade : (string,int,(int,'r) list)      -> 'r

cataProduct 関数を実装する場合、前と同じようにリストのマッピングが必要です。 Component のリストを (int,'r) のリストに変換します。

そのためのヘルパー関数が必要です。

// ComponentをComptを(int * 'r)のタプルに変換します
let convertComponentToTuple comp =
    (comp.qty,recurse comp.product)

このコードでは、recurse 関数を使用して内側の製品 (comp.product) を 'r に変換し、その後 int * 'r のタプルを作成しています。

convertComponentToTuple が利用可能になったので、List.map を使ってすべてのコンポーネントをタプルに変換できます。

let componentTuples = 
    made.components 
    |> List.map convertComponentToTuple

componentTuples(int * 'r) のリストで、これは fMade 関数に必要なものです。

cataProduct の完全な実装は次のようになります。

let rec cataProduct fBought fMade product :'r = 
    let recurse = cataProduct fBought fMade 

    // ComponentをComptを(int * 'r)のタプルに変換します
    let convertComponentToTuple comp =
        (comp.qty,recurse comp.product)

    match product with
    | Bought bought -> 
        fBought bought 
    | Made made -> 
        let componentTuples =  // (int * 'r) list
            made.components 
            |> List.map convertComponentToTuple 
        fMade (made.name,made.weight,componentTuples)

製品ドメイン: productWeight の例

cataProduct を使って、製品の重量を計算してみましょう。

let productWeight product =

    // Boughtケースを処理する
    let fBought (bought:BoughtProduct) = 
        bought.weight

    // Madeケースを処理する
    let fMade (name,weight,componentTuples) = 
        // 1つのコンポーネントタプルの重量を計算するヘルパー
        let componentWeight (qty,weight) =
            qty * weight
        // すべてのコンポーネントタプルの重量を合計する
        let totalComponentWeight = 
            componentTuples 
            |> List.sumBy componentWeight 
        // Madeケースの重量も加える
        totalComponentWeight + weight

    // カタモーフィズムを呼び出す
    cataProduct fBought fMade product

インタラクティブに動作を確認してみましょう。

label |> productWeight    // 1
shampoo |> productWeight  // 17 = 10 + (2x1 + 1x2 + 1x3)
twoPack |> productWeight  // 39 = 5  + (2x17)

期待通りの結果が得られました。

cataProduct のようなヘルパー関数を使わずに、ゼロから productWeight を実装してみてください。 もちろん可能ですが、再帰のロジックを正しく記述するのに時間がかかるでしょう。

製品ドメイン: mostUsedVendor 関数の例

もっと複雑な関数を作ってみましょう。最もよく使われているベンダーを見つけたいとします。

ロジックは単純です。製品がベンダーを参照するたびに、そのベンダーに 1 ポイントを与え、ポイントが最も多いベンダーが勝ちです。

ここでも、関数が何を返すべきか考えてみましょう。つまり、'r は何になるでしょうか?

単純に点数のようなものと考えがちですが、ベンダー名も必要です。では、タプルにしましょうか。しかし、ベンダーがない場合はどうでしょうか?

そこで、タプルではなく、小さな型 VendorScore を作成し、'rVendorScore option にしましょう。

type VendorScore = {vendor:string; score:int}

VendorScore からデータを簡単に取得するためのヘルパー関数も定義しましょう。

let vendor vs = vs.vendor
let score vs = vs.score

さて、木全体の結果が得られない限り、最もよく使われているベンダーを特定することはできません。 そのため、Bought ケースと Made ケースの両方で、木構造を再帰的に上っていく際に追加できるリストを返す必要があります。 そして、すべてのスコアを取得した後、降順にソートしてポイントが最も高いベンダーを見つけます。

つまり、'r はオプションではなく、VendorScore list にしなければならないのです!

Bought ケースのロジックは次のようになります。

  • ベンダーが存在する場合は、スコアが 1 の VendorScore を返しますが、単一の項目ではなく、1 要素のリストとして返します。
  • ベンダーが存在しない場合は、空のリストを返します。
let fBought (bought:BoughtProduct) = 
    // ベンダーがある場合、スコアを1に設定
    bought.vendor
    |> Option.map (fun vendor -> {vendor = vendor; score = 1} )
    // => VendorScore option
    |> Option.toList
    // => VendorScore list

Made ケースの関数はもう少し複雑です。

  • サブスコアのリストが空の場合は、空のリストを返します。
  • サブスコアのリストが空でない場合は、ベンダーごとに集計して新しいリストを返します。

しかし、fMade 関数に渡されるサブ結果のリストは、サブスコアのリストではなく、 qty * 'r というタプルのリストになります。ここで、 'rVendorScore list です。ややこしいですね!

必要な手順は次のとおりです。

  • 数量 ( qty ) は関係ないので、 qty * 'r を単に 'r に変換します。これで、VendorScore list のリストができます。これには List.map snd を使えます。
  • しかし、これでは VendorScore list のリストになってしまいます。リストのリストを単純なリストに平坦化するには、 List.collect を使います。実際には、List.collect snd を使うと、両方のステップを一度に行うことができます。
  • このリストをベンダーごとにグループ化し、 key=vendor; values=VendorScore list というタプルのリストにします。
  • 各ベンダーのスコア( values=VendorScore list )を集計し、単一の値にして、 key=vendor; values=VendorScore というタプルのリストにします。

この時点で、 cata 関数は VendorScore のリストを返します。 このリストから最高スコアを取得するには、List.sortByDescendingList.tryHead を使います。 リストが空の場合もあるため、maxBy は使えないことに注意してください。

以下は mostUsedVendor 関数の全体像です。

let mostUsedVendor product =

    let fBought (bought:BoughtProduct) = 
        // ベンダーがある場合、スコアを1に設定
        bought.vendor
        |> Option.map (fun vendor -> {vendor = vendor; score = 1} )
        // => VendorScore option
        |> Option.toList
        // => VendorScore list

    let fMade (name,weight,subresults) = 
        // subresultsは(qty * VendorScore list)のリスト

        // スコアの合計を取得するヘルパー
        let totalScore (vendor,vendorScores) =
            let totalScore = vendorScores |> List.sumBy score
            {vendor=vendor; score=totalScore}

        subresults 
        // => (qty * VendorScore list)のリスト
        |> List.collect snd  // サブ結果のqty部分を無視
        // => VendorScoreのリスト 
        |> List.groupBy vendor 
        // 2番目の項目はVendorScoreのリスト、合計に縮小
        |> List.map totalScore 
        // => VendorScoreのリスト 

    // カタモーフィズムを呼び出す
    cataProduct fBought fMade product
    |> List.sortByDescending score  // 最高スコアを見つける
    // リストが空の場合はNone、そうでなければ最初の要素を返す
    |> List.tryHead

この関数をテストしてみましょう。

label |> mostUsedVendor    
// Some {vendor = "ACME"; score = 1}

formulation |> mostUsedVendor  
// None

shampoo |> mostUsedVendor  
// Some {vendor = "ACME"; score = 2}

twoPack |> mostUsedVendor  
// Some {vendor = "ACME"; score = 4}

fMade 関数の実装方法は他にもあります。 List.fold を使って一度の処理で全体を処理することもできましたが、 このバージョンは最もわかりやすく読みやすい実装です。

また、cataProduct を使わずに mostUsedVendor をゼロから書くこともできます。 パフォーマンスが重要であれば、そちらの方が良いかもしれません。汎用的なカタモーフィズムは、( qty * VendorScore option のリストのような) 一般化されすぎていて無駄な中間値を作成するからです。

一方で、カタモーフィズムを使うことで、カウントロジックだけに集中し、再帰処理のロジックを無視することができます。

したがって、いつものように、再利用と新規作成のメリットとデメリットを検討する必要があります。 共通コードを一度書いて標準化された方法で使うことの利点と、 カスタムコードのパフォーマンス向上、追加作業、潜在的なバグの問題を天秤にかけなければなりません。


まとめ

この記事では、再帰型を定義する方法とカタモーフィズムについて説明しました。

また、カタモーフィズムの使い方もいくつか紹介しました。

  • Gift -> 'r のような、再帰型を「折りたたむ」関数は、その型のカタモーフィズムを使って書くことができます。
  • カタモーフィズムを使って型の内部構造を隠せます。
  • 各ケースを処理する関数を調整することで、カタモーフィズムを使ってある型から別の型へマッピングできます。
  • 型のケースコンストラクタを渡すことで、カタモーフィズムを使って元の値のクローンを作成できます。

しかし、カタモーフィズムの世界は完璧ではありません。 このページにあるカタモーフィズムの実装にはすべて、潜在的に深刻な欠陥があります。

次回の記事 では、 何がうまくいかないのか、どのように修正すればいいのか、そしてその過程で様々な種類の「畳み込み」を見ていきます。

それでは、また次回お会いしましょう!

  • この記事のソースコードは このgist で入手できます。

  • 追記: コメントで Paul Schnapp 氏が指摘した mostUsedVendor 関数のロジックエラーを修正しました。 ありがとう、Paul さん!

results matching ""

    No results matching ""