5.1.5 Asignadores de memoria
§1 Antecedentes
Para comprender las características y utilidad de los asignadores ("Allocators"), hay que recordar lo indicado
en la introducción de este tutorial (
0.I), donde afirmábamos que existen versiones de C++ para prácticamente todas
las plataformas hardware y Sistema Operativo conocidos. Es significativo que las diferentes combinaciones de máquinas y SOs
utilizan distintos modelos de memoria, a pesar de lo cual, C++ utiliza un modelo único cuyos detalles de implementación están
encapsulados en el compilador, de forma que el usuario no tiene que preocuparse de ellos. Por ejemplo, la diferencia entre un
puntero señalando al elemento enésimo de una matriz y el que señala al primer elemento es siempre un entero n,
con independencia del modelo de memoria de la máquina utilizada.
En C++ existen dos mecanismos relacionados con la gestión de memoria: el que se ocupa del manejo de los objetos automáticos
( 4.1.5) y el que se
ocupa de la memoria dinámica. El primero, que maneja la pila, está inextricablemente unido con el propio lenguaje
(recordemos que C y C++ son lenguajes orientados a la pila) y ha sido descrito en el apartado correspondiente
(
1.3.2). El segundo,
que entiende de la asignación ("allocation") de memoria a los objetos persistentes, y su desasignación o liberación
("free") cuando ya no son necesarios, es en cierta forma un "añadido" al propio lenguaje, y de hecho,
su interfaz para el programador (como lo utiliza), ha variado a lo largo del tiempo (se incluyó una descripción al respecto al
tratar del operador new
4.9.20).
Cuando se diseñó la STL, y debido precisamente a los distintos modelos de memoria a los que debería adaptarse, los miembros del Comité ya tenían en mente la previsible dificultad para implementar la enorme cantidad de código que constituía la nueva librería [1]. Con el fin de facilitar su implementación en los distintos compiladores (uno de los objetivos preferentes del Comité de Estandarización C++ es facilitar la portabilidad del lenguaje a las diversas plataformas), los puntos en que se hacía referencia al manejo de memoria se redujeron al mínimo, concentrándolos en una clase que se encargaría de asignar y desasignar memoria para todos los demás algoritmos que lo precisaran. El resultado es que los objetos de esta clase (allocators) permiten desvinculan la STL de los aspectos relativos al modelo de memoria utilizado por cada máquina concreta.
§2 Asignadores
Habida cuenta que la misión principal de los contenedores es el almacenamiento de objetos, resulta natural que entre sus
funciones más importantes, se encuentre la gestión del espacio necesario para albergarlos. Esta gestión no se limita a asignar y
liberar el espacio necesario para sus miembros. En ocasiones también debe gestionar espacio para la estructura de índices que
mantiene el orden de la secuencia. Por ejemplo, cuando añadimos miembros a un contenedor tipo vector
( 5.1.1c1), este se redimensiona automáticamente conforme se van
añadiendo nuevos elementos. Análogamente, cuando añadimos nuevos elementos a un map
(
5.1.1e4) este acomodo afecta a los
nuevos elementos y a las claves asociadas que constituyen su sistema de índice.
Como se ha dicho, todas las entidades de la STL que precisan de memoria dinámica lo hacen a través de los "servicios"
de una clase "asignadora" que es utilizada como argumento (recuerde que las entidades de la STL son plantillas
que aceptan distintos parámetros). Debido a que además de portable, la STL es extensible, existe libertad para que el usuario
implemente su propio asignador de memoria (ver nota
); aunque la STL proporciona un gestor por defecto, la
clase allocator, que implementa la funcionalidad requerida mediante la utilización de los operadores estándar new y
delete [2]. En caso de no indicarse otro explícitamente, se utiliza este asignador por defecto, que
suele ser suficiente en la mayoría de los casos. El resultado es que el usuario puede despreocuparse de la cuestión, y que los
asignadores son ráramente utilizados de forma explícita. Por ejemplo, la definición del contenedor vector es del siguiente
tenor:
namespace std {
template <class T, class Allocator = allocator<T> > class vector {
public:
...
};
}
Desde la perspectiva del usuario de un contenedor estándar, el manejo de memoria es realizado automáticamente mediante el
parámetro de moldeo. Por ejemplo, para definir una lista de enteros myList utilizando el asignador por defecto, puede
utilizarse el parámetro <int> y escribir:
#include <memory>
...
list <int> myList;
En la declaración del contenedor también puede proporcionarse un asignador específico mediante un segundo parámetro de
moldeo. Por ejemplo, para utilizar en la lista anterior un asignador propio denominado fastAllocator, que suponemos está
pensado para tipos int, y definido en el fichero de cabecera <fastAllocator.h>, utilizaríamos la definición
siguiente:
#include <memory>
#include <fastAllocator.h>
...
typedef list <int, fastAllocator> myList;
También podría utilizarse el asignador por defecto de forma explícita:
#include <memory>
...
allocator<int> miAllocInt;
list <int, miAllocInt> myList;
Como puede verse, cuando se instancia una especialización concreta de un contenedor genérico, debe especificarse el tipo de
miembro que alojará en el contenedor y el asignador de memoria que utilizara. Posteriormente, cuando se instancie un objeto
del tipo myList, se especifica el objeto-asignador que utilizará el contenedor:
fastAllocator asignador1;
myList lista1(asignador1);
Nota: el autor del lenguaje [3] nos informa que un
"allocator" es una abstracción utilizada para aislar los detalles del manejo de memoria dinámica de los algoritmos y
contenedores que deben utilizarla, y que cualquier cosa que se comporte como un allocator es un allocator. Definir un asignador
propio es un proceso relativamente simple ( xxx).
Debemos observar que en el diseño actual de la STL, los asignadores son clases genéricas (plantillas), y que una instancia
de dicha plantilla asigna memoria para objetos de un tipo específico T. En consecuencia, cuando proporcionamos un allocator
específico a un contenedor, su tipo debe coincidir con el de los miembros del contenedor.
A este respecto señalemos que el Estándar establece que condiciones debe cumplir una clase M para que pueda ser considerada un allocator y utilizada por las entidades de la STL que lo necesiten. En concreto establece que debe tener ciertas propiedades cuyos nombres y significados están determinados, así como ciertos métodos cuyos nombres y comportamiento están igualmente especificados. Entre ellos podríamos destacar los siguientes:
Expresión | Valor devuelto | Comentario |
M::pointer | Puntero a tipo T. | |
M::size_type | Entero sin signo | Un tipo que puede representar el tamaño del mayor objeto en el modelo de memoria utilizado. |
M::difference_type |
Entero con signo | Tipo que puede representar la diferencia entre dos punteros cualesquiera en el modelo de memoria utilizado. |
a.allocate(n) a.allocate(n,u) |
M::pointer | Asigna memoria para n objetos de tipo T (sin que los objetos sean construidos). En caso de fallo debe lanzar una excepción. |
a.deallocate(p,n) | Desasigna la memoria para n objetos a partir de la dirección señalada por el puntero p. La memoria debe haber sido asignada previamente con allocate(), y los objetos contenidos en ella haber sido destruidos previamente. | |
a1 == a2 | bool | La clase M debe terner definido el método operator== de forma que devuelva true si el almacenamiento asignado desde uno puede ser desasignado desde el otro. |
a1 != a2 | bool | Equivalente a: !(a1 == a2) |
Las expresiones utilizan los significados siguientes: T un tipo cualquiera M una clase-asignador para el tipo T n un valor de tipo M::size_type a, a1, a2 objetos de tipo M&. |
En el cuadro de condiciones anterior se observa que los asignadores no crean los objetos. Se
limitan a asignar memoria al más puro estilo de las funciones de librería clásica calloc() y malloc(). Lo que
realmente caracteriza a esta clase son los métodos allocate() y deallocate() que, en
cierta forma, representan la contrapartida de las funciones malloc() y free() de la librería clásica.
La STL incluye también algunos algoritmos para manipular la memoria no inicializada.
El funcionamiento del sistema se basa en que cada vez que es creado un contenedor que necesita manejo de memoria, recibe un asignador. De esta forma, cada vez que el contenedor necesita asignar o rehusar memoria no necesita conocer ningún detalle sobre el modelo de memoria de la máquina, ya que utiliza el asignador para estos menesteres.
§3 El manejador por defecto
Las definiciones de los asignadores de la STL están en el espacio de nombres std y se encuentran agrupadas en la cabecera <memory>. La STL proporciona un manejador por defecto denominado allocator. Esta clase es en realidad una plantilla que puede instanciarse para manejar cualquier tipo. Responde a la siguiente interfaz:
template <class T> class allocator {
public:
typedef size_t size_type;
typedef ptrdiff_t difference_type;
typedef T* pointer;
typedef const T* const_pointer;
typedef T& reference;
typedef const T& const_reference;
typedef T value_type;
template <class U> struct rebind { typedef allocator<U> other; };
allocator() throw();
allocator(const allocator&) throw();
template <class U> allocator(const allocator<U>&) throw();
~allocator() throw();
pointer address(reference x) const;
const_pointer address(const_reference x) const;
pointer allocate(
size_type, allocator<void>::const_pointer hint = 0);
void deallocate(pointer p, size_type n);
size_type max_size() const throw();
void construct(pointer p, const T& val);
void destroy(pointer p);
};
Por supuesto que la clase satisface las premisas indicadas por el Estándar para ser un allocator (apuntadas en el epígrafe
anterior ) por lo que es un
asignador estándar. También se proporciona una instanciación para el tipo void que responde a la siguiente interfaz:
template <> class allocator<void> {
public:
typedef void* pointer;
typedef const void* const_pointer;
// reference-to-void members are impossible.
typedef void value_type;
template <class U> struct rebind { typedef allocator<U> other; };
};
Ejemplos
vector<double> V(100, 5.0); // Usa el asignador por defecto
vector<double, single_client_alloc> local(V.begin(), V.end());
[1] En realidad, lo que hizo el Comité fue adoptar con algunos retoques la librería diseñada por Alexander Stepanov y Meng Lee en los laboratorios de Hewlett-Packard e incluirla como parte de la Librería Estándar C++.
[2] Observe que el Estándar declara que las funciones operator new y operator new[ ] son funciones de asignación ("Allocation functions"), que no deben ser confundidas con un asignador ("Allocator").