Hasta ahora cada clase iba por su cuenta. Esta lección va de cómo se relacionan: una clase puede heredar de otra y cambiar parte de su comportamiento, o comprometerse a cumplir una interfaz, un contrato que otras clases muy distintas también pueden cumplir. Después veremos los genéricos, el mecanismo que permite escribir una sola vez una lista, una pila o un método que funcione con cualquier tipo sin perder la revisión del compilador, y terminaremos con yield, la forma más sencilla de crear tus propias secuencias.
Herencia: virtual, override y base
Una clase puede heredar de otra poniendo dos puntos y el nombre de la clase base. La derivada recibe todos sus miembros y puede añadir otros. Lo interesante es que también puede cambiar los que la base haya marcado como virtual, escribiendo su propia versión con override:
Animal[] animales = [new Perro("Toby"), new Gato("Misi"), new Cachorro("Rufo"), new Animal("Bicho")];
foreach (var a in animales)
{
Console.WriteLine(a.Presentarse());
}
class Animal(string nombre)
{
public string Nombre { get; } = nombre;
public virtual string Sonido() => "...";
public string Presentarse() => $"{Nombre} dice {Sonido()}";
}
class Perro(string nombre) : Animal(nombre)
{
public override string Sonido() => "guau";
}
class Gato(string nombre) : Animal(nombre)
{
public override string Sonido() => "miau";
}
class Cachorro(string nombre) : Perro(nombre)
{
public override string Sonido() => base.Sonido() + " (bajito)";
}salidaToby dice guau Misi dice miau Rufo dice guau (bajito) Bicho dice ...
Fíjate en lo que ocurre: el array es de tipo Animal, y Presentarse() está escrito una sola vez, en la base. Pero al llamar a Sonido() desde dentro, .NET mira de qué clase es de verdad cada objeto y ejecuta su versión. Eso es el polimorfismo. base.Sonido() llama a la versión de la clase de arriba, y el constructor de cada clase derivada le pasa el nombre a su base con : Animal(nombre), usando los constructores primarios de la lección 5.
Al contrario que Java, en C# los métodos no son virtuales por omisión: si la base no pone virtual, la derivada no puede cambiarlo con override. Y en C# una clase solo puede heredar de una clase base.
Clases que no se pueden heredar: sealed
Si no quieres que nadie herede de una clase, márcala con sealed. Es lo que hace .NET con string, por ejemplo:
var gato = new Siames();
sealed class Gato
{
public string Sonido() => "miau";
}
class Siames : Gato
{
}dotnet buildsellada.cs(8,16): error CS0509: 'Siames': no puede derivar del tipo sellado 'Gato'
Clases abstractas
A veces la clase base no tiene sentido por sí sola: existe para que otras hereden de ella. Una clase abstracta no se puede instanciar, y puede declarar métodos abstract, sin cuerpo, que cada derivada está obligada a escribir:
Figura[] figuras = [new Circulo(1), new Rectangulo(2, 3)];
foreach (var f in figuras)
{
Console.WriteLine($"{f.GetType().Name}: área {f.Area():F2}");
}
abstract class Figura
{
public abstract double Area();
}
class Circulo(double radio) : Figura
{
public override double Area() => Math.PI * radio * radio;
}
class Rectangulo(double ancho, double alto) : Figura
{
public override double Area() => ancho * alto;
}salidaCirculo: área 3.14 Rectangulo: área 6.00
El :F2 dentro de la interpolación da formato al número con dos decimales. Y si intentas crear una Figura sin más, el compilador lo impide:
var f = new Figura();
abstract class Figura
{
public abstract double Area();
}dotnet buildabstracta_new.cs(1,9): error CS0144: No se puede crear una instancia de la interfaz o el tipo abstracto "Figura"
Interfaces: contratos
Una interfaz es una lista de miembros que una clase se compromete a tener, sin decir cómo. No guarda datos y no impone ninguna clase base, así que la pueden cumplir clases que no tienen nada que ver entre sí. Por convención, su nombre empieza por I:
List<IPagable> pagos = [new Factura(120.50m), new Suscripcion("Música", 9.99m)];
decimal total = 0;
foreach (var p in pagos)
{
Console.WriteLine(p.Describir());
total += p.Importe;
}
Console.WriteLine($"Total: {total} USD");
interface IPagable
{
decimal Importe { get; }
string Describir() => $"Pago de {Importe} USD"; // implementación por omisión
}
class Factura(decimal importe) : IPagable
{
public decimal Importe => importe;
}
class Suscripcion(string servicio, decimal cuota) : IPagable
{
public decimal Importe => cuota;
public string Describir() => $"Suscripción a {servicio}: {cuota} USD al mes";
}salidaPago de 120.50 USD Suscripción a Música: 9.99 USD al mes Total: 130.49 USD
Desde C# 8 una interfaz puede traer métodos con implementación por omisión, como Describir(): Factura no lo escribe y usa el de la interfaz, y Suscripcion pone el suyo. Un detalle que sorprende: ese método por omisión solo se ve a través de la interfaz. Con una variable de tipo Factura, factura.Describir() no compila; con una de tipo IPagable, sí.
El error que te ahorra: olvidar un miembro de la interfaz
IPagable recibo = new Recibo();
interface IPagable
{
decimal Importe { get; }
}
class Recibo : IPagable
{
public decimal Total => 15m; // se llama Total, no Importe
}dotnet buildinterfaz_falta.cs(8,16): error CS0535: 'Recibo' no implementa el miembro de interfaz 'IPagable.Importe'
La interfaz es una promesa, y el compilador comprueba que se cumpla entera. Sin él, el fallo aparecería al ejecutar, en el primer sitio que pidiera el Importe de un recibo, y en un lenguaje dinámico quizá ni eso: devolvería un valor vacío y seguiría adelante.
¿Clase abstracta o interfaz?
| Clase abstracta | Interfaz | |
|---|---|---|
| Cuántas puede tener una clase | una | todas las que quiera |
| Guarda datos (campos) | sí | no |
| Constructores | sí | no |
| Métodos con código | sí | sí, por omisión (desde C# 8) |
| Para qué | una familia de clases que comparten código | una capacidad que cumplen clases distintas |
La regla práctica: empieza por una interfaz. Si ves que varias clases repiten el mismo código y además son de verdad «una clase de» lo mismo, quizá te convenga una clase base abstracta.
Genéricos
Ya llevas cinco lecciones usando genéricos: List<string> o Dictionary<string, int> son clases escritas una sola vez con un parámetro de tipo, la T de List<T>, que se rellena al usarlas. Escribir los tuyos es igual de sencillo. Esta es una pila que sirve para cualquier tipo:
var pila = new Pila<string>();
pila.Apilar("uno");
pila.Apilar("dos");
Console.WriteLine(pila.Desapilar());
Console.WriteLine(pila.Cantidad);
class Pila<T>
{
private readonly List<T> elementos = [];
public int Cantidad => elementos.Count;
public void Apilar(T elemento) => elementos.Add(elemento);
public T Desapilar()
{
var ultimo = elementos[^1];
elementos.RemoveAt(elementos.Count - 1);
return ultimo;
}
}salidados 1
Una Pila<string> solo admite textos, y el compilador lo vigila igual que con una lista:
var pila = new Pila<string>();
pila.Apilar(3);
class Pila<T>
{
private readonly List<T> elementos = [];
public void Apilar(T elemento) => elementos.Add(elemento);
}dotnet buildpila_mal.cs(2,13): error CS1503: Argumento 1: no se puede convertir de 'int' a 'string'
Restricciones con where
Dentro de un genérico, el compilador no sabe nada de T: podría ser cualquier tipo. Si intentas comparar dos valores de tipo T con >, se queja, porque no todos los tipos se pueden comparar:
Console.WriteLine(Mayor(3, 7)); static T Mayor<T>(T a, T b) => a > b ? a : b;
dotnet buildmayor_mal.cs(3,32): error CS0019: El operador '>' no se puede aplicar a operandos del tipo 'T' y 'T'
La solución es una restricción: con where T : IComparable<T> le prometes al compilador que T será un tipo que sabe compararse consigo mismo. Ya puedes usar CompareTo, el método de esa interfaz que dice cuál va antes, y a cambio el método solo acepta tipos que lo cumplan:
Console.WriteLine(Mayor(3, 7));
Console.WriteLine(Mayor("pera", "manzana"));
Console.WriteLine(Mayor(2.5, -1.0));
static T Mayor<T>(T a, T b) where T : IComparable<T>
{
return a.CompareTo(b) >= 0 ? a : b;
}salida7 pera 2.5
| Restricción | Significa que T… |
|---|---|
where T : IComparable<T> | cumple esa interfaz (vale cualquier interfaz) |
where T : Animal | es Animal o una clase derivada |
where T : class | es un tipo de referencia |
where T : struct | es un tipo de valor que no admite null |
where T : new() | tiene un constructor sin parámetros: puedes hacer new T() |
where T : notnull | no admite null |
IComparable<T>: enseñar a ordenar tus tipos
List<T>.Sort() sabe ordenar números y textos, pero no tus clases: ¿cómo se ordenan dos alumnos? Si tu tipo implementa IComparable<T>, se lo dices tú. CompareTo devuelve un número negativo si este objeto va antes que el otro, cero si son iguales y positivo si va después:
List<Alumno> alumnos = [new("Luis", 8.5), new("Ana", 9.2), new("Eva", 7.9)];
alumnos.Sort();
foreach (var a in alumnos)
{
Console.WriteLine($"{a.Nombre} {a.Nota}");
}
record Alumno(string Nombre, double Nota) : IComparable<Alumno>
{
// de mayor a menor nota: se compara la del otro con la propia
public int CompareTo(Alumno? otro) => otro is null ? 1 : otro.Nota.CompareTo(Nota);
}salidaAna 9.2 Luis 8.5 Eva 7.9
Como Alumno ya cumple IComparable<Alumno>, también funcionaría con el Mayor<T> de antes. Para ordenar de varias formas distintas según el momento, sin tocar la clase, es más cómodo LINQ con OrderBy, que verás en la lección 7.
IEnumerable<T> y yield
IEnumerable<T> es la interfaz de «algo que se puede recorrer con foreach». La cumplen los arrays, las listas, los diccionarios… y la puedes cumplir tú sin escribir ninguna clase, con un método iterador: cada yield return entrega un elemento y deja el método en pausa hasta que se pide el siguiente.
foreach (var n in Pares(10))
{
Console.Write($"{n} ");
}
Console.WriteLine();
foreach (var f in Fibonacci())
{
if (f > 100) break;
Console.Write($"{f} ");
}
Console.WriteLine();
static IEnumerable<int> Pares(int hasta)
{
for (int i = 0; i <= hasta; i += 2)
{
yield return i;
}
}
static IEnumerable<long> Fibonacci()
{
long a = 0, b = 1;
while (true)
{
yield return a;
(a, b) = (b, a + b);
}
}salida0 2 4 6 8 10 0 1 1 2 3 5 8 13 21 34 55 89
Fibonacci() tiene un while (true) y aun así el programa termina: el iterador solo calcula el siguiente número cuando el foreach lo pide, y el break deja de pedirlos. La línea (a, b) = (b, a + b) intercambia los dos valores a la vez con tuplas. Esta pereza se ve mejor si el iterador avisa cada vez que trabaja:
var numeros = Contar(3);
Console.WriteLine("Antes del foreach");
foreach (var n in numeros)
{
Console.WriteLine($"Recibido {n}");
}
static IEnumerable<int> Contar(int hasta)
{
for (int i = 1; i <= hasta; i++)
{
Console.WriteLine($" generando {i}");
yield return i;
}
}salidaAntes del foreach generando 1 Recibido 1 generando 2 Recibido 2 generando 3 Recibido 3
Llamar a Contar(3) no ejecuta nada: «Antes del foreach» sale primero. El código del iterador corre por tramos, intercalado con el bucle. Es la misma idea en la que se basa LINQ, y la razón de una de sus rarezas: una consulta se vuelve a ejecutar cada vez que la recorres.
Resumen
| Para | Escribe | Recuerda |
|---|---|---|
| Heredar | class Perro : Animal | una sola clase base |
| Permitir cambiar un método | virtual en la base, override en la derivada | no son virtuales por omisión |
| Llamar a la versión de la base | base.Sonido() | |
| Impedir la herencia | sealed class | |
| Una base incompleta | abstract class y abstract en los métodos | no se puede hacer new |
| Un contrato | interface IPagable | una clase cumple todas las que quiera |
| Código para cualquier tipo | class Pila<T>, T Mayor<T>(...) | where dice qué puede hacer T |
| Que tu tipo se pueda ordenar | IComparable<T> | negativo, cero o positivo |
| Una secuencia propia | IEnumerable<T> con yield return | se calcula a medida que se recorre |
Retos
Reto 1. Añade a la jerarquía de figuras una clase Triangulo con base y altura, y comprueba que el bucle de Figura la recorre junto a las demás sin cambiar nada más.
Ver la solución
Figura[] figuras = [new Circulo(1), new Rectangulo(2, 3), new Triangulo(4, 5)];
foreach (var f in figuras)
{
Console.WriteLine($"{f.GetType().Name}: área {f.Area():F2}");
}
abstract class Figura
{
public abstract double Area();
}
class Circulo(double radio) : Figura
{
public override double Area() => Math.PI * radio * radio;
}
class Rectangulo(double ancho, double alto) : Figura
{
public override double Area() => ancho * alto;
}
class Triangulo(double @base, double altura) : Figura
{
public override double Area() => @base * altura / 2;
}salidaCirculo: área 3.14 Rectangulo: área 6.00 Triangulo: área 10.00
base es una palabra reservada; para usarla como nombre hay que escribir @base. También vale llamar al parámetro b.
Reto 2. Escribe un método genérico Contar<T> que reciba una secuencia y un valor y diga cuántas veces aparece. Pruébalo con números y con textos. Pista: para comparar dos T con Equals sin sorpresas con null, restringe T a IEquatable<T>.
Ver la solución
Console.WriteLine(Contar([1, 3, 1, 1, 2], 1));
Console.WriteLine(Contar(["sí", "no", "sí"], "sí"));
static int Contar<T>(IEnumerable<T> elementos, T buscado) where T : IEquatable<T>
{
int veces = 0;
foreach (var e in elementos)
{
if (e.Equals(buscado)) veces++;
}
return veces;
}salida3 2
Al pasar [1, 3, 1, 1, 2] a un parámetro IEnumerable<T>, la expresión de colección crea la secuencia y el compilador deduce que T es int.
Reto 3. Escribe un iterador CuentaAtras(int desde) que devuelva los números de desde a 1 y, al final, el texto «¡Despegue!». ¿Qué tipo de elemento tiene que devolver?
Ver la solución
foreach (var paso in CuentaAtras(3))
{
Console.WriteLine(paso);
}
static IEnumerable<string> CuentaAtras(int desde)
{
for (int i = desde; i >= 1; i--)
{
yield return i.ToString();
}
yield return "¡Despegue!";
}salida3 2 1 ¡Despegue!
Como mezcla números y un texto, la secuencia tiene que ser de string. Un iterador puede tener varios yield return, en bucles o fuera de ellos.
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.
