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. Conveciones 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, 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.

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 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).

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 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.

4.1.2. Malas suposiciones

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, 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.



[46] N de T:«Stash» se podría traducir como «Acumulador».

[47] N. de T.: heap se suele traducir al castellano como «montón» o «montículo».