Approfondimento della programmazione funzionale in C# (13) Funzione pura

Approfondimento della programmazione funzionale in C# (13) Funzione pura

[LINQ tramite serie C#]

[Serie di approfondimento programmazione funzionale C#]

Ultima versione:https://weblogs.asp.net/dixin/functional-csharp-pure-function

La programmazione funzionale incoraggia le operazioni di modellazione con funzioni pure.

Trasparenza referenziale e senza effetti collaterali

Una funzione è pura se:

  • Dà lo stesso output quando viene fornito lo stesso input. In altre parole, la funzione è referenzialmente trasparente.
  • Non ha un'interazione evidente con la funzione chiamante o il mondo esterno, in altre parole, la funzione non ha effetti collaterali. Ecco alcuni esempi di effetti collaterali:
    • Cambiamento dello stato, come la mutazione dei dati
    • Modifica di argomenti, variabile esterna o variabile globale
    • Produzione di I/O

Quindi la funzione pura è come la funzione matematica, che è una semplice relazione tra un insieme di input e un insieme di output, in cui ogni determinato input è mappato a un determinato output. Ad esempio, le seguenti funzioni non sono referenzialmente trasparenti:

  • Console.Read, Console.ReadLine, Console.ReadKey:genera un output imprevedibile quando viene chiamato ogni volta
  • Random.Next, Guid.NewGuid:fornisce un output casuale ogni volta che viene chiamato
  • DateTime.Now, DateTimeOffset.Now:fornisce un output diverso quando viene chiamato in un momento diverso

E le seguenti funzioni hanno effetti collaterali:

  • Setter di MutableDevice.Name, setter di MutableDevice.Price nella parte precedente:il setter di proprietà di solito cambia stato e interagisce con il sistema.
  • Nello spazio dei nomi System.Threading, Thread.Start, Thread.Abort:cambia stato
  • int.TryParse, Interlocked.Increase e qualsiasi metodo modificano l'argomento ref/out
  • Nello spazio dei nomi System.Windows, Application.SetExitCode:modifica internamente la variabile globale Environment.ExitCode
  • Console.Read, Console.ReadLine, Console.ReadKey, Console.Write, Console.Write, Console.WriteLine:produce l'I/O della console
  • Nello spazio dei nomi System.IO, Directory.Create, Directory.Move, Directory.Delete, File.Create, File.Move, File.Delete, File.ReadAllBytes, File.WriteAllBytes:produce I/O del file system
  • Nello spazio dei nomi System.Net, WebRequest.GetRequestStreamAsync, WebRequest.GetResponseAsync e nello spazio dei nomi System.Net.Http, HttpClient.GetAsync, HttpClient.PostAsync, HttpClinet.PutAsync, HttpClient.DeleteAsync:produce I/O di rete
  • IDiposable.Dispose:modifica lo stato per rilasciare risorse non gestite

A rigor di termini, qualsiasi funzione può interagire con il mondo esterno. Di solito, una chiamata di funzione può almeno far funzionare l'hardware, che consuma energia elettrica e riscalda il mondo. Qui, quando si identifica la purezza della funzione, vengono considerate solo le interazioni esplicite.

Al contrario, le seguenti funzioni sono pure perché sono sia referenzialmente trasparenti che prive di effetti collaterali:

  • La maggior parte delle funzioni matematiche, come gli operatori aritmetici decimali, la maggior parte dei metodi statici di tipo System.Math, ecc. Prendi Math.Max ​​e Math.Min come esempi, il loro output calcolato dipende solo dall'input e sono trasparenze residenziali, sono inoltre non producono effetti collaterali, come cambio di stato, cambio di argomento, cambio di variabile globale, I/O, ecc.:
    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:accetta una o più stringhe come input e genera una nuova stringa, poiché string è di tipo immutabile .
  • string.Length, Nullable.HasValue, Console.Error o qualsiasi getter di proprietà restituiscono uno stato. Anche il getter di MutableDevice.Name e il getter di MutableDevice.Price sono puri. Per un determinato oggetto MutableDevice, restituiscono uno stato prevedibile e durante l'esecuzione dei getter, i getter non cambiano lo stato o producono altri effetti collaterali.
  • Metodi dell'oggetto, come GetHashCode, GetType, Equals, ReferenceEquals, ToString
  • Metodi di conversione del tipo System.Convert, come ToBoolean, ToInt32, ecc.

La funzione pura ha molti vantaggi, ad esempio:

  • non comporta il cambio di stato, che è una delle principali fonti di problemi con il codice.
  • È autonomo e migliora notevolmente la testabilità e la manutenibilità.
  • Se 2 chiamate di funzione pure non hanno dipendenza dai dati, l'ordine delle chiamate di funzione non ha importanza, il che semplifica notevolmente il calcolo parallelo, come Parallel LINQ.

Come accennato in precedenza, esiste anche un paradigma di programmazione funzionale specializzato, chiamato programmazione puramente funzionale, in cui tutte le operazioni sono modellate come pure chiamate di funzione. Di conseguenza, sono consentiti solo valori immutabili e strutture di dati immutabili. Alcuni linguaggi, come Haskell, sono progettati per questo paradigma. In Haskell gestisce l'I/O con Monad, che è trattata nel capitolo sulla teoria delle categorie. Gli altri linguaggi funzionali, come C# e F#, sono chiamati linguaggio funzionale impuro.

PureAttribute e contratti di codice

.NET fornisce System.Diagnostics.Contracts.PureAttribute per specificare che un membro di funzione denominato è puro:

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;
    }
}

Può essere utilizzato anche per un tipo, per specificare che tutti i suoi membri di funzione siano puri:

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

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

Sfortunatamente, questo attributo non è per scopi generici e viene utilizzato solo dai contratti di codice .NET. Code Contracts è uno strumento Microsoft per .NET Framework. È composto da:

  • Codifica le API del contratto nello spazio dei nomi System.Diagnostics.Contracts per specificare precondizioni, post condizioni, invariante, purezza e così via, incluso il precedente PureAttribute.
  • Contratta gli assembly per alcuni assembly .NET Framework
  • Riscrittore e analizzatore del tempo di compilazione
  • Analizzatore di runtime

Per dimostrare come funziona [Pure] con i contratti di codice, installa lo strumento da Visual Studio Gallery, quindi in Visual Studio, vai alle proprietà del progetto, aggiungi il simbolo di compilazione condizionale CONTRACTS_FULL:

Avviso c'è una nuova scheda Codice contratto. Vai alla scheda e abilita Esegui controllo del contratto di runtime:

I contratti di codice possono essere specificati con i metodi statici del tipo System.Diagnostics.Contracts.Contract. Con i metodi Contract possono essere utilizzate solo chiamate di funzioni pure:

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.
}

Per il chiamante della funzione precedente, lo strumento Code Contract può verificare la precondizione e la post condizione specificate in fase di compilazione e runtime, se il controllo è abilitato. E logicamente, il controllo delle condizioni preliminari e successive dovrebbe essere trasparente e privo di effetti collaterali. Al contrario, l'esempio seguente chiama la funzione impura in precondizione e postcondizione:

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.
}

In fase di compilazione, Code Contract fornisce un avviso:chiamata rilevata al metodo IsNegative(System.Int32)' senza [Pure] nei contratti del metodo 'ImpureContracts(System.Int32)'.

[Pure] non può essere utilizzato per funzioni anonime. E per qualsiasi membro di funzione denominato, [Pure] deve essere usato con cautela. Il seguente metodo è dichiarato puro:

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

Ma in realtà è del tutto impuro, cambiando stato. Non esiste uno strumento per controllare il suo codice interno in fase di compilazione o runtime e fornire avvisi o errori. La purezza può essere garantita solo artificialmente in fase di progettazione.

Purezza in .NET

Quando il codice viene compilato e compilato in assembly, i relativi contratti possono essere compilati nello stesso assembly o in un assembly di contratti separato. Per gli assembly FCL .NET Framework già forniti, Microsoft fornisce contratti di assembly separati per alcuni assembly più utilizzati:

  • 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

Un assembly del contratto contiene i contratti (precondizione, post condition, invariante e così via) per le API in determinati assembly FLC. Ad esempio, mscorlib.Contracts.dll fornisce i contratti per le API in mscorlib.dll, System.ComponentModel.Composition.Contracts.dll fornisce i contratti per le API in System.ComponentModel.Composition.dll, ecc. Sopra viene fornita la funzione Math.Abs in mscorlib.dll, quindi il suo contratto di parità è fornito in mscorlib.Contracts.dll, con la stessa firma ma contiene solo contratti e nessuna logica:

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;
        }
    }
}

Per il chiamante di Math.Abs, lo strumento Code Contract può caricare la precondizione e la post condizione sopra da mscorlib.Contracts.dll ed eseguire il controllo in fase di compilazione e runtime, se il controllo è abilitato. Il linguaggio C# non è progettato per essere puramente funzionale, né lo sono le API .NET. Quindi solo una piccola percentuale delle funzioni integrate è pura. Per dimostrarlo, la riflessione può essere utilizzata per esaminare questi contratti di assemblaggio. Le API di riflessione integrate in .NET non funzionano bene con questi contrasti di assembly. Ad esempio, mscorlib.Contracts.dll contiene il tipo System.Void, che è considerato un tipo speciale da .NET Reflection e provoca arresti anomali. Il pacchetto Mono.Cecil NuGet, una libreria di riflessione di terze parti, può funzionare qui. L'esempio LINQ to Objects seguente chiama le API Mono.Cecil per interrogare gli assembly del contratto per i membri della funzione pubblica con [Pure], quindi interrogare tutti i membri della funzione pubblica degli assembly FCL di .NET Framework:

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
}

Di conseguenza, nelle assemblee FCL tradizionali di cui sopra, solo il 2,96% dei membri di funzioni pubbliche è puro.