Capítulo 4. El patrón de diseño del visitante

Este trabajo se ha traducido utilizando IA. Agradecemos tus opiniones y comentarios: translation-feedback@oreilly.com

Todo este capítulo de se centra en el patrón de diseño Visitante. Si ya has oído hablar del patrón de diseño Visitante o incluso lo has utilizado en tus propios diseños, quizá te preguntes por qué he elegido Visitante como primer patrón de diseño para explicarlo en detalle. Sí, definitivamente Visitante no es uno de los patrones de diseño más glamurosos. Sin embargo, sin duda servirá como un gran ejemplo para demostrar las muchas opciones que tienes al implementar un patrón de diseño y lo diferentes que pueden ser estas implementaciones. También servirá como ejemplo eficaz para publicitar las ventajas del C++ moderno.

En la "Directriz 15: Diseñar para añadirtipos u operaciones ",hablaremos primero de la decisión de diseño fundamental que tendrás que tomar cuando te adentres en el reino del polimorfismo dinámico: centrarte en los tipos o en las operaciones. En esa directriz, también hablaremos de los puntos fuertes y débiles intrínsecos de los paradigmas de programación.

En la "Pauta 16: Utilizar Visitante para ampliar operaciones", te presentaré el patrón de diseño Visitante. Te explicaré su intención de ampliar operaciones en lugar de tipos, y te mostraré tanto las ventajas como los defectos del patrón Visitante clásico.

En la "Pauta 17: Considera std::variant paraimplementar Visitante", conocerás la implementación moderna del patrón de diseño Visitante. Te presentaré std::variant y te explicaré las muchas ventajas de esa implementación concreta.

En la "Pauta 18: Cuidado con el rendimiento del Visitante acíclico", te presentaré al Visitante acíclico. A primera vista, este enfoque parece resolver algunos problemas fundamentales del patrón Visitante, pero si lo examinamos más detenidamente, veremos que la sobrecarga en tiempo de ejecución puede descalificar esta implementación.

Directriz 15: Diseño para la adición detipos u operaciones

Para, el término polimorfismo dinámico puede sonar a mucha libertad. Puede parecerte similar a cuando aún eras un niño: ¡posibilidades infinitas, sin limitaciones! Pues bien, te has hecho mayor y te has enfrentado a la realidad: no se puede tener todo, y siempre hay que elegir. Por desgracia, ocurre algo parecido con el polimorfismo dinámico. A pesar de que suena a libertad total, hay una elección limitante: ¿quieres extender tipos u operaciones?

Para ver a qué me refiero, volvamos al escenario del Capítulo 3: queremos dibujar una forma determinada.1 Nos ceñimos al polimorfismo dinámico, y para nuestro intento inicial, implementamos este problema con la buena y vieja programación procedimental.

Una solución procesal

El primer archivo de cabecera Point.h proporciona una clase Point bastante sencilla. Esto servirá principalmente para completar el código, pero también nos da la idea de que estamos tratando con formas 2D:

//---- <Point.h> ----------------

struct Point
{
   double x;
   double y;
};

El segundo archivo de encabezamiento conceptual Shape.h resulta mucho más interesante:

//---- <Shape.h> ----------------

enum ShapeType  1
{
   circle,
   square
};

class Shape  2
{
 protected:
   explicit Shape( ShapeType type )
      : type_( type )  5
   {}

 public:
   virtual ~Shape() = default;  3

   ShapeType getType() const { return type_; }  6

 private:
   ShapeType type_;  4
};

En primer lugar, introducimos la enumeración ShapeType, que actualmente enumera los dos enumeradores, circle y square(1). Aparentemente, al principio sólo nos ocupamos de círculos y cuadrados. En segundo lugar, introducimos la clase Shape(2Dado el constructor protegido y el destructor virtual (3), puedes anticipar que se supone que Shape funciona como clase base. Pero ése no es el detalle sorprendente de Shape: Shape tiene un miembro de datos de tipo ShapeType(4Este miembro de datos se inicializa mediante el constructor (5) y puede consultarse mediante la función miembro getType() (6). Aparentemente, un Shape almacena su tipo en forma de enumeración ShapeType.

Un ejemplo del uso de la clase base Shape es la clase Circle:

//---- <Circle.h> ----------------

#include <Point.h>
#include <Shape.h>

class Circle : public Shape  7
{
 public:
   explicit Circle( double radius )
      : Shape( circle )  8
      , radius_( radius )
   {
      /* Checking that the given radius is valid */
   }

   double radius() const { return radius_; }
   Point  center() const { return center_; }

 private:
   double radius_;
   Point center_{};
};

Circle hereda públicamente de Shape(7), y por esa razón, y debido a la falta de un constructor por defecto en Shape, necesita inicializar la clase base (8). Como es un círculo, utiliza el enumerador circle como argumento para el constructor de la clase base.

Como ya hemos dicho, queremos dibujar formas. Por tanto, introducimos la función draw() para los círculos. Como no queremos vincularnos demasiado a ningún detalle de implementación del dibujo, la función draw() se declara en el archivo de cabecera conceptualDrawCircle.h y se define en el archivo fuente correspondiente:

//---- <DrawCircle.h> ----------------

class Circle;

void draw( Circle const& );


//---- <DrawCircle.cpp> ----------------

#include <DrawCircle.h>
#include <Circle.h>
#include /* some graphics library */

void draw( Circle const& c )
{
   // ... Implementing the logic for drawing a circle
}

Por supuesto, no sólo hay círculos. Como indica el enumerador square, también existe la clase Square:

//---- <Square.h> ----------------

#include <Point.h>
#include <Shape.h>

class Square : public Shape  9
{
 public:
   explicit Square( double side )
      : Shape( square )  10
      , side_( side )
   {
      /* Checking that the given side length is valid */
   }

   double side  () const { return side_; }
   Point  center() const { return center_; }

 private:
   double side_;
   Point center_{};  // Or any corner, if you prefer
};


//---- <DrawSquare.h> ----------------

class Square;

void draw( Square const& );


//---- <DrawSquare.cpp> ----------------

#include <DrawSquare.h>
#include <Square.h>
#include /* some graphics library */

void draw( Square const& s )
{
   // ... Implementing the logic for drawing a square
}

La clase Square es muy similar a la clase Circle (9). La principal diferencia es que Square inicializa su clase base con el enumeradorsquare (10).

Con los círculos y cuadrados disponibles, ahora queremos dibujar un vector entero de formas diferentes. Para ello, introducimos la función drawAllShapes():

//---- <DrawAllShapes.h> ----------------

#include <memory>
#include <vector>
class Shape;

void drawAllShapes( std::vector<std::unique_ptr<Shape>> const& shapes );  11


//---- <DrawAllShapes.cpp> ----------------

#include <DrawAllShapes.h>
#include <Circle.h>
#include <Square.h>

void drawAllShapes( std::vector<std::unique_ptr<Shape>> const& shapes )
{
   for( auto const& shape : shapes )
   {
      switch( shape->getType() )  12
      {
         case circle:
            draw( static_cast<Circle const&>( *shape ) );
            break;
         case square:
            draw( static_cast<Square const&>( *shape ) );
            break;
      }
   }
}

drawAllShapes() toma un vector de formas en forma de std::unique_ptr<Shape>(11). El puntero a la clase base es necesario para contener distintos tipos de formas concretas, y el std::unique_ptr en particular para gestionar automáticamente las formas mediante el lenguaje RAII. Dentro de la función, empezamos recorriendo el vector para dibujar cada forma. Por desgracia, todo lo que tenemos en este punto son punteros Shape. Por lo tanto, tenemos que preguntar amablemente a cada forma mediante la función getType()(12): ¿Qué tipo de forma eres? Si la forma responde circle, sabemos que tenemos que dibujarla como Circle y realizar la correspondiente static_cast. Si la forma responde square, la dibujamos como Square.

Puedo intuir que no estás especialmente contento con esta solución. Pero antes de hablar de las deficiencias, consideremos la función main():

//---- <Main.cpp> ----------------

#include <Circle.h>
#include <Square.h>
#include <DrawAllShapes.h>
#include <memory>
#include <vector>

int main()
{
   using Shapes = std::vector<std::unique_ptr<Shape>>;

   // Creating some shapes
   Shapes shapes;
   shapes.emplace_back( std::make_unique<Circle>( 2.3 ) );
   shapes.emplace_back( std::make_unique<Square>( 1.2 ) );
   shapes.emplace_back( std::make_unique<Circle>( 4.1 ) );

   // Drawing all shapes
   drawAllShapes( shapes );

   return EXIT_SUCCESS;
}

¡Funciona! Con esta función main(), el código se compila y dibuja tres formas (dos círculos y un cuadrado). ¿No es genial? Lo es, pero no evitará que despotriques: "¡Qué solución tan primitiva! El switch no sólo es una mala elección para distinguir entre distintos tipos de formas, ¡sino que además no tiene un caso por defecto! ¿Y quién tuvo la loca idea de codificar el tipo de las formas mediante una enumeración no escalonada?".2 Estás mirando sospechosamente en mi dirección...

Bueno, puedo entender tu reacción. Pero analicemos el problema con un poco más de detalle. Déjame adivinar: recuerdas la "Directriz 5: Diseñar para la extensión". Y ahora imaginas lo que tendrías que hacer para añadir un tercer tipo de forma. En primer lugar, tendrías que ampliar la enumeración. Por ejemplo, tendríamos que añadir el nuevo enumerador triangle(13):

enum ShapeType
{
   circle,
   square,
   triangle  13
};

Ten en cuenta que esta adición repercutiría no sólo en la declaración switch de la funcióndrawAllShapes() (ahora está realmente incompleta), sino también en todas las clases derivadas deShape (Circle y Square). Estas clases dependen de la enumeración, ya que dependen de la clase base Shape y también utilizan la enumeración directamente. Por tanto, cambiar la enumeración provocaría una recompilación de todos tus archivos fuente.

Esto debería parecerte un asunto serio. Y de hecho lo es. El núcleo del problema es la dependencia directa de todas las clases y funciones de forma respecto a la enumeración. Cualquier cambio en la enumeración provoca un efecto dominó que obliga a recompilar los archivos dependientes. Obviamente, esto viola directamente el Principio Abierto-Cerrado (PCA) (ver"Directriz 5: Diseño para la extensión"). Esto no parece correcto: añadir un Triangle no debería provocar una recompilación de las clases Circle y Square.

Pero hay más. Además de escribir realmente una clase Triangle (algo que dejo a tu imaginación), tienes que actualizar la declaración switch para manejar triángulos (14):

void drawAllShapes( std::vector<std::unique_ptr<Shape>> const& shapes )
{
   for( auto const& shape : shapes )
   {
      switch( shape->getType() )
      {
         case circle:
            draw( static_cast<Circle const&>( *shape ) );
            break;
         case square:
            draw( static_cast<Square const&>( *shape ) );
            break;
         case triangle:  14
            draw( static_cast<Triangle const&>( *shape ) );
            break;
      }
   }
}

Puedo imaginarme tu clamor: "¡Copiar y pegar! Duplicación!" Sí, en esta situación es muy probable que un desarrollador utilice copiar y pegar para implementar la nueva lógica. Es muy cómodo porque el nuevo caso es muy parecido a los dos anteriores. Y, efectivamente, esto es un indicio de que el diseño podría mejorarse. Sin embargo, veo un fallo mucho más grave: supongo que en una base de código mayor, ésta no es la única declaración switch. Al contrario, habrá otras que también deban actualizarse. ¿Cuántas hay? ¿Una docena? ¿Cincuenta? ¿Más de cien? ¿Y cómo las encuentras todas? Vale, entonces argumentas que el compilador te ayudaría en esta tarea. Quizá con los interruptores, sí, pero ¿y si también hay cascadas if-else-if? Y entonces, después de este maratón de actualizaciones, cuando crees que has terminado, ¿cómo garantizas que realmente has actualizado todas las secciones necesarias?

Sí, puedo entender tu reacción y por qué prefieres no tener este tipo de código: esta manipulación explícita de los tipos es una pesadilla para el mantenimiento. Citando a Scott Meyers3

Este tipo de programación basada en tipos tiene una larga historia en C, y una de las cosas que sabemos de ella es que produce programas que son esencialmente imposibles de mantener.

Una solución orientada a objetos

Así que permíteme preguntarte: ¿qué habrías hecho tú? ¿Cómo habrías implementado el dibujo de formas? Bueno, me imagino que habrías utilizado un enfoque orientado a objetos. Eso significa que eliminarías la enumeración y añadirías una función virtual pura draw() a la clase baseShape. De este modo, Shape ya no tendría que recordar su tipo:

//---- <Shape.h> ----------------

class Shape
{
 public:
   Shape() = default;

   virtual ~Shape() = default;

   virtual void draw() const = 0;
};

Dada esta clase base, las clases derivadas ahora sólo tendrían que implementar la función miembro draw() (15):

//---- <Circle.h> ----------------

#include <Point.h>
#include <Shape.h>

class Circle : public Shape
{
 public:
   explicit Circle( double radius )
      : radius_( radius )
   {
      /* Checking that the given radius is valid */
   }

   double radius() const { return radius_; }
   Point  center() const { return center_; }

   void draw() const override;  15

 private:
   double radius_;
   Point center_{};
};


//---- <Circle.cpp> ----------------

#include <Circle.h>
#include /* some graphics library */

void Circle::draw() const
{
   // ... Implementing the logic for drawing a circle
}


//---- <Square.h> ----------------

#include <Point.h>
#include <Shape.h>

class Square : public Shape
{
 public:
   explicit Square( double side )
      : side_( side )
   {
      /* Checking that the given side length is valid */
   }

   double side  () const { return side_; }
   Point  center() const { return center_; }

   void draw() const override;  15

 private:
   double side_;
   Point center_{};
};


//---- <Square.cpp> ----------------

#include <Square.h>
#include /* some graphics library */

void Square::draw() const
{
   // ... Implementing the logic for drawing a square
}

Una vez que la función virtual draw() está en su sitio y es implementada por todas las clases derivadas, puede utilizarse para refactorizar la función drawAllShapes():

//---- <DrawAllShapes.h> ----------------

#include <memory>
#include <vector>
class Shape;

void drawAllShapes( std::vector< std::unique_ptr<Shape> > const& shapes );


//---- <DrawAllShapes.cpp> ----------------

#include <DrawAllShapes.h>
#include <Shape.h>

void drawAllShapes( std::vector< std::unique_ptr<Shape> > const& shapes )
{
   for( auto const& shape : shapes )
   {
      shape->draw();
   }
}

Puedo ver cómo te relajas y empiezas a sonreír de nuevo. Esto es mucho más agradable, mucho más limpio. Aunque comprendo que prefieras esta solución y que te gustaría permanecer en esta zona de confort un poco más de tiempo, desgraciadamente tengo que señalarte un defecto. Sí, esta solución también puede tener un inconveniente.

Como se indica en la introducción de esta sección, con un enfoque orientado a objetos, ahora podemos añadir nuevos tipos muy fácilmente. Todo lo que tenemos que hacer es escribir una nueva clase derivada. No tenemos que modificar ni recompilar ningún código existente (a excepción de la función main()). Ese cumple perfectamente la OCP. Sin embargo, ¿te has dado cuenta de que ya no podemos añadir operaciones fácilmente? Por ejemplo, supongamos que necesitamos una función virtual serialize() para convertir un Shape en bytes. ¿Cómo podemos añadir esto sin modificar el código existente? ¿Cómo podemos añadir fácilmente esta operación sin tener que tocar la clase base Shape?

Por desgracia, eso ya no es posible. Ahora estamos tratando con un conjunto cerrado de operaciones, lo que significa que violamos la OCP en relación con las operaciones de adición. Para añadir una función virtual, hay que modificar la clase base, y todas las clases derivadas (círculos, cuadrados, etc.) tienen que implementar la nueva función, aunque ésta no sea llamada nunca. En resumen, la solución orientada a objetos cumple la OCP respecto a la adición de tipos, pero la viola en relación con las operaciones.

Sé que pensabas que habíamos dejado atrás para siempre la solución procedimental, pero echemos un segundo vistazo. En el enfoque procedimental, añadir una nueva operación era en realidad muy sencillo. Se podían añadir nuevas operaciones en forma de funciones libres o clases independientes, por ejemplo. No era necesario modificar la clase base Shape ni ninguna de las clases derivadas. Así, en la solución procedimental, hemos cumplido la OCP en relación con la adición de operaciones. Pero, como hemos visto, la solución procedimental viola la OCP en relación con la adición de tipos. Así, parece una inversión de la solución orientada a objetos, que es al revés.

Sé consciente de la elección de diseño en el polimorfismo dinámico

La conclusión de este ejemplo es que existe una elección de diseño cuando se utiliza el polimorfismo dinámico: o bien puedes añadir tipos fácilmente fijando el número de operaciones, o bien puedes añadir operaciones fácilmente fijando el número de tipos. Así pues, la OCP tiene dos dimensiones: al diseñar software, tienes que tomar una decisión consciente sobre qué tipo de extensión esperas.

El punto fuerte de la programación orientada a objetos es la fácil adición de nuevos tipos, pero su punto débil es que la adición de operaciones se hace mucho más difícil. El punto fuerte de la programación procedimental es la facilidad para añadir operaciones, pero añadir tipos es un auténtico suplicio(Tabla 4-1). Depende de tu proyecto: si esperas que se añadan nuevos tipos con frecuencia, en lugar de operaciones, debes esforzarte por una solución de programación orientada a objetos, que trate las operaciones como un conjunto cerrado y los tipos como un conjunto abierto. Si esperas que se añadan operaciones, debes optar por una solución procedimental, que trate los tipos como un conjunto cerrado y las operaciones como un conjunto abierto. Si eliges bien, ahorrarás tu tiempo y el de tus compañeros, y las extensiones te resultarán naturales y fáciles.4

Tabla 4-1. Puntos fuertes y débiles de los distintos paradigmas de programación
Paradigma de programación Fuerza Debilidad

Programación procedimental

Adición de operaciones

Adición de tipos (polimórficos)

Programación orientada a objetos

Adición de tipos (polimórficos)

Adición de operaciones

consciente de estos puntos fuertes: en función de tus expectativas sobre cómo evolucionará una base de código, elige el enfoque adecuado para diseñar las extensiones. No ignores los puntos débiles, y no te metas en un desafortunado infierno de mantenimiento.

Supongo que a estas alturas te estarás preguntando si es posible tener dos conjuntos abiertos. Pues bien, que yo sepa, esto no es imposible, pero suele ser poco práctico. Como ejemplo, en la"Directriz 18: Cuidado con el rendimiento del Visitante acíclico", te mostraré que el rendimiento puede sufrir un golpe importante.

Puesto que puede que seas un fan de la programación basada en plantillas y de esfuerzos similares en tiempo de compilación, también debería hacer la nota explícita de que el polimorfismo estático no tiene las mismas limitaciones. Mientras que en el polimorfismo dinámico, uno de los ejes de diseño (tipos y operaciones) tiene que ser fijo, en el polimorfismo estático, ambas piezas de información están disponibles en tiempo de compilación. Por tanto, ambos aspectos pueden ampliarse fácilmente (si lo haces correctamente).5

Directriz 16: Utilizar al Visitante para ampliar las operaciones

En la sección anterior, has visto que el punto fuerte de la programación orientada a objetos (POO) es la adición de tipos y su punto débil es la adición de operaciones. Por supuesto, la POO tiene una respuesta a esa debilidad: el patrón de diseño Visitante.

El patrón de diseño Visitante es uno de los patrones de diseño clásicos descritos por el Gang of Four (GoF). Se centra en permitirte añadir con frecuencia operaciones en lugar de tipos. Permíteme explicar el patrón de diseño Visitante utilizando el ejemplo de juguete anterior: el dibujo de formas.

En la Figura 4-1, puedes ver la jerarquía Shape. La clase Shape es de nuevo la clase base de un cierto número de formas concretas. En este ejemplo, sólo existen las dos clases,Circle y Square, pero por supuesto es posible tener más formas. Además, podrías imaginar las clases Triangle, Rectangle o Ellipse.

Figura 4-1. Representación UML de una jerarquía de formas con dos clases derivadas (Circle y Square)

Analizar los problemas de diseño

Supongamos que estás seguro de que ya tienes todas las formas que vas a necesitar. Es decir, consideras que el conjunto de formas es un conjunto cerrado. Sin embargo, lo que te faltan son operaciones adicionales. Por ejemplo, te falta una operación para rotar las formas. Además, te gustaría serializar las formas, es decir, te gustaría convertir la instancia de una forma en bytes. Y, por supuesto, quieres dibujar formas. Además, quieres que cualquiera pueda añadir nuevas operaciones. Por tanto, esperas un conjunto abierto deoperaciones.6

Ahora, cada nueva operación requiere que introduzcas una nueva función virtual en la clase base. Desgraciadamente, eso puede ser problemático de diferentes maneras. La más obvia es que no todo el mundo puede añadir una función virtual a la clase base Shape. Yo, por ejemplo, no puedo simplemente ir y cambiar su código. Por tanto, este enfoque no cumpliría la expectativa de que todo el mundo pueda añadir operaciones. Aunque ya puedes ver esto como un veredicto final negativo, analicemos aún con más detalle el problema de las funciones virtuales.

Si decides utilizar una función virtual pura, tendrías que implementar la función en cada clase derivada. Para tus propias clases derivadas, podrías encogerte de hombros y considerar esto como un pequeño esfuerzo adicional. Pero también podrías causar trabajo extra a otras personas que hayan creado una forma heredando de la clase base Shape.7 Y eso es de esperar, ya que es la fuerza de la programación orientada a objetos: cualquiera puede añadir nuevos tipos fácilmente. Como esto es de esperar, puede ser una razón para no utilizar una función virtual pura.

Como alternativa, podrías introducir una función virtual normal, es decir, una función virtual con una implementación por defecto. Mientras que un comportamiento por defecto para una función rotate() parece una idea muy razonable, una implementación por defecto para una función serialize() no parece nada fácil. Admito que tendría que pensar mucho en cómo implementar una función así. Ahora podría sugerir simplemente lanzar una excepción por defecto. Sin embargo, esto significa que las clases derivadas deben implementar de nuevo el comportamiento que falta, y sería una función virtual pura disfrazada, o una clara violación del Principio de Sustitución de Liskov (véase "Directriz 6: Adherirse al comportamiento esperado de las abstracciones").

En cualquier caso, añadir una nueva operación a la clase base Shape es difícil o incluso imposible. La razón subyacente es que añadir funciones virtuales viola la OCP. Si realmente necesitas añadir nuevas operaciones con frecuencia, entonces debes diseñar de modo que la extensión de las operaciones sea fácil. Eso es lo que intenta conseguir el patrón de diseño Visitante.

Explicación del patrón de diseño visitante

La intención del patrón de diseño Visitante es permitir la adición de operaciones.

El patrón de diseño visitante

Intención: "Representar una operación a realizar sobre los elementos de una estructura de objetos. Visitante te permite definir una nueva operación sin cambiar las clases de los elementos sobre los que opera."8

En además de la jerarquía Shape, introduzco ahora la jerarquía ShapeVisitor en la parte izquierda de la Figura 4-2. La clase base ShapeVisitor representa unaabstracción de las operaciones de forma. Por ese motivo, se podría argumentar que ShapeOperationsería un nombre mejor para esa clase. Sin embargo, resulta beneficioso aplicarla "Directriz 14: Utiliza el nombre de un patrón de diseño para comunicar la intención". El nombre Visitante ayudará a los demás a entender el diseño.

Figura 4-2. Representación UML del patrón de diseño Visitante

La clase base ShapeVisitor incluye una función virtual pura visit() para cada forma concreta de la jerarquía Shape:

class ShapeVisitor
{
 public:
   virtual ~ShapeVisitor() = default;

   virtual void visit( Circle const&, /*...*/ ) const = 0;  1
   virtual void visit( Square const&, /*...*/ ) const = 0;  2
   // Possibly more visit() functions, one for each concrete shape
};

En este ejemplo, hay una función visit() para Circle(1) y otra para Square(2Por supuesto, podría haber más funciones visit() -por ejemplo, una paraTriangleuna para Rectangle, y otra para Ellipse-dado que también son clases derivadas de la clase base Shape.

Con la clase base ShapeVisitor en su sitio, ahora puedes añadir nuevas operaciones fácilmente. Todo lo que tienes que hacer para añadir una operación es añadir una nueva clase derivada. Por ejemplo, para activar la rotación de formas, puedes introducir la clase Rotate e implementar todas las funciones de visit(). Para permitir dibujar formas, todo lo que tienes que hacer es introducir una clase Draw:

class Draw : public ShapeVisitor
{
 public:
   void visit( Circle const& c, /*...*/ ) const override;
   void visit( Square const& s, /*...*/ ) const override;
   // Possibly more visit() functions, one for each concrete shape
};

Y puedes pensar en introducir varias clases Draw, una para cada biblioteca gráfica que necesites soportar. Puedes hacerlo fácilmente, porque no tienes que modificar ningúncódigo existente. Sólo es necesario ampliar la jerarquía ShapeVisitor añadiendonuevo código. Por tanto, este diseño cumple la OCP en lo que respecta a añadiroperaciones.

Para comprender completamente las características del diseño de software de Visitante, es importante entender por qué el patrón de diseño Visitante es capaz de cumplir la OCP. El problema inicial era que cada nueva operación requería un cambio en la clase base Shape. Visitante identifica la adición de operaciones como un punto de variación. Al extraer este punto de variación, es decir, al convertirlo en una clase independiente, se sigue el Principio de Responsabilidad Única (SRP): Shape no tiene que cambiar por cada nueva operación. Esto evita modificaciones frecuentes de la jerarquía Shape y permite añadir fácilmente nuevas operaciones. Por tanto, el SRP actúa como facilitador de la OCP.

Para utilizar visitantes (clases derivadas de la clase base ShapeVisitor ) en formas, ahora tienes que añadir una última función a la jerarquía Shape: la funciónaccept() (3):9

class Shape
{
 public:
   virtual ~Shape() = default;
   virtual void accept( ShapeVisitor const& v ) = 0;  3
   // ...
};

La función accept() se introduce como función virtual pura en la clase base y, por tanto, debe implementarse en cada clase derivada (4 y5):

class Circle : public Shape
{
 public:
   explicit Circle( double radius )
      : radius_( radius )
   {
      /* Checking that the given radius is valid */
   }

   void accept( ShapeVisitor const& v ) override { v.visit( *this ); }  4

   double radius() const { return radius_; }

 private:
   double radius_;
};


class Square : public Shape
{
 public:
   explicit Square( double side )
      : side_( side )
   {
      /* Checking that the given side length is valid */
   }

   void accept( ShapeVisitor const& v ) override { v.visit( *this ); }  5

   double side() const { return side_; }

 private:
   double side_;
};

La implementación de accept() es sencilla; basta con llamar a la funciónvisit() correspondiente en el visitante dado basándose en el tipo del Shape concreto. Esto se consigue pasando el puntero this como argumento a visit(). Así, la implementación de accept() es la misma en cada clase derivada, pero debido a un tipo diferente del puntero this, activará una sobrecarga diferente de la función visit()en el visitante dado. Por tanto, la clase base Shape no puede proporcionar una implementación por defecto.

Esta función accept() puede utilizarse ahora cuando necesites realizar una operación. Por ejemplo, la función drawAllShapes() utiliza accept() para dibujar todas las formas de un determinado vector de formas:

void drawAllShapes( std::vector<std::unique_ptr<Shape>> const& shapes )
{
   for( auto const& shape : shapes )
   {
      shape->accept( Draw{} );
   }
}

Con la adición de la función accept(), ahora puedes ampliar fácilmente tu jerarquía Shapecon operaciones. Ahora has diseñado para un conjunto abierto de operaciones ¡Increíble! Sin embargo, no hay una bala de plata, ni un diseño que funcione siempre. Cada diseño tiene sus ventajas, pero también sus inconvenientes. Así que antes de que empieces a celebrarlo, debería hablarte de los inconvenientes del patrón de diseño Visitante para que tengas una visión completa.

Análisis de las deficiencias del patrón de diseño visitante

Lamentablemente, el patrón de diseño Visitante de dista mucho de ser perfecto. Esto era de esperar, teniendo en cuenta que Visitante es una solución a una debilidad intrínseca de la programación orientada a objetos, en lugar de aprovechar los puntos fuertes de la programación orientada a objetos.

La primera desventaja es la escasa flexibilidad de implementación, que se hace evidente si consideras la implementación de un visitante Translate. El visitanteTranslate necesita desplazar el punto central de cada forma un desplazamiento determinado. Para ello, Translate necesita implementar una función visit() para cada Shape concreto. Especialmente para Translate, puedes imaginar que la implementación de estas funciones visit()sería muy similar, si no idéntica: no hay nada diferente entre trasladar un Circle y trasladar un Square. Aun así, tendrás que escribir todas las funcionesvisit(). Por supuesto, podrías extraer la lógica de las funciones visit()e implementarla en una tercera función independiente para minimizar la duplicación según el principio DRY.10 Pero, por desgracia, los estrictos requisitos impuestos por la clase base no te dan libertad para implementar estas funciones visit() como una sola. El resultado es un código repetitivo:

class Translate : public ShapeVisitor
{
 public:
   // Where is the difference between translating a circle and translating
   // a square? Still you have to implement all virtual functions...
   void visit( Circle const& c, /*...*/ ) const override;
   void visit( Square const& s, /*...*/ ) const override;
   // Possibly more visit() functions, one for each concrete shape
};

Una inflexibilidad de implementación similar es el tipo de retorno de las funciones visit(). La decisión sobre lo que devuelve la función se toma en la clase base ShapeVisitor. Las clases derivadas no pueden cambiarlo. Lo habitual es almacenar el resultado en el visitante y acceder a él más tarde.

El segundo inconveniente es que, con el patrón de diseño Visitante establecido, resulta difícil añadir nuevos tipos. Antes suponíamos que estabas seguro de tener todas las formas que necesitarías. Esta suposición se ha convertido ahora en una restricción. Añadir una nueva forma en la jerarquía Shape exigiría actualizar toda la jerarquía ShapeVisitor: tendrías que añadir una nueva función virtual pura a la clase base ShapeVisitor, y esta función virtual tendría que ser implementada por todas las clases derivadas. Por supuesto, esto conlleva todas las desventajas que hemos comentado antes. En concreto, obligarías a otros desarrolladores a actualizar sus operaciones.11 Así pues, el patrón de diseño Visitante requiere un conjunto cerrado de tipos y, a cambio, proporciona un conjunto abierto de operaciones.

La razón subyacente a esta restricción es que existe una dependencia cíclica entre la clase base ShapeVisitor, las formas concretas (Circle, Square, etc.) y la clase base Shape (véase la Figura 4-3).

Figura 4-3. Gráfico de dependencias del patrón de diseño Visitante

La clase base ShapeVisitor depende de las formas concretas, ya que proporciona una función visit() para cada una de estas formas. Las formas concretas dependen de la clase base Shape, ya que tienen que cumplir todas las expectativas y requisitos de la clase base. Y la clase base Shape depende de la clase base ShapeVisitor debido a la función accept(). Debido a esta dependencia cíclica, ahora podemos añadir nuevas operaciones fácilmente (en un nivel inferior de nuestra arquitectura debido a una inversión de dependencia), pero ya no podemos añadir tipos fácilmente (porque eso tendría que ocurrir en el nivel superior de nuestra arquitectura). Por eso, llamamos al patrón de diseño clásico Visitante Visitante Cíclico.

La tercera desventaja es la naturaleza intrusiva de un visitante. Para añadir un visitante a una jerarquía existente, tienes que añadir el virtual accept() a la clase base de esa jerarquía. Aunque esto suele ser posible, sigue adoleciendo del problema habitual de añadir una función virtual pura a una jerarquía existente (véase la"Directriz 15: Diseño para la adición detipos u operaciones"). Si, por el contrario, no es posible añadir la función accept(), esta forma de Visitante no es una opción. En ese caso, no te preocupes: veremos otra forma no intrusiva del patrón de diseño Visitante en la"Directriz 17: Considera std::variant paraimplementar Visitante".

Una cuarta desventaja, aunque ciertamente más oscura, es que la función accept() es heredada por las clases derivadas. Si más adelante alguien añade otra capa de clases derivadas (y ese alguien podrías ser tú) y se olvida de anular la funciónaccept(), el visitante se aplicará al tipo equivocado. Y, por desgracia, no recibirías ninguna advertencia al respecto. Esto no es más que una prueba más de que añadir nuevos tipos se ha vuelto más difícil. Una posible solución para esto sería declarar las clasesCircle y Square como final, lo que, sin embargo, limitaría futuras extensiones.

"Vaya, son muchas desventajas. ¿Hay alguna más?" Sí, desgraciadamente hay dos más. La quinta desventaja es obvia si tenemos en cuenta que ahora, para cada operación, tenemos que llamar a dos funciones virtuales. Inicialmente, no conocemos ni el tipo de operación ni el tipo de forma. La primera función virtual es la función accept(), a la que se le pasa un ShapeVisitor abstracto. La función accept()resuelve ahora el tipo concreto de forma. La segunda función virtual es la funciónvisit(), a la que se pasa un tipo concreto Shape. La función visit() resuelve ahora el tipo concreto de la operación. Lamentablemente, este llamado doble envío no es gratuito. Al contrario, desde el punto de vista del rendimiento, debes considerar que el patrón de diseño Visitante es bastante lento. En la siguiente pauta proporcionaré algunas cifras de rendimiento.

Al hablar de rendimiento, también debo mencionar otros dos aspectos que tienen un impacto negativo en el rendimiento. En primer lugar, normalmente asignamos cada forma y visitante individualmente. Considera la siguiente función main():

int main()
{
   using Shapes = std::vector< std::unique_ptr<Shape> >;

   Shapes shapes;

   shapes.emplace_back( std::make_unique<Circle>( 2.3 ) );  6
   shapes.emplace_back( std::make_unique<Square>( 1.2 ) );  7
   shapes.emplace_back( std::make_unique<Circle>( 4.1 ) );  8

   drawAllShapes( shapes );

   // ...

   return EXIT_SUCCESS;
}

En esta función main(), todas las asignaciones se producen mediante std::make_unique()(6,7y8). Estas muchas y pequeñas asignaciones cuestan tiempo de ejecución por sí solas y, a la larga, provocarán la fragmentación de la memoria.12 Además, la memoria puede distribuirse de forma desfavorable, poco amigable para la caché. En consecuencia, solemos utilizar punteros para trabajar con las formas y visitantes resultantes. Las indirecciones resultantes hacen que sea mucho más difícil para un compilador realizar cualquier tipo de optimización y aparecerán en los benchmarks de rendimiento. Sin embargo, para ser sinceros, no se trata de un problema específico de los Visitantes, sino que estos dos aspectos son bastante comunes a la programación orientada a objetos en general.

La última desventaja del patrón de diseño Visitante es que la experiencia ha demostrado que este patrón de diseño es bastante difícil de comprender y mantener plenamente. Se trata de una desventaja bastante subjetiva, pero la complejidad de la intrincada interacción de las dos jerarquías a menudo se siente más como una carga que como una solución real.

En resumen, el patrón de diseño Visitante es la solución de la programación orientada a objetos para permitir la fácil extensión de operaciones en lugar de tipos. Eso se consigue introduciendo una abstracción en forma de clase base ShapeVisitor, que te permite añadir operaciones sobre otro conjunto de tipos. Aunque éste es un punto fuerte único de Visitante, por desgracia viene acompañado de varias deficiencias: las inflexibilidades de implementación en ambas jerarquías de herencia debido a un fuerte acoplamiento a los requisitos de las clases base, un rendimiento bastante malo y la complejidad intrínseca de Visitante lo convierten en un patrón de diseño bastante impopular.

Si ahora estás indeciso sobre si utilizar o no un Visitante clásico, tómate tu tiempo para leer la siguiente sección. Te mostraré una forma diferente de implementar un Visitante, una solución que probablemente será mucho más satisfactoria para ti.

Directriz 17: Considera std::variant paraimplementar el Visitante

En "Pauta 16: Utilizar Visitante para Extender Operaciones", te presenté el patrón de diseño Visitante. Imagino que no te enamoraste de inmediato: aunque el Visitante tiene sin duda un par de propiedades únicas, también es un patrón de diseño bastante complejo, con algunos acoplamientos internos fuertes y deficiencias de rendimiento. No, ¡definitivamente no es amor! Sin embargo, no te preocupes, la forma clásica no es la única manera en que puedes implementar el patrón de diseño Visitante. En esta sección, me gustaría presentarte una forma diferente de implementar Visitante. Y estoy seguro de que este enfoque será mucho más de tu agrado.

Introducción a std::variant

En, al principio de este capítulo, hablamos de los puntos fuertes y débiles de los distintos paradigmas (programación orientada a objetos frente a programación procedimental). En concreto, hablamos de que la programación procedimental era especialmente buena para añadir nuevas operaciones a un conjunto de tipos existente. Así que, en lugar de buscar soluciones en la programación orientada a objetos, ¿qué tal si explotamos la fuerza de la programación procedimental? No, no te preocupes; por supuesto, no estoy sugiriendo volver a nuestra solución inicial. Ese enfoque era demasiado propenso a errores. En lugar de eso, estoy hablando de std::variant:

#include <cstdlib>
#include <iostream>
#include <string>
#include <variant>

struct Print  10
{
   void operator()( int value ) const
      { std::cout << "int: " << value << '\n'; }
   void operator()( double value ) const
      { std::cout << "double: " << value << '\n'; }
   void operator()( std::string const& value ) const
      { std::cout << "string: " << value << '\n'; }
};

int main()
{
   // Creates a default variant that contains an 'int' initialized to 0
   std::variant<int,double,std::string> v{};  1

   v = 42;        // Assigns the 'int' 42 to the variant  2
   v = 3.14;      // Assigns the 'double' 3.14 to the variant  3
   v = 2.71F;     // Assigns a 'float', which is promoted to 'double'  4
   v = "Bjarne";  // Assigns the string literal 'Bjarne' to the variant  5
   v = 43;        // Assigns the 'int' 43 to the variant  6

   int const i = std::get<int>(v);  // Direct access to the value  7

   int* const pi = std::get_if<int>(&v);  // Direct access to the value  8

   std::visit( Print{}, v );  // Applying the Print visitor  9

   return EXIT_SUCCESS;
}

Como es posible que aún no hayas tenido el placer de conocer el C++17std::variant, permíteme que te haga una introducción resumida, por si acaso. Una variante representa una de varias alternativas. La variante al principio de la función main()del ejemplo de código puede contener un int, un double, o un std::string(1). Fíjate en que he dicho o: una variante sólo puede contener una de estas tres alternativas. Nunca varias de ellas y, en circunstancias normales, nunca debe contener nada. Por eso, llamamos tipo suma a una variante: el conjunto de estados posibles es la suma de los estados posibles de las alternativas.

Una variante por defecto tampoco está vacía. Se inicializa con el valor por defecto de la primera alternativa. En el ejemplo, una variante por defecto contiene un entero de valor 0. Cambiar el valor de una variante es sencillo: basta con asignarle nuevos valores. Por ejemplo, podemos asignar el valor 42, lo que ahora significa que la variante almacena un entero de valor 42 (2). Si posteriormente asignamos el double 3,14, entonces la variante almacenará un double de valor 3,14 (3). Si alguna vez quieres asignar un valor de un tipo que no sea una de las alternativas posibles, se aplican las reglas de conversión habituales. Por ejemplo, si quieres asignar unfloat, en base a las reglas de conversión habituales se promovería a un double(4).

Para almacenar las alternativas, la variante proporciona el búfer interno suficiente para albergar la mayor de las alternativas. En nuestro caso, la mayor de las alternativas esstd::string, que suele tener entre 24 y 32 bytes (dependiendo de la implementación utilizada de la Biblioteca Estándar). Así, cuando asignes el literal de cadena"Bjarne", la variante limpiará primero el valor anterior (no hay mucho que hacer; es sólo un double) y luego, como es la única alternativa que funciona, construirá el std::string en su lugar dentro de su propio búfer (5). Cuando cambies de opinión y asignes el entero 43 (6), la variante destruirá correctamente el std::string mediante su destructor y reutilizará el búfer interno para el entero. Maravilloso, ¿verdad? La variante es de tipo seguro y siempre está correctamente inicializada. ¿Qué más podemos pedir?

Pues claro que quieres hacer algo con los valores dentro de la variante. No serviría de nada limitarse a almacenar el valor. Por desgracia, no puedes simplemente asignar una variante a cualquier otro valor, por ejemplo, un int, para recuperar su valor. No, acceder al valor es un poco más complicado. Hay varias formas de acceder a los valores almacenados, siendo la más directa std::get()(7). Con std::get() puedes consultar un valor de un tipo concreto. Si la variante contiene un valor de ese tipo, devuelve una referencia al mismo. Si no lo contiene, lanza el mensaje std::bad_variant_exception. Parece una respuesta bastante grosera, dado que has preguntado amablemente. Pero probablemente deberíamos alegrarnos de que la variante no pretenda contener algún valor cuando en realidad no lo tiene. Al menos es honesta. Hay una forma más agradable en std::get_if()(8). En comparación con std::get(), std::get_if() no devuelve una referencia, sino un puntero. Si solicitas un tipo que el std::variant no contiene, no lanza una excepción, sino que devuelve un nullptr. Sin embargo, existe una tercera forma, especialmente interesante para nuestros fines: std::visit() (9).std::visit() te permite realizar cualquier operación sobre el valor almacenado. O más exactamente, te permite pasar un visitante personalizado para realizar cualquier operación sobre el valor almacenado de un conjunto cerrado de tipos. ¿Te suena?

El visitante Print (10) que pasamos como primer argumento debe proporcionar un operador de llamada a función (operator()) para cada alternativa posible. En este ejemplo, eso se cumple proporcionando tresoperator()s: uno para int, uno para double, y uno para std::string. Cabe destacar que Print no tiene que heredar de ninguna clase base, y no tiene ninguna función virtual. Por lo tanto, no existe un fuerte acoplamiento a ningún requisito. Si quisiéramos, también podríamos colapsar los operadores de llamada a funciones para int y double en uno solo, ya que un int puede convertirse en un double:

struct Print
{
   void operator()( double value ) const
      { std::cout << "int or double: " << value << '\n'; }
   void operator()( std::string const& value ) const
      { std::cout << "string: " << value << '\n'; }
};

Aunque la cuestión de qué versión debemos preferir no nos interesa especialmente en este momento, te darás cuenta de que tenemos mucha flexibilidad de implementación. Sólo hay un acoplamiento muy laxo basado en la convención de que para cada alternativa tiene que haber un operator(), independientemente de la forma exacta. Ya no tenemos unaVisitor clase base que nos obligue a hacer las cosas de una forma muy concreta. Tampoco tenemos ninguna clase base para las alternativas: somos libres de utilizar tipos fundamentales como int y double, así como tipos de clase arbitrarios como std::string. Y quizás lo más importante, cualquiera puede añadir fácilmente nuevas operaciones. No es necesario modificar el código existente. Con esto, podemos argumentar que se trata de una solución procedimental, sólo que mucho más elegante que el planteamiento inicial basado en enumeraciones, que utilizaba una clase base para contener un discriminador.

Refactorizar el Dibujo de Formas comoSolución No Intrusiva Basada en Valores

Con estas propiedades, std::variant se adapta perfectamente a nuestro ejemplo de dibujo. Volvamos a implementar el dibujo de formas con std::variant. En primer lugar, refactorizamos las clasesCircle y Square:

//---- <Circle.h> ----------------

#include <Point.h>

class Circle
{
 public:
   explicit Circle( double radius )
      : radius_( radius )
   {
      /* Checking that the given radius is valid */
   }

   double radius() const { return radius_; }
   Point  center() const { return center_; }

 private:
   double radius_;
   Point center_{};
};


//---- <Square.h> ----------------

#include <Point.h>

class Square
{
 public:
   explicit Square( double side )
      : side_( side )
   {
      /* Checking that the given side length is valid */
   }

   double side  () const { return side_; }
   Point  center() const { return center_; }

 private:
   double side_;
   Point center_{};
};

Tanto Circle como Square se simplifican significativamente: ya no hay clase base Shape, ni necesidad de implementar ninguna función virtual, en particular la función accept(). Por tanto, este enfoque de Visitante no es intrusivo: ¡esta forma de Visitante puede añadirse fácilmente a los tipos existentes! Y no es necesario preparar estas clases para ninguna operación futura. Podemos centrarnos por completo en implementar estas dos clases como lo que son: primitivas geométricas.

Sin embargo, la parte más bonita de la refactorización es el uso real destd::variant:

//---- <Shape.h> ----------------

#include <variant>
#include <Circle.h>
#include <Square.h>

using Shape = std::variant<Circle,Square>;  11


//---- <Shapes.h> ----------------

#include <vector>
#include <Shape.h>

using Shapes = std::vector<Shape>;  12

Como nuestro conjunto cerrado de tipos es un conjunto de formas, la variante contendrá ahora unCircle o un Square. ¿Y cuál es un buen nombre para una abstracción de un conjunto de tipos que representan formas? Pues...Shape(11). En lugar de una clase base que abstrae del tipo real de forma, std::variant adquiere ahora esta tarea. Si es la primera vez que lo ves, probablemente estés completamente asombrado. Pero espera, hay más: esto también significa que ahora podemos dar la espalda a std::unique_ptr. Recuerda: la única razón por la que utilizábamos punteros (inteligentes) era para poder almacenar distintos tipos de formas en el mismo vector. Pero ahora que std::variant nos permite hacer lo mismo, podemos simplemente almacenar objetos variantes dentro de un único vector (12).

Con esta funcionalidad implementada, podemos escribir operaciones personalizadas sobre formas. Seguimos interesados en dibujar formas. Para ello, ahora implementamos elvisitante Draw:

//---- <Draw.h> ----------------

#include <Shape.h>
#include /* some graphics library */

struct Draw
{
   void operator()( Circle const& c ) const
      { /* ... Implementing the logic for drawing a circle ... */ }
   void operator()( Square const& s ) const
      { /* ... Implementing the logic for drawing a square ... */ }
};

Una vez más, seguimos la expectativa de implementar un operator() para cada alternativa: uno para Circle y otro para Square. Pero esta vez podemos elegir: no es necesario implementar ninguna clase base y, por tanto, no es necesario anular ninguna función virtual. Por lo tanto, no hay necesidad de implementar exactamente un operator()para cada alternativa. Aunque en este ejemplo parece razonable tener dos funciones, tenemos la opción de combinar las dos operator()s en una sola función. También tenemos la opción de elegir el tipo de retorno de la operación. Podemos decidir localmente qué debemos devolver, y no es una clase base la que, independientemente de la operación concreta, toma una decisión global. Flexibilidad de implementación. Acoplamiento suelto ¡Increíble!

La última pieza del puzzle es la función drawAllShapes():

//---- <DrawAllShapes.h> ----------------

#include <Shapes.h>

void drawAllShapes( Shapes const& shapes );


//---- <DrawAllShapes.cpp> ----------------

#include <DrawAllShapes.h>

void drawAllShapes( Shapes const& shapes )
{
   for( auto const& shape : shapes )
   {
      std::visit( Draw{}, shape );
   }
}

La función drawAllShapes() se refactoriza para hacer uso de std::visit(). En esta función, ahora aplicamos el visitante Draw a todas las variantes almacenadas en un vector.

El trabajo de std::visit() es realizar el envío de tipos necesario por ti. Si el std::variant dado contiene un Circle, llamará al Draw::operator() para círculos. En caso contrario, llamará al Draw::operator() para cuadrados. Si quisieras, podrías realizar manualmente la misma operación con std::get_if():

void drawAllShapes( Shapes const& shapes )
{
   for( auto const& shape : shapes )
   {
      if( Circle* circle = std::get_if<Circle>(&shape) ) {
         // ... Drawing a circle
      }
      else if( Square* square = std::get_if<Square>(&shape) ) {
         // ... Drawing a square
      }
   }
}

Sé lo que estás pensando: "¡Tonterías! ¿Por qué querría hacer eso? Daría lugar a la misma pesadilla de mantenimiento que una solución basada en enum". Estoy completamente de acuerdo contigo: desde el punto de vista del diseño de software, sería una idea terrible. Aun así, y tengo que decir que esto es difícil de admitir en el contexto de este libro, puede haber una buena razón para hacerlo (a veces): el rendimiento. Lo sé, ahora he despertado tu interés, pero como de todas formas ya casi estamos listos para hablar del rendimiento, permíteme aplazar esta discusión sólo unos párrafos. Volveré sobre ello, ¡lo prometo!

Con todos estos detalles, por fin podemos refactorizar la función main(). Pero no hay mucho trabajo que hacer: en lugar de crear círculos y cuadrados mediantestd::make_unique(), simplemente creamos círculos y cuadrados directamente, y los añadimos al vector. Esto funciona gracias al constructor no explícito de variante, que permite la conversión implícita de cualquiera de las alternativas:

//---- <Main.cpp> ----------------

#include <Circle.h>
#include <Square.h>
#include <Shapes.h>
#include <DrawAllShapes.h>

int main()
{
   Shapes shapes;

   shapes.emplace_back( Circle{ 2.3 } );
   shapes.emplace_back( Square{ 1.2 } );
   shapes.emplace_back( Circle{ 4.1 } );

   drawAllShapes( shapes );

   return EXIT_SUCCESS;
}

El resultado final de esta solución basada en valores es asombrosamente fascinante: no hay clases base en ninguna parte. Ni funciones virtuales. Sin punteros. Sin asignaciones manuales de memoria. Las cosas son tan sencillas como podrían serlo, y hay muy poco código repetitivo. Además, a pesar de que el código tiene un aspecto muy diferente al de lassoluciones anteriores, las propiedades arquitectónicas son idénticas: todo el mundo puede añadir nuevas operaciones sin necesidad de modificar el código existente (ver Figura 4-4). Por lo tanto, seguimos cumpliendo la OCP en lo que respecta a añadir operaciones.

Figura 4-4. Gráfico de dependencia de la solución std::variant

Como ya se ha mencionado, este enfoque del Visitante no es intrusivo. Desde un punto de vista arquitectónico, esto te proporciona otra ventaja significativa en comparación con el Visitante clásico. Si comparas el gráfico de dependencias del Visitante clásico (véase la Figura 4-3) con el gráfico de dependencias de la soluciónstd::variant (véase la Figura 4-4), verás que el gráfico de dependencias de la solución std::variant tiene un segundo límite arquitectónico. Esto significa que no existe ninguna dependencia cíclica entre std::variant y sus alternativas. Debería repetirlo para subrayar su importancia: ¡no hay dependencia cíclica entre std::variant y sus alternativas! Lo que puede parecer un pequeño detalle es en realidad una enorme ventaja arquitectónica. ¡ENORME! Por ejemplo, podrías crear sobre la marcha una abstracción basada en std::variant:

//---- <Shape.h> ----------------

#include <variant>
#include <Circle.h>
#include <Square.h>

using Shape = std::variant<Circle,Square>;  13


//---- <SomeHeader.h> ----------------

#include <Circle.h>
#include <Ellipse.h>
#include <variant>

using RoundShapes = std::variant<Circle,Ellipse>;  14


//---- <SomeOtherHeader.h> ----------------

#include <Square.h>
#include <Rectangle.h>
#include <variant>

using AngularShapes = std::variant<Square,Rectangle>;  15

Además de la abstracción Shape que ya hemos creado (13), puedes crear el std::variant para todas las formas redondas (14), y puedes crear un std::variant para todas las formas angulares (15), ambas posiblemente alejadas de la abstracción Shape. Puedes hacerlo fácilmente porque no hay necesidad de derivar de varias clases base Visitante. Al contrario, las clases de formas no se verían afectadas. Por tanto, ¡el hecho de que la solución std::variant no sea intrusiva es de un valor arquitectónico máximo!

Parámetros de rendimiento

Yo sé cómo te sientes ahora mismo. Sí, así es el amor a primera vista. Pero lo creas o no, hay más. Hay un tema del que aún no hemos hablado, un tema muy querido por todos los desarrolladores de C++, y es, por supuesto, el rendimiento. Aunque éste no es realmente un libro sobre rendimiento, merece la pena mencionar que no tienes que preocuparte por el rendimiento de std::variant. Ya puedo prometerte que es rápido.

Sin embargo, antes de mostrarte los resultados de las pruebas comparativas, permíteme un par de comentarios sobre las mismas. Rendimiento. Por desgracia, el rendimiento siempre es un tema difícil. Siempre hay alguien que se queja del rendimiento. Por eso, con mucho gusto me saltaría este tema por completo. Pero luego hay otras personas que se quejan de las cifras de rendimiento que faltan. Suspiro. Bueno, como parece que siempre habrá quejas, y como los resultados son demasiado buenos para pasarlos por alto, te mostraré un par de resultados de pruebas comparativas. Pero con dos condiciones: primero, no los considerarás valores cuantitativos que representen la verdad absoluta, sino sólo valores cualitativos que apunten en la dirección correcta. Y segundo, no lanzarás una protesta delante de mi casa porque no haya utilizado tu compilador, o bandera de compilación, o IDE favorito. ¿Lo prometes?

Tú: ¡asintiendo y prometiendo no quejarte de cosas triviales!

OK, genial, entonces la Tabla 4-2 te da los resultados del benchmark.

Tabla 4-2. Resultados de la prueba comparativa para diferentes implementaciones del Visitante
Aplicación para visitantes GCC 11.1 Clang 11.1

Patrón de diseño Visitante clásico

1.6161 s

1.8015 s

Solución orientada a objetos

1.5205 s

1.1480 s

Solución Enum

1.2179 s

1.1200 s

std::variant (con std::visit())

1.1992 s

1.2279 s

std::variant (con std::get_if())

1.0252 s

0.6998 s

Para que estas cifras tengan sentido, debo darte un poco más de información. Para que el escenario fuera un poco más realista, utilicé no sólo círculos y cuadrados, sino también rectángulos y elipses. Luego ejecuté 25.000 operaciones sobre 10.000 formas creadas al azar. En lugar de dibujar estas formas, actualicé el punto central mediante vectores aleatorios.13 Esto se debe a que esta operación de traslación es muy barata y me permite mostrar mejor la sobrecarga intrínseca de todas estas soluciones (como las indirecciones y la sobrecarga de las llamadas a funciones virtuales). Una operación cara, como draw(), ocultaría estos detalles y podría dar la impresión de que todos los enfoques son bastante similares. Utilicé tanto GCC 11.1 como Clang 11.1, y para ambos compiladores sólo añadí los indicadores de compilación-O3 y -DNDEBUG. La plataforma que utilicé fue macOS Big Sur (versión 11.4) en un Intel Core i7 de 8 núcleos a 3,8 GHz y 64 GB de memoria principal.

La conclusión más obvia de los resultados de las pruebas comparativas es que la solución variante es mucho más eficiente que la solución Visitante clásica. Esto no debería sorprender: debido al doble envío, la implementación clásica de Visitante contiene mucha indirección y, por tanto, también es difícil de optimizar. Además, la disposición en memoria de los objetos forma es perfecta: en comparación con todas las demás soluciones, incluida la solución basada en enum, todas las formas se almacenan contiguamente en memoria, que es la disposición más compatible con la caché que se podría elegir. La segunda conclusión es que std::variant es, en efecto, bastante eficiente, si no sorprendentemente eficiente. Sin embargo, es sorprendente que la eficiencia dependa en gran medida de si utilizamosstd::get_if() o std::visit() (prometí volver sobre esto). Tanto GCC como Clang producen un código mucho más lento cuando utilizan std::visit(). Supongo que std::visit() no está perfectamente implementado y optimizado en ese punto. Pero, como he dicho antes, el rendimiento siempre es difícil, y no intento aventurarme más en este misterio.14

Y lo que es más importante, la belleza de std::variant no se estropea por unas malas cifras de rendimiento. Al contrario: los resultados de rendimiento ayudan a intensificar tu recién descubierta relación constd::variant.

Análisis de las deficiencias de la solución std::variant

Aunque no quiere poner en peligro esta relación, considero que es mi deber señalar también un par de desventajas con las que tendrás que lidiar si utilizas la solución basada en std::variant.

En primer lugar, debo volver a señalar lo obvio: como solución similar al patrón de diseño Visitante y basada en la programación procedimental, std::variant también se centra en proporcionar unconjunto abierto de operaciones. El inconveniente es que tendrás que tratar con unconjunto cerrado de tipos. Añadir nuevos tipos causará problemas muy similares a los que experimentamos con la solución basada en enum en la"Pauta 15: Diseño para la adición detipos u operaciones". En primer lugar, tendrías que actualizar la propia variante, lo que podría provocar una recompilación de todo el código que utilice el tipo variante (¿recuerdas la actualización de la enum?). Además, tendrías que actualizar todas las operaciones y añadir la posible falta de operator() para la(s) nueva(s) alternativa(s). Lo bueno es que el compilador se quejaría si falta uno de estos operadores. Lo malo es que el compilador no producirá un mensaje de error bonito y legible, sino algo que se parece un poco más a la madre de todos los mensajes de error relacionados con plantillas. En conjunto, se parece bastante a nuestra experiencia anterior con la solución basada en enum.

Un segundo problema potencial que debes tener en cuenta es que debes evitar poner tipos de tamaños muy diferentes dentro de una variante. Si al menos una de las alternativas es mucho mayor que las demás, podrías desperdiciar mucho espacio almacenando muchas de las alternativas pequeñas. Esto afectaría negativamente al rendimiento. Una solución sería no almacenar directamente las alternativas grandes, sino almacenarlas detrás de punteros, mediante objetos Proxy, o utilizando el patrón de diseño Puente.15 Por supuesto, esto introduciría una indirección, que también cuesta rendimiento. Si esto es una desventaja en términos de rendimiento en comparación con el almacenamiento de valores de diferente tamaño es algo que tendrás que evaluar.

Por último, pero no por ello menos importante, siempre debes ser consciente de que una variante puede revelar mucha información. Aunque representa una abstracción en tiempo de ejecución, los tipos que contiene siguen siendo claramente visibles. Esto puede crear dependencias físicas de la variante, es decir, al modificar uno de los tipos alternativos, puede que tengas que recompilar cualquier código dependiente. La solución sería, de nuevo, almacenar punteros u objetos Proxy en su lugar, lo que ocultaría los detalles de la implementación. Desgraciadamente, eso también afectaría al rendimiento, ya que gran parte de las mejoras de rendimiento se deben a que el compilador conoce los detalles y los optimiza en consecuencia. Por tanto, siempre hay un compromiso entre rendimiento y encapsulación.

A pesar de estos defectos, en resumen, std::variant resulta ser un maravilloso sustituto del patrón de diseño Visitante basado en la POO. Simplifica mucho el código, elimina casi todo el código repetitivo y encapsula las partes feas y de mantenimiento intensivo, y ofrece un rendimiento superior. Además, std::variant resulta ser otro gran ejemplo de que un patrón de diseño trata de una intención, no de detalles de implementación.

Directriz 18: Cuidado con el rendimiento del visitante acíclico

Como viste en la "Directriz 15: Diseño para la adición detipos u operaciones", tienes que tomar una decisión al utilizar el polimorfismo dinámico: puedes admitir un conjunto abierto de tipos o un conjunto abierto de operaciones. No puedes tener ambos. Pues bien, he dicho específicamente que, según mi leal saber y entender, tener ambas cosas no es realmenteimposible, sino que suele ser poco práctico. Para demostrarlo, permíteme presentarte otra variación del patrón de diseño Visitante: el Visitante acíclico.16

En la "Pauta 16: Utilizar Visitante para ampliar operaciones", viste que existe una dependencia cíclica entre los actores clave del patrón de diseño Visitante: la claseVisitor depende de los tipos concretos de formas (Circle, Square, etc.), los tipos concretos de formas dependen de la clase base Shape, y la clase base Shape depende de la clase baseVisitor. Debido a esa dependencia cíclica, que bloquea a todos esos actores clave en un nivel de la arquitectura, es difícil añadir nuevos tipos a un Visitante. La idea del Visitante Acíclico es romper esta dependencia.

La Figura 4-5 muestra un diagrama UML para el Visitante acíclico. En comparación con el Visitante GoF, aunque sólo hay pequeñas diferencias en la parte derecha de la figura, hay algunos cambios fundamentales en la parte izquierda. Lo más importante es que la claseVisitor clase base se ha dividido en varias clases base: la clase base AbstractVisitor y una clase base para cada tipo concreto de forma (en este ejemplo, Circle​Visi⁠tory SquareVisitor). Todos los visitantes tienen que heredar de la clase base AbstractVisitor, pero ahora también tienen la opción de heredar de las clases base de visitantes específicas de cada forma. Si una operación quiere admitir círculos, hereda de la clase base Circle​Visi⁠tor e implementa la función visit() para Circle. Si no quiere admitir círculos, simplemente no hereda de CircleVisitor.

Figura 4-5. Representación UML de un Visitante acíclico

El siguiente fragmento de código muestra una posible implementación de las clases base Visitor:

//---- <AbstractVisitor.h> ----------------

class AbstractVisitor  1
{
 public:
   virtual ~AbstractVisitor() = default;
};


//---- <Visitor.h> ----------------

template< typename T >
class Visitor  2
{
 protected:
   ~Visitor() = default;

 public:
   virtual void visit( T const& ) const = 0;
};

La clase base AbstractVisitor no es más que una clase base vacía con un destructor virtual (1). No es necesaria ninguna otra función. Como verás, AbstractVisitor sólo sirve como etiqueta general para identificar a los visitantes y no tiene que proporcionar ninguna operación por sí misma. En C++ solemos implementar las clases base de visitantes específicas de la forma en forma de plantilla de clase (2). La plantilla de clase Visitor se parametriza en un tipo de forma específico e introduce el virtual puro visit() para esa forma concreta.

En la implementación de nuestro visitante Draw, ahora heredaríamos de tres clases base: de la AbstractVisitor, de la Visitor<Circle> y de la Visitor<Square>, ya que queremos dar soporte tanto a la Circle como a la Square:

class Draw : public AbstractVisitor
           , public Visitor<Circle>
           , public Visitor<Square>
{
 public:
   void visit( Circle const& c ) const override
      { /* ... Implementing the logic for drawing a circle ... */ }
   void visit( Square const& s ) const override
      { /* ... Implementing the logic for drawing a square ... */ }
};

Esta elección de implementación rompe la dependencia cíclica. Como demuestrala Figura 4-6, el nivel alto de la arquitectura ya no depende de los tipos de forma concretos. Tanto las formas (Circle y Square) como las operaciones están ahora en el nivel bajo de la frontera arquitectónica. Ahora podemos añadir tanto tipos como operaciones.

Llegados a este punto, estás mirando muy sospechosamente, casi acusadoramente, en mi dirección. ¿No he dicho que tener ambas cosas no sería posible? Evidentemente, es posible, ¿no? Pues, una vez más, no he afirmado que fuera imposible. Más bien dije que podría ser poco práctico. Ahora que has visto la ventaja de un Visitante acíclico, déjame que te muestre los inconvenientes de este enfoque.

Figura 4-6. Gráfico de dependencia del Visitante acíclico

En primer lugar, echemos un vistazo a la implementación de la función accept() en Circle:

//---- <Circle.h> ----------------

class Circle : public Shape
{
 public:
   explicit Circle( double radius )
      : radius_( radius )
   {
      /* Checking that the given radius is valid */
   }

   void accept( AbstractVisitor const& v ) override {  3
      if( auto const* cv = dynamic_cast<Visitor<Circle> const*>(&v) ) {  4
         cv->visit( *this );  5
      }
   }

   double radius() const { return radius_; }
   Point  center() const { return center_; }

 private:
   double radius_;
   Point center_{};
};

Habrás notado un pequeño cambio en la jerarquía Shape: la función virtual accept() acepta ahora un AbstractVisitor(3). Recuerda también que elAbstractVisitor no implementa ninguna operación por sí mismo. Por lo tanto, en lugar de llamar a una función visit() en el AbstractVisitor, el Circle determina si el visitante dado admite círculos realizando una dynamic_cast aVisitor<Circle> (4). Ten en cuenta que realiza una conversión de punteros, lo que significa que el dynamic_cast devuelve o bien un puntero válido a un Visitor<Circle> o bien un nullptr. Si devuelve un puntero válido a un Visitor<Circle>, llama a la función visit() correspondiente (5).

Aunque sin duda este enfoque funciona y forma parte de la ruptura de la dependencia cíclica del patrón de diseño Visitante, un dynamic_cast siempre deja una mala sensación. Undynamic_cast siempre debe dejar una sensación de desconfianza, porque, si se utiliza mal, puede romper una arquitectura. Eso ocurriría si realizamos un lanzamiento desde el nivel alto de la arquitectura a algo que reside en el nivel bajo de la arquitectura.17 En nuestro caso, en realidad está bien utilizarlo, ya que el uso se produce en el nivel bajo de nuestra arquitectura. Así, no rompemos la arquitectura al insertar conocimiento sobre un nivel inferior en el nivel alto.

La verdadera deficiencia reside en la penalización por tiempo de ejecución. Al ejecutar la misma prueba comparativa que en "Pauta 17: Considera std::variant paraimplementar el Visitante" para un Visitante acíclico, te das cuenta de que el tiempo de ejecución es casi un orden de magnitud superior al tiempo de ejecución de un Visitante cíclico (véase la Tabla 4-3). La razón es que un dynamic_cast es lento. Muy lento. Y es especialmente lento para esta aplicación. Lo que estamos haciendo aquí es una fundición cruzada. No estamos simplemente haciendo un casting descendente a una clase derivada concreta, sino que estamos haciendo un casting a otra rama de la jerarquía de herencia. Este crosscast, seguido de una llamada a una función virtual, es mucho más costoso que un simple downcast.

Tabla 4-3. Resultados de rendimiento de diferentes implementaciones de Visitantes
Aplicación para visitantes GCC 11.1 Clang 11.1

Visitante acíclico

14.3423 s

7.3445 s

Visitante cíclico

1.6161 s

1.8015 s

Solución orientada a objetos

1.5205 s

1.1480 s

Solución Enum

1.2179 s

1.1200 s

std::variant (con std::visit())

1.1992 s

1.2279 s

std::variant (con std::get_if())

1.0252 s

0.6998 s

Aunque arquitectónicamente, un Visitante Acílico es una alternativa muy interesante, desde un punto de vista práctico, estos resultados de rendimiento podrían descalificarlo. Esto no significa que no debas utilizarlo, pero al menos ten en cuenta que el mal rendimiento podría ser un argumento muy sólido para otra solución.

1 ¡Ya te veo poniendo los ojos en blanco! "¡Oh, otra vez ese aburrido ejemplo!". Pero ten en cuenta a los lectores que se saltaron el Capítulo 3. Ahora están contentos de poder leer esta sección sin una larga explicación sobre el escenario.

2 Desde C++11, tenemos a nuestra disposición las enumeraciones de ámbito, a veces también llamadas enumeraciones de clase debido a la sintaxis enum class. Esto ayudaría, por ejemplo, a que el compilador avisara mejor de las sentencias switch incompletas. Si has detectado esta imperfección, ¡te has ganado un punto extra!

3 Scott Meyers, C++ más eficaz: 35 nuevas formas de mejorar tus programas y diseños, Tema 31 (Addison-Wesley, 1995).

4 Ten en cuenta que la noción matemática de conjunto abierto y cerrado es algo completamente distinto.

5 Como ejemplo de diseño con polimorfismo estático, considera los algoritmos de la Biblioteca de Plantillas Estándar (STL). Puedes añadir fácilmente nuevas operaciones, es decir, algoritmos, pero también añadir fácilmente nuevos tipos que se puedan copiar, ordenar, etc.

6 Siempre es difícil hacer predicciones. Pero normalmente tenemos una idea bastante aproximada de cómo evolucionará nuestra base de código. En caso de que no tengas ni idea de cómo evolucionarán las cosas, deberías esperar al primer cambio o extensión, aprender de ello y tomar una decisión más informada. Esta filosofía forma parte del conocido principio YAGNI, que te previene contra la sobreingeniería; véase también "Directriz 2. Diseñar para el cambio" : Diseña para el cambio".

7 No me alegraría por ello -quizá incluso me sentiría muy infeliz-, pero probablemente no me enfadaría. ¿Pero tus otros compañeros? En el peor de los casos, podrían excluirte de la próxima barbacoa del equipo.

8 Erich Gamma y otros, Patrones de diseño: Elementos del software orientado a objetos reutilizable.

9 accept() es el nombre utilizado en el libro GoF. Es el nombre tradicional en el contexto del patrón de diseño Visitante. Por supuesto, eres libre de utilizar cualquier otro nombre, como apply(). Pero antes de cambiarle el nombre, ten en cuenta el consejo de la "Directriz 14: Utiliza el nombre de un patrón de diseño para comunicar la intención".

10 Realmente es aconsejable extraer la lógica en una sola función. La razón es el cambio: si tienes que actualizar la implementación más adelante, no querrás realizar el cambio varias veces. Esa es la idea del principio DRY (Don't Repeat Yourself). Así que recuerda la "Directriz 2: Diseña para el cambio".

11 Considera el riesgo: ¡esto podría excluirte de las barbacoas de equipo de por vida!

12 La fragmentación de la memoria es mucho más probable cuando utilizas std::make_unique(), que encapsula una llamada a new, en lugar de algunos esquemas de asignación especiales.

13 Efectivamente, estoy utilizando vectores aleatorios, creados mediante std::mt19937 y std::uniform_real_distribution, pero sólo después de probarme a mí mismo que el rendimiento no cambia para GCC 11.1, y sólo ligeramente para Clang 11.1. Aparentemente, crear números aleatorios no es especialmente caro en sí mismo (al menos en mi máquina). Puesto que prometiste considerar estos resultados como cualitativos, deberíamos estar bien.

14 Existen otras implementaciones alternativas de código abierto de variant. La biblioteca Boost proporciona dos implementaciones: Abseil proporciona una implementación variante, y merece la pena echar un vistazo a la implementación de Michael Park.

15 El patrón Proxy es otro de los patrones de diseño GoF, que lamentablemente no cubro en este libro debido a la limitación de páginas. Sin embargo, entraré en detalle sobre el patrón de diseño Puente; consulta la "Directriz 28: Construye puentes para eliminarlas dependencias físicas".

16 Para más información sobre el patrón Visitante acíclico por su inventor, véase Robert C. Martin, Agile Software Development: Principles, Patterns, and Practices (Pearson).

17 Consulta la "Directriz 9: Presta atención a la propiedad de las abstracciones" para obtener una definición de los términos alto nivel y bajo nivel.

Get Diseño de software en C now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.