Funzioni locali (Guida per programmatori C#)

Le funzioni locali sono metodi di un tipo annidati in un altro membro. Possono essere chiamate solo dal relativo membro contenitore. Le funzioni locali, in particolare, possono essere dichiarate in e chiamate da:

  • Metodi, soprattutto metodi iteratori e metodi asincroni
  • Costruttori
  • Funzioni di accesso alle proprietà
  • Funzioni di accesso agli eventi
  • Metodi anonimi
  • Espressioni lambda
  • Finalizzatori
  • Altre funzioni locali

Le funzioni locali, tuttavia, non possono essere dichiarate all'interno di un membro con corpo di espressione.

Nota

In alcuni casi, è possibile usare un'espressione lambda per implementare le funzionalità supportate anche da una funzione locale. Per un confronto, vedere Confronto tra funzioni locali ed espressioni Lambda.

Le funzioni locali rendono chiaro l'obiettivo del codice. Chiunque legga il codice, ad esempio, può vedere che il metodo può essere chiamato solo dal metodo contenitore. Per i progetti in team, le funzioni locali impediscono anche a un altro sviluppatore di chiamare per errore il metodo direttamente da un altro punto della classe o dello struct.

Sintassi delle funzioni locali

Una funzione locale viene definita come metodo annidato all'interno di un membro contenitore. La definizione presenta la sintassi seguente:

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

Nota

<parameter-list> non deve contenere i parametri denominati con la parola chiave contestualevalue. Il compilatore crea la variabile temporanea "value", che contiene le variabili esterne a cui si fa riferimento, cosa che potrà in seguito causare ambiguità e anche comportamenti imprevisti.

È possibile usare i modificatori seguenti con una funzione locale:

  • async
  • unsafe
  • static Una funzione locale statica non può acquisire variabili locali o lo stato dell'istanza.
  • extern Una funzione locale esterna deve essere static.

Tutte le variabili locali definite nel membro contenitore, inclusi i relativi parametri di metodo, sono accessibili in una funzione locale non statica.

Contrariamente a una definizione di metodo, una definizione di funzione locale non può contenere il modificatore di accesso del membro. Poiché tutte le funzioni locali sono private, l'integrazione di un modificatore di accesso come la parola chiave private genera l'errore del compilatore CS0106: "Il modificatore 'private' non è valido per questo elemento".

L'esempio seguente definisce una funzione locale denominata AppendPathSeparator, privata di un metodo denominato GetText:

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 + @"\";
     }
}

È possibile applicare attributi a una funzione locale, ai relativi parametri e ai parametri di tipo, come illustrato nell'esempio seguente:

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

Nell'esempio precedente viene usato un attributo speciale per assistere il compilatore nell'analisi statica in un contesto che ammette i valori Null.

Funzioni locali ed eccezioni

Le funzioni locali offrono il vantaggio di consentire alle eccezioni di essere rilevate immediatamente. Nel caso degli iteratori di metodo, le eccezioni vengono rilevate solo nel momento in cui la sequenza restituita viene enumerata e non quando viene recuperato l'iteratore. Nel caso dei metodi asincroni, qualsiasi eccezione generata viene rilevata mentre è attesa l'attività restituita.

L'esempio seguente definisce un metodo OddSequence che enumera i numeri dispari in un intervallo specifico. Poiché al metodo enumeratore OddSequence viene trasmesso un numero maggiore di 100, il metodo genera una ArgumentOutOfRangeException. Come illustrato dall'output dell'esempio, l'eccezione viene rilevata solo nel momento in cui vengono iterati i numeri e non quando si recupera l'enumeratore.

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

Se si inserisce la logica iteratore in una funzione locale, le eccezioni di convalida degli argomenti vengono generate quando si recupera l'enumeratore, come illustrato nell'esempio seguente:

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

Funzioni locali ed espressioni lambda

A prima vista, le funzioni locali e le espressioni lambda sono molto simili. In molti casi, la scelta tra l'uso di funzioni locali ed espressioni lambda è una questione di stile e preferenze personali. Esistono tuttavia differenze reali di cui è necessario essere consapevoli nei casi in cui è possibile usare le une o le altre.

Si esamineranno ora le differenze tra implementazioni di funzioni locali e implementazioni di espressioni lambda dell'algoritmo fattoriale. Ecco la versione che usa una funzione locale:

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

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

Questa versione usa espressioni lambda:

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

Denominazione

Le funzioni locali sono denominate in modo esplicito come metodi. Le espressioni lambda sono metodi anonimi e devono essere assegnate a variabili di un tipo delegate, in genere tipi Action o Func. Quando si dichiara una funzione locale, il processo è simile alla scrittura di un metodo normale; si dichiarano un tipo restituito e una firma di funzione.

Firme della funzione e tipi di espressioni lambda

Le espressioni lambda si basano sul tipo della variabile Action/Func assegnata per determinare l'argomento e i tipi restituiti. Nelle funzioni locali, poiché la sintassi è molto simile alla scrittura di un metodo normale, i tipi di argomento e il tipo restituito fanno già parte della dichiarazione di funzione.

A partire da C# 10, alcune espressioni lambda hanno un tipo naturale, che consente al compilatore di dedurre il tipo restituito e i tipi di parametro dell'espressione lambda.

Assegnazione definita

Le espressioni lambda sono oggetti dichiarati e assegnati in fase di esecuzione. Affinché venga usata un'espressione lambda, deve essere assegnata sicuramente: la variabile Action/Func a cui verrà assegnata deve essere dichiarata e l'espressione lambda deve esservi assegnata. Si noti che nthFactorial deve dichiarare e inizializzare l'espressione lambda LambdaFactorial prima di definirla. In caso contrario, si verifica un errore di compilazione per fare riferimento a nthFactorial prima di assegnarla.

Le funzioni locali vengono definite in fase di compilazione. Poiché non sono assegnate alle variabili, è possibile farvi riferimento da qualsiasi posizione del codice nell'ambito. Nel primo esempio LocalFunctionFactorial è possibile dichiarare la funzione locale sopra o sotto l'istruzione return senza attivare errori del compilatore.

Queste differenze fanno sì che gli algoritmi ricorsivi sino più facili da creare usando funzioni locali. È possibile dichiarare e definire una funzione locale che chiama se stessa. Le espressioni lambda devono essere dichiarate e a queste deve essere assegnato un valore predefinito prima che sia possibile riassegnarle a un corpo che fa riferimento alla stessa espressione lambda.

Implementazione come delegato

Le espressioni lambda vengono convertite in delegati quando vengono dichiarate. Le funzioni locali sono più flessibili in quanto possono essere scritte come un metodo tradizionale o come delegato. Le funzioni locali vengono convertite in delegati solo quando vengono usate come delegati.

Se si dichiara una funzione locale e si fa riferimento a questa solo chiamandola come un metodo, non verrà convertita in delegato.

Acquisizione di variabili

Le regole di assegnazione definita influiscono anche sulle variabili acquisite dalla funzione locale o dall'espressione lambda. Il compilatore può eseguire un'analisi statica che consente alle funzioni locali di assegnare in modo certo variabili acquisite nell'ambito che le contiene. Si consideri questo esempio:

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

    void LocalFunction() => y = 0;
}

Il compilatore può determinare che LocalFunction assegna in modo certo y quando viene chiamata. Poiché LocalFunction viene chiamata prima dell'istruzione return, y viene assegnata in modo certo in corrispondenza dell'istruzione return.

Si noti che quando una funzione locale acquisisce variabili nell'ambito di inclusione, viene implementata come tipo delegato.

Allocazioni di heap

A seconda del loro uso, le funzioni locali possono evitare le allocazioni di heap, che sono sempre necessarie per le espressioni lambda. Se una funzione locale non viene mai convertita in delegato e nessuna delle variabili acquisite dalla funzione locale viene acquisita da altre espressioni lambda o funzioni locali convertite in delegati, il compilatore può evitare allocazioni di heap.

Si consideri questo esempio asincrono:

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

La chiusura per questa espressione lambda contiene le variabili address, index e name. Nel caso delle funzioni locali, l'oggetto che implementa la chiusura può essere di tipo struct Tale tipo struct verrebbe passato per riferimento alla funzione locale. Questa differenza di implementazione consentirebbe di risparmiare un'allocazione.

La creazione di istanze necessaria per le espressioni lambda comporta allocazioni di memoria aggiuntive che possono ridurre le prestazioni nei percorsi di codice in cui il tempo è un fattore cruciale. Questo sovraccarico non si verifica per le funzioni locali. Nell'esempio precedente, la versione con funzioni locali presenta due allocazioni in meno rispetto alla versione con espressioni lambda.

Se si sa che la funzione locale non verrà convertita in un delegato e che nessuna delle variabili acquisite da essa viene acquisita da altre espressioni lambda o funzioni locali convertite in delegati, è possibile garantire che la funzione locale eviti l'allocazione nell'heap dichiarandola come funzione locale static.

Suggerimento

Abilitare la regola di stile del codice .NET IDE0062 per assicurarsi che le funzioni locali siano sempre contrassegnate come static.

Nota

La funzione locale equivalente di questo metodo usa anche una classe per la chiusura. Se la chiusura di una funzione locale viene implementata come class o struct non ha molta importanza. Una funzione locale può usare struct mentre un'espressione lambda userà sempre class.

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

Uso della parola chiave yield

Come ultimo vantaggio, non illustrato in questo esempio, le funzioni locali possono essere implementate come iteratori usando la sintassi yield return per produrre una sequenza di valori.

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

L'istruzione yield return non è consentita nelle espressioni lambda. Per altre informazioni, vedere l'errore del compilatore CS1621.

Sebbene le funzioni locali possano apparire ridondanti rispetto alle espressioni lambda, in realtà hanno finalità e usi diversi. Le funzioni locali sono più efficienti nel caso si voglia scrivere una funzione che viene chiamata solo dal contesto di un altro metodo.

Specifiche del linguaggio C#

Per altre informazioni, vedere la sezione Dichiarazioni di funzioni locali della Specifica del linguaggio C#.

Vedi anche