Desde C hasta C++. La evolución continua (I) Ignacio Martín Bragado
Índice General
Índice General Copyright Introducción Reseña histórica Herramientas Estructuras de datos vs. clases Arrays en C vs. arrays dinámicos La nueva entrada/salida de C++ Pequeñas mejoras del C++ frente al C Argumentos por defecto. Sobrecarga de operadores Referencias vs. punteros. Conclusiones Sobre este documento...
Copyright (C) Ignacio Martín Bragado, Mundo Linux. Este artículo ha sido publicado en el nº 33 de Mundo Linux, una publicación de Revistas Profesionales http://www.revistasprofesionales.com
Introducción Desde que en la década de los 70 Ritchie y Kernighan crearon el lenguaje C hasta hoy ha pasado ya el tiempo suficiente para poder afirmar que el lenguaje C ha marcado un hito en la historia de la computación. El C ha sido, probablemente el lenguaje más usado en creación de software, y sistemas operativos enteros como Linux están escritos, salvo minúsculas partes de código, en él.
No obstante desde su nacimiento (que la historia cifra en una variante de un lenguaje anterior llamado B, construido en parte como lenguaje en el que programar UNIX) hasta ahora, el C ha ido sufriendo una evolución, para ir adaptando y solucionando problemas que el C presentaba a los programadores, hasta dar lugar al estándar C ANSI (1990) y un segundo estándar en 1.999. Una evolución mucho más drástica del C ha dado lugar al C++. Este lenguaje supone más la creación de un nuevo lenguaje que una mejora de C, si bien se tomó C como base por ser un lenguaje de nivel relativamente bajo, versátil, muy extendido y portable.
Reseña histórica El C++ ha sufrido también un largo proceso desde que fue inventado en AT&T por Bjarne Stroustrup. Empezó en 1980 como un primitivo "C con clases" hasta que en 1987 se hizo evidente la necesidad de un proceso de estandarización, proyecto en el que los laboratorios Bell de AT&T, contribuyeron activamente compartiendo el borrador del manual de referencia de C++. Dicho manual fue el documento base para su posterior estandarización ANSI, que tuvo lugar entre los años 1989-1991. Este proceso finalizó por fin en Julio de 1.998 (¡hace sólo tres años!). El documento final se puede consultar en http://web.ansi.org/public/search.asp. Una copia, quizás no muy reciente, está presente en http://www.csci.csusb.edu/dick/c++std/cd2/index.html.
Herramientas La cuestión es: ¿Por qué desarrollar un nuevo lenguaje de programación cuando el C cuenta ya con toda una historia de éxitos a sus espaldas? ¿Por qué aprenderlo?. La respuesta es que el C presenta una serie de inconvenientes y dificultades que el C++ trata de paliar. Precisamente de cómo paliar dichas dificultades es de lo que va a tratar esta serie de artículos. Para seguir estos artículos basta con tener una distribución de Linux que incluya un gcc relativamente reciente (versión 2.95 o superior) y conocer ya el lenguaje C. Este requisito no es necesario para estudiar C++ pero si lo será aquí puesto que pretendemos establecer una comparación con él y avanzar relativamente deprisa en el C++.
Estructuras de datos vs. clases Dado cualquier problema de programación relativamente complejo en C suele ser necesario crear una estructura de datos que pueda manejar hábilmente el
problema. Posteriormente se crean una serie de funciones que puedan operar y manipular dichas estructuras de datos. Por ejemplo, si en un programa necesitamos operar con rectángulos distintos y calcular su área, podemos poner: struct RECTANGULO { double LadoX; double LadoY; }; y crear una función: double Area(struct RECTANGULO *rect) { return rect->LadoX * rect->LadoY; } En C++ se puede asociar directamente una función a una estructura, de tal forma que quede patente que dicha función opera exclusivamente con la estructura, es decir, definir la estructura como: struct RECTANGULO { double LadoX; double LadoY; double Area(); }; e incluir la función Area() con el operador de resolución de ámbito "::" double RECTANGULO::Area() { return LadoX * LadoY; } y poner así el código como: int main() { RECTANGULO rect.LadoX rect.LadoY printf("El }
rect; = 4.3; = 3.2; área es %f\n",rect.Area());
¿Qué ventajas supone esto frente a la codificación de C?
El programa queda más claro e inteligible. El código queda agrupado en cuanto a un objeto (un rectángulo) y no esparcido en datos y funciones. Podemos proteger los datos que queramos de la estructura usando las palabras claves public, que permiten el total a los datos, private,
que sólo deja acceder a los datos a las funciones de la propia estructura, y protected, cuyo uso se verá en siguientes artículos. Podemos usar constructores y destructores que serán invocados cuando se construya el objeto y cuando se salga de ámbito. Podemos tratar objetos propios creados por nosotros con la misma facilidad que tipos de datos propios del compilador como int, char, ...
De esta forma surge la noción de objeto o clase: una agrupación de datos y funciones encapsuladas y consistentes en las que su está limitado. Antes de ver un ejemplo más completo donde poder exponer todo lo anterior es aconsejable plantearse otro problema.
Arrays en C vs. arrays dinámicos Cuando en C queremos tener una colección de elementos podemos crear un array: int cadena[80]; y tener así una colección de 80 enteros. No obstante en muchas ocasiones no conocemos el tamaño exacto de la colección en tiempo de compilación, sino cuando se ejecuta el programa. Para esto podemos asignar dinámicamente memoria con malloc y poner: unsigned int elementos = 80; int *coleccion; coleccion = (int *)malloc(elementos*sizeof(int)); Esto soluciona parcialmente el problema a costa de crear nuevos problemas potenciales:
1. Tenemos ahora un puntero char que puede ser sobreescrito (si copiamos más de 80 caracteres, en esta caso concreto). 2. Hemos necesitado hacer un "type cast" del puntero void * que devuelve malloc a int *. Aunque en este caso no se ve claro por qué esto puede ser un problema, hay que decir que los punteros void, si bien son muy útiles en ciertas ocasiones, son también muy peligrosos porque carecen de toda información sobre el objeto al cual apuntan, y esto puede dar lugar a multiplicidad de errores de "puntero loco". 3. Tenemos que acordarnos de borrar más tarde la memoria tomada con malloc. La solución ideal al problema sería tener un objeto que pudiera dinámicamente cambiar su tamaño y que gestionara sus recursos de forma que los problemas anteriores se pudieran solucionar:
1. Gestionando las entradas y salidas, de forma que si se sobrescribe el objeto avise o asigne memoria automáticamente. 2. Desaparezca la necesidad de tener un puntero y se pase a tener un objeto completo. 3. El destructor del objeto libere la memoria y se ocupe de restituir el estado del sistema, dejándolo como estaba antes de su creación.
Todo esto se puede concretar en unas líneas de código. El objetivo es crear un objeto (muy sencillo, es sólo un ejemplo) que gestione su tamaño dinámicamente y demuestre la utilidad de las clases y objetos en C++. Reseñar además que en C++ se pueden poner comentarios que abarquen hasta el final de línea precedidos por // . class Coleccion { public: Coleccion(); //Constructor ~Coleccion(); //Destructor void Asignar(unsigned int, int); int Devolver(unsigned int); private: void Redimensiona(); int elementos; int * pInt; }; Con esto tendríamos la declaración de la clase, falta ahora la definición: Coleccion::Coleccion() { elementos = 40; //Construye ya una colección de 40 elementos pInt = new int[elementos]; } Coleccion::~Coleccion() { delete [] pInt; //Libera la memoria asignada } void Coleccion::Asignar(unsigned int Indice, int Valor) { while(Indice >= elementos) //Comprobación de error Redimensiona(); //Asigna más memoria pInt[Indice] = Valor; } int Coleccion::Devolver(unsigned int Indice) { if(Indice >=elementos) //Comprobación de error { printf("Accediendo a un elemento que no existe\n"); exit(1); } return pInt[Indice]; } void Coleccion::Redimensiona() { int *pNewInt = new int[elementos+40]; for(int i=0; i<elementos;i++) pNewInt[i] = pInt[i]; elementos+=40; delete [] pInt; pInt = pNewInt; }
Este código se podría utilizar en el siguiente fragmento de programa: #include
void main() { Coleccion impares; unsigned int i; for(i=0; i<432; i++) impares.Asignar(i,2*i+1); for(i=0; i<500; i++) printf("Impar %d es %d\n",i,impares.Devolver(i)); } Este código ilustra muchos aspectos indicados y otros nuevos:
El uso de new para asignar memoria y delete para borrarla, prescindiendo de la sintaxis de malloc. La palabra reservada private para impedir el desde fuera del objeto a ciertos datos o funciones, y public para permitirlo. En este caso el "externo" no debe modificar el puntero pInt ni el número de elementos máximos que mantiene, y tampoco tiene por qué reasignar más espacio. Si intentara, por ejemplo, poner: impares.Redimensiona() se encontraría con el mensaje del compilador:
imartin@imartin:~/SPL/code/1\$ make coleccion0-privateerror g++ coleccion0-privateerror.p -o coleccion0-privateerror coleccion0-privateerror.p: In function `int main(...)': coleccion0-privateerror.p:46: `void Coleccion:: Redimensiona()' is private coleccion0-privateerror.p:59: within this context
La utilidad y sintáxis de los constructores y destructores: no devuelven argumentos (ni siquiera void) y el destructor está precedido por el símbolo ~. El constructor puede tener argumentos de entrada. El uso de funciones miembro públicas (Asignar() y Devolver()) para acceder de forma controlada a la información de la clase.
Este ejemplo podría mejorarse drásticamente, añadiendo comprobaciones de error al asignar memoria, y flexibilizando el número de elementos que da cabida por defecto (ahora 40), que podría pasarse como parámetro del constructor. Sería muy útil, no obstante, que pudiera tomar un valor por defecto. Además, asignar y obtener los elementos usando las funciones miembro Asignar() y Devolver() no deja de ser algo artificial. Sería mucho más natural si pudiéramos usar la notación convencional de arrays: impares[i] = 2*i+i; //En vez de Asignar() printf("Impar %d es %d\n",i,impares[i]); //En vez de Devolver() Veremos después como realizar estas mejoras. Por último queda decir que este mismo código podría haberse hecho usando struct en lugar de class. La única diferencia está en que struct tiene por defecto sus datos y funciones miembro públicos, y class privados.
La nueva entrada/salida de C++
A partir de ahora se va a usar una forma alternativa de entrada / salida, más propia de C++, consistente en redireccionar la salida a la salida estándar, llamada cout, con el operador <<. Análogamente se podría leer de la entrada estándar con cin y el operador >>. Así podemos escribir un número poniendo: #include
void main() { int i=4; double d=5.3;
//Streams de Input/Output
cout << "i vale " << i << " y d vale " << d << "\n"; } Hay que incluir iostream para que el símbolo cout quede definido. Dejo pera más adelante una explicación detallada, que tendrá lugar cuando hablemos de los "streams" (flujos de datos) en C++.
Pequeñas mejoras del C++ frente al C El C++ ha incluido pequeñas mejoras frente al C, algunas de las cuales he usado en el código anterior. Una lista no exhaustiva, pero si útil, de dichos cambios es la siguiente:
Se ha añadido el tipo de dato bool (además de int, double, etc...) como resultado de operaciones booleanas, es decir:
unsigned int i=4; bool comparacion = (i==5); cout << ((comparacion)? "4==5" : "4!=5") << "\n"; Se pueden definir las variables en cualquier sitio del código (no sólo al principio) y asignarlas valores no constantes: float f=4.3; float sqr_f = sqrt(f); cout << "La raíz de " << f << " es " << sqr_f << "\n"; La palabra reservada const modifica el comportamiento de una variable, haciéndola de solo lectura: int a=8; a = 7; //Bien const int b=a; //b=7; b = 8; //Error del compilador, no se puede cambiar valor const.
O impidiendo que se cambie el contenido de un objecto apuntado: int a=7, b=8; int const *pa=&a; pa=&b; //OK *pa = 6; //Error, no se puede modificar a través de este puntero Al intentar compilar estos fragmento el compilador nos pondrá algo como: const0.p:6: assignment of read-only variable `b' para el primer intento y const1.p:6: assignment of read-only location
para el segundo.
Los ficheros estándar a incluir ya no tienen extensión .h, aunque se conservan los antiguos por compatibilidad. Aquellos ficheros que sean heredados del C llevan una c delante: #include
//Fichero stdio.h de C #include
//Fichero math.h de C #include <stream> //Fichero de C++ Cuando declaremos estructuras, clases, enumeraciones o uniones no hace falta poner la palabra struct, class... en su definición, sino sólo el nombre que hayamos dado a la estructura, clase... struct xy { int x; int y; }; xy a; //No es necesario struct xy a, como en C a.x = 4; a.y = 5; Se puede hacer una función "inline" poniendo la palabra inline delante de su declaración o bien escribiendo su definición en la declaración, si se trata de una función miembro. Esto puede dar lugar a una mejora de rendimiento en ciertos casos.
Argumentos por defecto. Las funciones de C++ pueden llevar argumentos por defecto. Esta es una característica muy útil, que vamos a usar para mejorar ligeramente nuestro contenedor de enteros. Un valor por defecto se pone en la declaración de la función, de tal forma que, si se invoca a la función omitiendo ese parámetro, el valor por defecto se pasa a la misma. Basta con modificar la declaración: class Coleccion { public: Coleccion(int = 40); //Constructor con argumento por defecto ~Coleccion(); //Destructor void Asignar(unsigned int, int); int Devolver(unsigned int); private: void Redimensiona(); int elementos; int bloque; //Almacena el número de elementos al redimensionar int *pInt; }; Y la definición del constructor y Redimensiona: Coleccion::Coleccion(int def) { elementos = bloque = def; //Construye ya una colección de x elementos pInt = new int[elementos]; } void Coleccion::Redimensiona() { int *pNewInt = new int[elementos+bloque]; for(int i=0; i<elementos;i++) //Copio los viejos valores
pNewInt[i] = pInt[i]; elementos+=bloque; delete [] pInt; pInt = pNewInt; } Para llamar ahora a este objecto podríamos usar "Coleccion impares" que tomaría el argumento por defecto (40) o bien "Coleccion impares(10)", que le asignaría un valor 10. En este ejemplo se ven dos aspector nuevos:
El constructor de un objeto puede tomar argumentos puesto que se trata de una función. Cuando una función tiene valores por defecto el compilador permite invocarla con distinto número de argumentos, y rellena el resto con dichos valores por defecto.
Sobrecarga de operadores Hemos comentado que el ejemplo anterior supone una mejora sustancial sobre una codificación normal de C, puesto que permite de una forma cómoda y fácil de usar crear una especie de array que asigna espacio dinámicamente. También se ha comentado que su uso sería mucho más natural si pudiéramos hacerlo a través del operador normal de indexación []. El C++ permite modificar el comportamiento de un operador unario o binario mediante una estrategia llamada "sobrecarga de funciones y operadores". Dicha sobrecarga permite también que el compilador elija entre distintas funciones que tienen igual nombre, pero iten distintos argumentos. Usando esta sobrecarga para la clase Coleccion tendríamos: class Coleccion { public: Coleccion(int = 40); //Constructor con argumento por defecto ~Coleccion(); //Destructor int * operator[](unsigned int); //Sobrecarga del operador [] private: void Redimensiona(); int elementos; int bloque; //Almacena el número de elementos al redimensionar int *pInt; }; int *Coleccion::operator[](unsigned int index) { while(index >= elementos) Redimensiona(); return &pInt[index]; } El resto del código sería igual, desaparecen las funciones Asignar y Devolver, y main pasa a ser: void main() { Coleccion impares(10); //Más granular unsigned int i; for(i=0; i<432; i++) *impares[i] = 2*i+1; for(i=0; i<441; i++) printf("Impar %d es %d\n",i,*impares[i]); }
Sin duda el código queda mejor ahora pero ¿por qué la extraña notación *impares[i] = 2*i?. Esto es debido a que, si devolviéramos simplemente un int desde el operator[] en vez de int *, entonces la instrucción impares[i] = 2*i+i nos cambiaría el valor de UNA COPIA de la variable, y no el valor de la variable en si. Para cambiar el valor de la propia variable tenemos que devolver algo que nos permita modificarla, un puntero en este caso. Esto sigue siendo algo artificial. ¿Hay alguna forma de mejorarlo?
Referencias vs. punteros. Para solucionar problemas como el anterior y clarificar ciertos aspectos del código el C++ introduce las referencias. Una referencia es como un "link" entre variables. Con una referencia accedemos a una misma variable a través de dos etiquetas distintas. Veamos un ejemplo: int a = 7; int &b = a; //b es, a todos los efectos, a. b=3; cout << "a vale " << a << " y b vale " << b << "\n"; Produce la salida: imartin@imartin:~/SPL/code\$ ./referencia0 a vale 3 y b vale 3 También se puede intentar entender pensando que una referencia actúa como un puntero, en cuanto a que se pueden modificar valores de otras variables "a través de", pero prescindiendo de la aritmética de punteros. Así podemos modificar una vez más Coleccion, aprovechar para hacer el destructor inline incluyéndole en la declaración y borrando su definición Coleccion::~Coleccion() y tener: class Coleccion { public: Coleccion(int = 40); //Constructor con argumento por defecto ~Coleccion() { delete pInt; } //Destructor inline int & operator[](unsigned int); private: void Redimensiona(); int elementos; int bloque; //Almacena el número de elementos al redimensionar int *pInt; }; Al definir el operador [] veremos el signo de referencia &: int & Coleccion::operator[](unsigned int index) { while(index >= elementos) Redimensiona(); return pInt[index]; } y por fin nos encontramos un main intuitivo: void main() { Coleccion impares(10); unsigned int i;
//Más granular
for(i=0; i<432; i++) impares[i] = 2*i+1; for(i=0; i<441; i++) printf("Impar %d es %d\n",i,impares[i]); } Hay que destacar que, si bien el símbolo & se emplea también para obtener la dirección de una variable, el compilador sabe a que nos estamos refiriendo según el contexto, ya que cuando queramos usarlo como referencia lo usaremos siempre en una declaración de variable, contexto en el que no tendría sentido como operador de dirección de memoria: int &a = b; int *p = &b;
//No tiene sentido como dirección //No tiene sentido como referencia
Conclusiones Hemos visto en esta primera parte cómo el C++ facilita y mejora enormemente muchas facetas de programación que, si bien podrían hacerse en C, quedan más claros y menos tendentes a error codificadas en C++. Se han visto algunas características "menores" así como los importantes conceptos de clase, sobrecarga y referencia. Nos queda aún por ver temas fundamentales como la herencia, los templates, streams y el uso de la biblioteca estándar STL. Esto se comentará en siguientes entregas. Hasta entonces. Se incluye el código completo y compilable (cuando no haya explícitamente un error adrede) del código aquí expuesto.