Tabla de contenidos
C++ es una herramienta de mejora de la productividad. ¿Por qué si no haría el esfuerzo (y es un esfuerzo, a pesar de lo fácil que intetemos hacer la transición)
de cambiar de algún lenguaje que ya conoce y con el cual ya es productivo a un nuevo lenguaje con el que será menos productivo durante un tiempo, hasta que se haga con él? Se debe a que está convencido de que conseguirá grandes ventajas usando esta nueva herramienta.
En términos de programación, productividad significa que menos personas, en menos tiempo, puedan realizar programas más complejos y significativos. Desde luego, hay otras cuestiones que nos deben importar a la hora de escoger un lenguaje de programación. Aspectos a tener en cuenta son la eficiencia (¿la naturaleza del lenguaje hace que nuestros programas sean lentos o demasiado grandes?), la seguridad (¿nos ayuda el lenguaje a asegurarnos de que nuestros programas hagan siempre lo que queremos? ¿maneja el lenguaje los errores apropiadamente?) y el mantenimiento (¿el lenguaje ayuda a crear código fácil de entender, modificar y extender?). Estos son, con certeza, factores importantes que se examinarán en este libro.
Pero la productividad real significa que un programa que para ser escrito, antes requería de tres personas trabajando una semana, ahora le lleve sólo un día o dos a una sola persona. Esto afecta a varios niveles de la esfera económica. A usted le agrada ver que es capaz de construir algo en menos tiempo, sus clientes (o jefe) están contentos porque los productos les llegan más rápido y utilizando menos mano de obra y finalmente los compradores se alegran porque pueden obtener productos más baratos. La única manera de obtener incrementos masivos en productividad es apoyándose en el código de otras personas; o sea, usando librerías.
Una librería es simplemente un montón de código que alguien ha
escrito y empaquetado todo junto. Muchas veces, el paquete mínimo es
tan sólo un archivo con una extensión especial como
lib
y uno o más archivos de cabecera que le
dicen al compilador qué contiene la librería. El enlazador sabrá cómo
buscar el archivo de la librería y extraer el código compilado
correcto. Sin embargo, ésta es sólo una forma de entregar una
librería. En plataformas que abarcan muchas arquitecturas, como
GNU o Unix, el único modo sensato de entregar una libraría es con
código fuente para que así pueda ser reconfigurado y reconstruido en
el nuevo objetivo.
De esta forma, las librerías probablemente sean la forma más importante de progresar en términos de productividad y uno de los principales objetivos del diseño de C++ es hacer más fácil el uso de librerías. Esto implica entonces, que hay algo difícil al usar librerías en C. Entender este factor le dará una primera idea sobre el diseño de C++, y por lo tanto, de cómo usarlo.
Aunque muchas veces, una librería comienza como una colección de funciones, si ha usado alguna librería C de terceros habrá observado que la cosa no termina ahí porque hay más que comportamiento, acciones y funciones. También hay características (azul, libras, textura, luminiscencia), las cuales están representadas por datos. En C, cuando debemos representar características, es muy conveniente agruparlas todas juntas en una estructura, especialmente cuando queremos representar más de un tipo de cosa en el problema. Así, se puede trabajar con una variable de esta estructuras para representar cada cosa.
Por eso, la mayoría de las librerías en C están formadas por un
conjunto de estructuras y funciones que actúan sobre las
primeras. Como ejemplo de esta técnica, considere una herramienta
de programación que se comporta como un array, pero cuyo tamaño se
puede fijar en tiempo de ejecución, en el momento de su
creación. La llamaremos CStash
[46]. Aunque está escrito en C++, tiene el estilo clásico
de una librería escrita en C:
//: C04:CLib.h // Header file for a C-like library // An array-like entity created at runtime typedef struct CStashTag { int size; // Size of each space int quantity; // Number of storage spaces int next; // Next empty space // Dynamically allocated array of bytes: unsigned char* storage; } CStash; void initialize(CStash* s, int size); void cleanup(CStash* s); int add(CStash* s, const void* element); void* fetch(CStash* s, int index); int count(CStash* s); void inflate(CStash* s, int increase); ///:~
Listado 4.1. C04/CLib.h
Normalmente se utiliza un «rótulo» como
CStashTag
en aquellas estructuras que necesitan
referenciarse dentro de si mismas. Ese es el caso de una
lista enlazada (cada elemento de la lista
contiene un puntero al siguiente elemento) se necesita un puntero a
la siguiente variable estructura, o sea, una manera de identificar
el tipo de ese puntero dentro del cuerpo de la propia
estructura. En la declaración de las estructuras de una librería
escrita en C también es muy común ver el uso de typedef
como el del ejemplo anterior. Esto permite al programador tratar
las estructuras como un nuevo tipo de dato y así definir nuevas
variables (de esa estructura) del siguiente modo:
CStash A, B, C;
El puntero storage
es un unsigned
char*
. Un unsigned char
es la menor pieza de
datos que permite un compilador C, aunque en algunas máquinas
puede ser de igual tamaño que la mayor. Aunque es dependiente de
la implementación, por lo general un unsigned char
tiene un tamaño de un byte. Dado que CStash
está
diseñado para almacenar cualquier tipo de estructura, el lector se
puede preguntar si no sería más apropiado un puntero void
*
. Sin embargo, el objetivo no es tratar este puntero de
almacenamiento como un bloque de datos de tipo desconocido, sino
como un bloque de bytes contiguos.
El archivo de código fuente para la implementación (del que no se
suele disponer si fuese una librería comercial —normalmente sólo
dispondrá de un .obj
,
.lib
o .dll
, etc) tiene
este aspecto:
//: C04:CLib.cpp {O} // Implementation of example C-like library // Declare structure and functions: #include "CLib.h" #include <iostream> #include <cassert> using namespace std; // Quantity of elements to add // when increasing storage: const int increment = 100; void initialize(CStash* s, int sz) { s->size = sz; s->quantity = 0; s->storage = 0; s->next = 0; } int add(CStash* s, const void* element) { if(s->next >= s->quantity) //Enough space left? inflate(s, increment); // Copy element into storage, // starting at next empty space: int startBytes = s->next * s->size; unsigned char* e = (unsigned char*)element; for(int i = 0; i < s->size; i++) s->storage[startBytes + i] = e[i]; s->next++; return(s->next - 1); // Index number } void* fetch(CStash* s, int index) { // Check index boundaries: assert(0 <= index); if(index >= s->next) return 0; // To indicate the end // Produce pointer to desired element: return &(s->storage[index * s->size]); } int count(CStash* s) { return s->next; // Elements in CStash } void inflate(CStash* s, int increase) { assert(increase > 0); int newQuantity = s->quantity + increase; int newBytes = newQuantity * s->size; int oldBytes = s->quantity * s->size; unsigned char* b = new unsigned char[newBytes]; for(int i = 0; i < oldBytes; i++) b[i] = s->storage[i]; // Copy old to new delete [](s->storage); // Old storage s->storage = b; // Point to new memory s->quantity = newQuantity; } void cleanup(CStash* s) { if(s->storage != 0) { cout << "freeing storage" << endl; delete []s->storage; } } ///:~
Listado 4.2. C04/CLib.cpp
initialize()
realiza las operaciones
iniciales necesarias para la struct CStash
, poniendo
los valores apropiados en las variables internas. Inicialmente, el
puntero storage
tiene un cero dado que aún no
se ha almacenado nada.
La función add()
inserta un elemento en el
siguiente lugar disponible de la CStash
. Para
lograrlo, primero verifica que haya suficiente espacio
disponible. Si no lo hay, expande el espacio de almacenamiento
(storage
) usando la función
inflate()
que se describe después.
Como el compilador no conoce el tipo específico de la variable que
está siendo almacenada (todo lo que obtiene la función es un
void*
), no se puede hacer una asignación simple, que
sería lo más conveniente. En lugar de eso, la variable se copia
byte a byte. La manera más directa de hacerlo es utilizando el
indexado de arrays. Lo habitual es que en
storage
ya haya bytes almacenados, lo cual es
indicado por el valor de next
. Para obtener la
posición de inserción correcta en el array, se multiplica
next
por el tamaño de cada elemento (en bytes)
lo cual produce el valor de startBytes
. Luego
el argumento element
se moldea a unsigned
char*
para que se pueda direccionar y copiar byte a byte en
el espacio disponible de storage
. Se incrementa
next
de modo que indique el siguiente lugar de
almacenamiento disponible y el «índice» en el que ha
almacenado el elemento para que el valor se puede recuperar
utilizando el índice con fetch()
.
fetch()
verifica que el índice tenga un valor
correcto y devuelve la dirección de la variable deseada, que se
calcula en función del argumento index
. Dado
que index
es un desplazamiento desde el
principio en la CStash
, se debe multiplicar por el
tamaño en bytes que ocupa cada elemento para obtener dicho
desplazamiento en bytes. Cuando utilizamos este desplazamiento
como índice del array storage
lo que obtenemos
no es la dirección, sino el byte almacenado. Lo que hacemos
entonces es utilizar el operador dirección-de &
.
count()
puede parecer un poco extraña a los
programadores experimentados en C. Podría parecer demasiado
complicada para una tarea que probablemente sea mucho más fácil de
hacer a mano. Por ejemplo, si tenemos una CStash
llamada intStash
, es mucho más directo
preguntar por la cantidad de elementos utilizando
intStash.next
, que llamar a una función (que
implica sobrecarga), como
count(&intStash)
. Sin embargo, la
cantidad de elementos se calcula en función tanto del puntero
next
como del tamaño en bytes de cada elemento
de la CStash
; por eso la interfaz de la función
count()
permite la flexibilidad necesaria
para no tener que preocuparnos por estas cosas. Pero, ¡ay!, la
mayoría de los programadores no se preocuparán por descubrir lo
que para nosotros es el «mejor» diseño para la
librería. Probablemente lo que harán es mirar dentro de la
estructura y obtener el valor de next
directamente. Peor aún, podrían incluso cambiar el valor de
next
sin nuestro permiso. ¡Si hubiera alguna
forma que permitiera al diseñador de la librería tener un mejor
control sobre este tipo de cosas! (Sí, esto es un presagio).
Nunca se puede saber la cantidad máxima de almacenamiento que se
necesitará para una CStash
, por eso la memoria a la
que apuntan los elementos de storage
se
asigna desde el montículo
(heap)
[47].
El montículo es un gran bloque de memoria que se utiliza para
asignar en pequeños trozos en tiempo de ejecución. Se usa el
heap cuando no se conoce de antemano la cantidad de memoria que
necesitará el programa que está escribiendo. Por ejemplo, eso
ocurre en un programa en el que sólo en el momento de la
ejecución se sabe si se necesia memoria para 200 variables
Avión
o para 20. En C Estándar, las funciones para
asignación dinámica de memoria incluyen
malloc()
, calloc()
,
realloc()
y free()
. En
lugar de llamadas a librerías, C++ cuenta con una técnica más
sofisticada (y por lo tanto más fácil de usar) para tratar la
memoria dinámica. Esta técnica está integrada en el lenguaje por
medio de las palabras reservadas new
y delete
.
La función inflate()
usa new
para
obtener más memoria para la CStash
. En este caso el
espacio de memoria sólo se amplia y nunca se
reduce. assert()
garantiza que no se pase
un número negativo como argumento a
inflate()
como valor de incremento. La
nueva cantidad de elmentos que se podrán almacenar (una vez se
haya terminado inflate()
) se determina en la
variable newQuantity
que se multiplica por
el número de bytes que ocupa cada elemento, para obtener el
nuevo número total de bytes de la asignación en la variable
newBytes
. Dado que se sabe cuántos bytes hay
que copiar desde la ubicación anterior,
oldBytes
se calcula usando la cantidad
antigua de bytes (quantity
).
La petición de memoria ocurre realmente en la
expresión-new que involucra la palabra
reservada new
:
new unsigned char[newBytes];
La forma general de una expresión-new es:
new Tipo;
donde Tipo
describe el tipo de variable para la
cual se solicita memoria en el
montículo. Dado que en este caso, se desea
asignar memoria para un array de unsigned char
de
newBytes
elementos, eso es lo que aparece como
Tipo
. Del mismo modo, se puede asignar memoria para
algo más simple como un int
con la expresión:
new int;
y aunque esto se utiliza muy poco, demuestra que la sintaxis es consistente.
Una expresión-new devuelve un
puntero a un objeto del tipo exacto que se
le pidió. De modo que con new Tipo
se obtendrá un
puntero a un objeto de tipo Tipo
, y con new
int
obtendrá un puntero a un int
. Si quiere
un nuevo array de unsigned char
la expresión
devolverá un puntero al primer elemento de dicho array. El
compilador verificará que se asigne lo que devuelve la
expresión-new a una variable puntero del
tipo adecuado.
Por supuesto, es posible que al pedir memoria, la petición falle, por ejemplo, si no hay más memoria libre en el sistema. Como verá más adelante, C++ cuenta con mecanismos que entran en juego cuando la operación de asignación de memoria no se puede satisfacer.
Una vez que se ha obtenido un nuevo espacio de almacenamiento,
los datos que estaban en el antiguo se deben copiar al
nuevo. Esto se hace, nuevamente, en un bucle, utilizando la
notación de índexado de arrays, copiando un byte en cada
iteración del bucle. Una vez finalizada esta copia, ya no se
necesitan los datos que están en el espacio de almacenamiento
original por lo que se pueden liberar de la memoria para que
otras partes del programa puedan usarlo cuando lo necesiten. La
palabra reservada delete
es el complemento de
new
y se debe utilizar sobre todas aquellas variables a
las cuales se les haya asignado memoria con new
. (Si se
olvida de utilizar delete
esa memoria queda
in-utilizable. Si estas fugas de memoria (memory
leak) son demasiado abundantes, la memoria
disponible se acabará.) Existe una sintaxis especial cuando se
libera un array. Es como si recordara al compilador que ese
puntero no apunta sólo a un objeto, sino a un array de objetos;
se deben poner un par de corchetes delante del puntero que se
quiere liberar:
delete []myArray;
Una vez liberado el antiguo espacio de almacenamiento, se puede
asignar el puntero del nuevo espacio de memoria al puntero
storage
, se actualiza
quantity
y con eso
inflate()
ha terminado su trabajo.
En este punto es bueno notar que el administrador de memoria del
montículo> es bastante primitivo. Nos facilita trozos de memoria
cuando se lo pedimos con new
y los libera cuando
invocamos a delete
. Si un programa asigna y libera
memoria muchas veces, terminaremos con un montículo
fragmentado, es decir un montículo en el
que si bien puede haber memoria libre utilizable, los trozos de
memoria están divididos de tal modo que no exista un trozo que
sea lo suficientemente grande para las necesidades concretas en
un momento dado. Lamentablemente no existe una capacidad
inherente del lenguaje para efectuar defragmentaciones
del montículo. Un defragmentador del montículo
complica las cosas dado que tiene que mover pedazos de memoria,
y por lo tanto, hacer que los punteros dejen de apuntar a
valores válidos. Algunos entornos operativos vienen con este
tipo de facilidades pero obligan al programador a utilizar
manejadores de memoria especiales en lugar de punteros (estos
manipuladores se pueden convertir temporalmente en punteros una
vez bloqueada la memoria para que el defragmentador del
montículo no la modifique). También podemos construir nosotros
mismos uno de estos artilugios, aunque no es una tarea sencilla.
Cuando creamos una variable en la pila en tiempo de compilación,
el mismo compilador es quien se encarga de crearla y liberar la
memoria ocupada por ella automáticamente. Conoce exactamente el
tamaño y la duración de este tipo de variables dada por las
reglas de ámbito. Sin embargo, en el caso de las variables
almacenadas dinámicamente, el compilador no poseerá información
ni del tamaño requerido por las mismas, ni de su duración. Esto
significa que el compilador no puede encargarse de liberar
automáticamente la memoria ocupada por este tipo de variables y
de aquí que el responsable de esta tarea sea el programador (o
sea usted). Para esto se debe utilizar delete
, lo cual
le indica al administrador del montículo que ese espacio de
memoria puede ser utilizado por próximas llamadas a
new
. En nuestra librería de ejemplo, el lugar lógico
para esta tarea es la función cleanup()
dado que allí es dónde se deben realizar todas las labores de
finalización de uso del objeto.
Para probar la librería se crean dos Cstash
, uno
que almacene enteros y otro para cadenas de 80 caracteres:
//: C04:CLibTest.cpp //{L} CLib // Test the C-like library #include "CLib.h" #include <fstream> #include <iostream> #include <string> #include <cassert> using namespace std; int main() { // Define variables at the beginning // of the block, as in C: CStash intStash, stringStash; int i; char* cp; ifstream in; string line; const int bufsize = 80; // Now remember to initialize the variables: initialize(&intStash, sizeof(int)); for(i = 0; i < 100; i++) add(&intStash, &i); for(i = 0; i < count(&intStash); i++) cout << "fetch(&intStash, " << i << ") = " << *(int*)fetch(&intStash, i) << endl; // Holds 80-character strings: initialize(&stringStash, sizeof(char)*bufsize); in.open("CLibTest.cpp"); assert(in); while(getline(in, line)) add(&stringStash, line.c_str()); i = 0; while((cp = (char*)fetch(&stringStash,i++))!=0) cout << "fetch(&stringStash, " << i << ") = " << cp << endl; cleanup(&intStash); cleanup(&stringStash); } ///:~
Listado 4.3. C04/CLibTest.cpp
Dado que debemos respetar la sintaxis de C, todas las variables
se deben declarar al comienzo de
main()
. Obviamente, no nos podemos olvidar
de inicializar todas las variables Cstash
más
adelante en el bloque main(), pero antes de usarlas, llamando a
initialize()
. Uno de los problemas con las
librerías en C es que uno debe asegurarse de convencer al
usuario de la importancia de las funciones de inicialización y
destrucción. ¡Habrá muchos problemas si estas funciones se
omiten! Lamentablemente el usuario no siempre se preguntará si
la inicialización y el limpiado de los objetos son
obligatorios. Ellos le darán importancia a lo que
ellos quieren hacer y no nos darán tanta
importancia a nosotros (el programador de la librería) cuando
les digamos «¡Hey! ¡espera un poco! ¡Debes hacer
esto primero!». Otro problema que
puede presentarse es el hecho de que algunos usuarios quieran
inicializar los elementos (datos internos) de una estructura por
su cuenta. En C no hay un mecanismo para prevenir este tipo de
conductas (más presagios de los temás que vendrán...).
La intStash
se va llenando con enteros
mientras que el stringStash
se va llenando
con arrays de caracteres. Estos arrays de caracteres son
producidos leyendo el archivo fuente
CLibTest.cpp
y almacenando las líneas de
este archivo en el string
line
. Obtenemos la representación
«puntero a carácter» de line
con
el método c_str()
.
Una vez cargados los Stash
ambos se muestran en
pantalla. intStash
se imprime usando un bucle
for
en el cual se usa count()
para
determinar la cantidad de elementos. El
stringStash
se muestra utilizando un bucle
while
dentro del cual se va llamando a
fetch()
. Cuando esta función devuelve cero
se rompe el bucle ya que esto significará que se han sobrepasado
los límites de la estructura.
El lector también pudo haber visto un molde adicional en la línea:
cp = (char*)fetch(&stringStash, i++)
Esto se debe a la comprobación estricta de tipos en C++, que no
permite asignar un void *
a una variable de
cualquier tipo, mientras que C sí lo hubiera permitido.
Antes de abordar los problemas generales de la creación de una
librería C, discutiremos otro asunto importante que se debe
tener claro. Fíjese que el archivo de cabecera
CLib.h
debe incluirse
en cada archivo fuente que haga referencia al tipo
CStash
ya que el compilador no puede adivinar qué
aspecto tiene la estructura. Sin embargo,
sí puede adivinar el aspecto de una
función. Aunque eso pueda parecer una ventaja, veremos que en
realidad, es un grave problema de C.
Aunque siempre debería declarar las funciones incluyendo un
archivo de cabecera, en C las declaraciones de funciones no son
esenciales. En este lenguaje (pero no en C++), es posible llamar
a una función que no ha sido declarada. Un buen compilador
seguramente avisará de que deberíamos declarar la función antes
de usarla, pero nos permitirá seguir dado que no es obligatorio
hacerlo en C estándar. Esta es una práctica peligrosa ya que el
compilador puede asumir que una función que ha sido llamada con
un int
como argumento, tenga un int
como argumento cuando, en realidad, es un
float
. Como veremos, esto puede producir errores
que pueden ser muy difíciles de depurar.
Se dice que cada archivo de implementación C (los archivos de
extensión .c
) es una unidad de traducción
(translation unit). El compilador
se ejecuta independientemente sobre cada unidad de traducción
ocupándose, en ese momento, solamente en ese archivo. Por eso,
la información que le demos al compilador por medio de los
archivos de cabecera es muy importante dado que determina la
forma enq que ese archivo se relaciona con las demás partes del
programa. Por eso motivo, las declaraciones en los archivos de
cabecera son particularmente importantes dado que, en cada lugar
que se incluyen, el compilador sabrá exactamente qué hacer. Por
ejemplo, si en un archivo de cabecera tenemos la declaración
void func(float)
, si llamamos a
func()
con un int
como
argumento, el compilador sabrá que deberá convertir el
int
a float
antes de pasarle el valor
a la función (a esto se le llama promoción
de tipos). Sin la declaración, el compilador asumiría que la
función tiene la forma func(int)
, no
realizaría la promoción y pasaría, por lo tanto, datos
incorrectos a la función.
Para cada unidad de traducción, el compilador crea un archivo
objeto, de extensión .o
,
.obj
o algo por el estilo. Estos archivos
objeto, junto con algo de código de arranque se unens por el
enlazador(linker) para crear el
programa ejecutable. Todas las referencias externas se deben
resolver en la fase de enlazado. En archivos como
CLibTest.cpp
, se declaran funciones como
initialize()
y fetch()
(o sea, se le informa al compilador qué forma tienen estas
funciones), pero no se definen. Están definidas en otro lugar,
en este caso en el archivo CLib.cpp
. De ese
modo, las llamadas que se hacen en
CLibTest.cpp
a estas funciones son
referencias externas. Cuando se unen los archivos objeto para
formar el programa ejecutable, el enlazador debe, para cada
referencia externa no resuelta, encontrar la dirección a la que
hace referencia y reemplazar cada referencia externa con su
dirección correspondiente.
Es importante señalar que en C, estas referencias externas que
el enlazador busca son simples nombres de funciones,
generalmente precedidos por un guión bajo. De esta forma, la
única tarea del enlazador es hacer corresponder el nombre de la
función que se llama, con el cuerpo (definición, código) de la
función del archivo objeto, en el lugar exacto de la llamada a
dicha función. Si, por ejemplo, accidentalmente hacemos una
llamada a una función que el compilador interprete como
func(int)
y existe una definición de
función para func(float)
en algún archivo
objeto, el enlazador verá _func
en un lugar
y _func
en otro, por lo que
pensará que todo está bien. En la llamada a
func()
se pasará un int
en la
pila pero el cuerpo de la función func()
esperará que la pila tenga un float
. Si la función
sólo lee el valor de este dato y no lo escribe, la pila no
sufrirá datos. De hecho, el supuesto float
leído de
la pila puede tener algo de sentido: la función seguirá
funcionando aunque sobre basura, y es por eso que los fallos
originadas por esta clase de errores son muy difíciles de
encontrar.