[OCP] Principio Abierto - Cerrado

Descripción general

Principios SOLID, Principios del diseño orientado a objetos; [OCP] Principio Abierto - Cerrado;
Básicamente lo que nos dice este principio es que las entidades de software (clases, módulos, funciones…) deben estar abiertas para extenderse, pero cerradas para modificación.

[TOC] Tabla de Contenidos


↑↑↑

[OCP] Principio Abierto - Cerrado


↑↑↑

Los cinco principios SOLID


↑↑↑

SOLID – [OCP] El Principio Abierto - Cerrado

El Principio Open/Closed (Open/Closed Principle, OCP) fue acuñado por el Dr. Bertrand Meyer en su libro "Object Oriented Software Construction" y básicamente lo que nos dice este principio es que "las entidades de software (clases, módulos, funciones…) deben estar abiertas para extenderse, pero cerradas para modificación".

"Una clase debe estar abierta a la extensión y cerrada a la modificación"

¿Qué significa esto?: Que una entidad tiene que ser extendida sin necesidad de ser modificada internamente. Por ejemplo, deberíamos poder ampliar la funcionalidad de una clase sin necesidad de tener que modificar su comportamiento interno.

Todas las aplicaciones cambian durante su ciclo de vida, y siempre habrá nuevas versiones que añadirán alguna nueva funcionalidad al diseño. Pero aunque sepamos que esta situación va a ocurrir, no debemos adelantarnos y desarrollar aquellas características que el cliente podría necesitar en el futuro; si nos ponemos en el papel de adivinos, lo más seguro es que fallemos y desarrollemos características que el cliente nunca necesitará. El principio KISS (Mantenlo simple) y el principio YAGNI ("You Ain"t Gonna Need It" o "No vas a necesitarlo") indican que solo debemos implementar únicamente lo que realmente se requiera. La idea es desarrollar ahora sobre los requisitos funcionales actuales, no sobre los que supongamos que aparecerán dentro de un mes.

Ahora bien, ¿cómo debemos plantear nuestras aplicaciones para que se mantengan estables ante cualquier modificación? La actitud de adelantarnos a los acontecimientos es un mecanismo de defensa de los desarrolladores para prevenir lo que tarde o temprano será inevitable: la modificación. Lo único que podemos hacer es minimizar el impacto de una futura modificación en nuestro sistema, y para ello es imprescindible empezar con un buen diseño, ya que la modificación de una clase o módulo de una aplicación mal diseñada generará cambios en cascada sobre las clases dependientes que derivarán en unos efectos indeseables. Un mal diseño convierte nuestra aplicación en rígida, impredecible y no reutilizable.

Las clases que cumplen con OCP tienen dos características:

Podría parecer que ambas características son incompatibles, pero eso no es así. Veamos varios ejemplos:


↑↑↑

Ejemplo Uno

Por ejemplo, supongamos una clase [Conductor] que es capaz de conducir un [Vehículo]. Si los requerimientos del sistema cambiaran y ahora el [Conductor] tuviese que conducir sólo vehículos a motor, está claro que la clase [Vehículo] cambiaría en su comportamiento y posiblemente en su interfaz, por lo que la clase [Conductor] tendría que adaptarse a los nuevos cambios del la clase [Vehículo].

Imageb 01

El Principio de Abierto-Cerrado, del inglés "The Open-Close Principle (OCP)", nos viene a decir que cualquier entidad software (clases, módulos, funciones, etc.) debe de estar abierta para ser extendida en funcionalidad pero cerrada para ser modificada. Es decir, una clase que cumpla con OCP tiene estas dos características:

¿Cómo se puede entender esto? ¿Cómo cambiar el comportamiento de una clase sin cambiar su código fuente? ¿Cómo hacer que la clase [Conductor] no se tenga que cambiar y sin embargo sea capaz de tratar con el nuevo tipo de [Vehículo]?. La respuesta está en la abstracción. La abstracción se puede conseguir de muchas maneras en programación orientada a objetos. A través de interfaces, clases abstractas, delegados, etc.

Imagen 02

En este caso, la interfaz [IConductor] es la abstracción que define la clase [Conductor] para conducir un [Vehículo]. Observa que la interfaz se llama [IConductor] en lugar de IVehículo porque el que define la funcionalidad es el [Conductor] mientras que [Vehículo] es únicamente una implementación de esa interfaz. Para implementar el cambio que restringe que un [Conductor] sólo puede conducir vehículos a motor bastaría únicamente con cambiar la implementación de [Vehículo] para ajustarse a las nuevas necesidades.

Con este diseño, la clase [Conductor] cumple con OCP, pues está abierta a cambios (respecto a conducir vehículos) y cerrado a los cambios (para cambiar el comportamiento de los vehículos, no se necesita cambiar el comportamiento del conductor). �Otro tema sería que los cambios que se pidieran no fuesen soportados por la interfaz disponible. Por ejemplo, tener en cuenta vehículos voladores. En este caso los cambios serían a nivel de requerimientos de más alto nivel, por lo que se entiende que no quede más remedio que cambiar la clase [Conductor], la interfaz [IConductor], y evidentemente todas las implementaciones de esta interfaz.


↑↑↑

Ejemplo Dos

El problema a resolver es diseñas y escribir una(s) Clase(s) para Filtrar una lista de clientes por una serie de características como: localidad, nombre y deudores.

Primero crearemos las entidades que vamos a utilizar en el ejemplo: vamos a representar las localidades en una enumeración, para simplificar la cosa, y vamos a crear una clase para representar a los clientes:

public enum Localidades
   {
     None,
     Zaragoza,
     Huesca,
     Teruel,
}
 
public class Cliente
   {
      public string Nombre { get; set; }
      public Localidades Localidad { get; set; }
      public decimal Saldo { get; set; }
}

Supongamos que en primer lugar, que nos piden filtrar la lista de clientes por la localidad a la que pertenecen. Para realizar esta tarea, escribimos una clase que se encargue de seleccionar a los clientes por localidades y la llamaremos FiltroClientes. (Recuerda el principio SRP - Principio de responsabilidad simple-),

El aspecto de la clase podría ser algo así:

public class FiltroClientes
{
    public IEnumerable<Cliente> FiltroPorLocalidad(
                                IList<Cliente> clientes, Localidades localidad)
    {
        // esto es una expresion Lambda
        // Mas info en: http://msdn.microsoft.com/es-es/library/bb534803.aspx
        return clientes.Where(c => c.Localidad == localidad);
    }
}

Como podemos ver, el objeto "filtrador por localidades" recibe como parámetros la lista de clientes, y la localidad por la cual filtrarlos y devuelve la lista de clientes que pertenecen a dicha localidad.

A continuación, el usuario comienza a pedir nuevas funcionalidades. Imaginemos que ahora también quiere filtrar los clientes por el nombre. Lo que se nos ocurre de forma inmediata es crear un método nuevo por cada filtro solicitado, algo como el siguiente código:

public class FiltroClientes
 {
    public IEnumerable<Cliente> FiltroPorLocalidad(
                                IList<Cliente> clientes, Localidades localidad)
    {
        return clientes.Where(c => c.Localidad == localidad);
    }
 
  public IEnumerable<Cliente> FiltroPorLocalidad(IList<Cliente> clientes, string nombre)
     {
         return clientes.Where(c => c.Nombre == nombre);
     }
 
     public IEnumerable<Cliente> FiltroPorLocalidad(IList<Cliente> clientes)
     {
         return clientes.Where(c => c.Saldo < 0);
     }
}

Aparentemente hemos resuelto el problema, pero estamos violando el principio OCP. En primer lugar cada vez que tengamos que agregar nuevos filtros debemos modificar la clase [FiltroClientes] y por lo tanto NO está cerrado para modificación. En segundo lugar, la única forma de extender la funcionalidad de [FiltroClientes] es abriendo el archivo y modificarlo, por lo tanto NO está abierto a la extensión.

Entonces, ¿qué podemos hacer para respetar este principio?

Una solución es crear las especificaciones de los filtros en clases separadas, y en cada una de ellas implementar la forma de filtrar la información (recuerda el principio SRP).

Para que las especificaciones tengan una estructura y comportamiento uniforme lo, podemos crear una clase base que hereden todas las clases [FiltroClientesEspecificas]. Esta clase base podría tener la siguiente forma:

public abstract class EspecificacionFiltroCliente
{
     public IEnumerable<Cliente> Filtrar (IList<Cliente> clientes)
     {
         return AplicarFiltro(clientes);
     }
 
     protected abstract IEnumerable<Cliente> AplicarFiltro(IList<Cliente> clientes);
}

Esta Clase base, es una clase abstracta que tiene definido dos métodos: el primero público que es sencillamente la acción filtrar y que es la que utilizará nuestro [FiltroClientes]. La acción filtrar llamará internamente a un segundo método que es el que nos encargaremos de sobre-escribir y ponerle la lógica de comportamiento correspondiente a cada especificación en cada clase hija, en cada clase con un filtro especifico.

Ahora ya estamos listos para crear nuestras propias especificaciones de filtros de clientes. Por ejemplo para filtrar las localidades vamos a crear la clase [FiltroLocalidad]:

public class FiltroLocalidad : EspecificacionFiltroCliente
{
    private Localidades localidad;
 
    public FiltroLocalidad(Localidades localidad)
    {
        this.Localidad = localidad;
    }
 
    protected override IEnumerable<Cliente> AplicarFiltro(IList<Cliente> clientes)
    {
        return clientes.Where(c => c.localidad == this.localidad);
    }
}

Evidentemente, también vamos a reescribir la clase [FiltroClientes]:

public class FiltroClientes
{
    public IEnumerable<Cliente> FiltrarPor( 
                                IList<Cliente> clientes,
                                EspecificacionFiltroCliente filtro)
    {
        return filtro.Filtrar(clientes);
    }
}

La estructura de las clases quedara de la siguiente forma:

Imagen 03

Finalmente veamos en un ejemplo su implementación mediante un ejemplo:

static void Main(string[] args)
{
   /*******************************************
   // Código usando la instrucción 'var'
   //var clientes = new Cliente[] 
   //    { 
   //               new Cliente() { Localidad = Localidades.Zaragoza, Nombre = "Joaquin" },
   //               new Cliente() { Localidad = Localidades.Zaragoza, Nombre = "Antonio" },
   //               new Cliente() { Localidad = Localidades.Huesca,   Nombre = "Carmen" },
   //               new Cliente() { Localidad = Localidades.Teruel,   Nombre = "Santiago" }
   //    };
   //
   //var filtrador = new FiltroClientes();
   //var clientesFiltrados = filtrador.FiltrarPor(
   //                        clientes, new FiltroLocalidad(Localidades.Zaragoza));
   //*******************************************
 
 
   IList<Cliente> clientes = new Cliente[] 
   { 
      new Cliente() { Localidad = Localidades.Zaragoza, Nombre = "Joaquin" },
      new Cliente() { Localidad = Localidades.Zaragoza, Nombre = "Antonio" },
      new Cliente() { Localidad = Localidades.Huesca,   Nombre = "Carmen" },
      new Cliente() { Localidad = Localidades.Teruel,   Nombre = "Santiago" }
   };
 
   FiltroClientes filtrador = new FiltroClientes();
   IEnumerable<Cliente> clientesFiltrados = filtrador.FiltrarPor(
                                            clientes, 
                                            new FiltroLocalidad(Localidades.Zaragoza));
 
   foreach (Cliente cli in clientesFiltrados)
   {
       System.Console.WriteLine(cli.Nombre);
   }
 
   Console.ReadKey();
}

Resultado de la ejecución del programa

Imagen 04

Ahora sí estamos respetando OCP. Observa que podemos extender la funcionalidad de [FiltroClientes] sin tener que modificar internamente su estructura, entonces SÍ está cerrada para modificación. Y podemos extenderla cuantas veces queramos simplemente creando nuevas especificaciones de filtros, entonces Sí está abierta para extensión.


↑↑↑

Ejemplo Tres

Veamos un ejemplo de una clase que no cumple con OCP. Supongamos un sistema de gestión de proyectos al estilo de Microsoft Project. Obviemos de momento la complejidad real que existe en dicho sistema, y centrémonos únicamente en la entidad Tarea, tal y como muestra la figura 5. Dicha clase viene determinada por uno de los estados Pendiente, Finalizada o Cancelada, representados mediante la enumeración EstadosTarea. Además, la clase implementa dos métodos, Cancelar y Finalizar que cambian, si es posible, el estado de la tarea. En el listado podemos ver la implementación inicial del método Finalizar.

Imagen 05
public void Finalizar()
 {
     switch (_estadoTarea)
     {
         case EstadosTarea.Pendiente:
             // finalizamos
             break;
         case EstadosTarea.Finalizada:
             throw new ApplicationException("Tarea ya finalizada");
         case EstadosTarea.Cancelada:
             throw new ApplicationException("Imposible finalizar. Tarea cancelada");
         default:
             throw new ArgumentOutOfRangeException();
     }
}

Un cambio típico solicitado por el cliente de la aplicación sería la adición de un nuevo estado para controlar las tareas que se han pospuesto, con lo que la adaptación a esta modificación podría ser la expuesta en el listado siguiente. Aparentemente, parece una modificación trivial; sin embargo, este cambio puede replicarse en otros métodos o clases que utilicen la enumeración EstadosTarea, de forma que en nuestro caso también deberíamos modificar el método Cancelar.

public void Finalizar()
 {
     switch (_estadoTarea)
     {
         case EstadosTarea.Pendiente:
             // finalizamos
             break;
         case EstadosTarea.Finalizada:
             throw new ApplicationException("Tarea ya finalizada");
         case EstadosTarea.Cancelada:
             throw new ApplicationException("Imposible finalizar. Tarea cancelada");
         case EstadosTarea.Pospuesta:
             throw new ApplicationException("Imposible finalizar. Tarea no completada");
         default:
             throw new ArgumentOutOfRangeException();
     }
 }
  
public void Cancelar()
  {
      switch (_estadoTarea)
      {
          case EstadosTarea.Pendiente:
              // cancelamos
              _estadoTarea = EstadosTarea.Cancelada;
              break;
          case EstadosTarea.Finalizada:
              throw new ApplicationException("Imposible cancelar. Tarea finalizada");
          case EstadosTarea.Cancelada:
              throw new ApplicationException("Tarea ya cancelada");
          case EstadosTarea.Pospuesta:
              // cancelamos
              _estadoTarea = EstadosTarea.Cancelada;
              break;
          default:
              throw new ArgumentOutOfRangeException();
      }
}

En definitiva, por cada nuevo estado que implementemos tendremos que identificar todas las clases que lo utilizan (tanto la clase Tarea como las clases lógicamente involucradas) y modificarlas, violando no únicamente OCP sino también el Principio DRY ("Don"t Repeat Yourself", "No te repitas"), otro principio que pretende reducir al máximo cualquier tipo de duplicación. En este tipo de modificaciones existe una alta probabilidad de olvidar modificar algún método relacionado con el nuevo estado implementado en el enumerador EstadosTarea, lo que elevaría la probabilidad de aparición de un nuevo error.

La cuestión se centra en cómo minimizar el impacto de una modificación en nuestro sistema, sin comprometer OCP; esto es, manteniendo la "simbiosis" entre las dos características del principio: abierto en extensión y cerrado en modificación.

Volvamos a la entidad Tarea del ejemplo anterior. Por lo que hemos podido ver, los métodos dependen en gran medida del estado de la tarea. Así, una tarea podrá finalizarse o cancelarse dependiendo de su estado previo, pues no podremos cancelar una tarea que haya sido finalizada. De la misma forma, introduciendo el nuevo estado EstadosTarea. Pospuesta implementaríamos un nuevo método llamado Posponer, cuya lógica sería obvia: únicamente podría posponerse una tarea que estuviera en estado pendiente. En definitiva, todo gira alrededor del estado de la tarea, y por tanto el comportamiento de la misma dependerá del estado en que se encuentre. Una opción sería encapsular dicho estado en una clase auxiliar e implementar en ella los métodos Finalizar, Cancelar y Posponer, mediante los cuales definimos el comportamiento, tal y como se muestra en el listado 4, para luego delegar los métodos del objeto Tarea hacia dicha clase.

class EstadosTareaHelper
{
    public virtual void Finalizar(EstadosTarea estado)
    {
        switch (estado)
        {
            case EstadosTarea.Pendiente:
            // finalizamos
            case EstadosTarea.Pospuesta:
                throw new ApplicationException("Imposible finalizar. Tarea no completada");
            default:
                throw new ArgumentOutOfRangeException();
        }
    }
    public virtual void Cancelar(EstadosTarea estado)
    {
        switch (estado)
        {
            // ...
            // cancelamos
        }
    }
    public virtual void Posponer(EstadosTarea estado)
    {
        switch (estado)
        {
            // ...
            // posponemos
        }
    }
}

Pese a que hayamos extraído y aislado el estado de la entidad Tarea, aun no hemos resuelto el problema. De hecho, ahora hemos aislado la responsabilidad en la clase EstadosTareaHelper; sin embargo, estamos algo más cerca de la solución. Estudiemos de nuevo los estados -métodos- de la clase Estados-TareaHelper. La lógica de cada acción está escrita en todos los métodos y por tanto se repite; es decir, todos los métodos contemplan la opción de Finalizar una tarea, y en base a ello actúan de una forma u otra. La operación Posponer no podrá ejecutarse si el estado de la tarea es Cancelada, y la operación Cancelar únicamente podrá ejecutarse si el estado es Pendiente. A través de este razonamiento, podemos detectar un patrón: un mismo contrato .los métodos. y diferentes comportamientos en base a un estado. Esto en OO puede ser solucionado mediante polimorfismo, como se muestra en el listado sigioente.

abstract class EstadoTareaBase
{
    protected Tarea _tarea;
    public abstract void Finalizar();
    public abstract void Cancelar();
    public abstract void Posponer();
}
class EstadoTareaPendiente : EstadoTareaBase
{
    public override void Finalizar()
    {
        // finalizamos
    }
    public override void Cancelar()
    {
        // cancelamos
    }
    public override void Posponer()
    {
        // posponemos
    }
}
class EstadoTareaFinalizada : EstadoTareaBase
{
    public override void Finalizar()
    {
        throw new ApplicationException("Tarea ya finalizada");
    }
    public override void Cancelar()
    {
        throw new ApplicationException("Imposible cancelar. Tarea finalizada");
    }
    public override void Posponer()
    {
        throw new ApplicationException("Imposible posponer. Tarea finalizada");
    }
}
class EstadoTareaCancelada : EstadoTareaBase
{
    public override void Finalizar()
    {
        throw new ApplicationException("Imposible finalizar. Tarea cancelada");
    }
    public override void Cancelar()
    {
        throw new ApplicationException("Tarea ya cancelada");
    }
    public override void Posponer()
    {
        throw new ApplicationException("Imposible posponer. Tarea cancelada");
    }
}
class EstadoTareaPospuesta : EstadoTareaBase
{
    public override void Finalizar()
    {
        throw new ApplicationException("Imposible posponer. Tarea finalizada");
    }
    public override void Cancelar()
    {
        // cancelamos
    }
    public override void Posponer()
    {
        throw new ApplicationException("Tarea ya pospuesta");
    }
}
class Tarea
{
    private EstadoTareaBase _estadoTarea;
    public Tarea()
    {
        _estadoTarea = new EstadoTareaPendiente();
    }
    public void Finalizar()
    {
        _estadoTarea.Finalizar();
    }
    public void Cancelar()
    {
        _estadoTarea.Cancelar();
    }
    public void Posponer()
    {
        _estadoTarea.Posponer();
    }
}
Imagen 06

Básicamente, lo que hemos hecho es crear una clase por cada estado en lugar de tener una única clase cuyos métodos están basados en sentencias condicionadas por el estado de la tarea (switch o if). Además, con esta nueva implementación hemos delegado la responsabilidad de finalizar, cancelar o posponer a una nueva clase [EstadoTareaBase] que hemos marcado como abstracta. La clase Tarea implementará sus propios métodos y delegará la responsabilidad a través de las [clases estados] que heredan de [EstadoTareaBase]. Debido a que la clase Tarea gira en torno a un estado, asumimos que el estado inicial por defecto es Pendiente, y así lo especificamos en el constructor, instanciando [EstadoTareaPendiente].

En realidad, hemos aplicado un patrón ya conocido, el patrón de diseño State, ya que el comportamiento de la clase cambia dependiendo del estado, en este caso, de la tarea, y por lo tanto hemos abstraído cada uno de los estados como entidades independientes. Ante un nuevo requisito en el que intervenga un nuevo estado, lo único que deberemos hacer es crear una nueva clase que herede de [EstadoTareaBase ] e implementar los métodos virtuales, extendiendo así el comportamiento de la aplicación sin comprometer el código existente.


↑↑↑

Conclusión

El Principio [SRP] de Responsabilidad Única, dice que cada clase tenga una y solo una responsabilidad dentro del sistema, de forma que cuanto menos impacto tenga una clase en el conjunto global del sistema, menos repercusión global tendrá una modificación de la clase en dicho sistema. Este mismo argumento es la línea que pretende seguir el Principio Open/Closed, que pese a ser relativamente sencillo de comprender conceptualmente, no sucede lo mismo cuando se aplica. Las claves para la correcta aplicación de este principio son la abstracción y el polimorfismo, como hemos podido ver en el ejemplo.


↑↑↑

Referencia Bibliográfica


↑↑↑

A.2.Enlaces

[Para saber mas]
[Grupo de documentos]
[Documento Index]
[Apuntes sobre principios de diseño]
[Documento Start]
[Imprimir el Documento]