C# 関数型プログラミングの詳細 (13) 純関数

[C# シリーズ経由の LINQ]

[C# 関数型プログラミングの詳細シリーズ]

最新バージョン:https://weblogs.asp.net/dixin/functional-csharp-pure-function

関数型プログラミングは、純粋関数によるモデリング操作を促進します。

参照透過性と副作用なし

次の場合、関数は純粋です:

  • 同じ入力が与えられると、同じ出力が得られます。つまり、この関数は参照透過的です。
  • 呼び出し元の関数や外界との明らかな相互作用はありません。つまり、関数には副作用がありません。副作用の例をいくつか示します:
    • データの変更などの状態の変更
    • 引数、外部変数、またはグローバル変数の変更
    • I/O の生成

したがって、純粋関数は数学関数のようなもので、一連の入力と一連の出力の間の単純な関係であり、特定の入力が特定の出力にマッピングされます。たとえば、次の関数は参照透過的ではありません:

  • Console.Read、Console.ReadLine、Console.ReadKey:毎回呼び出すと予測できない出力が返される
  • Random.Next、Guid.NewGuid:毎回呼び出されるとランダムな出力が得られます
  • DateTime.Now、DateTimeOffset.Now:異なる時間に呼び出されると、異なる出力が得られます

また、次の関数には副作用があります:

  • 前の部分の MutableDevice.Name のセッター、MutableDevice.Price のセッター:通常、プロパティ セッターは状態を変更し、システムと対話します。
  • System.Threading 名前空間では、Thread.Start、Thread.Abort:状態が変化します
  • int.TryParse、Interlocked.Increase、および任意のメソッドが ref/out 引数を変更します
  • System.Windows 名前空間では、Application.SetExitCode:グローバル変数 Environment.ExitCode を内部的に変更します
  • Console.Read、Console.ReadLine、Console.ReadKey、Console.Write、Console.Write、Console.WriteLine:コンソール I/O を生成します
  • System.IO 名前空間では、Directory.Create、Directory.Move、Directory.Delete、File.Create、File.Move、File.Delete、File.ReadAllBytes、File.WriteAllBytes:ファイル システム I/O を生成します
  • System.Net 名前空間では WebRequest.GetRequestStreamAsync、WebRequest.GetResponseAsync、System.Net.Http 名前空間では HttpClient.GetAsync、HttpClient.PostAsync、HttpClinet.PutAsync、HttpClient.DeleteAsync:ネットワーク I/O を生成します
  • IDisposable.Dispose:状態を変更して、管理されていないリソースを解放します

厳密に言えば、どの関数も外の世界とやり取りできます。通常、関数呼び出しは、少なくとも電気エネルギーを消費し、世界を加熱するハードウェアを機能させることができます。ここで、関数の純粋性を識別する場合、明示的な相互作用のみが考慮されます。

対照的に、次の関数は、参照が透過的であり、副作用がないため、純粋です。

  • 10 進数の算術演算子、ほとんどの System.Math 型の静的メソッドなど、ほとんどの数学関数。Math.Max と Math.Min を例にとると、計算された出力は入力のみに依存し、住宅の透過性があります。また、状態の変更、引数の変更、グローバル変数の変更、I/O などのような副作用も生じません:
    namespace System
    {
        public static class Math
        {
            public static int Max(int val1, int val2) => (val1 >= val2) ? val1 : val2;
    
            public static int Min(int val1, int val2) => (val1 <= val2) ? val1 : val2;
        }
    }
  • string.Concat、string.Substring、string.Insert、string.Replace、string.Trim、string.ToUpper、string.ToLower:1 つ以上の文字列を入力として受け入れ、新しい文字列を出力します。文字列は不変型であるためです。 .
  • string.Length、Nullable.HasValue、Console.Error、または任意のプロパティ ゲッターが状態を返します。 MutableDevice.Name の getter と MutableDevice.Price の getter も純粋です。特定の MutableDevice オブジェクトでは、予測可能な状態を返します。ゲッターの実行中、ゲッターは状態を変更したり、その他の副作用を生成したりしません。
  • GetHashCode、GetType、Equals、ReferenceEquals、ToString などのオブジェクトのメソッド
  • ToBoolean、ToInt32 などの System.Convert 型の変換メソッド

純粋な関数には多くの利点があります。たとえば:

  • コードの問題の主な原因である状態の変更は含まれません。
  • 自己完結型であり、テスト容易性と保守性が大幅に向上します。
  • 2 つの純粋な関数呼び出しにデータの依存関係がない場合、関数呼び出しの順序は問題にならないため、Parallel LINQ のように並列計算が大幅に簡素化されます。

前述のように、すべての操作が純粋な関数呼び出しとしてモデル化される、純粋関数型プログラミングと呼ばれる特殊な関数型プログラミング パラダイムもあります。その結果、不変の値と不変のデータ構造のみが許可されます。 Haskell などのいくつかの言語は、このパラダイム用に設計されています。 Haskell では Monad を使用して I/O を管理します。これについては圏論の章で説明します。 C# や F# などの他の関数型言語は、非純粋な関数型言語と呼ばれます。

PureAttribute とコード コントラクト

.NET は System.Diagnostics.Contracts.PureAttribute を提供して、名前付き関数メンバーが純粋であることを指定します:

internal static partial class Purity
{
    [Pure]
    internal static bool IsPositive(int int32) => int32 > 0;

    internal static bool IsNegative(int int32) // Impure.
    {
        Console.WriteLine(int32.WriteLine()); // Side effect: console I/O.
        return int32 < 0;
    }
}

また、すべての関数メンバーが純粋であることを指定するために、型に使用することもできます:

[Pure]
internal static class Pure
{
    internal static int Increase(int int32) => int32 + 1;

    internal static int Decrease(int int32) => int32 - 1;
}

残念ながら、この属性は汎用ではなく、.NET Code Contracts でのみ使用されます。 Code Contracts は、.NET Framework 用の Microsoft ツールです。構成:

  • System.Diagnostics.Contracts 名前空間でコントラクト API をコーディングして、上記の PureAttribute を含め、事前条件、事後条件、不変条件、純度などを指定します。
  • 一部の .NET Framework アセンブリのコントラクト アセンブリ
  • コンパイル時のリライターとアナライザー
  • ランタイム アナライザー

[Pure] がコード コントラクトでどのように機能するかを示すには、Visual Studio ギャラリーからツールをインストールしてから、Visual Studio でプロジェクト プロパティに移動し、条件付きコンパイル シンボル CONTRACTS_FULL を追加します。

新しいタブ Code Contract があることに注意してください。タブに移動し、ランタイム コントラクト チェックの実行を有効にします。

コード コントラクトは、System.Diagnostics.Contracts.Contract タイプの静的メソッドで指定できます。コントラクト メソッドで使用できるのは、純粋な関数呼び出しのみです:

internal static int PureContracts(int int32)
{
    Contract.Requires<ArgumentOutOfRangeException>(IsPositive(int32)); // Function precondition.
    Contract.Ensures(IsPositive(Contract.Result<int>())); // Function post condition.

    return int32 + 0; // Function logic.
}

上記の関数の呼び出し元の場合、コード コントラクト ツールは、指定された事前条件と事後条件をコンパイル時と実行時にチェックできます (チェックが有効になっている場合)。そして論理的には、前提条件と事後条件のチェックは参照透過的であり、副作用がないようにする必要があります。対照的に、次の例では、事前条件と事後条件で不純な関数を呼び出しています:

internal static int ImpureContracts(int int32)
{
    Contract.Requires<ArgumentOutOfRangeException>(IsNegative(int32)); // Function precondition.
    Contract.Ensures(IsNegative(Contract.Result<int>())); // Function post condition.

    return int32 + 0; // Function logic.
}

コンパイル時に、コード コントラクトは次の警告を表示します:メソッド ‘ImpureContracts(System.Int32)’ のコントラクトで [Pure] なしでメソッド IsNegative(System.Int32) への呼び出しが検出されました。

[Pure] は無名関数には使用できません。また、名前付き関数メンバーの場合、[Pure] は注意して使用する必要があります。次のメソッドは純粋であると宣言されています:

[Pure] // Incorrect.
internal static ProcessStartInfo Initialize(ProcessStartInfo processStart)
{
    processStart.RedirectStandardInput = false;
    processStart.RedirectStandardOutput = false;
    processStart.RedirectStandardError = false;
    return processStart;
}

しかし実際には、状態を変えることによって、それはまったく不純です。コンパイル時または実行時に内部コードをチェックし、警告またはエラーを出すツールはありません。純度は、設計時に人為的に確保することしかできません。

.NET の純度

コードがコンパイルされてアセンブリにビルドされると、そのコントラクトを同じアセンブリにコンパイルすることも、別のコントラクト アセンブリにコンパイルすることもできます。既に出荷されている .NET Framework FCL アセンブリについては、Microsoft は、最もよく使用される一部のアセンブリに対して個別のコントラクト アセンブルを提供しています。

  • Microsoft.VisualBasic.Compatibility.Contracts.dll
  • Microsoft.VisualBasic.Contracts.dll
  • mscorlib.Contracts.dll
  • PresentationCore.Contracts.dll
  • PresentationFramework.Contracts.dll
  • System.ComponentModel.Composition.Contracts.dll
  • System.Configuration.Contracts.dll
  • System.Configuration.Install.Contracts.dll
  • System.Contracts.dll
  • System.Core.Contracts.dll
  • System.Data.Contracts.dll
  • System.Data.Services.Contracts.dll
  • System.DirectoryServices.Contracts.dll
  • System.Drawing.Contracts.dll
  • System.Numerics.Contracts.dll
  • System.Runtime.Caching.Contracts.dll
  • System.Security.Contracts.dll
  • System.ServiceModel.Contracts.dll
  • System.ServiceProcess.Contracts.dll
  • System.Web.ApplicationServices.Contracts.dll
  • System.Web.Contracts.dll
  • System.Windows.Forms.Contracts.dll
  • System.Xml.Contracts.dll
  • System.Xml.Linq.Contracts.dll
  • WindowsBase.Contracts.dll

コントラクト アセンブリには、特定の FLC アセンブリ内の API のコントラクト (事前条件、事後条件、不変条件など) が含まれています。たとえば、mscorlib.Contracts.dll は mscorlib.dll の API のコントラクトを提供し、System.ComponentModel.Composition.Contracts.dll は System.ComponentModel.Composition.dll の API のコントラクトを提供します。上記の Math.Abs​​ 関数が提供されます。 mscorlib.dll で、そのパリティ コントラクトが mscorlib.Contracts.dll で提供されます。署名は同じですが、コントラクトのみが含まれ、ロジックは含まれていません。

namespace System
{
    public static class Math
    {
        [Pure]
        public static int Abs(int value)
        {
            Contract.Requires(value != int.MinValue);
            Contract.Ensures(Contract.Result<int>() >= 0);
            Contract.Ensures((value - Contract.Result<int>()) <= 0);

            return default;
        }
    }
}

Math.Abs​​ の呼び出し元の場合、コード コントラクト ツールは mscorlib.Contracts.dll から上記の事前条件と事後条件を読み込み、チェックが有効になっている場合はコンパイル時と実行時にチェックを実行できます。 C# 言語は純粋に機能するようには設計されておらず、.NET API もそうではありません。したがって、組み込み関数のごく一部のみが純粋です。これを実証するために、リフレクションを使用してこれらのアセンブリ コントラクトを調べることができます。リフレクション API に組み込まれている .NET は、これらのアセンブリ コントラストではうまく機能しません。たとえば、mscorlib.Contracts.dll には、.NET リフレクションによって特殊な型と見なされる System.Void 型が含まれており、クラッシュの原因となります。ここでは、サード パーティのリフレクション ライブラリである Mono.Cecil NuGet パッケージを使用できます。次の LINQ to Objects の例では、Mono.Cecil API を呼び出して、[Pure] でパブリック関数メンバーのコントラクト アセンブリをクエリし、次にすべての .NET Framework FCL アセンブリのパブリック関数メンバーをクエリします。

internal static void PureFunction(string contractsAssemblyDirectory, string gacDirectory = @"C:\Windows\Microsoft.NET\assembly")
{
    string[] contractAssemblyFiles = Directory
        .EnumerateFiles(contractsAssemblyDirectory, "*.dll")
        .ToArray();
    string pureAttribute = typeof(PureAttribute).FullName;
    // Query the count of all public function members with [Pure] in all public class in all contract assemblies.
    int pureFunctionCount = contractAssemblyFiles
        .Select(assemblyContractFile => AssemblyDefinition.ReadAssembly(assemblyContractFile))
        .SelectMany(assemblyContract => assemblyContract.Modules)
        .SelectMany(moduleContract => moduleContract.GetTypes())
        .Where(typeContract => typeContract.IsPublic)
        .SelectMany(typeContract => typeContract.Methods)
        .Count(functionMemberContract => functionMemberContract.IsPublic
            && functionMemberContract.CustomAttributes.Any(attribute =>
                attribute.AttributeType.FullName.Equals(pureAttribute, StringComparison.Ordinal)));
    pureFunctionCount.WriteLine(); // 2473

    string[] assemblyFiles = new string[] { "GAC_64", "GAC_MSIL" }
        .Select(platformDirectory => Path.Combine(gacDirectory, platformDirectory))
        .SelectMany(assemblyDirectory => Directory
            .EnumerateFiles(assemblyDirectory, "*.dll", SearchOption.AllDirectories))
        .ToArray();
    // Query the count of all public function members in all public class in all FCL assemblies.
    int functionCount = contractAssemblyFiles
        .Select(contractAssemblyFile => assemblyFiles.First(assemblyFile => Path.GetFileName(contractAssemblyFile)
            .Replace(".Contracts", string.Empty)
            .Equals(Path.GetFileName(assemblyFile), StringComparison.OrdinalIgnoreCase)))
        .Select(assemblyFile => AssemblyDefinition.ReadAssembly(assemblyFile))
        .SelectMany(assembly => assembly.Modules)
        .SelectMany(module => module.GetTypes())
        .Where(type => type.IsPublic)
        .SelectMany(type => type.Methods)
        .Count(functionMember => functionMember.IsPublic);
    functionCount.WriteLine(); // 83447
}

その結果、上記の主流の FCL アセンブリでは、純粋なパブリック関数メンバーは 2.96% しかありません。