Desafortunadamente el término "contenedor" no existe -si no es un armazón metálico- como sustantivo en castellano, pero para eso está la informática: para alterar los nervios de los puristas y modificar, de facto, para bien o mejor para mal, el lenguaje. Así diremos, parafraseando a la Real Academia, que un objeto “contenedor” (o de tipo contenedor) es “el que lleva o encierra objetos dentro de sí”. De la misma manera afirmaremos que una clase “contenedora” es una clase cuyas instancias pueden contener otras instancias. Obviaremos, pues, expresamente, el exacto vocablo “continente”, cuya acepción como “cosa que contiene en sí a otra” es en realidad lo que necesitamos, pues aquí la ortodoxia del lenguaje deberá ceder frente a la costumbre establecida: la práctica totalidad de los asistentes a cursos prefiere el neologismo, pues “continente” ofrece unas connotaciones de “uso restrictivo de la continencia” no deseadas. Pero, ¿qué interés tienen estas baratijas pseudo-lingüísticas? Pues resulta que, como el perspicaz lector ya habrá adivinado, continuamente estamos usando clases y objetos contenedores; de hecho existen muchos libros que, de forma mayormente lastimosa, dedican casi todo su espacio a la ejemplificación de los mismos: pilas, bolsas, conjuntos, vectores, matrices, listas, listas enlazadas, colas, etc. Su utilización es tan frecuente que la circunstancia de que, hasta hace poco, C++ no incluyera clases contenedoras como parte del lenguaje indudablemente ha perjudicado la extensión de su adopción. Piénsese, por ejemplo, que el esperanto es un lenguaje bienintencionado (y recordemos aquí a Valle-Inclán, buen ilustrador de la materia con que está empedrado el infierno) dotado de una sintaxis y pronunciación racionales que en teoría facilitarían su urgente adopción universal. Sin embargo algo falla, pues es evidente que estas líneas no están en esperanto [1]. Pasemos a otro escenario: si yo me siento bien con RPG, me he acostumbrado a él, le he cogido cariño con el tiempo y no creo en la muerte asistida, ¿por qué demonios he de cambiar a un lenguaje pretendidamente mejor, como anuncian pueda ser C++? Y lo mismo puedo pensar si estoy cuidando de Cobol, Visual Basic, C, Pascal, etc.: ¿No es demasiada presumida la asunción que si no cambio es debido a un cúmulo de ciertas incapacidades? Bueno, un lenguaje se usa en la práctica, independientemente de los bienintencionados motivos que lo amparen, en tanto que proporciona facilidades para el tipo de programación para el que se pretende utilizar como herramienta. En este sentido cabe destacar que C++ es ‑en la actualidad‑ un lenguaje de extensa sintaxis y gran prolijidad semántica (sólo hay que contar que el último borrador del estándar del lenguaje, del 20 de septiembre de 1994, consta de 654 páginas en formato A-4, y en él "sólo" se describe el lenguaje C++), y su concepción y desarrollo se han ligado, sobre casi todo lo demás, a su practicidad como herramienta de programación, de tal forma que si realmente se necesitaba una facilidad ésta se añadía al acervo del lenguaje. Pero, ¿proporciona C++, por ejemplo, las facilidades sobre contenedores que necesitamos? Pues bien, sí. Resulta que ANSI X3J16 ha incorporado recientemente como parte del lenguaje una biblioteca de plantillas de contenedores [2](STL, The Standard Template Library: La Biblioteca Estándar de Plantillas), que en un artículo posterior diseccionaremos pedagógicamente. Pero yendo más allá descubriríamos, en palabras de Stroustrup, que la génesis de las plantillas se basa "en el deseo de expresar la parametrización de clases contenedoras" [3], situando de esta manera en un lugar privilegiado, por su importancia, al diseño y uso de tales contenedores. En realidad C++ se convierte, así, en uno de los lenguajes mejor preparados para la gestión de contenedores. Y aunque esto pueda tildarse de opinión subjetiva, a lo que sigue me remito, pues la intención primera de este artículo es, además de ponderar la importancia de los contenedores, llegar a explicitar los mecanismos, sintaxis, sutilezas y carencias que animan el soporte conceptual en que se apoyan las plantillas (templates) en C++. Pero el tema es largo, muy largo, así que ¡al grano!. DISEÑO DE CONTENEDORES Aparte de lo anteriormente expresado, los contenedores son vitales para los programadores por una razón adicional: virtualmente todas las actuales bases de objetos (OODBS: Object-Oriented Database Systems) gestionan buena parte de los accesos a objetos mediante estas estructuras. Pero a esto atenderemos más adelante. Por ahora simplemente consideraremos las distintas estrategias que, de forma inexorable, nos conducirán al empleo de plantillas en C++. En principio para el diseño de contenedores en C++ podemos tener en cuenta, básicamente, cuatro enfoques distintos: lo obvio, el preprocesado, la herencia y las plantillas. Pero vayamos paso a paso. LA OPCIÓN INGENUA : CUESTIÓN DE ÁMBITOS Una primera solución elemental consiste en usar un pequeño truco para sustituir en cada ocasión únicamente el tipo de objetos a usar por el contenedor, sin tener que modificar la definición de éste. De esta manera, al definir la siguiente clase en un fichero denominado "coleccio.h":
si queremos una colección de enteros deberíamos codificar algo de la siguiente guisa:
Si por otra parte queremos -puro capricho- una colección de decimales, podríamos codificar, en un ámbito diferente, así:
Lo que en ambos casos hemos hecho es, en primer lugar, asignar una sustitución del identificador TIPO con sentido para el lenguaje, para después, usando del preprocesador, incluir el código de definición de la clase colección (mediante la directiva "#include"). Seguidamente se define una variable (objeto) del tipo Coleccion (que en realidad, en ese momento, no es una clase colección genérica, sino que es ya, de hecho, una colección de enteros en el primer caso y otra de doubles en el segundo). Por último se llama a una función miembro de la clase con el argumento adecuado en cada caso. ¡Ea, parece que esto funciona!, podría aquí exclamar el incauto lector. Pero no, desafortunadamente ¡esto no funciona! En primer lugar la clase Coleccion depende, para su compilación, de una definición exterior a la misma, de forma que se genera una necesidad de secuencialidad bien difícil de documentar. Por otro lado resulta que si compilamos las líneas:
aparecerá en primer lugar un aviso indicándonos que la redefinición de TIPO como Cliente no es idéntica a su anterior definición como double (¡naturalmente!). Pero, atención, se trata de un aviso, no de un error, y esto quiere decir que el compilador simplemente ignorará esta nueva redefinición, de forma que al llegar a la línea en que se incluye de nuevo el fichero “coleccio.h”, con la ingenua intención de reprocesar el identificador TIPO, podrán pasar dos cosas: si el fichero de cabecera incluye la típica construcción
entonces, dado que se ha definido “coleccio_h” en la pasada anterior del preprocesador, se ignorará en la práctica la nueva lectura del fichero; si, por el contrario, no se incluyesen estas instrucciones (con la vaga y fatal idea de forzar el nuevo preprocesamiento de la clase) entonces aparecería un error por “múltiple declaración de la clase Coleccion”. En cualquier caso la últ ima línea originará un error por “desajuste de tipo en el parámetro”: se espera un double, y aquí se le pasa un Cliente (recordemos que el compilador ha ignorado nuestros infantiles deseos). De aquí se colige que, con este esquema, no podríamos usar dos colecciones que manejen distintos tipos en el mismo ámbito. Y esto es inaceptable. Debemos encontrar una forma de compatibilizar tales dos distintas colecciones. ¿Y qué tal ‑preguntaría aquí el lector inquieto‑ si definimos la primera clase con el identificador "doubleColeccion" y la segunda con el de "ClienteColeccion"? Bueno, querido lector, esto puede funcionar. Veámoslo. LA TRAMPA DEL PREPROCESADOR Bueno, necesitamos un fichero "ppcolecc.h" en el que escribiremos lo siguiente, usando de la sintaxis del preprocesador:
De esta forma si codificamos
el preprocesador realizará el siguiente trabajo por nosotros:
y, de la misma manera, si codificamos
obtendremos la definición de una clase con identificador "doubleColeccion", así que ahora ya podemos usar en el mismo ámbito dos colecciones que, con el mismo funcionamiento interno, contienen objetos de tipos distintos: ints y doubles, pues sus identificadores respectivos son también distintos. ¡Vaya, ‑exclamará aquí el lector de antes‑ mi idea ha funcionado perfectamente! Pues no, reincidente lector. Ha fallado de nuevo, y esto se está conviertiendo en un hábito. Resulta que, al fin, esta idea puede devenir mala malísima de la muerte. Echémosle, si no, un vistazo a la siguiente galería de horrores:
Se deja como ejercicio al lector la comprobación individual de los errores en cada línea. En definitiva, se puede fácilmente apreciar que las macrosustituciones del preprocesador ("macros", si usamos un flagrante neologismo) no entienden de tipos: su "inteligencia" está limitada a la mera sustitución léxica. Así, por ejemplo, la línea
intentaría generar el código de una clase con un identificador tal que así:
lo que, naturalmente, generaría un error en compilación sobre una línea que en realidad no está visible en el código fuente. Claro que esto podría arreglarse de la siguiente forma:
pero lo que aquí estamos haciendo es ... suplir el trabajo que usualmente el lenguaje hace por nosotros, reconociendo a la vez la incapacidad del preprocesador respecto de los tipos y su alejamiento del lenguaje. Una de las mejores características de C++ es su fuerte chequeo de tipos que, en el caso de las macrosustituciones, se pierde como se perdió Cuba: de forma irremediable. Húyase, pues, del preprocesador siempre que sea posible ("Me gustaría ver abolido el Cpp" [6], Stroustrup dixit). Pero, entonces, ¿qué hacer respecto de los contenedores genéricos? Bueno, la mayoría de bibliotecas de C++ se basan en un uso más o menos intensivo de la herencia mediante la derivación de clases. ¿Qué tal si aplicamos la derivación y los mecanismos de conversión, implícitos o no, de C++ para procurar la genericidad deseada? Al grano. CUANDO LA HERENCIA ES DISPUTADA Si deseamos un contenedor genérico (esto es, que admita objetos de cualesquiera de los tipos que usamos, o de un subconjunto de estos), necesitamos que todos los objetos que hayan de formar el "contenido" deriven de una misma clase base, usualmente llamada "Objeto". Volvamos a nuestra clase:
Si deseamos una colección en la que insertar libros, lo único que necesitamos es hacer derivar la clase "Libro" de "Objeto":
de forma que ahora podríamos codificar lo siguiente:
y esto funciona perfectamente, pues aunque en la última línea se ha usado como argumento un puntero a un objeto Libro donde se esperaba un puntero a un objeto de clase Objeto, dado que aquél deriva públicamente de éste, se produce ‑como el lector ya sabe‑ una conversión implícita de tipos, y el puntero a Libro se transforma en un puntero a Objeto, y todo funciona como en los cuentos de Perrault. Bien, sigamos. Pero antes una consideración adicional: ¿y si deseáramos mantener una colección de bibliotecas ‑o sea, una colección de colecciones? Pues no tendríamos más que volver a aplicar el esquema derivativo. Esto es, necesitaríamos que la misma clase Coleccion derivara también, directa o indirectamente, de la clase Objeto:
para así poder codificar lo siguiente:
Por otro lado ahora podemos mantener perfectamente en el mismo ámbito varias colecciones conteniendo tipos distintos, pues en realidad se tratará de objetos del mismo tipo: una colección de enteros es exactamente del mismo tipo que otra de caracteres (ambas son instanciaciones de la clase Coleccion). Resulta así que todas nuestras clases derivarán de Objeto: o sea todas las instancias que manejemos serán Objetos (no olvidemos que la derivación pública en C++ representa relaciones "ES-UN"). Y aquí nos entrentamos con el primer problema. ¿Qué pasa si codificamos lo siguiente?
¡pues que obtendremos un bonito error! Pasamos como argumento un puntero a un int, pero "int" no es un tipo derivado de "Objeto", y por lo tanto el compilador se queja. ¿Qué hacer, entonces? Pues tenemos dos soluciones: bien codificamos una serie de funciones miembros sobrecargadas que puedan manejar los tipos predefinidos:
bien encapsulamos tales tipos en sendas clases derivadas, éstas sí, de la clase Objeto, de la forma:
de tal manera que ahora sí se podrá escribir
¿Cuál es la mejor opción? Bueno, piénsese que codificar una clase simuladora de un tipo predefinido no es un ejercicio trivial, y habría que hacerlo para cada uno de los tipos definidos en el sistema: float, long, char, etc. En contrapartida, si nos hacemos con el conjunto de tales clases en el futuro podremos utilizarlos sin codificación adicional en cualquiera de los contenedores que manejen Objetos, pero a costa de obligar al programador a no usar los tipos predefinidos, de tal manera que se influye en el posible código ya existente cuando se intentan usar estos contenedores, so pena de usar una mixtura de operadores de conversión y de sobrecarga, algo que casi nunca funciona totalmente de la forma deseada. La opción de la sobrecarga de las funciones miembros es, por otro lado, menos intrusiva, pero obliga a la codificación repetitiva de las funciones en cada nueva clase contenedora, de forma que cada nueva función miembro que se adicione y contenga punteros a Objeto en su signatura deberá ser replicada para cada uno de los tipos predefinidos. Si suponemos una jerarquía estable de contenedores esta sería la opción menos desventajosa: de hecho es la elegida por la mayoría de bibliotecas comerciales. Ya hemos visto que el enfoque derivativo en contenedores presenta problemas respecto de los tipos predefinidos. Ahora veremos que tales problemas persisten en los tipos definidos-por-el-usuario (las clases): nuestro enfoque se basa en que los objetos de una determinada clase podrán ser insertados en nuestros contenedores si derivan de la clase Objeto, pero ¿qué ocurre si necesitamos utilizar objetos de clases pertenecientes a una biblioteca comercial aparte? Imaginemos, por ejemplo, que una jerarquía de clases comercial nos proporciona una clase "Politico" que deseamos usar en nuestra aplicación: ¿qué sabe tal jerarquía de nuestra clase Objeto? Probablemente nada, así que añadir a tal clase una nueva clase base (Objeto) va a resultar, si no imposible, sí ciertamente costoso, porque, en cualquier caso, la biblioteca comercial no está preparada para conjuntarse con la nuestra. Y además habría que modificar el código de la biblioteca comercial: algo calificado como anatema, pues habría que repetir tales modificaciones (y volver a comprobar su repercusión) en cada versión de la misma. Y, con todo, suponemos que disponemos del código fuente de la biblioteca para poder modificar las clases, algo que no siempre ocurre. De lo anterior se infiere la necesidad ‑al menos hasta que examinemos esta cuestión bajo la luz de la herencia múltiple‑ que todos los contenidos hayan de ser "Objetos", pero la misma naturaleza polimórfica del contenedor fuerza el siguiente corolario: "cualquier Objeto (instancia de una clase derivada de Objeto) puede insertarse en nuestro contenedor". Esto significa que no podemos garantizar la homogeneidad de los tipos que maneje un contenedor dado. Examinemos el siguiente código:
Estamos introduciendo indiscriminadamente en la misma colección tanto objetos de tipo Persona como objetos de tipo ElementoQuimico. ¿Qué ocurrirá si escribimos lo siguiente?
El lector pensará que si el último elemento es una Persona la línea funcionará perfectamente. Pues no. Resulta que la función miembro "ultimo()" devuelve un puntero a Objeto (en realidad un puntero a la porción de Objeto contenida en el objeto accedido), y sobre este puntero se aplica la función "edad()", pero la clase Objeto no tiene definida tal función, por lo que el compilador arrojará un lógico error. Necesitamos, pues, un cast que troque el puntero a Objeto en un puntero a nuestra clase deseada (esta es, Persona), y explicitaré la sintaxis con profusión de paréntesis:
Esto es lo que se denomina un "downcast" (o sea, un cast de una clase base a una clase derivada de ésta). Así, claro, la línea funciona si el último elemento es una Persona. Pero ¿qué ocurre si tal elemento es un ElementoQuimico? Pues que se estará aplicando un cast sin sentido a tal objeto, obteniendo resultados impredecibles. Y encima este error no lo puede detectar el compilador (es un típico error de ejecución). Pero la cosa se puede complicar aún más: imaginemos la siguiente jerarquía:
En este caso Persona derivaría "virtualmente" de Objeto, y entonces ... ¡la línea con el cast originaría un error en compilación!, pues resulta que el lenguaje no permite un "downcasting" de una clase base virtual a una clase derivada de ésta (y el lector que dude de esta afirmación debería urgentemente consultar el ARM). La biblioteca NIH dispone, sin embargo, de una triquiñuela para salvar esta imposibilidad, facilitando un conjunto de funciones estáticas con identificador "castdown(...)" que, mediante macrosubstituciones del preprocesador, se generan automáticamente para cada clase del sistema. Pero, como ya sabe el lector, las soluciones del cpp no nos gustan, así que ... ¡al saco! Naturalmente una posible solución es evitar la aplicación de moldeos (casts) en contenedores de este tipo (y no es una idea descabellada intentar evitarlos en general), para lo cual deberíamos dotar a la clase base de funciones virtuales (usualmente puras) que deberán ser redefinidas en cada clase derivada, con independencia de que tales sean efectivamente usadas o no por la clase derivada. De todas formas, a pesar que Persona y ElementoQuimico comparten algún que otro elemento común, la edad no puede aplicarse a un ElementoQuimico, por lo que el cast aparece inevitable [7] . Para poder aplicar una cierta validación de tipos necesitaríamos un contenedor como el siguiente:
Lo que ocurre es que de esta manera ... ¡perdemos por completo la genericidad objeto de las presentes disquisiciones! Podríamos, por otra parte, intentar la siguiente desgraciada triquiñuela, usando del mecanismo de identificación de tipos en tiempo de ejecución (el esquema se ha simplificado de forma grosera para evitar perdernos en profundidades que ahora mismo resultan un poco lejanas, sobre todo teniendo en cuenta que la clase "typeid" está pendiente todavía de definición en el estándar del lenguaje [8]):
Con esta esquema se daría la posibilidad de crear "Colecciones con Tipo" (o "Colecciones Tipificadas") pasando un argumento en el constructor que, copiado a un dato miembro de la clase, mantenga la información del tipo de objeto que se desea manejar. En cada llamada a la función de inserción se compararía el tipo del objeto candidato a insertar con el tipo del objeto con el que se creó, en su caso, la colección, de manera que si el objeto candidato pertenece a la misma clase que tal objeto, o a una clase derivada de ésta (de ahí el “before(...)”), entonces se procederá a la inserción; en otro caso no se efectuará la inserción y la función devolverá un valor de FALSE. Bien, parece que esto puede funcionar, pero ... ¡no convence del todo!, pues recuerda los tan denostados campos selectores de tipo (con mejoras, naturalmente). Resulta, así, que la rapidez y efectividad de nuestro código quedan efectivamente mermadas, el tamaño de la colección aumenta al añadir un nuevo dato miembro, y se nos fuerza a codificar en cada clase derivada de Objeto una función virtual de clonación que devuelva un puntero a un nuevo objeto copia de ése al que se aplica. Aparte de que, al producirse la comparación en tiempo de ejecución, se pierde el chequeo estático de tipos, tan propio de C++. Además, a los inconvenientes expresados se añaden los debidos a la derivación forzosa desde la clase Objeto. Tenemos, en general, que en el tipo de contenedores que ahora nos ocupa "todo son Objetos". Esto significa que, al forzar un grado mínimo de derivación en cada clase y en virtud de los mecanismos de derivación propios de C++, cada vez que se llame a un constructor de un objeto "contenido" se llamará también, de forma dirigida por la lista de inicialización o no, a cada uno de los constructores de las clases base de esa a la que el objeto pertenece. En todo caso siempre se llamará al constructor de la clase Objeto, y si éste no es trivial entonces una línea como
originará cien mil llamadas al constructor por defecto de Objeto, y, francamente, el asunto puede resultar muy costoso. Además cada porción de la clase base Objeto contenida en objetos de clases derivadas de ésta incluirá un puntero a la tabla de funciones virtuales de Objeto, utilizando así una memoria en ocasiones valiosísima. Bueno, lo estamos pintando muy negro, pero la verdad es que los problemas del enfoque derivativo no acaban aquí: pasemos a un nuevo capítulo de molestias. Dado que la conversión implícita de tipos exige el paso de argumentos mediante punteros y las referencias son desechadas, las funciones miembros de nuestros contenedores que manejen elementos no pueden trabajar con objetos. Al trabajar con punteros aparecen, pues, una serie de errores, relacionados con lo que se denomina CRUD [9], demasiado comunes para considerarlos meras curiosidades, casi todos ellos basados en los desajustes de gestión de memoria entre contenedores y contenidos. Así, por ejemplo, un esquema muy extendido es el siguiente:
Vemos que se destruye el contenedor, pero ¿qué pasa con el objeto Persona que hemos insertado? Pues que, al serle asignada memoria del almacenamiento libre, queda sin destruir ocupando memoria útil del sistema, y además sin posibilidad evidente de ser accedido. Estamos suponiendo, naturalmente, que el contenedor no suprime al destruírse los objetos que contiene, porque si así fuera nos veríamos abocados a errores aún más peligrosos:
Cuál es el problema aquí? Pues que al destruírse el partido Minimalista intenta destruir a todos sus miembros, pero resulta que el objeto al que apunta el identificador "incauto" ya ha sido destruido anteriormente, por lo que el resultado de aplicar el operador "delete" a tal puntero es impredecible. Naturalmente obtendríamos el mismo desastroso resultado si destruyéramos "a mano" los objetos contenidos. Esta es, de hecho, la poderosa razón por la que los contenedores de la mayoría de bibliotecas comerciales no destruyen a sus objetos contenidos cuando se destruyen ellos mismos. Otra solución muy socorrida es proveer a la clase Objeto de las siguientes funciones:
de forma que shallowCopy() copia "bit a bit" un objeto (usando normalmente el constructor de copia por defecto), mientras que deepCopy() replica completamente el objeto obteniendo uno nuevo sin referencias del original. Se deja, así, a criterio del usuario de las clases el tipo de copia deseado, de manera que, por ejemplo, puede manejarse un contenedor que destruya su contenido usando de la función deepCopy() y duplicando, por tanto, los objetos antes de insertarlos. Pero los peligros de los punteros no acaban aquí. Consideren, si no, el siguiente código:
Nos encontramos con una colección que contiene, como en el caso anterior, un puntero a un objeto que ya no existe. Cualquier operación sobre la colección que afecte a tal puntero originará ... cualquier cosa, usualmente muy desagradable. Vemos, pues, que se dan muchos problemas pero, demonios, no todo es tan malo en el esquema de diseño de contenedores por derivación. Después de todo es un enfoque que funciona en Smalltalk <sic> y en las jerarquías de clases C++ consideradas como un "idioma" o "patrón" (pattern) Smalltalk, casi todas ellas descendientes de la biblioteca NIH de Keith Gorlen et al. [10], aunque en ésta el mecanismo de identificación de tipos recae, de una forma muy propia de Smalltalk, en una clase denominada Class. Además estas jerarquías cósmicas (pues así se denominan las jerarquías de clases que tienen una única clase base) son muy prácticas cuando se trata de obtener una adecuada granulación de objetos en base a sus relaciones de herencia. Vamos a intentar explicarlo con un ejemplo: supongamos la siguiente jerarquía (que respecta escrupulosamente la directriz ES-UN, aunque de estas cuitas ya hablaremos más adelante):
Supongamos que montamos un esquema de codificación que, para cada clase, inserte cada nuevo objeto en una colección representativa del tipo (lo que se denomina -en inglés- el "extent"), y lo extraiga en el momento de su destrucción. Veámoslo:
De esta manera cada vez que creemos un nuevo objeto de alguna de estas clases automáticamente se insertará en la colección estática (única para todos los objetos de la clase) correspondiente a su tipo (con el nombre "CLASE::extent" y accesible a través de las correspondientes funciones miembros públicas estáticas, que aquí se han obviado). Así, por ejemplo, la línea
insertará mi pulsera de marca en el extent de pulseras. Pero, atención, como sabemos que el constructor de Pulsera automáticamente llamará al constructor de su clase base ‑Prenda‑, y éste a su vez al de su respectiva clase base, hasta llegar a la clase Objeto, resultará que el mismo objeto Pulsera se insertará en cada una de las colecciones estáticas de las clases bases de Pulsera. ¡Vaya, pero esto puede resultar muy costoso! Y, ¿para qué sirve? Muy fácil: imaginemos que deseamos relacionar, para su posterior impresión o visionado, todos los objetos existentes del tipo (de la clase) BienMueble. Dado que las pulseras son bienes muebles, el comportamiento lógico a esperar es que mi pulsera de compromiso aparezca en tal relación. Y así es en este caso. Se trata, en definitiva, del polimorfismo aplicado a las jerarquías. Cuando se destruya el objeto pulsera:
al entrar en acción los destructores de las clases bases (en orden inverso al de construcción, como el voraz lector ya sabe), se extraerá tal objeto de cada una de las colecciones. Un corolario lógico es que la colección estática de la clase Objeto contendrá en cada momento todas las instancias de clases disponibles en nuestra aplicación. De cualquier forma, y como ya se ha dicho, este es un esquema particularmente poco eficiente, así que sólo se usará cuando el polimorfismo que proporciona sea real y positivamente necesario (lástima que no existan todavía mecanismos ni metricas orientadas-a-objetos maduras que permitan calibrar tal necesidad). Tenemos, pues, que el esquema de derivación simple no encaja demasiado bien con lo que queremos. Pero, puesto que estamos en C++ y no en Smalltalk, ¿no nos podría ofrecer alguna solución la herencia múltiple? Bueno, es justo que le echemos un vistazo. HERENCIA MÚLTIPLE: ¿MÁS DE LO MISMO? Como no queremos, y normalmente no podemos conseguir, que todas nuestras clases deriven de Objeto, vamos a intentar un enfoque matizadamente distinto. Se trata de, para no modificar nuestra clase, crear una nueva clase que derive a la vez de ésta y de Objeto. Esto es, si tenemos:
crearíamos una nueva clase:
de forma que así se facilitaría la parametrización de contenedores respecto de clases de las que no podemos obtener el código fuente, o que no pudieran ser modificadas. De cualquier forma el esquema no cambia mucho y, sin embargo, nos procura complicaciones adicionales, pues deberemos dotar a nuestra nueva clase de un interfaz mínimo: constructores, sobrecargas del operador de asignación, del operador de comparación, etc. Pero lo peor es, sin duda, que tendríamos que usar objetos de una clase distinta a la prevista: un ObjCliente NO-ES, en sentido estricto, un Cliente, y en realidad la derivación pública de Cliente tampoco nos solucionaría este problema de fondo. Imaginemos un sistema persistente donde a cada objeto se le otorga un identificador único (cual es el caso de la mayoría de las bases de objetos, donde a cada objeto se le asigna un OID: object identifier): el hecho de instanciar un objeto de una nueva clase supone la asignación de un nuevo identificador, de forma que no se mantiene la integridad referencial, que debería aquí ser expresamente codificada (o sea: ¡problemas!). Claro que algo se puede arreglar -nunca se arregla todo, como el desencantado lector ya puede intuir- a costa de complicar un tanto -o mejor un mucho- el esquema de derivación y de relaciones entre clases. Se trata, en esencia, de aplicar a nuestros contenedores un patrón conocido como de clases "Sobre/Carta" montado sobre un mecanismo virtual que permita la inserción y extracción de objetos en contenedores sin tener que realizar "casts" expresos. Este patrón está suficientemente explicitado en el libro de Coplien [11], y junto con el patrón o idioma de "Ejemplares" proporciona soporte para una parametrización en tiempo de ejecución de nuestros cotenedores. Pero quizás esta modelización sobrepase el tono resueltamente elemental del presente artículo. Además, ¿qué demonios son "patrones"? En pocas palabras: se trata de construcciones idiomáticas con personalidad y estructuración propias, que conllevan particulares formas de codificar y modelar los problemas[12]. Pero esto, aparte de resultar muy extenso, en realidad es relativamente nuevo (de hecho existe en Internet un grupo de discusión de patrones via correo electrónico al que se puede apuntar cualquier interesado), así que volvamos a nuestras cuitas anteriores: ¿puede solucionar la conjunción de herencia múltiple y el uso de patrones nuestros problemas de parametrización de contenedores? Sí en buena medida, pero básicamente a costa de la eficiencia y del chequeo estático de tipos, aparte de que se genera la necesidad de mantener un esquema derivativo no trivial en el que hay que insertar cada nueva clase (esto es, se hace necesario un "administrador" de la parametrización). Pero la cosa no acaba aquí. ITERADORES Los contenedores agrupan objetos que, tras ser debidamente insertados, necesitarán ser accedidos, y este trabajo lo facilitan los iteradores. Un iterador es, en esencia, un objeto que, de forma secuencial, accede a los objetos insertos en un contenedor, y su implementación se basa, por lo general, en un dato miembro que apunta al contenedor sobre el que opera y en datos miembros que apuntan a los objetos en cada momento accedidos. Algo así como:
Se trata de un iterador “genérico” que, al operar sobre Objetos puede manejar cualquier tipo derivado. El operador () devuelve un puntero al objeto actualmente apuntado, mientras que el operador ++ llama a la típica función next() del contenedor para devolver un puntero al próximo objeto en la secuencia. ¿Perfecto? Bueno, como el lector ya habrá adivinado, aquí se nos plantea el mismo problema de “downcasting” anterior. Examinemos una porción de código:
Por supuesto que si suprimiéramos el cast a Cerdo* la última línea no funcionaría, y pensar que la función groink() debe ser virtual en Objeto sobrepasa la mínima prudencia y el decoro que se le suponen a un programador. Entonces, ¿cómo solucionar este problema y los anteriormente expuestos? ¡Con las plantillas, naturalmente! Pasemos a ello. LAS PLANTILLAS: CON HERENCIA, SIN ELLA O A SU PESAR ¿Qué es una plantilla? En primer lugar la traducción, desafortunada o no, del término "template", actualmente parte del lenguaje C++. Otros autores prefieren "genérico", pero personalmente pienso que esto constituye un sobreabuso del vocablo (adjetivo, al fin y al cabo). Se trata, en definitiva, de un concepto asociado al de "parametrización de tipos", de manera que se usarán "plantillas" para implementar en C++ estructuras de datos (objetos contenedores, de forma más explícita) y algoritmos independientes en buena medida (aunque no absolutamente) de los tipos de objetos sobre los que operan o se aplican. En resumen, y dado que en este artículo no examinaremos la sintaxis de las plantillas, ahora podríamos codificar una colección genérica de la siguiente forma:
donde "T" ha de ser sustituido por el tipo de objeto (clase o predefinido) que deseamos manejar en nuestra colección (y obviaremos momentáneamente el espinoso asunto de los punteros y los objetos en plantillas). Pero esta sustitución se produce mediante la "instanciación" de la plantilla en una línea como la siguiente:
de tal forma que las siguientes líneas:
originarán un error en tiempo de compilación (algo que no habíamos podido conseguir con el esquema derivativo), pues se está pasando como argumento un puntero a un Perro, mientras que lo que se espera es un puntero a Politico. A la vez tenemos que la siguiente línea
funciona sin cast alguno, pues la función miembro devuelve directamente un puntero a Politico (en este caso), y no un puntero a una clase base (cual era el caso con Objeto en el enfoque anterior) que necesitaría de un "downcast" para poder acceder a una función miembro de la clase adecuada. Bien: hemos visto, aun muy brevemente, que el enfoque de plantillas soluciona los problemas planteados. ¿Cuál es el siguiente paso? Parece que habría que usar la sintaxis de plantillas para codificar las clases contenedoras que necesitemos. Pero, ¿es lógico que cada programador haya de enfrentarse individualmente al no-trivial problema de codificar los mismos contenedores? O sea, ¿hay que inventar la rueda cada vez? ¡Naturalmente que no! El lector puede estar seguro que existen multitud de bibliotecas comerciales con particulares implementaciones de contenedores mediante plantillas (Rogue Wave, Borland, etc.). Pero, ¿cuál elegir? ¡Ninguna de ellas! Resulta que, como ya se anunció al principio de este artículo, C++ ya posee su propia biblioteca estándar de plantillas (STL), desarrollada por Alex Stepanov en los laboratorios HP y puesta por Hewlett Packard en el dominio público, liberando todas las licencias a ella afectas. Cualquier interesado puede acceder al código fuente completo de la STL via ftp anónimo o vía e-mail, aunque el fichero también puede encontrarse en distintos servicios electrónicos, como por ejemplo en el forum de Microsoft España en Compuserve. La STL supone un trabajo de desarrollo de unos diez años, y el código C++ es de una calidad que impresiona, con dos particularidades notables: en absoluto se usa de la herencia en esta biblioteca y el tratamiento algorítmico recuerda las mejores bibliotecas fortran. Pero el uso de la STL y su integración en los esquemas de programación en C++ es asunto demasiado prolijo para ser expuesto ahora, así que queda pospuesto a un nuevo artículo exclusivo. [1] En Español (castellano <sic>) en el original. [2] En verdad la adición de características en C++ es tan importante, en comparación con lo mostrado por la mayoría de compiladores comercializados en la actualidad, que realmente sorprende. Algún colega ha llegado a decir que C++ ha dejado de ser un lenguaje para transformarse en un entorno. Bueno, es una idea. [3] Bjarne Stroustrup, The Design and Evolution of C++, 1994, Addison-Wesley, Página 337. [4] Este código para concatenar identificadores no es, como resulta fácil suponer, portable entre sistemas, de tal manera que también se usan lindezas como las siguientes: #define concatenar(a,b) a/**/b #define concatenar(a,b) a\b [5] Atención: la macroexpansión de la definición de la clase se producirá en una sola línea (noten los caracteres "\" al final de cada línea de la definición de la macro del preprocesador). Aquí se ha mejorado la presentación debido a razones tipográficas, pero hay que tener en cuenta que algunos editores ‑afortunadamente muy pocos hoy en día‑ pueden truncar líneas demasiado largas, ocasionando errores de compilación difíciles de detectar y, por ende, corregir. [6] Cpp es el acrónimo de "C Preprocessor" ‑o sea, "Preprocesador de C"‑, lo que indica suficientemente su carácter básico de herencia de C adquirida por C++. Como en las malas herencia de la vida jurídica real, no hay más remedio que resignarse: se trata de una deuda. [7] A no ser que creemos lo que se denomina una "Flat Base Class", o sea, una clase base dotada de un abrumador número de funciones virtuales puras, comprehensivas de toda la posible funcionalidad de sus clases derivadas, presentes y futuras. Esto suele dar lugar, en jerarquías poco meditadas, a verdaderos pastiches formales. [8] El ejemplo siguiente se basa en lo que se denomina RTTI (Run-Time Type Identification: Identificación de Tipos en Tiempo de Ejecución), y a pesar de la actual indefinición del estándar, ya se pueden encontrar implementaciones concretas en compiladores, como por ejemplo en Borland C++ 4.XX. [9] CRUD es el acrónimo de "Create, Read, Update & Delete" (Crear, Leer, Actualizar y Borrar) y se refiere a las operaciones más usuales que pueden aplicarse a objetos. [10] La biblioteca NIH es, para gozo del lector, de dominio público y puede encontrarse en la mayoría de servicios on-line: Internet, BIX, Compuserve, etc. Aunque yo, personalmente, no prescindiría del libro en que se explicitan las decisiones de diseño de tal biblioteca, un clásico ya de la programación en C++: Data Abstraction and Object-Oriented Programming in C++, Gorlen, Orlow & Plexico, 1990, John Wiley & Sons, 0-471-92346-X. [11] Advanced C++ Programming Styles and Idioms, James O. Coplien, 1992, Addison-Wesley, 0-201-54855-0. [12] El lector inquieto puede leer el excelente texto “Design Patterns: Elements of Reusable Object-Oriented Software”, de Gamma, Helm, Johnson & Vlissides, 1994, Addison-Wesley, 0-201-63361-2. |
||
| Pº. Castellana 188, 14º e · 28046 - Madrid · info@a4devis.com |
||