Lokale Funktionen (C#-Programmierhandbuch)

Ab C#-7.0 unterstützt C# lokale Funktionen. Lokale Funktionen sind private Methoden eines Typs, die in einem anderen Member geschachtelt sind. Sie können nur aus ihrem enthaltenden Member aufgerufen werden. Lokale Funktionen können deklariert und aufgerufen werden aus:

  • Methoden, insbesondere Iteratormethoden und Async-Methoden
  • Konstruktoren
  • Eigenschaftenaccessoren
  • Ereignisaccessoren
  • Anonymen Methoden
  • Lambdaausdrücke
  • Finalizer
  • Anderen lokalen Funktionen

Lokale Funktionen können jedoch nicht in einem Ausdruckskörpermember deklariert werden.

Hinweis

In einigen Fällen können Sie einen Lambdaausdruck zum Implementieren von Funktionen verwenden, die auch von einer lokalen Funktion unterstützt werden. Einen Vergleich finden Sie unter Lokale Funktionen im Vergleich zu Lambdaausdrücken.

Lokale Funktionen machen den Zweck Ihres Codes deutlich. Beim Lesen des Codes wird deutlich, dass die Methode nur von der enthaltenden Methode aufgerufen werden kann. Bei Teamprojekten wird auch verhindert, dass ein anderer Entwickler die Methode versehentlich direkt an anderer Stelle in der Klasse oder Struktur aufruft.

Syntax einer lokalen Funktion

Eine lokale Funktion wird definiert als eine geschachtelte Methode in einem enthaltenden Member. Ihre Definition besitzt die folgende Syntax:

<modifiers> <return-type> <method-name> <parameter-list>

Sie können die folgenden Modifizierer mit einer lokalen Funktion verwenden:

  • async
  • unsafe
  • static (in C# 8.0 und höher). Eine statische lokale Funktion kann keine lokalen Variablen oder den Instanzzustand erfassen.
  • extern (in C# 9.0 und höher). Eine externe lokale Funktion muss static sein.

Alle im enthaltenden Member definierten lokalen Variablen, einschließlich der Methodenparameter, sind in einer nicht statischen lokalen Funktion zugänglich.

Im Gegensatz zu einer Methodendefinition kann die Definition einer lokalen Funktion keinen Memberzugriffsmodifizierer enthalten. Da alle lokale Funktionen privat sind, generiert das Verwenden eines Zugriffsmodifizierers wie etwa das Schlüsselwort private den Compilerfehler CS0106 „Der Modifizierer ‚private‘ ist für dieses Element nicht gültig“.

Das folgende Beispiel definiert eine lokale Funktion mit dem Namen AppendPathSeparator, die für eine Methode mit dem Namen GetText privat ist:

private static string GetText(string path, string filename)
{
     var reader = File.OpenText($"{AppendPathSeparator(path)}{filename}");
     var text = reader.ReadToEnd();
     return text;

     string AppendPathSeparator(string filepath)
     {
        return filepath.EndsWith(@"\") ? filepath : filepath + @"\";
     }
}

Ab C# 9.0 können Sie Attribute auf eine lokale Funktion, ihre Parameter und Typparameter anwenden. Sehen Sie sich dazu das folgende Beispiel an:

#nullable enable
private static void Process(string?[] lines, string mark)
{
    foreach (var line in lines)
    {
        if (IsValid(line))
        {
            // Processing logic...
        }
    }

    bool IsValid([NotNullWhen(true)] string? line)
    {
        return !string.IsNullOrEmpty(line) && line.Length >= mark.Length;
    }
}

Im vorherigen Beispiel wird ein spezielles Attribut verwendet, um den Compiler bei der statischen Analyse in einem Nullable-Kontext zu unterstützen.

Lokale Funktionen und Ausnahmen

Eine nützliche Funktion von lokalen Funktionen ist die Tatsache, dass sie Ausnahmen sofort verfügbar machen können. Bei Methodeniteratoren werden Ausnahmen erst eingeblendet, wenn die zurückgegebene Sequenz aufgelistet wird, und nicht, wenn der Iterator abgerufen wird. Bei async-Methoden werden Ausnahmen festgestellt, wenn die zurückgegebene Aufgabe erwartet wird.

Das folgende Beispiel definiert eine OddSequence-Methode, die ungerade Zahlen in einem angegebenen Bereich aufzählt. Da eine Zahl größer als 100 an die OddSequence-Enumeratormethode übergeben wird, wird ArgumentOutOfRangeException ausgelöst. Die Ausgabe des Beispiels zeigt, dass die Ausnahme erst beim Durchlaufen der Zahlen und nicht beim Abrufen des Enumerators eingeblendet wird.

using System;
using System.Collections.Generic;

public class IteratorWithoutLocalExample
{
   public static void Main()
   {
      IEnumerable<int> xs = OddSequence(50, 110);
      Console.WriteLine("Retrieved enumerator...");

      foreach (var x in xs)  // line 11
      {
         Console.Write($"{x} ");
      }
   }

   public static IEnumerable<int> OddSequence(int start, int end)
   {
      if (start < 0 || start > 99)
         throw new ArgumentOutOfRangeException(nameof(start), "start must be between 0 and 99.");
      if (end > 100)
         throw new ArgumentOutOfRangeException(nameof(end), "end must be less than or equal to 100.");
      if (start >= end)
         throw new ArgumentException("start must be less than end.");

      for (int i = start; i <= end; i++)
      {
         if (i % 2 == 1)
            yield return i;
      }
   }
}
// The example displays the output like this:
//
//    Retrieved enumerator...
//    Unhandled exception. System.ArgumentOutOfRangeException: end must be less than or equal to 100. (Parameter 'end')
//    at IteratorWithoutLocalExample.OddSequence(Int32 start, Int32 end)+MoveNext() in IteratorWithoutLocal.cs:line 22
//    at IteratorWithoutLocalExample.Main() in IteratorWithoutLocal.cs:line 11

Wenn Sie Iteratorlogik in eine lokale Funktion platzieren, werden Ausnahmen bei der Argumentvalidierung ausgelöst, wenn Sie den Enumerator abrufen. Sehen Sie sich dazu das folgende Beispiel an:

using System;
using System.Collections.Generic;

public class IteratorWithLocalExample
{
   public static void Main()
   {
      IEnumerable<int> xs = OddSequence(50, 110);  // line 8
      Console.WriteLine("Retrieved enumerator...");

      foreach (var x in xs)
      {
         Console.Write($"{x} ");
      }
   }

   public static IEnumerable<int> OddSequence(int start, int end)
   {
      if (start < 0 || start > 99)
         throw new ArgumentOutOfRangeException(nameof(start), "start must be between 0 and 99.");
      if (end > 100)
         throw new ArgumentOutOfRangeException(nameof(end), "end must be less than or equal to 100.");
      if (start >= end)
         throw new ArgumentException("start must be less than end.");

      return GetOddSequenceEnumerator();

      IEnumerable<int> GetOddSequenceEnumerator()
      {
         for (int i = start; i <= end; i++)
         {
            if (i % 2 == 1)
               yield return i;
         }
      }
   }
}
// The example displays the output like this:
//
//    Unhandled exception. System.ArgumentOutOfRangeException: end must be less than or equal to 100. (Parameter 'end')
//    at IteratorWithLocalExample.OddSequence(Int32 start, Int32 end) in IteratorWithLocal.cs:line 22
//    at IteratorWithLocalExample.Main() in IteratorWithLocal.cs:line 8

Lokale Funktionen im Vergleich zu Lambdaausdrücken

Auf den ersten Blick sind lokale Funktionen und Lambdaausdrücke sehr ähnlich. In vielen Fällen ist die Entscheidung zwischen Lamdaausdrücken und lokalen Funktionen eine Frage des Formats und persönlicher Präferenz. Es gibt allerdings tatsächliche Unterschiede, wann das eine oder das andere verwendet werden kann. Diese sollten Ihnen bekannt sein.

Sehen wir uns die Unterschiede zwischen der Implementierungen des Fakultätsalgorithmus als lokale Funktion und als Lambdaausdruck an. Dies ist die Version mit einer lokalen Funktion:

public static int LocalFunctionFactorial(int n)
{
    return nthFactorial(n);

    int nthFactorial(int number) => number < 2 
        ? 1 
        : number * nthFactorial(number - 1);
}

Diese Version verwendet Lambdaausdrücke:

public static int LambdaFactorial(int n)
{
    Func<int, int> nthFactorial = default(Func<int, int>);

    nthFactorial = number => number < 2
        ? 1
        : number * nthFactorial(number - 1);

    return nthFactorial(n);
}

Benennung

Lokale Funktionen werden explizit wie Methoden benannt. Lambdaausdrücke sind anonyme Methoden und müssen Variablen eines delegate-Typs zugewiesen werden. In der Regel handelt es sich entweder um Action- oder Func-Typen. Die Deklaration einer lokalen Funktion erfolgt so ähnlich wie das Schreiben einer normalen Methode. Sie müssen dazu einen Rückgabetyp und eine Funktionssignatur deklarieren.

Funktionssignaturen und Typen für Lambdaausdrücke

Beim Bestimmen der Argument- und Rückgabetypen sind Lambdaausdrücke auf den Typ der Action/Func-Variablen angewiesen, der sie zugewiesen werden. Da die Syntax in lokalen Funktionen stark einer normalen Methode ähnelt, sind die Argumenttypen und der Rückgabetyp bereits Teil der Funktionsdeklaration.

Ab C# 10 weisen einige Lambdaausdrücke einen natürlichen Typ auf, mit dem der Compiler den Rückgabetyp und die Parametertypen des Lambdaausdrucks ableiten kann.

Definite assignment (Festgelegte Zuweisung)

Lambdaausdrücke sind Objekte, die zur Laufzeit deklariert und zugewiesen werden. Damit ein Lambdaausdruck verwendet werden kann, muss er definitiv zugewiesen werden: die Action/Func-Variable, der er zugewiesen wird, muss deklariert werden. Anschließend muss der Lambdaausdruck der Variablen zugewiesen werden. Beachten Sie, dass LambdaFactorial den Lambdaausdruck nthFactorial deklarieren und initialisieren muss, bevor dieser definiert wird. Wird das nicht gemacht, führt dies zu einem Kompilierzeitfehler, weil auf nthFactorial verwiesen wurde, bevor es zugewiesen wurde.

Lokale Funktionen werden zur Kompilierzeit definiert. Da sie keinen Variablen zugewiesen werden, kann an jeder Stelle im Code innerhalb des Gültigkeitsbereichs der Funktion darauf verwiesen werden. Im ersten Beispiel LocalFunctionFactorial konnten Sie die lokale Funktion entweder oberhalb oder unterhalb der return-Anweisung deklarieren, ohne Compilerfehler auszulösen.

Diese Unterschiede bedeuten, dass rekursive Algorithmen mit lokalen Funktionen leichter erstellt werden können. Sie können eine lokale Funktion deklarieren und definieren, die sich selbst aufruft. Lambdaausdrücke müssen deklariert werden, und dann muss ihnen ein Standardwert zugewiesen werden, bevor sie erneut einem Text zugewiesen werden können, der auf den gleichen Lambdaausdruck verweist.

Implementierung als Delegat

Lambdaausdrücke werden bei der Deklaration in Delegate konvertiert. Lokale Funktionen sind flexibler, da sie wie eine herkömmliche Methode oder als Delegat geschrieben werden können. Lokale Funktionen werden nur dann in Delegate konvertiert, wenn sie als Delegate verwendet werden.

Wenn Sie eine lokale Funktion deklarieren und nur darauf verweisen, indem Sie sie wie eine Methode aufrufen, wird sie nicht in einen Delegaten konvertiert.

Erfassung von Variablen

Die Regeln für definitive Zuweisungen gelten auch für alle Variablen, die von der lokalen Funktion oder dem Lambdaausdruck erfasst werden. Zudem kann der Compiler statische Analysen durchführen, mit denen lokale Funktionen erfasste Variablen im einschließenden Gültigkeitsbereich definitiv zuweisen können. Betrachten Sie das folgende Beispiel:

int M()
{
    int y;
    LocalFunction();
    return y;

    void LocalFunction() => y = 0;
}

Der Compiler kann festlegen, dass LocalFunction``y bei Aufruf definitiv zuweist. Da LocalFunction vor der return-Anweisung aufgerufen wird, wird y definitiv bei der return-Anweisung zugewiesen.

Beachten Sie, dass die lokale Funktion als Delegattyp implementiert wird, wenn diese lokale Funktion Variablen im einschließenden Gültigkeitsbereich erfasst.

Heapzuweisungen

Je nach Verwendung können lokale Funktionen Heapzuweisungen vermeiden, die immer für Lambdaausdrücke erforderlich sind. Wenn eine lokale Funktion nie in einen Delegaten konvertiert wird und keine der von der lokalen Funktion erfassten Variablen von anderen Lambdaausdrücken oder lokalen Funktionen, die in Delegate konvertiert werden, erfasst wird, kann der Compiler Heapzuweisungen vermeiden.

Betrachten Sie das folgende asynchrone Beispiel:

public async Task<string> PerformLongRunningWorkLambda(string address, int index, string name)
{
    if (string.IsNullOrWhiteSpace(address))
        throw new ArgumentException(message: "An address is required", paramName: nameof(address));
    if (index < 0)
        throw new ArgumentOutOfRangeException(paramName: nameof(index), message: "The index must be non-negative");
    if (string.IsNullOrWhiteSpace(name))
        throw new ArgumentException(message: "You must supply a name", paramName: nameof(name));

    Func<Task<string>> longRunningWorkImplementation = async () =>
    {
        var interimResult = await FirstWork(address);
        var secondResult = await SecondStep(index, name);
        return $"The results are {interimResult} and {secondResult}. Enjoy.";
    };

    return await longRunningWorkImplementation();
}

Der Abschluss dieses Lambdaausdrucks enthält die Variablen address, index und name. Im Fall von lokalen Funktionen ist das Objekt, das den Abschluss implementiert, möglicherweise vom Typ struct. Dieser struct-Typ würde per Verweis an die lokale Funktion übergeben. Dieser Unterschied bei der Implementierung würde bei einer Zuweisung gespart.

Die für Lambdaausdrücke erforderliche Instanziierung bedeutet zusätzliche Speicherbelegung, was ein Leistungsfaktor in zeitkritischen Codepfaden sein kann. Lokale Funktionen erfordern diesen Mehraufwand nicht. Im obigen Beispiel hat die Version mit der lokalen Funktion zwei Zuordnungen weniger als die Version mit dem Lambdaausdruck.

Wenn Sie wissen, dass Ihre lokale Funktion nicht in einen Delegaten konvertiert wird und dass keine der von ihr erfassten Variablen auch von anderen Lambdaausdrücken oder lokalen Funktionen, die in Delegate konvertiert werden, erfasst wird, können Sie durch Deklarieren der lokalen Funktion als statisch (static) verhindern, dass Ihre lokale Funktion im Heap zugewiesen wird. Beachten Sie, dass dieses Feature in C# 8.0 und höher verfügbar ist.

Hinweis

Die Entsprechung dieser Methode mit der lokalen Funktion verwendet auch eine Klasse für den Abschluss. Ob der Abschluss für eine lokale Funktion als class oder struct implementiert wird, ist ein Implementierungsdetail. Eine lokale Funktion verwendet möglicherweise struct, während ein Lambdaausdruck immer class nutzt.

public async Task<string> PerformLongRunningWork(string address, int index, string name)
{
    if (string.IsNullOrWhiteSpace(address))
        throw new ArgumentException(message: "An address is required", paramName: nameof(address));
    if (index < 0)
        throw new ArgumentOutOfRangeException(paramName: nameof(index), message: "The index must be non-negative");
    if (string.IsNullOrWhiteSpace(name))
        throw new ArgumentException(message: "You must supply a name", paramName: nameof(name));

    return await longRunningWorkImplementation();

    async Task<string> longRunningWorkImplementation()
    {
        var interimResult = await FirstWork(address);
        var secondResult = await SecondStep(index, name);
        return $"The results are {interimResult} and {secondResult}. Enjoy.";
    }
}

Verwendung des Schlüsselworts yield

Eine letzter Vorteil, der in diesem Beispiel zu kurz gekommen ist, besteht darin, dass lokale Funktionen mithilfe der yield return-Syntax als Iteratoren implementiert werden können, um eine Sequenz von Werten zu erzeugen.

public IEnumerable<string> SequenceToLowercase(IEnumerable<string> input)
{
    if (!input.Any())
    {
        throw new ArgumentException("There are no items to convert to lowercase.");
    }
    
    return LowercaseIterator();
    
    IEnumerable<string> LowercaseIterator()
    {
        foreach (var output in input.Select(item => item.ToLower()))
        {
            yield return output;
        }
    }
}

Die yield return-Anweisung ist in Lambdaausdrücken unzulässig. Weitere Informationen finden Sie unter Compilerfehler CS1621.

Während lokale Funktionen für Lambdaausdrücke als überflüssig erscheinen, dienen sie tatsächlich anderen Zwecken und haben unterschiedliche Verwendungen. Lokale Funktionen sind effizienter, im Fall dass Sie eine Funktion schreiben möchten, die nur aus dem Kontext einer anderen Methode abgerufen wird.

Siehe auch