Ir al contenido
wannacode

record, switch y patrones

En resumen: Un record es un tipo de datos para el que C# escribe el constructor, la igualdad por valor y ToString, y un switch con patrones elige según el tipo y las propiedades.

Problema

El asiento está ocupado, pero Dictionary no lo encuentra

Reservas en un cine: la clave es el asiento (fila y número), el valor es el nombre de la persona.

Seat
class Seat
{
private int row;
private int number;
public Seat(int row, int number)
{
this.row = row;
this.number = number;
}
}
Dictionary<Seat, string> reservations = new Dictionary<Seat, string>();
reservations.Add(new Seat(5, 12), "María");
Console.WriteLine(reservations.GetValueOrDefault(new Seat(5, 12), "libre"));
Console.WriteLine(reservations.ContainsKey(new Seat(5, 12)));
Salida
libre
False

María está en la fila 5, asiento 12, pero GetValueOrDefault(clave, alternativa), que da el valor o la alternativa si falta la clave, dice «libre». Ya sabes por qué desde la lección «Códigos hash y funciones hash»: new Seat(5, 12) escrito dos veces son dos objetos distintos, y Seat no dice cuándo son iguales. La solución es Equals y GetHashCode, más ToString para imprimir, y propiedades para leer. Unas 30 líneas para una clase que solo lleva dos números.

Más precisamente, para curiosos

El indexador reservations[...] con una clave que falta no da una alternativa, lanza una excepción:

try
{
Console.WriteLine(reservations[new Seat(5, 12)]);
}
catch (KeyNotFoundException e)
{
Console.WriteLine("KeyNotFoundException: " + e.Message);
}
Salida
KeyNotFoundException: The given key 'Seat' was not present in the dictionary.

La idea

record: una clase para datos

Desde C# 9 lo mismo ocupa una línea:
record Seat(int Row, int Number);
Dictionary<Seat, string> reservations = new Dictionary<Seat, string>();
reservations.Add(new Seat(5, 12), "María");
Console.WriteLine(reservations.GetValueOrDefault(new Seat(5, 12), "libre"));
Console.WriteLine(reservations.ContainsKey(new Seat(5, 12)));
Seat seat = new Seat(5, 12);
Console.WriteLine(seat);
Console.WriteLine("Fila " + seat.Row + ", asiento " + seat.Number);
Salida
María
True
Seat { Row = 5, Number = 12 }
Fila 5, asiento 12

Un record es una clase cuyo trabajo es llevar datos. Entre los paréntesis están sus parámetros posicionales, y C# escribe por ti:

  • una propiedad para cada parámetro, con el mismo nombre: Row y Number. Se asigna solo al crear el objeto;
  • un constructor con todos los parámetros;
  • Equals y GetHashCode, que comparan las propiedades. Por eso Dictionary ahora encuentra a María. Los operadores == y != también comparan las propiedades;
  • ToString con la forma Seat { Row = 5, Number = 12 }.

Un objeto de un record no cambia después de crearse. Un objeto nuevo con un cambio se obtiene con with (mira «Más precisamente, para curiosos»).

Dictionary<Seat, string> reservations = new Dictionary<Seat, string>();
reservations.Add(new Seat(5, 12), "María");
Console.WriteLine(reservations.GetValueOrDefault(new Seat(5, 12), "libre"));
Console.WriteLine(reservations.ContainsKey(new Seat(5, 12)));
Seat seat = new Seat(5, 12);
Console.WriteLine(seat);
Console.WriteLine("Fila " + seat.Row + ", asiento " + seat.Number);
Paso a paso, en palabras
  1. Un mapa vacío: la clave es un asiento del cine, el valor es un nombre.
  2. Add añade la clave Seat(5, 12) con el valor «María».
  3. GetValueOrDefault recibe un objeto Seat nuevo. Es otro objeto en la memoria, no la clave de Add.
  4. El GetHashCode de un record se calcula con las propiedades, así que es el mismo: la búsqueda va al mismo lugar.
  5. El Equals de un record compara propiedad por propiedad: fila 5 y 5, asiento 12 y 12. Son iguales, y GetValueOrDefault devuelve «María».
  6. ContainsKey hace lo mismo con otro Seat nuevo más: True.
  7. El record también escribe solo ToString y las propiedades Row y Number.
Más precisamente, para curiosos
  • with hace una copia del objeto y cambia solo las propiedades indicadas. El objeto anterior sigue igual. Un record posicional también se puede descomponer en variables separadas:
Seat seat = new Seat(5, 12);
Seat next = seat with { Number = 13 };
Console.WriteLine(next);
Console.WriteLine(seat);
var (row, number) = next;
Console.WriteLine($"Fila {row}, asiento {number}");
Salida
Seat { Row = 5, Number = 13 }
Seat { Row = 5, Number = 12 }
Fila 5, asiento 13
  • Un intento de cambiar una propiedad no compila:
Seat seat = new Seat(5, 12);
seat.Row = 6;
Error
error CS8852: Init-only property or indexer 'Seat.Row' can only be assigned in an object initializer, or on 'this' or 'base' in an instance constructor or an 'init' accessor.
  • Un record puede tener sus propios métodos, propiedades y campos además de los posicionales. También cuentan en Equals. Puede extender otro record (lo usamos en la tarjeta de los pagos), pero no una clase normal, y puede implementar interfaces. Un sealed record no se puede extender.
  • Un record es una clase: la variable apunta al objeto. record struct Point(int X, int Y); es un tipo por valor, como int. También compara por sus campos.
  • La inmutabilidad es solo de un nivel. Si una propiedad es una List, siempre apunta a la misma lista, pero la propia lista puede cambiar. Y Equals compara las listas como flechas, no por su contenido:
record Cart(List<string> Items);
Cart cart = new Cart(new List<string> { "café" });
cart.Items.Add("miel");
Console.WriteLine(string.Join(", ", cart.Items));
Console.WriteLine(cart == new Cart(new List<string> { "café", "miel" }));
Salida
café, miel
False
  • En el módulo «Colecciones» escribiste Equals y GetHashCode a mano. Con record ya los tienes. Si necesitas otra regla de igualdad, todavía puedes escribir la tuya.

La idea

Comprobaciones y métodos en un record

Un precio no puede ser negativo. La forma posicional record Price(decimal Amount, string Currency); no tiene sitio para una comprobación. Así que aquí las propiedades y el constructor se escriben a mano, y record igualmente da Equals, GetHashCode y ToString.
record Price
{
public decimal Amount { get; }
public string Currency { get; }
public Price(decimal amount, string currency)
{
if (amount < 0)
{
throw new ArgumentException("El precio no puede ser negativo: " + amount);
}
Amount = amount;
Currency = currency;
}
public Price WithDiscount(decimal percent)
{
return new Price(Math.Round(Amount * (1 - percent / 100), 2), Currency);
}
}
Price coffee = new Price(18.50m, "EUR");
Price sale = coffee.WithDiscount(20);
Console.WriteLine(coffee);
Console.WriteLine(sale);
try
{
new Price(-5, "EUR");
}
catch (ArgumentException e)
{
Console.WriteLine(e.Message);
}
Salida
Price { Amount = 18.50, Currency = EUR }
Price { Amount = 14.80, Currency = EUR }
El precio no puede ser negativo: -5
  • La comprobación va antes de asignar las propiedades. Si el constructor lanza una excepción, el objeto no se crea.
  • WithDiscount no cambia el precio. Devuelve un Price nuevo. Así se trabaja con objetos que no cambian: coffee sigue en 18,50 €.
  • Math.Round(..., 2) redondea al céntimo. decimal conserva las cifras después del punto decimal: sin redondear, 18.50 por 0.80 es 14 800.
  • Las propiedades aquí solo tienen get. Así que with no puede cambiarlas ni saltarse la comprobación.

Pruébalo

¿Son iguales?

Seat es un record, como en la tarjeta anterior.

¿Qué imprimirá el programa?

Seat a = new Seat(5, 12);
Seat b = new Seat(5, 12);
Console.WriteLine(a == b);
Console.WriteLine(a.GetHashCode() == b.GetHashCode());
Console.WriteLine(ReferenceEquals(a, b));
Respuesta

Pruébalo

El envío sale caro

El envío a una oficina cuesta 4,50 €, pero el programa dice 6,90 €. ¿En qué línea está el error?

Elige la línea con el error.

La idea

Un switch que devuelve un valor

Desde C# 8 un switch puede ser una expresión: da un valor que guardas en una variable o devuelves.
string method = "oficina";
decimal price = method switch
{
"oficina" => 4.50m,
"mensajero" or "domicilio" => 6.90m,
"tienda" => 0,
_ => throw new ArgumentException("Método de envío desconocido: " + method),
};
Console.WriteLine("Envío: " + price + " €");
Salida
Envío: 4.50 €
  • El valor que se comprueba va antes de switch. Cada caso es patrón => valor. No hay caída, y no se escribe break.
  • Varios valores en un caso se unen con or: "mensajero" or "domicilio".
  • _ atrapa todo lo demás, como default. En ese caso puedes lanzar una excepción.
  • La expresión tiene que dar un valor para cada entrada. Con texto las entradas son infinitas, así que _ tiene que estar. Sin él C# no detiene la compilación, solo avisa:
static decimal Price(string method)
{
return method switch
{
"oficina" => 4.50m,
"mensajero" => 6.90m,
};
}
Advertencia
warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern '""' is not covered.

Y el programa se detiene solo cuando llega una entrada sin caso, Price("correo"):

Error
Unhandled exception. System.Runtime.CompilerServices.SwitchExpressionException: Non-exhaustive switch expression failed to match its input.
Unmatched value was correo.

Si el caso depende además de otra condición, se añade con when:

static decimal Delivery(string method, double weightKg)
{
return method switch
{
"oficina" => 4.50m,
"mensajero" when weightKg > 10 => 6.90m + 3,
"mensajero" => 6.90m,
_ => 0,
};
}
Console.WriteLine(Delivery("mensajero", 4));
Console.WriteLine(Delivery("mensajero", 12));
Salida
6.90
9.90
Más precisamente, para curiosos
  • Cada caso es una sola expresión: aquí no hay un bloque de varias líneas. Si el cálculo es largo, ponlo en un método y llámalo desde el caso.
  • El switch antiguo con case "oficina": ... break; sigue funcionando, y lo verás en código antiguo. En él tampoco cae un caso con código (la tarjeta anterior).

La idea

Una familia de tipos

La tienda acepta tres formas de pago: tarjeta, transferencia bancaria y contra reembolso. No hay otras. Cada forma es su propio record, un subtipo de un abstract record común:

abstract record Payment;
sealed record CardPayment(string CardNumber) : Payment;
sealed record BankTransfer(string Iban) : Payment;
sealed record CashOnDelivery() : Payment;

abstract record Payment; es el tipo común: no puedes crear un objeto de él, solo de los subtipos. : Payment después de un subtipo dice «esto es un tipo de Payment». El cuerpo está vacío, así que la declaración termina con ;. Los subtipos son record, porque cada forma de pago son solo datos: un número de tarjeta, un IBAN o nada.

sealed en C# significa otra cosa: la clase no se puede extender. Aquí cada forma de pago es sealed, para que no haya «un tipo de tarjeta». Pero la propia familia no se puede cerrar: en C# no hay forma de decir «solo estos tres extienden Payment». Un cuarto tipo compila sin error:

sealed record Voucher(string Code) : Payment;

Cómo protegerse de eso está en la siguiente tarjeta.

Míralo

Patrones en switch

La comisión depende de la forma de pago. Un switch también puede comprobar el tipo del objeto:
static decimal Fee(Payment payment, decimal amount)
{
return payment switch
{
CardPayment => Math.Round(amount * 0.015m, 2),
BankTransfer => 0.50m,
CashOnDelivery => 2.00m,
_ => throw new ArgumentException("Pago desconocido: " + payment),
};
}
Console.WriteLine(Fee(new CardPayment("4111222233334444"), 200));
Console.WriteLine(Fee(new BankTransfer("ES9121000418450200051332"), 200));
Console.WriteLine(Fee(new CashOnDelivery(), 200));
Salida
3.00
0.50
2.00

CardPayment => ... significa «si el objeto es un CardPayment». Si también necesitas el propio objeto, ponle un nombre: CardPayment card => .... Es is con patrón del módulo «Herencia y polimorfismo», pero para muchos tipos a la vez.

El _ del final es obligatorio. El compilador no conoce todos los subtipos de Payment: cualquiera puede añadir uno nuevo. Sin _ solo avisa:

static decimal Fee(Payment payment, decimal amount)
{
return payment switch
{
CardPayment => Math.Round(amount * 0.015m, 2),
BankTransfer => 0.50m,
CashOnDelivery => 2.00m,
};
}
Advertencia
warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern '_' is not covered.

Entonces un cuarto tipo detiene el programa solo durante la ejecución, con una SwitchExpressionException. _ con throw al menos lo detiene con un mensaje claro que dice qué tipo falta:

try
{
Fee(new Voucher("OFERTA10"), 200);
}
catch (ArgumentException e)
{
Console.WriteLine(e.Message);
}
Salida
Pago desconocido: Voucher { Code = OFERTA10 }

Un patrón posicional saca las propiedades directamente, en el orden de los parámetros posicionales, y when añade una condición al caso:

static string Describe(Payment payment)
{
return payment switch
{
CardPayment(var number) => "tarjeta ****" + number.Substring(number.Length - 4),
BankTransfer(var iban) when iban.StartsWith("ES") => "transferencia desde una cuenta española",
BankTransfer => "transferencia desde el extranjero",
CashOnDelivery => "contra reembolso",
_ => throw new ArgumentException("Pago desconocido: " + payment),
};
}
Console.WriteLine(Describe(new CardPayment("4111222233334444")));
Console.WriteLine(Describe(new BankTransfer("ES9121000418450200051332")));
Console.WriteLine(Describe(new BankTransfer("DE89370400440532013000")));
Console.WriteLine(Describe(new CashOnDelivery()));
Salida
tarjeta ****4444
transferencia desde una cuenta española
transferencia desde el extranjero
contra reembolso
Más precisamente, para curiosos
  • El patrón posicional también funciona con is. También se puede nombrar una propiedad: CardPayment { CardNumber: var digits }. Eso es un patrón de propiedad, y funciona con cualquier clase, no solo con record:
Payment payment = new CardPayment("4111222233334444");
if (payment is CardPayment(var number))
{
Console.WriteLine("La tarjeta termina en " + number.Substring(12));
}
if (payment is CardPayment { CardNumber: var digits })
{
Console.WriteLine("Y con el nombre de la propiedad: " + digits.Substring(12));
}
Salida
La tarjeta termina en 4444
Y con el nombre de la propiedad: 4444
  • Si el objeto es null, no encaja con ningún tipo y llega a _. Si null es una entrada con sentido, añade null => ... antes de _.
  • Los patrones se pueden anidar: Order(Customer(var name), var total).

Pruébalo

¿Qué caso gana?

Los casos se comprueban de arriba abajo. Gana el primero que encaja.

¿Qué imprimirá el programa?

static string Label(Payment payment, decimal amount)
{
return payment switch
{
CashOnDelivery when amount > 500 => "contra reembolso: no se acepta por encima de 500 €",
CashOnDelivery => "contra reembolso",
CardPayment when amount > 1000 => "tarjeta: hace falta confirmación",
CardPayment => "tarjeta",
BankTransfer => "transferencia",
_ => throw new ArgumentException("Pago desconocido: " + payment),
};
}
Console.WriteLine(Label(new CashOnDelivery(), 650));
Console.WriteLine(Label(new CashOnDelivery(), 80));
Console.WriteLine(Label(new CardPayment("4111222233334444"), 650));
Respuesta
Más precisamente, para curiosos

El caso con when tiene que ir antes del general para el mismo tipo. Al revés no compila, porque el caso general lo atrapa todo y nunca se podría llegar al de when:

CashOnDelivery => "contra reembolso",
CashOnDelivery when amount > 500 => "contra reembolso: no se acepta por encima de 500 €",
Error
error CS8510: The pattern is unreachable. It has already been handled by a previous arm of the switch expression or it is impossible to match.

La idea

Cadenas sin formato y var

Un texto de varias líneas, como un recibo o un JSON, se lee mal con " y \n en cada línea. Desde C# 11 existe la cadena literal sin formato (raw string literal) entre """ y """:

string receipt = """
La tienda de la esquina
Café en grano 18.50 €
Miel 12.00 €
Total: 30.50 €
""";
Console.WriteLine(receipt);
string name = "María";
decimal total = 75.5m;
string json = $$"""
{"customer": "{{name}}", "total": {{total:F2}}}
""";
Console.WriteLine(json);
Salida
La tienda de la esquina
Café en grano 18.50 €
Miel 12.00 €
Total: 30.50 €
{"customer": "María", "total": 75.50}
  • Después de los """ de apertura el texto empieza en una línea nueva, y los """ de cierre están en su propia línea. El salto de línea antes de ellos no es parte del texto, por eso es WriteLine.
  • Se quita la sangría común de la izquierda. La medida es la sangría de los """ de cierre.
  • Las comillas de dentro no se escapan.
  • Un $ delante de la cadena rellena valores entre llaves. Aquí el propio JSON tiene llaves, así que delante hay dos $$, y los valores van entre llaves dobles: {{name}} y {{total:F2}}.

Y un recordatorio de «Fundamentos del lenguaje»: var deja que el compilador deduzca el tipo. Con los tipos largos de las colecciones es con lo que más ahorra:

var prices = new Dictionary<string, decimal>();
prices["café"] = 18.50m;
prices["miel"] = 12.00m;
foreach (var pair in prices)
{
Console.WriteLine(pair.Key + ": " + pair.Value + " €");
}
Salida
café: 18.50 €
miel: 12.00 €

var es solo para variables locales con un valor. El tipo se fija una vez y no cambia. Los campos, los parámetros y var x = null; no están permitidos.

Ejercicio

C# moderno

Resuelve las tareas en tu editor.

1. Figuras. Un abstract record Shape con tres subtipos sealed record: Circle(double Radius), Rectangle(double Width, double Height) y Triangle(double Base, double Height). Escribe Area(Shape) con una expresión switch y patrones posicionales, con un _ que lance ArgumentException. Escribe también Name(Shape), que dice «cuadrado» para un rectángulo con los lados iguales (con when). Imprime un círculo de radio 1, un rectángulo de 2 por 3, un cuadrado de 4 por 4, un triángulo de base 3 y altura 5, y el área total.
Salida
círculo: 3.14
rectángulo: 6.00
cuadrado: 16.00
triángulo: 7.50
Total: 32.64
2. Precio del envío. Un abstract record Delivery con ToOffice(string Office), ToAddress(string City, decimal WeightKg) y Pickup(). El precio: a una oficina 4,50 €; a domicilio 5,90 € en Madrid y 6,90 € en otros lugares, más 1 € por cada kilogramo por encima de 5; desde la tienda 0 €. Imprime cada envío (con el ToString del record) y su precio.
Salida
ToOffice { Office = Valencia Centro } -> 4.50 €
ToAddress { City = Madrid, WeightKg = 2 } -> 5.90 €
ToAddress { City = Valencia, WeightKg = 8 } -> 9.90 €
Pickup { } -> 0.00 €
3. Un punto como clave. El mensajero pasa por puntos del mapa: (2, 3), (5, 1), (2, 3), (0, 0), (5, 1), (2, 3). Haz record struct Point(int X, int Y) y cuenta cuántas veces estuvo en cada punto en un Dictionary<Point, int> con visits.GetValueOrDefault(p) + 1. Al final, busca new Point(2, 3) en el diccionario.
Salida
Point { X = 2, Y = 3 }: 3
Point { X = 5, Y = 1 }: 2
Point { X = 0, Y = 0 }: 1
Visitas a (2, 3): 3

Más tareas

  • Un estudiante como record. Un record Student con las propiedades Name y Grade y un constructor que rechace una nota fuera del rango de 2 a 6, como Price. Ordena una lista de estudiantes con OrderBy(s => s.Grade).
  • Día de la semana. Reescribe la tarea «día de la semana» de la lección «Condiciones» con una expresión switch: 1 or 2 or 3 or 4 or 5 => "laborable".
  • Una carta. Con una cadena sin formato y $, haz una carta que confirme una reserva: nombre, fecha, número de invitados y precio en euros.

Test

Ponte a prueba

1. ¿Qué obtienes ya hecho con record Seat(int Row, int Number);?
2. ¿Por qué un record sirve como clave en un Dictionary y una clase normal sin nada añadido no?
3. ¿Qué ventaja tiene la expresión switch sobre el switch antiguo?
4. Payment es un abstract record con tres subtipos. ¿Por qué un switch sobre Payment tiene _ al final?
5. ¿Qué hace BankTransfer(var iban) when iban.StartsWith("ES") => ...?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·