4: Abstracción de datos

Tabla de contenidos

4.1. Una librería pequeña al estilo C
4.2. ¿Qué tiene de malo?
4.3. El objeto básico
4.4. ¿Qué es un objeto?
4.5. Tipos abstractos de datos
4.6. Detalles del objeto
4.7. Convenciones para los ficheros de cabecera
4.8. Estructuras anidadas
4.9. Resumen
4.10. Ejercicios

C++ es una herramienta de mejora de la productividad. ¿Por qué si no haría el esfuerzo (y es un esfuerzo, por muy fácil que se intente 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 se deben tener en cuenta a la hora de escoger un lenguaje de programación, como la eficiencia (¿la naturaleza del lenguaje hace que los programas sean lentos o demasiado grandes?), la seguridad (¿le ayuda el lenguaje a asegurarse de que sus programas hagan siempre lo que pretende? ¿maneja el lenguaje los errores apropiadamente?) y el mantenimiento (¿el lenguaje ayuda a crear código fácil de entender, modificar y extender?). Éstos 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 fichero con una extensión especial como lib y uno o más ficheros de cabecera que le dicen al compilador qué contiene la librería. El enlazador sabrá cómo buscar en el fichero 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/Linux y Unix, a menudo el único modo sensato de entregar una librería es con código fuente para que así pueda ser reconfigurado y recompilado 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.

4.1. Una librería pequeña al estilo C

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 hay que manejar un conjunto de características, es muy conveniente agruparlas todas juntas en una estructura, especialmente cuando se quiere representar más de una cosa similar en el espacio del problema. Así, se puede crear una variable de esa estructura para 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. Se llamará CStash [49]. 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 sí mismas. Ése 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 variable, 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 fichero 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 vale cero dado que aún no se ha reservado espacio de almacenamiento.

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 se devuelve el «índice» en el que se ha almacenado el elemento para que el valor se pueda recuperar utilizando ese índice con fetch().

fetch() verifica que el índice no esté fuera de rango 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 se utiliza este desplazamiento como índice del array storage lo que se obtiene no es la dirección, sino el byte almacenado en ella. Para obtener la dirección hay que 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 se tiene una CStash llamada intStash, parece 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, si quisiera cambiar la representación interna de CStash, y por tanto la forma de calcular la cantidad de elementos, la interfaz basada en una llamada a función proporciona la flexibilidad necesaria. Pero, ¡ay!, la mayoría de los programadores no se preocuparán por descubrir lo que para usted 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 su 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).

4.1.1. Asignación dinámica de memoria

Nunca se puede saber la cantidad máxima de almacenamiento que se necesitará para una CStash, por eso la memoria a la que apunta storage se asigna desde el montículo (heap) [50]. El montículo es un gran bloque de memoria que se utiliza para asignar trozos más pequeños en tiempo de ejecución. Se usa el montículo 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 necesita 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 (aunque 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 amplía 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 elementos 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. Para saber cuántos bytes hay que copiar desde la ubicación anterior, oldBytes se calcula usando la cantidad antigua de elementos (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 coherente.

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 indexado 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 inutilizable. 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 hubiera que recordarle al compilador que ese puntero no apunta sólo a un objeto, sino a un array de objetos; se debe poner un par de corchetes vacíos 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. Proporciona trozos de memoria cuando se le piden con new y los recupera cuando se liberan con delete. Si un programa asigna y libera memoria muchas veces, se puede terminar 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 existe un trozo que sea lo suficientemente grande para las necesidades concretas en un momento dado. Lamentablemente no existe una capacidad inherente para efectuar defragmentaciones del montículo, que compactan el montículo para obtener trozos libres más grandes. 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 manejadores se pueden convertir temporalmente en punteros una vez bloqueada la memoria para que el defragmentador del montículo no la mueva). También es posible construir un sistema propio de defragmentación, aunque no es una tarea para tomarse a la ligera.

Cuando se crea 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 la librería de ejemplo, el lugar lógico para esta tarea es la función cleanup() dado que allí es donde se deben realizar todas las labores de finalización de uso del objeto.

Para probar la librería se crean dos CStash, una que almacene enteros y otra 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


Siguiendo la forma que exige C, todas las variables se crean al comienzo del ámbito de main(). Obviamente, no hay que olvidarse de inicializar todas las variables CStash más adelante en el bloque de 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 le prestarán tanta atención a usted (el programador de la librería) cuando les diga «¡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 temas que vendrán...).

La intStash se va llenando con enteros mientras que la stringStash se va llenando con arrays de caracteres. Estos arrays de caracteres se obtienen abriendo el fichero fuente CLibTest.cpp y leyendo sus líneas en el string line. Se obtiene 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. La 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.

Observe también el molde adicional de la línea:

cp = (char*)fetch(&stringStash, i++)

Esto se debe a la comprobación estricta de tipos en C++, que no permite asignar sin más un void * a una variable de cualquier otro tipo, mientras que C sí lo hubiera permitido.

4.1.2. Malas suposiciones

Antes de abordar los problemas generales de la creación de una librería C, hay otro asunto importante que se debe tener claro. Fíjese que el fichero de cabecera CLib.h debe incluirse en cada fichero 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, en realidad es un grave escollo de C.

Aunque siempre debería declarar las funciones incluyendo un fichero 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ía declarar la función antes de usarla, pero le permitirá seguir dado que no es obligatorio hacerlo en C estándar. Ésta es una práctica peligrosa ya que el compilador puede asumir que una función que ha sido llamada con un int como argumento tiene un int como argumento cuando, en realidad, puede ser un float. Como verá, esto puede producir errores que pueden ser muy difíciles de depurar.

Se dice que cada fichero de implementación C (los ficheros 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 de esa unidad. Por eso, la información que se le proporcione al compilador por medio de los ficheros de cabecera es muy importante dado que determina lo que el compilador sabe de las demás partes del programa. Por ese motivo, las declaraciones en los ficheros de cabecera son particularmente importantes dado que, en cada lugar que se incluyen, el compilador sabrá exactamente qué hacer. Por ejemplo, si en un fichero de cabecera está la declaración void func(float) y se llama 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 de C 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 fichero objeto, de extensión .o, .obj o algo por el estilo. Estos ficheros objeto, junto con el código de arranque necesario, los reúne el enlazador (linker) para crear el programa ejecutable. Todas las referencias externas se deben resolver en la fase de enlazado. Por ejemplo, en CLibTest.cpp se declaran y se usan 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 fichero CLib.cpp. De ese modo, las llamadas que se hacen en CLibTest.cpp a estas funciones son referencias externas. Cuando se unen los ficheros 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 en el lugar en que se llama con el cuerpo (definición, código) de la función en el fichero objeto. Si, por ejemplo, accidentalmente se hace 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 fichero 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á daños. De hecho, el supuesto float leído de la pila puede tener algo de sentido: la función seguirá funcionando aunque sobre basura, y por eso los fallos originados por esta clase de errores son muy difíciles de encontrar.



[49] (N. del T.) «Stash» se podría traducir como «Acumulador».

[50] (N. del T.) heap se suele traducir al castellano como «montón» o «montículo».