Capítulo 4. C# avanzado

Este trabajo se ha traducido utilizando IA. Agradecemos tus opiniones y comentarios: translation-feedback@oreilly.com

En este capítulo, cubrimos temas avanzados de C# que se basan en conceptos explorados en los Capítulos 2 y 3. Debes leer las cuatro primeras secciones secuencialmente; puedes leer las secciones restantes en cualquier orden.

Delegados

Un delegado es un objeto que sabe cómo llamar a un método.

Un tipo de delegado define el tipo de método al que pueden llamar las instancias delegadas. En concreto, define el tipo de retorno del método y los tipos de sus parámetros. A continuación se define un tipo de delegado llamado Transformer:

delegate int Transformer (int x);

Transformer es compatible con cualquier método con un tipo de retorno int y un único parámetro int, como éste:

int Square (int x) { return x * x; }

O, más escuetamente:

int Square (int x) => x * x;

Al asignar un método a una variable delegada, se crea una instancia delegada:

Transformer t = Square;

Puedes invocar una instancia de delegado del mismo modo que un método:

int answer = t(3);    // answer is 9

Aquí tienes un ejemplo completo:

Transformer t = Square;          // Create delegate instance
int result = t(3);               // Invoke delegate
Console.WriteLine (result);      // 9

int Square (int x) => x * x;

delegate int Transformer (int x);   // Delegate type declaration

Una instancia de delegado actúa literalmente como delegado de la persona que llama: la persona que llama invoca al delegado y, a continuación, el delegado llama al método de destino. Esta indirección desvincula a la persona que llama del método de destino.

La declaración:

Transformer t = Square;

es la abreviatura de

Transformer t = new Transformer (Square);
Nota

Técnicamente, estamos especificando un grupo de métodos cuando nos referimos a Square sin paréntesis ni argumentos. Si el método está sobrecargado, C# elegirá la sobrecarga correcta basándose en la firma del delegado al que se asigna.

La expresión

t(3)

es la abreviatura de

t.Invoke(3)
Nota

Un delegado es similar a una llamada de retorno, término general que engloba construcciones como los punteros a funciones C.

Escribir métodos de complemento con delegados

A una variable delegada se le asigna un método en tiempo de ejecución. Esto es útil para escribir métodos complementarios. En este ejemplo, tenemos un método de utilidad llamado Transform que aplica una transformación a cada elemento de una matriz de enteros. El método Transform tiene un parámetro delegado, que puedes utilizar para especificar una transformación de complemento:

int[] values = { 1, 2, 3 };
Transform (values, Square);      // Hook in the Square method

foreach (int i in values)
  Console.Write (i + "  ");      // 1   4   9

void Transform (int[] values, Transformer t)
{
  for (int i = 0; i < values.Length; i++)
    values[i] = t (values[i]);
}

int Square (int x) => x * x;
int Cube (int x) => x * x * x;

delegate int Transformer (int x);

Podemos cambiar la transformación simplemente cambiando Square por Cube en la segunda línea de código.

Nuestro método Transform es una función de orden superior porque es una función que toma una función como argumento. (Un método que devuelva un delegado también sería una función de orden superior).

Objetivos de instancias y métodos estáticos

El método objetivo de un delegado puede ser un método local, estático o de instancia. A continuación se ilustra un método objetivo estático:

Transformer t = Test.Square;
Console.WriteLine (t(10));      // 100

class Test { public static int Square (int x) => x * x; }

delegate int Transformer (int x);

A continuación se ilustra un método de destino de instancia:

Test test = new Test();
Transformer t = test.Square;
Console.WriteLine (t(10));      // 100

class Test { public int Square (int x) => x * x; }

delegate int Transformer (int x);

Cuando se asigna un método de instancia a un objeto delegado, éste mantiene una referencia no sólo al método, sino también a la instancia a la que pertenece el método. La propiedad Target de la clase System.Delegate representa esta instancia (y será nula para un delegado que haga referencia a un método estático). He aquí un ejemplo:

MyReporter r = new MyReporter();
r.Prefix = "%Complete: ";
ProgressReporter p = r.ReportProgress;
p(99);                                 // %Complete: 99
Console.WriteLine (p.Target == r);     // True
Console.WriteLine (p.Method);          // Void ReportProgress(Int32)
r.Prefix = "";
p(99);                                 // 99

public delegate void ProgressReporter (int percentComplete);

class MyReporter
{
  public string Prefix = "";

  public void ReportProgress (int percentComplete)
    => Console.WriteLine (Prefix + percentComplete);
}

Como la instancia se almacena en la propiedad Target del delegado, su tiempo de vida se extiende (al menos tanto como) al tiempo de vida del delegado.

Delegados multidifusión

Todas las instancias delegadas tienen capacidad de multidifusión. Esto significa que una instancia de delegado puede hacer referencia no sólo a un único método de destino, sino también a una lista de métodos de destino. Los operadores + y += combinan instancias de delegado:

SomeDelegate d = SomeMethod1;
d += SomeMethod2;

La última línea es funcionalmente igual a la siguiente:

d = d + SomeMethod2;

Invocar a d llamará ahora tanto a SomeMethod1 como a SomeMethod2. Los delegados se invocan en el orden en que se añaden.

Los operadores - y -= eliminan el operando delegado derecho del operando delegado izquierdo:

d -= SomeMethod1;

Invocar d hará que ahora sólo se invoque SomeMethod2.

Llamar a + o += sobre una variable delegada con un valor null funciona, y equivale a asignar a la variable un nuevo valor:

SomeDelegate d = null;
d += SomeMethod1;       // Equivalent (when d is null) to d = SomeMethod1;

Del mismo modo, llamar a -= en una variable delegada con un único objetivo coincidente equivale a asignar null a esa variable.

Nota

Los delegados son inmutables, así que cuando llamas a += o -=, en realidad estás creando una nueva instancia de delegado y asignándola a la variable existente.

Si un delegado multidifusión tiene un tipo de retorno no vacío, la persona que llama recibe el valor de retorno del último método invocado. Los métodos precedentes se siguen invocando, pero sus valores de retorno se descartan. En la mayoría de los casos en los que se utilizan los delegados multidifusión, éstos tienen tipos de retorno void, por lo que esta sutileza no se plantea.

Nota

Todos los tipos de delegado derivan implícitamente de System.MulticastDelegate, que hereda de System.Delegate. C# compila las operaciones +, -, +=, y -= realizadas sobre un delegado a los métodos estáticos Combine y Remove de la clase System.Delegate.

Ejemplo de delegado multidifusión

Supón que escribes un método que tarda mucho tiempo en ejecutarse. Ese método podría informar regularmente del progreso a su invocador invocando a un delegado. En este ejemplo, el método HardWork tiene un parámetro delegado ProgressReporter, al que invoca para indicar el progreso:

public delegate void ProgressReporter (int percentComplete);

public class Util
{
  public static void HardWork (ProgressReporter p)
  {
    for (int i = 0; i < 10; i++)
    {
      p (i * 10);                           // Invoke delegate
      System.Threading.Thread.Sleep (100);  // Simulate hard work
    }
  }
}

Para monitorizar el progreso, podemos crear una instancia de delegado de multidifusión p, de forma que el progreso se monitorice mediante dos métodos independientes:

ProgressReporter p = WriteProgressToConsole;
p += WriteProgressToFile;
Util.HardWork (p);

void WriteProgressToConsole (int percentComplete)
  => Console.WriteLine (percentComplete);

void WriteProgressToFile (int percentComplete)
  => System.IO.File.WriteAllText ("progress.txt",
                                   percentComplete.ToString());

Tipos genéricos de delegados

Un tipo de delegado puede contener parámetros de tipo genérico:

public delegate T Transformer<T> (T arg);

Con esta definición, podemos escribir un método de utilidad generalizado Transform que funcione en cualquier tipo:

int[] values = { 1, 2, 3 };
Util.Transform (values, Square);      // Hook in Square
foreach (int i in values)
  Console.Write (i + "  ");           // 1   4   9

int Square (int x) => x * x;

public class Util
{
  public static void Transform<T> (T[] values, Transformer<T> t)
  {
    for (int i = 0; i < values.Length; i++)
      values[i] = t (values[i]);
  }
}

Los delegados Func y Acción

Con los delegados genéricos, es posible escribir un pequeño conjunto de tipos de delegados que son tan generales que pueden funcionar para métodos de cualquier tipo de retorno y cualquier número (razonable) de argumentos. Estos delegados son los delegados Func y Action, definidos en el espacio de nombres System (las anotaciones in y out indican varianza, que trataremos en breve en el contexto de los delegados):

delegate TResult Func <out TResult>                ();
delegate TResult Func <in T, out TResult>          (T arg);
delegate TResult Func <in T1, in T2, out TResult>  (T1 arg1, T2 arg2);
... and so on, up to T16

delegate void Action                 ();
delegate void Action <in T>          (T arg);
delegate void Action <in T1, in T2>  (T1 arg1, T2 arg2);
... and so on, up to T16

Estos delegados son extremadamente generales. El delegado Transformer de nuestro ejemplo anterior puede sustituirse por un delegado Func que tome un único argumento de tipo T y devuelva un valor del mismo tipo:

public static void Transform<T> (T[] values, Func<T,T> transformer)
{
  for (int i = 0; i < values.Length; i++)
    values[i] = transformer (values[i]);
}

Los únicos supuestos prácticos que no cubren estos delegados son ref/out y los parámetros de puntero.

Nota

Cuando se introdujo C#, los delegados Func y Action no existían (porque no existían los genéricos). Por esta razón histórica, gran parte de .NET utiliza tipos de delegado personalizados en lugar de Func y Action.

Delegados frente a interfaces

Un problema que puedes resolver con un delegado también se puede resolver con una interfaz. Por ejemplo, podemos reescribir nuestro ejemplo original con una interfaz llamada ITransformer en lugar de un delegado:

int[] values = { 1, 2, 3 };
Util.TransformAll (values, new Squarer());
foreach (int i in values)
  Console.WriteLine (i);

public interface ITransformer
{
  int Transform (int x);
}

public class Util
{
 public static void TransformAll (int[] values, ITransformer t)
 {
   for (int i = 0; i < values.Length; i++)
     values[i] = t.Transform (values[i]);
 }
}

class Squarer : ITransformer
{
  public int Transform (int x) => x * x;
}

Un diseño de delegado puede ser una mejor opción que un diseño de interfaz si se cumplen una o varias de estas condiciones:

  • La interfaz sólo define un único método.

  • Se necesita capacidad multidifusión.

  • El abonado tiene que implementar la interfaz varias veces.

En el ejemplo de ITransformer, no necesitamos multidifusión. Sin embargo, la interfaz sólo define un único método. Además, nuestro suscriptor podría tener que implementar ITransformer varias veces, para admitir diferentes transformaciones, como cuadrado o cubo. Con las interfaces, nos vemos obligados a escribir un tipo distinto por cada transformación, porque una clase sólo puede implementar ITransformer una vez. Esto es bastante engorroso:

int[] values = { 1, 2, 3 };
Util.TransformAll (values, new Cuber());
foreach (int i in values)
  Console.WriteLine (i);

class Squarer : ITransformer
{
  public int Transform (int x) => x * x;
}

class Cuber : ITransformer
{
  public int Transform (int x) => x * x * x;
}

Compatibilidad de delegados

Compatibilidad de tipos

Los tipos de delegados son incompatibles entre sí, aunque sus firmas sean iguales:

D1 d1 = Method1;
D2 d2 = d1;                           // Compile-time error

void Method1() { }

delegate void D1();
delegate void D2();
Nota

Sin embargo, se permite lo siguiente:

D2 d2 = new D2 (d1);

Las instancias de delegado se consideran iguales si tienen los mismos objetivos de método:

D d1 = Method1;
D d2 = Method1;
Console.WriteLine (d1 == d2);         // True

void Method1() { }
delegate void D();

Los delegados multidifusión se consideran iguales si hacen referencia a los mismos métodos en el mismo orden.

Compatibilidad de parámetros

Cuando llamas a un método, puedes suministrar argumentos que tengan tipos más específicos que los parámetros de ese método. Se trata de un comportamiento polimórfico ordinario. Por la misma razón, un delegado puede tener tipos de parámetros más específicos que su método objetivo. Esto se llama contravarianza. He aquí un ejemplo:

StringAction sa = new StringAction (ActOnObject);
sa ("hello");

void ActOnObject (object o) => Console.WriteLine (o);   // hello

delegate void StringAction (string s);

(Al igual que con la varianza de parámetros de tipo, los delegados son variantes sólo para las conversiones de referencia).

Un delegado simplemente llama a un método en nombre de otro. En este caso, se invoca a StringAction con un argumento de tipo string. Cuando el argumento se transmite al método de destino, el argumento se convierte implícitamente en un object.

Nota

El patrón de eventos estándar está diseñado para ayudarte a utilizar la contravarianza mediante el uso de la clase base común EventArgs. Por ejemplo, puedes tener un único método invocado por dos delegados diferentes, uno que pase un MouseEventArgs y otro que pase un KeyEventArgs.

Compatibilidad del tipo de devolución

Si llamas a un método, puede que te devuelva un tipo más específico que el que pediste. Se trata de un comportamiento polimórfico ordinario. Por la misma razón, el método objetivo de un delegado puede devolver un tipo más específico que el descrito por el delegado. Esto se llama covarianza:

ObjectRetriever o = new ObjectRetriever (RetrieveString);
object result = o();
Console.WriteLine (result);      // hello

string RetrieveString() => "hello";

delegate object ObjectRetriever();

ObjectRetriever espera obtener de vuelta un object, pero una subclase de object también servirá: los tipos de retorno de los delegados son covariantes.

Varianza de parámetros de tipo delegado genérico

En el Capítulo 3 vimos cómo las interfaces genéricas admiten parámetros de tipo covariante y contravariante. La misma capacidad existe también para los delegados.

Si estás definiendo un tipo de delegado genérico, es una buena práctica hacer lo siguiente:

  • Marcar como covariante un parámetro de tipo utilizado sólo en el valor de retorno (out).

  • Marca como contravariante cualquier parámetro de tipo utilizado sólo en parámetros (in).

Esto permite que las conversiones funcionen de forma natural, respetando las relaciones de herencia entre tipos.

El siguiente delegado (definido en el espacio de nombres System ) tiene una covariante TResult:

delegate TResult Func<out TResult>();

Esto permite:

Func<string> x = ...;
Func<object> y = x;

El siguiente delegado (definido en el espacio de nombres System ) tiene una contravariante T:

delegate void Action<in T> (T arg);

Esto permite:

Action<object> x = ...;
Action<string> y = x;

Eventos

Cuando se utilizan delegados, suelen aparecer dos roles emergentes: emisor y abonado.

El emisor es un tipo que contiene un campo delegado. El emisor decide cuándo emitir, invocando al delegado.

Los suscriptores son los destinatarios del método. Un suscriptor decide cuándo empezar y dejar de escuchar llamando a += y -= en el delegado del emisor. Un suscriptor no conoce a otros suscriptores ni interfiere con ellos.

Los eventos son una característica del lenguaje que formaliza este patrón. Un event es una construcción que expone sólo el subconjunto de funciones de delegado necesarias para el modelo emisor/suscriptor. El objetivo principal de los eventos es evitar que los suscriptores interfieran entre sí.

La forma más sencilla de declarar un evento es poner la palabra clave event delante de un miembro delegado:

// Delegate definition
public delegate void PriceChangedHandler (decimal oldPrice,
                                          decimal newPrice);
public class Broadcaster
{
  // Event declaration
  public event PriceChangedHandler PriceChanged;
}

El código dentro del tipo Broadcaster tiene acceso completo a PriceChanged y puede tratarlo como un delegado. El código fuera de Broadcaster sólo puede realizar operaciones de += y -= en el evento PriceChanged.

Considera el siguiente ejemplo. La clase Stock dispara su evento PriceChanged cada vez que cambia el Price del Stock:

public delegate void PriceChangedHandler (decimal oldPrice,
                                          decimal newPrice);
public class Stock
{
  string symbol;
  decimal price;

  public Stock (string symbol) => this.symbol = symbol;

  public event PriceChangedHandler PriceChanged;

  public decimal Price
  {
    get => price;
    set
    {
      if (price == value) return;      // Exit if nothing has changed
      decimal oldPrice = price;
      price = value;
      if (PriceChanged != null)           // If invocation list not
        PriceChanged (oldPrice, price);   // empty, fire event.
    }
  }
}

Si eliminamos la palabra clave event de nuestro ejemplo para que PriceChanged se convierta en un campo delegado ordinario, nuestro ejemplo daría los mismos resultados. Sin embargo, Stock sería menos robusto en la medida en que los suscriptores podrían hacer las siguientes cosas para interferir entre sí:

  • Sustituye a otros abonados reasignando PriceChanged (en lugar de utilizar el operador += ).

  • Borra todos los abonados (configurando PriceChanged en null).

  • Difúndelo a otros abonados invocando al delegado.

Patrón de eventos estándar

En casi todos los casos en los que se definen eventos en las bibliotecas .NET, su definición se adhiere a un patrón estándar diseñado para proporcionar coherencia entre el código de la biblioteca y el del usuario. En el núcleo del patrón estándar de eventos está System.EventArgs, una clase .NET predefinida sin miembros (salvo el campo estático Empty ). EventArgs es una clase base para transmitir la información de un evento. En nuestro ejemplo de Stock, subclasificaríamos EventArgs para transmitir los precios antiguo y nuevo cuando se dispara un evento PriceChanged:

public class PriceChangedEventArgs : System.EventArgs
{
  public readonly decimal LastPrice;
  public readonly decimal NewPrice;

  public PriceChangedEventArgs (decimal lastPrice, decimal newPrice)
  {
    LastPrice = lastPrice;
    NewPrice = newPrice;
  }
}

Para poder reutilizarla, la subclase EventArgs se denomina según la información que contiene (en lugar del evento para el que se utilizará). Normalmente expone los datos como propiedades o como campos de sólo lectura.

Con una subclase de EventArgs, el siguiente paso es elegir o definir un delegado para el evento. Existen tres reglas:

  • Debe tener un tipo de retorno void.

  • Debe aceptar dos argumentos: el primero de tipo object y el segundo una subclase de EventArgs. El primer argumento indica el emisor del evento, y el segundo contiene la información extra que hay que transmitir.

  • Su nombre debe terminar en EventHandler.

.NET define un delegado genérico llamado System.EventHandler<> para ayudar con esto:

public delegate void EventHandler<TEventArgs> (object source, TEventArgs e)
Nota

Antes de que existieran los genéricos en el lenguaje (antes de C# 2.0), habríamos tenido que escribir en su lugar un delegado personalizado como el siguiente:

public delegate void PriceChangedHandler
  (object sender, PriceChangedEventArgs e);

Por razones históricas, la mayoría de los eventos de las bibliotecas .NET utilizan delegados definidos de esta forma.

El siguiente paso es definir un evento del tipo de delegado elegido. Aquí utilizamos el delegado genérico EventHandler:

public class Stock
{
  ...
  public event EventHandler<PriceChangedEventArgs> PriceChanged;
}

Por último, el patrón requiere que escribas un método virtual protegido que dispare el evento. El nombre debe coincidir con el nombre del evento, prefijado con la palabra "On", y aceptar un único argumento EventArgs:

public class Stock
{
  ...

  public event EventHandler<PriceChangedEventArgs> PriceChanged;

  protected virtual void OnPriceChanged (PriceChangedEventArgs e)
  {
    if (PriceChanged != null) PriceChanged (this, e);
  }
}
Nota

Para que funcione con solidez en escenarios multihilo(Capítulo 14), debes asignar el delegado a una variable temporal antes de probarlo e invocarlo:

var temp = PriceChanged;
if (temp != null) temp (this, e);

Podemos conseguir la misma funcionalidad sin la variable temp con el operador condicional nulo:

PriceChanged?.Invoke (this, e);

Al ser a la vez segura para los hilos y sucinta, ésta es la mejor forma general de invocar eventos.

Esto proporciona un punto central desde el que las subclases pueden invocar o anular el evento (suponiendo que la clase no esté sellada).

Aquí tienes el ejemplo completo:

using System;

Stock stock = new Stock ("THPW");
stock.Price = 27.10M;
// Register with the PriceChanged event
stock.PriceChanged += stock_PriceChanged;
stock.Price = 31.59M;

void stock_PriceChanged (object sender, PriceChangedEventArgs e)
{
  if ((e.NewPrice - e.LastPrice) / e.LastPrice > 0.1M)
    Console.WriteLine ("Alert, 10% stock price increase!");
}

public class PriceChangedEventArgs : EventArgs
{
  public readonly decimal LastPrice;
  public readonly decimal NewPrice;

  public PriceChangedEventArgs (decimal lastPrice, decimal newPrice)
  {
    LastPrice = lastPrice; NewPrice = newPrice;
  }
}

public class Stock
{
  string symbol;
  decimal price;

  public Stock (string symbol) => this.symbol = symbol;

  public event EventHandler<PriceChangedEventArgs> PriceChanged;

  protected virtual void OnPriceChanged (PriceChangedEventArgs e)
  {
    PriceChanged?.Invoke (this, e);
  }

  public decimal Price
  {
    get => price;
    set
    {
      if (price == value) return;
      decimal oldPrice = price;
      price = value;
      OnPriceChanged (new PriceChangedEventArgs (oldPrice, price));
    }
  }
}

El delegado no genérico predefinido EventHandler puede utilizarse cuando un evento no lleva información adicional. En este ejemplo, reescribimos Stock de forma que el evento PriceChanged se dispare después de que cambie el precio, y no sea necesaria ninguna información sobre el evento, aparte de que ha ocurrido. También hacemos uso de la propiedad Even⁠t​Args.Empty para evitar instanciar innecesariamente una instancia de EventArgs:

public class Stock
{
  string symbol;
  decimal price;

  public Stock (string symbol) { this.symbol = symbol; }

  public event EventHandler PriceChanged;

  protected virtual void OnPriceChanged (EventArgs e)
  {
    PriceChanged?.Invoke (this, e);
  }

  public decimal Price
  {
    get { return price; }
    set
    {
      if (price == value) return;
      price = value;
      OnPriceChanged (EventArgs.Empty);
    }
  }
}

Accesorios para eventos

Los accesores de un evento son las implementaciones de sus funciones += y -=. Por omisión, el compilador implementa implícitamente los accesores. Considera esta declaración de evento:

public event EventHandler PriceChanged;

El compilador lo convierte en lo siguiente:

  • Un campo delegado privado

  • Un par de funciones públicas de acceso a eventos (add_PriceChanged y remove_Pri⁠ce​Changed) cuyas implementaciones reenvían las operaciones += y -= al campo delegado privado

Puedes encargarte de este proceso definiendo accesores de eventos explícitos. Aquí tienes una implementación manual del evento PriceChanged de nuestro ejemplo anterior:

private EventHandler priceChanged;         // Declare a private delegate

public event EventHandler PriceChanged
{
  add    { priceChanged += value; }
  remove { priceChanged -= value; }
}

Este ejemplo es funcionalmente idéntico a la implementación del accesor por defecto de C# (salvo que C# también garantiza la seguridad de los hilos al actualizar el delegado mediante un algoritmo de comparación e intercambio sin bloqueo; consulta http://albahari.com/threading). Al definir nosotros mismos los accesores a eventos, indicamos a C# que no genere la lógica de campos y accesores por defecto.

Con accesores explícitos a eventos, puedes aplicar estrategias más complejas al almacenamiento y acceso del delegado subyacente. Hay tres escenarios para los que esto es útil:

  • Cuando los accesorios del evento son meros repetidores de otra clase que emite el evento.

  • Cuando la clase expone muchos eventos, para los que la mayoría de las veces existen muy pocos suscriptores, como un control de Windows. En estos casos, es mejor almacenar las instancias de delegado del suscriptor en un diccionario, porque un diccionario contendrá menos sobrecarga de almacenamiento que decenas de referencias nulas a campos de delegado.

  • Al implementar explícitamente una interfaz que declara un evento.

He aquí un ejemplo que ilustra este último punto:

public interface IFoo { event EventHandler Ev; }

class Foo : IFoo
{
  private EventHandler ev;

  event EventHandler IFoo.Ev
  {
    add    { ev += value; }
    remove { ev -= value; }
  }
}
Nota

Las partes add y remove de un evento se compilan en add_XXX y remove_XXX métodos.

Modificadores de eventos

Al igual que los métodos, los eventos pueden ser virtuales, anulados, abstractos o sellados. Los eventos también pueden ser estáticos:

public class Foo
{
  public static event EventHandler<EventArgs> StaticEvent;
  public virtual event EventHandler<EventArgs> VirtualEvent;
}

Expresiones lambda

Una expresión lambda es un método sin nombre escrito en lugar de una instancia de delegado. El compilador convierte inmediatamente la expresión lambda en una de las siguientes:

  • Una instancia de delegado.

  • Un árbol de expresión, de tipo Expression<TDelegate>, que representa el código dentro de la expresión lambda en un modelo de objetos transitable. Esto permite interpretar posteriormente la expresión lambda en tiempo de ejecución (ver "Construcción de expresiones de consulta").

En el siguiente ejemplo, x => x * x es una expresión lambda:

Transformer sqr = x => x * x;
Console.WriteLine (sqr(3));    // 9

delegate int Transformer (int i);
Nota

Internamente, el compilador resuelve las expresiones lambda de este tipo escribiendo un método privado y trasladando el código de la expresión a ese método.

Una expresión lambda tiene la siguiente forma:

(parameters) => expression-or-statement-block

Por comodidad, puedes omitir los paréntesis si y sólo si hay exactamente un parámetro de tipo inferible.

En nuestro ejemplo, hay un único parámetro, x, y la expresión es x * x:

x => x * x;

Cada parámetro de la expresión lambda corresponde a un parámetro del delegado, y el tipo de la expresión (que puede ser void) corresponde al tipo de retorno del delegado.

En nuestro ejemplo, x corresponde al parámetro i, y la expresión x * x corresponde al tipo de retorno int, siendo por tanto compatible con el delegado Transformer:

delegate int Transformer (int i);

El código de una expresión lambda puede ser un bloque de sentencias en lugar de una expresión. Podemos reescribir nuestro ejemplo como sigue:

x => { return x * x; };

Las expresiones lambda se utilizan más comúnmente con los delegados Func y Action, por lo que lo más frecuente es que veas nuestra expresión anterior escrita de la siguiente manera:

Func<int,int> sqr = x => x * x;

Aquí tienes un ejemplo de expresión que acepta dos parámetros:

Func<string,string,int> totalLength = (s1, s2) => s1.Length + s2.Length;
int total = totalLength ("hello", "world");   // total is 10;

Si no necesitas utilizar los parámetros, puedes descartarlos con un guión bajo (a partir de C# 9):

Func<string,string,int> totalLength = (_,_) => ...

Aquí tienes un ejemplo de expresión que toma cero argumentos:

Func<string> greeter = () => "Hello, world";

A partir de C# 10, el compilador permite la tipificación implícita con expresiones lambda que pueden resolverse mediante los delegados Func y Action, por lo que podemos acortar esta declaración a

var greeter = () => "Hello, world";

Especificar explícitamente los tipos de parámetros y de retorno de Lambda

Normalmente, el compilador puede deducir contextualmente el tipo de los parámetros lambda. Cuando no sea así, deberás especificar explícitamente el tipo de cada parámetro. Considera los dos métodos siguientes:

void Foo<T> (T x)         {}
void Bar<T> (Action<T> a) {}

El siguiente código no compilará, porque el compilador no puede deducir el tipo de x:

Bar (x => Foo (x));     // What type is x?

Podemos solucionarlo especificando explícitamente el tipo de xde la siguiente manera:

Bar ((int x) => Foo (x));

Este ejemplo concreto es lo suficientemente sencillo como para que pueda arreglarse de otras dos formas:

Bar<int> (x => Foo (x));   // Specify type parameter for Bar
Bar<int> (Foo);            // As above, but with method group

El siguiente ejemplo ilustra otro uso de los tipos de parámetros explícitos (de C# 10):

var sqr = (int x) => x * x;

El compilador infiere que sqr es del tipo Func<int,int>. (Sin especificar int, la tipificación implícita fallaría: el compilador sabría que sqr debe ser Func<T,T>, pero no sabría qué debe ser T.)

A partir de C# 10, también puedes especificar el tipo de retorno lambda:

var sqr = int (int x) => x;

Especificar un tipo de retorno puede mejorar el rendimiento del compilador con lambdas anidadas complejas.

Parámetros Lambda por defecto (C# 12)

Igual que los métodos ordinarios pueden tener parámetros opcionales:

void Print (string message = "") => Console.WriteLine (message);

también pueden hacerlo las expresiones lambda:

var print = (string message = "") => Console.WriteLine (message);

print ("Hello");
print ();

Esta función es útil con bibliotecas como ASP.NET Minimal API.

Captura de variables externas

Una expresión lambda puede hacer referencia a cualquier variable que sea accesible en el lugar donde se define la expresión lambda. Éstas se denominan variables externas y pueden incluir variables locales, parámetros y campos:

int factor = 2;
Func<int, int> multiplier = n => n * factor;
Console.WriteLine (multiplier (3));            // 6

Las variables externas a las que hace referencia una expresión lambda se denominan variables capturadas. Una expresión lambda que captura variables se llama cierre.

Nota

Las variables también pueden ser capturadas por métodos anónimos y métodos locales. Las reglas para las variables capturadas, en estos casos, son las mismas.

Las variables capturadas se evalúan cuando se invoca realmente al delegado, no cuando se capturaron las variables:

int factor = 2;
Func<int, int> multiplier = n => n * factor;
factor = 10;
Console.WriteLine (multiplier (3));           // 30

Las expresiones lambda pueden actualizar por sí mismas variables capturadas:

int seed = 0;
Func<int> natural = () => seed++;
Console.WriteLine (natural());           // 0
Console.WriteLine (natural());           // 1
Console.WriteLine (seed);                // 2

La vida de las variables capturadas se extiende a la del delegado. En el siguiente ejemplo, la variable local seed desaparecería normalmente del ámbito cuando Natural terminara de ejecutarse. Pero como seed ha sido capturada, su tiempo de vida se extiende al del delegado que la captura, natural:

static Func<int> Natural()
{
  int seed = 0;
  return () => seed++;      // Returns a closure
}

static void Main()
{
  Func<int> natural = Natural();
  Console.WriteLine (natural());      // 0
  Console.WriteLine (natural());      // 1
}

Una variable local instanciada dentro de una expresión lambda es única por cada invocación de la instancia delegada. Si refactorizamos nuestro ejemplo anterior para instanciar seed dentro de la expresión lambda, obtendremos un resultado diferente (en este caso, indeseable):

static Func<int> Natural()
{    
  return() => { int seed = 0; return seed++; };
}

static void Main()
{
  Func<int> natural = Natural();
  Console.WriteLine (natural());           // 0
  Console.WriteLine (natural());           // 0
}
Nota

La captura se implementa internamente "colgando" las variables capturadas en campos de una clase privada. Cuando se llama al método, la clase se instanciará y se vinculará de por vida a la instancia del delegado.

Lambdas estáticas

Cuando capturas variables locales, parámetros, campos de instancia o la referencia this, es posible que el compilador tenga que crear e instanciar una clase privada para almacenar una referencia a los datos capturados. Esto supone un pequeño coste de rendimiento, porque hay que asignar memoria (y posteriormente recogerla). En situaciones en las que el rendimiento es crítico, una estrategia de microoptimización consiste en minimizar la carga del recolector de basura asegurándose de que las rutas calientes del código incurran en pocas o ninguna asignación.

A partir de C# 9, puedes asegurarte de que una expresión lambda, una función local o un método anónimo no capturen estado aplicando la palabra clave static. Esto puede ser útil en escenarios de microoptimización para evitar asignaciones de memoria involuntarias. Por ejemplo, podemos aplicar el modificador estático a una expresión lambda de la siguiente manera:

Func<int, int> multiplier = static n => n * 2;

Si más tarde intentamos modificar la expresión lambda para que capture una variable local, el compilador generará un error:

int factor = 2;
Func<int, int> multiplier = static n => n * factor;  // will not compile
Nota

La propia lambda se evalúa a una instancia de delegado, lo que requiere una asignación de memoria. Sin embargo, si la lambda no captura variables, el compilador reutilizará una única instancia almacenada en caché durante toda la vida de la aplicación, por lo que en la práctica no habrá ningún coste.

Esta característica también puede utilizarse con métodos locales. En el siguiente ejemplo, el método Multiply no puede acceder a la variable factor:

void Foo()
{
  int factor = 123;
  static int Multiply (int x) => x * 2;   // Local static method
}

Por supuesto, el método Multiply podría asignar memoria explícitamente llamando a new. De lo que esto nos protege es de una posible asignación furtiva. Aplicar static aquí también puede ser útil como herramienta de documentación, ya que indica un nivel reducido de acoplamiento.

Las lambdas estáticas pueden seguir accediendo a variables y constantes estáticas (porque éstas no requieren un cierre).

Nota

La palabra clave static actúa simplemente como una comprobación; no tiene ningún efecto sobre el IL que produce el compilador. Sin la palabra clave static, el compilador no genera un cierre a menos que lo necesite (e incluso entonces, tiene trucos para mitigar el coste).

Captura de variables de iteración

Cuando capturas la variable de iteración de un bucle for, C# trata esa variable como si estuviera declarada fuera del bucle. Esto significa que se captura la misma variable en cada iteración. El siguiente programa escribe 333 en lugar de 012:

Action[] actions = new Action[3];

for (int i = 0; i < 3; i++)
  actions [i] = () => Console.Write (i);

foreach (Action a in actions) a();     // 333

Cada cierre (mostrado en negrita) captura la misma variable, i. (En realidad, esto tiene sentido si tienes en cuenta que i es una variable cuyo valor persiste entre iteraciones del bucle; incluso puedes cambiar explícitamente i dentro del cuerpo del bucle si quieres). La consecuencia es que cuando los delegados son invocados posteriormente, cada delegado ve el valor de ien el momento de la invocación, quees 3. Podemos ilustrarlo mejor ampliando el bucle for, como sigue:

Action[] actions = new Action[3];
int i = 0;
actions[0] = () => Console.Write (i);
i = 1;
actions[1] = () => Console.Write (i);
i = 2;
actions[2] = () => Console.Write (i);
i = 3;
foreach (Action a in actions) a();    // 333

La solución, si queremos escribir 012, es asignar la variable de iteración a una variable local de ámbito dentro del bucle:

Action[] actions = new Action[3];
for (int i = 0; i < 3; i++)
{
  int loopScopedi = i;
  actions [i] = () => Console.Write (loopScopedi);
}
foreach (Action a in actions) a();     // 012

Como loopScopedi se crea de nuevo en cada iteración, cada cierre captura una variable diferente.

Nota

Antes de C# 5.0, los bucles foreach funcionaban del mismo modo. Esto causaba bastante confusión: a diferencia de lo que ocurre con un bucle for, la variable de iteración de un bucle foreach es inmutable, por lo que cabría esperar que se tratara como local al cuerpo del bucle. La buena noticia es que ahora está solucionado y puedes capturar con seguridad la variable de iteración de un bucle foreach sin sorpresas.

Expresiones lambda frente a métodos locales

La funcionalidad de los métodos locales (ver "Métodos locales") se solapa con la de las expresiones lambda. Los métodos locales tienen las tres ventajas siguientes:

  • Pueden ser recursivos (pueden llamarse a sí mismos) sin hacks feos.

  • Evitan el lío de especificar un tipo de delegado.

  • Incurren en algo menos de gastos generales.

Los métodos locales son más eficientes porque evitan la indirección de un delegado (que cuesta algunos ciclos de CPU y una asignación de memoria). También pueden acceder a las variables locales del método contenedor sin que el compilador tenga que "izar" las variables capturadas a una clase oculta.

Sin embargo, en muchos casos necesitas un delegado, sobre todo cuando llamas a una función de orden superior, es decir, a un método con un parámetro de tipo delegado:

public void Foo (Func<int,bool> predicate) { ... }

(Puedes ver muchos más en el capítulo 8). En estos casos, necesitas un delegado de todos modos, y es precisamente en estos casos en los que las expresiones lambda suelen ser más sencillas y limpias.

Métodos anónimos

Los métodos anónimos son una característica de C# 2.0 que fue subsumida en su mayor parte por las expresiones lambda de C# 3.0. Un método anónimo es como una expresión lambda, pero carece de las siguientes características:

  • Parámetros tipados implícitamente

  • Sintaxis de expresiones (un método anónimo debe ser siempre un bloque de expresiones)

  • La capacidad de compilar a un árbol de expresiones, asignando a Expression<T>

Un método anónimo utiliza la palabra clave delegate seguida (opcionalmente) de una declaración de parámetros y, a continuación, del cuerpo del método. Por ejemplo:

Transformer sqr = delegate (int x) {return x * x;};
Console.WriteLine (sqr(3));                            // 9

delegate int Transformer (int i);

La primera línea es semánticamente equivalente a la siguiente expresión lambda:

Transformer sqr =       (int x) => {return x * x;};

O simplemente:

Transformer sqr =            x  => x * x;

Los métodos anónimos capturan variables externas del mismo modo que las expresiones lambda, y pueden ir precedidos de la palabra clave static para que se comporten como lambdas estáticas.

Nota

Una característica única de los métodos anónimos es que puedes omitir por completo la declaración del parámetro, aunque el delegado lo espere. Esto puede ser útil para declarar eventos con un manejador vacío por defecto:

public event EventHandler Clicked = delegate { };

Esto evita la necesidad de realizar una comprobación de nulos antes de lanzar el evento. Lo siguiente también es legal:

// Notice that we omit the parameters:
Clicked += delegate { Console.WriteLine ("clicked"); };

Sentencias try y excepciones

Una sentencia try especifica un bloque de código sujeto a tratamiento de errores o código de limpieza. El bloque try debe ir seguido de uno o más bloques catch y/o un bloque finally, o ambos. El bloque catch se ejecuta cuando se produce un error en el bloque try. El bloque finally se ejecuta después de que la ejecución abandone el bloque try (o, si está presente, el bloque catch ) para realizar código de limpieza, independientemente de si se ha lanzado una excepción.

Un bloque catch tiene acceso a un objeto Exception que contiene información sobre el error. Utiliza un bloque catch para compensar el error o para volver a lanzar la excepción. Vuelves a lanzar una excepción si sólo quieres registrar el problema o si quieres volver a lanzar un nuevo tipo de excepción de nivel superior.

Un bloque finally añade determinismo a tu programa: el CLR se esfuerza por ejecutarlo siempre. Es útil para tareas de limpieza, como cerrar conexiones de red.

Una declaración try tiene este aspecto:

try
{
  ... // exception may get thrown within execution of this block
}
catch (ExceptionA ex)
{
  ... // handle exception of type ExceptionA
}
catch (ExceptionB ex)
{
  ... // handle exception of type ExceptionB
}
finally
{
  ... // cleanup code
}

Considera el siguiente programa:

int y = Calc (0);
Console.WriteLine (y);

int Calc (int x) => 10 / x;

Como x es cero, el tiempo de ejecución lanza una DivideByZeroException y nuestro programa termina. Podemos evitarlo capturando la excepción de la siguiente manera:

try
{
  int y = Calc (0);
  Console.WriteLine (y);
}
catch (DivideByZeroException ex)
{
  Console.WriteLine ("x cannot be zero");
}
Console.WriteLine ("program completed");

int Calc (int x) => 10 / x;

Este es el resultado:

x cannot be zero
program completed
Nota

Se trata de un ejemplo sencillo para ilustrar la gestión de excepciones. En la práctica, podríamos tratar mejor esta situación concreta comprobando explícitamente si el divisor es cero antes de llamar a Calc.

La comprobación de errores evitables es preferible a confiar en los bloques try/catch porque las excepciones son relativamente caras de manejar, ya que tardan cientos de ciclos de reloj o más.

Cuando se lanza una excepción dentro de una sentencia try, el CLR realiza una prueba:

¿La declaración try tiene algún bloque compatible catch ?

  • Si es así, la ejecución salta al bloque catch compatible, seguido del bloque finally (si está presente), y luego la ejecución continúa normalmente.

  • Si no, la ejecución salta directamente al bloque finally (si está presente), luego el CLR busca en la pila de llamadas otros bloques try; si los encuentra, repite la prueba.

Si ninguna función de la pila de llamadas se responsabiliza de la excepción, el programa termina.

La cláusula de captura

Una cláusula catch especifica qué tipo de excepción atrapar. Debe ser System.Exception o una subclase de System.Exception.

Atrapar System.Exception atrapa todos los errores posibles. Esto es útil en las siguientes circunstancias:

  • Tu programa puede recuperarse potencialmente independientemente del tipo específico de excepción.

  • Piensa volver a lanzar la excepción (quizás después de registrarla).

  • Tu manejador de errores es el último recurso, antes de la terminación del programa.

Sin embargo, lo más habitual es que captures tipos de excepción específicos para evitar tener que enfrentarte a circunstancias para las que tu manejador no fue diseñado (por ejemplo, un OutOfMemoryException).

Puedes manejar varios tipos de excepción con varias cláusulas catch (de nuevo, este ejemplo podría escribirse con comprobación explícita de argumentos en lugar de con manejo de excepciones):

class Test
{
  static void Main (string[] args)
  {
    try
    {
      byte b = byte.Parse (args[0]);
      Console.WriteLine (b);
    }
    catch (IndexOutOfRangeException)
    {
      Console.WriteLine ("Please provide at least one argument");
    }
    catch (FormatException)
    {
      Console.WriteLine ("That's not a number!");
    }
    catch (OverflowException)
    {
      Console.WriteLine ("You've given me more than a byte!");
    }
  }
}

Sólo se ejecuta una cláusula catch para una excepción determinada. Si quieres incluir una red de seguridad para capturar excepciones más generales (como System.Exception), debes poner primero los manejadores más específicos.

Se puede capturar una excepción sin especificar una variable, si no necesitas acceder a sus propiedades:

catch (OverflowException)   // no variable
{
  ...
}

Además, puedes omitir tanto la variable como el tipo (lo que significa que se capturarán todas las excepciones):

catch { ... }

Filtros de excepción

Puedes especificar un filtro de excepciones en una cláusula catch añadiendo una cláusula when:

catch (WebException ex) when (ex.Status == WebExceptionStatus.Timeout)
{
  ...
}

Si se lanza un WebException en este ejemplo, se evalúa entonces la expresión booleana que sigue a la palabra clave when. Si el resultado es falso, se ignora el bloque catch en cuestión y se consideran las cláusulas catch posteriores. Con los filtros de excepciones, puede tener sentido volver a capturar el mismo tipo de excepción:

catch (WebException ex) when (ex.Status == WebExceptionStatus.Timeout)
{ ... }
catch (WebException ex) when (ex.Status == WebExceptionStatus.SendFailure)
{ ... }

La expresión booleana de la cláusula when puede tener efectos secundarios, como un método que registre la excepción con fines de diagnóstico.

El bloque definitivo

Un bloque finally siempre se ejecuta, independientemente de si se lanza una excepción y de si el bloque try se ejecuta hasta completarse. Normalmente utilizas los bloques finally para limpiar código.

Un bloque finally se ejecuta después de cualquiera de las siguientes acciones:

  • Un bloque catch finaliza (o lanza una nueva excepción).

  • El bloque try finaliza (o lanza una excepción para la que no hay bloque catch ).

  • El control abandona el bloque try debido a una sentencia jump (por ejemplo, return o goto).

Lo único que puede anular un bloqueo finally es un bucle infinito o que el proceso termine bruscamente.

Un bloque finally ayuda a añadir determinismo a un programa. En el siguiente ejemplo, el archivo que abrimos siempre se cierra, independientemente de si:

  • El bloque try finaliza normalmente.

  • La ejecución vuelve antes de tiempo porque el archivo está vacío (EndOfStream).

  • Se lanza un IOException al leer el archivo:

void ReadFile()
{
  StreamReader reader = null;    // In System.IO namespace
  try
  {
    reader = File.OpenText ("file.txt");
    if (reader.EndOfStream) return;
    Console.WriteLine (reader.ReadToEnd());
  }
  finally
  {
    if (reader != null) reader.Dispose();
  }
}

En este ejemplo, cerramos el archivo llamando a Dispose en el bloque StreamReader. Llamar a Dispose en un objeto, dentro de un bloque finally, es una convención estándar y se admite explícitamente en C# mediante la sentencia using.

La declaración de uso

Muchas clases encapsulan recursos no gestionados, como manejadores de archivos, manejadores gráficos o conexiones a bases de datos. Estas clases implementan System.IDisposable, que define un único método sin parámetros llamado Dispose para limpiar estos recursos. La sentencia using proporciona una elegante sintaxis para llamar a Dispose sobre un objeto IDisposable dentro de un bloque finally.

Así

using (StreamReader reader = File.OpenText ("file.txt"))
{
  ...
}

es precisamente equivalente a lo siguiente

{
  StreamReader reader = File.OpenText ("file.txt");
  try
  {
    ...
  }
  finally
  {
    if (reader != null)
      ((IDisposable)reader).Dispose();
  }
}

utilizar declaraciones

Si omites los corchetes y el bloque de sentencia que sigue a una sentencia using (C# 8+), se convierte en una declaración de uso. Entonces, el recurso se elimina cuando la ejecución cae fuera del bloque de sentencia que lo encierra:

if (File.Exists ("file.txt"))
{
  using var reader = File.OpenText ("file.txt");
  Console.WriteLine (reader.ReadLine());
  ...
}

En este caso, reader se eliminará cuando la ejecución caiga fuera del bloque de sentencia if.

Lanzar excepciones

Las excepciones pueden ser lanzadas por el tiempo de ejecución o en el código de usuario. En este ejemplo, Display lanza una System.ArgumentNullException:

try { Display (null); }
catch (ArgumentNullException ex)
{
  Console.WriteLine ("Caught the exception");
}

void Display (string name)
{
  if (name == null)
    throw new ArgumentNullException (nameof (name));

  Console.WriteLine (name);
}
Nota

Como comprobar un argumento y lanzar un ArgumentNullException es una ruta de código tan común, en realidad existe un atajo para ello, desde .NET 6:

void Display (string name)
{
  ArgumentNullException.ThrowIfNull (name);
  Console.WriteLine (name);
}

Observa que no necesitamos especificar el nombre del parámetro. Explicaremos por qué más adelante, en "LlamadorExpresiónArgumento".

tirar expresiones

throw también puede aparecer como expresión en funciones con cuerpo de expresión:

public string Foo() => throw new NotImplementedException();

Una expresión throw también puede aparecer en una expresión condicional ternaria:

string ProperCase (string value) =>
  value == null ? throw new ArgumentException ("value") :
  value == "" ? "" :
  char.ToUpper (value[0]) + value.Substring (1);

Volver a lanzar una excepción

Puedes capturar y volver a lanzar una excepción del siguiente modo:

try {  ...  }
catch (Exception ex)
{
  // Log error
  ...
  throw;          // Rethrow same exception
}
Nota

Si sustituyéramos throw por throw ex, el ejemplo seguiría funcionando, pero la propiedad StackTrace de la excepción recién propagada ya no reflejaría el error original.

Volver a lanzar de esta forma te permite registrar un error sin tragarlo. También te permite desistir de manejar una excepción si las circunstancias resultan ser mayores de lo que esperabas. El otro escenario habitual es volver a lanzar un tipo de excepción más específico:

try
{
  ... // Parse a DateTime from XML element data
}
catch (FormatException ex)
{
  throw new XmlException ("Invalid DateTime", ex);
}

Observa que cuando construimos XmlException, pasamos la excepción original, ex, como segundo argumento. Este argumento rellena la propiedad InnerException de la nueva excepción y ayuda a la depuración. Casi todos los tipos de excepción ofrecen un constructor similar.

Volver a lanzar una excepción menos específica es algo que podrías hacer al cruzar un límite de confianza, para no filtrar información técnica a posibles piratas informáticos.

Propiedades clave de System.Exception

Las propiedades más importantes de System.Exception son las siguientes:

StackTrace
Una cadena que representa todos los métodos a los que se llama desde el origen de la excepción hasta el bloque catch.
Message
Una cadena con una descripción del error.
InnerException
La excepción interna (si la hay) que provocó la excepción externa. Ésta, a su vez, puede tener otro InnerException.
Nota

Todas las excepciones en C# son excepciones en tiempo de ejecución: no hay equivalentes a las excepciones comprobadas en tiempo de compilación de Java.

Tipos comunes de excepción

Los siguientes tipos de excepción se utilizan ampliamente en las bibliotecas CLR y .NET. Puedes lanzarlas tú mismo o utilizarlas como clases base para derivar tipos de excepción personalizados:

System.ArgumentException
Se lanza cuando se llama a una función con un argumento falso. Generalmente indica un error del programa.
System.ArgumentNullException
Subclase de ArgumentException que se lanza cuando un argumento de una función es (inesperadamente) null.
System.ArgumentOutOfRangeException
Subclase de ArgumentException que se lanza cuando un argumento (normalmente numérico) es demasiado grande o demasiado pequeño. Por ejemplo, se lanza cuando se pasa un número negativo a una función que sólo acepta valores positivos.
System.InvalidOperationException
Se lanza cuando el estado de un objeto es inadecuado para que un método se ejecute correctamente, independientemente de los valores particulares de los argumentos. Algunos ejemplos son la lectura de un archivo sin abrir o la obtención del siguiente elemento de un enumerador cuya lista subyacente se ha modificado a mitad de la iteración.
System.NotSupportedException
Se lanza para indicar que no se admite una funcionalidad concreta. Un buen ejemplo es llamar al método Add sobre una colección para la que IsReadOnly devuelve true.
System.NotImplementedException
Se lanza para indicar que una función aún no se ha implementado.
System.ObjectDisposedException
Se lanza cuando el objeto sobre el que se llama a la función ha sido eliminado.

Otro tipo de excepción frecuente es NullReferenceException. El CLR lanza esta excepción cuando intentas acceder a un miembro de un objeto cuyo valor es null (lo que indica un error en tu código). Puedes lanzar una NullReferenceException directamente (con fines de prueba) de la siguiente manera:

throw null;

El patrón del método TryXXX

Al escribir un método, tienes la opción, cuando algo va mal, de devolver algún tipo de código de fallo o lanzar una excepción. En general, lanzas una excepción cuando el error está fuera del flujo de trabajo normal, o si esperas que la persona que llama al método no sea capaz de resolverlo. En ocasiones, sin embargo, puede ser mejor ofrecer ambas opciones al consumidor. Un ejemplo de ello es el tipo int, que define dos versiones de su método Parse:

public int Parse     (string input);
public bool TryParse (string input, out int returnValue);

Si falla el análisis, Parse lanza una excepción; TryParse devuelve false.

Puedes aplicar este patrón haciendo que el método XXX llame al método TryXXX como se indica a continuación:

public return-type XXX (input-type input)
{
  return-type returnValue;
  if (!TryXXX (input, out returnValue))
    throw new YYYException (...)
  return returnValue;
}

Alternativas a las excepciones

Al igual que en int.TryParse, una función puede comunicar un fallo enviando un código de error de vuelta a la función que la llama a través de un tipo de retorno o parámetro. Aunque esto puede funcionar con fallos simples y predecibles, se vuelve torpe cuando se amplía a errores inusuales o impredecibles, contaminando las firmas de los métodos y creando complejidad y desorden innecesarios.

Tampoco puede generalizarse a funciones que no sean métodos, como operadores (por ejemplo, el operador de división) o propiedades. Una alternativa es colocar el error en un lugar común donde todas las funciones de la pila de llamadas puedan verlo (por ejemplo, un método estático que almacene el error actual por hilo). Esto, sin embargo, requiere que cada función participe en un patrón de propagación de errores, lo cual es engorroso e, irónicamente, propenso en sí mismo a errores.

Enumeración e Iteradores

Enumeración

Un enumerador es un cursor de sólo lectura y sólo hacia adelante sobre una secuencia de valores. C# trata un tipo como enumerador si realiza alguna de las siguientes acciones:

  • Tiene un método público sin parámetros llamado MoveNext y una propiedad llamada Current

  • Implementa System.Collections.Generic.IEnumerator<T>

  • Implementa System.Collections.IEnumerator

La sentencia foreach itera sobre un objeto enumerable. Un objeto enumerable es la representación lógica de una secuencia. No es en sí mismo un cursor, sino un objeto que produce cursores sobre sí mismo. C# trata un tipo como enumerable si realiza alguna de las siguientes acciones (la comprobación se realiza en este orden):

  • Tiene un método público sin parámetros llamado GetEnumerator que devuelve un enumerador

  • Implementa System.Collections.Generic.IEnumerable<T>

  • Implementa System.Collections.IEnumerable

  • (A partir de C# 9) Puede vincularse a un método de extensión llamado GetEnumerator que devuelva un enumerador (ver "Métodos de extensión")

El patrón de enumeración es el siguiente

class Enumerator   // Typically implements IEnumerator or IEnumerator<T>
{
  public IteratorVariableType Current { get {...} }
  public bool MoveNext() {...}
}

class Enumerable   // Typically implements IEnumerable or IEnumerable<T>
{
  public Enumerator GetEnumerator() {...}
}

Ésta es la forma de alto nivel de iterar a través de los caracteres de la palabra "cerveza" utilizando una sentencia foreach:

foreach (char c in "beer")
  Console.WriteLine (c);

Ésta es la forma más sencilla de recorrer los caracteres de "cerveza" sin utilizar una sentencia foreach:

using (var enumerator = "beer".GetEnumerator())
  while (enumerator.MoveNext())
  {
    var element = enumerator.Current;
    Console.WriteLine (element);
  }

Si el enumerador implementa IDisposable, la sentencia foreach también actúa como una sentencia using, disponiendo implícitamente del objeto enumerador.

Enel capítulo 7 se explican con más detalle las interfaces de enumeración.

Inicializadores de colecciones y expresiones de colecciones

Puedes instanciar y poblar un objeto enumerable en un solo paso mediante un inicializador de colección:

using System.Collections.Generic;

var list = new List<int> {1, 2, 3};

A partir de C# 12, puedes acortarlo aún más con una expresión de colección (fíjate en los corchetes):

using System.Collections.Generic;

List<int> list = [1, 2, 3];
Nota

Las expresiones de colección son de tipo objetivo, lo que significa que el tipo de [1,2,3] depende del tipo al que se asigna (en este caso, List<int>). En el siguiente ejemplo, los tipos de destino son int[] y Span<int> (que veremos en el capítulo 23):

int[] array = [1, 2, 3];
Span<int> span = [1, 2, 3];

La tipificación objetivo significa que puedes omitir el tipo en otras situaciones en las que el compilador pueda deducirlo, como al llamar a métodos:

Foo ([1, 2, 3]);

void Foo (List<int> numbers) { ... }

El compilador traduce esto a lo siguiente:

using System.Collections.Generic;

List<int> list = new List<int>();
list.Add (1);
list.Add (2);
list.Add (3);

Esto requiere que el objeto enumerable implemente la interfaz System.Collections.IEnumerable, y que tenga un método Add que tenga el número de parámetros adecuado para la llamada. (Con las expresiones de colección, el compilador también admite otros patrones para permitir la creación de colecciones de sólo lectura).

De forma similar, puedes inicializar los diccionarios (ver "Diccionarios") de la siguiente forma:

var dict = new Dictionary<int, string>()
{
  { 5, "five" },
  { 10, "ten" }
};

O, más sucintamente:

var dict = new Dictionary<int, string>()
{
  [3] = "three",
  [10] = "ten"
};

Esto último es válido no sólo con los diccionarios, sino también con cualquier tipo para el que exista un indexador.

Iteradores

Mientras que una sentencia foreach es un consumidor de un enumerador, un iterador es un productor de un enumerador. En este ejemplo, utilizamos un iterador para devolver una secuencia de números Fibonacci (donde cada número es la suma de los dos anteriores):

using System;
using System.Collections.Generic;

foreach (int fib in Fibs(6))
  Console.Write (fib + "  ");
}

IEnumerable<int> Fibs (int fibCount)
{
  for (int i = 0, prevFib = 1, curFib = 1; i < fibCount; i++)
  {
    yield return prevFib;
    int newFib = prevFib+curFib;
    prevFib = curFib;
    curFib = newFib;
  }
}

OUTPUT: 1  1  2  3  5  8

Mientras que una sentencia return expresa: "Aquí está el valor que me pediste que devolviera de este método", una sentencia yield return expresa: "Aquí está el siguiente elemento que me pediste que devolviera de este enumerador". En cada sentencia yield, el control se devuelve al llamante, pero el estado del llamante se mantiene para que el método pueda seguir ejecutándose en cuanto el llamante enumere el siguiente elemento. El tiempo de vida de este estado está vinculado al enumerador, de modo que el estado pueda liberarse cuando la persona que llama haya terminado de enumerar.

Nota

El compilador convierte los métodos de los iteradores en clases privadas que implementan IEnumerable<T> y/o IEnumerator<T>. La lógica dentro del bloque iterador se "invierte" y se empalma en el método MoveNext y la propiedad Current de la clase enumeradora escrita por el compilador. Esto significa que cuando llamas a un método iterador, lo único que haces es instanciar la clase escrita por el compilador; ¡en realidad no se ejecuta nada de tu código! Tu código sólo se ejecuta cuando empiezas a enumerar sobre la secuencia resultante, normalmente con una sentencia foreach.

Los iteradores pueden ser métodos locales (ver "Métodos locales").

Semántica de los iteradores

Un iterador es un método, propiedad o indexador que contiene una o varias sentencias yield. Un iterador debe devolver una de las cuatro interfaces siguientes (de lo contrario, el compilador generará un error):

// Enumerable interfaces
System.Collections.IEnumerable
System.Collections.Generic.IEnumerable<T>

// Enumerator interfaces
System.Collections.IEnumerator
System.Collections.Generic.IEnumerator<T>

Un iterador tiene una semántica diferente, según devuelva una interfaz enumerable o una interfaz enumeradora. Lo describiremos en el capítulo 7.

Se permitenmúltiples declaraciones de rendimiento:

foreach (string s in Foo())
  Console.WriteLine(s);         // Prints "One","Two","Three"

IEnumerable<string> Foo()
{
  yield return "One";
  yield return "Two";
  yield return "Three";
}

interrupción del rendimiento

Una sentencia return es ilegal en un bloque iterador; en su lugar, debes utilizar la sentencia yield break para indicar que el bloque iterador debe salir antes, sin devolver más elementos. Podemos modificar Foo como se indica a continuación para demostrarlo:

IEnumerable<string> Foo (bool breakEarly)
{
  yield return "One";
  yield return "Two";

  if (breakEarly)
    yield break;

  yield return "Three";
}

Iteradores y bloques try/catch/finally

Una sentencia yield return no puede aparecer en un bloque try que tenga una cláusula catch:

IEnumerable<string> Foo()
{
  try { yield return "One"; }    // Illegal
  catch { ... }
}

Tampoco puede aparecer yield return en un bloque catch o finally. Estas restricciones se deben a que el compilador debe traducir los iteradores a clases ordinarias con miembros MoveNext, Current y Dispose, y traducir los bloques de gestión de excepciones crearía una complejidad excesiva.

Sin embargo, puedes ceder dentro de un bloque try que tenga (sólo) un bloque finally:

IEnumerable<string> Foo()
{
  try { yield return "One"; }    // OK
  finally { ... }
}

El código del bloque finally se ejecuta cuando el enumerador consumidor llega al final de la secuencia o se desecha. Una sentencia foreach dispone implícitamente del enumerador si se abandona antes de tiempo, lo que hace que ésta sea una forma segura de consumir enumeradores. Cuando se trabaja con enumeradores explícitamente, una trampa es abandonar la enumeración antes de tiempo sin deshacerse de él, eludiendo el bloque finally. Puedes evitar este riesgo envolviendo el uso explícito de enumeradores en una sentencia using:

string firstElement = null;
var sequence = Foo();
using (var enumerator = sequence.GetEnumerator())
  if (enumerator.MoveNext())
    firstElement = enumerator.Current;

Componer secuencias

Los iteradores son altamente componibles. Podemos ampliar nuestro ejemplo, esta vez para que sólo salgan números pares de Fibonacci:

using System;
using System.Collections.Generic;

foreach (int fib in EvenNumbersOnly (Fibs(6)))
  Console.WriteLine (fib);

IEnumerable<int> Fibs (int fibCount)
{
  for (int i = 0, prevFib = 1, curFib = 1; i < fibCount; i++)
  {
    yield return prevFib;
    int newFib = prevFib+curFib;
    prevFib = curFib;
    curFib = newFib;
  }
}

IEnumerable<int> EvenNumbersOnly (IEnumerable<int> sequence)
{
  foreach (int x in sequence)
    if ((x % 2) == 0)
      yield return x;
}

Cada elemento no se calcula hasta el último momento, cuando lo solicita una operación MoveNext(). La Figura 4-1 muestra las peticiones de datos y la salida de datos a lo largo del tiempo.

Composing sequences
Figura 4-1. Componer secuencias

La componibilidad del patrón iterador es extremadamente útil en LINQ; volveremos a tratar el tema en el Capítulo 8.

Tipos de valores anulables

Los tipos de referencia pueden representar un valor inexistente con una referencia nula. Los tipos valor, en cambio, no pueden representar normalmente valores nulos:

string s = null;       // OK, Reference Type
int i = null;          // Compile Error, Value Type cannot be null

Para representar un nulo en un tipo de valor, debes utilizar una construcción especial llamada tipo anulable. Un tipo anulable se denota con un tipo de valor seguido del símbolo ?:

int? i = null;                     // OK, Nullable Type
Console.WriteLine (i == null);     // True

Nullable<T> Estruct

T? se traduce en , que es una estructura inmutable ligera, que sólo tiene dos campos, para representar y . La esencia de es muy sencilla: System.Nullable<T> Value HasValue System.Nullable<T>

public struct Nullable<T> where T : struct
{
  public T Value {get;}
  public bool HasValue {get;}
  public T GetValueOrDefault();
  public T GetValueOrDefault (T defaultValue);
  ...
}

El código

int? i = null;
Console.WriteLine (i == null);              // True

se traduce en lo siguiente:

Nullable<int> i = new Nullable<int>();
Console.WriteLine (! i.HasValue);           // True

Si se intenta recuperar Value cuando HasValue es falso, se produce un error InvalidOperatio⁠n​Exception. GetValueOrDefault() devuelve Value si HasValue es verdadero; en caso contrario, devuelve new T() o un valor predeterminado personalizado especificado.

El valor por defecto de T? es null.

Conversiones anulables implícitas y explícitas

La conversión de T a T? es implícita, mientras que de T? a T la conversión es explícita:

int? x = 5;        // implicit
int y = (int)x;    // explicit

El lanzamiento explícito equivale directamente a llamar a la propiedad Value del objeto anulable. Por lo tanto, se lanza un InvalidOperationException si HasValue es falso.

Encajonar y desencajonar valores nulos

Cuando T? está en caja, el valor en caja del montón contiene T, no T?. Esta optimización es posible porque un valor en caja es un tipo de referencia que ya puede expresar nulos.

C# también permite el unboxing de tipos de valor anulables con el operador as. El resultado será null si el reparto falla:

object o = "string";
int? x = o as int?;
Console.WriteLine (x.HasValue);   // False

Elevación del operario

La estructura Nullable<T> no define operadores como <, >, ni siquiera ==. A pesar de ello, el siguiente código se compila y ejecuta correctamente:

int? x = 5;
int? y = 10;
bool b = x < y;      // true

Esto funciona porque el compilador toma prestado o "levanta" el operador menor que del tipo de valor subyacente. Semánticamente, traduce la expresión de comparación anterior en esto:

bool b = (x.HasValue && y.HasValue) ? (x.Value < y.Value) : false;

En otras palabras, si tanto x como y tienen valores, los compara mediante el operador menos-que de int; en caso contrario, devuelve false.

La elevación de operadores significa que puedes utilizar implícitamente los operadores de Ten T?. Puedes definir operadores para T? con el fin de proporcionar un comportamiento nulo especial, pero en la gran mayoría de los casos, es mejor confiar en que el compilador aplique automáticamente la lógica nulable sistemática por ti. Aquí tienes algunos ejemplos:

int? x = 5;
int? y = null;

// Equality operator examples
Console.WriteLine (x == y);    // False
Console.WriteLine (x == null); // False
Console.WriteLine (x == 5);    // True
Console.WriteLine (y == null); // True
Console.WriteLine (y == 5);    // False
Console.WriteLine (y != 5);    // True

// Relational operator examples
Console.WriteLine (x < 6);     // True
Console.WriteLine (y < 6);     // False
Console.WriteLine (y > 6);     // False

// All other operator examples
Console.WriteLine (x + 5);     // 10
Console.WriteLine (x + y);     // null (prints empty line)

El compilador realiza la lógica de nulos de forma diferente según la categoría del operador. Las siguientes secciones explican estas diferentes reglas.

Operadores de igualdad (== y !=)

Los operadores de igualdad elevados tratan los nulos igual que los tipos de referencia. Esto significa que dos valores nulos son iguales:

Console.WriteLine (       null ==        null);   // True
Console.WriteLine ((bool?)null == (bool?)null);   // True

Además:

  • Si exactamente un operando es nulo, los operandos son desiguales.

  • Si ambos operandos son no nulos, se comparan sus Values.

Operadores relacionales (<, <=, >=, >)

Los operadores relacionales funcionan según el principio de que no tiene sentido comparar operandos nulos. Esto significa que la comparación de un valor nulo con un valor nulo o no nulo devuelve false:

bool b = x < y;    // Translation:

bool b = (x.HasValue && y.HasValue) 
         ? (x.Value < y.Value)
         : false;

// b is false (assuming x is 5 and y is null)

Todos los demás operadores (+, -, *, /, %, &, |, ^, <<, >>, +, ++, --, !, ~)

Estos operadores devuelven nulo cuando alguno de los operandos es nulo. Este patrón debería resultar familiar a los usuarios de SQL:

int? c = x + y;   // Translation:

int? c = (x.HasValue && y.HasValue)
         ? (int?) (x.Value + y.Value) 
         : null;

// c is null (assuming x is 5 and y is null)

Una excepción es cuando los operadores & y | se aplican a bool?, de lo que hablaremos en breve.

Mezclar tipos anulables y no anulables

Puedes mezclar y combinar tipos de valores anulables y no anulables (esto funciona porque hay una conversión implícita de T a T?):

int? a = null;
int b = 2;
int? c = a + b;   // c is null - equivalent to a + (int?)b

bool? con los operadores & y |

Cuando se suministran operandos del tipo bool?, los operadores & y | tratan null como un valor desconocido. Por tanto, null | true es verdadero porque

  • Si el valor desconocido es falso, el resultado sería verdadero.

  • Si el valor desconocido es verdadero, el resultado sería verdadero.

Del mismo modo, null & false es falso. Este comportamiento debería resultar familiar a los usuarios de SQL. El siguiente ejemplo enumera otras combinaciones:

bool? n = null;
bool? f = false;
bool? t = true;
Console.WriteLine (n | n);    // (null)
Console.WriteLine (n | f);    // (null)
Console.WriteLine (n | t);    // True
Console.WriteLine (n & n);    // (null)
Console.WriteLine (n & f);    // False
Console.WriteLine (n & t);    // (null)

Tipos de valores anulables y operadores nulos

Los tipos de valor anulables funcionan especialmente bien con el operador ?? (consulta "Operador de anulación"), como se ilustra en este ejemplo:

int? x = null;
int y = x ?? 5;        // y is 5

int? a = null, b = 1, c = 2;
Console.WriteLine (a ?? b ?? c);  // 1 (first non-null value)

Utilizar ?? en un tipo de valor anulable es equivalente a llamar a GetValueOrDefault con un valor predeterminado explícito, salvo que la expresión para el valor predeterminado nunca se evalúa si la variable no es nula.

Los tipos de valor anulables también funcionan bien con el operador condicional nulo (consulta "Operador condicional nulo"). En el siguiente ejemplo, longitud se evalúa como nulo:

System.Text.StringBuilder sb = null;
int? length = sb?.ToString().Length;

Podemos combinarlo con el operador de coalescencia nula para evaluar a cero en lugar de a nulo:

int length = sb?.ToString().Length ?? 0;  // Evaluates to 0 if sb is null

Escenarios para tipos de valor anulables

Uno de los escenarios más habituales para los tipos de valores anulables es representar valores desconocidos. Esto ocurre con frecuencia en la programación de bases de datos, donde una clase se asigna a una tabla con columnas anulables. Si estas columnas son cadenas (por ejemplo, una columna EmailAddress en una tabla Customer), no hay problema porque string es un tipo de referencia en el CLR, que puede ser nulo. Sin embargo, la mayoría de los demás tipos de columnas SQL se asignan a tipos struct del CLR, lo que hace que los tipos de valores anulables sean muy útiles al asignar SQL al CLR:

// Maps to a Customer table in a database
public class Customer
{
  ...
  public decimal? AccountBalance;
}

Un tipo anulable también puede utilizarse para representar el campo de respaldo de lo que a veces se denomina una propiedad de ambiente. Una propiedad de entorno, si es nula, devuelve el valor de su padre:

public class Row
{
  ...
  Grid parent;
  Color? color;

  public Color Color
  {
    get { return color ?? parent.Color; }
    set { color = value == parent.Color ? (Color?)null : value; }
  }
}

Alternativas a los tipos de valor anulables

Antes de que los tipos de valores anulables formaran parte del lenguaje C# (es decir, antes de C# 2.0), había muchas estrategias para tratarlos, ejemplos de las cuales siguen apareciendo en las bibliotecas .NET por razones históricas. Una de estas estrategias consiste en designar un determinado valor no nulo como "valor nulo"; un ejemplo lo tenemos en las clases string y array. String.IndexOf devuelve el valor mágico de −1 cuando no se encuentra el carácter:

int i = "Pink".IndexOf ('b');
Console.WriteLine (i);         // −1

Sin embargo, Array.IndexOf devuelve −1 sólo si el índice tiene un límite 0. La fórmula más general es que IndexOf devuelve uno menos que el límite inferior de la matriz. En el siguiente ejemplo, IndexOf devuelve 0 cuando no se encuentra un elemento:

// Create an array whose lower bound is 1 instead of 0:

Array a = Array.CreateInstance (typeof (string),
                                new int[] {2}, new int[] {1});
a.SetValue ("a", 1);
a.SetValue ("b", 2);
Console.WriteLine (Array.IndexOf (a, "c"));  // 0

Nombrar un "valor mágico" es problemático por varias razones:

  • Significa que cada tipo de valor tiene una representación diferente de null. En cambio, los tipos de valor anulables proporcionan un patrón común que funciona para todos los tipos de valor.

  • Puede que no haya ningún valor designado razonable. En el ejemplo anterior, no siempre se podía utilizar -1. Lo mismo ocurre con nuestro ejemplo anterior que representa un saldo de cuenta desconocido.

  • Olvidarse de comprobar el valor mágico da como resultado un valor incorrecto que puede pasar desapercibido hasta más adelante en la ejecución, cuando hace un truco de magia involuntario. Sin embargo, olvidarse de comprobar HasValue en un valor nulo, lanza un InvalidOperationException en el acto.

  • La posibilidad de que un valor sea nulo no se recoge en el tipo. Los tipos comunican la intención de un programa, permiten que el compilador compruebe su corrección y permiten un conjunto coherente de reglas aplicadas por el compilador.

Tipos de referencia anulables

Mientras que los tipos de valor anulables aportan anulabilidad a los tipos de valor, los tipos de referencia anulables (C# 8+) hacen lo contrario. Cuando están activados, aportan (cierto grado de) no anulabilidad a los tipos de referencia, con el fin de ayudar a evitar NullReferenceExceptions.

Los tipos de referencia anulables introducen un nivel de seguridad que es aplicado exclusivamente por el compilador, en forma de advertencias cuando detecta código que corre el riesgo de generar un NullReferenceException.

Para activar los tipos de referencia anulables, debes añadir el elemento Nullable a tu archivo de proyecto .csproj (si quieres activarlo para todo el proyecto):

<PropertyGroup>
  <Nullable>enable</Nullable>
</PropertyGroup>

o/y utiliza las siguientes directivas en tu código, en los lugares donde deba tener efecto:

#nullable enable   // enables nullable reference types from this point on
#nullable disable  // disables nullable reference types from this point on
#nullable restore  // resets nullable reference types to project setting

Una vez activada, el compilador hace que la no anulabilidad sea el valor por defecto: si quieres que un tipo de referencia acepte nulos sin que el compilador genere una advertencia, debes aplicar el sufijo ? para indicar un tipo de referencia anulable. En el siguiente ejemplo, s1 es no anulable, mientras que s2 es anulable:

#nullable enable    // Enable nullable reference types

string s1 = null;   // Generates a compiler warning!
string? s2 = null;  // OK: s2 is nullable reference type
Nota

Como los tipos de referencia anulables son construcciones en tiempo de compilación, no hay diferencia en tiempo de ejecución entre string y string?. En cambio, los tipos de valor anulables introducen algo concreto en el sistema de tipos, a saber, la estructura Nullable<T>.

Lo siguiente también genera una advertencia porque x no está inicializado:

class Foo { string x; }

La advertencia desaparece si inicializas x, ya sea mediante un inicializador de campo o mediante código en el constructor.

El operador que no perdona

El compilador también te avisa al hacer referencia a un tipo de referencia anulable, si cree que puede producirse un NullReferenceException. En el siguiente ejemplo, el acceso a la propiedad Length de la cadena genera una advertencia:

void Foo (string? s) => Console.Write (s.Length);

Puedes eliminar la advertencia con el operador de perdón de nulos (!):

void Foo (string? s) => Console.Write (s!.Length);

En este ejemplo, el uso del operador de perdón de nulos es peligroso, ya que podríamos acabar lanzando el mismo NullReferenceException que intentábamos evitar en primer lugar. Podríamos solucionarlo de la siguiente manera

void Foo (string? s)
{
  if (s != null) Console.Write (s.Length);
}

Observa ahora que no necesitamos el operador de perdón de nulos. Esto se debe a que el compilador realiza un análisis estático del flujo y es lo suficientemente inteligente como para deducir -al menos en casos sencillos- cuándo una desreferencia es segura y no hay posibilidad de que se produzca un NullReferenceException.

La capacidad del compilador para detectar y avisar no es a prueba de balas, y también hay límites a lo que es posible en términos de cobertura. Por ejemplo, es incapaz de saber si se han rellenado los elementos de una matriz, por lo que lo siguiente no genera una advertencia:

var strings = new string[10];
Console.WriteLine (strings[0].Length);

Separar los contextos de anotación y advertencia

Activar los tipos de referencia anulables mediante la directiva #nullable enable (o la configuración del proyecto <Nullable>enable</Nullable> ) hace dos cosas:

  • Activa el contexto de anotación anulable, que indica al compilador que trate todas las declaraciones de variables de tipo referencia como no anulables a menos que vayan acompañadas del símbolo ?.

  • Activa el contexto de advertencia anulable, que indica al compilador que genere advertencias al encontrar código con riesgo de lanzar un NullReference​Excep⁠tion.

A veces puede ser útil separar estos dos conceptos y activar sólo el contexto de anotación, o (menos útil) sólo el contexto de aviso:

#nullable enable annotations    // Enable the annotation context
// OR:
#nullable enable warnings       // Enable the warning context

(El mismo truco funciona con #nullable disable y #nullable restore.)

También puedes hacerlo a través del archivo de proyecto:

<Nullable>annotations</Nullable>
<!-- OR -->
<Nullable>warnings</Nullable>

Habilitar sólo el contexto de anotación para una clase o conjunto concreto puede ser un buen primer paso para introducir tipos de referencia anulables en una base de código heredada. Al anotar correctamente los miembros públicos, te aseguras de que tu clase o conjunto pueda actuar como un "buen ciudadano" para otras clases o conjuntos -de modo que puedan beneficiarse plenamente de los tipos de referencia anulables- sin tener que lidiar con advertencias en tu propia clase o conjunto.

Tratar los avisos anulables como errores

En proyectos totalmente nuevos, tiene sentido activar completamente el contexto anulable desde el principio. Tal vez quieras dar el paso adicional de tratar las advertencias de anulable como errores, de modo que tu proyecto no pueda compilar hasta que se hayan resuelto todas las advertencias de anulable:

<PropertyGroup>
  <Nullable>enable</Nullable>
  <WarningsAsErrors>CS8600;CS8602;CS8603</WarningsAsErrors>
</PropertyGroup>

Métodos de extensión

Los métodos de extensión permiten ampliar un tipo existente con nuevos métodos sin alterar la definición del tipo original. Un método de extensión es un método estático de una clase estática, en el que el modificador this se aplica al primer parámetro. El tipo del primer parámetro será el tipo extendido:

public static class StringHelper
{
  public static bool IsCapitalized (this string s)
  {
    if (string.IsNullOrEmpty(s)) return false;
    return char.IsUpper (s[0]);
  }
}

El método de extensión IsCapitalized puede invocarse como si fuera un método de instancia sobre una cadena, del siguiente modo:

Console.WriteLine ("Perth".IsCapitalized());

Una llamada a un método de extensión, cuando se compila, se convierte en una llamada a un método estático normal:

Console.WriteLine (StringHelper.IsCapitalized ("Perth"));

La traducción funciona del siguiente modo:

arg0.Method (arg1, arg2, ...);              // Extension method call
StaticClass.Method (arg0, arg1, arg2, ...); // Static method call

Las interfaces también se pueden ampliar:

public static T First<T> (this IEnumerable<T> sequence)
{
  foreach (T element in sequence)
    return element;

  throw new InvalidOperationException ("No elements!");
}
...
Console.WriteLine ("Seattle".First());   // S

Encadenamiento de métodos de extensión

Los métodos de extensión, al igual que los métodos de instancia, proporcionan una forma ordenada de encadenar funciones. Considera las dos funciones siguientes:

public static class StringHelper
{
  public static string Pluralize (this string s) {...}
  public static string Capitalize (this string s) {...}
}

x y y son equivalentes, y ambos evalúan a "Sausages", pero x utiliza métodos de extensión, mientras que y utiliza métodos estáticos:

string x = "sausage".Pluralize().Capitalize();
string y = StringHelper.Capitalize (StringHelper.Pluralize ("sausage"));

Ambigüedad y resolución

Espacios de nombres

No se puede acceder a un método de extensión a menos que su clase esté en el ámbito, normalmente mediante la importación de su espacio de nombres. Considera el método de extensión IsCapitalized en el siguiente ejemplo:

using System;

namespace Utils
{
  public static class StringHelper
  {
    public static bool IsCapitalized (this string s)
    {
      if (string.IsNullOrEmpty(s)) return false;
      return char.IsUpper (s[0]);
    }
  }
}

Para utilizar IsCapitalized, la siguiente aplicación debe importar Utils para evitar un error de compilación:

namespace MyApp
{
  using Utils;

  class Test
  {
    static void Main() => Console.WriteLine ("Perth".IsCapitalized());
  }
}

Métodos de extensión frente a métodos de instancia

Cualquier método de instancia compatible siempre tendrá prioridad sobre un método de extensión. En el siguiente ejemplo, el método Test's Foo siempre tendrá preferencia, incluso cuando se llame con un argumento x de tipo int:

class Test
{
  public void Foo (object x) { }    // This method always wins
}

static class Extensions
{
  public static void Foo (this Test t, int x) { }
}

La única forma de llamar al método de extensión en este caso es mediante la sintaxis estática normal, es decir, Extensions.Foo(...).

Métodos de extensión frente a métodos de extensión

Si dos métodos de extensión tienen la misma firma, el método de extensión debe llamarse como un método estático ordinario para desambiguar el método a llamar. Sin embargo, si un método de extensión tiene argumentos más específicos, el método más específico tiene prioridad.

Para ilustrarlo, considera las dos clases siguientes:

static class StringHelper
{
  public static bool IsCapitalized (this string s) {...}
}
static class ObjectHelper
{
  public static bool IsCapitalized (this object s) {...}
}

El siguiente código llama al método IsCapitalized de StringHelper:

bool test1 = "Perth".IsCapitalized();

Las clases y los structs se consideran más específicos que las interfaces.

Degradar un método de extensión

Puede plantearse una situación interesante cuando Microsoft añade un método de extensión a una biblioteca en tiempo de ejecución .NET que entra en conflicto con un método de extensión de alguna biblioteca de terceros existente. Como autor de la biblioteca de terceros, puede que quieras "retirar" tu método de extensión, pero sin eliminarlo y sin romper la compatibilidad binaria con los consumidores existentes.

Afortunadamente, esto es fácil de conseguir, simplemente eliminando la palabra clave this de la definición de tu método de extensión. Esto convierte tu método de extensión en un método estático normal. Lo bueno de esta solución es que cualquier ensamblado compilado con tu antigua biblioteca seguirá funcionando (y enlazándose a tu método, como antes). La razón es que las llamadas a métodos de extensión se convierten en llamadas a métodos estáticos durante la compilación.

Los consumidores sólo se verán afectados por tu degradación cuando recompilen, momento en el que las llamadas a tu antiguo método de extensión se enlazarán a la versión de Microsoft (si se ha importado el espacio de nombres). Si el consumidor aún desea llamar a tu método, puede hacerlo invocándolo como método estático.

Tipos anónimos

Un tipo anónimo es una clase simple creada por el compilador sobre la marcha para almacenar un conjunto de valores. Para crear un tipo anónimo, utiliza la palabra clave new seguida de un inicializador de objeto, especificando las propiedades y valores que contendrá el tipo; por ejemplo:

var dude = new { Name = "Bob", Age = 23 };

El compilador traduce esto a (aproximadamente) lo siguiente:

internal class AnonymousGeneratedTypeName
{
  private string name;  // Actual field name is irrelevant
  private int    age;   // Actual field name is irrelevant

  public AnonymousGeneratedTypeName (string name, int age)
  {
    this.name = name; this.age = age;
  }

  public string  Name => name;
  public int     Age  => age;

  // The Equals and GetHashCode methods are overridden (see Chapter 6).
  // The ToString method is also overridden.
}
...

var dude = new AnonymousGeneratedTypeName ("Bob", 23);

Debes utilizar la palabra clave var para hacer referencia a un tipo anónimo porque no tiene nombre.

El nombre de la propiedad de un tipo anónimo puede deducirse de una expresión que sea a su vez un identificador (o termine con uno); así

int Age = 23;
var dude = new { Name = "Bob", Age, Age.ToString().Length };

equivale a lo siguiente

var dude = new { Name = "Bob", Age = Age, Length = Age.ToString().Length };

Dos instancias de tipo anónimo declaradas dentro del mismo conjunto tendrán el mismo tipo subyacente si sus elementos se nombran y tipan de forma idéntica:

var a1 = new { X = 2, Y = 4 };
var a2 = new { X = 2, Y = 4 };
Console.WriteLine (a1.GetType() == a2.GetType());   // True

Además, el método Equals se anula para realizar la comparación de igualdad estructural (comparación de los datos):

Console.WriteLine (a1.Equals (a2));   // True

Mientras que el operador de igualdad (==) realiza una comparación referencial:

Console.WriteLine (a1 == a2);         // False

Puedes crear matrices de tipos anónimos como se indica a continuación:

var dudes = new[]
{
  new { Name = "Bob", Age = 30 },
  new { Name = "Tom", Age = 40 }
};

Un método no puede (útilmente) devolver un objeto de tipo anónimo, porque es ilegal escribir un método cuyo tipo de retorno sea var:

var Foo() => new { Name = "Bob", Age = 30 };  // Not legal!

(En las secciones siguientes describiremos los registros y las tuplas, que ofrecen enfoques alternativos para devolver varios valores de un método).

Los tipos anónimos son inmutables, por lo que las instancias no pueden modificarse tras su creación. Sin embargo, a partir de C# 10, puedes utilizar la palabra clave with para crear una copia con variaciones(mutación no destructiva):

var a1 = new { A = 1, B = 2, C = 3, D = 4, E = 5 };
var a2 = a1 with { E = 10 }; 
Console.WriteLine (a2);      // { A = 1, B = 2, C = 3, D = 4, E = 10 }

Los tipos anónimos son especialmente útiles al escribir consultas LINQ (consulta el capítulo 8).

Tuplas

Al igual que los tipos anónimos, las tuplas proporcionan una forma sencilla de almacenar un conjunto de valores. Las tuplas se introdujeron en C# con el objetivo principal de permitir que los métodos devolvieran varios valores sin recurrir a los parámetros de out (algo que no se puede hacer con los tipos anónimos). Desde entonces, sin embargo, se han introducido los registros, que ofrecen un enfoque tipado conciso que describiremos en la siguiente sección.

Nota

Las tuplas hacen casi todo lo que hacen los tipos anónimos y tienen la ventaja potencial de ser tipos de valor, pero sufren -como verás enseguida- el borrado de tipos en tiempo de ejecución con elementos con nombre.

La forma más sencilla de crear una tupla literal es enumerar los valores deseados entre paréntesis. Esto crea una tupla con elementos sin nombre, a los que te refieres como Item1, Item2, etc:

var bob = ("Bob", 23);    // Allow compiler to infer the element types

Console.WriteLine (bob.Item1);   // Bob
Console.WriteLine (bob.Item2);   // 23

Las tuplas son tipos de valores, con elementos mutables (lectura/escritura):

var joe = bob;                 // joe is a *copy* of bob
joe.Item1 = "Joe";             // Change joe’s Item1 from Bob to Joe
Console.WriteLine (bob);       // (Bob, 23)
Console.WriteLine (joe);       // (Joe, 23)

A diferencia de los tipos anónimos, puedes especificar un tipo de tupla explícitamente. Sólo tienes que enumerar cada uno de los tipos de elementos entre paréntesis:

(string,int) bob  = ("Bob", 23);

Esto significa que puedes devolver útilmente una tupla de un método:

(string,int) person = GetPerson();  // Could use 'var' instead if we want
Console.WriteLine (person.Item1);   // Bob
Console.WriteLine (person.Item2);   // 23

(string,int) GetPerson() => ("Bob", 23);

Las tuplas funcionan bien con los genéricos, por lo que los siguientes tipos son todos legales:

Task<(string,int)>
Dictionary<(string,int),Uri>
IEnumerable<(int id, string name)>   // See below for naming elements

Nombrar elementos de tupla

Opcionalmente, puedes dar nombres significativos a los elementos al crear literales de tupla:

var tuple = (name:"Bob", age:23);

Console.WriteLine (tuple.name);     // Bob
Console.WriteLine (tuple.age);      // 23

Puedes hacer lo mismo al especificar tipos de tupla:

var person = GetPerson();
Console.WriteLine (person.name);    // Bob
Console.WriteLine (person.age);     // 23

(string name, int age) GetPerson() => ("Bob", 23);
Nota

En "Registros", mostraremos cómo puedes definir clases simples o structs sin ruido, para que te resulte fácil definir un tipo de retorno formal:

var person = GetPerson();
Console.WriteLine (person.Name);    // Bob
Console.WriteLine (person.Age);     // 23

Person GetPerson() => new ("Bob", 23); 
record Person (string Name, int Age);

A diferencia de las tuplas, las propiedades de un registro (Name y Age) están fuertemente tipadas, por lo que pueden refactorizarse fácilmente. Este enfoque también reduce la duplicación de código y fomenta el buen diseño de un par de maneras. En primer lugar, el proceso de decidir un nombre sencillo y no artificioso para el tipo ayuda a validar tu diseño (la incapacidad de hacerlo puede indicar la falta de un único propósito cohesivo). En segundo lugar, es probable que acabes añadiendo métodos u otro código al registro (los tipos bien nombrados tienden a atraer código), y trasladar el código a los datos es un principio básico del buen diseño orientado a objetos.

Ten en cuenta que aún puedes tratar los elementos como sin nombre y referirte a ellos como Item1, Item2, etc. (aunque Visual Studio oculta estos campos del IntelliSense).

Los nombres de los elementos se deducen automáticamente de los nombres de las propiedades o campos:

var now = DateTime.Now;
var tuple = (now.Day, now.Month, now.Year);
Console.WriteLine (tuple.Day);               // OK

Las tuplas son compatibles entre sí si los tipos de sus elementos coinciden (en orden). Sus nombres de elementos no tienen por qué coincidir:

(string name, int age, char sex)  bob1 = ("Bob", 23, 'M');
(string age,  int sex, char name) bob2 = bob1;   // No error!

Nuestro ejemplo particular conduce a resultados confusos:

Console.WriteLine (bob2.name);    // M
Console.WriteLine (bob2.age);     // Bob
Console.WriteLine (bob2.sex);     // 23

Borrado de tipo

Anteriormente dijimos que el compilador de C# maneja los tipos anónimos construyendo clases personalizadas con propiedades con nombre para cada uno de los elementos. Con las tuplas, C# funciona de forma diferente y utiliza una familia preexistente de structs genéricos:

public struct ValueTuple<T1>
public struct ValueTuple<T1,T2>
public struct ValueTuple<T1,T2,T3>
...

Cada uno de los structs ValueTuple<> tiene campos denominados Item1, Item2, y así sucesivamente.

Por lo tanto, (string,int) es un alias de ValueTuple<string,int>, y esto significa que los elementos con nombre de tupla no tienen nombres de propiedad correspondientes en los tipos subyacentes. En su lugar, los nombres sólo existen en el código fuente y en la imaginación del compilador. En tiempo de ejecución, los nombres desaparecen en su mayor parte, de modo que si descompilas un programa que hace referencia a elementos de tupla con nombre, sólo verás referencias a Item1, Item2, etc. Además, cuando examinas una variable tupla en un depurador después de haberla asignado a un object (o Dump en LINQPad), los nombres de los elementos no están ahí. Y, en general, no puedes utilizar la reflexión(Capítulo 18) para determinar los nombres de los elementos de una tupla en tiempo de ejecución. Esto significa que, con API como System.Net.Http.HttpClient, las tuplas no pueden sustituir a los tipos anónimos en situaciones como las siguientes:

// Create JSON payload:
var json = JsonContent.Create (new { id = 123, name = "Test" })
Nota

Dijimos que los nombres desaparecen en la mayoría de los casos porque hay una excepción. Con los métodos/propiedades que devuelven tipos de tupla con nombre, el compilador emite los nombres de los elementos aplicando un atributo personalizado llamado TupleElementNamesAttribute (ver "Atributos") al tipo de retorno del miembro. Esto permite que los elementos con nombre funcionen al llamar a métodos de un ensamblado diferente (del que el compilador no tiene el código fuente).

Alias de tuplas (C# 12)

A partir de C# 12, puedes aprovechar la directiva using para definir alias para tuplas:

using Point = (int, int);
Point p = (3, 4);

Esta función también funciona con tuplas que tienen elementos con nombre:

using Point = (int X, int Y);    // Legal (but not necessarily *good*!)
Point p = (3, 4);

De nuevo, veremos en breve cómo los registros ofrecen una solución totalmente tipada con el mismo nivel de concisión:

Point p = new (3, 4);
record Point (int X, int Y);

ValueTuple.Crear

También puedes crear tuplas mediante un método de fábrica en el tipo (no genérico) ValueTuple:

ValueTuple<string,int> bob1 = ValueTuple.Create ("Bob", 23);
(string,int)           bob2 = ValueTuple.Create ("Bob", 23);
(string name, int age) bob3 = ValueTuple.Create ("Bob", 23);

Deconstruir tuplas

Las tuplas admiten implícitamente el patrón de deconstrucción (véase "Deconstructores"), por lo que puedes deconstruir fácilmente una tupla en variables individuales. Piensa en lo siguiente:

var bob = ("Bob", 23);

string name = bob.Item1;
int age = bob.Item2;

Con el deconstructor de la tupla, puedes simplificar el código a esto:

var bob = ("Bob", 23);

(string name, int age) = bob;   // Deconstruct the bob tuple into
                                // separate variables (name and age).
Console.WriteLine (name);
Console.WriteLine (age);

La sintaxis para la deconstrucción es confusamente similar a la sintaxis para declarar una tupla con elementos con nombre. A continuación se destaca la diferencia:

(string name, int age)      = bob;   // Deconstructing a tuple
(string name, int age) bob2 = bob;   // Declaring a new tuple

He aquí otro ejemplo, esta vez al llamar a un método, y con inferencia de tipo (var):

var (name, age, sex) = GetBob();
Console.WriteLine (name);        // Bob
Console.WriteLine (age);         // 23
Console.WriteLine (sex);         // M

string, int, char) GetBob() => ( "Bob", 23, 'M');

También puedes deconstruir directamente en campos y propiedades, lo que proporciona un buen atajo para rellenar varios campos o propiedades en un constructor:

class Point
{
  public readonly int X, Y;
  public Point (int x, int y) => (X, Y) = (x, y);
}

Comparación de igualdad

Al igual que con los tipos anónimos, el método Equals realiza una comparación de igualdad estructural. Esto significa que compara los datos subyacentes en lugar de la referencia:

var t1 = ("one", 1);
var t2 = ("one", 1);
Console.WriteLine (t1.Equals (t2));    // True

Además, ValueTuple<> sobrecarga los operadores == y !=:

Console.WriteLine (t1 == t2);    // True (from C# 7.3)

Las tuplas también anulan el método GetHashCode, por lo que resulta práctico utilizar tuplas como claves en los diccionarios. Trataremos en detalle la comparación de igualdades en "Comparación de igualdades", y los diccionarios en el Capítulo 7.

Los tipos ValueTuple<> también implementan IComparable (véase "Comparación de orden"), lo que permite utilizar tuplas como clave de ordenación.

Las clases System.Tuple

Encontrarás otra familia de tipos genéricos en el espacio de nombres System llamada Tuple (en lugar de ValueTuple). Se introdujeron en 2010 y se definieron como clases (mientras que los tipos ValueTuple son structs). Definir las tuplas como clases se consideró, en retrospectiva, un error: en los escenarios en los que se suelen utilizar las tuplas, los structs tienen una ligera ventaja de rendimiento (ya que evitan asignaciones de memoria innecesarias), sin apenas inconvenientes. Por eso, cuando Microsoft añadió soporte de lenguaje para tuplas en C# 7, ignoró los tipos Tuple existentes en favor de los nuevos ValueTuple. Es posible que aún te encuentres con las clases Tuple en código escrito antes de C# 7. No tienen soporte especial de lenguaje y se utilizan como se indica a continuación:

Tuple<string,int> t = Tuple.Create ("Bob", 23);  // Factory method 
Console.WriteLine (t.Item1);       // Bob
Console.WriteLine (t.Item2);       // 23

Registros

Un registro es un tipo especial de clase o estructura diseñado para funcionar bien con datos inmutables (sólo de lectura). Su característica más útil es la mutación no destructiva; sin embargo, los registros también son útiles para crear tipos que sólo combinan o mantienen datos. En casos sencillos, eliminan el código repetitivo al tiempo que respetan la semántica de igualdad más adecuada para los tipos inmutables.

Los registros son puramente una construcción de C# en tiempo de compilación. En tiempo de ejecución, el CLR los ve como clases o structs (con un montón de miembros "sintetizados" adicionales añadidos por el compilador).

Antecedentes

Escribir tipos inmutables (cuyos campos no pueden modificarse tras la inicialización) es una estrategia popular para simplificar el software y reducir los errores. También es un aspecto central de la programación funcional, en la que se evita el estado mutable y las funciones se tratan como datos. LINQ se inspira en este principio.

Para "modificar" un objeto inmutable, debes crear uno nuevo y copiar los datos incorporando tus modificaciones (esto se llama mutación no destructiva). En términos de rendimiento, esto no es tan ineficaz como cabría esperar, porque una copia superficial siempre será suficiente (una copia profunda, en la que también copias subobjetos y colecciones, es innecesaria cuando los datos son inmutables). Pero en términos de esfuerzo de codificación, implementar la mutación no destructiva puede ser muy ineficiente, especialmente cuando hay muchas propiedades. Los registros resuelven este problema mediante un patrón soportado por el lenguaje.

Un segundo problema es que los programadores -sobre todo los funcionales- a vecesutilizan tipos inmutables sólo para combinar datos (sin añadir comportamiento). Definir esos tipos es más trabajo del que debería, ya que requiere un constructor para asignar cada parámetro a cada propiedad pública (también puede ser útil un deconstructor). Con los registros, el compilador puede hacer este trabajo por ti.

Por último, una de las consecuencias de que un objeto sea inmutable es que su identidad no puede cambiar, lo que significa que es más útil para esos tipos implementar la igualdad estructural que la igualdad referencial. La igualdad estructural significa que dos instancias son iguales si sus datos son los mismos (como ocurre con las tuplas). Los registros te proporcionan igualdad estructural por defecto -independientemente de si el tipo subyacente es una clase o una estructura- sin necesidad de código repetitivo.

Definir un registro

Una definición de registro es como una definición de clase o estructura, y puede contener los mismos tipos de miembros, incluidos campos, propiedades, métodos, etc. Los registros pueden implementar interfaces, y los registros (basados en clases) pueden subclasificar otros registros (basados en clases).

Por defecto, el tipo subyacente de un registro es una clase:

record Point { }          // Point is a class

A partir de C# 10, el tipo subyacente de un registro también puede ser una estructura:

record struct Point { }   // Point is a struct

(record class también es legal y tiene el mismo significado que record.)

Un registro sencillo puede contener sólo un puñado de propiedades de sólo inicio, y tal vez un constructor:

record Point
{
  public Point (double x, double y) => (X, Y) = (x, y);

  public double X { get; init; }
  public double Y { get; init; }    
}
Nota

Nuestro constructor emplea un atajo que describimos en la sección anterior.

(X, Y) = (x, y);

equivale (en este caso) a lo siguiente:

{ this.X = x; this.Y = y; }

Al compilar, C# transforma la definición de registro en una clase (o estructura) y realiza los siguientes pasos adicionales:

  • Escribe un constructor de copia protegido (y un método Clonar oculto) para facilitar la mutación no destructiva.

  • Anula/sobrecarga las funciones relacionadas con la igualdad para aplicar la igualdad estructural.

  • Anula el método ToString() (para ampliar las propiedades públicas del registro, como con los tipos anónimos).

La declaración de registro anterior se expande en algo como esto:

class Point
{  
  public Point (double x, double y) => (X, Y) = (x, y);

  public double X { get; init; }
  public double Y { get; init; }    

  protected Point (Point original)    // “Copy constructor”
  {
    this.X = original.X; this.Y = original.Y
  }

  // This method has a strange compiler-generated name:
  public virtual Point <Clone>$() => new Point (this);   // Clone method

  // Additional code to override Equals, ==, !=, GetHashCode, ToString()
  // ...
}
Nota

Aunque nada te impide introducir parámetros opcionales en el constructor, un buen patrón (al menos en las bibliotecas públicas) es dejarlos fuera del constructor y exponerlos únicamente como propiedades init:

new Foo (123, 234) { Optional2 = 345 };

record Foo
{
  public Foo (int required1, int required2) { ... }

  public int Required1 { get; init; }
  public int Required2 { get; init; }

  public int Optional1 { get; init; }
  public int Optional2 { get; init; }
}

La ventaja de este patrón es que puedes añadir con seguridad propiedades sólo init más adelante sin romper la compatibilidad binaria con los consumidores que hayan compilado con versiones anteriores de tu ensamblado.

Listas de parámetros

Una definición de registro puede acortarse mediante el uso de una lista de parámetros:

record Point (double X, double Y)
{
  // You can optionally define additional class members here...
}

Los parámetros pueden incluir los modificadores in y params, pero no out ni ref. Si se especifica una lista de parámetros, el compilador realiza los siguientes pasos adicionales:

  • Escribe una propiedad sólo init por parámetro.

  • Escribe un constructor primario para rellenar las propiedades.

  • Escribe un deconstructor.

Esto significa que si declaramos nuestro registro Point simplemente como:

record Point (double X, double Y);

el compilador acabará generando (casi) exactamente lo que hemos enumerado en la expansión anterior. Una pequeña diferencia es que los nombres de los parámetros del constructor primario acabarán siendo X y Y en lugar de x y y:

  public Point (double X, double Y)   // “Primary constructor”
  {
    this.X = X; this.Y = Y;
  }
Nota

Además, al ser un constructor primario, los parámetros X y Y pasan a estar mágicamente disponibles para cualquier inicializador de campo o propiedad de tu registro. Discutiremos las sutilezas de esto más adelante, en "Constructores primarios".

Otra diferencia, cuando defines una lista de parámetros, es que el compilador también genera un deconstructor:

  public void Deconstruct (out double X, out double Y)   // Deconstructor
  {
    X = this.X; Y = this.Y;
  }

Los registros con listas de parámetros pueden subclasificarse utilizando la siguiente sintaxis:

record Point3D (double X, double Y, double Z) : Point (X, Y);

A continuación, el compilador emite un constructor primario como el siguiente

class Point3D : Point
{
  public double Z { get; init; }

  public Point3D (double X, double Y, double Z) : base (X, Y) 
    => this.Z = Z;
}
Nota

Las listas de parámetros ofrecen un buen atajo cuando necesitas una clase que simplemente agrupe un montón de valores (un tipo de producto en programación funcional) y también pueden ser útiles para crear prototipos. Como veremos más adelante, no son tan útiles cuando necesitas añadir lógica a los accesorios de init (como la validación de argumentos).

Mutación no destructiva

El paso más importante que realiza el compilador con todos los registros es escribir un constructor de copia (y un método oculto Clonar ). Esto permite la mutación no destructiva mediante la palabra clave with:

Point p1 = new Point (3, 3);
Point p2 = p1 with { Y = 4 };
Console.WriteLine (p2);       // Point { X = 3, Y = 4 }

record Point (double X, double Y);

En este ejemplo, p2 es una copia de p1, pero con su propiedad Y ajustada a 4. La ventaja es más evidente cuando hay más propiedades:

Test t1 = new Test (1, 2, 3, 4, 5, 6, 7, 8);
Test t2 = t1 with { A = 10, C = 30 };
Console.WriteLine (t2);

record Test (int A, int B, int C, int D, int E, int F, int G, int H);

Este es el resultado:

Test { A = 10, B = 2, C = 30, D = 4, E = 5, F = 6, G = 7, H = 8 }

La mutación no destructiva se produce en dos fases:

  1. En primer lugar, el constructor de copia clona el registro. Por defecto, copia cada uno de los campos subyacentes del registro, creando una réplica fiel y evitando (la sobrecarga de) cualquier lógica en los accesores de init. Se incluyen todos los campos (públicos y privados, así como los campos ocultos que respaldan propiedades automáticas).

  2. A continuación, se actualiza cada propiedad de la lista de inicializadores de miembros (esta vez utilizando los accesores init ).

El compilador traduce

Test t2 = t1 with { A = 10, C = 30 };

en algo funcionalmente equivalente a lo siguiente

Test t2 = new Test(t1);  // Use copy constructor to clone t1 field by field
t2.A = 10;               // Update property A
t2.C = 30;               // Update property C

(El mismo código no compilaría si lo escribieras explícitamente porque A y C son propiedades sólo init. Además, el constructor de copia está protegido; C# lo evita invocándolo mediante un método público oculto que escribe en el registro llamado <Clone>$.)

Si es necesario, puedes definir tu propio constructor de copia. C# utilizará entonces tu definición en lugar de escribir uno él mismo:

protected Point (Point original)
{
  this.X = original.X; this.Y = original.Y;
}

Escribir un constructor de copia personalizado puede ser útil si tu registro contiene subobjetos o colecciones mutables que quieras clonar, o si hay campos computados que quieras borrar. Por desgracia, sólo puedes sustituir, no mejorar, la implementación por defecto.

Nota

Al subclasificar otro registro, el constructor de la copia se encarga de copiar sólo sus propios campos. Para copiar los campos del registro base, delega en la base:

protected Point (Point original) : base (original)
{
  ...
}

Validación de propiedades

Con las propiedades explícitas, puedes escribir lógica de validación en los accesores de init. En el siguiente ejemplo, nos aseguramos de que X nunca pueda ser NaN (No es un número):

record Point
{
  // Notice that we assign x to the X property (and not the _x field):
  public Point (double x, double y) => (X, Y) = (x, y);

  double _x;
  public double X
  { 
    get => _x;
    init
    {
      if (double.IsNaN (value))
        throw new ArgumentException ("X Cannot be NaN");
      _x = value;
    }
  }
  public double Y { get; init; }    
}

Nuestro diseño garantiza que la validación se produzca tanto durante la construcción como cuando el objeto muta de forma no destructiva:

Point p1 = new Point (2, 3);
Point p2 = p1 with { X = double.NaN };   // throws an exception

Recuerda que el constructor de copia generado automáticamente copia sobre todos los campos y propiedades automáticos. Esto significa que el constructor de copia generado tendrá ahora este aspecto:

protected Point (Point original)
 {
   _x = original._x; Y = original.Y;
 }

Observa que la copia del campo _x elude el accesor de la propiedad X. Sin embargo, esto no puede romper nada, porque se está copiando fielmente un objeto que ya se habrá rellenado de forma segura mediante el accesor init de X.

Campos calculados y evaluación perezosa

Un patrón de programación funcional popular que funciona bien con tipos inmutables es la evaluación perezosa, en la que un valor no se computa hasta que se necesita y luego se almacena en caché para reutilizarlo. Supongamos, por ejemplo, que queremos definir una propiedad en nuestro registro Point que devuelva la distancia desde el origen (0, 0):

record Point (double X, double Y)
{
  public double DistanceFromOrigin => Math.Sqrt (X*X + Y*Y);
}

Intentemos ahora refactorizar esto para evitar el coste de volver a calcular DistanceFromOrigin cada vez que se accede a la propiedad. Empezaremos eliminando la lista de propiedades y definiendo X, Y, y DistanceFromOrigin como propiedades de sólo lectura. Entonces podremos calcular esta última en el constructor:

record Point
{
  public double X { get; }
  public double Y { get; }
  public double DistanceFromOrigin { get; }

  public Point (double x, double y) =>
    (X, Y, DistanceFromOrigin) = (x, y, Math.Sqrt (x*x + y*y));
}

Esto funciona, pero no permite la mutación no destructiva (cambiar X y Y a propiedades sólo init rompería el código porque DistanceFromOrigin se volvería obsoleta después de que se ejecutaran los accesores de init ). También es subóptima en el sentido de que el cálculo se realiza siempre, independientemente de si la propiedad DistanceFromOrigin se lee alguna vez. La solución óptima es almacenar en caché su valor en un campo y rellenarlo perezosamente (en el primer uso):

record Point
{
  ...

  double? _distance;
  public double DistanceFromOrigin
  {
    get
    {
      if (_distance == null) 
        _distance = Math.Sqrt (X*X + Y*Y);

      return _distance.Value;
    }
  }
}
Nota

Técnicamente, en este código mutamos _distance. Sin embargo, sigue siendo justo llamar a Point un tipo inmutable. Mutar un campo únicamente para rellenar un valor perezoso no invalida los principios ni las ventajas de la inmutabilidad, e incluso puede enmascararse mediante el uso del tipo Lazy<T> que describimos en el Capítulo 21.

Con el operador de asignación de coalescencia nula de C# (??=), podemos reducir toda la declaración de propiedades a una línea de código:

  public double DistanceFromOrigin => _distance ??= Math.Sqrt (X*X + Y*Y);

(Es decir, devuelve _distance si no es nulo; si no, devuelve Math.Sqrt (X*X + Y*Y) asignándolo a _distance.)

Para que esto funcione con propiedades sólo init, necesitamos un paso más, que es borrar el campo _distance almacenado en caché cuando se actualice X o Y mediante el accesor init. Aquí tienes el código completo:

record Point
{
  public Point (double x, double y) => (X, Y) = (x, y);

  double _x, _y;
  public double X { get => _x; init { _x = value; _distance = null; } }
  public double Y { get => _y; init { _y = value; _distance = null; } }
    
  double? _distance;
  public double DistanceFromOrigin => _distance ??= Math.Sqrt (X*X + Y*Y);
}

Point ahora pueden mutar de forma no destructiva:

Point p1 = new Point (2, 3);
Console.WriteLine (p1.DistanceFromOrigin);   // 3.605551275463989
Point p2 = p1 with { Y = 4 };
Console.WriteLine (p2.DistanceFromOrigin);   // 4.47213595499958

Una ventaja adicional es que el constructor de copia autogenerado copia el campo _distance almacenado en caché. Esto significa que si un registro tiene otras propiedades que no intervienen en el cálculo, una mutación no destructiva de esas propiedades no provocaría una pérdida innecesaria del valor almacenado en caché. Si no te interesa esta ventaja, una alternativa a borrar el valor almacenado en caché en los accesores de init es escribir un constructor de copia personalizado que ignore el campo almacenado en caché. Esto es más conciso porque funciona con listas de parámetros, y el constructor de copia personalizado puede aprovechar el deconstructor:

record Point (double X, double Y)
{
  double? _distance;
  public double DistanceFromOrigin => _distance ??= Math.Sqrt (X*X + Y*Y);

  protected Point (Point other) => (X, Y) = other;
}

Ten en cuenta que, con cualquiera de las dos soluciones, la adición de campos calculados perezosos rompe la comparación de igualdad estructural por defecto (porque dichos campos pueden estar rellenados o no), aunque en breve veremos que es relativamente fácil de arreglar.

Constructores primarios

Cuando defines un registro con una lista de parámetros, el compilador genera automáticamente declaraciones de propiedades, así como un constructor primario (y un deconstructor). Como hemos visto, esto funciona bien en casos sencillos, y en casos más complejos puedes omitir la lista de parámetros y escribir manualmente las declaraciones de propiedades y el constructor.

C# también ofrece una opción intermedia medianamente útil -si estás dispuesto a lidiar con la curiosa semántica de los constructores primarios- que consiste en definir una lista de parámetros mientras escribes tú mismo algunas o todas las declaraciones de propiedades:

record Student (string ID, string LastName, string GivenName)
{
  public string ID { get; } = ID;
}

En este caso, nos "apoderamos" de la definición de la propiedad ID, definiéndola como de sólo lectura (en lugar de sólo init), lo que impide que participe en la mutación no destructiva. Si nunca necesitas mutar de forma no destructiva una propiedad concreta, hacerla de sólo lectura te permite almacenar datos calculados en el registro sin tener que codificar un mecanismo de actualización.

Observa que hemos tenido que incluir un inicializador de propiedades (en negrita):

  public string ID { get; } = ID;

Cuando "asumes" la declaración de una propiedad, te haces responsable de inicializar su valor; el constructor primario ya no lo hace automáticamente. (Esto coincide exactamente con el comportamiento al definir constructores primarios en clases o structs). Observa también que el ID en negrita se refiere al parámetro del constructor primario, no a la propiedad ID.

Nota

Con los structs de registro, es legal redefinir una propiedad como campo:

record struct Student (string ID)
{
  public string ID = ID;
}

De acuerdo con la semántica de los constructores primarios en clases y structs (véase "Constructores primarios"), los parámetros del constructor primario (ID, LastName, y GivenName en este caso) son mágicamente visibles para todos los inicializadores de campos y propiedades. Podemos ilustrarlo ampliando nuestro ejemplo como sigue:

record Student (string ID, string LastName, string FirstName)
{
  public string ID { get; } = ID;
  readonly int _enrollmentYear = int.Parse (ID.Substring (0, 4));
}

De nuevo, el ID en negrita se refiere al parámetro principal del constructor, no a la propiedad. (La razón de que no haya ambigüedad es que es ilegal acceder a propiedades desde inicializadores).

En este ejemplo, calculamos _enrollmentYear a partir de los cuatro primeros dígitos de ID. Aunque es seguro almacenarlo en un campo de sólo lectura (porque la propiedad ID es de sólo lectura y, por tanto, no se puede mutar de forma no destructiva), este código no funcionaría tan bien en el mundo real. La razón es que, sin un constructor explícito, no hay un lugar central en el que validar ID y lanzar una excepción significativa en caso de que no sea válido (un requisito habitual).

La validación también es una buena razón para necesitar escribir accesores explícitos de sólo init (como ya comentamos en "Validación de propiedades"). Por desgracia, los constructores primarios no funcionan bien en este escenario. Para ilustrarlo, considera el siguiente registro, en el que un accesor init realiza una comprobación de validación de nulos:

record Person (string Name)
{
  string _name = Name;
  public string Name
  {
    get  => _name;
    init => _name = value ?? throw new ArgumentNullException ("Name");
  }
}

Como Name no es una propiedad automática, no puede definir un inicializador. Lo mejor que podemos hacer es poner el inicializador en el campo de respaldo (en negrita). Desgraciadamente, al hacerlo se omite la comprobación de nulos:

var p = new Person (null);    // Succeeds! (bypasses the null check)

La dificultad es que no hay forma de asignar un parámetro constructor primario a una propiedad sin escribir el constructor nosotros mismos. Aunque hay soluciones (como factorizar la lógica de validación de init en un método estático independiente que llamamos dos veces), la solución más sencilla es evitar la lista de parámetros y escribir manualmente un constructor ordinario (y un deconstructor, si lo necesitas):

record Person
{
  public Person (string name) => Name = name;  // Assign to *PROPERTY*

  string _name;
  public string Name { get => _name; init => ... }
}

Comparación de Registros e Igualdad

Al igual que ocurre con los structs, los tipos anónimos y las tuplas, los registros proporcionan igualdad estructural de forma inmediata, lo que significa que dos registros son iguales si sus campos (y propiedades automáticas) son iguales:

var p1 = new Point (1, 2);
var p2 = new Point (1, 2);
Console.WriteLine (p1.Equals (p2));   // True

record Point (double X, double Y);

El operador de igualdad también funciona con los registros (igual que con las tuplas):

Console.WriteLine (p1 == p2);         // True

La implementación de la igualdad por defecto para los registros es inevitablemente frágil. En concreto, se rompe si el registro contiene valores perezosos, valores transitorios, matrices o tipos de colección (que requieren un tratamiento especial para la comparación de igualdad). Afortunadamente, es relativamente fácil de arreglar (en caso de que necesites que la igualdad funcione), y hacerlo supone menos trabajo que añadir un comportamiento completo de igualdad a las clases o structs.

A diferencia de lo que ocurre con las clases y los structs, no debes (ni puedes) anular el método object.Equals; en su lugar, debes definir un método público Equals con la siguiente firma:

record Point (double X, double Y)
{
  double _someOtherField;
  public virtual bool Equals (Point other) =>
    other != null && X == other.X && Y == other.Y;
}

El método Equals debe ser virtual (no override), y debe estar fuertemente tipado de modo que acepte el tipo de registro real (Point en este caso, no object). Una vez que tengas la firma correcta, el compilador parcheará automáticamente tu método.

En nuestro ejemplo, hemos cambiado la lógica de igualdad de forma que sólo comparamos X y Y (e ignoramos _someOtherField).

Si subclasificas otro registro, puedes llamar al método base.Equals:

  public virtual bool Equals (Point other) => base.Equals (other) && ...

Como con cualquier tipo, si asumes la comparación de igualdad, también debes anular GetHashCode(). Una buena característica de los registros es que no sobrecargas != o ==; ni implementas IEquatable<T>: todo esto se hace por ti. Trataremos este tema de la comparación de igualdad en "Comparación de igualdad".

Patrones

En el capítulo 3, demostramos cómo utilizar el operador is para comprobar si una conversión de referencia tendrá éxito:

if (obj is string)
  Console.WriteLine (((string)obj).Length);

O, más concisamente:

if (obj is string s)
  Console.WriteLine (s.Length);

Este atajo emplea un tipo de patrón llamado patrón de tipo. El operador is también admite otros patrones que se introdujeron en versiones recientes de C#, como el patrón de propiedades:

if (obj is string { Length:4 })
  Console.WriteLine ("A string with 4 characters");

Los patrones se admiten en los siguientes contextos:

  • Después del operador is (variable is pattern)

  • En las sentencias switch

  • En expresiones de conmutación

Ya hemos tratado el patrón de tipos (y, brevemente, el patrón de tuplas) en "Cambio de tipos" y "El operador is". En esta sección, cubrimos patrones más avanzados que se introdujeron en versiones recientes de C#.

Algunos de los patrones más especializados están pensados sobre todo para su uso en sentencias/expresiones de conmutación. En este caso, reducen la necesidad de las cláusulas when y te permiten utilizar conmutadores donde antes no podías.

Nota

Los patrones de esta sección son de leve a moderadamente útiles en algunos escenarios. Recuerda que siempre puedes sustituir las expresiones de conmutación con muchos patrones por simples sentencias if -o, en algunos casos, por el operador condicional ternario- a menudo sin mucho código adicional.

Patrón constante

El patrón constante te permite coincidir directamente con una constante, y es útil cuando trabajas con el tipo object:

void Foo (object obj) 
{
  if (obj is 3) ...
}

Esta expresión en negrita equivale a lo siguiente:

obj is int && (int)obj == 3

(Al ser un operador estático, C# no te permite utilizar == para comparar un object directamente con una constante, porque el compilador necesita conocer los tipos de antemano).

Por sí solo, este patrón sólo tiene una utilidad marginal, ya que existe una alternativa razonable:

if (3.Equals (obj)) ...

Como veremos enseguida, el patrón constante resulta mucho más útil con los combinadores de patrones.

Patrones relacionales

A partir de C# 9, puedes utilizar los operadores <, >, <=, y >= en patrones:

if (x is > 100) Console.WriteLine ("x is greater than 100");

Esto adquiere una utilidad significativa en un switch:

string GetWeightCategory (decimal bmi) => bmi switch
{
  < 18.5m => "underweight",
  < 25m => "normal",
  < 30m => "overweight",
  _ => "obese"
};

Los patrones relacionales resultan aún más útiles junto con los combinadores de patrones.

Nota

El patrón relacional también funciona cuando la variable tiene un tipo en tiempo de compilación de object, pero tienes que ser extremadamente cuidadoso con el uso de constantes numéricas. En el siguiente ejemplo, la última línea imprime False porque estamos intentando hacer coincidir un valor decimal con un literal entero:

object obj = 2m;                  // obj is decimal
Console.WriteLine (obj is < 3m);  // True
Console.WriteLine (obj is < 3);   // False

Combinadores de patrones

A partir de C# 9, puedes utilizar las palabras clave and, or y not para combinar patrones:

bool IsJanetOrJohn (string name) => name.ToUpper() is "JANET" or "JOHN";

bool IsVowel (char c) => c is 'a' or 'e' or 'i' or 'o' or 'u';

bool Between1And9 (int n) => n is >= 1 and <= 9;

bool IsLetter (char c) => c is >= 'a' and <= 'z'
                            or >= 'A' and <= 'Z';

Al igual que con los operadores && y ||, and tiene mayor precedencia que or. Puedes anular esto con paréntesis.

Un buen truco es combinar el combinador not con el patrón de tipos para comprobar si un objeto es (o no) un tipo:

if (obj is not string) ...

Esto parece más bonito que:

if (!(obj is string)) ...

var Patrón

El patrón var es una variación del patrón tipo por la que sustituyes el nombre del tipo por la palabra clave var. La conversión siempre tiene éxito, por lo que su finalidad es simplemente permitirte reutilizar la variable que sigue:

bool IsJanetOrJohn (string name) => 
  name.ToUpper() is var upper && (upper == "JANET" || upper == "JOHN");

Esto equivale a

bool IsJanetOrJohn (string name)
{
  string upper = name.ToUpper();
  return upper == "JANET" || upper == "JOHN";
}

La posibilidad de introducir y reutilizar una variable intermedia (upper, en este caso) en un método con cuerpo de expresión es conveniente, sobre todo en las expresiones lambda. Por desgracia, sólo suele ser útil cuando el método en cuestión tiene un tipo de retorno bool.

Patrones tupla y posicional

El patrón tupla (introducido en C# 8) coincide con tuplas:

var p = (2, 3);
Console.WriteLine (p is (2, 3));   // True

Puedes utilizarlo para activar varios valores:

int AverageCelsiusTemperature (Season season, bool daytime) =>
  (season, daytime) switch
  {
    (Season.Spring, true) => 20,
    (Season.Spring, false) => 16,
    (Season.Summer, true) => 27,
    (Season.Summer, false) => 22,
    (Season.Fall, true) => 18,
    (Season.Fall, false) => 12,
    (Season.Winter, true) => 10,
    (Season.Winter, false) => -2,
    _ => throw new Exception ("Unexpected combination")
};

enum Season { Spring, Summer, Fall, Winter };

El patrón tupla puede considerarse un caso especial del patrón posicional (C# 8+), que coincide con cualquier tipo que exponga un método Deconstruct (ver "Deconstructores"). En el siguiente ejemplo, aprovechamos el deconstructor generado por el compilador del registro Point:

var p = new Point (2, 2);
Console.WriteLine (p is (2, 2));  // True

record Point (int X, int Y);      // Has compiler-generated deconstructor

Puedes deconstruir a medida que emparejes, utilizando la siguiente sintaxis:

Console.WriteLine (p is (var x, var y) && x == y);   // True

Aquí tienes una expresión de conmutación que combina un patrón de tipo con un patrón posicional:

string Print (object obj) => obj switch 
{
  Point (0, 0)                      => "Empty point",
  Point (var x, var y) when x == y  => "Diagonal"
  ...
};

Patrones de propiedad

Un patrón de propiedades (C# 8+) coincide con uno o varios valores de las propiedades de un objeto. Anteriormente dimos un ejemplo sencillo en el contexto del operador is:

if (obj is string { Length:4 }) ...

Sin embargo, esto no ahorra mucho respecto a lo siguiente:

if (obj is string s && s.Length == 4) ...

Con declaraciones y expresiones de conmutación, los patrones de propiedades son más útiles. Considera la clase System.Uri, que representa un URI. Tiene propiedades que incluyen Scheme, Host, Port y IsLoopback. Al escribir un cortafuegos, podríamos decidir si permitir o bloquear un URI empleando una expresión switch que utilice patrones de propiedades:

bool ShouldAllow (Uri uri) => uri switch
{
  { Scheme: "http",  Port: 80  } => true,
  { Scheme: "https", Port: 443 } => true,
  { Scheme: "ftp",   Port: 21  } => true,
  { IsLoopback: true           } => true,
  _ => false
};

Puedes anidar propiedades, por lo que la cláusula siguiente es legal:

  { Scheme: { Length: 4 }, Port: 80 } => true,

que, desde C# 10, se puede simplificar a

  { Scheme.Length: 4, Port: 80 } => true,

Puedes utilizar otros patrones dentro de los patrones de propiedades, incluido el patrón relacional:

  { Host: { Length: < 1000 }, Port: > 0 } => true,

Las condiciones más elaboradas pueden expresarse con una cláusula when:

  { Scheme: "http" } when string.IsNullOrWhiteSpace (uri.Query) => true,

También puedes combinar el patrón de propiedades con el patrón de tipos:

bool ShouldAllow (object uri) => uri switch
{
  Uri { Scheme: "http",  Port: 80  } => true,
  Uri { Scheme: "https", Port: 443 } => true,
  ...

Como cabría esperar con los patrones de tipos, puedes introducir una variable al final de una cláusula y luego consumir esa variable:

  Uri { Scheme: "http", Port: 80 } httpUri => httpUri.Host.Length < 1000,

También puedes utilizar esa variable en una cláusula when:

  Uri { Scheme: "http", Port: 80 } httpUri 
                                   when httpUri.Host.Length < 1000 => true,

Un giro un tanto extraño de los patrones de propiedades es que también puedes introducir variables a nivel de propiedad:

  { Scheme: "http", Port: 80, Host: string host } => host.Length < 1000,

Se permite la tipificación implícita, por lo que puedes sustituir string por var. Aquí tienes un ejemplo completo:

bool ShouldAllow (Uri uri) => uri switch
{
  { Scheme: "http",  Port: 80, Host: var host } => host.Length < 1000,
  { Scheme: "https", Port: 443                } => true,
  { Scheme: "ftp",   Port: 21                 } => true,
  { IsLoopback: true                          } => true,
  _ => false
};

Es difícil inventar ejemplos en los que esto ahorre más que unos pocos caracteres. En nuestro caso, la alternativa es en realidad más corta:

  { Scheme: "http", Port: 80 } => uri.Host.Length < 1000 => ...

O:

  { Scheme: "http", Port: 80, Host: { Length: < 1000 } } => ...

Lista de patrones

Los patrones de lista (de C# 11) funcionan con cualquier tipo de colección que sea contable (con una propiedad Count o Length ) e indexable (con un indexador de tipo int o System.Index).

Un patrón de lista coincide con una serie de elementos entre corchetes:

int[] numbers = { 0, 1, 2, 3, 4 };
Console.Write (numbers is [0, 1, 2, 3, 4]);   // True

Un guión bajo coincide con un único elemento de cualquier valor:

Console.Write (numbers is [0, 1, _, _, 4]);   // True

El patrón var también funciona al coincidir con un solo elemento:

Console.Write (numbers is [0, 1, var x, 3, 4] && x > 1);   // True

Dos puntos indican un corte. Un corte coincide con cero o más elementos:

Console.Write (numbers is [0, .., 4]);    // True

Con las matrices y otros tipos que admiten índices y rangos (consulta "Índices y rangos"), puedes seguir un corte con un patrón var:

Console.Write (numbers is [0, .. var mid, 4] && mid.Contains (2)); // True

Un patrón de lista puede incluir como máximo una rebanada.

Atributos

Ya estás familiarizado con la noción de atribuir elementos de código de un programa con modificadores, como virtual o ref. Estas construcciones están integradas en el lenguaje. Los atributos son un mecanismo extensible para añadir información personalizada a los elementos de código (conjuntos, tipos, miembros, valores de retorno, parámetros y parámetros de tipo genérico). Esta extensibilidad es útil para servicios que se integran profundamente en el sistema de tipos, sin requerir palabras clave o construcciones especiales en el lenguaje C#.

Clases de atributos

Un atributo lo define una clase que hereda (directa o indirectamente) de la clase abstracta System.Attribute. Para adjuntar un atributo a un elemento de código, especifica el nombre de la clase del atributo entre corchetes, antes del elemento de código. Por ejemplo, lo siguiente adjunta el ObsoleteAttribute a la clase Foo:

[ObsoleteAttribute]
public class Foo {...}

Este atributo concreto es reconocido por el compilador y provocará advertencias del compilador si se hace referencia a un tipo o miembro marcado como obsoleto. Por convención, todos los tipos de atributo terminan en la palabra "Atributo". C# lo reconoce y te permite omitir el sufijo al adjuntar un atributo:

[Obsolete]
public class Foo {...}

ObsoleteAttribute es un tipo declarado en el espacio de nombres System como sigue (simplificado para abreviar):

public sealed class ObsoleteAttribute : Attribute {...}

Las bibliotecas .NET incluyen muchos atributos predefinidos. En el capítulo 18 describimos cómo escribir tus propios atributos.

Parámetros de Atributos Nombrados y Posicionales

Los atributos pueden tener parámetros. En el siguiente ejemplo, aplicamos XmlTypeAttribute a una clase. Este atributo indica al serializador XML (en System.Xml.Serialization) cómo se representa un objeto en XML y acepta varios parámetros de atributo. El siguiente atributo asigna la clase CustomerEntity a un elemento XML llamado Customer, que pertenece al espacio de nombres http://oreilly.com:

[XmlType ("Customer", Namespace="http://oreilly.com")]
public class CustomerEntity { ... }

(Cubrimos la serialización XML y JSON en el suplemento online en http://www.albahari.com/nutshell.)

Los parámetros de los atributos pertenecen a una de estas dos categorías: posicionales o con nombre. En el ejemplo anterior, el primer argumento es un parámetro posicional; el segundo es un parámetro con nombre. Los parámetros posicionales corresponden a los parámetros de los constructores públicos del tipo de atributo. Los parámetros con nombre corresponden a campos públicos o propiedades públicas del tipo de atributo.

Al especificar un atributo, debes incluir parámetros posicionales que correspondan a uno de los constructores del atributo. Los parámetros con nombre son opcionales.

En el capítulo 18 describimos los tipos de parámetros válidos y las reglas para su evaluación.

Aplicar atributos a conjuntos y campos de respaldo

Implícitamente, el objetivo de un atributo es el elemento de código al que precede inmediatamente, que suele ser un tipo o un miembro de tipo. Pero también puedes adjuntar atributos a un conjunto. Para ello, debes especificar explícitamente el objetivo del atributo. He aquí cómo puedes utilizar el atributo AssemblyFileVersion para adjuntar una versión al conjunto:

[assembly: AssemblyFileVersion ("1.2.3.4")]

Con el prefijo field:, puedes aplicar un atributo a los campos de respaldo de una propiedad automática. Esto es útil en casos especiales, como cuando se aplica el atributo (ahora obsoleto) NonSerialized:

[field:NonSerialized]
public int MyProperty { get; set; }

Aplicar atributos a las expresiones lambda

A partir de C# 10, puedes aplicar atributos al método, los parámetros y el valor de retorno de una expresión lambda:

Action<int> a = [Description ("Method")]
                [return: Description ("Return value")]
                ([Description ("Parameter")]int x) => Console.Write (x);
Nota

Esto es útil cuando trabajas con frameworks -como ASP.NET- que dependen de que coloques atributos en los métodos que escribes. Con esta función, puedes evitar tener que crear métodos con nombre para operaciones sencillas.

Estos atributos se aplican al método generado por el compilador al que apunta el delegado. En el Capítulo 18, describiremos cómo reflexionar sobre los atributos en el código. Por ahora, aquí tienes el código adicional que necesitas para resolver esa indirección:

var methodAtt = a.GetMethodInfo().GetCustomAttributes();
var paramAtt = a.GetMethodInfo().GetParameters()[0].GetCustomAttributes();
var returnAtt = a.GetMethodInfo().ReturnParameter.GetCustomAttributes();

Para evitar ambigüedades sintácticas al aplicar atributos a un parámetro de una expresión lambda, siempre se requieren paréntesis. Los atributos no están permitidos en las lambdas de árbol de expresión.

Especificar varios atributos

Puedes especificar varios atributos para un mismo elemento de código. Puedes enumerar cada atributo dentro del mismo par de corchetes (separados por una coma) o en pares de corchetes separados (o una combinación de ambos). Los tres ejemplos siguientes son semánticamente idénticos:

[Serializable, Obsolete, CLSCompliant(false)]
public class Bar {...}

[Serializable] [Obsolete] [CLSCompliant(false)]
public class Bar {...}

[Serializable, Obsolete]
[CLSCompliant(false)]
public class Bar {...}

Atributos de la información de llamada

Puedes etiquetar los parámetros opcionales con uno de los tres atributos de información del autor de la llamada, que indican al compilador que introduzca la información obtenida del código fuente del autor de la llamada en el valor por defecto del parámetro:

  • [CallerMemberName] aplica el nombre del miembro de la persona que llama.

  • [CallerFilePath] aplica la ruta al archivo de código fuente de la persona que llama.

  • [CallerLineNumber] aplica el número de línea en el archivo de código fuente de la persona que llama.

El método Foo del siguiente programa demuestra las tres cosas:

using System;
using System.Runtime.CompilerServices;

class Program
{
  static void Main() => Foo();

  static void Foo (
    [CallerMemberName] string memberName = null,
    [CallerFilePath] string filePath = null,
    [CallerLineNumber] int lineNumber = 0)
  {
    Console.WriteLine (memberName);
    Console.WriteLine (filePath);
    Console.WriteLine (lineNumber);
  }
}

Suponiendo que nuestro programa reside en c:\source\test\Program.cs, la salida sería:

Main
c:\source\test\Program.cs
6

Al igual que con los parámetros opcionales estándar, la sustitución se realiza en el sitio de llamada. Por lo tanto, nuestro método Main es azúcar sintáctico para esto:

static void Main() => Foo ("Main", @"c:\source\test\Program.cs", 6);

Los atributos de información de llamada son útiles para el registro y para implementar patrones como disparar un único evento de notificación de cambio cada vez que cambia cualquier propiedad de un objeto. De hecho, existe una interfaz estándar para ello en el espacio de nombres System.ComponentModel, denominada INotifyPropertyChanged:

public interface INotifyPropertyChanged
{
  event PropertyChangedEventHandler PropertyChanged;
}

public delegate void PropertyChangedEventHandler
  (object sender, PropertyChangedEventArgs e);

public class PropertyChangedEventArgs : EventArgs
{
  public PropertyChangedEventArgs (string propertyName);
  public virtual string PropertyName { get; }
}

Observa que PropertyChangedEventArgs requiere el nombre de la propiedad que ha cambiado. Sin embargo, aplicando el atributo [CallerMemberName], podemos implementar esta interfaz e invocar el evento sin especificar nunca los nombres de las propiedades:

public class Foo : INotifyPropertyChanged
{
  public event PropertyChangedEventHandler PropertyChanged = delegate { };

  void RaisePropertyChanged ([CallerMemberName] string propertyName = null)
    => PropertyChanged (this, new PropertyChangedEventArgs (propertyName));

  string customerName;
  public string CustomerName
  {  
    get => customerName;
    set  
    {  
      if (value == customerName) return;
      customerName = value;
      RaisePropertyChanged();
      // The compiler converts the above line to:
      // RaisePropertyChanged ("CustomerName");
    } 
  }
}

LlamadorExpresiónArgumento

Un parámetro de método al que apliques el atributo [CallerArgumentExpression] (de C# 10) captura una expresión de argumento del sitio de llamada:

Print (Math.PI * 2);

void Print (double number,
           [CallerArgumentExpression("number")] string expr = null)
  => Console.WriteLine (expr);

// Output: Math.PI * 2

El compilador introduce el código fuente de la expresión de llamada literalmente, incluidos los comentarios:

Print (Math.PI /*(π)*/ * 2);

// Output:  Math.PI /*(π)*/ * 2

La principal aplicación de esta función es al escribir bibliotecas de validación y aserción. En el siguiente ejemplo, se lanza una excepción cuyo mensaje incluye el texto "2 + 2 == 5". Esto ayuda en la depuración:

Assert (2 + 2 == 5);

void Assert (bool condition,
            [CallerArgumentExpression ("condition")] string message = null)
{
  if (!condition) throw new Exception ("Assertion failed: " + message);
}

Otro ejemplo es el método estático ThrowIfNull de la clase ArgumentNullException. Este método se introdujo en .NET 6 y se define como sigue:

public static void ThrowIfNull (object argument,
  [CallerArgumentExpression("argument")] string paramName = null)
{
  if (argument == null)
    throw new ArgumentNullException (paramName);
}

Se utiliza del siguiente modo:

void Print (string message)
{
  ArgumentNullException.ThrowIfNull (message); 
  ...
}

Puedes utilizar [CallerArgumentExpression] varias veces, para capturar expresiones con varios argumentos.

Encuadernación dinámica

La vinculación dinámica aplaza la vinculación -elproceso de resolver tipos, miembros y operadores- del tiempo de compilación al tiempo de ejecución. La vinculación dinámica es útil cuando en tiempo de compilación sabes que existe una determinada función, miembro u operación, pero el compilador no. Esto suele ocurrir cuando interoperas con lenguajes dinámicos (como IronPython) y COM, así como en situaciones en las que podrías utilizar la reflexión.

Un tipo dinámico se declara con la palabra clave contextual dynamic:

dynamic d = GetSomeObject();
d.Quack();

Un tipo dinámico indica al compilador que se relaje. Esperamos que el tipo en tiempo de ejecución de d tenga un método Quack. Pero no podemos probarlo estáticamente. Como d es dinámico, el compilador aplaza la vinculación de Quack a d hasta el tiempo de ejecución. Para entender lo que esto significa hay que distinguir entre vinculación estática y vinculación dinámica.

Vinculación estática frente a vinculación dinámica

El ejemplo canónico de vinculación es asignar un nombre a una función concreta al compilar una expresión. Para compilar la siguiente expresión, el compilador necesita encontrar la implementación del método llamado Quack:

d.Quack();

Supongamos que el tipo estático de d es Duck:

Duck d = ...
d.Quack();

En el caso más sencillo, el compilador realiza la vinculación buscando un método sin parámetros llamado Quack en Duck. En su defecto, el compilador amplía su búsqueda a métodos que tomen parámetros opcionales, métodos en clases base de Duck, y métodos de extensión que tomen Duck como primer parámetro. Si no se encuentra ninguna coincidencia, obtendrás un error de compilación. Independientemente del método que se vincule, lo esencial es que la vinculación la realiza el compilador, y la vinculación depende totalmente de que se conozcan estáticamente los tipos de los operandos (en este caso, d). Esto lo convierte en una vinculación estática.

Ahora cambiemos el tipo estático de d a object:

object d = ...
d.Quack();

Llamar a Quack nos da un error de compilación, porque aunque el valor almacenado en d puede contener un método llamado Quack, el compilador no puede saberlo, porque la única información que tiene es el tipo de la variable, que en este caso es object. Pero cambiemos ahora el tipo estático de d por dynamic:

dynamic d = ...
d.Quack();

Un tipo dynamic es como object-es igualmente no descriptivo sobre un tipo. La diferencia es que te permite utilizarlo de formas que no se conocen en tiempo de compilación. Un objeto dinámico se vincula en tiempo de ejecución en función de su tipo en tiempo de ejecución, no de su tipo en tiempo de compilación. Cuando el compilador ve una expresión vinculada dinámicamente (que, en general, es una expresión que contiene cualquier valor del tipo dynamic), se limita a empaquetar la expresión de modo que la vinculación pueda realizarse posteriormente en tiempo de ejecución.

En tiempo de ejecución, si un objeto dinámico implementa IDynamicMetaObjectProvider, se utiliza esa interfaz para realizar la vinculación. Si no, la vinculación se produce casi del mismo modo que si el compilador hubiera conocido el tipo en tiempo de ejecución del objeto dinámico. Estas dos alternativas se denominan enlace personalizado y enlace de lenguaje.

Encuadernación a medida

La vinculación personalizada se produce cuando un objeto dinámico implementa IDynamicMetaObjectProvider (IDMOP). Aunque puedes implementar IDMOP en tipos que escribas en C#, y es útil hacerlo, el caso más común es que hayas adquirido un objeto IDMOP de un lenguaje dinámico que esté implementado en .NET en el Tiempo de Ejecución de Lenguajes Dinámicos (DLR), como IronPython o IronRuby. Los objetos de esos lenguajes implementan implícitamente IDMOP como medio para controlar directamente el significado de las operaciones que se realizan sobre ellos.

Hablaremos con más detalle de las carpetas personalizadas en el Capítulo 19, pero de momento vamos a escribir una sencilla para demostrar la función:

using System;
using System.Dynamic;

dynamic d = new Duck();
d.Quack();                  // Quack method was called
d.Waddle();                 // Waddle method was called

public class Duck : DynamicObject
{
  public override bool TryInvokeMember (
    InvokeMemberBinder binder, object[] args, out object result)
  {
    Console.WriteLine (binder.Name + " method was called");
    result = null;
    return true;
  }
}

La clase Duck no tiene en realidad un método Quack. En su lugar, utiliza un enlace personalizado para interceptar e interpretar todas las llamadas a métodos.

Encuadernación en idiomas

La vinculación lingüística se produce cuando un objeto dinámico no implementa IDynamic​Me⁠taObjectProvider. Es útil cuando se trabaja con tipos imperfectamente diseñados o con limitaciones inherentes al sistema de tipos .NET (exploraremos más escenarios en el Capítulo 19). Un problema típico al utilizar tipos numéricos es que no tienen una interfaz común. Hemos visto que podemos enlazar métodos dinámicamente; lo mismo ocurre con los operadores:

int x = 3, y = 4;
Console.WriteLine (Mean (x, y));

dynamic Mean (dynamic x, dynamic y) => (x + y) / 2;

La ventaja es obvia: no necesitas duplicar el código para cada tipo numérico. Sin embargo, pierdes la seguridad de tipo estático, arriesgándote a excepciones en tiempo de ejecución en lugar de errores en tiempo de compilación.

Nota

La vinculación dinámica elude la seguridad de tipos estática, pero no la seguridad de tipos en tiempo de ejecución. A diferencia de lo que ocurre con la reflexión(capítulo 18), con la vinculación dinámica no puedes eludir las normas de accesibilidad de los miembros.

Por diseño, la vinculación en tiempo de ejecución del lenguaje se comporta de la forma más parecida posible a la vinculación estática, si los tipos en tiempo de ejecución de los objetos dinámicos se hubieran conocido en tiempo de compilación. En nuestro ejemplo anterior, el comportamiento de nuestro programa sería idéntico si hubiéramos codificado Mean para que funcionara con el tipo int. La excepción más notable en la paridad entre la vinculación estática y la dinámica son los métodos de extensión, que tratamos en "Funciones invocables".

Nota

La vinculación dinámica también afecta al rendimiento. Sin embargo, gracias a los mecanismos de almacenamiento en caché del DLR, se optimizan las llamadas repetidas a la misma expresión dinámica, lo que te permite llamar eficazmente a expresiones dinámicas en un bucle. Esta optimización reduce a menos de 100 ns la sobrecarga típica de una expresión dinámica sencilla en el hardware actual.

RuntimeBinderException

Si un miembro no se vincula, se lanza un mensaje RuntimeBinderException. Puedes considerarlo como un error de compilación en tiempo de ejecución:

dynamic d = 5;
d.Hello();                  // throws RuntimeBinderException

Se lanza la excepción porque el tipo int no tiene el método Hello.

Representación en tiempo de ejecución de la dinámica

Existe una equivalencia profunda entre los tipos dynamic y object. El tiempo de ejecución trata la siguiente expresión como true:

typeof (dynamic) == typeof (object)

Este principio se extiende a los tipos construidos y a los tipos de matriz:

typeof (List<dynamic>) == typeof (List<object>)
typeof (dynamic[]) == typeof (object[])

Al igual que una referencia a un objeto, una referencia dinámica puede apuntar a un objeto de cualquier tipo (excepto de tipo puntero):

dynamic x = "hello";
Console.WriteLine (x.GetType().Name);  // String

x = 123;  // No error (despite same variable)
Console.WriteLine (x.GetType().Name);  // Int32

Estructuralmente, no hay diferencia entre una referencia a un objeto y una referencia dinámica. Una referencia dinámica simplemente permite realizar operaciones dinámicas sobre el objeto al que apunta. Puedes convertir de object a dynamic para realizar cualquier operación dinámica que desees en un object:

object o = new System.Text.StringBuilder();
dynamic d = o;
d.Append ("hello");
Console.WriteLine (o);   // hello
Nota

Si reflexionas sobre un tipo que expone miembros (públicos) dynamic, verás que esos miembros se representan como objects anotados. Por ejemplo,

public class Test
{
  public dynamic Foo;
}

es equivalente a

public class Test
{
  [System.Runtime.CompilerServices.DynamicAttribute]
  public object Foo;
}

Esto permite a los consumidores de ese tipo saber que Foo debe tratarse como dinámico, al tiempo que permite a los lenguajes que no admiten la vinculación dinámica recurrir a object.

Conversiones dinámicas

El tipo dynamic tiene conversiones implícitas hacia y desde todos los demás tipos:

int i = 7;
dynamic d = i;
long j = d;        // No cast required (implicit conversion)

Para que la conversión tenga éxito, el tipo en tiempo de ejecución del objeto dinámico debe ser implícitamente convertible al tipo estático de destino. El ejemplo anterior funcionó porque un int es implícitamente convertible a un long.

El siguiente ejemplo lanza un RuntimeBinderException porque un int no es convertible implícitamente en un short:

int i = 7;
dynamic d = i;
short j = d;      // throws RuntimeBinderException

var Versus dinámico

Los tipos var y dynamic tienen un parecido superficial, pero la diferencia es profunda:

  • var dice: "Deja que el compilador averigüe el tipo".

  • dynamic dice: "Deja que el tiempo de ejecución averigüe el tipo".

Para ilustrarlo:

dynamic x = "hello";  // Static type is dynamic, runtime type is string
var y = "hello";      // Static type is string, runtime type is string
int i = x;            // Runtime error      (cannot convert string to int)
int j = y;            // Compile-time error (cannot convert string to int)

El tipo estático de una variable declarada con var puede ser dynamic:

dynamic x = "hello";
var y = x;            // Static type of y is dynamic
int z = y;            // Runtime error (cannot convert string to int)

Expresiones dinámicas

Se puede llamar dinámicamente a campos, propiedades, métodos, eventos, constructores, indexadores, operadores y conversiones.

Intentar consumir el resultado de una expresión dinámica con un tipo de retorno void está prohibido, igual que con una expresión tipada estáticamente. La diferencia es que el error se produce en tiempo de ejecución:

dynamic list = new List<int>();
var result = list.Add (5);         // RuntimeBinderException thrown

Las expresiones que incluyen operandos dinámicos suelen ser dinámicas en sí mismas, porque el efecto de la ausencia de información de tipo es en cascada:

dynamic x = 2;
var y = x * 3;       // Static type of y is dynamic

Hay un par de excepciones obvias a esta regla. En primer lugar, si pasas una expresión dinámica a un tipo estático, obtendrás una expresión estática:

dynamic x = 2;
var y = (int)x;      // Static type of y is int

En segundo lugar, las invocaciones a constructores siempre producen expresiones estáticas, incluso cuando se invocan con argumentos dinámicos. En este ejemplo, x está tipado estáticamente como StringBuilder:

dynamic capacity = 10;
var x = new System.Text.StringBuilder (capacity);

Además, hay algunos casos de perímetro en los que una expresión que contiene un argumento dinámico es estática, como pasar un índice a una matriz y las expresiones de creación de delegados.

Llamadas dinámicas sin receptores dinámicos

El caso de uso canónico de dynamic implica un receptor dinámico. Esto significa que un objeto dinámico es el receptor de una llamada a una función dinámica:

dynamic x = ...;
x.Foo();          // x is the receiver

Sin embargo, también puedes llamar a funciones conocidas estáticamente con argumentos dinámicos. Tales llamadas están sujetas a la resolución dinámica de sobrecargas, y pueden incluir lo siguiente:

  • Métodos estáticos

  • Constructores de instancia

  • Métodos de instancia en receptores con un tipo conocido estáticamente

En el siguiente ejemplo, el Foo concreto que se vincula dinámicamente depende del tipo en tiempo de ejecución del argumento dinámico:

class Program
{
  static void Foo (int x)    => Console.WriteLine ("int");
  static void Foo (string x) => Console.WriteLine ("string");

  static void Main()
  {
    dynamic x = 5;
    dynamic y = "watermelon";

    Foo (x);    // int
    Foo (y);    // string
  }
}

Como no interviene un receptor dinámico, el compilador puede realizar estáticamente una comprobación básica para ver si la llamada dinámica tendrá éxito. Comprueba si existe una función con el nombre y el número de parámetros correctos. Si no encuentra ninguna candidata, obtendrá un error en tiempo de compilación:

class Program
{
  static void Foo (int x)    => Console.WriteLine ("int");
  static void Foo (string x) => Console.WriteLine ("string");

  static void Main()
  {
    dynamic x = 5;
    Foo (x, x);        // Compiler error - wrong number of parameters
    Fook (x);          // Compiler error - no such method name
  }
}

Tipos estáticos en expresiones dinámicas

Es obvio que los tipos dinámicos se utilizan en la vinculación dinámica. No es tan obvio que los tipos estáticos también se utilicen -siempre que sea posible- en la vinculación dinámica. Piensa en lo siguiente:

class Program
{
  static void Foo (object x, object y) { Console.WriteLine ("oo"); }
  static void Foo (object x, string y) { Console.WriteLine ("os"); }
  static void Foo (string x, object y) { Console.WriteLine ("so"); }
  static void Foo (string x, string y) { Console.WriteLine ("ss"); }

  static void Main()
  {
    object o = "hello";
    dynamic d = "goodbye";
    Foo (o, d);               // os
  }
}

La llamada a Foo(o,d) está vinculada dinámicamente porque uno de sus argumentos, d, es dynamic. Pero como o se conoce estáticamente, la vinculación -aunque se produzca dinámicamente- hará uso de ella. En este caso, la resolución de sobrecargas elegirá la segunda implementación de Foo debido al tipo estático de o y al tipo en tiempo de ejecución de d. En otras palabras, el compilador es "lo más estático posible".

Funciones no invocables

Algunas funciones no pueden llamarse dinámicamente. No puedes llamar a las siguientes:

  • Métodos de extensión (mediante la sintaxis de los métodos de extensión)

  • Miembros de una interfaz, si para ello necesitas hacer un casting a esa interfaz

  • Miembros base ocultos por una subclase

Entender por qué es así es útil para comprender la vinculación dinámica.

La vinculación dinámica requiere dos datos: el nombre de la función a llamar y el objeto sobre el que llamar a la función. Sin embargo, en cada uno de los tres escenarios invocables, interviene un tipo adicional, que sólo se conoce en tiempo de compilación. En el momento de escribir esto, no hay forma de especificar estos tipos adicionales dinámicamente.

Al llamar a métodos de extensión, ese tipo adicional está implícito. Es la clase estática sobre la que se define el método de extensión. El compilador lo busca a partir de las directivas using de tu código fuente. Esto hace que los métodos de extensión sean conceptos sólo en tiempo de compilación, porque las directivas using desaparecen al compilar (después de que hayan hecho su trabajo en el proceso de enlace al asignar nombres simples a nombres calificados por el espacio de nombres).

Cuando llames a miembros a través de una interfaz, especifica ese tipo adicional mediante un casting implícito o explícito. Hay dos situaciones en las que puedes querer hacer esto: cuando llames a miembros de la interfaz implementados explícitamente y cuando llames a miembros de la interfaz implementados en un tipo interno de otro conjunto. Podemos ilustrar el primer caso con los dos tipos siguientes:

interface IFoo   { void Test();        }
class Foo : IFoo { void IFoo.Test() {} }

Para llamar al método Test, debemos hacer un casting a la interfaz IFoo. Esto es fácil con la tipificación estática:

IFoo f = new Foo();   // Implicit cast to interface
f.Test();

Considera ahora la situación con la tipificación dinámica:

IFoo f = new Foo();
dynamic d = f;
d.Test();             // Exception thrown

El molde implícito que se muestra en negrita indica al compilador que vincule las siguientes llamadas a miembros de f a IFoo en lugar de a Foo; en otras palabras, que vea ese objeto a través de la lente de la interfaz IFoo. Sin embargo, esa lente se pierde en tiempo de ejecución, por lo que el DLR no puede completar la vinculación. La pérdida se ilustra como sigue:

Console.WriteLine (f.GetType().Name);    // Foo

Una situación similar se produce al llamar a un miembro base oculto: debes especificar un tipo adicional mediante un molde o la palabra clave base, y ese tipo adicional se pierde en tiempo de ejecución.

Nota

Si necesitas invocar miembros de la interfaz de forma dinámica, una solución es utilizar la biblioteca de código abierto Uncapsulator, disponible en NuGet y GitHub. Uncapsulator fue escrita por el autor para abordar este problema, y aprovecha la vinculación personalizada para proporcionar una dinámica mejor que dynamic:

IFoo f = new Foo();
dynamic uf = f.Uncapsulate();
uf.Test();

Uncapsulador también te permite hacer cast a tipos base e interfaces por su nombre, llamar dinámicamente a miembros estáticos y acceder a miembros no públicos de un tipo.

Sobrecarga del operador

Puedes sobrecargar los operadores para proporcionar una sintaxis más natural a los tipos personalizados. La sobrecarga de operadores es más adecuada para implementar structs personalizados que representen tipos de datos bastante primitivos. Por ejemplo, un tipo numérico personalizado es un candidato excelente para la sobrecarga de operadores.

Los siguientes operadores simbólicos pueden sobrecargarse:

+ (unario) - (unario) ! ˜ ++
-- + - * /
% & | ^ <<
>> == != > <
>= <=

Los siguientes operadores también son sobrecargables:

  • Conversiones implícitas y explícitas (con las palabras clave implicit y explicit )

  • Los operadores true y false (no literales)

Los siguientes operadores están sobrecargados indirectamente:

  • Los operadores de asignación compuestos (por ejemplo, +=, /=) se anulan implícitamente anulando los operadores no compuestos (por ejemplo, +, /).

  • Los operadores condicionales && y || se anulan implícitamente mediante la anulación de los operadores bit a bit & y |.

Funciones del operador

Puedes sobrecargar un operador declarando una función de operador. Una función operadora tiene las siguientes reglas:

  • El nombre de la función se especifica con la palabra clave operator seguida de un símbolo de operador.

  • La función del operador debe estar marcada static y public.

  • Los parámetros de la función operador representan los operandos.

  • El tipo de retorno de una función operador representa el resultado de una expresión.

  • Al menos uno de los operandos debe ser del tipo en el que se declara la función operador.

En el siguiente ejemplo, definimos una estructura llamada Note que representa una nota musical y, a continuación, sobrecargamos el operador +:

public struct Note
{
  int value;
  public Note (int semitonesFromA) { value = semitonesFromA; }
  public static Note operator + (Note x, int semitones)
  {
    return new Note (x.value + semitones);
  }
}

Esta sobrecarga nos permite añadir un int a un Note:

Note B = new Note (2);
Note CSharp = B + 2;

Al sobrecargar un operador, se sobrecarga automáticamente el operador de asignación compuesta correspondiente. En nuestro ejemplo, como hemos sobrecargado +, también podemos utilizar +=:

CSharp += 2;

Al igual que con los métodos y las propiedades, C# permite que las funciones de operador que comprenden una única expresión se escriban de forma más escueta con una sintaxis con cuerpo de expresión:

public static Note operator + (Note x, int semitones)
                               => new Note (x.value + semitones);

Operarios controlados

A partir de C# 11, cuando declaras una función de operador, también puedes declarar una versión checked:

public static Note operator + (Note x, int semitones)
  => new Note (x.value + semitones);

public static Note operator checked + (Note x, int semitones)
  => checked (new Note (x.value + semitones));

La versión comprobada se llamará dentro de expresiones o bloques comprobados:

Note B = new Note (2);
Note other = checked (B + int.MaxValue);  // throws OverflowException

Sobrecarga de operadores de igualdad y comparación

Los operadores de igualdad y comparación a veces se sobrecargan al escribir structs, y en raras ocasiones al escribir clases. La sobrecarga de los operadores de igualdad y comparación conlleva reglas y obligaciones especiales, que explicamos en el Capítulo 6. Un resumen de estas reglas es el siguiente:

Emparejamiento
El compilador de C# obliga a definir los operadores que son pares lógicos. Estos operadores son (== != ), (< > ), y (<= >= ).
Equals y GetHashCode
En la mayoría de los casos, si sobrecargas (==) y (!=), debes anular los métodos Equals y GetHashCode definidos en object para obtener un comportamiento significativo. El compilador de C# te avisará si no lo haces. (Para más detalles, consulta "Comparación de igualdades" ).
IComparable y IComparable<T>
Si sobrecargas (< >) y (<= >=), debes implementar IComparable y IComparable<T>.

Conversiones implícitas y explícitas personalizadas

Las conversiones implícitas y explícitas son operadores sobrecargables. Estas conversiones suelen sobrecargarse para que la conversión entre tipos fuertemente relacionados (como los tipos numéricos) sea concisa y natural.

Para convertir entre tipos débilmente relacionados, son más adecuadas las siguientes estrategias:

  • Escribe un constructor que tenga un parámetro del tipo a convertir.

  • Escribe ToXXX y (estático) FromXXX para convertir entre tipos.

Como se ha explicado en la discusión sobre los tipos, la razón de ser de las conversiones implícitas es que tienen garantizado el éxito y no pierden información durante la conversión. Por el contrario, se debe exigir una conversión explícita cuando las circunstancias del tiempo de ejecución determinen si la conversión tendrá éxito o si se puede perder información durante la conversión.

En este ejemplo, definimos conversiones entre nuestro tipo musical Note y un doble (que representa la frecuencia en hercios de esa nota):

...
// Convert to hertz
public static implicit operator double (Note x)
  => 440 * Math.Pow (2, (double) x.value / 12 );

// Convert from hertz (accurate to the nearest semitone)
public static explicit operator Note (double x)
  => new Note ((int) (0.5 + 12 * (Math.Log (x/440) / Math.Log(2) ) ));
...

Note n = (Note)554.37;  // explicit conversion
double x = n;           // implicit conversion
Nota

Siguiendo nuestras propias directrices, este ejemplo podría implementarse mejor con un método ToFrequency (y un método estático FromFrequency ) en lugar de operadores implícitos y explícitos.

Advertencia

Los operadores as y is ignoran las conversiones personalizadas:

Console.WriteLine (554.37 is Note);   // False
Note n = 554.37 as Note;              // Error

Sobrecarga de verdadero y falso

Los operadores true y false se sobrecargan en el caso extremadamente raro de tipos que son booleanos "en espíritu" pero no tienen una conversión a bool. Un ejemplo es un tipo que implemente la lógica de tres estados: sobrecargando true y false, un tipo así puede trabajar sin problemas con sentencias y operadores condicionales: if, do, while, for, &&, || y ?:. La estructura System.Data.SqlTypes.SqlBoolean proporciona esta funcionalidad:

SqlBoolean a = SqlBoolean.Null;
if (a)
  Console.WriteLine ("True");
else if (!a)
  Console.WriteLine ("False");
else
  Console.WriteLine ("Null");

OUTPUT:
Null

El código siguiente es una reimplementación de las partes de SqlBoolean necesarias para demostrar los operadores true y false:

public struct SqlBoolean
{
  public static bool operator true (SqlBoolean x)
    => x.m_value == True.m_value;

  public static bool operator false (SqlBoolean x)
    => x.m_value == False.m_value;  

  public static SqlBoolean operator ! (SqlBoolean x)
  {
    if (x.m_value == Null.m_value)  return Null;
    if (x.m_value == False.m_value) return True;
    return False;
  }

  public static readonly SqlBoolean Null =  new SqlBoolean(0);
  public static readonly SqlBoolean False = new SqlBoolean(1);
  public static readonly SqlBoolean True =  new SqlBoolean(2);

  private SqlBoolean (byte value) { m_value = value; }
  private byte m_value;
}

Polimorfismo estático

En "Llamar a miembros estáticos virtuales/abstractos de la interfaz", introdujimos una función avanzada por la que una interfaz puede definir miembros static virtual o static abstract, que luego son implementados como miembros estáticos por clases y structs. Más adelante, en "Restricciones genéricas", demostramos que aplicar una restricción de interfaz a un parámetro de tipo da acceso a un método a los miembros de esa interfaz. En esta sección, demostraremos cómo esto posibilita el polimorfismo estático, permitiendo características como las matemáticas genéricas.

Para ilustrarlo, considera la siguiente interfaz, que define un método estático para crear una instancia aleatoria de algún tipo T:

interface ICreateRandom<T>
{
  static abstract T CreateRandom();  // Create a random instance of T
}

Supongamos que queremos implementar esta interfaz en el siguiente registro:

record Point (int X, int Y);

Con la ayuda de la clase System.Random (cuyo método Next genera un número entero aleatorio), podemos implementar el método estático CreateRandom del siguiente modo:

record Point (int X, int Y) : ICreateRandom<Point>
{
  static Random rnd = new();
  public static Point CreateRandom() => new Point (rnd.Next(), rnd.Next());
}

Para llamar a este método a través de la interfaz, utilizamos un parámetro de tipo restringido. El siguiente método crea una matriz de datos de prueba utilizando este enfoque:

T[] CreateTestData<T> (int count) where T : ICreateRandom<T>
{
  T[] result = new T[count];
  for (int i = 0; i < count; i++)
    result [i] = T.CreateRandom();
  return result;
}

Esta línea de código demuestra su uso:

Point[] testData = CreateTestData<Point>(50);  // Create 50 random Points.

Nuestra llamada al método estático CreateRandom en CreateTestData es polimórfica porque funciona no sólo con Point, sino con cualquier tipo que implemente ICreateRandom<T>. Esto es diferente del polimorfismo de instancia, porque no necesitamos una instancia de ICreateRandom<T> sobre la que llamar a CreateRandom; llamamos a CreateRandom sobre el propio tipo.

Operadores polimórficos

Dado que los operadores son esencialmente funciones estáticas (véase "Sobrecarga de operadores"), los operadores también pueden declararse como miembros estáticos de interfaces virtuales:

interface IAddable<T> where T : IAddable<T>
{
   abstract static T operator + (T left, T right);
}
Nota

La restricción de tipo autorreferente en esta definición de interfaz es necesaria para satisfacer las reglas del compilador para la sobrecarga de operadores. Recuerda que al definir una función de operador, al menos uno de los operandos debe ser del tipo en el que se declara la función de operador. En este ejemplo, nuestros operandos son del tipo T, mientras que el tipo que los contiene es IAddable<T>, por lo que necesitamos una restricción de tipo de autorreferencia para permitir que T se trate como IAddable<T>.

Así es como podemos implementar la interfaz:

record Point (int X, int Y) : IAddable<Point>
{
  public static Point operator + (Point left, Point right) =>
    new Point (left.X + right.X, left.Y + right.Y);
}

Con un parámetro de tipo restringido, podemos escribir un método que llame a nuestro operador de suma polimórficamente (omitiendo el tratamiento de los casos de perímetro por brevedad):

T Sum<T> (params T[] values) where T : IAddable<T>
{
  T total = values[0];
  for (int i = 1; i < values.Length; i++)
    total += values[i];
  return total;
}

Nuestra llamada al operador + (a través del operador += ) es polimórfica porque se vincula a IAddable<T>, no a Point. Por lo tanto, nuestro método Sum funciona con todos los tipos que implementan IAddable<T>.

Por supuesto, una interfaz como IAddable<T> sería mucho más útil si estuviera definida en el tiempo de ejecución de .NET, y si todos los tipos numéricos de .NET la implementaran. Afortunadamente, éste es el caso a partir de .NET 7: el espacio de nombres System.Numerics incluye (una versión más sofisticada de) IAddable, junto con muchas otras interfaces aritméticas, la mayoría de las cuales están englobadas en INumber<TSelf>.

Matemáticas genéricas

Antes de .NET 7, el código que realizaba operaciones aritméticas tenía que ser de un tipo numérico determinado:

int Sum (params int[] numbers)   // Works only with int.
{                                // Cannot use with double, decimal, etc.
    int total = 0;
    foreach (int n in numbers)
        total += n;
    return total;
}

.NET 7 introdujo la interfaz INumber<TSelf> para unificar las operaciones aritméticas entre tipos numéricos. Esto significa que ahora puedes escribir una versión genérica del método anterior:

T Sum<T> (params T[] numbers) where T : INumber<T>
{
  T total = T.Zero;
  foreach (T n in numbers)
    total += n;      // Invokes addition operator for any numeric type
  return total;
}

int intSum = Sum (3, 5, 7);
double doubleSum = Sum (3.2, 5.3, 7.1);
decimal decimalSum = Sum (3.2m, 5.3m, 7.1m);

INumber<TSelf> lo implementan todos los tipos numéricos reales e integrales de .NET (así como char) y puede considerarse como una interfaz paraguas, que comprende otras interfaces más granulares para cada tipo de operación aritmética (suma, resta, multiplicación, división, cálculo del módulo, comparación, etc.), así como interfaces para el análisis sintáctico y el formateo. Aquí tienes una de estas interfaces:

public interface IAdditionOperators<TSelf, TOther, TResult>
  where TSelf : IAdditionOperators<TSelf, TOther, TResult>?
{
  static abstract TResult operator + (TSelf left, TOther right);

  public static virtual TResult operator checked + 
    (TSelf left, TOther right) => left + right;  // Call operator above
}

El operador static abstract + es lo que permite que el operador += funcione dentro de nuestro método Sum. Fíjate también en el uso de static virtual en el operador comprobado: esto proporciona un comportamiento alternativo por defecto para los implementadores que no proporcionan una versión comprobada del operador de suma.

El espacio de nombres System.Numerics también contiene interfaces que no forman parte de INumber para operaciones específicas de ciertos tipos de números (como los de coma flotante). Para calcular una raíz cuadrática media, por ejemplo, podemos añadir la interfaz IRootFunctions<T> a la lista de restricciones para exponer su método estático RootN a T :

T RMS<T> (params T[] values) where T : INumber<T>, IRootFunctions<T>
{
  T total = T.Zero;
  for (int i = 0; i < values.Length; i++)
    total += values [i] * values [i];
  // Use T.CreateChecked to convert values.Length (type int) to T.
  T count = T.CreateChecked (values.Length);
  return T.RootN (total / count, 2);   // Calculate square root
}

Código inseguro y punteros

C# admite la manipulación directa de la memoria mediante punteros dentro de bloques de código marcados como unsafe. Los tipos de punteros son útiles para interoperar con API nativas, para acceder a la memoria fuera del montón gestionado y para implementar microoptimizaciones en puntos críticos de rendimiento.

Los proyectos que incluyan código no seguro deben especificar <AllowUnsafeBlocks>true</AllowUnsafeBlocks> en el archivo del proyecto.

Puntos básicos

Para cada tipo de valor o tipo de referencia V, existe un tipo de puntero V* correspondiente. Una instancia de puntero contiene la dirección de una variable. Los tipos de puntero se pueden convertir (de forma insegura) en cualquier otro tipo de puntero. A continuación se describen los principales operadores de punteros:

Operario Significado
& El operador dirección-de devuelve un puntero a la dirección de una variable.
* El operador de desreferencia devuelve la variable a la dirección de un puntero.
-> El operador puntero a miembro es un atajo sintáctico, en el que x->y equivale a (*x).y.

De acuerdo con C, sumar (o restar) un desplazamiento entero a un puntero genera otro puntero. Restar un puntero a otro genera un entero de 64 bits (tanto en plataformas de 64 como de 32 bits).

Código inseguro

Al marcar un tipo, un miembro de tipo o un bloque de sentencia con la palabra clave unsafe, se te permite utilizar tipos de puntero y realizar operaciones de puntero al estilo C en la memoria dentro de ese ámbito. He aquí un ejemplo de uso de punteros para procesar rápidamente un mapa de bits:

unsafe void BlueFilter (int[,] bitmap)
{
  int length = bitmap.Length;
  fixed (int* b = bitmap)
  {
    int* p = b;
    for (int i = 0; i < length; i++)
      *p++ &= 0xFF;
  }
}

El código inseguro puede ejecutarse más rápido que su correspondiente implementación segura. En este caso, el código habría requerido un bucle anidado con indexación de matrices y comprobación de límites. Un método C# no seguro también puede ser más rápido que llamar a una función C externa, ya que no hay sobrecarga asociada a la salida del entorno de ejecución gestionado.

La Declaración fija

La sentencia fixed es necesaria para fijar un objeto gestionado, como el mapa de bits del ejemplo anterior. Durante la ejecución de un programa, se asignan y desasignan muchos objetos del montón. Para evitar el desperdicio innecesario o la fragmentación de la memoria, el recolector de basura desplaza los objetos. Señalar un objeto es inútil si su dirección puede cambiar mientras se hace referencia a él, por lo que la sentencia fixed indica al recolector de basura que "fije" el objeto y no lo mueva. Esto puede repercutir en la eficiencia del tiempo de ejecución, por lo que debes utilizar los bloques fijos sólo brevemente, y debes evitar la asignación al montón dentro del bloque fijo.

Dentro de una sentencia fixed, puedes obtener un puntero a cualquier tipo de valor, a una matriz de tipos de valor o a una cadena. En el caso de las matrices y las cadenas, el puntero apuntará al primer elemento, que es un tipo de valor.

Los tipos de valor declarados en línea dentro de tipos de referencia requieren que el tipo de referencia esté fijado, como se indica a continuación:

Test test = new Test();
unsafe
{
  fixed (int* p = &test.X)   // Pins test
  {
    *p = 9;
  }
  Console.WriteLine (test.X);
}

class Test { public int X; }

Describimos con más detalle la declaración fixed en "Asignación de una estructura a la memoria no gestionada".

El operador puntero a miembro

Además de los operadores & y *, C# también proporciona el operador -> al estilo de C++, que puedes utilizar en structs:

Test test = new Test();
unsafe
{
  Test* p = &test;
  p->X = 9;
  System.Console.WriteLine (test.X);
}

struct Test { public int X; }

La palabra clave stackalloc

Puedes asignar memoria en un bloque en la pila explícitamente utilizando la palabra clave stackalloc. Como se asigna en la pila, su vida está limitada a la ejecución del método, igual que ocurre con cualquier otra variable local (cuya vida no se haya ampliado por haber sido capturada por una expresión lambda, un bloque iterador o una función asíncrona). El bloque puede utilizar el operador [] para indexar en memoria:

int* a = stackalloc int [10];
for (int i = 0; i < 10; ++i)
   Console.WriteLine (a[i]);

En el Capítulo 23, describimos cómo puedes utilizar Span<T> para gestionar la memoria asignada a pilas sin utilizar la palabra clave unsafe:

Span<int> a = stackalloc int [10];
for (int i = 0; i < 10; ++i)
  Console.WriteLine (a[i]);

Búferes de tamaño fijo

La palabra clave fixed tiene otro uso, que es crear buffers de tamaño fijo dentro de structs (esto puede ser útil cuando se llama a una función no gestionada; ver Capítulo 24):

new UnsafeClass ("Christian Troy");

unsafe struct UnsafeUnicodeString
{
  public short Length;
  public fixed byte Buffer[30];   // Allocate block of 30 bytes
}

unsafe class UnsafeClass
{
  UnsafeUnicodeString uus;

  public UnsafeClass (string s)
  {
    uus.Length = (short)s.Length;
    fixed (byte* p = uus.Buffer)
      for (int i = 0; i < s.Length; i++)
        p[i] = (byte) s[i];
  }
}

Los búferes de tamaño fijo no son matrices: si Buffer fuera una matriz, consistiría en una referencia a un objeto almacenado en el montón (gestionado), en lugar de 30 bytes dentro de la propia estructura.

La palabra clave fixed también se utiliza en este ejemplo para fijar el objeto del montón que contiene el búfer (que será la instancia de UnsafeClass). Por tanto, fixed significa dos cosas distintas: fijo en tamaño y fijo en lugar. Ambas se utilizan a menudo juntas, en el sentido de que un búfer de tamaño fijo debe estar fijo en su lugar para poder ser utilizado.

vacío*

Un puntero void (void*) no hace suposiciones sobre el tipo de los datos subyacentes y es útil para funciones que tratan con memoria bruta. Existe una conversión implícita de cualquier tipo de puntero a void*. Un void* no puede ser desreferenciado, y no se pueden realizar operaciones aritméticas con punteros nulos. He aquí un ejemplo:

short[] a = { 1, 1, 2, 3, 5, 8, 13, 21, 34, 55 };
unsafe
{
  fixed (short* p = a)
  {
    //sizeof returns size of value-type in bytes
    Zap (p, a.Length * sizeof (short));
  }
}
foreach (short x in a)
  Console.WriteLine (x);   // Prints all zeros

unsafe void Zap (void* memory, int byteCount)
{
  byte* b = (byte*)memory;
  for (int i = 0; i < byteCount; i++)
    *b++ = 0;
}

Enteros de tamaño nativo

Los tipos enteros de tamaño nativo nint y nuint (introducidos en C# 9) tienen un tamaño que coincide con el espacio de direcciones del proceso en tiempo de ejecución (en la práctica, 32 ó 64 bits). Los enteros de tamaño nativo se comportan como enteros estándar, con soporte completo para operaciones aritméticas y comprobación de desbordamiento:

nint x = 123, y = 234;
checked
{
  nint sum = x + y, product = x * y;
  Console.WriteLine (product);
}

A los enteros de tamaño nativo se les pueden asignar constantes enteras de 32 bits (pero no constantes enteras de 64 bits, porque podrían desbordarse en tiempo de ejecución). Puedes utilizar una conversión explícita para convertir a o desde otros tipos integrales.

Puedes utilizar enteros de tamaño nativo para representar direcciones de memoria o desplazamientos sin utilizar punteros. nuint también es un tipo natural para representar la longitud de un bloque de memoria.

Al trabajar con punteros, los enteros de tamaño nativo pueden mejorar la eficiencia, porque el resultado de restar dos punteros en C# es siempre un entero de 64 bits (long), lo que es ineficiente en plataformas de 32 bits. Al convertir primero los punteros a nint, el resultado de una resta es también nint (que será de 32 bits en una plataforma de 32 bits):

unsafe nint AddressDif (char* x, char* y) => (nint)x - (nint)y;
Nota

Un buen ejemplo del uso en el mundo real de nint y nuint junto con punteros está en la implementación de Buffer.MemoryCopy. Puedes verlo en el código fuente .NET de Buffer.cs en GitHub, o descompilando el método en ILSpy. También se ha incluido una versión simplificada en las muestras de LINQPad para C# 12 in a Nutshell.

Gestión del tiempo de ejecución al utilizar .NET 7+

Para los proyectos orientados a .NET 7 o superior, nint y nuint actúan como sinónimos de los tipos .NET subyacentes System.IntPtr y System.UIntPtr (del mismo modo que int actúa como sinónimo de System.Int32). Esto funciona porque los tipos IntPtr y UIntPtr (que han existido desde .NET Framework 1.0, pero con funcionalidad limitada) se mejoraron en .NET 7 para permitir capacidades aritméticas completas y comprobación de desbordamiento con el compilador de C#.

Nota

La adición de la capacidad aritmética comprobada a IntPtr/UIntPtr es técnicamente un cambio de ruptura. Sin embargo, los efectos son limitados, porque el código heredado que depende de que IntPtr no respete los bloques de checked no se romperá cuando se ejecute simplemente en .NET 7+; para romperse, el proyecto debe recompilarse también con un objetivo .NET 7+. Esto significa que los autores de bibliotecas no tienen que preocuparse por el cambio de rotura hasta que publiquen una nueva versión orientada específicamente a .NET 7 o posterior.

Gestión del tiempo de ejecución cuando se utiliza .NET 6 o inferior

Para los proyectos orientados a .NET 6 o inferior (o .NET Standard), nint y nuint siguen utilizando IntPtr y UIntPtr como tipos subyacentes en tiempo de ejecución. Sin embargo, como los tipos heredados IntPtr y UIntPtr carecen de soporte para la mayoría de las operaciones aritméticas, el compilador rellena los huecos, haciendo que los tipos nint/nuint se comporten como lo harían en .NET 7+ (incluso permitiendo operaciones checked ). Puedes pensar en una variable nint/nuint como una IntPtr/UIntPtr con un sombrero especial. Este sombrero es reconocido por el compilador para significar "por favor, trátame como un IntPtr/UIntPtr moderno". Este sombrero se perderá, naturalmente, si más adelante pasas a una IntPtr/UIntPtr:

nint x = 123;
Console.WriteLine (x * x);   // OK: multiplication supported

IntPtr y = x;
Console.WriteLine (y * y);   // Compiler error: operator * not supported

Punteros de función

Un puntero a función (de C# 9) es como un delegado, pero sin la indirección de una instancia de delegado; en su lugar, apunta directamente a un método. Un puntero a función sólo puede apuntar a métodos estáticos, carece de capacidad de multidifusión y requiere un contexto unsafe (porque elude la seguridad de tipos en tiempo de ejecución). Su principal finalidad es simplificar y optimizar la interoperabilidad con API no gestionadas (consulta "Devoluciones de llamada desde código no gestionado").

Un tipo de puntero de función se declara como sigue (el tipo de retorno aparece en último lugar):

delegate*<int, char, string, void>   // (void refers to the return type)

Coincide con una función con esta firma:

void SomeFunction (int x, char y, string z)

El operador & crea un puntero a una función a partir de un grupo de métodos. Aquí tienes un ejemplo completo:

unsafe
{
  delegate*<string, int> functionPointer = &GetLength;
  int length = functionPointer ("Hello, world");

  static int GetLength (string s) => s.Length;
}

En este ejemplo, functionPointer no es un objeto sobre el que puedas llamar a un método como Invoke (o con una referencia a un objeto Target ). En su lugar, es una variable que apunta directamente a la dirección del método objetivo en memoria:

Console.WriteLine ((IntPtr)functionPointer);

Como cualquier otro puntero, no está sujeto a comprobación de tipo en tiempo de ejecución. Lo siguiente trata el valor de retorno de nuestra función como un decimal (que, al ser más largo que un int, significa que incorporamos algo de memoria aleatoria a la salida):

var pointer2 = (delegate*<string, decimal>) (IntPtr) functionPointer;
Console.WriteLine (pointer2 ("Hello, unsafe world"));

[SkipLocalsInit]

Cuando C# compila un método, emite una bandera que indica al tiempo de ejecución que inicialice las variables locales del método a sus valores por defecto (poniendo a cero la memoria). A partir de C# 9, puedes pedir al compilador que no emita esta bandera aplicando el atributo [Ski⁠p​LocalsI⁠nit] a un método (en el espacio de nombres System.Runtime.CompilerServices ):

[SkipLocalsInit]
void Foo() ...

También puedes aplicar este atributo a un tipo -lo que equivale a aplicarlo a todos los métodos del tipo- o incluso a todo un módulo (el contenedor de un conjunto):

[module: System.Runtime.CompilerServices.SkipLocalsInit]

En escenarios seguros normales, [SkipLocalsInit] tiene poco efecto sobre la funcionalidad o el rendimiento, porque la política de asignación definitiva de C# requiere que asignes explícitamente variables locales antes de que puedan ser leídas. Esto significa que es probable que el optimizador JIT emita el mismo código máquina, se aplique o no el atributo.

En un contexto inseguro, sin embargo, el uso de [SkipLocalsInit] puede ahorrar al CLR la sobrecarga de inicializar variables locales de tipo valor, creando una pequeña ganancia de rendimiento con métodos que hacen un uso extensivo de la pila (a través de un stackalloc grande). El siguiente ejemplo imprime la memoria no inicializada cuando se aplica [SkipLocalsInit] (en lugar de todos los ceros):

[SkipLocalsInit]
unsafe void Foo()
{
  int local;
  int* ptr = &local;
  Console.WriteLine (*ptr);

  int* a = stackalloc int [100];
  for (int i = 0; i < 100; ++i) Console.WriteLine (a [i]);
}

Curiosamente, puedes conseguir el mismo resultado en un contexto "seguro" mediante el uso de Span<T>:

[SkipLocalsInit]
void Foo()
{
  Span<int> a = stackalloc int [100];
  for (int i = 0; i < 100; ++i) Console.WriteLine (a [i]);
}

En consecuencia, el uso de [SkipLocalsInit] requiere que compiles tu proyecto con <AllowUnsafeBlocks> establecido como verdadero, aunque ninguno de tus métodos esté marcado como unsafe.

Directivas del preprocesador

Las directivas de preprocesador proporcionan al compilador información adicional sobre regiones de código. Las directivas de preprocesador más comunes son las directivas condicionales, que permiten incluir o excluir regiones de código de la compilación:

#define DEBUG
class MyClass
{
  int x;
  void Foo()
  {
    #if DEBUG
    Console.WriteLine ("Testing: x = {0}", x);
    #endif
  }
  ...
}

En esta clase, la sentencia en Foo se compila como condicionalmente dependiente de la presencia del símbolo DEBUG. Si eliminamos el símbolo DEBUG, la sentencia no se compila. Puedes definir los símbolos del preprocesador dentro de un archivo fuente (como hemos hecho nosotros) o a nivel de proyecto en el archivo .csproj:

<PropertyGroup>
  <DefineConstants>DEBUG;ANOTHERSYMBOL</DefineConstants>
</PropertyGroup>

Con las directivas #if y #elif, puedes utilizar los operadores ||, && y ! para realizar operaciones or, and y not en varios símbolos. La siguiente directiva indica al compilador que incluya el código que sigue si el símbolo TESTMODE está definido y el símbolo DEBUG no lo está:

#if TESTMODE && !DEBUG
  ...

Ten en cuenta, sin embargo, que no estás construyendo una expresión C# normal y corriente, y que los símbolos sobre los que operas no tienen absolutamente ninguna conexión con variables -estáticaso de otro tipo-.

Los símbolos #error y #warning evitan el mal uso accidental de las directivas condicionales haciendo que el compilador genere una advertencia o un error dado un conjunto indeseable de símbolos de compilación. La Tabla 4-1 enumera las directivas del preprocesador.

Tabla 4-1. Directivas del preprocesador
Directiva del preprocesador Acción
#define symbol Define symbol
#undef symbol No define symbol
#if symbol [operator symbol2]... symbol para probar
operators son ==, !=, &&, y || seguidos de #else, #elif, y #endif
#else Ejecuta el código a continuación #endif
#elif symbol [operator symbol2] Combina la rama #else y la prueba #if
#endif Finaliza las directivas condicionales
#warning text text de la advertencia que debe aparecer en la salida del compilador
#error text text del error que aparecerá en la salida del compilador
#error version Informa de la versión del compilador y sale
#pragma warning [disable | restore] Desactiva/restaura la(s) advertencia(s) del compilador
#line [ number ["file"] | hidden] number especifica la línea en el código fuente (también se puede especificar una columna de C# 10); file es el nombre de archivo que aparecerá en la salida del ordenador; hidden indica a los depuradores que se salten el código desde este punto hasta la siguiente directiva #line
#region name Marca el inicio de un esquema
#endregion Finaliza una región de contorno
#nullable option Ver "Tipos de referencia anulables"

Atributos condicionales

Un atributo decorado con el atributo Conditional sólo se compilará si está presente un símbolo de preprocesador determinado:

// file1.cs
#define DEBUG
using System;
using System.Diagnostics;
[Conditional("DEBUG")]
public class TestAttribute : Attribute {}

// file2.cs
#define DEBUG
[Test]
class Foo
{
  [Test]
  string s;
}

El compilador incorporará los atributos [Test] sólo si el símbolo DEBUG está en el ámbito de file2.cs.

Advertencia pragma

El compilador genera una advertencia cuando detecta algo en tu código que parece involuntario. A diferencia de los errores, las advertencias no suelen impedir la compilación de tu aplicación.

Las advertencias del compilador pueden ser muy útiles para detectar errores. Sin embargo, su utilidad se ve mermada cuando recibes advertencias falsas. En una aplicación grande, mantener una buena relación señal-ruido es esencial para que se perciban las advertencias "reales".

Para ello, el compilador te permite suprimir selectivamente las advertencias mediante la directiva #pragma warning. En este ejemplo, indicamos al compilador que no nos advierta de que el campo Message no se utiliza:

public class Foo
{
  static void Main() { }

  #pragma warning disable 414
  static string Message = "Hello";
  #pragma warning restore 414
}

Omitir el número en la directiva #pragma warning desactiva o restaura todos los códigos de advertencia.

Si eres meticuloso en la aplicación de esta directiva, puedes compilar con el modificador /warnaserror, que indica al compilador que trate las advertencias residuales como errores.

Documentación XML

Un comentario de documentación es un fragmento de XML incrustado que documenta un tipo o miembro. Un comentario de documentación aparece inmediatamente antes de la declaración de un tipo o miembro y comienza con tres barras:

/// <summary>Cancels a running query.</summary>
public void Cancel() { ... }

Los comentarios multilínea pueden hacerse así:

/// <summary>
/// Cancels a running query
/// </summary>
public void Cancel() { ... }

O así (fíjate en la estrella de más al principio):

/** 
    <summary> Cancels a running query. </summary>
*/
public void Cancel() { ... }

Si añades la siguiente opción a tu archivo .csproj:

<PropertyGroup>
  <DocumentationFile>SomeFile.xml</DocumentationFile>
</PropertyGroup>

el compilador extrae y recopila los comentarios de la documentación en el archivo XML especificado. Esto tiene dos usos principales:

  • Si se coloca en la misma carpeta que el conjunto compilado, herramientas como Visual Studio y LINQPad leen automáticamente el archivo XML y utilizan la información para proporcionar listados de miembros IntelliSense a los consumidores del conjunto del mismo nombre.

  • Herramientas de terceros (como Sandcastle y NDoc) pueden transformar el archivo XML en un archivo de ayuda HTML.

Etiquetas estándar de documentación XML

Éstas son las etiquetas XML estándar que reconocen Visual Studio y los generadores de documentación:

<summary>
<summary>...</summary>

Indica la información sobre herramientas que IntelliSense debe mostrar para el tipo o miembro; normalmente una sola frase u oración.

<remarks>
<remarks>...</remarks>

Texto adicional que describe el tipo o miembro. Los generadores de documentación lo recogen y lo integran en el grueso de la descripción de un tipo o miembro.

<param>
<param name="name">...</param>

Explica un parámetro de un método.

<returns>
<returns>...</returns>

Explica el valor de retorno de un método.

<exception>
<exception [cref="type"]>...</exception>

Enumera las excepciones que puede lanzar un método (cref se refiere al tipo de excepción).

<example>
<example>...</example>

Denota un ejemplo (utilizado por los generadores de documentación). Suele contener texto descriptivo y código fuente (el código fuente suele estar dentro de una etiqueta <c> o <code> ).

<c>
<c>...</c>

Indica un fragmento de código en línea. Esta etiqueta suele utilizarse dentro de un bloque <example>.

<code>
<code>...</code>

Indica una muestra de código multilínea. Esta etiqueta suele utilizarse dentro de un bloque <example>.

<see>
<see cref="member">...</see>

Inserta una referencia cruzada en línea a otro tipo o miembro. Los generadores de documentación HTML suelen convertirlo en un hipervínculo. El compilador emite una advertencia si el nombre del tipo o miembro no es válido. Para hacer referencia a tipos genéricos, utiliza llaves; por ejemplo, cref="Foo{T,U}".

<seealso>
<seealso cref="member">...</seealso>

Hace referencia a otro tipo o miembro. Los generadores de documentación suelen escribir esto en una sección separada "Véase también" al final de la página.

<paramref>
<paramref name="name"/>

Hace referencia a un parámetro desde dentro de una etiqueta <summary> o <remarks>.

<list>
<list type=[ bullet | number | table ]>
  <listheader>
    <term>...</term>
    <description>...</description>
  </listheader>
  <item>
    <term>...</term>
    <description>...</description>
  </item>
</list>

Indica a los generadores de documentación que emitan una lista con viñetas, numerada o en forma de tabla.

<para>
<para>...</para>

Indica a los generadores de documentación que formateen el contenido en un párrafo aparte.

<include>
<include file='filename' path='tagpath[@name="id"]'>...</include>

Fusiona un archivo XML externo que contenga documentación. El atributo path denota una consulta XPath a un elemento concreto de ese archivo.

Etiquetas definidas por el usuario

Las etiquetas XML predefinidas reconocidas por el compilador de C# tienen poco de especial, y eres libre de definir las tuyas propias. El único procesamiento especial que realiza el compilador es sobre la etiqueta <param> (en la que verifica el nombre del parámetro y que todos los parámetros del método estén documentados) y el atributo cref (en el que verifica que el atributo se refiere a un tipo o miembro real y lo expande a un ID de tipo o miembro totalmente cualificado). También puedes utilizar el atributo cref en tus propias etiquetas; se verifica y expande igual que en las etiquetas predefinidas <exception>, <permission>, <see> y <seealso>.

Tipo o miembro Referencias cruzadas

Los nombres de tipo y las referencias cruzadas de tipo o miembro se traducen en ID que definen de forma única el tipo o miembro. Estos nombres se componen de un prefijo que define lo que representa el ID y una firma del tipo o miembro. A continuación se indican los prefijos de los miembros:

Prefijo de tipo XML Prefijos ID aplicados a...
N Espacio de nombres
T Tipo (clase, estructura, enumeración, interfaz, delegado)
F Campo
P Propiedad (incluye indexadores)
M Método (incluye métodos especiales)
E Evento
! Error

Las reglas que describen cómo se generan las firmas están bien documentadas, aunque son bastante complejas.

Aquí tienes un ejemplo de un tipo y los ID que se generan:

// Namespaces do not have independent signatures
namespace NS
{
  /// T:NS.MyClass
  class MyClass
  {
    /// F:NS.MyClass.aField
    string aField;

    /// P:NS.MyClass.aProperty
    short aProperty {get {...} set {...}}

    /// T:NS.MyClass.NestedType
    class NestedType {...};

    /// M:NS.MyClass.X()
    void X() {...}

    /// M:NS.MyClass.Y(System.Int32,System.Double@,System.Decimal@)
    void Y(int p1, ref double p2, out decimal p3) {...}

    /// M:NS.MyClass.Z(System.Char[ ],System.Single[0:,0:])
    void Z(char[ ] p1, float[,] p2) {...}

    /// M:NS.MyClass.op_Addition(NS.MyClass,NS.MyClass)
    public static MyClass operator+(MyClass c1, MyClass c2) {...}

    /// M:NS.MyClass.op_Implicit(NS.MyClass)˜System.Int32
    public static implicit operator int(MyClass c) {...}

    /// M:NS.MyClass.#ctor
    MyClass() {...}

    /// M:NS.MyClass.Finalize
    ˜MyClass() {...}

    /// M:NS.MyClass.#cctor
    static MyClass() {...}
  }
}

Get C# 12 en pocas palabras now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.