Lección 5 de 8. Hasta ahora has usado tipos que ya existían: int, string, List. Aquí empiezas a crear los tuyos. Una cuenta bancaria, un libro o un pedido se describen con una clase o con un record, y elegir bien entre los dos, y entre ellos y un struct, es buena parte de escribir C# claro.
Una clase es un molde
Una clase describe un tipo de objeto: qué datos guarda y qué sabe hacer. Con new se crean instancias, objetos concretos hechos con ese molde. Los datos se exponen con propiedades, que por fuera parecen variables pero por dentro pueden tener lógica, y el comportamiento con métodos:
var cuenta = new CuentaBancaria("Ana");
cuenta.Ingresar(150m);
cuenta.Ingresar(40m);
Console.WriteLine($"{cuenta.Titular}: {cuenta.Saldo}");
Console.WriteLine(cuenta);
class CuentaBancaria
{
public string Titular { get; }
public decimal Saldo { get; private set; }
public CuentaBancaria(string titular)
{
Titular = titular;
}
public void Ingresar(decimal importe)
{
if (importe <= 0)
{
throw new ArgumentOutOfRangeException(nameof(importe));
}
Saldo += importe;
}
public override string ToString() => $"Cuenta de {Titular} con {Saldo}";
}Las clases van al final del archivo, después de las instrucciones de nivel superior; en un proyecto lo normal es poner cada una en su propio archivo.
salidaAna: 190 Cuenta de Ana con 190
Hay mucho en pocas líneas:
publicyprivatedeciden quién ve cada cosa. Lo que no se marca esprivate: solo lo ve la propia clase.Titularsolo tieneget: se puede leer desde fuera y solo se le puede dar valor en el constructor.Saldotieneprivate set: todo el mundo puede leerlo, pero solo la clase puede cambiarlo, y solo a través deIngresar, que valida el importe. Eso es la encapsulación: nadie puede dejar la cuenta con un saldo imposible.- El constructor es el método con el nombre de la clase y sin tipo de retorno; se ejecuta con cada
new. ToStringdecide cómo se ve el objeto al escribirlo. Sin él,Console.WriteLine(cuenta)escribiría solo el nombre de la clase.
Intentar saltarse la encapsulación no compila:
var cuenta = new CuentaBancaria("Luis");
cuenta.Saldo = 1_000_000m;dotnet buildset_privado.cs(2,1): error CS0272: La propiedad o el indizador 'CuentaBancaria.Saldo' no se pueden usar en este contexto porque el descriptor de acceso set es inaccesible
Inicializadores, init y required
Para objetos que son sobre todo datos, escribir un constructor con muchos parámetros es pesado. La alternativa es el inicializador de objetos: new seguido de llaves con las propiedades. Si una propiedad usa init en vez de set, solo se puede asignar ahí, al crear el objeto, y después queda fija. Y required obliga a darle valor:
var libro = new Libro
{
Titulo = "Rayuela",
Autor = "Julio Cortázar",
Paginas = 600,
};
Console.WriteLine($"{libro.Titulo}, de {libro.Autor} ({libro.Paginas} páginas)");
var corto = new Libro { Titulo = "Aura", Autor = "Carlos Fuentes" };
Console.WriteLine($"{corto.Titulo}: {corto.Paginas} páginas");
class Libro
{
public required string Titulo { get; init; }
public required string Autor { get; init; }
public int Paginas { get; init; }
}salidaRayuela, de Julio Cortázar (600 páginas) Aura: 0 páginas
El error que te ahorra: crear un objeto a medias
var libro = new Libro { Titulo = "Pedro Páramo" };
libro.Paginas = 124;dotnet buildfalta_required.cs(1,17): error CS9035: El miembro requerido 'Libro.Autor' debe establecerse en el inicializador de objeto o constructor de atributos. falta_required.cs(2,1): error CS8852: Solo se puede asignar la propiedad o el indizador de solo inicialización "Libro.Paginas" en un inicializador de objeto o en "this" o "base" en un constructor de instancia o un descriptor de acceso "init".
Dos errores en dos líneas. El primero: falta Autor, que es obligatorio, así que el libro quedaría con un autor null que reventaría más adelante, lejos de aquí. El segundo: Paginas es init, y ya no se puede cambiar una vez creado el objeto. Sin required, el objeto a medias se crearía sin quejarse.
Propiedades con lógica: field
Una propiedad automática, { get; set; }, guarda el valor sin más. Cuando hace falta validar o transformar, antes había que declarar un campo privado a mano para guardar el dato. Desde C# 14, la palabra field se refiere a ese campo que el compilador crea por ti:
var p = new Producto { Nombre = " teclado ", Precio = 25m };
Console.WriteLine($"[{p.Nombre}] {p.Precio}");
try
{
p.Precio = -3m;
}
catch (ArgumentOutOfRangeException ex)
{
Console.WriteLine(ex.Message);
}
class Producto
{
public string Nombre
{
get;
set => field = value.Trim();
} = "";
public decimal Precio
{
get;
set => field = value >= 0 ? value : throw new ArgumentOutOfRangeException(nameof(value), "El precio no puede ser negativo");
}
}salida[teclado] 25 El precio no puede ser negativo (Parameter 'value')
Dentro del set, value es el valor que se está asignando. El = "" detrás de Nombre le da un valor inicial, para que nunca sea null.
Constructores primarios y miembros static
Desde C# 12, los parámetros del constructor se pueden escribir directamente junto al nombre de la clase: es el constructor primario. Esos parámetros se ven en toda la clase, lo que ahorra el constructor clásico cuando solo copia valores. Y lo marcado con static pertenece a la clase, no a cada objeto: hay uno solo, compartido.
var a = new Pedido("teclado", 2);
var b = new Pedido("ratón", 1);
Console.WriteLine(a.Resumen());
Console.WriteLine(b.Resumen());
Console.WriteLine($"Pedidos creados: {Pedido.Creados}");
class Pedido(string articulo, int cantidad)
{
public static int Creados { get; private set; }
public int Numero { get; } = ++Creados;
public string Resumen() => $"Pedido {Numero}: {cantidad} x {articulo}";
}salidaPedido 1: 2 x teclado Pedido 2: 1 x ratón Pedidos creados: 2
Lo static se usa con el nombre de la clase, Pedido.Creados, no con un objeto. Ya lo conoces: Console.WriteLine, Math.Round e int.Parse son métodos estáticos.
Records: objetos que son datos
Muchas clases solo guardan datos: un punto, una dirección, una línea de un pedido. Para ellas C# tiene los records. En una línea declaran las propiedades (con init), el constructor, un ToString legible y, lo más importante, la igualdad por valor: dos records son iguales si sus datos lo son.
var c1 = new PersonaClase("Ana", 31);
var c2 = new PersonaClase("Ana", 31);
Console.WriteLine(c1 == c2);
var r1 = new Persona("Ana", 31);
var r2 = new Persona("Ana", 31);
Console.WriteLine(r1 == r2);
Console.WriteLine(r1);
class PersonaClase(string nombre, int edad)
{
public string Nombre { get; } = nombre;
public int Edad { get; } = edad;
}
record Persona(string Nombre, int Edad);salidaFalse
True
Persona { Nombre = Ana, Edad = 31 }Como sus propiedades son init, un record no se modifica: se crea uno nuevo con los cambios usando with. Y se puede deconstruir en variables sueltas, igual que una tupla:
var ana = new Persona("Ana", 31);
var mayor = ana with { Edad = 32 };
Console.WriteLine(ana);
Console.WriteLine(mayor);
var (nombre, edad) = mayor;
Console.WriteLine($"{nombre} tiene {edad}");salidaPersona { Nombre = Ana, Edad = 31 }
Persona { Nombre = Ana, Edad = 32 }
Ana tiene 32Los records encajan con todo lo que llega de fuera y no debería cambiar: filas de un CSV, respuestas de una API en JSON, mensajes entre partes de un programa. Lo verás en ejemplos como JSON en C# o CSV en C#.
struct: un tipo de valor propio
Las clases y los records son tipos de referencia. Un struct es como una clase pequeña, pero de valor: al asignarlo o pasarlo a un método se copia entero, como un int. Es lo que viste en el esquema de la lección 2, ahora con un tipo tuyo:
var p = new PuntoS { X = 1 };
var q = p;
q.X = 99;
Console.WriteLine($"struct: p.X = {p.X}, q.X = {q.X}");
var r = new PuntoC { X = 1 };
var t = r;
t.X = 99;
Console.WriteLine($"class: r.X = {r.X}, t.X = {t.X}");
struct PuntoS
{
public int X { get; set; }
}
class PuntoC
{
public int X { get; set; }
}salidastruct: p.X = 1, q.X = 99 class: r.X = 99, t.X = 99
Los struct convienen para valores pequeños e inmutables, como una coordenada o un color: se crean y destruyen muy rápido y no cargan al recolector de basura. Para casi todo lo demás, usa una clase o un record. También existe record struct, que combina las dos ideas. Lo que pasa al modificar un struct guardado en una lista es una de las rarezas de C#.
Resumen
| Para | Escribe | Recuerda |
|---|---|---|
| Un objeto con comportamiento | class Cuenta con métodos | private por omisión |
| Un dato que solo cambia la clase | { get; private set; } | la clase decide cómo cambia |
| Fijar un valor al crear | { get; init; } | se asigna con new X { ... } |
| Un dato obligatorio | required | sin él no compila el new |
| Validar al asignar | set => field = ... | field es de C# 14 |
| Algo compartido por todos | static | se usa con el nombre de la clase |
| Un objeto que es solo datos | record Persona(string Nombre, int Edad); | igualdad por valor; cambios con with |
| Un valor pequeño | struct | se copia al asignarlo |
Retos
Reto 1. Crea una clase Temperatura con una propiedad Celsius que no admita valores por debajo de -273.15 (lanza una excepción) y una propiedad calculada Fahrenheit, de solo lectura. Prueba con 25 grados y con -300.
Ver la solución
var t = new Temperatura { Celsius = 25 };
Console.WriteLine($"{t.Celsius} °C son {t.Fahrenheit} °F");
try
{
t.Celsius = -300;
}
catch (ArgumentOutOfRangeException)
{
Console.WriteLine("Por debajo del cero absoluto");
}
class Temperatura
{
public double Celsius
{
get;
set => field = value >= -273.15 ? value : throw new ArgumentOutOfRangeException(nameof(value));
}
public double Fahrenheit => Celsius * 9 / 5 + 32;
}salida25 °C son 77 °F Por debajo del cero absoluto
Fahrenheit no guarda nada: con => y sin set es una propiedad calculada, que se recalcula cada vez que se lee, así que nunca puede quedar desfasada respecto a Celsius.
Reto 2. Declara un record Producto con nombre y precio. Crea una lista con dos productos iguales y uno distinto, y cuenta cuántos productos distintos hay metiéndolos en un HashSet. ¿Qué pasaría si fuera una clase?
Ver la solución
List<Producto> carrito = [new("pan", 1.20m), new("pan", 1.20m), new("leche", 0.95m)];
var distintos = new HashSet<Producto>(carrito);
Console.WriteLine(distintos.Count);
record Producto(string Nombre, decimal Precio);salida2
Da 2, porque el record compara por valor y el HashSet usa esa igualdad. Con una clase daría 3: los dos panes serían objetos distintos. El new(...) sin nombre de tipo funciona porque la lista ya dice que son Producto.
Reto 3. Con el record Persona(string Nombre, int Edad), crea a Ana con 31 años y, con with, una copia con el nombre cambiado a «Eva». Comprueba que Ana no ha cambiado y que las dos no son iguales.
Ver la solución
var ana = new Persona("Ana", 31);
var eva = ana with { Nombre = "Eva" };
Console.WriteLine(ana);
Console.WriteLine(eva);
Console.WriteLine(ana == eva);
record Persona(string Nombre, int Edad);salidaPersona { Nombre = Ana, Edad = 31 }
Persona { Nombre = Eva, Edad = 31 }
Falsewith nunca toca el original: copia todas las propiedades y cambia solo las que indicas.
Todo el código de esta lección se compiló con el .NET SDK 10.0.403 (C# 14) y se ejecutó con .NET 10.0.14 en un contenedor limpio antes de publicarla; los mensajes del compilador y las salidas están copiados de esa ejecución. ¿Algo no te funciona? Revisa que tengas .NET 10: dotnet --version.
