Skip to content

Diseño de Clases Basado en Políticas

Andres Angarita edited this page Dec 28, 2019 · 4 revisions

Descripción de las políticas y las clases de políticas, importantes técnicas de diseño de clases que permiten la creación de bibliotecas flexibles y altamente reutilizables. En resumen, el diseño de clases basado en políticas fomenta el emsamblaje de una clase con un comportamiento complejo de muchas clases pequeñas llamadas políticas, cada una de las cuales se ocupa de un solo aspecto conductual o estructural. Una política establece una interfaz relacionada con un problema específico. Puede implementar políticas de varias maneras siempre que respete la interfaz de políticas.

Debido a que puede mezclar y combinar políticas, puede lograr un conjunto combinatorio de comportamientos mediante el uso de un pequeño núcleo de componentes elementales.

Este capítulo explica el problema que las políticas están destinadas a resolver, proporciona detalles del diseño de clases basadas en políticas y brinda consejos sobre cómo descomponer una clase en políticas.

La multiplicidad del diseño de software

La ingeniería de software, tal vez más que cualquier otra disciplina de ingeniería, exhibe una rica multiplicidad: puede hacer lo mismo de muchas maneras correctas, y hay infinitos matices entre lo correcto y lo incorrecto. Cada camino abre un nuevo mundo. Una vez que elige una solución, aparece una gran cantidad de posibles variantes, en todos los niveles, desde el nivel de la arquitectura del sistema hasta el más mínimo detalle de codificación. El diseño de un sistema de software es una elección de soluciones de un espacio de solución combinatoria.

Pensemos en un artefacto de diseño simple de bajo nivel: un puntero inteligente. Una clase de puntero inteligente puede ser de un solo subproceso o multiproceso, puede usar varias estrategias de propiedad, puede hacer varias compensaciones entre seguridad y velocidad, y puede no admitir conversiones automáticas al tipo de puntero sin procesar el subyacente. Todas las características se pueden combinar libremente y, por lo general, exactamente una solución es la más adecuada para un área determinada de su aplicación.

La falla de la interfaz Todo-en-Uno

Implementar todo bajo el mismo paraguas de una interfaz para hacer todo no es una buena solución, por varias razones.

Algunas consecuencias negativas importantes son la sobrecarga intelectual, el gran tamaño y la ineficiencia. Las clases gigantescas no tienen éxito porque incurren en una gran sobrecarga de aprendizaje, tienden a ser innecesariamente grandes y conducen a un código que es mucho más lento que la versión artesanal equivalente.

Pero quizas el problema más imortante de una interfaz demasiado rica es la perdida de seguridad de tipo estático. Un propósito esencial de la arquitectura de un sistema es hacer cumplir ciertos axiomas por diseño, por ejemplo, no puede crear dos objetos Singleton o crear objetos de familias disjuntas. Idealmente, un diseño debería imponer la mayoría de las restricciones en tiempo de compilación.

En una interfaz grande que lo abarca todo, es muy difícil hacer cumplir tales restricciones. Normalmente, una vez que ha elegido un cierto conjunto de restricciones de diseño, solo ciertos subconjuntos de la interfaz grande permanece semánticamente válidos. Crece una brecha entre los usos sitácticamente válidos y semanticamente válidos de la biblioteca. El programador puede escribir un número creciente de construcciones que son sintácticamente válidas, pero semánticamente ilegales.

Los diseños imponen restricciones; en consecuencia, las bibliotecas orientadas al diseño deben ayudar a los diseños creados por el usuario a imponer sus propias restricciones predefinidas.

El beneficio de las plantillas

Las plantillas son un buen candidato para hacer frente a los comportamientos combinatorios porque generan código en tiempo de compilación en función de los tipos proporcionados por el usuario.

Las plantillas de clase se pueden personalizar de formas que no son compatibles con las clases regulares. Si desea implementar un caso especial, puede especializar cualquier función miembro de una plantilla de clase para una instanciación específica de la plantilla de clase. Por ejemplo, si la plantilla es SmartPtr<T>, puede especializar cualquier función miembro para, por ejemplo, SmartPtr<Widget>. Esto le brinda una buena granularidad en el comportamiento de personalización.

Además, para las plantillas de clase con múltiples parámetros, puede usar la especialización de plantilla parcial. La especialización de plantilla parcial le brinda la capacidad de especializar una plantilla de clase solo para algunos de sus argumentos. Por ejemplo, dada la definición:

template <class T, class U> class SmartPtr { ... };

puede especializarse SmartPtr<T, U> para Widget y cualquier otro tipo utilizando la siguiente sintaxis:

template <class U> class SmartPtr<Widget, U> { ... };

El tiempo de compilación innato y la naturaleza combinatoria de las plantillas las hacen muy atractivas para crear piezas de diseño. Tan pronto como intente implementar tales diseños, se toporá con varios problemas que no son evidentes:

  1. No puedes especializar la estructura. Usando plantillas solo, no puede especializar la estructura de una clase (sus miembros de datos). Solo puedes especializar funciones

  2. La especialización de las funciones miembros no se escala. Puede especializar cualquier función miembro de una plantilla de clase con un parámetro de plantilla, pero no puede especializar funciones miembro individuales para plantillas con múltiples parámetros de plantilla.

  3. El escritor de la biblioteca no puede proporcionar múltiples valores predeterminados. En el mejor de los casos, un implementador de plantilla de clase puede proporcionar una implementación predeterminada única para cada función miembro. No puede proporcionar varios valores predeterminado para una función miembro de plantilla.

Ahora compare la lista de inconvenientes de herencia múltiple con la lista de inconvenientes de plantilla. Curiosamente, la herencia múltiple y las plantillas fomentan compensaciones complementarias. La herencia múltiple pierde información de tipo, que abunda en plantillas. La especialización de plantillas no escala, pero la herencia múltiple escala bastante bien. Puede proporcionar solo un valor predeterminado para la función miembro de plantilla, pero puede escribir un número ilimitado de clases base.

Este análisis sugiere que una combinación de plantillas y herencia múltiple podría generar un dispositivo muy flexible, apropiado para crear bibliotecas de elementos de diseño.

Políticas y clases de políticas

Las políticas y clases de políticas ayudan a implementar elementos de diseño seguros, eficientes y altamente personalizables. Una política define una interfaz de clase o una interfaz de plantilla de clase. La interfaz consta de uno o todos los siguientes: definiciones de tipo interno, funciones miembro y variables miembro.

Las políticas tienen mucho en común con los rasgos (Alexandrescu 2000a) pero difieren en que ponen menos énfasis en el tipo y más énfasis en el comportamiento. Además, las políticas recuerdan el patrón de diseño de la Estrategia (Gamma et al. 1995), con el giro de que las políticas están limitadas en el tiempo de compilación.

Por ejemplo, definamos una política para crear objetos. La política Creator prescribe una plantilla de clase de tipo T. Esta plantilla de clase debe exponer una función miembro llamada Create que no toma argumentos y devuelve un puntero a T. Semánticamente, cada llamada a Create debe devolver un puntero a un nuevo objeto de tipo T. El modo exacto en el que se crea el objeto se deja a la latitud de la implementación de lo política.

Definamos algunas clases de políticas que implementan la política Creator. Una forma posible es usar el nuevo operador. Otra forma es usar malloc y una llamada al nuevo operador de colocación (Meyers 1998b). Otra forma sería crear objetos clonando un objeto prototipo. Aquí hay ejemplos de los tres métodos:

template <class T>
struct OperatorNewCreator {
	static T* Create() {
		return new T;
	}
};

template <class T>
struct MallocCreator {
	static T* Create() {
		void* buf = std::malloc(sizeof(T));
		if (!buf) return 0;
		return new(buf) T;
	}
};

template <class T>
struct PrototypeCreator {
	PrototypeCreator(T* pObj = 0) : pPrototype_(pObj) {}
	T* Create()	{
		return pPrototype_ ? pPrototype_->Clone() : 0;
	}
	T* GetPrototype() { return pPrototype_; }
	void SetPrototype(T* pObj) { pPrototype_ = pObj; }
private:
	T* pPrototype_;
};

Para una política dada, puede haber un número ilimitado de implementaciones. Las implementaciones de una política se denominan clases de políticas. Las clases de políticas no están destinadas a un uso independiente; en cambio, son heredados o contenidos dentro de otras clases.

Un aspecto importante es que, a diferencia de las interfaces clásicas (colecciones de funciones virtuales puras), las interfaces de las políticas están poco definidas. Las políticas están orientadas a la sintaxis, no a la firma. En otras palabras, Creator especifica que construcciones sintácticas deberían ser válidas para una clase conforme, en lugar de que funciones exactas deben implementar esa clase. Por ejemplo, la política Creator no especifica que Create debe ser estático o virtual; el único requisito es que la plantilla de clase defina una función miembro Create. Además, Creator dice que Create debería devolver un puntero a un nuevo objeto. En consecuencia, es aceptable que, en casos especiales, Create devuelva cero o arroje una excepción.

Puede implementar varias clases de políticas para una política determinada. Todos deben respetar la interfaz definida por la política. El usuario luego elige que clase de política usar en estructuras más grandes, como verá.

Las tres clases de políticas definidas anteriormente tienen implementaciones diferentes e incluso interfaces ligeramente diferentes (por ejemplo, PrototypeCreator tiene dos funciones adicionales: GetPrototype y SetPrototype). Sin embargo, todos definen una función llamada Create con el tipo de retorno requerido, por lo que se ajustan a la política de Creator.

Veamos ahora como podemos diseñar una clase que explote la política Creator. Dicha clase contendrá o heredará una de las tres clases definidas anteriormente, como se muestra a continuación:

// Library code
template <class CreationPolicy>
class WidgetManager : public CreationPolicy {
	...
};

Las clases que usan una o más políticas se denominan host o clases de host. En el ejemplo anterior, WidgetManager es una clase de host con una política. Los anfitriones son responsables de esamblar las estructuras y comportamientos de sus políticas en una sola unidad compleja.

Al crear instancias de la plantilla WidgetManager, el cliente pasa la política deseada:

// Application code
typedef WidgetManager< OperatorNewCreator<Widget> > MyWidgetMgr;

Analicemos el contexto resultante. Siempre que un objeto de tipo MyWidgetMgr necesita crear un Widget, invoca Create() para su subobjeto de política OperatorNewCreator<Widget>. Sin embargo, es el usuario de MyWidgetMgr quien elige la política de creación. Efectivamente, a través de su diseño, MyWidgetMgr permite a sus usuarios configurar un aspecto especifico de la funcionalidad de MyWidgetMgr.

Esta es la esencia del diseño de clases basado en políticas.

Implementación de clases de políticas con parámetros de plantilla

A menudo, como es el caso anterior, el argumento de la plantilla de la política es redundante. Es incómodo que el usuario deba pasar el argumento de la plantilla OperatorNewCreator explicitamente. Normalmente, la clase de host ya conoce, o puede deducir facílmente, el argumento de plantilla de la clase de política. En el ejemplo anterior, MyWidgetMgr siempre maneja objetos de tipo Widget, por lo que requerir que el usuario especifique Widget nuevamente en la instancia de OperatorNewCreator es redundante y potencialmente peligroso.

En este caso, el código de la biblioteca puede usar parámetros de plantilla de plantilla para especificar políticas, como se muestra a continuación:

// Library code
template <template <class Created> class CreationPolicy>
class WidgetManager : public CreationPolicy<Widget> {
	...
};

A pesar de las apariencias, el símbolo Created no contribuye a la definición de WidgetManager. No puede usar Created dentro de WidgetManager: es un argumento formal para CreationPolicy y simplemente puede omitirse.

El código de la aplicación ahora solo necesita proporcionar el nombre de la plantilla al crear una instancia de WidgetManager:

// Application code
typedef WidgetManager<OperatorNewCreator> MyWidgetMgr;

Usar parámetros de plantilla de plantilla con clases de políticas no es simplemente una cuestión de conveniencia; a veces, es esencial que la clase de host tenga acceso a la plantilla para que el host pueda instanciar con un tipo diferente. Por ejemplo, suponga que WidgetManager también necesita crear objetos de tipo Gadget usando la misma política de creación. Entonces el código se vería así:

// Library code
template <template <class> class CreationPolicy>
class WidgetManager : public CreationPolicy<Widget> {
	...
	void DoSomething()	{
		Gadget* pW = CreationPolicy<Gadget>().Create();
		...
	}
};

¿El uso de políticas te da una ventaja? A primera vista, no mucho. Por un lado, todas las implementaciones de la política Creator son trivialmente simples. El autor de WidgetManager ciertamente podría haber escrito el código de creación en línea y evitar la molestia de hacer de WidgetManager una plantilla.

Pero el uso de políticas le da una gran flexibilidad a WidgetManager. Primero, puede cambiar las políticas desde el exterior tan facilmente como cambiar un argumento de plantilla cuando crea una instancia de WidgetManager. En segundo lugar, puede proporcionar sus propias políticas especificas de su aplicación concreta. Puede usar new, malloc, prototipos o una biblioteca de asignación de memoria peculiar que solo usa sus sistema. Es como si WidgetManager fuera un pequeño motor de generación de código, y usted configura las formas en que genera el código.

Para facilitar la vida de los desarrolladores de aplicaciones, el autor de WidgetManager podría definir una batería de políticas de uso frecuente y proporcionar un argumento de plantilla predeterminado para la política que se usa más comúnmente:

template <template <class> class CreationPolicy = OperatorNewCreator>
class WidgetManager ...

Tenga en cuenta que las políticas son bastante diferentes de las meras funciones virtuales. Las funciones virtuales prometen un efecto similar: el implementador de una clase define funciones de nivel superior en términos de funciones virtuales primitivas y permite al usuario anular el comportamiento de esas primitivas. Sin embargo, como se muestra arriba, las políticas vienen con conocimiento de tipo enriquecido y enlace estático, que son ingredientes esenciales para los diseños de edificios. ¿No están los diseños llenos de reglas que dictan antes del tiempo de ejecución cómo interactúan los tipos entre sí y que puedes y qué no puedes hacer? Las políticas le permiten generar diseños mediante la combinación de opciones simples de una manera segura. Además, debido a que el enlace entre una clase de host y sus políticas se realiza en tiempo de compilación, el código es escrito y eficiente, comparable a su equivalente artesanal.

Por supuesto, las características de las políticas también las hacen inadecuadas para el enlace dinámico y las interfaces binarias, por lo que, en esencia, las políticas y las interfaces clásicas no compiten.

Implementación de clases de políticas con funciones miembro de plantilla

Una alternativa al uso de parámetros de plantillas de plantillas es usar funciones miembro de plantilla junto con clases simples. Es decir, la implementación de la política es una clase simple (a diferencia de una clase de plantilla) pero tiene uno o más miembros con plantilla.