Funciones Locales en Lenguaje C#

A partir de C# 7.0, C# admite funciones locales. Las funciones locales son métodos privados de un tipo que están anidados en otro miembro. Solo se pueden llamar desde su miembro contenedor. Las funciones locales se pueden declarar en y llamar desde:

  • Métodos, especialmente los métodos de iterador y asincrónicos
  • Constructores
  • Descriptores de acceso de propiedad
  • Descriptores de acceso de un evento
  • Métodos anónimos
  • Expresiones lambda
  • Finalizadores
  • Otras funciones locales

En cambio, las funciones locales no se pueden declarar dentro de un miembro con forma de expresión.

 Nota

En algunos casos, puede usar una expresión lambda para implementar funcionalidad compatible también con una función local. Para ver una comparación, consulte Funciones locales frente a expresiones lambda.

Las funciones locales aclaran la intención del código. Cualquiera que lea el código puede ver que solo el método que lo contiene puede llamar al método. Para los proyectos de equipo, también hacen que sea imposible que otro desarrollador llame erróneamente al método de forma directa desde cualquier otro punto de la clase o estructura.

Sintaxis de función local

Una función local se define como un método anidado dentro de un miembro contenedor. Su definición tiene la siguiente sintaxis:

C#

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

Puede usar los siguientes modificadores con una función local:

  • async
  • unsafe
  • static (en C# 8.0 y posterior). Una función local estática no puede capturar variables locales o el estado de la instancia.
  • extern (en C# 9.0 y posterior). Una función local externa debe ser static.

Todas las variables locales que se definen en el miembro contenedor, incluidos sus parámetros de método, son accesibles en la función local no estática.

A diferencia de una definición de método, una definición de función local no puede incluir el modificador de acceso de los miembros. Dado que todas las funciones locales son privadas, incluido un modificador de acceso, como la palabra clave private, se genera el error del compilador CS0106, “El modificador ‘private’ no es válido para este elemento”.

En el ejemplo siguiente, se define una función local denominada AppendPathSeparator que es privada a un método denominado GetText:

C#

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

A partir de C# 9.0, puede aplicar atributos a una función local, sus parámetros y parámetros de tipo, como se muestra en el ejemplo siguiente:

C#

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

En el ejemplo anterior se usa un atributo especial para ayudar al compilador en el análisis estático en un contexto que acepta valores NULL.

Excepciones y funciones locales

Una de las características útiles de las funciones locales es que pueden permitir que las excepciones aparezcan inmediatamente. Para los iteradores de método, las excepciones aparecen solo cuando la secuencia devuelta se enumera y no cuando se recupera el iterador. Para los métodos asincrónicos, las excepciones producidas en un método asincrónico se observan cuando se espera la tarea devuelta.

En el ejemplo siguiente se define un método OddSequence que enumera los números impares de un intervalo especificado. Dado que pasa un número mayor de 100 al método de enumerador OddSequence, el método produce una excepción ArgumentOutOfRangeException. Como se muestra en el resultado del ejemplo, la excepción aparece solo cuando itera los números, y no al recuperar el enumerador.

C#

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

Si coloca la lógica de iterador en una función local, se iniciarán excepciones de validación de argumentos al recuperar el enumerador, como se muestra en el ejemplo siguiente:

C#

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

Funciones locales frente a expresiones lambda

A primera vista, las funciones locales y las expresiones lambda son muy similares. En muchos casos, la elección entre usar expresiones lambda y funciones locales es una cuestión de estilo y de preferencia personal, aunque hay diferencias que debe tener en cuenta acerca de dónde puede usar una u otra.

Vamos a examinar las diferencias entre las implementaciones de la función local y la expresión lambda del algoritmo factorial. Esta es la versión que usa una función local:

C#

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

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

Esta versión usa expresiones lambda:

C#

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

Las funciones locales se nombran explícitamente como métodos. Las expresiones lambda son métodos anónimos y deben asignarse a variables de un tipo delegate, normalmente los tipos Action o Func. Cuando se declara una función local, el proceso es como escribir un método normal: se declaran un tipo de valor devuelto y una signatura de función.

Signaturas de función y tipos de expresiones lambda

Las expresiones lambda se basan en el tipo de la variable Action/Func al que están asignadas para determinar los tipos de argumento y de valor devuelto. En las funciones locales, dado que la sintaxis se parece mucho a escribir un método normal, los tipos de argumento y el tipo de valor devuelto ya forman parte de la declaración de función.

Asignación definitiva

Las expresiones lambda son objetos que se declaran y se asignan en tiempo de ejecución. Para poder usar una expresión lambda, debe asignarse definitivamente: se debe declarar la variable Action/Func a la que se va a asignar y luego asignar a esta la expresión lambda. Observe que LambdaFactorial debe declarar e inicializar la expresión lambda nthFactorial antes de definirla. De no hacerlo, se produce un error en tiempo de compilación por hacer referencia a nthFactorial antes de asignarlo.

Las funciones locales se definen en tiempo de compilación. Dado que no están asignadas a variables, se puede hacer referencia a ellas desde cualquier ubicación del código que esté en ámbito; en el primer ejemplo LocalFunctionFactorial, se podría declarar la función local por encima o por debajo de la instrucción return y no desencadenar ningún error del compilador.

Estas diferencias implican que los algoritmos recursivos son más fáciles de crear usando funciones locales. Puede declarar y definir una función local que se llama a sí misma. Las expresiones lambda se deben declarar y se les debe asignar un valor predeterminado para que se les pueda volver a asignar un cuerpo que haga referencia a la misma expresión lambda.

Implementación como delegado

Las expresiones lambda se convierten en delegados en el momento en que se declaran. Las funciones locales son más flexibles, ya que se pueden escribir como un método tradicional o como un delegado. Las funciones locales solo se convierten en delegados cuando se usan como delegados.

Si se declara una función local y solo se hace referencia a ella llamándola como un método, no se convertirá en un delegado.

Captura de variables

Las reglas de asignación definitiva también afectan a las variables capturadas por la función local o la expresión lambda. El compilador puede efectuar un análisis estático que permite a las funciones locales asignar definitivamente variables capturadas en el ámbito de inclusión. Considere este ejemplo:

C#

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

    void LocalFunction() => y = 0;
}

El compilador puede determinar que LocalFunction asigna y definitivamente cuando se le llama. Como se llama a LocalFunction antes de la instrucción returny se asigna definitivamente en la instrucción return.

Observe que cuando una función local captura variables en el ámbito de inclusión, la función local se implementa como un tipo delegado.

Asignaciones de montón

Dependiendo de su uso, las funciones locales pueden evitar las asignaciones de montón que siempre son necesarias para las expresiones lambda. Si una función local no se convierte nunca en un delegado y ninguna de las variables capturadas por la función local se captura con otras expresiones lambda o funciones locales que se han convertido en delegados, el compilador puede evitar las asignaciones de montón.

Considere este ejemplo asincrónico:

C#

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 clausura de esta expresión lambda contiene las variables addressindex y name. En el caso de las funciones locales, el objeto que implementa el cierre puede ser un tipo struct. Luego, ese tipo de estructura se pasaría por referencia a la función local. Esta diferencia de implementación supondría un ahorro en una asignación.

La creación de instancias necesaria para las expresiones lambda significa asignaciones de memoria adicionales, lo que puede ser un factor de rendimiento en rutas de acceso de código crítico en el tiempo. Las funciones locales no suponen esta sobrecarga. En el ejemplo anterior, la versión de las funciones locales tiene dos asignaciones menos que la versión de la expresión lambda.

Si sabe que la función local no se va a convertir en delegado y ninguna de las variables capturadas por ella han sido capturadas por otras expresiones lambda o funciones locales que se han convertido en delegados, puede garantizar la no asignación de la función local al montón al declararla como función local static. Observe que esta característica está disponible en C# 8.0 y versiones más recientes.

 Nota

La función local equivalente de este método también usa una clase para el cierre. Si el cierre de una función local se implementa como class o struct es un detalle de implementación. Una función local puede usar struct mientras una expresión lambda siempre usará class.

C#

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 de la palabra clave yield

Una ventaja final que no se muestra en este ejemplo es que las funciones locales pueden implementarse como iteradores, con la sintaxis yield return para producir una secuencia de valores.

C#

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

La instrucción yield return no se permite en las expresiones lambda; vea el Error del compilador CS1621.

Aunque las funciones locales pueden parecer redundantes con respecto a las expresiones lambda, en realidad tienen finalidades y usos diferentes. Las funciones locales son más eficaces si se quiere escribir una función a la que solo se llame desde el contexto de otro método.