Interfaces y genéricos en C#

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 abstractaInterfaz
Cuántas puede tener una claseunatodas las que quiera
Guarda datos (campos)síno
Constructoressíno
Métodos con códigosísí, por omisión (desde C# 8)
Para quéuna familia de clases que comparten códigouna 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.

A la izquierda, Perro y Gato heredan de una única clase base Animal. A la derecha, la clase Suscripcion implementa dos interfaces a la vez, IPagable e IComparable<T>, que son contratos sin datosHerencia: una sola clase base. Interfaces: todas las que quierasHerencia (class Perro : Animal)Animalla clase basePerroGatoHeredan los miembros y puedencambiar los virtual con override.Interfaces (class X : IA, IB)IPagableun contratoIComparable<T>un contratoSuscripcionSe compromete a tener sus miembros,sin heredar ningún dato.

La herencia une una clase con una sola base, de la que recibe datos y código. Las interfaces son contratos: una clase puede cumplir varias a la vez. Esquema: decodigo.com.

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ónSignifica que T…
where T : IComparable<T>cumple esa interfaz (vale cualquier interfaz)
where T : Animales Animal o una clase derivada
where T : classes un tipo de referencia
where T : structes un tipo de valor que no admite null
where T : new()tiene un constructor sin parámetros: puedes hacer new T()
where T : notnullno 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

ParaEscribeRecuerda
Heredarclass Perro : Animaluna sola clase base
Permitir cambiar un métodovirtual en la base, override en la derivadano son virtuales por omisión
Llamar a la versión de la basebase.Sonido()
Impedir la herenciasealed class
Una base incompletaabstract class y abstract en los métodosno se puede hacer new
Un contratointerface IPagableuna clase cumple todas las que quiera
Código para cualquier tipoclass Pila<T>, T Mayor<T>(...)where dice qué puede hacer T
Que tu tipo se pueda ordenarIComparable<T>negativo, cero o positivo
Una secuencia propiaIEnumerable<T> con yield returnse 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.