Page 72
Conversiones de tipos
Cuando queremos definir una serie de operadores para trabajar con una clase tenemos que
redefinir cada operació n para emplearla con los objetos de esa clase y despué s redefinirla
tambié n para todas las operaciones con otros tipos (y además en ambos sentidos, para
operadores conmutativos). Para evitar tener que definir las funciones que operan con objetos de
nuestra clase y objetos de otras clases podemos emplear un truco bastante simple: definimos un
conversor de los tipos para pasar objetos de otros tipos a objetos de nuestra clase.
Uso de constructores
Un tipo de conversió n de tipos es la realizada mediante constructores que aceptan como
parámetro un objeto de un tipo y crean un objeto de nuestra clase usando el objeto parámetro.
Por ejemplo, si tenemos un tipo complejo con la siguiente definició n:
class complejo {
private:
double re, im;
public:
complejo(double r, double i=0) { // constructor / conversor de double a complejo
re = r; im = i;
}
// operadores como funciones amigas
friend complejo operator+ (complejo, complejo); // suma de complejos
friend complejo operator* (complejo, complejo); // producto de complejos
...
// operadores como funciones miembro
complejo operator+=(complejo); // suma y asignación
complejo operator*=(complejo); // producto y asignación
...
};
El constructor de la clase complejo nos sirve como conversor de variables double a complejos, y
además como los
constructores con un só lo parámetro no necesitan ser
invocados
explícitamente, la sintaxis de la conversió n es mucho más amigable:
complejo z1 = complejo (23); // z1 toma el valor (23 + i*0)
complejo z2 = 23; // z2 toma el valor (23 + i*0), llamamos
// implícitamente al constructor
Con la definició n de la clase anterior, cualquier constante será convertida a double y generará el
nú mero complejo correcto. Si usáramos los dos parámetros del constructor, la conversió n de
constantes a double tambié n se realizaría. Si en un operador se necesita un complejo como
operando y empleamos un double, el operador llamara al constructor de complejos y
transformará el operando en complejo. Só lo deberemos implementar operadores con parámetros
de tipos distintos a nuestra clase si es necesaria la máxima eficiencia (nos evitaremos la
construcció n del objeto temporal).
Si el operador crea objetos temporales automáticamente, los destruirá en cuanto pueda
(generalmente despué s de emplearlos en la operació n). La conversió n implícita só lo se
realiza si
el conversor definido por el usuario es ú nico.
Operadores de conversió n
La conversió n de tipos usando constructores tiene algunos problemas:
1. No puede haber conversió n implícita de un objeto de una clase a un tipo básico, ya que
los tipos básicos no son clases.
2. No podemos especificar la conversió n de un tipo nuevo a uno viejo sin modificar la
vieja clase.
3. No es posible tener un constructor sin tener además un conversor.
66

Page 73
El ú ltimo problema no es realmente grave, ya que el empleo del constructor como conversor
suele tener siempre un sentido, y los dos primeros problemas se solucionan definiendo
operadores de conversió n para el tipo fuente.
Una funció n miembro X::operatorT(), donde T es el nombre de un tipo, define una conversió n
de X a T. Este tipo de conversiones se deben definir só lo si son realmente necesarias, si se usan
poco es preferible definir una funció n miembro normal para hacer las conversiones, ya que hay
que llamarla explícitamente y puede evitar errores no intencionados.
Problemas de ambigüedad
Una asignació n o inicializació n de un objeto de una clase
X
es legal
si, o
bien el valor asignado
es de tipo
X
o só lo hay una conversió n de el valor asignado al
tipo
X
. En algunos casos una
conversió n necesita el uso repetido de constructores o operadores de conversió n, só lo se usará
conversió n implícita de usuario en un primer nivel, si son necesarias varias conversiones de
usuario hay que hacerlas explícitamente. Si existe más de un conversor de tipos, la conversió n
implícita es ilegal.
Operadores y objetos grandes
Cuando una clase define objetos pequeños, la utilizació n de copias de los objetos en las
conversiones o en las operaciones no causa mucho problema, pero su la clase define objetos de
gran tamaño, la copia puede ser excesivamente costosa (ineficiente). Para evitar el empleo de
copias podemos definir los argumentos (y retornos) de una funció n como referencias
(recordemos que los punteros no se pueden usar porque no se puede modificar el significado de
un operador cuando se aplica a punteros).
Los parámetros referencia no causan ningú n problema, pero los retornos referencia deben se
usados con cuidado: si hay que crear el objeto resultado es preferible retornar un objeto y que se
copie, ya que la gestió n de memoria para retornar referencias a objetos creados en la funció n del
operador puede resultar muy complicada.
Asignació n e inicializació n
La asignació n entre objetos de un mismo tipo definido por el usuario puede crear problemas,
por ejemplo, si tenemos la clase cadena:
class cadena {
private:
char *p; // puntero a cadena
int tam; // tamaño de la cadena apuntada por p
public:
cadena (int t) { p = new char [tam =t] }
~cadena () { delete []p; }
}
la operació n:
cadena c1(10);
cadena c2(20);
c2 = c1;
asignará a c2 el puntero de c1, por lo que al destruir los objetos dejaremos la cadena c2 original
sin tocar y llamaremos dos veces al destructor de c1. Esto se puede resolver redefiniendo el
operador de asignació n:
class cadena {
...
cadena& operator= (const cadena&); // operador de asignación
}
cadena& cadena::operator= (const cadena& a) {
if (this != &a) { // si no igualamos una cadena a si misma
67

Page 74
delete []p;
p = new char[tam = a.tam];
strncpy (p, a.p);
}
return *this; // nos retornamos a nosotros mismos
}
Con esta definició n resolvemos el problema anterior, pero aparece un nuevo problema: hemos
dado por supuesto que la asignació n se hace para objetos inicializados, pero, ¿ qué pasa si en
lugar de una asignació n estamos haciendo una inicializació n? Por ejemplo:
cadena c1(10);
cadena c2 = c1;
en esta situació n só lo construimos un objeto, pero destruimos dos. El operador de asignació n
definido por el usuario no se aplica a un objeto sin inicializar, en realidad debemos definir un
constructor copia para objetos de un mismo tipo, este constructor es el que se llama en la
inicializació n:
class cadena {
...
cadena (const cadena&); // constructor copia
}
cadena::cadena (const cadena& a) {
p = new char[tam = a.tam];
strncpy (p, a.p);
}
Subíndices
El operador operator[] puede redefinirse para dar un significado a los subíndices para los
objetos de una clase. Lo bueno es que el segundo operando (el subíndice) puede ser de
cualquier tipo.
Para redefinir el operador de subíndice debemos definirlo como funció n miembro. Por ejemplo,
para acceder a los elementos de un conjunto de enteros almacenado en una lista podemos
redefinir el operador de subíndice:
class cjto {
private:
nodo_lista *elem;
...
public:
...
int operator[] (int);
...
}
int cjto::operator[] (int i) {
nodo_lista *n = elem; // puntero al primer elem. de la lista
for (int k=0; k<i; k++) // recorremos la lista hasta llegar al elem i
if (!(n = n->sig)) return 0; // siempre y cuando este exista
return n->val; // retornamos el contenido de la posición i
}
Llamadas a funció n
La llamada a funció n, esto es, la notació n expresión(lista_expresiones), puede ser interpretada
como una operació n binaria con expresión como primer argumento y lista_expresiones como
segundo. La llamada operator() puede ser sobrecargada como los otros operadores. La lista de
expresiones se chequea como en las llamadas a funció n.
La sobrecarga de la llamada a funció n se redefine para emplear los objetos como llamadas a
funció n (sobre todo para definir iteradores sobre clases), la ventaja de usar objetos y no
funciones está en que los objetos tienen sus propios datos para guardar informació n sobre la
68

Page 75
aplicació n sucesiva de la funció n, mientras que las funciones normales no pueden hacerlo.
Otro
uso de la sobrecarga de la llamada a funció n está en su empleo como operador de subíndice,
sobre todo para arrays multidimensionales.
Dereferencia
El operador de dereferencia -> puede ser considerado como un operador unario postfijo. Dada
una clase:
class Ptr {
...
X* operator->();
};
podemos usar objetos de la clase Ptr para acceder a objetos de la clase X como si accedié ramos a
travé s de punteros:
Ptr p;
p->m = 7; // (p.operator->)()->m = 7;
Como se ve, se aplica el operador dereferencia y luego con el puntero resultado se accede a un
miembro. La transformació n de
p
en el puntero
p.operator->()
no depende del miembro
m
al que
se accede. En este sentido el operador es postfijo.
La utilidad de esta sobrecarga está en la definició n de punteros inteligentes, objetos que sirven
de punteros pero que realizan alguna funció n cuando accedemos a un objeto a travé s de ellos.
La posibilidad de esta sobrecarga es importante para una clase interesante de programas, la
razó n es que la indirecció n es un concepto clave, y la sobrecarga de -> proporciona una buena
forma de representar la indirecció n en los programas.
Incremento y decremento
Los operadores de incremento y decremento son muy interesantes a la hora de sobrecargarlos
por varias razones: pueden ser prefijos y postfijos, son ideales para representar los accesos a
estructuras ordenadas (listas, arrays, pilas, etc.), y pueden definirse de forma que verifiquen
rangos en tiempo de ejecució n.
Para sobrecargar estos operadores en forma prefija hacemos lo de siempre, pero para indicar
que el operador es postfijo lo definimos con un argumento entero (como el operador es unario,
está claro que no se usará el parámetro, es un parámetro vacío, pero con la declaració n el
compilador distingue entre uso prefijo y postfijo)::
class Puntero_seguro_a_T {
T* p; // puntero a T, valor inicial del array
int tam; // tamaño vector apuntado por T
...
T* operator++ (); // Prefijo
T* operator++ (int); // Postfijo
...
};
Definiendo los operadores de incremento y decremento de esta forma podremos verificar si
accedemos a un valor superior o inferior del array, impidiendo errores por interferencia con
otras variables.
Sobrecarga de new y delete
Al igual que el resto de operadores, los operadores operator new y operator delete se pueden
sobrecargar. Esto se emplea para crear y destruir objetos de formas distintas a las habituales:
reservando el espacio de forma diferente o en posiciones de memoria que no están libres en el
heap, inicializando la memoria a un valor concreto, etc.
69

Page 76
El operador new tiene un parámetro obligatorio de tipo size_t y luego podemos poner todo tipo y
nú mero de parámetros. Su retorno debe ser un puntero void. El parámetro size_t es el tamaño
en bytes de la memoria a reservar, si la llamada a new es para crear un vector size_t debe ser el
nú mero de elementos por el tamaño de la clase de los objetos del array.
Es muy importante tener claro lo que hacemos cuando redefinimos la gestió n de memoria, y
siempre que sobrecarguemos el new o el delete tener presente que ambos operadores están
relacionados y ambos deben ser sobrecargados a la vez para reservar y liberar memoria de
formas extrañas.
Funciones amigas o mé todos
Una pregunta importante es: ¿ Cuándo debo sobrecargar un operador como miembro o como
funció n amiga?
En general, siempre es mejor usar miembros porque no introducen nuevos nombres globales.
Cuando queremos definir operandos que afectan al estado de la clase debemos definirlos como
miembros o como funciones amigas que toman una referencia no constante a un objeto de la
clase. Si queremos emplear conversiones implícitas para todos los operandos de una operació n,
la funció n que sobrecarga el operador deberá ser global y recibir como parámetros una
referencia constante o un argumento que no sea una referencia (esto permite la conversió n de
constantes).
Si no hay ninguna razó n que nos incline a usar una cosa u otra lo mejor es usar miembros. Son
más có modos de definir y más claros a la hora de leer el programa. Es mucho más evidente que
un operador puede modificar al objeto si es un miembro que si la funció n que lo implementa
recibe una referencia a un objeto.
TEMPLATES
Genericidad
El C++ es un lenguaje muy potente tal y como lo hemos definido hasta ahora, pero al ir
incorporándole características se ha tendido a que no se perdiera eficiencia (dentro de unos
márgenes) a cambio de una mayor comodidad y potencia a la hora de programar.
El C introdujo en su momento un mecanismo sencillo para facilitar la escritura de có digo: las
macros. Una macro es una forma de representar expresiones, se trata en realidad de evitar la
repetició n de la escritura de có digo mediante el empleo de abreviaturas, sustituimos una
expresió n por un nombre o un nombre con aspecto de funció n que luego se expande y sustituye
las abreviaturas por có digo.
El mecanismo de las macros no estaba mal, pero tenía un grave defecto: el uso y la definició n de
macros se hace a ciegas en lo que al compilador se refiere. El mecanismo de sustitució n que nos
permite definir pseudo-funciones no realiza ningú n tipo de chequeos y es por tanto poco
seguro. Además, la potencia de las macros es muy limitada.
Para evitar que cada vez que definamos una funció n o una clase tengamos que replicar có digo
en funció n de los tipos que manejemos (como parámetros en funciones o como miembros y
retornos y parámetros de funciones miembro en clases) el C++ introduce el concepto de
funciones y clases gené ricas.
Una funció n gené rica es realmente como una plantilla de una funció n, lo que representa es lo
que tenemos que hacer con unos datos sin especificar el tipo de algunos de ellos. Por ejemplo
una funció n máximo se puede implementar igual para enteros, para reales o para complejos,
siempre y cuando este definido el operador de relació n <. Pues bien, la idea de las funciones
gené ricas es definir la operació n de forma general, sin indicar los tipos de las variables que
intervienen en la operació n. Una vez dada una definició n general, para usar la funció n con
70

Page 77
diferentes tipos de datos la llamaremos indicando el tipo (o los tipos de datos) que intervienen
en ella. En realidad es como si le pasáramos a la funció n los tipos junto con los datos.
Al igual que sucede con las funciones, las clases contenedor son estructuras que almacenan
informació n de un tipo determinado, lo que implica que cada clase contenedor debe ser reescrita
para contener objetos de un tipo concreto. Si definimos la clase de forma general, sin considerar
el tipo que tiene lo que vamos a almacenar y luego le pasamos a la clase el tipo o los tipos que le
faltan para definir la estructura, ahorraremos tiempo y có digo al escribir nuestros programas.
Funciones gené ricas
Para definir una funció n gené rica só lo tenemos que poner delante de la funció n la palabra
template seguida de una lista de nombres de tipos (precedidos de la palabra class) y separados
por comas, entre los signos de menor y mayor. Los nombres de los tipos no se deben referir a
tipos existentes, sino que deben ser como los nombres de las variables, identificadores.
Los tipos definidos entre mayor y menor se utilizan dentro de la clase como si de tipos de datos
normales se tratara. Al llamar a la funció n el compilador sustituirá los tipos parametrizados en
funció n de los parámetros actuales (por eso, todos los tipos parametrizados deben aparecer al
menos una vez en la lista de parámetros de la funció n).
Ejemplo:
template <class T> // sólo un tipo parámetro
T max (T a, T b) { return (a>b) ? a : b } // función genérica máximo
Los tipos parámetro no só lo se pueden usar para especificar tipos de variables o de retornos,
tambié n podemos usarlos dentro de la funció n para lo que queramos (definir variables,
punteros, asignar memoria dinámica, etc.). En definitiva, los podemos usar para lo mismo que
los tipos normales.
Todos lo modificadores de una funció n (inline, static, etc.) van despué s de template < ... >.
Las funciones gené ricas se pueden sobrecargar y tambié n especializar. Para sobrecargar una
funció n gené rica lo ú nico que debemos hacer es redefinirla con distinto tipo de parámetros
(haremos que emplee más tipos o que tome distinto nú mero o en distinto orden los parámetros),
y para especializar una funció n debemos implementarla con los tipos parámetro especificados
(algunos de ellos al menos):
template <class T>
T max (T a, T b) { ... } // función máximo para dos parámetros de tipo T
// sobrecarga de la función
template <class T>
T max (int *p, T a) { ... } // función máximo para punteros a entero y valores de
// tipo T
// sobrecarga de la función
template <class T>
T max (T a[]) { ... } // función genérica máximo para vectores de tipo T
// especialización
// función máximo para cadenas como punteros a carácter
const char* max(const char *c1, const char *c2) {
return (strncmp(c1, c2) >=1) ? c1 : c2;
}
// ejemplos de uso
int i1 = 9, i2 = 12;
cout << max (i1, i2); // se llama a máximo con dos enteros, T=int
int *p = &i2;
cout << max (p, i1); //llamamos a la función que recibe puntero y tipo T (T=entero)
71

Page 78
cout << max ("HOLA", "ADIOS"); // se llama a la función especializada para trabajar
// con cadenas.
Con las funciones especializadas lo que sucede es muy simple: si llamamos a la funció n y existe
una versió n que especifica los tipos, usamos esa. Si no encuentra la funció n, busca una funció n
template de la que se pueda instanciar una funció n con los tipos de la llamada. Si las funciones
están sobrecargadas resuelve como siempre, si no encuentra ninguna funció n aceptable, da un
error.
Clases gené ricas
Tambié n podemos definir clases gené ricas de una forma muy similar a las funciones. Esto es
especialmente ú til para definir las clases contenedor, ya que los tipos que contienen só lo nos
interesan para almacenarlos y podemos definir las estructuras de una forma más o menos
gené rica sin ningú n problema. Hay que indicar que si las clases necesitan comparar u operar de
alguna forma con los objetos de la clase parámetro, las clases que usemos como parámetros
actuales de la clase deberán tener sobrecargados los operadores que necesitemos.
Para declarar una clase paramé trica hacemos lo mismo de antes:
template <class T> // podríamos poner más de un tipo
class vector {
T* v; // puntero a tipo T
int tam;
public:
vector (int);
T& operator[] (int); // el operador devuelve objetos de tipo T
...
}
pero para declarar objetos de la clase debemos especificar los tipos (no hay otra forma de saber
por que debemos sustituirlos hasta no usar el objeto):
vector<int> v(100); // vector de 100 elementos de tipo T = int
Una vez declarados los objetos se usan como los de una clase normal.
Para definir los mé todos de la clase só lo debemos poner la palabra template con la lista de tipos
y al poner el nombre de la clase adjuntarle su lista de identificadores de tipo (igual que lo que
ponemos en template pero sin poner class):
template <class T>
vector<T>::vector (int i) {
...
}
template <class T>
T& vector<T>::operator[] (int i) {
...
}
...
Al igual que las funciones gené ricas, las clases gené ricas se pueden especializar, es decir,
podemos definir una clase especifica para unos tipos determinados e incluso especializar só lo
mé todos de una clase. Lo ú nico a tener en cuenta es que debemos poner la lista de tipos
parámetro especificando los tipos al especificar una clase o un mé todo:
// especializamos la clase para char *, podemos modificar totalmente la def. de la
// clase
class vector <char *> {
char *feo;
public:
vector ();
void hola ();
72

Page 79
}
// Si sólo queremos especializar un método, lo declaramos como siempre pero con el
// tipo para el que especializamos indicado
vector<float>::vector (int i) {
... // constructor especial para float
}
Además de lo visto el C++ permite que las clases gené ricas admitan constantes en la lista de
tipos parámetro:
template <class T, int SZ>
class pila {
T bloque[SZ]; // vector de SZ elementos de tipo T
...
};
La ú nica limitació n para estas constantes es que deben ser conocidas en tiempo de compilació n.
Otra facilidad es la de poder emplear la herencia con clases parametrizadas, tanto para definir
nuevas clases gené ricas como para definir clases no gené ricas. En ambos casos debemos indicar
los tipos de la clase base, aunque para clases gené ricas derivadas de clases gené ricas podemos
emplear tipos de nuestra lista de parámetros.
Ejemplo:
template <class T, int SZ>
class pila {
...
}
// clase template derivada
template <class T, class V>
class pilita : public pila<T, 20> { // la clase base usa el tipo T y SZ vale 20
...
};
// clase no template derivada
class pilita_chars : public pila<char, 50> { // heredamos de la clase pila con
// T=char y SZ=50
...
};
M ANEJO DE EXCEPCIONES
Programació n y errores
Existen varios tipos de errores a la hora de programar: los errores sintácticos y los errores de
uso de funciones o clase y los errores del usuario del programa. Los primeros los debe detectar
el compilador, pero el resto se deben detectar en tiempo de ejecució n, es decir, debemos tener
có digo para detectarlos y tomar las acciones oportunas. Ejemplos típicos de errores son el
salirse del rango de un vector, divisiones por cero, desbordamiento de la pila, etc.
Para facilitarnos el manejo de estos errores el C++ incorpora un mecanismo de tratamiento de
errores más potente que el simple uso de có digos de error y funciones para tratarlos.
Tratamiento de excepciones en C++ (throw - catch - try)
La idea es la siguiente: en una cadena de llamadas a funciones los errores no se suelen tratar
donde se producen, por lo que la idea es lanzar un mensaje de error desde el sitio donde se
produce uno y ir pasándolo hasta que alguien se encargue de é l. Si una funció n llama a otra y la
funció n llamada detecta un error lo lanza y termina. La funció n llamante recibirá el error, si no
lo trata, lo pasará a la funció n que la ha llamado a ella. Si la funció n recoge la excepció n
ejecuta
una funció n de tratamiento del error. Además de poder lanzar y recibir errores, debemos definir
73