En mi afán de aprender bien JavaScript, y ya de paso un poquito de inglés y un poquito de Ingeniería del Software, me he propuesto traducir una serie de artículos sobre los principios SOLID aplicados a JavaScript. El tercero de ellos es: SOLID JavaScript: The Liskov Substitution Principle.

De la misma serie: SOLID JavaScript

 

Esta es la tercera entrega de la serie “SOLID JavaScript” que explora los principios de diseño SOLID dentro del contexto del lenguaje JavaScript. En esta entrega, vamos a explicar el principio de sustitución de Liskov.

El principio de sustitución Liskov

El principio de sustitución Liskov se refiere a la interoperabilidad de los objetos dentro de una jerarquía de herencia. El principio declara:

Los subtipos deben ser sustituibles por sus tipos base.

En la programación orientada a objetos, la herencia proporciona un mecanismo para el intercambio de código dentro de una jerarquía de tipos relacionados. Esto se logra mediante un proceso de encapsulación de los datos y comportamientos comunes dentro de un tipo base, y luego definir tipos especializados en función del tipo base. Para cumplir con el principio de sustitución Liskov, un tipo derivado debe ser semánticamente equivalente a la conducta esperada de su tipo base.

Para ilustrar, considere el siguiente código:

function Vehicle(my) {

    my = my || {};
    my.speed = 0;
    my.running = false;

    this.speed = function() {
        return my.speed;
    };

    this.start = function() {
        my.running = true;
    };

    this.stop = function() {
        my.running = false;
    };

    this.accelerate = function() {
        my.speed++;
    };

    this.decelerate = function() {
        my.speed--;
    };

    this.state = function() {
        if (!my.running) {
            return "parked";
        }
        else if (my.running && my.speed) {
            return "moving";
        }
        else if (my.running) {
            return "idle";
        }
    };
}

Este listado muestra el constructor Vehicle que proporciona unas operaciones básicas para un objeto de tipo vehículo. Digamos que este constructor se está utilizando actualmente en producción por varios clientes. Ahora bien, consideramos que se nos pide añadir un nuevo constructor que represente a los vehículos de movimiento rápido. Después de pensarlo un poco, nos encontramos con el siguiente nuevo constructor:

function FastVehicle(my) {

    my = my || {};
   
    var that = new Vehicle(my);

    that.accelerate = function() {
        my.speed += 3;
    };

    return that;
}

Después de probar nuestro nuevo constructor FastVehicle en la ventana de la consola del navegador, estamos satisfechos de que todo funciona como se esperaba. Los objetos creados con FastVehicle aceleran 3 veces más rápido que antes y todos los métodos heredados funcionan según lo previsto. Seguros de que todo funciona como se espera, decidimos desplegar la nueva versión de la biblioteca. Sin embargo, al utilizar el nuevo tipo nos informan de que los objetos creados con el constructor FastVehicle rompen el código cliente existente. Aquí está el fragmento de código que muestra el problema:

var maneuver = function(vehicle) {

    write(vehicle.state());
    vehicle.start();
    write(vehicle.state());
    vehicle.accelerate();
    write(vehicle.state());
    write(vehicle.speed());
    vehicle.decelerate();
    write(vehicle.speed());
    if (vehicle.state() != "idle") {
        throw "The vehicle is still moving!";
    }
    vehicle.stop();
    write(vehicle.state());
};

Al ejecutar el código, vemos que se produce una excepción: “El vehículo está en movimiento”. Parece ser que el autor de esta función hace la suposición de que los vehículos siempre aceleran y desaceleran uniformemente. Los objetos creados a partir de nuestro FastVehicle no son completamente intercambiables por los objetos creados a partir de nuestro constructor del vehículo base. Nuestro FastVehicle incumple el principio de sustitución de Liskov!

En este punto, usted puede estar pensando: “Pero, el cliente no debería haber asumido que todos los vehículos se comportan de esa manera.” Irrelevante! Los incumplimientos del principio de sustitución de Liskov no se basan en si pensamos que los objetos derivados deben ser capaces de realizar ciertas modificaciones en el comportamiento, sino en si tales modificaciones se pueden hacer a la luz de las expectativas actuales.

En el caso de este ejemplo, la resolución del problema de incompatibilidad requiere un poco de rediseño en la biblioteca de vehículo, en los clientes que la consumen, o quizás en ambos.

Atenuando los incumplimientos LSP

Así que, ¿cómo podemos interceptar los incumplimientos al principio de sustitución de Liskov? Desafortunadamente, esto no siempre es posible. Para evitar los incumplimientos LSP al completo, te enfrentas a la difícil tarea de anticipar todas las posibles formas de como la biblioteca puede ser utilizada. Añádase a esto la posibilidad de que puedes no ser el autor original del código que se está pidiendo extender y puede que no tengas visibilidad de todas las formas de como se está utilizando actualmente la biblioteca. Dicho esto, hay algunas estrategias para evitar los incumplimientos dentro de su propio código.

Contratos

Una estrategia para interceptar los incumplimientos más graves del LSP es utilizar contratos. Los contratos se manifiestan de dos formas principales: especificaciones ejecutables y comprobación de errores. Con especificaciones ejecutables, el contrato de como una biblioteca particular está destinada a ser usada está contenido en un conjunto de pruebas automatizadas. El segundo enfoque consiste en incluir la comprobación de errores directamente en el código en forma de precondiciones, postcondiciones y chequeos. Esta técnica se conoce como Diseño Por Contrato, un término acuñado por Bertrand Meyer, creador del lenguaje de programación de Eiffel. Los temas sobre pruebas automatizadas y sobre el Diseño Por Contrato están más allá del alcance de esta serie, pero os dejamos las siguientes recomendaciones para cuándo usar cada uno.

  • Utilizar siempre el Desarrollo Guiado por Pruebas (TDD – Test Driven Development) para guiar el diseño de su propio código.
  • Opcionalmente utilizar técnicas de Diseño Por Contrato en el diseño de bibliotecas reutilizables.

Para el código que vas a mantener y que consumes tú mismo, el uso de técnicas de Diseño Por Contrato tienden simplemente a añadir un montón de ruido innecesario y aumentar los gastos generales de su código. Si tienes el control de los datos de entrada, un conjunto de pruebas sirve como un mejor control para especificar como una biblioteca está destinada a ser usada. Si eres el autor de bibliotecas reutilizables, el Diseño Por Contrato sirve tanto para protegerse contra el uso indebido y como herramienta de depuración para sus usuarios.

Evite la herencia

Otra estrategia para evitar incumplimientos LSP es reducir al mínimo el uso de la herencia cuando sea posible. En el libro Design Patterns: Elements of Reusable Object-Oriented Software por Gamma y otros, nos encontramos con los siguientes consejos:

Composición de los objetos mejor que herencia de clases

Mientras que algunos de los debates del libro sobre las ventajas de la composición sobre la herencia es relevante sólo para lenguajes de tipado estático, basados en la clase (por ejemplo, la imposibilidad de cambiar el comportamiento en tiempo de ejecución), para JavaScript uno de los temas relevantes es el del acoplamiento. Al utilizar la herencia, los tipos derivados están acoplado a sus tipos base. Esto significa que los cambios producidos en el tipo base pueden afectar sin darnos cuenta a los tipos derivados. Además, la composición tiende a producir objetos más pequeños y más específicos que son más fáciles de mantener tanto para lenguajes dinámicos como estáticos.

Es sobre comportamiento, no sobre herencia

Hasta ahora, hemos discutido el principio de sustitución de Liskov en el contexto de la herencia, lo cual es perfectamente apropiado dado que JavaScript es un lenguaje orientado a objetos. Sin embargo, la esencia del principio de sustitución de Liskov no está realmente preocupado por la herencia, sino por la compatibilidad en el comportamiento. JavaScript es un lenguaje dinámico, por lo tanto, el contrato del comportamiento de un objeto no está determinado por el tipo del objeto, sino por las capacidades esperadas por dicho objeto. Mientras que el principio de sustitución de Liskov fue concebido originalmente como un principio guiado por la herencia, es igualmente relevante para el diseño de objetos que se adhieren a interfaces implícitas.

Para ilustrar esto, consideremos un ejemplo tomado del libro Agile Software Development, Principles, Patterns, and Practices de Robert C. Martin: el ejemplo del rectángulo.

El ejemplo del rectángulo

Consideremos que tenemos una aplicación que utiliza un objeto rectángulo definido de la siguiente manera:

var rectangle = {
    length: 0,
    width: 0
};

Posteriormente, se determina que la aplicación también tiene que trabajar con un cuadrado. Basándose en el conocimiento de que un cuadrado es un rectángulo cuyos lados son iguales en longitud, decidimos crear un objeto cuadrado en lugar de usar el objeto rectángulo. Añadimos las propiedades de longitud y anchura para que coincida con la definición del objeto rectángulo, pero decidimos usar getters y setters para dichas propiedades de tal forma que podamos mantener los valores de longitud y anchura sincronizados, garantizando que se adhiera a la definición de un cuadrado:

var square = {};
(function() {
    var length = 0, width = 0;
    Object.defineProperty(square, "length", {
        get: function() { return length; },
        set: function(value) { length = width = value; }
    });
    Object.defineProperty(square, "width", {
        get: function() { return width; },
        set: function(value) { length = width = value; }
    });
})();

Desafortunadamente, un problema se descubre cuando la aplicación intenta utilizar nuestro cuadrado en lugar del rectángulo. Resulta que uno de los métodos calcula el área del rectángulo así:

var g = function(rectangle) {
    rectangle.length = 3;
    rectangle.width = 4;
    write(rectangle.length);
    write(rectangle.width);
    write(rectangle.length * rectangle.width);
};

Cuando el método se invoca con el cuadrado, el producto es 16 más que el valor esperado de 12. Nuestro objeto cuadrado incumple el principio de sustitución Liskov con respecto a la función g. En este caso, la presencia de las propiedades de longitud y anchura era un indicio de que nuestro cuadrado podría no llegar a ser 100% compatible con el rectángulo, pero no siempre existirán estos indicios evidentes. La corrección de esta situación probablemente requerirá de un rediseño de los objetos de forma y de la aplicación que los utiliza. Un enfoque más flexible podría ser la definición de rectángulos y cuadrados en términos de polígonos. En cualquier caso, lo importante a relucir de este ejemplo es que el principio de sustitución Liskov no es solamente relevante con la herencia, sino también con cualquier planteamiento en donde un comportamiento sea sustituido por otro.

La próxima vez, vamos a discutir el siguiente principio en el acrónimo SOLID: el princio de segregación de iterfaz.

Anuncios