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++ (I): Las peculia-ridades sintácticas, semánticas y arquitectónicas de la asignación de objetos en C++

Ricardo Devis Botella

¿Qué se esconde tras una aparentemente simple asignación en C++? En esencia la voluntad de copiar un objeto en otro. Pero en C++ la complejidad se esconde en casi todos los recovecos del lenguaje, de forma que una simple expresión de asignación puede generar gravísimos errores muy difíciles de depurar. Naturalmente el remedio es el conocimiento exacto no sólo de la sintaxis del lenguaje, sino también de la precisa actuación semántica que se agazapa tras aquélla.

 


La asignación en C++ no es, como no lo son otras muchísimas características del lenguaje, asunto baladí. C++ es sorprendentemente copioso en sutilezas sintácticas, lo que plantea dos cuestiones: la seguridad de las herramientas generadoras de código y la eficacia de los cursos-relámpago de C++ avanzado (se han llegado a publicitar risibles reclamos de la guisa “Programación Avanzada Orientada-a-Objetos en C++, Visual Basic y Clipper en 15 días”). C++ es extenso, prolijo, complejo y, afortunadamente, imperfecto en su sentido más práctico: amigo de lo bueno, como podría haber dicho Voltaire . Lo que sigue es, pues, una concentración de técnicas, sintácticas y de construcción de programas, que pretende mostrar al programador novel -y aun intermedio- en C++ las soluciones a distintos problemas bien conocidos, como fuego en carne, por los expertos [1].

CONCEPTOS BÁSICOS

Como bien sintetiza Winston [2], “El operador de asignacion se utiliza para cambiar el valor de una variable” y en C++ viene representado por los símbolos:

= *= /= %= += .= >>= <<= &= ^= |=

de los cuales en adelante representativamente usaremos el primero: la asignación simple.

Dado el carácter esencialmente mutable de las variables, todos los lenguajes de programación incorporan un operador de asignación con una funcionalidad predefinida para los tipos incorporados. Así, en C++, el operador de asignación por defecto es exactamente el operador estándar de C. ¿Qué ocurre, sin embargo, con los tipos definidos-por-el-usuario o clases? Pues que, dado que en C++ se pretende una equiparación de las clases con los tipos predefinidos, el compilador garantizará la declaración y, en su caso, definición de un operador de asignación implícito para las clases en que expresamente tal no se codifique, y que en esencia consistirá en la copia miembro a miembro, secuencial y recursiva, de los miembros no-estáticos y de las correspondientes porciones de las clases base de la dada. De esta manera tenemos que (suponiendo que un político es un objeto con una cierta oscura funcionalidad) la siguiente línea:

unPolitico = otroPolitico;

equivale a

unPolitico.operator=( otroPolitico );

El operador implítico, o por defecto, garantiza una intuitiva codificación de la copia por asignación en objetos de cualquier tipo, pero ¿que normas rigen respecto de tal operador implícito? ¿No deben ser declaradas en C++ todas las funciones? ¿Existe alguna diferencia respecto de otros operadores? ¿Qué ocurre con la asociatividad? ¿Las clases vacías también disponen de operador implícito? Ataquemos, sin más, la parte sintáctica del tema.

EL OPERADOR DE ASIGNACIÓN POR DEFECTO

Si no se declara explícitamente el operador de asignación para una clase dada, el compilador automáticamente declarará (con las excepciones que más adelante veremos) un operador de asignación implícito, o por defecto, que se definirá la primera vez que se utilice una asignación a un elemento de tal clase. Asi, si tenemos, por ejemplo:

class Politico { /* clase vacía */ }; // sin comentarios

Politico unPolitico, otroPolitico;

unPolitico = otroPolitico; // todos los políticos son iguales

en la última línea se producirá la definición implícita del operador de asignación (definiendo antes, si procediera, los operadores de asignación declarados también implícitamente correspondientes a las clases bases directas de la dada y a los datos miembros no-estáticos de la misma), en calidad de función miembro no-estática perteneciente al protocolo público (public) de la clase, con el prototipo:

Politico& Politico::operator=( Politico& )

y cuya implementación realiza una copia bit-a-bit del objeto otroPolitico en el objeto unPolitico. De hecho, y según el borrador del estándar C++ (en adelante WP [3]: working paper), “un operador de asignación por copia, operator=, es una función miembro no-estática de la clase X con exactamente un parámetro de tipo X& ó const X&”. El prototipo de operador por defecto, generado por el compilador, que hemos visto antes, cambiaría empero su parámetro a constante

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

únicamente si, y sólo si, todos los miembros de la clase Politico y sus posibles clases base directas (muy variadas, atendiendo al sorprendente resultado) poseyeran operadores de asignación con argumento constante.

El operador de asignación se dice trivial si y sólo si es implícito y todas las clases bases directas de la dada, además de todos los datos miembros no-estáticos de tipo no-predefinido, poseen un operador de asignación también trivial.

El esquema de copia por defecto es, en definitiva, el siguiente: para cada dato miembro no-estático de la clase se busca, recursivamente, si el tipo o clase a que tal miembro pertenece posee operador de asignación, de forma que si existe se usa para copiarlo y si no se genera una copia bit-a-bit.

LOS LÍMITES EXPLÍCITOS DE LA ASIGNACIÓN IMPLÍCITA

El compilador no generará un operador de asignación por defecto en los siguientes casos:

  • Si la clase contiene un dato miembro no-estático de tipo referencia o de tipo constante, como por ejemplo en:

class Politico {

const long ambicion; //una constante política

Persona& padrino; // imposible empezar sin él

// sigue descripción de clase

};

  • El compilador no podrá generar el operador implícito para tal clase, pues, como el leído lector conoce, los datos miembros de tipo referencia y constantes tienen que ser inicializados y asignados a la vez: esto es, no pueden ser primero inicializados para luego asignarles un valor. Precisamente por esto tales miembros han de ser construidos forzosamente en la lista de inicialización de todos los constructores, y no tienen cabida en una mera copia por asignación, que es la que proporciona el operador implícito. El compilador originará, pues, sendos errores (parece, así, que la ambición, por ser constante, y el padrino, por constituirse en referencia, impiden, o cuando menos dificultan, la sustitución de los políticos).
  • Si la clase contiene un dato miembro no-estático con un operador de asignación inaccesible o deriva de una clase base con un operador de asignación inaccesible. Naturalmente la inaccesibilidad se refiere bien a la imposibilidad de generación del operador de asignación por defecto bien a la restricción en la cualificación de acceso de tal operador, implícito o no. En cuanto a la inexistencia, veamos un ejemplo:

class PartidoPolitico {

Politico Presidente; // sin operador implícito o explícito

// y un largo etcétera

};

  • Si consideramos a la clase Politico con una ambición constante (como acabamos de ver), la clase PartidoPolitico contendrá un miembro con un operador de asignación inaccesible (de imposible generación implícita), y por tanto no generará su propio operador de asignación por defecto. O sea, un Partido Político no podrá ser cambiado, sustituido o absorvido por otro si su presidente posee una ambición constante.
  • En cuanto a la cualificación de acceso, veamos este otro ejemplo:

class Politico {

private:

Politico& operator=( const Politico& );

// sigue descripción de clase

};

  • Aquí hemos proporcionado un operador definido-por-el-usuario, evitando la necesidad de un operador implícito. Tal operador, no obstante, ha sido declarado como privado, de tal forma que no es “accesible”, así que la clase PartidoPolitico no podrá generar tampoco su operador por defecto. De la misma forma, la clase derivada

class Alcalde : public Politico { /* clase vacía */ };

  • no podrá generar, en cualquier caso de los expuestos, su propio operador de asignación por defecto. Así, cuando el compilador se encuentre con la primera expresión de asignación exclamará algo así como:

error EDC3209: class "X" does not have a copy assignment operator

LA COPIA DE UN PUNTERO GENERA ... ¡OTRO PUNTERO!

Atendamos a un error muy extendido. Como el lector bien sabe, el operador de asignación de los datos miembros, si existe, implícito o no, únicamente se usará cuando estos sean objetos, no punteros. Esto es, si tenemos el código:

class Politico {

String* nombre;

String alias;

};

otroPolitico = unPolitico;

y suponemos que la clase String tiene definido un operador de asignación (implícito o no), la última línea hara que se copien los datos miembros de un objeto de tipo Politico al otro de la siguiente forma: la dirección del puntero a “nombre” se copiará en la variable correspondiente del objeto “otroPolitico”, mientras que para la copia del dato miembro “alias” se usará del operador de asignación de String. Naturalmente lo ideal sería que el compilador emitiera un “aviso” cuando un operador de asignación implícito copiara un puntero. La realidad, sin embargo, está ahí para indicarnos que estas son cuitas del programador y que los compiladores ya tienen demasiado trabajo. ¡Ah, la sin par realidad!

ASIGNACIÓN NO ES INICIALIZACIÓN

El siguiente código:

Politico unPolitico;

Politico otroPolitico = unPolitico;

unPolitico = otroPolitico;

equivale a:

Politico otroPolitico( unPolitico );

unPolitico.operator=( otroPolitico );

Esto es, la inicialización (realizada por un constructor) se da cuando se crea un nuevo objeto, mientras que la asignación (realizada por el operador de asignación) muta los valores en objetos ya creados. Por eso en la línea en que aparece el símbolo “=” y donde se crea el objeto otroPolitico interviene el constructor (denominado constructor de copia) en lugar del operador de asignación.

EL OPERADOR POR DEFECTO RESULTA DEFECTUOSO

Naturalmente la copia por defecto no funciona adecuadamente en numerosas ocasiones, en concreto cuando las clases envueltas contienen miembros que requieren la asignación dinámica de memoria. Así, por ejemplo, si nuestra clase contuviera una cadena de caracteres de la forma:

class Politico {

public:

Politico( int numero = 0 ) : numeroDeLista( numero ){

cout << “Se crea el nuevo ideario ”

<< numeroDeLista << “\n”;

ideario = new String;

}

~Politico() {

cout << “Se destruye el ideario “

<< numeroDeLista << “\n”;

delete ideario;

}

private:

int numeroDeLista;

String* ideario;

// resto definición de clase

};

y nos fiáramos del operador de asignación implícito, obtendríamos resultados inesperados, de forma que el siguiente código

Politico* unPolitico = new Politico( 1 );

Politico* otroPolitico = new Politico( 2 );

*unPolitico = *otroPolitico; // ¿pueden dos políticos compartir

// exactamente el mismo ideario?

delete otroPolitico; // imperativos electorales

delete unPolitico; // ¡la hecatombe!

originaría el siguiente resultado (en mi compilador IBM Visual­Age C++):

Se crea el nuevo ideario 1

Se crea el nuevo ideario 2

Se destruye el ideario 2

Exception = c0000005 occurred at EIP = 12705.

¿Qué ha ocurrido? Vemos que, en primer lugar, la copia implícitamente definida origina que se copie en el objeto receptor ... el puntero a la cadena (como vimos en el parágrafo anterior) y no la cadena en sí (para lo que habría que reservar espacio y luego copiar los caracteres haciendo uso, por ejemplo, de strcpy(...) suponiendo que la representación interna de String sea un puntero a char y que además sea accesible), de forma que tenemos dos objetos apuntando a la misma cadena de caracteres, siendo así que si uno de ellos cambiara la cadena ésta cambiaría en ambos. Esta situación, en que ambos comparten exactamente la misma cadena, causa que al operar el destructor de uno de ellos eliminando tal cadena, el otro se quede conteniendo un puntero ... ¿a qué? ¡A basura! De esta manera, cuando se aplica el destructor sobre el segundo objeto, el resultado es ... el desastre. Naturalmente, y sin necesidad de aplicar el segundo destructor, el segundo objeto está en estado inestable, y cualquier operación con él puede procurar resultados indeseables. Incidentalmente cabe notar, también, que el ideario primero queda libre ocupando memoria y ... ¡sin que nadie lo destruya!, gastando así los preciados recursos del sistema. Por fin, y esto lo dejo en ejercicio al inteligente lector, siempre desconfiado de las apariencias, ¿qué ocurriría si cambiáramos el orden de destrucción de los objetos?

Realmente esto no funciona, así que, como afirman Meyers, Cargill y otros muchos, siempre se definirá un operador de asignación para clases que usen de memoria dinámica. Stroustrup propone una asimilación, a este respecto, entre constructor de copia, destructor y operador de asignación, que Horstman formula como regla de esta interesante forma: “Cualquier clase con un destructor no-trivial necesita un operador de asignación definido-por-el usuario que realice las copias de manera adecuada”, queriendo indicar que si el destructor tiene trabajo que realizar, probablemente el operador de copia/asignación tenga parecidas tareas, lo que descalificaría al operador por defecto.

EL OPERADOR DE ASIGNACIÓN EXPLÍCITO

Si el operador por defecto no funciona correctamente en ciertas clases, habrá que dotar a éstas con operadores de asignación expresos, solapando al operador implícito cuando exista o simplemente dotando de él a las clases en otro caso.

En primer lugar hay que insistir en que, como ya hemos visto, el operador = debe ser una función miembro (regla que se extiende a los tres operadores (), [ ] y ->), de manera que su declaración como función global (o amiga, un caso especial de aquélla), como por ejemplo

extern void operator=( Politico&, const Politico& );

originaría un error en compilación. El operador ha de ser también forzosamente no-estático (no hay que olvidar que su funcionalidad se refiere a las instancias de la clase).

Recordemos, por otro lado, que la asociatividad de un operador predefinido se mantiene en sus sobrecargas. Tenemos, de esta manera, que cualquier operador de asignación (predefinido, definido-por-el-usuario, implícito o explícito) es asociativo a la derecha, esto es, de derecha a izquierda, de manera que la expresión

a = b = c = d = e;

siempre se evaluará en el siguiente ordén:

( a = ( b = ( c = ( d = e ) ) ) );


DESIGNACIÓN NO ES ASIGNACIÓN

Pero veamos cómo añadir nuestros propios operadores de asignación: En principio, cuando definamos expresamente tal operador ya no se generará el operador implícito, siempre que la declaración de nuestra función admita un solo parámetro del tipo const Politico& ó Politico&. Esto quiere decir que si declaramos el operador de asignación únicamente como la siguiente función miembro no-estática:

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

se producirá la definición del operador de asignación implícito, pese a que quizás semánticamente se produzca una copia completa de un objeto en otro. De esta manera tenemos que es sintácticamente aceptable la siguiente codificación:

class Politico {

public:

// operador ortodoxo explícito

Politico& operator=( const Politico& );

// sobrecarga (en el argumento, no en el tipo de retorno)

void operator=( Politico& );

// operador no-ortodoxo

Politico operator=( Politico* );

// etc., etc.

};

De hecho, para establecer la diferencia entre cualquier operador de asignación y el operador ortodoxo, a este último se le denomina “operador de copia/asignación”.

Ahora bien, ¿es semánticamente aceptable que un operador de copia/asignación devuelva un tipo distinto de Politico&? ¿Es razonable que se devuelva ‘void’ (aunque alguno pueda alegar que un político nunca devuelve nada)? ¿Qué tipo debe, en definitiva, devolver el operador de asignación/copia?

LO QUE LA ASIGNACIÓN PRODUCE

Como el lector sobradamente sabe, la sobrecarga de operadores en C++ debe intentar mimetizar el comportamiento predefinido de tales operadores, ajustándolo a la idiosincrasia de la clase afectada. Si atendemos, pues, al comportamiento de los tipos predefinidos, encontramos que el operador de asignación, como todos los operadores de C++, produce un valor que, por convención, es el mismo que el valor asignado (así el valor de la expresión ‘variable = 17’ es 17). Precisamente el hecho que los operadores devuelvan un valor permite codificaciones habituales en C++ como:

if ( unPolitico == otroPolitico ) {

// sigue ...

Vemos así que nuestro operador de asignación explícito ha de devolver el objeto al que se asigna, a fin de permitir que las expresiones de asignación pueden aparecer como subexpresiones anidadas en expresiones más grandes. Naturalmente podemos estimar que no deseamos codificar tales expresiones encadenadas, y que precisamente para evitarlo devolvemos “void”. Bueno, esto puede ser cierto e incluso correcto respecto de los operadores no-ortodoxos (con argumento distinto de Politico& ó const Politico&), pero dada la reutilización latente de todo el código C++, es lógico suponer que cualquier usuario de nuestro código presupondrá lo más intuitivo: esto es, que puede utilizar tales expresiones anidadas y que, por tanto, no ha de cambiar su forma de codificar por el arbitrario capricho de otra persona. Pero, exactamente ¿qué devolvería nuestro operador? ¿Un objeto? Evidentemente no, porque si así fuera operaríamos con una copia del objeto. Debe devolver, pues, una referencia a un objeto. Pero, ¿una simple referencia o una referencia a un objeto constante? Fácilmente podemos advertir en el siguiente ejemplo

int uno = 1;

int dos = 2;

int tres = 3;

cout << ( uno = dos = tres ) << dos << tres; // imprime ‘333’

cout << ( ( uno = dos ) = tres ) << dos << tres; // imprime ‘323’

cout << ( uno = dos = uno ) << dos << tres; // imprime ‘113’

cout << ( ( uno = dos ) = uno ) << dos << tres; // imprime ‘223’

que el resultado de una asignación puede ser, a su vez, asignado, así que el operador predefinido devuelve una referencia no constante. Pero en el ejemplo advertimos, también, los un tanto inesperados resultados de aplicar la asociatividad de izquierda a derecha en la última línea. Debemos considerar, pues, a la hora de codificar nuestra función de asignación si vamos a permitir tal comportamiento. Si no deseamos que el resultado de una asignación pueda ser usado a la izquierda de una asignación (como lvalue), que suele ser lo más prudente, debemos prototipar así nuestro operador:

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


CONVERSIÓN, CONSTRUCCIÓN Y ASIGNACIÓN

¿Se debe limitar, con todo, el prototipo del operador de asignación explícito a las sobrecargas con un argumento Politico& y const Politico&? Diríase que no. Examinemos el siguiente código:

class Politico {

public:

Politico( char* promesas = 0 );

const Politico& operator=( const Politico& );

// ...

};

Politico puedoPrometerYPrometo;

puedoPrometerYPrometo = “Jamás dimitiré”;

En la última línea, dado que el operador de asignación de la clase espera una referencia a un objeto Politico constante y encuentra un puntero a char, se produce una conversión implícita y entra en juego el constructor de la clase, creando a partir de la cadena un objeto temporal Politico que, después de ser asignado a “puedoPrometerYPrometo” se destruirá antes de finalizar el ámbito en que se ubica la expresión. Si deseamos evitar la construcción de un objeto temporal deberemos dotar a nuestra clase con una función como:

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

evitando así la puesta en marcha del esquema de conversiones del lenguaje. Naturalmente la regla se puede generalizar de la siguiente forma: Se replicará la sobrecarga de parametros unitarios de los constructores respecto del operador de asignación.

LA ASIGNACIÓN EN JERARQUÍAS DE HERENCIA

El operador de asignación evidentemente no se hereda, pues, como ya hemos visto, si no se declara expresamente en una clase derivada, el compilador proveerá uno por defecto, solapando así, en cualquiera de los casos, la función de la clase base. Pero, atención, esto significa que se solaparán todas las funciones con nombre “operator=(...)” y no sólo la función con un argumento Base& o const Base&. Y es que el lector debe aprender que asignación y herencia no son pareja de recibo: si la herencia es múltiple los problemas aumentarán; si virtual, los problemas pueden resultar demasiado sutiles (al decir de los notarios: las asignaciones de herencias pueden conllevar muchos problemas).

CONTINUARÁ …

Tras una primera y elemental revisión de las cuitas que rodean y bordean al operador de asignación, en la siguiente entrega examinaremos algunos de los recovecos más oscuros del lenguaje hasta llegar a … “la regla de oro”. Paciencia, amable lector.

[1] Al decir de Oscar Wilde, “la experiencia es el nombre que cada uno da a sus propios errores”, mientras que para Auguez es “la suma de nuestros desengaños”. Un experto es, por tanto, una persona que, a más de equivocarse muchísimo, sufre abundantes rechazos y desencantos. La línea entre un experto y un psicótico resulta, así, sorprendentemente frágil.

[2] La bibliografía se detalla al final de la segunda parte de este artículo.

[3] El nombre completo es “Working Paper for Draft Proposed International Standard for Information Sytems -- Programming Language C++”, y su última versión, de abril-95 en el momento de escribir este artículo, ha sido sometida a revisión pública, con petición de comentarios, en los países cuyo capítulo nacional de estandarización así lo ha requerido (no España, naturalmente).

 
 
 
volver a la página de publicaciones
 
 
 Pº. Castellana 188, 14º e · 28046 - Madrid · info@a4devis.com