Novedades de C++98

abril 8, 2010

Me he pasado varios artículos discutiendo las novedades de C++0x (que se iba a publicar en algún momento de la década pasada, paciencia…) pero me he dado cuenta de que mucha gente que programa en C++ hoy en día aprendieron hace ya muchos años, o utilizaron un libro antiguo, y no conocen muchas de las características del lenguaje que se incluyeron en el año 1998. Creo que es importante ponerse al día antes de que se publique la próxima versión, así que daré un repaso un tanto resumido a las principales diferencias entre C++ pre-estándar y el C++98 (si crees que me olvido alguna deja un mensaje y lo añado).

Espacios de nombres

Los espacios de nombres se utilizan para organizar los identificadores globales y evitar así colisiones de nombre entre módulos. La sintaxis es bastante obvia:

namespace NombreUnico
{
    class Foo
    {
        //...
    };
    namespace Sub
    {
        int bar();
    } //namespace Sub
} //namespace NombreUnico
 
NombreUnico::Foo var;
int x = NombreUnico::Sub::bar();

Como puede verse en el ejemplo, los espacios de nombres son anidables y se accede a los miembros con el operador de acceso ::, el mismo que para acceder a los miembros de una clase. De hecho, se puede considerar que las clases y estructuras definen un espacio de nombres implícito. Nótese que, al contrario que con las clases, la definición de un espacio de nombres no termina con ;, por lo que suele ser una buena idea añadir un comentario indicando a qué viene esa llave.

Si no queremos referirnos a los miembros de un espacio de nombres siempre con el nombre completo podemos utilizar cualquiera de las siguientes técnicas:

{
    NombreUnico::Sub::bar();
}
{
    namespace nu = NombreUnico;
    nu::Sub::bar();
}
{
    using NombreUnico::Sub::bar;
    bar();
}
{
    using namespace NombreUnico::Sub;
    bar();
}

La idea general era que cada librería C++ definiera sus símbolos globales en un espacio de nombres de nombre largo, y por lo tanto único, y luego los usuarios hicieran un alias. Sin embargo, los alias de espacios de nombres no se usan demasiado.
Los using solo se recomiendan para programas pequeños o en contextos locales y nunca en un fichero .h.

También se pueden crear espacios de nombres anónimos, y entonces sus miembros son locales a la unidad de compilación en la que se declaran (como funciones y variables globales static en C).

Declaración de variables en for/while/if

En C++ clásico ya se podía declarar una variable en la primera expresión de un for, pero en C++ pre-98 la variable se crea fuera del bucle, mientras que en C++98 es una declaración local al bucle, por lo que el siguiente código es legal en C++98 pero falla con declaración duplicada con compiladores antiguos:

for (int x=0; x < 10; ++x)
    /*...*/;
for (int x=0; x < 10; ++x)
    /*...*/;

Un truco clásico para engañar al compilador viejo y que acepte la sintaxis nueva era:

#define for if (0) ; else for

Además en C++98 también se pueden declarar variables en la condición de un if, while o switch. Las restricciones son que solo se puede declarar una variable, debe estar inicializada y debe ser convertible a bool o entero, según corresponda. La declaración se evalúa siempre al valor de la variable (no se puede comparar con nada) así que es especialmente útil con punteros:

if (Dato *x = Funcion())
    /*usa x*/
while (char c = readchar())
    /*usa c*/

Tipo bool

El tipo bool es ahora un tipo predefinido con dos valores literales: true y false. Las tres son palabras reservadas.

Operadores de conversión

En C se fuerza la conversión de una expresión a un tipo dado escribiendo ese tipo entre paréntesis antes de la expresión:

void *x = (void *)0x800000;

En C++ esta sintaxis es válida, por supuesto, por compatibilidad, pero no es demasiado buena porque la conversión es indiscriminada: puede convertir una expresión a casi cualquier tipo. Y las garantías de tipo en C++ son más estrictas que en C. Y el hecho de que puedan existir funciones sobrecargadas hace que el tipo exacto de una expresión pueda tener gran importancia.
Para comprender el problema veamos los siguientes ejemplos:

//sobrecarga const/noconst
void Procesa(char *c) { /*...*/ }
void Procesa(const char *c) { /*...*/ }
 
void main()
{
    unsigned char buffer1[10];
    const unsigned char buffer2[10];
    //...
    Procesa((char*)buffer1); //bien
    Procesa((char*)buffer2); //???
}
void Funcion(long *x, long **y[])
{
    int a = (int)(*x);  //bien
    int b = (int)(*y[2]); //???
}

En el primer ejemplo el primer cast convierte un puntero a caracteres sin signo a un puntero a caracteres normales, una conversión inocua. El segundo pretende hacer lo mismo, pero inadvertidamente elimina el const llamando así a la sobrecarga errónea, con resultados potencialmente catastróficos.
El primer cast del segundo ejemplo es una simple conversión aritmética (long a int), pero el segundo se lía con tanto puntero y está convirtiendo un puntero a entero (falta un *).

El compilador acepta alegremente estos ejemplos aunque parece obvio que son errores. Así que C++98 define una serie de nuevos operadores para hacer casts, con una sintaxis idéntica a la de los templates:

static_cast

Se utiliza para conversiones aritméticas, enumeraciones, punteros y referencias que podrían ocurrir de forma implícita, además de sus inversas, pero no puede utilizarse para saltarse las restricciones const ni para convertir punteros de tipos no relacionados. Entre las de punteros se incluyen las conversiones de y hacia void* y las conversiones de una clase base a una derivada y viceversa.

char c = static_cast<char>(3);
void *p1 = static_cast<void*>(&c); 
char *p2 = static_cast<char*>(&c);
int *p3 = static_cast<int*>(&c); //Error! la conversión
  //entre (int*) y (char*) no es implícita en ningún sentido
 
enum Enum {A,B,C};
Enum e = static_cast<Enum>(2);
 
const char *txt = "hola";
char *s = static_cast<char*>(txt); //Error!

reinterpret_cast

Convierte entre todo tipo de punteros o referencias. Se llama reinterpret_cast porque no hace ningún tipo de conversión ni ajuste, sino que interpreta la memoria apuntada por un tipo como si fuera de otro:

const int x = 3;
const char *p1 = reinterpret_cast<const char*>(&x);
char *p2 = reinterpret_cast<char*>(&x); //Error! Se pierde const

const_cast

Quita o añade const (o volatile, pero esto se usa poco) de un tipo, pero no puede hacer ninguna otra conversión. Eso sí, puede cambiar varios const a la vez:

const char * const t = "hola";
char **q = const_cast<char**>(&t);

dynamic_cast

Este es el operador más sofisticado. Solamente hace conversiones entre punteros o referencias a objetos en una jerarquía de clases, pero es capaz hasta de convertir entre clases aparententemente no relacionas. Además comprueba si el objeto que se convierte es realmente del tipo indicado; si no lo es y la conversión es de punteros, devuelve NULL; si la conversión es de referencias lanza una excepción std::bad_cast. Compárese con static_cast que hace la conversión incondicionalmente.
Es útil, entre otras muchas cosas, para comprobar si un objeto es de una cierta subclase (aunque en general es mejor usar funciones virtuales):

class Animal
{
    public: virtual ~Animal() {}
};
class Perro : public Animal
{ //...
};
class Gato : public Animal
{ //...
};
 
void Funcion(Animal *a)
{
    if (Perro *perro = dynamic_cast<Perro*>(a))
        /*...*/;
}

También es capaz de resolver conversiones que atraviesan árboles de herencia múltiple arbitrariamente complejos:

class Uno
{
public: virtual ~Uno() {};
};
class Dos
{
public: virtual ~Dos() {};
};
class Derivado : public Uno, public Dos
{
};
Derivado obj;
Uno *uno = &obj;
Dos *dos = dynamic_cast<Dos*>(uno);

Este cast funciona a pesar de que los tipos Uno y Dos no están relacionados.
La principal limitación es que los tipos implicados deben ser polimórficos, es decir, deben tener alguna función virtual, propia o heredada. Por eso los ejemplos declaran el destructor virtual.

Literales de cadena constantes

En C un literal de cadena es de tipo array de caracteres. En C++98 es un array constante de caracteres. Al fin y al cabo, está prohibido modificarlos. Se admite provisionalmente, por compatibilidad, usar un literal de caracteres en el contexto de un char* (no constante), pero es probable que esto produzca un warning (aviso: conversión obsoleta de una constante de cadena a ‘char*’).

    const char *h = "hola"; //bien
    char *m = "mundo!"; //warning

Nombres de los ficheros cabecera

Algunas de las clases estándar cambian de significado, notablemente iostream y compañía. Para hacer la transición más sencilla a la nueva semántica se decidió que las nuevas clases estarían en un fichero de cabecera sin .h, es decir en lugar de . Además todos los símbolos globales se declaran en el espacio de nombres std (de standard).
Y por simetría de adaptan las cabeceras de C, quitando el .h al tiempo que se antepone una c. Por ejemplo:

#include <cstdio>
{
    std::printf("hola mundo\n");    
}

Constructor explicit

Un constructor de una clase que se pueda llamar con solo un parámetro se considera un constructor de conversión, es decir, se puede llamar implícitamente para convertir un valor del tipo del parámetro al tipo de la clase. Esto está muy bien para clases que encapsulan un valor, como string, por ejemplo. Pero a veces una clase tiene un constructor con un único parámetro de forma que no tenga sentido verlo como conversión. En ese caso el constructor se declara con la nueva palabra reservada explicit. Por ejemplo:

class Vector
{
public:
    explicit Vector(int tamano);
};
 
{
    Vector x(3); //bien
    Vector x = 3; //error
}

Variable miembro mutable

En ocasiones una función miembro de una clase es conceptualmente constante, es decir, no modifica el contenido de la clase, pero necesita modificar alguna variable miembro. En estos casos se puede usar un cast para quitar el const a this, pero eso no es muy prudente ni elegante. C++98 incluye la palabra reservada mutable que, aplicada a la declaración de una variable miembro, hace que esa variable no sea constante en las funciones miembro constantes. Esto se usa, por ejemplo, para hacer caché de propiedades costosas de calcular:

class DireccionIP
{
private:
    mutable bool m_hostNameValido;
    mutable std::string m_hostName;
 
    std::string GetHostNamePriv() const
    {
        //función costosa
    }
public:
    std::string GetHostName() const
    {
        if (!m_hostNameValido)
        {
            m_hostName = GetHostNamePriv();
            m_hostNameValido = true;
        }
        return m_hostName;
    }
};

Excepciones

En C++ pre-98 ya existían las excepciones pero cada compilador las implementaba de forma un tanto distinta. En general solo se podían lanzar como excepciones valores de tipos simples, y en el caso de querer lanzar objetos complejos se creaban dinámicamente y se lanzaba el puntero. Pero claro, la vida de ese objeto excepción se complicaba un poco.
En C++98 se puede lanzar cualquier objeto copiable, y se pueden capturar objetos de tipos complejos usando referencias. De hecho se incluye en la librería estándar una jerarquía de excepciones que se lanzan por valor, cuya base es std::exception:

#include <iostream>
#include <stdexcept>
 
int main()
{
    try
    {
        if (...)
            throw std::runtime_error("mensaje");
    }
    catch (std::exception &e)
    {
        std::cerr << e.what() << std::endl;
        return 1;
    }
    return 0;
}

Templates

Los compiladores previos a C++98 implementaban los templates de formas bastante diferentes unos de otros, y a menudo defectuosas. El nuevo estándar impuso orden en el caos y definió un comportamiento concreto al que, poco a poco, fueron convergiendo los compiladores del mundo real.
No entraré aquí en detalles sobre estos cambios, los templates ya son bastante difíciles por sí mismos.

Librería estándar STL

Por último, pero no menos importante, se estandariza una librería estándar con muchas clases útiles, notablemente una clase de cadenas de caracteres std::string, y muchos tipos de contenedores distintos: std::vector, std::map, std::set, std::list, etc. Esta librería se basa de forma intensiva en templates, y por eso se llama Standard Template Library o STL.

Artículos relacionados:

  1. L-valores y R-valores error: se requiere un l-valor como operando izquierdo de la...

Deja un comentario