Disponible la nueva versión "donationware" 7.3 de OrganiZATOR
Descubre un nuevo concepto en el manejo de la información.
La mejor ayuda para sobrevivir en la moderna jungla de datos la tienes aquí.

Curso C++

[Home]  [Inicio]  [Índice]


4.9.18k   Conversiones definidas por el usuario

El presente capítulo es un claro ejemplo de un tópico difícil de clasificar. Aunque podría haber encajado igualmente bien (o mal) en otros sitios, lo hemos incluido en el epígrafe dedicado a la sobrecarga de operadores porque en una de sus formas se refiere a la función-operador operator. Este es también el criterio del Dr. Stroustrup en su obra TC++PL.

§1  Preámbulo

Recordemos que el lenguaje C++ dispone de una serie de mecanismos de conversión para los tipos básicos, que son utilizados automáticamente en determinadas circunstancias. Son las conversiones estándar ( 2.2.5). El lenguaje también permite que puedan realizarse conversiones implícitas o explícitas para los tipos abstractos, aunque en este caso es el programador el que debe adoptar las medidas pertinentes, razón por la cual se denominan conversiones definidas por el usuario ("User-defined conversions").

Existen dos formas de definir estas últimas conversiones: mediante constructores y mediante operadores de conversión. Ambos tipos serán tratados en el presente capítulo.

§2  Conversiones de constructor

Hemos indicado ( 4.9.9) que el modelado de tipos está estrechamente relacionado con los constructores de clases, y que la posibilidad de realizar un modelado de un objeto b de tipo B, a otro tipo A distinto:

a = A(b);

depende de cómo esté definido el constructor de la clase A. Para que esta conversión sea posible, debe existir un constructor de conversión ( 4.11.2d1) que acepte un objeto tipo B como único argumento. Es decir, debe existir un método:

A::A(B b) { /* detalle de la conversión */  }


En realidad, los constructores de conversión constituyen el soporte del mecanismo C++ de modelado, de forma que la existencia de estos constructores es condición necesaria y suficiente para que pueda efectuarse este último (el modelado). Por ejemplo:

class X {
  public:
  X(int);    // constructor C-1
};

la mera existencia del constructor C-1 en la clase X, permite las siguientes asignaciones:

void f() {
  X a = 1;                 // Ok. invocación implícita a X(1)

  X b(1);                  // Ok. invocación implícita a X(1)
  a = 2;                   // Ok. invocación implícita a X(2)

  a = (X) 2;               // Ok. casting explícito (estlo tradicional)

  a = static_cast<X>(2);   // Ok. casting explícito (estilo C++)
}

Si eliminamos el constructor C-1 de la declaración de la clase, las sentencias anteriores serían erróneas.

Nota: las tres últimas sentencias implican en realidad dos operaciones: la creación de un objeto temporal tipoX conteniendo el Rvalue de la expresión y una asignación posterior utilizando el operador de asignación implícito de la clase X.

§2.1  Ejemplo

Para ilustrar la problemática de este tipo de conversiones explícitas e implícitas, construiremos un ejemplo ejecutable en el que creamos una clase Par que destinaremos a albergar los enteros múltiplos de 2. Lo que pretendemos es poder utilizar los miembros de esta clase en todas las circunstancias en que se podría utilizar un tipo básico int suponiendo que su valor sea un número par (divisible por 2).

El criterio para aceptar que un entero n puede ser miembro de la clase es que el resto de la división n/2 sea cero (consideramos que el cero es par, puesto que 0/2 es 0).

El diseño básico es el siguiente:

#include <iostream>
using namespace std;

class BadNumber {
  int num;                       // miembro -privado por defecto-
  public:
  BadNumber(int n=0): num(n) {}  // constructor explícito
  int what() { return num; }     // método
};

class Par {
  int val;
  void verify(int n) { if (n % 2) throw BadNumber(n); }
  public:
  Par(int n=0) {      // L.15: constructor de conversión
    verify(n);
    val = n;
    cout << "Creado numero " << val << endl;
  }
};

int main() {    // =================
  try {
    Par p0;            // M2: Creado numero 0
    Par p2 = 2;        // M3: Creado numero 2 
    Par p3 = 3;        // M4: Error: numero 3 impar
    Par p4 = (Par) 4;  // M5: Creado numero 4
    Par P6 = Par(6);   // M6: Creado numero 6
  }
  catch (BadNumber& e) {
    cout << "Error: numero " << e.what() << " impar." << endl;
  }
}

Salida:

Las salidas producidas por las sentencias de asignación contenidas en la función main, se han incluido en los comentarios junto a las sentencias, aunque las repetimos aquí:

Creado numero 0
Creado numero 2
Error: numero 3 impar
Creado numero 4
Creado numero 6

Comentario

La clase BadNumber sirve para lanzar una excepción en caso que se pretenda crear un número Par inválido. El método what nos devuelve dicho número.

La clase Par tiene un diseño muy simple: el miembro val almacena el número correspondiente. El método verify sirve para comprobar que el "valor" del objeto a crear cumple la condición exigida de ser par. En caso contrario lanza una excepción ( 1.6) que será recogida por el dispositivo try ... catch correspondiente de la función main.

Se ha dotado a la clase de un constructor por defecto que acepta un int como argumento. Si el valor n suministrado pasa la verificación correspondiente, se inicia el miembro val con el valor del argumento n y se muestra en pantalla el número creado.

En los comentarios de las sentencias M2 a M6 se muestra la salida obtenida en cada caso. Puede comprobarse que la existencia de un constructor de conversión como el definido en L.15 permite los modelados implícitos en las sentencias M3 y M4 o explícitos (sentencias M5 y M6), donde las constantes numéricas utilizadas como Rvalues son convertidas a objetos de tipo Par.

Observe que no ha sido necesario definir el operador de asignación = entre objetos Par. La versión por defecto suministrada por el compilador ( 4.9.18a) resulta suficiente para las asignaciones planteadas.

§2.1  Aumentar la funcionalidad

La clase diseñada cumple con su función de almacenar número pares. También permite una asignación de tipo Par = int, y detecta cualquier intento de crear un objeto no válido. Sin embargo, dista mucho de poder ser utilizada con la misma generalidad que los objetos de tipo int. Por ejemplo, las siguientes sentencias producirían un error de compilación:

Par p3 = p2 + 2;    // Error
Par p4 = p2 + p2;   // Error
Par p8 = 4 + p4;    // Error

La razón es que no están definidas las operaciones correspondientes: suma Par & int;  suma Par & Par, y suma int & Par [1].

Para acercar nuestro diseño a la funcionalidad deseada agregamos los operadores correspondientes. Serían los siguientes algoritmos:

Nota: en los ejemplos que siguen, pi es un objeto tipo Par, y n es un int.

§2.1a  Operador suma + entre tipos Par e int (resuelve situaciones del tipo  pi + n):

operator+(int n) {
  verify(n);    // verificar que el operando n es adecuado
  val += n;
}


§2.1b
  Operador suma + para tipos Par  (resuelve situaciones del tipo pi + pj):

Par operator+(const Par& p) {
  return Par(val + p.val);
}

Observe que el valor a devolver por la función se ha obtenido mediante una explícita al constructor utilizando el argumento adecuado. Aunque esta operación no precisa de verificación, de todas formas el constructor la realiza.


§2.1c
  Operador suma + entre tipos int y Par  (resuelve situaciones del tipo  n + pi):

Par operator+(int n, Par p) {
  Par pt(n);         // L1:
  return pt + p;     // L2:
}

La sentencia L1 crea un objeto automático pt tipo Par y valor n, mediante una invocación implícita al constructor de la clase (el constructor se encarga de verificar que el valor n es correcto). En L2 se utiliza el operador suma entre objetos tipo Par, definido en el punto anterior (§2.1b  ), para devolver el objeto resultante de la operación.

Una definición alternativa y más eficiente, sería la siguiente:

Par operator+(int n, Par p) {
  return Par(p.val + n);
}


Las dos primeras funciones-operador se integran como métodos no estáticos de clase; la última como función externa. En el listado adjunto se muestra el diseño resultante después de las adiciones anteriores ( Listado-1). El nuevo diseño permite realizar las operaciones deseadas (§2.1b ) con los resultados que se indican:

Par p3 = p2 + 1;    // -> Error: numero 1 impar.
Par p4 = p2 + p2;   // -> Creado numero 4
Par p5 = 3 + p2;    // -> Error: numero 3 impar.
Par p8 = 4 + p4;    // -> Creado numero 4
                    // -> Creado numero 8

Observe que los resultados son los esperados; la doble salida generada por la última sentencia es producida por el algoritmo §2.1c . La primera corresponde a la creación del objeto automático pt. La segunda a la construcción del objeto a devolver realizada en L2.

§2.2  Nuevas dificultades

No obstante lo anterior, una total libertad para la utilización conjunta de nuestro tipo Par con el tipo int exigiría muchas más posibilidades. Por ejemplo:

Par p2 += 2;
Par p4 += p2;
Par p8 /= 2;
++p2;

etc.

A las anteriores habría que añadir todas las circunstancias en que la conversión deba realizarse en sentido contrario (del tipo Par a int). Por ejemplo:

int x = p2;
int y = 3 + p2;
y += p2;

etc.

Con el diseño actual de la clase Par, todas estas sentencias producen un error de compilación. Su utilización exigiría implementar toda una serie de versiones sobrecargadas de los operadores correspondientes, lo que supone desde luego una buena cantidad de código.

§3  Aritmética mixta

Los problemas encontrados para establecer una aritmética mixta entre tipos Par e int, podrían generalizarse para cualquier par de tipos A y B. Resulta evidente que los problemas de estas aritméticas se refieren exclusivamente a los operadores @ binarios (operaciones del tipo a @ b). Y habida cuenta que los operadores están implementados como funciones-operador, si hemos establecido un álgebra para uno de los tipos (A por ejemplo), el problema de compatibilizarla con el otro tipo se reduce a disponer de un mecanismo de conversión de tipos B A. De forma que cuando una función-operador espere un argumento tipo A, el mecanismo de congruencia de argumentos ( 4.4.1a) disponga de recursos para realizar la conversión automáticamente.

Por ejemplo, en la sentencia:

int y = p2 + 3;

El compilador procede en dos fases: en la primera intenta resolver la expresión del Rvalue (p2 + 3), para a continuación resolver la asignación y = Rvalue.

Respecto a la primera, el compilador intentará encontrar una función operator+() adecuada ( 4.9.18b2), que caso de existir, respondería alguno de los siguientes prototipos:

Par::operator+(int);       // §3a
Par operator+(Par, int);   // §3b
int operator+(Par, int);   // §3c


En caso de existir alguna definición concordante, se utilizará el mecanismo de congruencia de argumentos para ajustar los valores de los argumentos actuales a los formales, y solo en caso de no existir concordancia se devolverá un error señalando que no existe una implementación adecuada de operator+ en el programa.

Nota: observe que en este caso no sirve la existencia de definiciones del tipo:

Par operator+(int, Par);   // §3d
int operator+(int, Par);   // §3e

y que tampoco se intentará convertir el tipo int en Par para intentar una operación del tipo Par + Par, o del tipo Par en int para intentar int + int. Observe igualmente que la existencia simultánea de las versiones §3a y §3b o §3c conduciría a un error de compilación porque ninguna de ellas tiene preferencia y se produciría ambigüedad.


En el supuesto que se consiga resolver el primer paso, el compilador intentará resolver la asignación utilizando el mismo criterio. Observe que dependiendo de la forma de operator+ encontrada, el resultado del Rvalue puede ser un tipo Par o int. Si se ha utilizado la forma §3c el resultado es un int, y la asignación int = int no presenta dificultad. Por el contrario, cualquiera de las formas  §3a o  §3b produce un resultado tipo Par. La asignación int = Par exige que el compilador busque una función-miembro de la clase int que responda al prototipo ( 4.9.18a):

int& int::operator=(Par);

En su defecto, encuentra la versión global para el tipo int:

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

Su utilización exigiría que el mecanismo de congruencia de argumentos encontrase una forma de adaptar el argumento actual (Par) con el formal (int). Como esto no es posible, el compilador lanza un mensaje de error: Cannot convert 'Par' to 'int'....

§4  Operadores de conversión

Hemos visto que los constructores de conversión antes mencionados , permiten la existencia de conversiones explícitas e implícitas. Sin embargo requieren un considerable esfuerzo de programación para establecer una aritmética mixta entre dos tipos A y B.

La conveniencia de disponer de un sistema que permita definir fácilmente la transformación de un tipo B en otro A, ha motivado que el lenguaje C++ implemente la existencia de los denominados operadores de conversión (también funciones de conversión); un extraño híbrido entre los constructores y el mecanismo de sobrecarga de operadores, que utiliza las funciones-operador [2].

Nota: Ellis y Stroustrup informan [6] que los operadores de conversión pueden realizar dos tareas que no pueden ser realizadas por los constructores:

  • Definir la conversión de un tipo abstracto a un tipo simple.

  • Definir la conversión de una clase B a otra A sin modificar la declaración de la clase A.

Un operador o función de conversión de un tipo abstracto B a otro A, es un método público implementado en la clase B que define una conversión del tipo B al tipo A y responde a la siguiente declaración:

B::operator A ();

Observe que esta declaración se parece a la de los constructores ( 4.11.2d1) en el sentido que no puede especificarse el valor devuelto en su declaración (ni siguiera void), aunque la definición sí debe contener una sentencia return. Tampoco acepta la especificación de ningún tipo de parámetro. Estas funciones se definen como "función sin aceptar argumento, devolviendo un especificador de conversión de tipo" [3].

El componente A de la declaración es el especificador del tipo de conversión. Se considera parte integrante del nombre de la función operador, y no tiene porqué ser necesariamente un nombre de clase. Puede ser un tipo básico o cualquier calificador de tipo con excepción de una matriz o una función. Ejemplos:

B::operator int ();     // convierte a tipo int
B::operator char* ();   // convierte a tipo puntero-a-char
B::operator void* ();   // convierte a tipo puntero-a-void (genérico)
B::operator bool ();    // convierte a tipo bool [5]
B::operator int& ();    // convierte a tipo referencia-a-int


La definición debe incluir una sentencia return que devuelva un valor, cuyo tipo debe coincidir con el del especificador de tipo de conversión utilizado, o ser asimilable a él mediante una transformación estándar. Este valor devuelto es justamente el que especifica la conversión. Ejemplo:

class MiClase {
  int val;
  operator int() { return val; }
};

En cambio:

class MiClase {
  int val;
  operator void*() { return val; }   // Error!
};

Genera un error de compilación: Cannot convert 'int' to 'void *', ya que el valor val devuelto no puede ser convertido a tipo void* (puntero genérico 4.2.1d).

Cuando existe un operador de este tipo, cada vez que el compilador necesite una conversión de un objeto obj de tipo MiClase a tipo int, utilizará el valor devuelto por la invocación (recuerde que operator int es un solo identificador).  Es decir: si existe un operador:

B::operator A () { ...; return a; }

las conversiones explícitas (mediante un modelado -cast-) o implícitas:

a = (A)b;

a = b:

son transformadas por el compilador en:

a = b.oprator A();

§4.1  Ejemplo:

Retomando el caso anterior de una clase Par para albergar los enteros pares , la definición de un operador de conversión de los tipos Par a int podría tener alguna de las definiciones siguientes:

operator int () { return val; }        // definición inline
Par::operator int () { return val; }   // definición offline


El listado-2 muestra el diseño resultante para la clase ( Listado-2). La mera existencia de este método en el cuerpo de Par garantiza que el compilador puede realizar automáticamente todas las conversiones de tipo Par int que sean necesarias. En todos los casos la conversión se realiza mediante una invocación a la función de conversión correspondiente:

Par::operator int()


Las sentencias que siguen muestran algunas de estas conversiones junto con los resultados obtenidos (suponemos estas sentencias en el cuerpo de la función main del listado-2):

Par p2 = 2;           // -> Creado numero 2
int x1 = p2 + 3;      // -> x1 == 5
int x2 = p2;          // -> x2 == 2
int x3 = 3 + p2;      // -> x3 == 5
x3 += p2;             // -> x3 == 7
cout << "X3 = " << x3 << endl;

El uso de estas conversiones no está limitada a operaciones de asignación e inicialización.  También son posibles otras:

int x4 = (p2) ? 1 + p2 : 0;         // -> x4 == 3
int x5 = (p2 && x1) ? p2 + 1 : x3;  // -> x5 == 3
if (p2) cout << "Cierto";           // -> Cierto
else    cout << "Falso";


§4.2  Una función  de conversión puede ser virtual ( 4.11.8a) y puede ser heredada [4].  Ejemplo:

class B  {
  int x;
  public:  operator int () { return x; }
};
class D : public B { };
...
void foo() {
   D d;
   int n = d;   // Ok! equivale a: n = d.B::operator int()
}


§4.3  La función de conversión en una clase derivada no oculta la función de conversión de una superclase, a menos que ambas se refieren al mismo tipo. Ejemplo:

class B {
  ...
  public:
  operator int();
};
 
class D : public B {
  ...
  public:
  operator char();
};
...
void func(D& d) {
  if (d) {       // Error! 
     ...
   }
}

La sentencia anterior produce un error de compilación: Ambiguity between 'B::operator int()' and 'D::operator char().


§4.4
  No es posible definir más de una función de conversión para una clase dada. La razón de esta limitación es que en caso contrario se producirían ambigüedades difíciles de controlar. Ejemplo:

class B {
   ...
   public:
   operator int();
   operator cha();
};
...
void func(B b) {
  if (b) {      // Error!
    ...
  }
}

La sentencia anterior produce un error de compilación: Ambiguity between 'B::operator int()' and 'B::operator char()'.


§5  Reglas de uso de las conversiones de usuario

Las conversiones de usuario solo se utilizan cuando cuando no existe ambigüedad, y están sujetas a ciertas restricciones:


§5.1
   El compilador solo aplicará a un objeto b una conversión definida por el usuario de forma automática (implícita). Es decir, puede aplicar una función de conversión del tipo

b.operator tipoA()

pero no una doble conversión como

b.operator tipoA().operator tipoC()

En aquellos casos es que se requiera una conversión compuesta como la anterior, es necesario expresarla explícitamente. Ejemplo:

class A {
  ...
  public:
  operator int();
};
class B {
  ...
  public:
  operator A();
};
...
void func(A& a, B& b) {
  int x = a;       // Ok. -> a.operator int()
  a = b;           // Ok. -> b.operator A()
  int y = (A) b;   // Ok. cast explícito -> b.operator A().operator int()
  int z = b;       // Error!!
}


§5.2  Recordemos que en la invocación de funciones, las reglas de congruencia estándar de argumentos ( 4.4.1a) establecen que las concordancia después de realizar conversiones estándar preceden a las posibles concordancias después de realizar conversiones de usuario. De forma que estas últimas solo se producen si no existe posibilidad de ninguna de las anteriores. Ejemplo:

class A { /* ... */ public: A(int); };
class B { /* ... */ public: operator int(); };
void f1(double d);          // L.3:
void f1(A a);               // L.4
void f1(B b);               // L.5
...
void func() {
  f1(5);                    // L.8:
}


En este caso el compilador dispone de recursos para convertir el tipo const int utilizado en L.8 como argumento, a cualquiera de los tipos (double, A y B) utilizados en las tres versiones de f1. Sin embargo, la versión elegida sería L.3, porque la conversión const int a double es estándar, mientras que cualquiera de las otras dos es de usuario.

Suprimiendo la sentencia L.3, ocurre que ambas conversiones de usuario (L.4 y L.5) son igualmente válidas, con lo que se produciría ambigüedad y el correspondiente error de compilación en L.8.

Nota: de los tres compiladores comprobados (Borland C++ 5.5;  MS Visual C++ 6.0 y GNU C++ 2.95.3, es este último el que genera un error más explícito:

L.8: -> call of overloaded 'f1 (int) is ambiguous.
L.4: -> candidates are: void f1(A &) <near match>
L.5: ->                 void f1(B &) <near match>


Si se suprimen las sentencias L.3 y L.4 solo quedaría la conversión de usuario implícita en L.5 y sería esta la versión utilizada. La conversión const int a B es realizada por el operador de conversión B::operator int(). Recíprocamente, si se suprimen L.3 y L.5 la versión L.4 utilizada realiza una conversión const int a A de constructor.

En el momento de escribir estas notas el grado de adecuación al Estándar de los compiladores utilizados varía considerablemente. En concreto, si los argumentos son pasados "por referencia", en vez de "por valor" ( 4.2.3), solo el compilador Borland es capaz de realizar correctamente las conversiones de usuario implícitas y explícitas. En el cuadro adjunto se muestra los resultados obtenidos para las conversiones de constructor y de operador de conversión:

// Conversiones de constructor
class A {
  ...
  public: A(int);
};
void f1(A& a);
...
void func() {
  f1(5);        // L.1
  f1( (A)5 );   // L.2
}

L. Borland C++ 5.5 MS VC++ 6.0 GNU C++ 2.95.3
L.1 Ok. Error Error
L.2 Ok. Ok. Error


// conversiones de operador de conversión
class B {
  ...
  public: operator int();
};
void f1(B& b);
...
void func() {
  f1(5);       // L.1
  f1( (B)5 );  // L.2
}

L. Borland C++ 5.5 MS VC++ 6.0 GNU C++ 2.95.3
L.1 Ok. Error Error
L.2 Ok. Ok. Error

  Inicio.


[1]  Una aritmética en la que se mezclan objetos de distinto tipo se denomina aritmética mixta.

[2]  El Estándar las clasifica dentro del epígrafe denominado de funciones-miembro especiales. Además de las funciones de conversión, el citado epígrafe comprende las siguientes entidades: constructores por defecto; constructor-copia; operador de asignación y destructores.

[3]  Literalmente (según la definición del Estándar): "Function taking no parameter returning conversion-type-id".

[4]  Recuerde que las funciones operator@ no siempre son heredadas. Por ejemplo operator= no es heredada en las subclases ( 4.9.18a).

[5]  En el capítulo dedicado a la sobrecarga de los operadores lógicos ( 4.9.18g) se muestra una interesante relación entre este operador de conversión y los operadores lógicos.

[6]  Stroustrup & Ellis:  ACRM  §12.3.2