Funções locais (Guia de Programação em C#)

Funções locais são métodos de um tipo que estão aninhados em outro membro. Eles só podem ser chamados do membro que os contém. Funções locais podem ser declaradas em e chamadas de:

  • Métodos, especialmente os métodos iteradores e os métodos assíncronos
  • Construtores
  • Acessadores de propriedades
  • Acessadores de eventos
  • Métodos anônimos
  • Expressões lambda
  • Finalizadores
  • Outras funções locais

No entanto, as funções locais não podem ser declaradas dentro de um membro apto para expressão.

Observação

Em alguns casos, você pode usar uma expressão lambda para implementar uma funcionalidade que também tem suporte por uma função local. Para obter uma comparação, confira Funções locais em comparação a expressões Lambda.

Funções locais tornam a intenção do seu código clara. Qualquer pessoa que leia o código poderá ver que o método não pode ser chamado, exceto pelo método que o contém. Para projetos de equipe, elas também impossibilitam que outro desenvolvedor chame o método por engano diretamente de qualquer outro lugar na classe ou no struct.

Sintaxe de função local

Uma função local é definida como um método aninhado dentro de um membro recipiente. Sua definição tem a seguinte sintaxe:

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

Observação

O <parameter-list> não deve conter os parâmetros nomeados com a palavra-chave contextualvalue. O compilador cria a variável temporária "value", que contém as variáveis externas referenciadas, que posteriormente causam ambiguidade e também podem causar um comportamento inesperado.

Você pode usar os seguintes modificadores com uma função local:

  • async
  • unsafe
  • static Uma função local estática não pode capturar variáveis locais nem o estado da instância.
  • extern Uma função local externa deve ser static.

Todas as variáveis locais definidas no membro relativo, incluindo os parâmetros do método, são acessíveis em uma função local não estática.

Ao contrário de uma definição de método, uma definição de função local não pode incluir o modificador de acesso de membro. Já que todas as funções locais são privadas, incluir um modificador de acesso como a palavra-chave private gera o erro do compilador CS0106, "O modificador 'private' não é válido para este item".

O exemplo a seguir define uma função local chamada AppendPathSeparator que é privada para um método chamado 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 + @"\";
     }
}

Você pode aplicar atributos a uma função local, seus parâmetros e parâmetros de tipo, como mostra o exemplo a seguir:

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

O exemplo anterior usa um atributo especial para ajudar o compilador na análise estática em um contexto anulável.

Funções locais e exceções

Um dos recursos úteis de funções locais é que elas podem permitir que exceções sejam apresentadas imediatamente. Para métodos de iteradores, as exceções são apresentadas somente quando a sequência retornada é enumerada e não quando o iterador é recuperado. Para métodos assíncronos, as exceções geradas em um método assíncrono são observadas quando a tarefa retornada é esperada.

O exemplo a seguir define um método OddSequence que enumera números ímpares em um intervalo especificado. Já que ele passa um número maior que 100 para o método enumerador OddSequence, o método gera uma ArgumentOutOfRangeException. Assim como demonstrado pela saída do exemplo, a exceção é apresentada somente quando você itera os números e não quando você recupera o enumerador.

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 você colocar a lógica do iterador em uma função local, as exceções de validação de argumento serão geradas quando você recuperar o enumerador, como mostra o exemplo a seguir:

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

Funções locais vs. expressões lambda

À primeira vista, funções locais e expressões lambda são muito semelhantes. Em muitos casos, a escolha entre usar expressões lambda e funções locais é uma questão de estilo e preferência pessoal. No entanto, há diferenças reais nos casos em que você pode usar uma ou outra, e é importante conhecer essas diferenças.

Examinaremos as diferenças entre a função local e as implementações de expressão lambda do algoritmo fatorial. Esta é a versão que usa uma função local:

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

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

Esta versão usa expressões 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);
}

Nomenclatura

As funções locais são explicitamente nomeadas como métodos. As expressões lambda são métodos anônimos e precisam ser atribuídas a variáveis de um tipo delegate, normalmente os tipos Action ou Func. Quando você declara uma função local, o processo é como gravar um método normal. Você declara um tipo de retorno e uma assinatura de função.

Assinaturas de função e tipos de expressão lambda

As expressões lambda dependem do tipo da variável Action/Func atribuída para determinar o argumento e os tipos de retorno. Em funções locais, como a sintaxe é muito parecida com gravar um método normal, os tipos de argumento e o tipo de retorno já fazem parte da declaração de função.

A partir do C# 10, algumas expressões lambda têm um tipo natural, o que permite que o compilador infira o tipo de retorno e os tipos de parâmetro da expressão lambda.

Atribuição definida

As expressões lambda são objetos declarados e atribuídos em tempo de execução. Para que uma expressão lambda seja usada, ela precisa ser atribuída de maneira definitiva: a variável Action/Func à qual ela será atribuída deve ser declarada e a expressão lambda atribuída a ela. Observe que LambdaFactorial deve declarar e inicializar a expressão lambda nthFactorial antes de defini-la. Não fazer isso resulta em um erro em tempo de compilação para referenciar nthFactorial antes de atribuí-lo.

As funções locais são definidas em tempo de compilação. Como elas não são atribuídas a variáveis, podem ser referenciadas em qualquer local de código em que esteja no escopo. Em nosso primeiro exemplo LocalFunctionFactorial, podemos declarar nossa função local acima ou abaixo da instrução return e não disparar erros do compilador.

Essas diferenças significam que os algoritmos recursivos são mais fáceis de criar usando funções locais. Você pode declarar e definir uma função local que chame a si mesma. As expressões lambda devem ser declaradas e atribuídas a um valor padrão antes que possam ser reatribuídas a um corpo que referencie a mesma expressão lambda.

Implementação como delegado

As expressões lambda são convertidas em delegados quando declaradas. As funções locais são mais flexíveis, pois podem ser gravadas como método tradicional ou como delegado. As funções locais só são convertidas em delegados quando usadas como delegado.

Se você declarar uma função local e só referenciá-la ao chamá-la como um método, ela não será convertida em um delegado.

Captura de variável

As regras de atribuição definitiva também afetam as variáveis capturadas pela função local ou pela expressão lambda. O compilador pode executar uma análise estática, que permite que as funções locais atribuam de maneira definitiva as variáveis capturadas no escopo delimitador. Considere este exemplo:

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

    void LocalFunction() => y = 0;
}

O compilador pode determinar que LocalFunction definitivamente atribua y quando chamada. Como a LocalFunction é chamada antes da instrução return, y é atribuído definitivamente na instrução return.

Observe que quando uma função local captura variáveis no escopo delimitador, a função local é implementada usando um fechamento, como ocorre com os tipos delegados.

Alocações de heap

Dependendo do uso, as funções locais podem evitar as alocações de heap que são sempre necessárias nas expressões lambda. Se uma função local nunca é convertida em um delegado, e nenhuma das variáveis capturadas pela função local é capturada por outras lambdas ou funções locais convertidas em delegados, o compilador pode evitar alocações de heap.

Considere este exemplo assíncrono:

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

O fechamento desta expressão lambda contém as variáveis address, index e name. No caso de funções locais, o objeto que implementa o encerramento pode ser um tipo struct. Esse tipo de struct seria passado por referência à função local. Essa diferença na implementação poderia economizar em uma alocação.

A instanciação necessária para expressões lambda ocasiona alocações adicionais de memória, tornando-se um fator de desempenho em caminhos de código com tempo crítico. As funções locais não incorrem nessa sobrecarga. No exemplo acima, a versão das funções locais tem duas alocações a menos que a versão da expressão lambda.

Se você sabe que a função local não será convertida em um delegado e nenhuma das variáveis capturadas por ela será capturada por outras lambdas ou funções locais convertidas em delegados, você pode garantir que a função local evite ser alocada no heap, declarando-a como função local static.

Dica

Habilite a regra de estilo de código .NET IDE0062, para garantir que as funções locais sejam sempre marcadas como static.

Observação

A função local equivalente desse método também usa uma classe para o fechamento. O fechamento de uma função local ser implementado como um class ou como um struct, trata-se de um detalhe de implementação. Uma função local pode usar um struct, enquanto uma lambda sempre usará um 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 da palavra-chave yield

Uma vantagem final não demonstrada neste exemplo é que as funções locais podem ser implementadas como iteradores, usando a sintaxe yield return para produzir uma sequência de valores.

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

A instrução yield return não é permitida em expressões lambda. Para obter mais informações, confira Erro do compilador CS1621.

Embora as funções locais possam parecer redundantes para expressões lambda, elas realmente têm finalidades e usos diferentes. As funções locais são mais eficientes para quando você deseja escrever uma função que é chamada apenas do contexto de outro método.

Especificação da linguagem C#

Para obter mais informações, confira a seção Declarações de função local da Especificação da linguagem C#.

Confira também