experiencia
Servicios - Sepa lo que Ricardo Devis & Asociados pueden hacer por ustedPublicaciones - Consulte los documentos que ponemos a su disposiciónContacto - Conozca como ponerse en contacto con Ricardo Devis & Asociados
 


RPP julio 1997

Asignación en C++ (II): Las peculia-ridades sintácticas, semánticas y arquitectónicas de la asignación de objetos en C++

Ricardo Devis Botella

¿Operadores de asignación virtuales? ¿Restricción de la asignación? ¿Asignaciones recursivas? Oh, como casi siempre, el usuario de C++ se enfrenta a la inseguridad de lo desconocido, que en este lenguaje se plasma en la certeza que el código casi siempre conlleva implicaciones más allá de la intuitiva lectura. Bien: planteemos algunos problemas y solucionemos algún que otro malentendido.

 

Ya vimos en la primera parte de este artículo que el uso del operador de asignación en C++ deviene (¿innecesariamente?) prolijo en sutilezas y deseables exactitudes sintácticas y semánticas. En esa parte acabamos mencionando el uso del operador en jerarquías. Ataquemos para empezar ahora, en el mejor estilo de los prestidigitadores, el posible trueque de identidades.

ASIGNACIÓN NO ES TRANSMUTACIÓN

Es difícil que un patán se convierta instantáneamente en educado, pues solo copiará -si acaso- algunas características de aquél (y si no, recordemos a El Burgués Gentilhombre). ¿A qué viene esto? Bien: si a un determinado objeto se le asigna otro, parece intuitivo esperar que el objeto a la izquierda del operador sea sustituido por el objeto a la derecha del mismo. Pero esperar lo intuitivo en informática es tan aventurado como apostar por la lógica en derecho. Y es que, como el lector ya sabe, en C++ un objeto no puede cambiar de clase. Así que el operador de asignación, implícito o explícito, en ningún caso cambiará la disposición o tamaño del objeto a la izquierda del mismo (ni por supuesto del de la derecha). Esto es, si tenemos:

Persona persona;

Politico politico;

y suponemos definido el operador de asignación en la clase Politico admitiendo como parámetro una referencia a un objeto de tipo Persona, la línea

politico = persona;

no conseguirá que el político se convierta en una persona, pese a mucho que se porfíe. Y es que eso sería tanto como convertir el oro en plomo. Pero veámoslo en detalle:

class Persona {

public:

virtual void disculpa() {

cout << “Lo siento\n”;

}

};

class Politico : public Persona {

public:

void disculpa() {

cout << “Estaba así cuando llegué\n”;

}

};

Persona persona;

Politico politico;

// llama a Persona::operator=( const Persona& )

// pues se produce conversión implícita del tipo

// Politico a su clase base pública Persona

persona = politico;

// pero veremos que no se observa cambio alguno:

persona.disculpa(); // “Lo siento”

politico.disculpa(); // “Estaba así cuando llegué”

// lo que sigue es un error, porque intenta acceder

// con un objeto de tipo Persona a la función de

// asignación implícita, que tiene un prototipo

// Politico& Politico::operator=( const Politico& )

politico = persona;

// lo que sigue, sin embargo, sí funciona, porque

// se asigna una persona a la porción de Persona

// que existe en un Politico (al menos en teoría).

// Así que en realidad se aplica la llamada:

// ((Persona&)politico).Persona::operator(persona);

( Persona& )politico = persona;

// pero, como se ve, el político no cambia:

politico.disculpa(); // “Estaba así cuando llegué”


RECURSIVIDAD EXPLÍCITA: CÉSAR O NADA

Cuando el compilador provee un operador de asignación implícito está cargando con un tedioso trabajo, posiblemente recursivo, de copia. Si codificamos nuestro propio operador el compilador entenderá, sin embargo, que no debe en absoluto intervenir. O sea, que no deberemos contar con una ayuda similar a la que se da en los constructores: si no codificamos expresamente la copia de un dato miembro tal copia no se producirá. Pero, cómo no, el problema se intensifica en una jerarquía de herencia: el operador implícito automáticamente asigna las porciones de las clases base directas de la dada (en el mismo orden en que fueron declaradas en la definición de la clase), pero en el explícito este trabajo de copia habrá de ser expresamente codificado. Esto es, deberemos codificar algo como lo siguiente (y en adelante usaré structs discreccionalmente para evitar las etiquetas de acceso público):

struct Persona {

Persona& operator=( const Persona& );

};

struct Politico : public Persona {

Politico& operator=( const Politico& politico ) {

// llama al operador de la clase base

Persona:operator=( politico );

// y seguidamente copia los datos //miembros

}

};

Bien, pero ¿qué ocurriría si la clase base no dispusiese de operador de asignación explícito? ¡Nada nuevo! No olvidemos que el compilador proveería entonces un operador implícito, que es una función miembro de pleno derecho y que, por tanto, puede ser llamada expresamente, cual es el caso en el ejemplo anterior, o también de la siguiente forma:

( Persona& )*this = politico;

Esta es la mejor manera de copiar la porción de una clase base y, de hecho, la única pausible, pues de otra forma tendríamos que copiar uno a uno los datos miembros de la clase base, pero estos usualmente son privados, por lo que habríamos de amigar las clases y ... bueno, esto nos plantea que habremos de tener buenas razones para prescindir del operador de asignación por defecto.

CUIDADO CON LA AUTO-ASIGNACIÓN

Puestos a codificar nuestro propio operador de asignación, examinemos el siguiente ejemplo:

class Animal { /* definición */ };

class Persona : public Animal { /* ¡evidente! */ };

class X : public Animal { /* ¿quién sabe qué? */ };

class Politico : public Persona, public X {

public:

Politico ( char* unNombre = 0 ) {

ponNombre( unNombre );

}

const Politico& operator=( const Politico& politico ) {

delete[] nombre;

ponNombre( politico.nombre );

}

private:

void ponNombre( char* unNombre ) {

if ( unNombre ) {

nombre = new char[ strlen( unNombre ) + 1 ];

strcpy( nombre, unNombre );

} else {

nombre = new char[ 1 ];

nombre = ‘\0’;

}

char* nombre

};

Y ahora añadamos estas líneas de pura maldad:

Politico* politico = new Politico( “Fulano” );

Persona* persona = &politico;

*politico = *persona;

¿Qué ocurre en la peligrosa última línea? Pues que se ejecuta el código contenido en el cuerpo de la función de copia/asignación, y en ésta lo primero es desasignar la memoria dinámica del dato miembro “nombre” en el objeto *politico, para después asignar memoria suficiente (midiendo a partir del dato miembro “nombre” de *persona) para seguidamente copiar la cadena de caracteres. ¿El problema? Que al eliminar la cadena de caracteres del objeto *politico ... ¡se está eliminando la cadena “nombre” del objeto *persona!, por lo que las subsiguientes operaciones de medición y copia originan ... ¡el desastre! Pues claro -exclamará el atento lector-: ¡los dos punteros apuntan al mismo objeto y -de nuevo claro- esto no funciona! ¡Hay que cambiar el orden de las sentencias y modificar ligeramente el código para solucionar este pequeño problema! (dejemos que el hirsuto lector tome las riendas y veamos dónde nos lleva). “Basta con copiar la cadena del objeto a la derecha de la asignación (*persona en nuestro caso) a una cadena temporal en calidad de variable local, a la que habrá que asignar memoria dinámica suficiente midiendo la longitud de la cadena a copiar, para después poder eliminar esta cadena sin problemas y luego -aquí el lector empieza a mostrar signos de preocupación-, bueno luego habría que ver si hay más punteros o, mejor, copiar todo el objeto en un objeto temporal del mismo tipo, para así poder restituir las variables y...” (en este momento el lector, totalmente avergonzado, reconoce que su estrategia en absoluto funciona, y proyecta serios planes de enmienda). En fin, retomemos el artículo: si el problema sólo se produce en caso de autoasignación (directa o indirecta, por medio de lo que se denomina autoexplicativa y bárbaramente “aliasing”), chequeemos esta circunstancia para adoptar diferencialmente la estrategia adecuada, que evidentemente es ... ¡no copiar nada! Es natural que si lo que se pretende es copiar dos objetos exactamente -bueno, inmediatamente matizaremos este adverbio- iguales, el mejor trabajo es no realizar trabajo alguno, con lo que la copia quedará hecha. Nuestro operador quedaría de la siguiente guisa:

const Politico& Politico::operator=( const Politico& politico ) {

if ( *this == politico )

return *this;

delete[] nombre;

ponNombre( politico.nombre );

}

¡Ah, esto parece perfecto! Pero las apariencias no son más que eso: pura vanidad. Si atendemos a la línea de chequeo vemos que se comparan los objetos a ambos lados del operador de asignación (objetos, no punteros), y además haciendo uso de un operador de comparación que ha de suponerse definido para la clase Politico. Atendamos a los hechos.

IDENTIDAD, IGUALDAD Y ... ¿FRATERNIDAD?

¿Cuál es el criterio que nos permitirá comparar dos objetos respecto de la autoasignación, como hemos hecho en el parágrafo anterior? ¿La igualdad o la identidad? Parece claro que, en general, la igualdad no, pues ésta se basa en la exacta similitud de los valores de dos objetos, de forma que, por ejemplo, dos objetos de una clase con un único atributo de nombre serían iguales si sus nombres coincidieran. La identidad, sin embargo, se refiere al hecho diferencial del objeto: yo soy yo, independientemente de los valores que asuma mi representación interna. Claro que en la práctica las cosas no son tan sencillas. ¿Serían por ejemplo idénticas dos personas con el mismo NIF? Esto es, ¿sería adecuada la siguiente función?

bool Persona::operator==( const Persona& persona ) {

return nif == persona.nif;

}

Pues bien, sólo si el establecimiento de NIFs asegurara su biunivocidad respecto de cada persona, y únicamente dentro del espacio geográfico español, y sólo si se descarta a los menores. Demasiadas condiciones. Meyers sugiere la implementación de una función virtual que devuelva una cadena identificativa especial y única para cada objeto. Claro que esto podría acarrear problemas en un entorno distribuido donde la asignación de identificadores no estuviera sincronizada. ¿Y por qué no usar del mecanismo de identificación de objetos propio de C++? En C++ todos los objetos son transitorios (transient), esto es, no sobreviven al proceso o CPU que los creó, y su naturaleza mutable está representada por su dirección en memoria. ¿Por qué no usar, pues, tal dirección y comparar simplemente punteros para verificar la identidad? Pues porque, como hemos visto en el ejemplo del parágrafo anterior de una clase con herencia múltiple, un objeto puede ser referenciado a través de distintos punteros (correspondientes a las distintas porciones de las clases base que lo componen), con lo que el esquema alternativo en que sólo se comparan punteros:

if ( this == &politico )

return *this;

no tiene por qué necesariamente funcionar. El modelo de objetos propuesto por el OMG (Object Management Group) en CORBA (Common Object Request Broker Architecture) propone unos identificadores de objetos (denominados “objrefs”) que no tienen por qué ser únicos para un sólo objeto en tanto cada uno de estos puede existir en distintos contextos no solapados. De cualquier manera el enfoque habitual en bases de objetos es la asignación automática y opaca por el sistema de un OID (Object Identifier) a cada objeto. Pero estamos entrando en un tema que examinaremos con detenimiento -¿cómo no?- en un cercano artículo sobre Bases de Objetos o Bases de Datos Orientadas-a-Objetos. A fin de cuentas este enfoque de “luego veremos” es parte sustancial de la Orientación-a-Objetos: involución permanente y citas circulares.

SOBRE LA ASIGNACIÓN BIDIRECCIONAL

Hasta ahora hemos visto cómo una referencia o puntero de una clase derivada de una dada se convierte convenientemente en un puntero o referencia a la clase base para encajar con su operador de asignacion, implícito o no. Pero seamos exigentes: si tenemos

struct Persona {

const Persona& operator=( const Persona& );

};

struct Politico : public Persona {

const Politico& operator=( const Politico& );

};

Persona persona;

Politico politico;

y quisiéramos codificar

politico = persona;

o, en general, deseáramos una jerarquía de clases en que los valores de los objetos pudieran ser asignados con independencia de las clases involucradas (siempre en la misma jerarquía derivativa), necesitaríamos añadir, en el presente caso, una nueva función a la clase Politico:

const Politico& Politico::operator=( const Persona& );

que manejara adecuadamente la asignación de clase base a clase derivada (lo usual es exactamente lo contrario, de clase derivada a clase base, haciendo normalmente uso de la conversión implícita de tipos en caso de ámbitos con derivación pública). Pero esto significa que ahora tendremos en nuestra clase Politico dos funciones, una con argumento “const Persona&” y otra con argumento “const Politico&”, de parejo contenido (relacionadas cuando menos por una inclusión no-estricta). Y si la jerarquía es una larga cadena de derivación tendremos que cada clase constará de un operador de asignación más que la anterior, y que la adición de una clase en medio de tal jerarquía obligaría a añadir una nueva sobrecarga del operador de asignación en las clases derivadas de la insertada. Bueno, es un panorama ciertamente poco elegante y difícil de explicitar en una jerarquía de uso comercial. ¿No habría una forma de usar tan sólo un operador que reuniera la funcionalidad de los otros con argumentos de clases bases? En realidad el comportamiento de tales funciones resulta seguir el siguiente esquema:

const Politico& Politico::operator=( const Persona& persona ) {

// se chequea la autoasignación

Persona::operator=( persona );

// y aquí las escasas particularidades semánticas

return *this;

}

const Politico& Politico::operator=( const Politico& politico ) {

// se chequea la autoasignación

Persona::operator=( politico );

// y aquí la copia del resto de datos miembros

return *this;

}

¿Y si dispusiéramos de un sólo operador con argumento de referencia a la clase base y la actuación se determinara mediante un cast dinámico? Veámoslo:

const Politico& Politico::operator=( const Persona& persona ) {

if ( *this == persona )

return *this;

const Politico* politico =

dynamic_cast< const Politico* >( &persona );

// si politico es distinto de cero significa que “persona”

// es una referencia a Politico o a una clase derivada, con

// lo que la función con argumento “const Politico&” ya no

// es necesaria, pues ésta hace el trabajo de ambas.

if ( politico )

// aquí se copiarían los datos miembros de Politico

Persona::operator= ( persona );

return *this;

}

Con esta solución eliminaríamos la posible explosión combinatoria (es un decir) y conservaríamos una sola signatura para el operador de copia/asignación a través de la jerarquía. Naturalmente si se produjera una modificación en la jerarquía el cuerpo de tales funciones debería ser también modificado, pues se llama expresamente al operador de la clase base directa. ¿Es, pues, una buena solución? ¡Ea! Seguro que el inteligente lector ya supone, a estas alturas, que con el operador de asignación en C++ no hay solución única. Podríamos decir que el operador de asignación implícito proporciona lo que se denomina una “copia superficial” (shallow copy), mientras que nuestra recursividad manual procura la llamada “copia profunda” (deep copy). La copia superficial presenta escasos problemas conceptuales, mientras que la copia profunda, bueno, por citar sólo un problema: ¿qué ocurre cuando se dan referencias circuales en la recursividad hacia atrás planteada? ¿Qué ocurre, por otro lado, con el operador de comparación para el chequeo de la autoasignación? ¿No debería tal operador codificarse de la misma manera que el de asignación montando casts dinámicos? ¡Seguro que sí! Digamos que el aumento de complejidad supone un parejo aumento de la capacitación del programador respecto del lenguaje. Así que, como dicen en Tráfico, “si no está realmente seguro, no adelante”, so pena de encontrarse con demasiados problemas y otras tantas peligrosas implicaciones. Y si no veamos qué ocurre cuando el problema se hace virtual (¡Problemas virtuales! Gracián caeríase muerto ya).

POLIMORFISMO EN ASIGNACIÓN

Como quiera que un operador de asignación es una función miembro de una clase (se trate de un operador de copia/asignación o de un operador de asignación cualquiera), en tal calidad puede ser perfectamente declarado como virtual. La única salvedad a considerar aquí es que el operador de copia/asignación de la clase derivada no se constituye en destinatario del mecanismo virtual respecto del operador de copia/asignación en la clase base, lo cual es ciertamente lógico, merced a la diferencia del tipo del argumento y al especial mecanismo implícito del operador. Examinemos, así, el siguiente código:

struct Persona {

virtual int operator=( char* );

virtual Persona& operator=( const Persona& );

};

struct Politico : Persona {

virtual int operator=( char* );

virtual Politico& operator=( const Persona& );

virtual Politico& operator=( const Politico& );

};

Politico unPolitico, otroPolitico;

Persona* persona = &unPolitico;

// llamada a Politico::operator=( char* )

persona->operator=( “Disraeli” );

*persona = “Disraeli”;

// llamada a Politico::operator=( const Persona& )

persona->operator=( otroPolitico );

*persona = otroPolitico;

// llamada a Politico::operator=( const Politico& )

unPolitico = otroPolitico;

¡Ah, todo perfecto! Si se definiera, no obstante, únicamente la función

Politico& Politico::operator=( const Politico& );

en la clase Politico, desechando la que observa un argumento const Persona&, sí se solaparía el mecanismo virtual, pues tal función escondería a la de la clase base. Este comportamiento resulta, por ejemplo, en la siguiente lógica situación: si el operador de asignación se declara virtual y una clase derivada no implementa operador explícito, el mecanismo virtual se resolverá, finalmente, en una llamada al operador de asignación correspondiente a la clase base directa de la dada. ¿Qué pasaría, sin embargo -y esto se lo dejo de elemental ejercicio al inquieto lector-, si tal clase base no dispusiera de operador de asignación explícito? Podríamos evitar problemas, no obstante, si codificáramos en cada clase de la jerarquía la función con cast dinámico que hemos visto antes. Con tal enfoque convendría declarar siempre el operador de asignación como virtual, lo que permitiría codificaciones del tipo:

Politico& creaPolitico( const Persona& ) {

persona = personaLobotomizada;

}

pudiendo pasar como un argumento una referencia a una Persona o una referencia a una clase derivada de ésta.

CUANDO LA ASIGNACIÓN ES INDESEABLE

¿Qué ocurre si no se desea que el cliente de una clase utilice la semántica de copia para cambiar los valores de un objeto de tal clase? Lo usual cuando no se desea procurar cierta funcionalidad es evitar su inclusión en el protocolo de la clase. Aquí, sin embargo, la elipsis haría que entrara en marcha el mecanismo implícito, generando de cualquier forma la función indeseada. ¿La solución? Plum y Saks proponen declarar el operador de asignación pero sin dotarlo de cuerpo, de manera que su uso generaría un error en el enlazado del programa. No es ésta, empero, una solución segura, pues al estar declarada la función en la definición de la clase, el usuario puede añadir la definición en un aparte. Un procedimiento más seguro consiste en declarar además el operador de asignación en la sección privada de la clase, de forma que el compilador señalaría como error cualquier uso del mismo, así como impediría la declaración del operador implícito en las clases derivadas. El problema es que si en la jerarquía en cuestión el operador de asignación ha sido declarado como virtual, el lector ya sabe que la cualificación de acceso al operador que se resuelve en tiempo de ejecución depende de la cualificación de acceso de la clase a que pertenece el puntero o la referencia desde la que se accede a la función. Esto es, si tenemos:

struct Persona {

virtual Persona& operator=( const Persona& );

};

class Politico : public Persona {

// operador privado

Politico& operator=( const Persona& );

};

Politico unPolitico, otroPolitico;

Persona* persona = &unPolitico;

persona->operator=( otroPolitico ); // OK

la última línea accede al operador privado a través del puntero a la porción de Persona en político, con lo que se vulnera nuestra intención inicial. Tenemos, así, que lo mejor sería que el operador de asignación no fuera virtual, replicando en este caso en cada clase los argumentos del operador de asignación de sus clases base. Como vemos, se trata de un enfoque sustancialmente distinto al hasta ahora expuesto. En fin, que en C++, como en el mus, objetivamente no puede ganarse a todo (otra cosa es que en la práctica una buena pareja saque el máximo partido a su juego y fuerce continuamente la victoria).

LA ASIGNACIÓN CON CLASES BASE VIRTUALES

Retomemos un ejemplo anterior el en que se da derivación múltiple:

class Animal {};

class Persona : public Animal {};

class X : public Animal {};

class Politico : public Persona, public X {};

Politico unPolitico, otroPolitico;

unPolitico = otroPolitico;

La última línea originará que se defina el operador de copia/asignación implícito para la clase Politico, que a su vez llamará a los operadores implícitos de las clases bases directas Persona y X (por este orden), los cuales, a su vez, llamarán al operador implícito de la clase Animal, que resultará llamado dos veces (una por cada clase derivada, correspondientes a las dos porciones físicas de la clase base Animal contenidas en Politico). Bien, aparte que esto pueda hacer pensar al lector, ¿qué ocurriría si la derivación fuera virtual? Esto es, si se tuviera:

class Animal {};

class Persona : virtual public Animal {};

class X : virtual public Animal {};

class Politico : public Persona, public X {};

Pues que, de acuerdo con el WP, queda sin especificar si la porción de la clase base virtual Animal se asignará una o dos veces mediante el operador de copia/asignación definido implícitamente en la clase Politico. El comportamiento quedará, en cada caso, determinado por el compilador concreto utilizado, con lo que viene a colación el consejo de Cargill: “No trates de aprender la semántica de la herencia múltiple de tu propio compilador”. Naturalmente tal inespecificidad puede ser salvada -¿cómo no?- codificando expresamente todos los operadores de asignación y procurando manejar las asignaciones de todas las porciones, tal y como muestra el mismo Cargill. De cualquier forma en el ARM se reconoce que llegar a un estado de consistencia en jerarquías virtuales con operadores de asignación es francamente difícil.

ALGUNOS CONFUSOS CONSEJOS

Ante todo lo expuesto, ¿qué hacer? ¿Codificar o no el operador de asignación? ¿Declarar el operador virtual? ¿Usar del mecanismo de resolución dinámica de tipos? Atendamos a lo que opinan los expertos: Saks afirma que se debe trabajar con el operador implícito a menos que haya una razón funcional que lo impida, mientras que Taligent indica que hay que explicitarlo todo, que es mejor codificar un costoso operador de asignación, cuya funcionalidad sea la misma que la del operador implícito, que dejar a la imaginación del cliente de la clase el motivo de una supuesta elipsis. Evidentemente aquí se oponen dos criterios bien distintos: el del programador individual y el del programador de marcos o bibliotecas de clases, que serán utilizadas de forma intensiva por otros programadores o usuarios finales. ¿Sabe el lector que aconseja la Guía de Taligent? ¡Pues que en caso de duda se consulte a un arquitecto de software! Y es que, como dijera Disraeli, “cuánto más fácil resulta ser crítico que correcto”. Así están, con todo, las cosas. La conclusión, así, es la misma que enunciara Bernard Shaw: respecto del operador de asignación “la regla de oro es que no hay reglas de oro”.

BIBLIOGRAFÍA

  • Working Paper for Draft Proposed International Standard for Information Systems -- Programming Language C++ , ANSI X3J16, 28-abril-1995.
  • The C++ Programming Language, 2nd Edition , Bjarne Stroustrup, 1991, Addison-Wesley, 0-201-53992-6.
  • Mastering Object-Oriented Design in C++ , Cay S. Horstmann, 1995, Wiley, 0-471-59484-9.
  • On To C++ , Patrick Henry Winston, 1994, Addison-Wesley, 0-201-58043-8.
  • C++ Programming Guidelines , Thomas Plum & Dan Saks, 1991, Plum Hall, 0-911-537-10-4.
  • C++ Strategies and Tactics , Robert B. Murray, 1993, Addison-Wesley, 0-201-56382-7.
  • Class Construction in C and C++ , Roger Sessions, 1992, Prentice Hall, 0-13-630104-5.
  • Effective C++: 50 Specific Ways to Improve Your Programs and Designs , Scott Meyers, 1992, Addison-Wesley, 0-201-56364-9.
  • Advanced C++: Programming Styles and Idioms , James O. Coplien, 1992, Addison-Wesley, 0-201-54855-0.
  • The Design and Evolution of C++ , Bjarne Stroustrup, 1994, Addison-Wesley, 0-201-54330-3.
  • Taligent’s Guide to Designing Programs: Well-Mannered Object-Oriented Design in C++ , Taligent, 1994, Taligent Press, 0-201-40888-0.
 
 
 
volver a la página de publicaciones
 
 
 Pº. Castellana 188, 14º e · 28046 - Madrid · info@a4devis.com