El abecé de la plantilla vector<>

Última actualización del post: Octubre de 2020

Introducción


Como sabemos, la estructura estándar std::vector<> se considera el contenedor dinámico por defecto en el lenguaje C++. Ésta representa un array alojado en el free store, cuyo tamaño puede aumentar o disminuir a conveniencia durante el tiempo de ejecución de un programa. El hecho fundamental de que todos los elementos almacenados en el vector ocupen entradas contiguas en memoria virtual convierte a esta estructura en especialmente eficiente dado el modo de funcionamiento de los distintos niveles de memoria caché de nuestra computadora (véase este post anterior en relación a la localidad espacial y temporal de un programa), en contraste con el comportamiento típico de, por ejemplo, una lista (ver la figura inferior).


En este artículo discutiremos brevemente el funcionamiento del método principal de esta estructura, de signatura void vector<T>::push_back(T const& val), el cual introduce una copia de su argumento val al fondo del vector. Simplificaremos enormemente nuestra presentación omitiendo detalles finos de la implementación del contenedor, que incluirían el uso de técnicas avanzadas de alojamiento en memoria o la posibilidad de utilizar la semántica de movimiento como método para evitar copias innecesarias de objetos. Nuestra implementación resultará, por tanto, poco eficiente y de utilidad ciertamente limitada, pero de claro valor pedagógico.

Primeros pasos


En la imagen inferior se muestra el estado típico de un vector en el free store en un instante determinado. La estructura tiene reservado un bloque contiguo en memoria donde introducir objetos de tipo T. En la figura inferior, por ejemplo, se dispone de una capacidad máxima de ocho entradas, de las cuales sólo las tres primeras están ocupadas, mientras que las cinco restantes se encuentran disponibles para inserciones adicionales. Una vez agotada la capacidad, el vector deberá reubicarse en otra localización, reservando un mayor espacio en memoria para poder admitir nuevos datos.


El puntero v_ señala la dirección de almacenamiento del vector (inicio de su capacidad), space_ apunta a un hipotético elemento posterior al último dato almacenado y last_ marca el fin de la capacidad actual del contenedor. Puede consultarse [1] para más detalles.

Las primeras funciones miembro de la interfaz pública de nuestro contenedor (que llamaremos Vector para distinguirlo del estándar) podrían tomar, en una primera aproximación, la forma siguiente:

   template<typename T> // T debe ser un tipo default_initializable    class Vector {       T* v_,     // inicio del espacio de capacidad reservada para el vector        * space_, // fin de la secuencia de elementos almacenados        * last_;  // fin de la capacidad reservada para el vector       void bounds_check(std::size_t i) const { if (size() < i) throw std::out_of_range{}; }      public:       Vector() // construye un vector vacío          : v_{new T[0]}, space_{v_}, last_{v_} { }              Vector(Vector<T> const& v)          : v_{new T[v.capacity()]}, space_{v_ + v.size()}, last_{v_ + v.capacity()}       {          try {             for (auto i = size_t{0}; i < v.size(); ++i)                v_[i] = v[i];          }          catch (...) {             delete[] v_;             throw;          }       }       auto& operator=(Vector<T> const& rhs)       {          auto tmp = Vector<T>{rhs};          std::swap(v_, tmp.v_);          std::swap(space_, tmp.space_);          std::swap(last_, tmp.last_);          return *this;       }       ~Vector() { delete[] v_; } // destruye el array apuntado por v_       // funciones de capacidad_________________________________________       auto size()     const -> std::size_t { return space_ - v_; }       auto capacity() const -> std::size_t { return last_ - v_; }       auto empty()    const -> bool        { return v_ == space_; }       // funciones de acceso____________________________________________       // sin bounds checking:       auto operator[](std::size_t i)       -> T&       { return v_[i]; }       auto operator[](std::size_t i) const -> T const& { return v_[i]; }       // con bounds checking:       auto at(std::size_t i)       -> T&       { bounds_check(i); return v_[i]; }       auto at(std::size_t i) const -> T const& { bounds_check(i); return v_[i]; }       auto begin()       -> T*       { return v_; }       auto begin() const -> T const* { return v_; }       auto end()         -> T*       { return space_; }       auto end()   const -> T const* { return space_; }       // resto de la interfaz pública...    };

Notemos que el constructor por defecto de la plantilla crea un vector vacío asignando la misma dirección a los tres punteros v_space_ y last_. El destructor realiza simplemente una operación delete[] sobre el array referenciado por v_, en un claro ejemplo de empleo de la técnica RAII. Sirviéndose de la aritmética de punteros tradicional, la función size() proporciona el número de elementos de tipo T almacenados en el vector. La función capacity() informa de la capacidad total que el vector tiene reservada en el momento de su invocación (es decir, el número total de elementos de tipo T que podría contener sin necesidad de realojarse). Por su parte, empty() retorna una variable booleana igual a true de ser size() nulo, y false en caso contrario.

Función push_back


Con el fin de insertar elementos al fondo del vector, implementaremos la función miembro pública push_back, de signatura void push_back(T const& val). Su modo de funcionamiento es el siguiente: si el vector dispone de al menos una entrada libre donde introducir al nuevo elemento (es decir, si los punteros space_ y last_ no referencian la misma dirección en memoria), realizaremos una copia del argumento val en la posición indicada por space_ e incrementaremos dicho puntero en una unidad. Por contra, si los punteros space_ y last_ coincidiesen, debemos contemplar dos escenarios independientes:
  1. Es la primera vez que realizamos una operación push_back (capacidad inicial nula). En tal caso, reservaremos espacio, no ya para un único elemento, sino para dos (el introducido con la operación push_back recién invocada y uno adicional en previsión de una más que probable inserción posterior).
  2. El vector contiene elementos, pero ha agotado su capacidad no-nula actual. En tal caso, reservaremos un nuevo bloque de memoria en el free store cuya capacidad doble el tamaño actual del vector. Procederemos entonces a copiar el contenido del array original en el nuevo e insertaremos, a continuación, una copia del argumento val. De finalizar este proceso de forma exitosa, destruiremos el array original y reasignaremos convenientemente los punteros v_, space_ y last_ para que referencien las direcciones apropiadas en el nuevo array.
   template<typename T>    void Vector<T>::push_back(T const& val)    {       if (space_ == last_) { // capacidad agotada o primera vez que invocamos push_back          std::size_t cp = capacity(),           // capacidad actual del vector                      new_cp = (cp == 0)? 2 : 2*cp; // nueva capacidad          T* new_block = new T[new_cp]; // nuevo bloque de memoria          try {             for (auto i = std::size_t{}; i < cp; ++i)                new_block[i] = v_[i];           new_block[cp] = val; }          catch (...) { // de lanzarse una excepción...             delete[] new_block; //... destruimos el nuevo array...             throw;              // ...y relanzamos la excepción          }          // destruimos el array original y reasignamos los punteros:          delete[] v_;          v_ = new_block;          space_ = new_block + cp + 1;          last_ = new_block + new_cp;       } else {        *space_ = val;        ++space_; }    }

Observemos que el bucle for que realiza la copia de los elementos del array original en su nuevo destino new_block, así como la inserción de la copia de val, se encuentran encerrados en un bloque try con el fin de que, de lanzarse una excepción por parte de una cualquiera de dichas copias, ésta sea capturada en el bloque catch con elipsis posterior. Se procede en ese caso a destruir el nuevo array new_block y a relanzar la excepción, interrumpiendo en ese punto la ejecución de la función push_back(). Dado que aún no se habría modificado el array original (su contenido aún no se habría destruido ni se habrían reasignado sus vectores v_space_ y last_), la función push_back proporciona claramente una garantía fuerte ante excepciones (véase este post anterior para más detalles).

Ejemplo de uso


El siguiente código sencillo para C++20 demuestra el crecimiento en tiempo de ejecución del vector. El programa solicita al usuario la introducción por la terminal de tantas palabras (strings) como desee. Esta operación puede detenerse con la combinación especial de caracteres EOF (Ctrl+Z-Return en MS Windows, Ctrl+D en Linux). El vector de palabras es entonces ordenado lexicográficamente y mostrado en consola:

   auto words = Vector<std::string>{}; // vector de palabras inicialmente vacío    auto mssg = []{ fmt::print("Insert a word (EOF to finish): "); return true; };    for (auto wrd = std::string{}; mssg() and std::getline(std::cin, wrd); /* no-op */)       words.push_back(wrd);    std::ranges::sort(words);    for (std::string const& wrd : words)       fmt::print("{}, ", wrd);


Referencias bibliográficas:
  1. Stroustrup B., 'The C++ Programming Language', Addison-Wesley, 4th Edition (2013).

9 comentarios:

  1. ¿Qué tipo de excepción podría producirse dentro del bloque try?

    ResponderEliminar
    Respuestas
    1. La copia del elemento v_[i] dentro del nuevo array podría provocar distintos tipos de excepciones que en principio no podemos prever. Ello depende del tipo T al que pertenezcan los elementos contenidos en el vector y de la implementación de su operador de copia. En el peor de los casos, imaginemos que el vector contiene objetos muy pesados en memoria y que, de agotarse el free store, se emitiese una excepción std::bad_alloc.

      Eliminar
  2. ¿No debería esta línea "auto newBlock = new T[cp];" estar dentro del bloque try? new podría producir una excepción del tipo bad_alloc, y en ese caso, ¿quién es el encargado de liberar la memoria ya asignada? Por otra parte, creo que podemos especificar en new std::nothrow y si la asignación de memoria falla, podríamos liberar la memoria ya asignada, pero creo que ya no estaríamos hablando de una garantía fuerte de excepción.

    ResponderEliminar
    Respuestas
    1. En dicha línea, el operador new lanza una excepción std::bad_alloc si se produce un fallo en el alojamiento de memoria. De ser éste el caso, el nuevo bloque de memoria newBlock no llegaría a crearse y podemos dejar que la excepción escape de la función sin más, interrumpiendo la ejecución de push_back(). Observa, asimismo, que el vector original no habría sufrido modificación hasta ese punto, por lo que aún se verificaría la garantía fuerte ante excepciones.

      Eliminar
    2. Pero tendríamos un memory leak de v_ si hemos reservado anteriormente memoria dinámica, si new aquí auto newBlock = new T[cp]; lanza una excepción.

      Eliminar
    3. Puedes encontrar un análisis más detallado del funcionamiento del operador new en el post de este blog de título 'Gestión de memoria (IV)'.

      Eliminar
    4. Observa que el puntero v_ no se reasigna hasta que hayamos alojado con éxito el nuevo bloque de memoria newBlock y copiado en él los datos originales del vector. Es por ello que, de producirse una excepción (ya sea en el propio alojamiento mediante la expresión new o durante la copia de alguno de los elementos del vector), ésta termina siendo lanzada de la función push_back() sin haberse modificado el vector original referido por v_, cumpliéndose la garantía fuerte ante excepciones.

      Eliminar
    5. Entiendo lo que dices, con respecto a la garantía de excepción no tengo nada que decir. Lo que intento decir es que pasaría con la memoria ya asignada de algún push_back previo, por ejemplo:
      Vector v;
      v.push_back(1); // asignamos memoria, en mi caso un double ocupa 8bytes entonces 16bytes (sizeof(double)*2)
      v.push_back(1);
      v.push_back(1); // se agotó la capacidad, tenemos que redimensionar el vector; y ahora por lo que sea ocurre una excepción en esta línea auto newBlock = new T[cp]; entonces ¿quién libera la memoria que ya tengo asignada, es decir, los 16 bytes del primer push_back? es cierto que tenemos una garantía fuerte de excepción, el puntero this no sea modificado, ¿pero y esa memoria?
      Si quieres puedes imaginar que en lugar de un double tenemos un objeto pesado en memoria.
      Espero que con este ejemplo se haya clarificado lo que quería decir. Un saludo

      Eliminar
    6. Hola de nuevo. Si la función push_back() emitiese una excepción como consecuencia de un error en el alojamiento de un nuevo bloque de memoria o al copiar uno de los elementos del contenedor original, la función no tendría efecto (esta es la esencia, precisamente, de la garantía fuerte ante excepciones). Es decir, si el tercer push_back() de tu ejemplo fallase, el vector permanecería sin cambios, manteniendo los dos doubles ya insertados. Es el usuario de la clase el que debe capturar la excepción y decidir las acciones a emprender, disponiendo aún de un vector válido, en idéntico estado al previo a la llamada fallida de push_back() y con el que poder intentar seguir operando. En última instancia, la memoria ya reservada por el vector sería liberada por el destructor de la clase cuando el vector salga fuera del ámbito en que fue definido.

      Eliminar