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.
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)));libreFalseMarí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);}KeyNotFoundException: The given key 'Seat' was not present in the dictionary.La idea
record: una clase para datos
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);MaríaTrueSeat { Row = 5, Number = 12 }Fila 5, asiento 12Un 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:
RowyNumber. Se asigna solo al crear el objeto; - un constructor con todos los parámetros;
EqualsyGetHashCode, que comparan las propiedades. Por esoDictionaryahora encuentra a María. Los operadores==y!=también comparan las propiedades;ToStringcon la formaSeat { 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
- Un mapa vacío: la clave es un asiento del cine, el valor es un nombre.
- Add añade la clave Seat(5, 12) con el valor «María».
- GetValueOrDefault recibe un objeto Seat nuevo. Es otro objeto en la memoria, no la clave de Add.
- El GetHashCode de un record se calcula con las propiedades, así que es el mismo: la búsqueda va al mismo lugar.
- El Equals de un record compara propiedad por propiedad: fila 5 y 5, asiento 12 y 12. Son iguales, y GetValueOrDefault devuelve «María».
- ContainsKey hace lo mismo con otro Seat nuevo más: True.
- El record también escribe solo ToString y las propiedades Row y Number.
Más precisamente, para curiosos
withhace una copia del objeto y cambia solo las propiedades indicadas. El objeto anterior sigue igual. Unrecordposicional 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}");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 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
recordpuede tener sus propios métodos, propiedades y campos además de los posicionales. También cuentan enEquals. Puede extender otrorecord(lo usamos en la tarjeta de los pagos), pero no una clase normal, y puede implementar interfaces. Unsealed recordno se puede extender. - Un
recordes una clase: la variable apunta al objeto.record struct Point(int X, int Y);es un tipo por valor, comoint. 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. YEqualscompara 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" }));café, mielFalse- En el módulo «Colecciones» escribiste
EqualsyGetHashCodea mano. Conrecordya los tienes. Si necesitas otra regla de igualdad, todavía puedes escribir la tuya.
La idea
Comprobaciones y métodos en un record
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);}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.
WithDiscountno cambia el precio. Devuelve unPricenuevo. Así se trabaja con objetos que no cambian:coffeesigue en 18,50 €.Math.Round(..., 2)redondea al céntimo.decimalconserva las cifras después del punto decimal: sin redondear, 18.50 por 0.80 es 14 800.- Las propiedades aquí solo tienen
get. Así quewithno 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));a y b son dos objetos, pero el == de un record compara las propiedades, no las flechas, así que es True. GetHashCode también se calcula con las propiedades y es el mismo: justo lo que necesita Dictionary. Si las dos flechas apuntan al mismo objeto te lo dice ReferenceEquals: no, son dos objetos.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.
case "oficina": en la línea 5 no tiene código propio. En C# un caso con código no puede caer al siguiente: el código de Java sin break ni siquiera compila:
switch (method){ case "oficina": price = 4.50m; case "mensajero": price = 6.90m; break;}error CS0163: Control cannot fall through from one case label ('case "oficina":') to anotherLas etiquetas case vacías, en cambio, se acumulan: las etiquetas una debajo de otra comparten un mismo trozo de código. Por eso la oficina recibe el precio del mensajero. La solución es que la línea 5 tenga su propio código: price = 4.50m; y break;. Más claro todavía es un switch que devuelve un valor. Eso es la siguiente tarjeta.
La idea
Un switch que devuelve un valor
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 + " €");Envío: 4.50 €- El valor que se comprueba va antes de
switch. Cada caso espatrón => valor. No hay caída, y no se escribebreak. - Varios valores en un caso se unen con
or:"mensajero" or "domicilio". _atrapa todo lo demás, comodefault. 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, };}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"):
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));6.909.90Má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
switchantiguo concase "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
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));3.000.502.00CardPayment => ... 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, };}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);}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()));tarjeta ****4444transferencia desde una cuenta españolatransferencia desde el extranjerocontra reembolsoMá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 conrecord:
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));}La tarjeta termina en 4444Y con el nombre de la propiedad: 4444- Si el objeto es
null, no encaja con ningún tipo y llega a_. Sinulles una entrada con sentido, añadenull => ...antes de_. - Los patrones se pueden anidar:
Order(Customer(var name), var total).
Pruébalo
¿Qué caso gana?
¿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));when y llega al segundo. La tarjeta es por 650 €, y la confirmación hace falta por encima de 1000 €, así que gana el caso sin condición: «tarjeta».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 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);La tienda de la esquinaCafé 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 esWriteLine. - 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 + " €");}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. Unabstract 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.círculo: 3.14rectángulo: 6.00cuadrado: 16.00triángulo: 7.50Total: 32.64abstract 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.ToOffice { Office = Valencia Centro } -> 4.50 €ToAddress { City = Madrid, WeightKg = 2 } -> 5.90 €ToAddress { City = Valencia, WeightKg = 8 } -> 9.90 €Pickup { } -> 0.00 €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.Point { X = 2, Y = 3 }: 3Point { X = 5, Y = 1 }: 2Point { X = 0, Y = 0 }: 1Visitas a (2, 3): 3Más tareas
- Un estudiante como record. Un
record Studentcon las propiedadesNameyGradey un constructor que rechace una nota fuera del rango de 2 a 6, comoPrice. Ordena una lista de estudiantes conOrderBy(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
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Dictionary y HashSet · Códigos hash y funciones hash · Lambdas y delegados · LINQ
- Continúa en: Patrones de diseño · Bases de datos · Observer · Factory Method y Simple Factory
- Ejercicios sobre el tema: C# moderno