En esta página

Herencia e interfaces en C#

14 min lectura TextoCap. 2 — OOP en C#

Herencia en C#

La herencia permite crear nuevas clases basadas en clases existentes, reutilizando y extendiendo su funcionalidad. En C#, una clase puede heredar de una sola clase (herencia simple), pero puede implementar múltiples interfaces.

// Sintaxis de herencia: clase Hija : ClasePadre
public class Animal
{
    public string Nombre { get; init; } = string.Empty;
    public string Sonido()  => "...";
    public virtual string Describir() => $"{Nombre} hace '{Sonido()}'";
}

public class Perro : Animal
{
    public new string Sonido() => "Guau";  // oculta (hide), no sobreescribe
}

public class Gato : Animal
{
    public override string Describir()     // sobreescribe el virtual
        => base.Describir() + " [es un gato]";
}

virtual, override y abstract

Palabra clave Significado
virtual El método puede sobreescribirse en subclases
override Sobreescribe un método virtual o abstracto de la clase base
abstract El método debe implementarse en subclases (clase debe ser abstracta)
sealed Impide que el método sea sobreescrito en subclases
new Oculta el miembro de la clase base (diferente a override)
public abstract class Figura
{
    // Abstract: SIN implementación, DEBE implementarse
    public abstract double Area();
    public abstract double Perimetro();

    // Virtual: CON implementación, PUEDE sobreescribirse
    public virtual void Dibujar()
        => Console.WriteLine($"Dibujando {GetType().Name}...");

    // Concreto: no puede sobreescribirse a menos que sea virtual
    public string Tipo() => GetType().Name;
}

public class Circulo : Figura
{
    public double Radio { get; init; }

    public override double Area()       => Math.PI * Radio * Radio;
    public override double Perimetro()  => 2 * Math.PI * Radio;

    // Sobreescribe y luego sella — nadie puede sobreescribir en subclases
    public sealed override void Dibujar()
        => Console.WriteLine($"○ Círculo de radio {Radio:F2}");
}

public class Rectangulo : Figura
{
    public double Ancho  { get; init; }
    public double Alto   { get; init; }

    public override double Area()       => Ancho * Alto;
    public override double Perimetro()  => 2 * (Ancho + Alto);
}

Polimorfismo

El polimorfismo permite tratar objetos de distintos tipos de forma uniforme a través de su tipo base o interfaz:

List<Figura> figuras = new()
{
    new Circulo    { Radio = 5 },
    new Rectangulo { Ancho = 4, Alto = 6 },
    new Circulo    { Radio = 3 },
};

double areaTotal = 0;
foreach (Figura f in figuras)
{
    f.Dibujar();                          // método sobreescrito específico
    areaTotal += f.Area();                // polimorfismo en acción
    Console.WriteLine($"  Área: {f.Area():F2}");
}
Console.WriteLine($"Área total: {areaTotal:F2}");

Interfaces

Una interfaz define un contrato sin implementación (salvo los métodos por defecto de C# 8+). Cualquier tipo que implemente la interfaz garantiza que tiene esos miembros:

public interface IRepositorio<T> where T : class
{
    Task<T?>          ObtenerPorIdAsync(int id);
    Task<List<T>>     ListarAsync();
    Task<T>           CrearAsync(T entidad);
    Task<T>           ActualizarAsync(T entidad);
    Task<bool>        EliminarAsync(int id);
}

public interface IValidable
{
    bool EsValido();
    IEnumerable<string> ObtenerErrores();
}

Implementación de múltiples interfaces

public class Empleado : IRepositorio<Empleado>, IValidable, IComparable<Empleado>
{
    public int    Id     { get; init; }
    public string Nombre { get; set; } = string.Empty;
    public decimal Salario { get; set; }

    // IValidable
    public bool EsValido()
        => !string.IsNullOrEmpty(Nombre) && Salario > 0;

    public IEnumerable<string> ObtenerErrores()
    {
        if (string.IsNullOrEmpty(Nombre)) yield return "Nombre es requerido";
        if (Salario <= 0) yield return "Salario debe ser positivo";
    }

    // IComparable<Empleado>
    public int CompareTo(Empleado? other)
        => other is null ? 1 : Nombre.CompareTo(other.Nombre);

    // IRepositorio<Empleado> — implementación simplificada
    public Task<Empleado?> ObtenerPorIdAsync(int id) => Task.FromResult<Empleado?>(null);
    public Task<List<Empleado>> ListarAsync() => Task.FromResult(new List<Empleado>());
    public Task<Empleado> CrearAsync(Empleado e) => Task.FromResult(e);
    public Task<Empleado> ActualizarAsync(Empleado e) => Task.FromResult(e);
    public Task<bool> EliminarAsync(int id) => Task.FromResult(true);
}

Métodos de interfaz por defecto (C# 8+)

public interface ILogger
{
    void Log(string mensaje, string nivel);

    // Método con implementación por defecto
    void LogInfo(string mensaje)    => Log(mensaje, "INFO");
    void LogWarning(string mensaje) => Log(mensaje, "WARNING");
    void LogError(string mensaje)   => Log(mensaje, "ERROR");
}

public class ConsoleLogger : ILogger
{
    // Solo necesita implementar el método abstracto
    public void Log(string mensaje, string nivel)
        => Console.WriteLine($"[{nivel}] {DateTime.Now:HH:mm:ss}{mensaje}");
}

ILogger logger = new ConsoleLogger();
logger.LogInfo("Aplicación iniciada");      // usa el default
logger.LogError("Error en el sistema");    // usa el default
logger.Log("Mensaje custom", "DEBUG");     // implementación directa

Principio de sustitución de Liskov

Una instancia de una subclase debe poder sustituir a su clase base sin romper el programa:

// MAL: viola Liskov — el cuadrado no es un rectángulo correcto
public class RectanguloMal
{
    public virtual int Ancho  { get; set; }
    public virtual int Alto   { get; set; }
    public int Area() => Ancho * Alto;
}

public class CuadradoMal : RectanguloMal
{
    public override int Ancho  { set => base.Ancho = base.Alto = value; }
    public override int Alto   { set => base.Ancho = base.Alto = value; }
}

// BIEN: modela la realidad con interfaces
public interface IForma       { double Area(); }
public class RectanguloBien  : IForma { public double Area() => Ancho * Alto; public double Ancho; public double Alto; }
public class CuadradoBien    : IForma { public double Area() => Lado * Lado;  public double Lado; }

Práctica

  1. Jerarquía de figuras: Crea abstract class Figura con Area() y Perimetro(). Implementa Circulo, Rectangulo y Triangulo. Crea una lista de figuras y calcula el área total.
  2. Interfaz IExportable: Define una interfaz IExportable con métodos ExportarCsv() y ExportarJson(). Impleméntala en una clase Reporte.
  3. Polimorfismo: Crea un método ImprimirFiguras(IEnumerable<Figura> figuras) que itere e imprima el tipo, área y perímetro de cada figura.

En la siguiente lección exploraremos los genéricos y las colecciones: List, Dictionary<K,V>, HashSet y cómo crear tus propios tipos genéricos.

Prefiere composición sobre herencia
La herencia crea acoplamiento fuerte. En muchos casos es mejor inyectar interfaces (composición) que heredar. Usa herencia cuando la relación sea genuinamente 'es-un' (un AutoElectrico ES un Vehiculo). Para comportamientos reutilizables, usa interfaces.
Interfaces vs clases abstractas
Usa interfaces cuando quieras definir un contrato que múltiples tipos no relacionados puedan implementar. Usa clases abstractas cuando compartas implementación base y exista una jerarquía clara. En C# puedes implementar múltiples interfaces pero solo heredar de una clase.
No abuses de la herencia profunda
Las jerarquías de herencia de más de 2-3 niveles se vuelven difíciles de mantener. El principio de Liskov (LSP) dice que una subclase debe poder sustituir a su clase base sin romper el comportamiento esperado.
// Clase base (abstracta)
public abstract class Vehiculo
{
    public string Marca  { get; init; }
    public string Modelo { get; init; }
    public int    Anio   { get; init; }

    protected Vehiculo(string marca, string modelo, int anio)
    {
        Marca  = marca;
        Modelo = modelo;
        Anio   = anio;
    }

    // Método abstracto — DEBE implementarse en subclases
    public abstract string TipoMotor();

    // Método virtual — PUEDE sobreescribirse
    public virtual string Describir()
        => $"{Marca} {Modelo} ({Anio}) — {TipoMotor()}";

    // Método sellado en subclase
    public string Identificador() => $"{Marca}-{Modelo}-{Anio}";
}

// Subclase concreta
public class AutoElectrico : Vehiculo
{
    public int CapacidadBateria { get; init; } // kWh

    public AutoElectrico(string marca, string modelo, int anio, int bateria)
        : base(marca, modelo, anio)
    {
        CapacidadBateria = bateria;
    }

    public override string TipoMotor() => "Eléctrico";

    // sealed — ninguna subclase puede sobreescribir esto
    public sealed override string Describir()
        => base.Describir() + $" | Batería: {CapacidadBateria} kWh";
}

var tesla = new AutoElectrico("Tesla", "Model 3", 2024, 82);
Console.WriteLine(tesla.Describir());
// Tesla Model 3 (2024) — Eléctrico | Batería: 82 kWh