🐷

C#でHaskellモナド

2024/12/09に公開

Haskellは純粋関数型パラダイムの代表格です
しかし、純粋とはなんでしょうか?
実際には標準入出力に対する操作手段を有しており、これらは副作用に数えられる動作です
では、Haskellは非純粋な言語なのでしょうか?
純粋性のある入出力処理とは何でしょうか?

純粋性

例えば、C++ における以下の関数はコンパイルが成功します

constexpr式
#include <stdio.h>

constexpr auto test(void)->int (*)(void){
    return []->int{
        printf("Hello World");
        return 0;
    };
};

int main() {
    test()();
    return 0;
}
main.cpp: In function 'constexpr int (* test())()':
main.cpp:4:14: warning: parameter declaration before lambda trailing return type only optional with '-std=c++2b' or '-std=gnu++2b'
    4 |     return []->int{
      |              ^~
Hello World
[Execution complete with exit code 0]

conctexpr定数式を明示する修飾子です
定数式は関数が純粋な状態であることを保証します
printfreturn句以外で直接呼び出すとエラーを返しますが、関数ポインタを返す文脈では定数とみなされます

printf自体は副作用を孕んでも、それがポインタを通してネストされる場合は純粋になるということです
またreturn句での宣言は有効であることから、純粋性の有無は関数が常に同一値を返すか否かが基準であると伺えます

つまり、副作用はそれが実行される状態を内包すれば良いと解釈できます

詰まるところ、Haskellが作る手続きは巨大なクロージャであると言えます
構造を模倣できれば、動作を値とする状態も表現可能でしょう

以下は、それを C# で模した例です

IO手続き
global using System;

global using static System.Console;
global using static Monad;
global using static ConsoleIO;
					
public class Program
{
    static void Main()=>Run.Begin();
	
    static IIOControl<object> Run
        =>Print($"Hello{1},{2}")
        .Trans(x=>x.Return(10))
        .Trans(Print)
        .Trans(x=>GetLine.Trans(Print))
    .Trans(x=>Start.Return("10"))
        .Trans(x=>Start.Return(int.Parse(x))
              .Trans(x=>Sample(x>5,Start.Return(x+10),Start.Return(-x))))
        .Trans(Print)
        .Trans(x=>GetLine)
        .Trans(Print);

    static IO<A> Sample<A>(bool condition,IO<A> caseA,IO<A> caseB)
        =>condition?caseA:caseB;
}

public interface IIOControl<out A>{
    internal Func<object,A> Function{get;}
    internal object RealWorld{get;}
}

public struct IO<A>:IIOControl<A>{
    internal IO((Func<object,A>,object) @value)=>_value=@value;
    readonly (Func<object,A> function,object realWorld) _value;
    Func<object,A> IIOControl<A>.Function=>_value.function;
    object IIOControl<A>.RealWorld=>_value.realWorld;
}

public static class Monad{
    public static object Start=>null;
    public static IO<A> Return<A>(this object o,Func<object,A> a)=>new((a,o));
    public static IO<A> Return<A>(this object o,A a)=>new((world=>a,o));

    public static IO<B> Next<A,B>(this IIOControl<A> io,Func<A,IO<B>> function)
        =>function(io.Function(io.RealWorld));

    public static IO<B> Trans<A,B>(this IIOControl<A> io,Func<A,IO<B>> function)
        =>Start.Return(world=>((IIOControl<B>)function(io.Function(io.RealWorld))).Function(world));

    public static IO<A> Finish<A>(this IIOControl<A> io)
        =>io.Next(world=>Start.Return(argument=>world));

    public static IO<A> Begin<A>(this IIOControl<A> io)
        =>Start.Return(io.Function(null));
}

public static class ConsoleIO{
    public static IO<object> Print<A>(A a)
        =>Start.Return<object>
            (delegate{
                WriteLine(a);
                return null;
            });

    public static IO<string> GetLine=>Start.Return(world=>ReadLine());
}
Hello1,2
10
Input1
20
Input2

この式を構成する基本のメソッドは、TransReturnの二つです

C# には柔軟な型推論機構が備わっています
ラムダ式と通常のメソッドの型は等価です
加えてインターフェイスが共変性に対応するため、より高度な抽象化が可能です

Transは、一連の手続きを全てIO<A>のフィールドである_valueに格納します
つまり、Transでマークされる宣言は全て一個のクロージャに包まれます

副作用の純粋性は、constexprの例の通り、その実行タイミングによって定型化されます
例えばプロパティRunを、Console.WriteLineで呼び出す手続きを考えます

static void Main()
    =>Console.WriteLine(Run);

このコードは以下の結果を返します

IO`1[System.Object]

Runを評価するだけでは、クロージャは呼び出されません
ロジックを実行するには、ここではBeginメソッドを使用します

static void Main()=>Run.Begin();

このように、TransHaskell(>>=)に相当することが確認できます

main = print "Hello,1,2"
 >>= \ x -> return 10
 >>=print
 >>= \ x -> getLine>>=print
 >>= \ x -> return "10"
 >>= \ x -> return (read x ::Int)
 >>=(\ x -> if x>5 then return (x+10) else return (-x))
 >>=print
 >>= \ x -> getLine
 >>=print
"Hello,1,2"
10
"Input1"
20
"Input2"

ここから想像できることは、HaskellgetLineprintなどの関数が、文字列や()を直接返しているわけではないということです

例えばこれらの関数に相当する C# 側のメソッドの実装は以下になります

public static class ConsoleIO{
    public static IO<object> Print<A>(A a)
        =>Start.Return<object>
            (delegate{
                WriteLine(a);
                return null;
            });

    public static IO<string> GetLine=>Start.Return(world=>ReadLine());
}

いずれも戻り値にクロージャを返すことが見て取れます

ReadLineWriteLineHaskellにおいて通常直接は呼び出せない入出力処理を表しますが、それを除けばこのライブラリが提供する仕組に沿って取り扱われていることが分かります

この設計を用いれば、オーバーヘッドを考慮しない条件下で、 C# の全ての標準ライブラリを関数型ライクに変換することも可能でしょう

そしてこれこそがHaskell動作を値として返す仕組みの正体と言えるのです

逐次評価

副作用が純粋性を持つ理由は、Haskekllがそれらをクロージャの深い階層に隠蔽し、getLineprintなどがそのクロージャのシンボルとして振る舞うためであると示しました

ランタイムが最終的に処理を実行することで副作用が発生するとしても、コードを評価する段階において、Haskellからは巨大なクロージャの構造体しか取り出せません

宣言時点で副作用が発生する手続き式とは異なり、後続のクロージャに引数として渡されるまで、その実行が保留される点において、両者には差異があります
以下のTransの実装は、それを表します

public static IO<B> Trans<A,B>(this IIOControl<A> io,Func<A,IO<B>> function)
        =>Start.Return(world=>((IIOControl<B>)function(io.Function(io.RealWorld))).Function(world));

ところで、純粋である理由が副作用の閉じ込めであるのなら、ライブラリの制約は、実際にはもう少し緩和できるかもしれません

Transは、いったんクロージャに隠した全ての手続きを実行時に一度に展開するという意味において、コンパイル式の言語と類似します

しかし、先の純粋性の考え方から、副作用をクロージャに隠しながら実行するのであれば、動的にIO<A>を組み立てつつ、展開する方式にすることで、インタプリタのような挙動が純粋な範囲で表現できるはずです
それを示したメソッドがNextです

public static IO<B> Next<A,B>(this IIOControl<A> io,Func<A,IO<B>> function)
        =>function(io.Function(io.RealWorld));

Transで綴られた一覧の手続きにNextを挟むと、そこがブレイクポイントのように働き、実行が中断されます

static IIOControl<object> Run
    =Print($"Hello{1},{2}")
    .Trans(x=>x.Return(10))
    .Trans(Print)
    .Trans(x=>GetLine.Trans(Print))
    .Next(x=>Start.Return(world=>"10"))
    .Trans(x=>Start.Return(world=>in.Parse(x))
          .Trans(x=>Sample(x>5,Start.Return(world=>x+10),Start.Return(world=>-x)))
    .Trans(Print)
    .Trans(x=>GetLine)
    .Trans(Print);

ここではRun変数となっている点に注意しましょう
例えばMainで以下のコードを書き下します

static void Main(){
   var io=Run;
   WriteLine(io==Run);
   io.Begin();
}

以下の結果を返します

Hello1,2
10
Impit1
True
20
Input2

実行結果の途中にTrueが挟まれています
これはWriteLine(io==Run)の比較結果です
ioにはRunがコピーされているために真となります
ですがRunの宣言時にNextの行まで処理が進行するため、残りの処理がioに包まれる結果、得られる動作が変化します

Nextからは常に新しいIO<A>が返されるため、実行時には副作用が現れますが、その副作用は常に構造体の内部にクロージャとして格納されるので、このメソッド自体の純粋性は常に保たれます

HaskellではIOによって純粋性と副作用が切り離されるという解説がよくなされますが、手続き式で書かれた以上の例からも分かる通り、両者は内部で連続性を持っていると言えるでしょう

所感

ここまでで、Haskellが純粋性の範囲で非純粋をどのように組み込むのか、その理屈を C# で実際に模倣しながら説いてきました

結論として、この解説の根拠はconstexprの仕様に依拠すると言えます
クロージャ自体は確かに値ではあるものの、それを取得して評価にかけるところまでを踏まえるならば、その非純粋性を完全に排除することができないのは事実です

しかし、重要なのは評価の結果、何が得られ、それがどのタイミングで解釈されるかであり、少なくともHaskellで生成したコードだけでは、クロージャの取得以外は何もできないという点も事実なのです

ここまでで様々な意見を持った方もいらっしゃると思います
異論は大歓迎なので、もしよろしければコメント欄に書き置きしてくだされば幸いです

Discussion