Saltar al contenido principal

Programación con objetos

¿Cuál es la ventaja principal de la orientación a objetos? Ocultar la implementación. El resto de este tema explica cómo la OO consigue esa ocultación y qué principios hay detrás.

Principios básicos de la construcción de software (OO)

  • Abstracción: diferenciar el qué y el cómo.
  • Modularidad: componentes, módulos (en OO, clases y objetos), interfaces, etc.
  • Maximizar la cohesión: módulos auto-contenidos, independientes y con un único propósito.
  • Minimizar el acoplamiento: reducir las dependencias entre módulos.

Estos cuatro principios son el hilo conductor de todo el tema: cada decisión de diseño que veamos a continuación se puede leer en términos de cuánta cohesión gana o cuánto acoplamiento introduce.

Cohesión y acoplamiento: evolución de un TAD Lista

Para ver estos principios en acción, vamos a criticar y evolucionar cuatro versiones sucesivas de un tipo abstracto de datos List en Java. El código de cada versión es deliberadamente esquemático (los cuerpos de los métodos se omiten con { ... }) porque el objetivo no es ejecutarlo, sino discutir su diseño antes de seguir a la siguiente versión.

Versión inicial: Lista v0.1

public abstract class List<T> {
  public void addFirst(T value) { ... };
  public void removeFirst() { ... };
  public void addLast(T value) { ... };
  public void removeLast() { ... };
  public T first() { ... };
  public T last() { ... };
  public boolean isEmpty() { ... };
  public int length() { ... };
  public List<T> clone() { ... };
  public boolean isEqualTo(List<T>) { ... };
  public abstract void traverse();
  // etc...
}

La clase abstracta List<T> sí diferencia entre el qué y el cómo — qué hace la lista frente a cómo se almacenan los elementos —, así que cumple con la abstracción. Pero:

  • List<T> aglutina más de una responsabilidad: almacenar y recorrer. La implementación no parece cohesionada.
  • El método traverse() da una interfaz a quien implemente el recorrido de la lista, pero ¿para hacer qué exactamente con cada elemento?
  • Si hay distintas implementaciones de traverse(), y llegamos a tener varias versiones de la lista, introducimos más dependencias (acoplamiento).

Problemas de v0.1: cohesión baja; variabilidad no bien tratada → poca flexibilidad.

Cohesion refers to the degree to which the elements inside a module belong together.

— E. Yourdon & L. Constantine, Structured Design: Fundamentals of a Discipline of Computer Program and Systems Design. Prentice Hall, 2nd edition, 1986.

Implementación alternativa: Lista v0.2

Si hace falta crear nuevos tipos de recorrido, una opción es ampliar la interfaz:

public interface List<T> {
  public void addFirst(T value);
  public void removeFirst();
  public void addLast(T value);
  public void removeLast();
  public T first();
  public T last();
  public boolean isEmpty();
  public int length();
  public List<T> clone();
  public boolean isEqualTo(List<T>);

  public void traverseForward();
  public void traverseBackWard();
  public void traverseEvens(); // pares
  public void traverseOdds();  // impares
}
  • Si hay que cambiar la operación básica que hace traverse() con cada elemento (imprimir, sumar, etc.), ¿cuántos métodos hay que cambiar? Hay muchas dependencias.
  • Cuanta más variedad de recorridos tenga la interfaz, menos flexibilidad para los cambios.

Problemas de v0.2: acoplamiento excesivo (muchas dependencias, provocadas por el exceso de herencia); flexibilidad escasa.

Implementación alternativa: Lista v0.3

Otra opción es delegar la funcionalidad hacia las subclases, vía herencia:

class ListForward<T> extends List<T> {
  //...
  public void traverse() { // recorrer hacia adelante
  };
}
class ListBackward<T> extends List<T> {
  //...
  public void traverse() { // recorrer hacia atras
  };
}
  • ¿Hay que especializar de nuevo para cada tipo de operación que hace traverse() con cada elemento individual (imprimir, sumar, etc.)?
  • ¿Y si hay que especializar de nuevo el recorrido — solo los pares, solo los impares, etc.?

Problemas de v0.3: acoplamiento elevado (si hay que crear nuevos tipos de recorrido, se abusará de la herencia para crear estructuras complejas); variabilidad mal tratada → poca flexibilidad, baja reutilización.

¿Cómo se resuelve esto en las bibliotecas típicas que ya conocéis (C++ STL, Java Collections, etc.)? Con iteradores.

Implementación alternativa: Lista v0.4 — delegar hacia otra clase

public interface List<T> {
  void addFirst(T value);
  void removeFirst();
  void addLast(T value);
  void removeLast();
  T first();
  T last();
  boolean isEmpty();
  int length();
  List<T> clone();
  boolean isEqualTo(List<T>);
  Iterator<T> iterator();
}

Las ventajas de esta versión son claras:

  • Mayor cohesión: las responsabilidades están ahora separadas — List almacena, Iterator recorre.
  • Para hacer List más cohesionada hemos tenido que introducir una dependencia (acoplamiento) hacia Iterator.
  • Se ha usado delegación (o composición) en lugar de herencia: la responsabilidad de recorrer se ha delegado hacia otro sitio.

Resumen de problemas

Problemadesde v0.1 ...... hasta v0.4
CohesiónBaja: List<T> aglutina almacenamiento y recorridoAlta: List<T> solo almacena; Iterator<T> recorre
VariabilidadNo tratada: difícil cambiar la forma de recorrerFácil crear nuevos Iterator sin tocar List
FlexibilidadPoca: cambios en recorrido afectan a ListMayor: cambios aislados en Iterator
AcoplamientoAlto: List depende de cómo se recorreBajo: separación clara de responsabilidades

Ocultar la implementación

Los principios aplicados en la evolución anterior han sido, en definitiva, los cuatro principios básicos: abstracción (diferenciar el qué del cómo), maximizar la cohesión, minimizar el acoplamiento y usar la modularidad (clases, interfaces, componentes).

Cuando los componentes están aislados, puedes cambiar uno sin preocuparte por el resto. Mientras no cambies las interfaces externas, no habrá problemas en el resto del sistema.

— E. Yourdon & L. Constantine, Structured Design.

La modularidad consiste en reducir el acoplamiento usando módulos o componentes con distintas responsabilidades, agrupados en bibliotecas.

Técnicas de ocultación

  • Encapsular: agrupar en módulos y clases.
  • Visibilidad: public, private, protected, etc.
  • Delegación: incrementar la cohesión extrayendo funcionalidad pensada para otros propósitos fuera de un módulo.
  • Herencia: delegar en vertical.
  • Polimorfismo: ocultar la implementación de un método, manteniendo la misma interfaz de la clase base.
  • Interfaces: usar interfaces bien documentadas.

Herencia

La herencia sirve, ante todo, para reutilizar la interfaz: la clase base y la derivada son del mismo tipo, y todas las operaciones de la base están también disponibles en la derivada.

También distingue entre redefinir y reutilizar el comportamiento:

  • Overriding (redefinición o sobreescritura): cambio de comportamiento.
  • Overloading (sobrecarga): cambio de interfaz.

Y entre dos formas de heredar:

  • Herencia pura: mantiene la interfaz tal cual (relación es-un).
  • Extensión: amplía la interfaz con nuevas funcionalidades (relación es-como-un). Puede causar problemas de casting, como veremos más adelante.

When you inherit, you take an existing class and make a special version of it. In general, this means that you're taking a general-purpose class and specializing it for a particular need. [...] it would make no sense to compose a car using a vehicle object — a car doesn't contain a vehicle, it is a vehicle. The is-a relationship is expressed with inheritance, and the has-a relationship is expressed with composition.

— Bruce Eckel

Polimorfismo

El polimorfismo es el fenómeno por el que, al llamar a una operación de un objeto del que no se sabe su tipo específico, se ejecuta el método adecuado de acuerdo con su tipo real. Se basa en el enlace dinámico (dynamic binding): el método a ejecutar se elige en tiempo de ejecución, en función del tipo del objeto.

Overriding

En general, en un lenguaje OO es posible sobreescribir o redefinir (override) los métodos heredados de una superclase. En algunos lenguajes es obligatorio (en otros, recomendado) especificar explícitamente cuándo un método se redefine. Veámoslo en varios lenguajes.

Ejemplo 1: Override en Scala

class Complejo(real: Double, imaginaria: Double) {
  def re = real
  def im = imaginaria
  override def toString() =
    "" + re + (if (im < 0) "" else "+") + im + "i"
}

object Test {
  def main(args: Array[String]): Unit = {
    println(new Complejo(1.2, 3.4))
  }
}

Cuando se redefine un método abstracto, override no es necesario en Scala. Pero si se quiere redefinir un método concreto (como toString, que ya tiene implementación en AnyRef), override es obligatorio, precisamente para evitar sobreescrituras accidentales. En Scala el riesgo de redefinición accidental es mayor por el uso de mixins (trait).

Un trait separa las dos responsabilidades principales de una clase: definir el estado de sus instancias y definir su comportamiento. Las clases y objetos de Scala pueden extender un trait (similares a las interface de Java); no pueden instanciarse por sí solos, no tienen estado propio (usan el de la instancia a la que se aplican), y los métodos definidos en una clase tienen precedencia sobre los de un trait.

Ejemplo 2: un iterador con Scala traits

trait Iterador[A] {
  def haySiguiente: Boolean
  def siguiente(): A
}

class IteradorEnteros(hasta: Int) extends Iterador[Int] {
  private var actual = 0
  override def haySiguiente: Boolean = actual < hasta
  override def siguiente(): Int = {
    if (haySiguiente) {
      val t = actual
      actual += 1
      t
    } else 0
  }
}

object Test {
  def main(args: Array[String]): Unit = {
    val iterador = new IteradorEnteros(10)
    println(iterador.siguiente()) // 0
    println(iterador.siguiente()) // 1
  }
}

¿Y en Java no hay traits? Desde Java 8, las interfaces pueden incorporar métodos por defecto que las acercan al comportamiento de un trait, y sirven para implementar herencia múltiple.

Ejemplo 3: @Override en Java

El siguiente ejemplo es, en realidad, la implementación de un diseño incorrecto: hay una doble dependencia entre Real y Complejo, y la frontera entre diseño e implementación queda aquí un poco difusa. Aun así es muy útil para entender qué aporta @Override:

class Real {
  double re;
  public Real(double real) {
    re = real;
  }
  public double getRe() { return re; }
  // Prueba a comentar el siguiente método manteniendo el
  // @Override de Complejo.sum(Real other)
  public Real sum(Real other) {
    return new Real(re + other.getRe());
  }
  // Prueba a comentar el siguiente método manteniendo el
  // @Override de Complejo.sum(Complejo other)
  public Complejo sum(Complejo other) {
    return new Complejo(re + other.getRe(), other.getIm());
  }
  public String toString() {
    return String.format("%.1f", re);
  }
}

class Complejo extends Real {
  double im;
  public Complejo(double real, double imaginaria) {
    super(real);
    im = imaginaria;
  }
  @Override
  public Complejo sum(Real other) {
    return new Complejo(re + other.getRe(), im);
  }
  @Override
  public Complejo sum(Complejo other) {
    return new Complejo(re + other.getRe(), im + other.getIm());
  }
  public Double getIm() { return im; }
  public String toString() {
    return String.format("%.1f", re) + ((im < 0) ? "" : "+") +
        String.format("%.1f", im) + "i";
  }
}

public class Main {
  public static void main(String args[]) {
    Real r = new Real(7.6);
    Complejo c = new Complejo(1.2, 3.4);
    System.out.println("Número real: " + r);
    System.out.println("Número complejo: " + c);
    System.out.println("Número complejo: " + c.sum(r));
    System.out.println("Número complejo: " + r.sum(c));
  }
}

Prueba a comentar alguno de los métodos sum de Real (indicado en el propio código) manteniendo el @Override correspondiente en Complejo: verás un error de compilación, porque sin ese método en la clase base ya no hay nada que redefinir. Y si quitas @Override de un método que en realidad no redefine nada (por ejemplo, porque cambiaste su firma sin querer), Java compilará igualmente — pero habrás hecho un overload accidental en lugar de un override. Esa es la razón de ser de la anotación: convierte un error silencioso de diseño en un error de compilación.

Ejemplo 4: Override en C#

DescribeCar muestra una descripción básica de un coche y llama a ShowDetails para información adicional; cada clase define su propia versión de ShowDetails, usando los modificadores new y override:

class Car
{
  public void DescribeCar()
  {
    System.Console.WriteLine("Four wheels and an engine.");
    ShowDetails();
  }

  public virtual void ShowDetails()
  {
    System.Console.WriteLine("Standard transportation.");
  }
}

class ConvertibleCar : Car
{
  public new void ShowDetails()
  {
    System.Console.WriteLine("A roof that opens up.");
  }
}

class Minivan : Car
{
  public override void ShowDetails()
  {
    System.Console.WriteLine("Carries seven people.");
  }
}

class Program
{
  static void TestCars1()
  {
    System.Console.WriteLine("\nTestCars1\n----------");

    var cars = new System.Collections.Generic.List<Car> {
      new Car(),
      new ConvertibleCar(),
      new Minivan() };

    foreach (var car in cars)
    {
      car.DescribeCar();
      System.Console.WriteLine("----------");
    }
  }

  static void TestCars2()
  {
    System.Console.WriteLine("\nTestCars2\n----------");
    ConvertibleCar car2 = new ConvertibleCar();
    Minivan car3 = new Minivan();
    car2.ShowDetails();
    car3.ShowDetails();
  }

  static void TestCars3()
  {
    System.Console.WriteLine("\nTestCars3\n----------");
    Car car2 = new ConvertibleCar();
    Car car3 = new Minivan();
    car2.ShowDetails();
    car3.ShowDetails();
  }

  static void Main()
  {
    TestCars1();
    TestCars2();
    TestCars3();
  }
}

TestCars1 produce esta salida:

TestCars1
----------
Four wheels and an engine.
Standard transportation.
----------
Four wheels and an engine.
Standard transportation.
----------
Four wheels and an engine.
Carries seven people.
----------

¿Son los resultados esperados? El tipo del segundo objeto de la lista es ConvertibleCar, pero DescribeCar no accede a la versión de ShowDetails definida en ConvertibleCar, precisamente por usar new. El tipo del tercer objeto es Minivan, que sí redefine con override el método declarado en la clase base — y por eso su salida cambia.

TestCars2 y TestCars3 lo confirman:

TestCars2
----------
A roof that opens up.
Carries seven people.

TestCars3
----------
Standard transportation.
Carries seven people.

En TestCars2 el tipo de los objetos creados coincide con el tipo declarado. En TestCars3, el tipo declarado es la clase base Car. La diferencia entre new y override en C# es que new oculta la implementación de la clase base, mientras que override la extiende de verdad (enlace dinámico).

Tipado

  • Tipado estático vs. dinámico: en el tipado estático, el tipo de cada variable se conoce en tiempo de compilación; en el dinámico (duck typing), se conoce en tiempo de ejecución.
  • Contrato nominal vs. estructural: en el nominal hay que escribir un tipo explícito; en el estructural, el tipo se deduce de la estructura de métodos que tiene el objeto.

Moldes o casting de tipos

  • Upcasting: interpretar un objeto de una clase derivada como del mismo tipo que la clase base.
  • Downcasting: interpretar un objeto de una clase base como del mismo tipo que una de sus clases derivadas.

Ejemplo de casting: Aventura v0.1

class PersonajeDeAccion {
  public void luchar() { System.out.println("Luchando..."); }
}

class Heroe extends PersonajeDeAccion {
  public void volar() { System.out.println("Volando..."); }
}

class Creador {
  PersonajeDeAccion[] personajes() {
    PersonajeDeAccion[] x = {
        new PersonajeDeAccion(),
        new PersonajeDeAccion(),
        new Heroe(), // Upcast implícito: se guarda como PersonajeDeAccion
        new PersonajeDeAccion()
    };
    return x;
  }
}

public class Main {
  public static void main(String[] args) {
    PersonajeDeAccion[] cuatroFantasticos = new Creador().personajes();
    cuatroFantasticos[1].luchar();
    cuatroFantasticos[2].luchar(); // Ya es un PersonajeDeAccion (upcast al crearlo)

    // En tiempo de compilación no existe el método volar() en PersonajeDeAccion:
    // cuatroFantasticos[2].volar();
    ((Heroe) cuatroFantasticos[2]).volar(); // Downcast correcto
    try {
      ((Heroe) cuatroFantasticos[1]).volar(); // Downcast incorrecto
    } catch (ClassCastException e) {
      System.out.println("ClassCastException al hacer downcast de " + cuatroFantasticos[1]);
    }
  }
}

¿De qué tipo son realmente los personajes del array? Solo se sabe en tiempo de ejecución, y cualquier downcasting a Heroe puede fallar con ClassCastException si el objeto real no lo es. Es un diseño inseguro que conviene rediseñar.

Aventura v0.2 (Java) frente a v0.3 (C++): el mismo problema, dos formas de tipado

Java resuelve el problema anterior separando responsabilidades en interfaces (SabeLuchar, SabeNadar, SabeVolar) que Heroe declara explícitamente implementar — tipado nominal. C++20 puede expresar exactamente la misma idea con concepts, sin que Heroe tenga que declarar nada explícito — basta con que tenga los métodos requeridos: tipado estructural.

interface SabeLuchar {
  void luchar();
}
interface SabeNadar {
  void nadar();
}
interface SabeVolar {
  void volar();
}
class PersonajeDeAccion {
  public void luchar() { System.out.println("Luchando (PersonajeDeAccion)"); }
}
class Heroe extends PersonajeDeAccion
    implements SabeLuchar, SabeNadar, SabeVolar {
  public void nadar() { System.out.println("Nadando"); }
  public void volar() { System.out.println("Volando"); }
}

public class Main {
  static void t(SabeLuchar x) { x.luchar(); }
  static void u(SabeNadar x) { x.nadar(); }
  static void v(SabeVolar x) { x.volar(); }
  static void w(PersonajeDeAccion x) { x.luchar(); }

  public static void main(String[] args) {
    Heroe i = new Heroe();
    t(i); // Tratado como SabeLuchar
    u(i); // Tratado como SabeNadar
    v(i); // Tratado como SabeVolar
    w(i); // Tratado como PersonajeDeAccion
  }
}

En la versión Java, t, u, v y w aceptan solo los tipos que declaran explícitamente implementar la interfaz correspondiente (o heredar de la clase). En la versión C++, t, u, v usan concepts (tipado estructural): aceptan cualquier tipo que satisfaga la forma requerida, se resuelva en tiempo de compilación y sin necesidad de casting — mientras que w sigue usando herencia clásica con tipado nominal, igual que en Java.

Uso de la herencia

Conviene distinguir dos ejes distintos:

  • Herencia de interfaz vs. herencia de comportamiento (o implementación): en Java, implements es herencia de interfaz y extends es herencia de interfaz más implementación. ¿Hay herencia solo de comportamiento? Pista: pensar en la herencia privada de C++.
  • Herencia como tipo vs. herencia como estructura: en la herencia de tipos, cada subclase es un subtipo, y debe cumplirse el principio de sustitución de Liskov (LSP): toda operación que funciona para un objeto de la clase CC también debe funcionar para un objeto de una subclase de CC. Usar la herencia solo para estructurar programas (sin que haya una auténtica relación es-un) es erróneo, porque no se satisface LSP — como veremos a continuación.

¿El polimorfismo está ligado siempre a la herencia? No: también existe el polimorfismo paramétrico, mediante tipos genéricos (Ada, C++ generics, Java generics desde JDK 1.5, Scala...). Eso sí, con diferencias entre lenguajes: en C++ los genéricos permiten meta-programación en tiempo de compilación, mientras que en Java los generics son sobre todo wrappers que moldean objetos (syntactic sugar, por type erasure).

Usos incorrectos de la herencia

Mal ejemplo 1 (Java): herencia como estructura

class Account {
  float balance;
  float getBalance() { return balance; }
  void transferIn(float amount) { balance += amount; }
}

class VerboseAccount extends Account {
  void verboseTransferIn(float amount) {
    super.transferIn(amount);
    System.out.println("Balance: " + balance);
  }
}

class AccountWithFee extends VerboseAccount {
  float fee = 1;
  void transferIn(float amount) { super.verboseTransferIn(amount - fee); }
}

public class Main {
  static void f(Account a) {
    float before = a.getBalance();
    a.transferIn(10);
    float after = a.getBalance();
    System.out.println("before=" + before + " after=" + after);
  }

  public static void main(String[] args) {
    System.out.println("Con Account:");
    f(new Account());
    System.out.println("Con AccountWithFee:");
    f(new AccountWithFee());
  }
}

Todos los objetos a de la clase Account deben cumplir que, si b = a.getBalance() antes de ejecutar a.transferIn(s) y b' = a.getBalance() después, entonces b + s = b'. Sin embargo, con la estructura AccountWithFee < VerboseAccount < Account, un objeto AccountWithFee no se comporta así cuando se trata como un Account: al ejecutar el ejemplo verás que para Account, before + 10 = after, pero para AccountWithFee, before + 10 ≠ after (en su lugar, before + 10 - fee = after). La cadena de herencia se usó para reutilizar código, no para expresar una relación es-un — y el resultado viola las expectativas de quien solo conoce la interfaz de Account.

Mal ejemplo 2 (Scala): herencia de implementación

abstract class Writer {
  def print(str: String): Unit
}

class ConsoleWriter extends Writer {
  override def print(str: String) = println(str)
}

class UppercaseWriter extends ConsoleWriter {
  override def print(str: String) =
    super.print(str.toUpperCase())
}

object Test {
  def main(args: Array[String]): Unit = {
    val writer = new UppercaseWriter
    writer.print("abc")
  }
}

Ahora queremos añadir un nuevo comportamiento — imprimir con espacios entre letras:

class WithSpacesWriter extends ConsoleWriter {
  override def print(str: String) =
    super.print(str.split("").mkString(" "))
}

¿Y si queremos combinar ambas formas de imprimir (mayúsculas y con espacios)? Con herencia de implementación no queda más remedio que crear una subclase por cada combinación:

class UppercaseWithSpacesWriter extends UppercaseWriter {
  override def print(str: String) =
    super.print(str.split("").mkString(" "))
}

class WithSpacesUppercaseWriter extends WithSpacesWriter {
  override def print(str: String) =
    super.print(str.toUpperCase())
}

Nótese que, para "abc", ambas clases producen el mismo resultado ("A B C"): poner en mayúsculas y separar caracteres con espacios son operaciones que conmutan en este caso concreto. El problema que ilustran estas dos clases no es que el orden cambie el resultado, sino que hace falta una subclase distinta por cada combinación — y ese número crece combinatoriamente en cuanto aparece una forma de imprimir más.

Y si aparece una nueva forma de imprimir (por ejemplo, con un checksum delante), hay que repetir la combinatoria para cada forma ya existente:

class ChecksumWriter extends ConsoleWriter {
  override def print(str: String) = {
    super.print(java.security.MessageDigest.getInstance("MD5")
      .digest(str.getBytes("UTF-8"))
      .map("%02x".format(_))
      .mkString("[", "", "] ") + str)
  }
}

El resultado de seguir combinando así es una jerarquía de herencia que crece combinatoriamente y se vuelve inmanejable. El diagrama extrapola cómo quedaría la jerarquía si se siguiera combinando Uppercase, WithSpaces y Checksum entre sí — no se ha mostrado en código cada una de esas combinaciones, solo las piezas sueltas (UppercaseWriter, WithSpacesWriter, ChecksumWriter) y dos combinaciones a modo de ejemplo:

Jerarquía de herencia fuera de control entre las variantes de Writer

¡Mal uso de la herencia! Cuantas más formas de imprimir se necesiten combinar, más subclases hacen falta — el problema es combinatorio, y es un síntoma claro de que la herencia se está usando para reutilizar implementación en lugar de para modelar una relación es-un.

Arreglo (Scala): herencia de interfaz con traits

La solución en Scala es usar traits apilables (stackable traits) en lugar de una cadena de subclases:

abstract class Writer {
  def print(str: String): Unit
}

class ConsoleWriter extends Writer {
  override def print(str: String) = println(str)
}

trait Uppercase extends Writer {
  abstract override def print(str: String) =
    super.print(str.toUpperCase())
}

trait WithSpaces extends Writer {
  abstract override def print(str: String) =
    super.print(str.split("").mkString(" "))
}

object Test {
  def main(args: Array[String]): Unit = {
    val writer1 = new ConsoleWriter with Uppercase
    writer1.print("abc")
    val writer2 = new ConsoleWriter with Uppercase with WithSpaces
    writer2.print("abc")
  }
}

Esto genera la salida:

ABC
A B C

Con stackable traits, cada combinación se construye componiendo traits en el momento de crear el objeto (with Uppercase with WithSpaces), en lugar de declarar una subclase nueva por cada combinación. Un trait puede llamar a super.metodo() con un override normal si ese método ya es concreto en la cadena de supertipos declarada por el trait. Aquí no lo es: Uppercase y WithSpaces extienden Writer, que declara print como abstracto — así que hace falta abstract override, precisamente para poder compilar una llamada a super.print confiando en que, en tiempo de ejecución, la linearization de clases (el orden en que Scala resuelve super cuando se mezclan varios traits) habrá colocado antes una implementación concreta (la de ConsoleWriter, mezclada al construir el objeto). abstract no sería necesario si el trait extendiera directamente una clase con print ya concreto. En términos de diseño, este patrón es una implementación del patrón Decorator, pero por composición de clases en vez de composición de objetos.

Implementación y diseño: rectángulos y cuadrados

Geométricamente, un cuadrado es-un rectángulo, así que la primera tentación es usar herencia pura:

public class Rectangle
{
  private Point topLeft;
  private double width;
  private double height;

  public double Width
  {
    get { return width; }
    set { width = value; }
  }

  public double Height
  {
    get { return height; }
    set { height = value; }
  }
}

public class Square : Rectangle
{
  // ...
}

Pero en informática un objeto Square no es-un objeto Rectangle: un Square no tiene propiedades independientes width y height. Si asumimos que no importa el desperdicio de memoria, Square heredará los accesores de Rectangle — así que probamos lo siguiente.

Rectángulos v0.2: ocultar con new

public class Square : Rectangle
{
  public new double Width
  {
    set {
      base.Width = value;
      base.Height = value;
    }
  }
  public new double Height
  {
    set {
      base.Height = value;
      base.Width = value;
    }
  }
}

El comportamiento de un Square no es consistente con el de un Rectangle:

Square s = new Square();
s.Width = 1;   // fija ambos, width y height
s.Height = 2;  // fija ambos, width y height

void f(Rectangle r)
{
  r.Width = 32; // llama a Rectangle.Width, no a Square.Width
}

¿Qué sucede si pasamos un Square a f? ¡No cambia Height! El modificador new de C# oculta el miembro de la clase base con una nueva implementación, pero solo se usa la versión de Square cuando el tipo declarado de la variable ya es Square — si el tipo declarado es Rectangle (como en el parámetro r de f), se llama siempre a la versión de Rectangle, sin enlace dinámico. Podría argumentarse que el error fue no declarar Width y Height como virtual en Rectangle.

Rectángulos v0.3: virtual/override en vez de new

public class Rectangle
{
  private Point topLeft;
  private double width;
  private double height;
  public virtual double Width
  {
    get { return width; }
    set { width = value; }
  }
  public virtual double Height
  {
    get { return height; }
    set { height = value; }
  }
}

La diferencia entre new y override en C# es que new oculta la implementación de la clase base, y override la extiende de verdad mediante enlace dinámico. Sin embargo, que crear una clase derivada obligue a cambiar la clase base (aquí, añadir virtual a Rectangle) ya es un síntoma de mal diseño: el principio LSP pone de manifiesto que la relación es-un tiene que ver con el comportamiento público extrínseco del que dependen los clientes.

Ahora parece que Square y Rectangle funcionan y quedan bien definidos matemáticamente. Pero consideremos esto:

void g(Rectangle r)
{
  r.Width = 5;    // el autor de g cree que r es un Rectangle
  r.Height = 4;   // y que Width y Height son independientes
  if (r.Area() != 20)
    throw new Exception("Bad area!");
}

¿Qué pasa si llamamos a g(new Square(3))? El autor de g asumió que cambiar el ancho de un rectángulo deja intacto el alto — un invariante razonable para un Rectangle. Si pasamos un Square, ese invariante no se cumple: violación de LSP. Al pasar una instancia de una clase derivada (Square) se altera el comportamiento definido por la clase base (Rectangle), de forma que g deja de funcionar correctamente.

¿Quién tiene la culpa? ¿Diseño o implementación?

  • ¿El autor de g, por asumir que "en un rectángulo, ancho y alto son independientes" (un invariante razonable)?
  • ¿El autor de Square, por violar ese invariante?
  • ¿De qué clase se ha violado realmente el invariante? — De Rectangle, no de Square.

Para evaluar si un diseño es apropiado no basta con mirar la solución por sí sola: hay que juzgarla en términos de los supuestos razonables que hagan los usuarios de ese diseño. La lección de este tema, en el fondo, es esa: la frontera entre diseño e implementación es difusa, pero los principios de abstracción, cohesión, acoplamiento y modularidad — y el respeto a LSP cuando se usa herencia — son la vara de medir en ambos lados de esa frontera.