Tuve un problema de diseño/implementación con una clase en particular mientras escribía algunas cosas básicas para el nuevo MotoGT, que me resultó muy interesante por lo simple que es el planteo, y por los caminos rebuscados que puede tomar la respuesta. Básicamente quería garantizar que las instancias de una clase particular solo se pudiesen crear por medio de una función auxiliar. La solución habitual es poner el constructor de la clase como privado/protegido y hacer a la clase amiga de la función. Pero esto no es 100% seguro desde algún punto de vista y, por sobretodo, se complica cuando queremos usar std::shared_ptr y std::make_shared con esa clase. Si no se van, les cuento mejor dónde aparece el problema, para qué sirve, y a qué solución llegué.
Primero, detalles del caso de aplicación. En MotoGT, ahora hay una clase Profile que guarda los datos y el estado de un jugador registrado en el juego, y una clase ProfilesManager que es la que se encarga de obtener la lista de perfiles, ordenarlos, etc. Entonces, al comienzo del juego ProfilesManager determina cuales son los archivos de perfiles disponibles y le pasa los nombres de usuarios de cada uno a la interfaz para que el jugador elija el suyo, o cree uno nuevo. En cualquier caso, la instancia de Profile para el jugador se debe crear desde ProfileManager, porque es el que sabe los detalles, y porque su lista requiere cierto mantenimiento interno en sincronía con los perfiles que halla dando vueltas. Por eso, esto está pensado para no crear directamente instancias de Profile, sino pedírselas a ProfileManager, que éste las cree para el resto de los componentes del juego.
Lo siguiente, es garantizar que esta regla lógica se cumpla siempre, y no sea solo un acuerdo de palabra. Esto es, hacer la regla explícita en la sintaxis del programa, para que si por algún error intento violar ese acuerdo el programa no compile. Quiero que el compilador me controle de que no meta la pata cuando cambie algo en el juego dentro de un año y ya no me acuerde de que no tenía que crear Profiles por mi cuenta. No se si hay un patrón de diseño específico para esto... en algún sentido la primer solución se parece al Factory, pero el objetivo no pasa por el polimorfismo (ni lo hay), sino por garantizar que las instancias se creen por un único camino (un método particular de la clase ProfileManager).
Una forma de resolver esto consiste en hacer el constructor de Profile privado para garantizar que no pueda andar creando instancias en cualquier lado, y agregar la excepción (el profile manager) mediante una declaración de amistad:
class Profile {
private:
friend class ProfilesManager;
Profile(std::string filename);
};
Así, el único constructor (o sea, la única forma de instanciar un Profile) solo se puede usar desde ProfileManager. Bah, no es tecnicamente la única, hay otros constructores implícitos, como el de copia, los tendría que poner a todos privados, pero para simplificar el ejemplo ignoremoslos por un rato, total la idea es la misma. Hasta aquí el problema incial, y una solución que funcionó muy bien por mucho tiempo.
Ahora, la complicación con C++11. En aras de simplificar el manejo de memoria y cumplir con las nuevas reglas de etiqueta de C++11 en adelante, estas instancias, que por varias razones deberán ser dinámicas, serán gestionadas con alguna suerte de smart pointer (¿se acuerdan de estos ejemplos?). Aquí entra en este caso el std::shared_ptr. Pero, entre las buenas prácticas con estos nuevos punteros se recomienda fuertemente no usar el constructor de std::shared_ptr (por varias razones con las que no los voy a aburrir), y usar en cambio una llamada a la función std::make_shared. Esta función recibe los argumentos para la construcción del objeto, lo construye a la par del std::shared_ptr con el que lo va a apuntar, y retorna este puntero. En la mayoría de los casos es funcionalmente equivalente a crear el std::shared_ptr a mano, pero evita explicitar el new, asegura el uso de un objeto nuevo (en otro caso podría haber problemas), y hasta queda más corto y bonito en el código. El problema en mi caso es que no funciona en combinación con lo que dije antes de Profile y ProfileManager...
Veamos otro poco de código. Antes de std::shared_ptr:
Profile *
ProfileManager::LoadProfile(std::string filename) {
return new Profile(filename);
// y acuérdense del delete!!
}
y después de std::shared_ptr:
std::shared_ptr<Profile>
ProfileManager::LoadProfile(std::string filename) {
return std::make_shared<Profile>(filename);
// el delete se hará automágicamente
}
El segundo código no compila. Pues el verdadero new se hace dentro de la función std::make_shared, y como el constructor de Profile es privado, eso resulta en un error. He aquí el problema. Y no, la solución no es hacer al constructor amigo de std::make_shared, porque entonces cualquiera podría hacer un std::make_shared en cualquier lado y crear Profiles sin pasar por el ProfileManager.
Hay una solución fácil, y otra interesante que desarrollé después de varias pruebas. La fácil sería usar el constructor de std::shared_ptr en lugar de la función auxiliar std::make_shared. El primero recibe el puntero común al objeto ya creado, y entonces el new sería responsabilidad del método ProfilesManager::LoadProfile, que sí tiene acceso al constructor privado de Profile. Pero discutamos el plan B, porque estoy aburrido, porque dicen que el new va abajo de la alfombra, porque es interesante, y porque hasta tiene una pequeña ventaja.
Después de un rato de probar, llegué a la siguiente idea: hacer el constructor de Profile público, pero requerir entre sus parámetros una instancia de un objeto auxiliar, cuyo constructor sí sea privado y solo accesible por ProfilesManager:
class Profile {
public:
class ConstructionKey {
private:
friend class ProfilesManager;
ConstructionKey(); // ctor privado
};
Profile(const ConstructionKey &key,
std::string filename); // ctor público
};
entonces:
std::shared_ptr<Profile>
ProfileManager::LoadProfile(std::string filename) {
return std::make_shared<Profile>( \
Profile::ConstructionKey(),filename);
}
¿Cómo es esto? Bien, la idea es que para usar ese constructor (que todos ven, es público) se necesita una llave (una instancia de Profile::ConstructorKey). El Profile se construye en std::make_shared, pero la a la llave necesaria para usar ese constructor la construye el ProfilesManager. Y éste es el único capaz de construir una de esas llaves, porque la clase de la llave tiene su constructor privado, y ProfileManager es su única amiga (como era Profile antes de los std::shared_ptr). Creo que es una buena solución, y al usar la clase el código que se escribe es bastante explícito acerca de su lógica y funcionamiento. En ese sentido la nueva interfaz es más o menos auto-explicativa, y no introduce mucha complejidad. Solo debemos agregar un parámetro más en cada llamada al constructor.
También le quise dar vueltas al asunto para no tener siquiera que pasar esa llave. Intenté poner el argumento de la llave al final y darle un valor por defecto, pero no funcionó. Al usar la clase desde fuera del ProfileManager, el parseo de la cabecera de Profile fallaba porque el argumento por defecto no era accesible. Entonces, lo siguiente es tratar de evitar que los demás clientes de Profile siquiera procesen ese constructor. La forma de hacerlo es con templates. Si el constructor es templatizado, y lo que uso para construir la llave es genérico, entonces solo se va a especializar donde efectivamente intente usar el constructor. Así que el argumento por defecto debe ser suficientemente genérico como para que no moleste cuando no intento usarlo, pero suficientemente explícito como para que valga como valor por defecto cuando realmente lo use desde ProfilesManager. Bien, si combinamos esto con un par de sobrecargas del constructor, no hay forma simple de resolverlo sin recurrir a esos trucos raros que hacía con la regla SFINAE. Y eso sí es bastante horrible y complejo a nivel de sintaxis, y no estoy dispuesto a ensuciar tanto mi preciosa clasesita.
Así que me quedo con el último código que mostré, que hasta tiene una ventaja más. En el ejemplo original (el primero, donde no había std::shared_ptr), la clase ProfilesManager tenía acceso a cualquier cosa privada dentro de Profile, no solo al constructor. Y en algún escenario eso también podría ser peligroso. Con esta nueva solución, eso no ocurre, el único método especial es el constructor (o cualquier conjunto de métodos que yo elija agregando esa clave-argumento). En conclusión, cumple su objetivo, creo que su uso es muy claro, no molesta mucho, y es fácil y rápido de implementar. No se si no estaré reinventando la rueda y esto será un patrón con algún nombre fancy. En caso de que no lo tenga, podríamos bautizarlo como: Key-Based-Public-Private-Constructors-Access-Control-Policy-MotoGT-Edition... pattern, y tal vez me gane un lugar en el Guiness por poner el nombre de patrón más largo del mundo.
No hay comentarios:
Publicar un comentario