Principios SOLID en PHP

Principios SOLID en PHP (con ejemplos)

SOLID es el acrónimo que da nombre a cinco de los principios básicos de diseño que más se usan cuando trabajamos bajo el paradigma de la programación orientada a objetos. Se trata de cinco reglas muy sencillas que han de cumplir tus clases si quieres desarrollar un software mucho más fácil de mantener y de extender, además de que tendrás un código más legible y limpio.

Creados por Robert C. Martin (Uncle Bob) y presentados en el libro Desarrollo ágil de software: principios, patrones y prácticas, SOLID está compuesto por los siguientes principios:

  • Single Responsibility Principle (Principio de responsabilidad única)
  • Open/Close Principle (Principio de abierto/cerrado)
  • Liskov Substitution Principle (Principio de sustitución de Liskov)
  • Interface Segregation Principle (Principio de segregación de la interfaz)
  • Dependeny Inversion Principle (Principio de inversión de dependencia)

Si estás en una fase que ya tienes ciertos conocimientos de PHP y tienes claros los conceptos de Programación Orientada a Objetos, es el momento idóneo para que empieces a conocer y practicar estos principios. Su práctica y uso temprano de ellas, hará incrementar tu calidad como desarrollador de forma exponencial y te permitirá crear código más fácil de mantener y escalable.

En este artículo os voy a explicar estos principios (uno por uno) con ejemplos sencillos en PHP muy fáciles de entender. ¡Vamos allá!

Principio de responsabilidad única (Single responsibility principle)

El principio de responsabilidad única nos dice:

Una clase sólo debe de tener un motivo para cambiar.

O sea, una clase debería de realizar una única tarea. En el momento que la clase es responsable de varias funcionalidades, comienza a haber un acoplamiento de funcionalidad (código poco resiliente a cambios).

Veamos un ejemplo de código para entenderlo mejor.

class Post {
    private $title;
    private $author;
    private $body;

    public function __construct($title, $author, $body)
    {
        $this->title = $title;
        $this->author = $author;
        $this->body = $body;
    }

    public function getTitle()
    {
        return $this->title;
    }

    public function getAuthor()
    {
        return $this->author;
    }
    public function getBody()
    {
        return $this->body;
    }

    public function formatJson() {
        return json_encode([
            'title' => $this->getTitle(),
            'author' => $this->getAuthor(),
            'body' => $this->getBody()
        ]);
    }
}

Tenemos una clase Post que representa una publicación de un blog, con sus campos título, autor y cuerpo. También tenemos de un método que nos permite recuperar sus valores codificados en una cadena json.

Aunque a primera vista puede parecer una clase de lo más razonable, podemos ver enseguida que estamos mezclando dos conceptos muy diferentes: La lógica de negocio de modelar un post y su lógica de presentación para volcar sus valores en un formato determinado. Estamos violando el Principio de Responsabilidad Única.

La solución pasa por separar ambas responsabilidades en clases distintas.

class Post {
    private $title;
    private $author;
    private $body;

    public function __construct($title, $author, $body)
    {
        $this->title = $title;
        $this->author = $author;
        $this->body = $body;
    }

    public function getTitle()
    {
        return $this->title;
    }

    public function getAuthor()
    {
        return $this->author;
    }
    public function getBody()
    {
        return $this->body;
    }
}

class JsonPostFormatter {
    public static function format(Post $post) {
        return json_encode([
            'title' => $post->getTitle(),
            'author' => $post->getAuthor(),
            'body' => $post->getBody()
        ]);
    }
}

$post = new Post('Symfony 5 is available', 'Fabien Potencier', 'Lorem ipsum...');

echo JsonPostFormatter::format($post);

Ahora el principio de responsabilidad única se cumple: La clase Post representa el modelo de un artículo de blog y la clase JsonPostFormatter un formateador para representarla en formato json. Son dos clases independientes con su propia responsabilidad única.

¿Qué ganamos? Clases más pequeñas, mejores de mantener, desacopladas, reutilizables y fáciles de testear.

Principio de abierto/cerrado (Open/closed principle)

El principio abierto/cerrado dice:

Las clases deben estar abiertas a la extensión pero cerradas a la modificación.

¿Qué significa esto? Que tenemos que ser capaces de extender el comportamiento de nuestras clases sin necesidad de modificar su código inicial. Esto supondrá que podremos seguir añadiendo nuevas funcionalidades con la seguridad de que no afectará al código existente actual.

Vamos a verlo con ejemplo sencillo:

class Snake {}

class Dog {}

class Octopus {}

class SumPawsCalculator
{
    private $animals;

    public function __construct(array $animals)
    {
        $this->animals = $animals;
    }

    public function getAnimals()
    {
        return $this->animals;
    }

    public function sum() {
        $paws = 0;
        foreach ($this->getAnimals() as $animal) {
            if(is_a($animal, 'Octopus')){
                $paws += 8;
            } elseif (is_a($animal, 'Dog')){
                $paws += 4;
            } elseif (is_a($animal, 'Snake')){
                $paws += 0;
            }
        }

        return $paws;
    }
}

$animals = [ new Snake(), new Dog(), new Octopus() ];

$sumPawsCalculator = new SumPawsCalculator($animals);

echo $sumPawsCalculator->sum();

Tenemos tres clases que representan animales (perro, pulpo y serpiente). Y otra clase que sirve para calcular la suma de las patas de un grupo de objetos de animales (SumPawsCalculator).

La lógica es bien sencilla: En función de la clase del animal evaluado en cada iteración del bucle, se añadirá al total sumado x número de patas (4 el perro, 8 el pulpo y 0 la serpiente).

Hasta aquí todo bien.

¿Pero qué ocurre si ahora se nos pide incorporar al sumatorio uno, dos o más clases de animales? Nos veremos obligados a modificar, de nuevo, la función sum de la clase SumPawsCalculator. Tendremos que añadir más condicionales ifelse o un switch enorme para evaluar todas las posibilidades.

En este caso, estamos violando el principio Open/Close: La clase no está cerrada a su modificación.

¿Cómo solucionamos esto? Haciendo uso del polimorfismo. En lugar de obligar a la clase principal a saber cómo realizar la operación, delegamos esa labor a las clases que la utiliza.

Veamos cómo quedaría.

interface animalInterface {
    public function paws();
}

class Snake implements animalInterface {
    public function paws()
    {
        return 0;
    }
}

class Dog implements animalInterface {
    public function paws()
    {
        return 4;
    }
}

class Octopus implements animalInterface {
    public function paws()
    {
        return 8;
    }
}

class SumPawsCalculator
{
    private $animals;

    public function __construct(array $animals)
    {
        $this->animals = $animals;
    }

    public function getAnimals()
    {
        return $this->animals;
    }

    public function sum() {
        $paws = 0;
        foreach ($this->getAnimals() as $animal) {
            $paws += $animal->paws();
        }

        return $paws;
    }
}

$animals = [ new Snake(), new Dog(), new Octopus() ];

$sumPawsCalculator = new SumPawsCalculator($animals);

echo $sumPawsCalculator->sum();

Creamos una interfaz llamada animalInterface que tendrán que implementar todas las clases de animales, con un método público que será el encargado de devolver el número de patas de dicho animal.

De esta forma, es sumamente sencillo añadir un nuevo animal para que sea evaluado en el sumatorio. Simplemente hay que crear la nueva clase de animal implementando la interfaz animalInterface y definir su método paws().

class Centipede implements animalInterface {

    public function paws()
    {
        return 100;
    }
}

Ahora sí que se cumple el principio Open/Close: La clase está abierta a su extensión y cerrada a su modificación.

Principio de sustitución de Liskov (Liskov substitution principle)

El principio de sustitución de Liskov nos dice:

Si S es una subclase de T, entonces los objetos de T podrían ser substituidos por objetos del tipo S sin alterar las propiedades del problema. Esto es, cada clase que hereda de otra puede usarse como su padre sin necesidad de conocer las diferencias entre ellas.

Aunque el enunciado pueda parecer un poco lioso, la idea principal es muy sencilla: las subclases deberían de poder ser sustituibles por la clase padre y mantener todo su funcionamiento sin alterarse.

Vamos a verlo con el ejemplo de las clases de rectángulos y cuadrados (muy típico para explicar este principio).

<?php

class Rectangle {
    protected $width;
    protected $height;

    public function __construct($width, $height)
    {
        $this->width = $width;
        $this->height = $height;
    }

    public function setWidth($w) {
        $this->width = $w;
    }

    public function setHeight($h) {
        $this->height = $h;
    }

    public function getArea() {
        return $this->height * $this->width;
    }
}

class Square extends Rectangle {
    public function __construct($side)
    {
        parent::__construct($side, $side);
    }

    public function setWidth($w) {
        $this->width = $w;
        $this->height = $w;
    }

    public function setHeight($h) {
        $this->height = $h;
        $this->width = $h;
    }
}


function client(Rectangle $rectangle)
{
    echo $rectangle->getArea() . "\n";
    $rectangle->setHeight(8);
    echo $rectangle->getArea() . "\n";
}

$rectangle = new Rectangle(10,5);
$square = new Square(10);

client($rectangle);
client($square);

Tenemos una clase para representar un rectángulo Rectangle con sus atributos width (ancho) y height (alto), acompañada de un método llamado getArea para obtener el total del área (ancho x alto). Dispone también de dos métodos set para actualizar los valores de width y height.

Además tenemos una clase llamada Square para representar un cuadrado, que extiende de Rectangle. Sabemos que un cuadrado no es más que un rectángulo cuyo ancho y alto es el mismo. Por tanto, para instanciar un objeto Square solo necesitamos de un valor el cual lo asignamos tanto al width como al height.

Para respetar la regla del cuadrado sobre-escribimos los métodos set para que los valores de ancho y alto sean siempre el mismo.

Hasta aquí todo claro.

Veamos qué ocurre en la función client.

Si pasamos como parámetro un rectángulo 10 x 5, la función getArea nos mostrará el área de forma correcta: 10 x 5 = 50. Y si, seguidamente modificamos su alto en 8, el área será de 10 x 8 = 80. Todo correcto.

Ahora vamos a probar si se cumple el principio Liskov: Si sustituimos el rectángulo por un cuadrado en la función client, la funcionalidad de la clase padre Rectangle debería respetarse.

Añadimos un cuadrado de lado 10 como parámetro que muestra el valor de área de 10 x 10 = 100. Pero ¿Qué ocurre al cambiar su alto en 8? Debería de mostrar un valor de área de 80 (10 x 8) para respetar la funcionalidad de la clase padre. Pero eso no ocurre: el área devuelta es de 64 (8 x 8). Por tanto estamos violando el principio de Liskov.

Principio de segregación de la interfaz (Interface segregation principle)

El principio de segregación de la interfaz dice:

Una clase nunca debe ser forzada a implementar una interface que no usa, empleando métodos que no tiene por qué usar.

Esto quiere decir que las ninguna clase debería de depender de métodos que no usa. Por lo que, cuando tengamos que crear interfaces que definan unos comportamientos, debemos de asegurarnos que todas las clases implementen todos y cada uno de los métodos definidos en la interfaz. No debemos permitir que existan clases con métodos vacíos de lógica o que necesiten devolver una excepción.

Por tanto, la solución pasaría por tener varias interfaces más pequeñas e ir implementándolas en las clases que realmente las requieran.

Veamos un ejemplo.

interface Employee {
    public function schedule();
    public function salary();
    public function codeApp();
    public function designUI();
}

class Developer implements Employee {
    public function codeApp() {
        echo "coding";
    }
    public function designUI() {
        throw new \Exception('I´m not a designer');
    }
    public function schedule() {
        return "09:00-17:00";
    }
    public function salary() {
        return "3,000USD";
    }
}

class Designer implements Employee {
    public function codeApp() {
        throw new \Exception('I´m not a developer');
    }
    public function designUI() {
        echo "designing ui";
    }
    public function schedule() {
        return "09:00-13:00";
    }
    public function salary() {
        return "1,000USD";
    }
}

class Seller implements Employee {
    public function codeApp() {
        throw new \Exception('I´m not a developer');
    }
    public function designUI() {
        throw new \Exception('I´m not a designer');
    }
    public function schedule() {
        return "09:00-18:00";
    }
    public function salary() {
        return "2,000USD";
    }
}

Tenemos una interfaz para los empleados de una empresa. Todos han de tener un par de métodos que retornen su salario mensual y su horario. Pero además, tenemos dos métodos que son de uso exclusivo para unos perfiles de trabajador en concreto: uno para programadores (codeApp) y otro para diseñadores (designUI).

No tardamos en ver que, en todas las clases, estamos forzando a definir funciones que no las necesitan. El Developer (programador) no necesita de la función designUI, el Designer (diseñador) no necesita de codeApp y el Seller (Vendedor) no requiere ninguna de las dos.

Para “solventar” esta inconsistencia, se han definido los métodos lanzando excepciones (como se muestra en el ejemplo).

En este caso estamos violando el principio de segregación de interfaces: Estamos forzando a implementar unas funcionalidades que no se necesitan.

¿La solución? Dividir esta interfaz Employee en varias más pequeñas e implementarlas en las clases que realmente las requieran.

interface Employee {
    public function schedule();
    public function salary();
}

interface DeveloperEmployee {
    public function codeApp();
}

interface DesignerEmployee {
    public function designUI();
}

class Developer implements Employee, DeveloperEmployee{
    public function codeApp() {
        echo "coding";
    }
    
    public function schedule() {
        return "09:00-17:00";
    }
    public function salary() {
        return "3,000USD";
    }
}

class Designer implements Employee, DesignerEmployee {
    public function designUI() {
        echo "designing ui";
    }
    public function schedule() {
        return "09:00-13:00";
    }
    public function salary() {
        return "1,000USD";
    }
}

class Seller implements Employee {
    public function schedule() {
        return "09:00-18:00";
    }
    public function salary() {
        return "2,000USD";
    }
}

Ahora todas las clases implementan los métodos que realmente necesitan. De este modo, cumplimos el principio de segregación de interfaces.

Principio de inversión de la dependencia (Dependency inversion principle)

El principio de inversión de dependencias nos dice:

Las entidades deben depender de abstracciones no de concreciones. El módulo de alto nivel no debe depender del módulo de bajo nivel, pero deben depender de abstracciones.

Esto quiere decir que una clase determinada no debería depender directamente de otra, sino de la abstracción de ésta. Con esto conseguimos que la clase sea reusable y evita que nos acoplemos. De esta forma nuestro código no depende de detalles de la implementación (código desacoplado) y nos facilita mucho la vida a la hora de realizar nuestros tests.

Veamos un ejemplo.

class MysqlConnection
{
    public function connect()
    {
        //Return mysql connection
    }
}

class SomeClass
{
    private $dbConnection;

    public function __construct(MysqlConnection $dbConnection)
    {
        $this->dbConnection = $dbConnection;
    }
}

La clase SomeClass hace uso de la clase MysqlConnection para establecer una conexión a base de datos MySQL. Esta clase se está acoplando al tipo de base de datos de la aplicación (está conociendo detalles de la implementación).

¿Pero qué pasaría si quisiéramos cambiar de motor de base de datos? Nos veríamos obligados a modificar la clase, pasando por constructor otra clase de conexión de base de datos distinta. Violaríamos el principio Open/Close.

La solución idónea sería declarar una interfaz común llamada DataBaseConnectionInterface para todas las clases encargadas de las conexiones a bases de datos.

interface DataBaseConnectionInterface
{
    public function connect();
}

class PostgresqlConnection implements DataBaseConnectionInterface
{
    public function connect()
    {
        //Return postgresql connection
    }
}

class MysqlConnection implements DataBaseConnectionInterface
{
    public function connect()
    {
        //Return mysql connection
    }
}

class SomeClass
{
    private $dbConnection;

    public function __construct(DataBaseConnectionInterface $dbConnection)
    {
        $this->dbConnection = $dbConnection;
    }
}

$mysqlConnection = new MysqlConnection();
$postgreConnection = new PostgresqlConnection();

$someMysqlClass = new SomeClass($mysqlConnection);
$somePostgresClass = new SomeClass($postgreConnection);

De esta forma, si queremos cambiar el motor de base de datos, no será necesario tocar la clase SomeClass (cumplimos Open/Close). Solo habría que pasar una clase distinta por constructor que cumpla con la interfaz DataBaseConnectionInterface.

Ahora la clase SomeClass depende de abstracciones, no de concreciones. Conseguimos que nuestras clases estén desacopladas, que no conozcan detalles de implementación y que sus tests sean fáciles de realizar.

Deja un comentario