Gestión de memoria (II)

Pila del usuario (user stack)


En un procesador x86-64, el registro %rsp (puntero de pila o stack pointer en Inglés) referencia en todo momento a la cima/cabecera de la pila del usuario. Para alojar (desalojar) memoria en la misma, basta disminuir (incrementar) el valor del puntero.

La llamada a una función introduce, por lo general, un nuevo marco de pila (stack frame en Inglés) en la pila del usuario, disminuyendo el valor del registro %rsp convenientemente. Así, supongamos que una función f() invoca a otra función g() (ver la figura adjunta). En primer lugar, se introduce en el marco de f() la dirección de retorno donde el programa debe continuar su ejecución tras finalizar g(). Se crea entonces un nuevo marco de pila para g(), en el que se almacenan copias de los valores contenidos en los registros no volátiles que vayan a utilizarse, los argumentos y las variables locales que no puedan contenerse directamente en los registros y/o los argumentos para las funciones invocadas posteriormente por g(). Al finalizar la ejecución de g(), el puntero de pila será incrementado hasta su valor original.

El tamaño máximo habitual para la pila en un sistema Linux es de 8 MiB.

Gestión de memoria (I)

Última actualización de la serie: Septiembre 15, 2020

Introducción


En un sistema Linux, todo proceso tiene asociado un espacio de memoria virtual gestionado por el sistema operativo en cooperación con hardware específico de la CPU (concretamente, la unidad de manejo de memoria o MMU en sus siglas inglesas).

La arquitectura x86-64 [1] define un espacio virtual con direcciones de 64 bits, de los cuales las implementaciones actuales habilitan sólo los 48 bits menos significativos para direccionamiento. Esto proporciona un tamaño máximo de 256 TiB (248 bytes) de espacio virtual --a comparar con los 4 GiB (232 bytes) de la arquitectura x86--, de un máximo teórico de 16 EiB (264 bytes). La figura inferior muestra la estructura (enormemente simplificada) del espacio de memoria virtual asignado a un proceso cualquiera en una plataforma Linux de 64 bits, dividido en áreas de propósito bien definido [2]. A saber, y en orden creciente de direcciones en memoria:

Semánticas de copia y movimiento (VI)

Artículos de la serie:

Últimos pasos. Sentencias de control for basadas en rango


En este último post de la serie, mostraremos el código completo de la plantilla Dynarray<> (que debería incluirse en un espacio de nombres conveniente), introduciendo funciones miembro que permitan iterar a través del array, acceder a sus elementos (con y sin verificación de rango), etcétera.La mayor parte del código es autoexplicativo y descansa en las discusiones realizadas en artículos anteriores:

Semánticas de copia y movimiento (V)

Artículos de la serie:

Semántica de movimiento


Consideremos una función read_staff() que reciba como único argumento un número entero de elementos n escogido, por regla general, en tiempo de ejecución. La función inicializará un contenedor Dynarray<Employee> de dicha longitud, siendo Employee una clase cuyos objetos consisten en registros de empleados. A continuación, la función realizará una serie de operaciones sobre el array y, finalmente, lo retornará como resultado. Por ejemplo, la función podría solicitar al usuario que introdujese por la terminal los nombres y los números identificativos de los n empleados a almacenar en el array.

Se plantea aquí el problema no trivial de retornar el array Dynarray<Employee> de manera eficiente fuera de la función. Tengamos en cuenta, en primer lugar, que el número de elementos contenidos en el array (y, por tanto, el tamaño del bloque de memoria) puede ser arbitrariamente grande, siempre que no superemos los límites impuestos por la implementación o los recursos de memoria en el momento de ejecución de la función.

Supongamos, en primer lugar, el empleo del lenguaje C++98/03, con el fin de realizar un análisis histórico de las mejoras proporcionadas por estándares posteriores. En una primera y poco acertada aproximación, y con nuestra implementación actual del contenedor Dynarray<>, podríamos estar tentados a escribir:

   // código ineficiente en C++98 (ignorando optimizaciones del compilador)     Dynarray<Employee> read_staff(std::size_t n)    {       Dynarray<Employee> res(n);       // solicitamos los datos al usuario...       return res; // retorno por valor    }    // ... Dynarray<Employee> data = read_staff(n);

Es importante remarcar que, por el momento, ignoraremos las optimizaciones que el compilador pueda realizar en una situación como ésta, si bien retomaremos este tema más adelante en este mismo post.

Semánticas de copia y movimiento (IV)

Artículos de la serie:

 Técnica copy-and-swap


Con la implementación del contenedor Dynarray<> de que disponemos a partir del estudio realizado en posts anteriores, cabe preguntarse si el constructor copia y el operador de asignación copia generados automáticamente por el compilador son adecuados para nuestra plantilla. A poco que reflexionemos sobre esta cuestión concluiremos que ambas funciones especiales generadas por omisión son de todo punto inadmisibles en nuestro caso, al reducirse ambas a una mera copia byte a byte de la representación del objeto pasado como argumento (es decir, del puntero first y el entero count del objeto a copiar). Este comportamiento resultaría funcionalmente equivalente a:

   template<typename T> inline // constructor copia    Dynarray<T>::Dynarray(Dynarray<T> const& d) noexcept       : first{d.first}, count{d.count} { }    template<typename T> inline // operador de asignación copia    auto Dynarray::operator=(Dynarray<T> const& d) noexcept -> Dynarray<T>&    {       first = d.first;       count = d.count;       return *this;    }

Semánticas de copia y movimiento (III)

Artículos de la serie:

 Primeros constructores para la plantilla Dynarray<>


Tal y como se explicó en el anterior post de esta serie, la plantilla Dynarray<> se hace derivar de la plantilla base detail::Dynarray_base<> con el fin de simplificar el cuerpo de los constructores que definiremos para la clase, así como la gestión de la memoria en el free store:

   template<typename T>    class Dynarray : protected detail::Dynarray_base<T> {       using Base = detail::Dynarray_base<T>;       using Base::first;       using Base::count;    public:       // definiciones de tipos:       using value_type             = T;       using const_reference        = T const&;       using reference              = T&;       using const_iterator         = T const*;       using iterator               = T*;       using const_reverse_iterator = std::reverse_iterator<const_iterator>;       using reverse_iterator       = std::reverse_iterator<iterator>;       using size_type              = std::size_t;       // resto de la interfaz pública...    };

Observemos la introducción de múltiples declaraciones using en la interfaz pública de la clase. Así, por ejemplo, value_type es declarado como alias del tipo de elemento T almacenado en el array, const_reference es sinónimo de una referencia a un objeto constante de tipo T, etcétera. Nuestro objetivo al proceder así es tripe. En primer lugar, proporcionamos un listado coherente de tipos (compatible con la biblioteca estándar del lenguaje) que el programador puede utilizar para declarar variables en su propio código. Dichos tipos sirven, asimismo, de canal de comunicación natural con algoritmos. Por último, mejoramos la sintaxis de la interfaz pública, la cual, como comprobaremos más adelante, se torna más precisa.

Semánticas de copia y movimiento (II)

Artículos de la serie:

Una plantilla base para el contenedor Dynarray<>


Nos proponemos implementar un contenedor Dynarray<> que encapsule arrays de tamaño fijo alojados en el free store:

   template<typename T> // T debe ser un tipo    class Dynarray;

Siguiendo la técnica RAII, un constructor de la clase será el encargado de asignar la memoria dinámica, mientras que el destructor la liberará al concluir la duración de almacenamiento del objeto.

Semánticas de copia y movimiento (I)

Última actualización de la serie: 11 de octubre de 2020

Artículos de la serie:
  1. Introducción
  2. Una plantilla base para el contenedor Dynarray<>
  3. Primeros constructores para la plantilla Dynarray<>
  4. Técnica copy-and-swap
  5. Semántica de movimiento
  6. Últimos pasos. Sentencias de control for basadas en rango
 

Introducción


En nuestra primera serie de posts, destinada a la implementación de una clase genérica Complex<> capaz de representar números complejos, se omitieron deliberadamente los detalles relacionados con la inicialización de objetos a partir de otros ya existentes. A modo de ejemplo, en el código siguiente se crea el número complejo y como copia exacta de x:

   auto x = Complex<double>{1.0, 2.0},  // x = (1,2)         y = x;  // y = (1,2); equivalente a: auto y = Complex<double>{x};

Tampoco se hizo mención a la asignación de objetos. Consideremos el código siguiente, en el que x es ahora un objeto ya existente cuyo valor original es eliminado para almacenar una copia exacta de y:

   auto x = Complex<double>{};          // x = (0,0)    auto y = Complex<double>{1.0, 2.0};  // y = (1,2)    x = y; // x es ahora una copia independiente de y, x = (1,2)

Excepciones, destructores y técnica RAII (VII)


Técnica RAII (segunda parte)

La técnica RAII no se circunscribe únicamente a la asignación dinámica de memoria, sino que afecta a cualquier tipo de recurso, sea éste un fichero, un mutex (programación concurrente), un socket (networking), etcétera.

Este tratamiento homogéneo de recursos en C++ contrasta con el de otros lenguajes de programación orientados a objetos, en los que debe distinguirse claramente entre los mecanismos de recolección de basura (ligados a la gestión de la memoria dinámica) y los bloques try-catch-finally (a utilizar con otros tipos de recursos). Cabe decir que, frente al esquema tradicional de adquisición y liberación de recursos en bloques try-catch-finally, Java 7 y superiores ofrecen la construcción alternativa try-with-resources [1]. Análogamente, C# 8.0 y superiores proporcionan los denominados using statements [2]. Por su parte, Python 2.5 y superiores poseen los with statements [3]. En todos los casos, sin embargo, se trata de soluciones parciales que no poseen el alcance y generalidad de la técnica RAII.

Excepciones, destructores y técnica RAII (VI)


Técnica RAII (primera parte)


Consideremos la función siguiente:

   void array_test(std::size_t n)    {       auto const p = new double[n]{};                   // (A)       // usamos el array, lo que puede dar lugar al lanzamiento de excepciones...              delete[] p;                                       // (B)    }

En ella, se asigna dinámicamente memoria para una matriz unidimensional de escalares tipo double mediante una expresión de tipo new. Una vez completado un conjunto de operaciones sobre la matriz, el bloque de memoria es liberado mediante una expresión delete. Este código no es seguro ante excepciones. En efecto, observemos que de lanzarse una excepción entre la asignación de memoria en (A) y su posterior liberación en (B), el proceso de desenredo de la pila no liberaría la memoria apuntada por el puntero, pues la operación delete[] p no llegaría a ejecutarse. Con el fin de evitar esta posible fuga de memoria, analizaremos dos opciones, siendo la segunda la solución idiomática en C++:

Excepciones, destructores y técnica RAII (V)


Garantías ante excepciones


Al implementar nuestros propios algoritmos y estructuras de datos en C++, debemos analizar cuidadosamente el tipo de garantía que ofrecemos a los usuarios ante una eventual emisión de excepciones por parte de una operación dada. De acuerdo con la clasificación [1] realizada por David Abrahams, uno de los desarrolladores principales de la familia de bibliotecas Boost [2], pueden distinguirse tres tipos fundamentales de garantías, a modo de acuerdo contractual entre las componentes y el usuario:
  1. Garantía básica: se respetan los invariantes básicos de todos los objetos y se evita la fuga de recursos (memoria, ficheros, etcétera). Ello permite el uso de los objetos (particularmente su destrucción) con posterioridad a la emisión de la excepción.
  2. Garantía fuerte: la operación concluye satisfactoriamente o emite una excepción, en cuyo caso el estado del programa retorna al inmediatamente anterior a la ejecución de la operación.
  3. Garantía nothrow: la operación no emite excepciones bajo ninguna circunstancia.

Excepciones, destructores y técnica RAII (IV)


Todas las excepciones emitidas por la biblioteca estándar del lenguaje C++ pertenecen a la siguiente jerarquía de clases, contenida en el espacio de nombres std y actualizada según C++20 [1]:

exception                       <exception>
     bad_alloc                  <new>
         bad_array_new_length   <new>
         bad_cast                   <typeinfo>
             bad_any_cast           <any>
     bad_exception              <exception>
     bad_function_call          <functional>
     bad_optional_access         <optional>
     bad_typeid                 <typeinfo>
     bad_variant_access         <variant>
     bad_weak_ptr               <memory>
     logic_error                <stdexcept>
         domain_error           <stdexcept>
         invalid_argument       <stdexcept>
         future_error           <future>
         length_error           <stdexcept>
         out_of_range           <stdexcept>
     runtime_error              <stdexcept>
         ambiguous_local_time   <chrono>
         format_error           <format>
         nonexistent_local_time <chrono>
         overflow_error         <stdexcept>   
         range_error            <stdexcept>
         regex_error            <regex>
         system_error           <system_error>
                 ios_base::failure  <ios>
                 filesystem::filesystem_error <filesystem> 
         underflow_error        <stdexcept>

Excepciones, destructores y técnica RAII (III)


Desenredo de la pila


La emisión de excepciones es el mecanismo natural de información en C++ de los errores recuperables acontecidos durante la ejecución de un programa. Una función que, por la razón que fuera, sea incapaz de completar satisfactoriamente su tarea puede informar del error al agente invocador emitiendo una excepción (típicamente, un objeto de una clase definida a tal efecto) a través de una expresión de tipo throw. El código que, explícita o implícitamente, realice la llamada a dicha función y deba hacerse cargo de los posibles errores, deberá encerrar el punto de invocación en un bloque try y capturar y manejar las excepciones emitidas a través de una o varias cláusulas catch. El esquema básico de actuación es, pues, el siguiente:

   struct Error { }; // clase de excepción    void g()    {       // ...       if (/* tiene lugar una situación de error */)          throw Error{};       // ...    }    void f()    {       try {          g();       }       catch (Error& e) {          // manejamos cualquier excepción de tipo Error emitida por g()       }    }

Excepciones, destructores y técnica RAII (II)

Duración de almacenamiento

La siguiente tabla indica, a modo de recordatorio, el tiempo de vida de una variable en virtud de su tipo de duración de almacenamiento:

Duración de almacenamiento Tipo de variable afectada Finalización del tiempo de vida
Estática (i) Declarada en un ámbito namespace (incluido el espacio de nombres global) y/o (ii) declarada static o extern. Al terminar la ejecución del programa.
Automática Local, excepto si se declara static, extern o thread_local. Cuando el flujo del programa abandona el ámbito donde la variable fue creada. En ese momento, los objetos con almacenamiento automático (variables locales) creados en dicho ámbito son destruidos en orden inverso a su construcción.
Dinámica Alojada dinámicamente durante la ejecución del programa mediante una expresión de tipo new. Al alcanzarse una expresión de tipo delete.
Thread-local Declarada thead_local. Al finalizar la ejecución del hilo donde la variable fue creada.

Excepciones, destructores y técnica RAII (I)

Última actualización: 12 de septiembre de 2020

Artículos de la serie:

Introducción: Destructores

Cuando se crea un objeto de una clase, el constructor invocado debe establecer sus invariantes --es decir, el conjunto de propiedades que deben respetarse independientemente del estado del objeto--, posiblemente adquiriendo recursos tales como memoria dinámica, archivos, locks (programación concurrente), sockets (networking), etcétera. Una de las características definitorias del lenguaje C++ descansa en el hecho de que, al finalizar el tiempo de vida de dicho objeto, éste llama implícitamente y de forma automática al destructor de su clase [1], una función miembro especial cuyas características describiremos más adelante.

Introducción a la programación genérica (V)

Artículos de la serie:

Literales definidos por el usuario

 
Siendo el conjunto de números reales R isomorfo al subconjunto {(x, 0) : x∈ R}⊂ C, e introduciendo la denominada unidad imaginaria i = (0, 1)∈ C, se tiene que todo número complejo z = (x,y)∈ C admite ser expresado en forma binómica como:

z = (x, y) = (x, 0) + (0, y) = (x, 0) + (0, 1)•(y, 0) = x + iy.

Esta notación, especialmente conveniente al operar con números complejos, es fácilmente implementable en el lenguaje C++. Así, podemos introducir un operador literal [1] que transforme un literal numérico de tipo double seguido del sufijo _i en un objeto de tipo Complex<double> puramente imaginario (es decir, con parte real nula): 

Introducción a la programación genérica (IV)

Artículos de la serie:

Sobrecarga de operadores


Llegados a este punto, desearíamos habilitar las operaciones aritméticas básicas de los números complejos (suma, diferencia, producto de números complejos y producto por escalares reales). En primer lugar, sobrecargaremos los operadores binarios de asignación compuesta +=, -= y *= en la interfaz pública de la clase. Estos operadores permitirán calcular la suma, diferencia o multiplicación de un objeto dado (considerado de manera implícita como operando izquierdo) con otro número real o complejo (operando derecho), almacenándose el resultado en el operando izquierdo. Así, por ejemplo, la expresión x+=y resultará ser equivalente a x=x+y. Emplearemos el operador operator* para denotar tanto el producto por números complejos como el producto por escalares reales:

Introducción a la programación genérica (III)

Artículos de la serie:

Plantillas de clase


Como aspecto esencial de nuestro diseño, conviene valorar más detenidamente el tipo elemental a utilizar para almacenar en memoria las partes real e imaginaria de un número complejo. Ciertamente, debe utilizarse una representación de coma flotante como en el código expuesto en el segundo post. Tengamos en cuenta, sin embargo, que el usuario de la biblioteca puede desear escoger entre los distintos tipos disponibles (float, double, long double) en función de la precisión requerida por sus cálculos y la plataforma en que esté trabajando. Por ello, definiremos una clase genérica (plantilla de clase, class template en Inglés [1]) como modo de proporcionar, no una única clase Complex en la que el tipo elemental de representación subyacente (hasta ahora, double) quede establecido de antemano por el diseñador de la clase, sino toda una familia uniparamétrica de clases en la que el modo de representación pueda ser escogido por el usuario de entre los tres tipos mencionados:

Introducción a la programación genérica (II)

Artículos de la serie:

Clases, atributos y funciones miembro


El objetivo de este post es el de resumir brevemente algunos aspectos básicos del diseño de clases en C++. Como sabemos, el concepto central de la programación orientada a objetos es el de la clase, que actúa como unidad básica de encapsulamiento. Podemos pensar en ella como un prototipo que modela los atributos y las operaciones que pueden realizarse sobre cualquier instancia (objeto) de la misma [1, 2]. Según el problema planteado en el primer post de la serie, deseamos introducir una nueva clase Complex tal que cada uno de sus objetos represente un número complejo potencialmente distinto:

   class Complex {    private:       double re_, // parte real              im_; // parte imaginaria    public:       // constructor no-explícito:       constexpr Complex(double re = 0.0double im = 0.0noexcept          : re_{re}, im_{im} // lista de inicializadores       {           // cuerpo vacío del constructor   }       // resto de la interfaz pública (por definir)    };

Introducción a la programación genérica (I)

Última actualización: 12 de septiembre de 2020

Artículos de la serie:
  1. Un ejemplo sencillo: los números complejos
  2. Clases, atributos y funciones miembro
  3. Plantillas de clase
  4. Sobrecarga de operadores
  5. Literales definidos por el usuario

Un ejemplo sencillo: los números complejos


Planteémonos la tarea de diseñar una biblioteca matemática en C++ que permita operar con números complejos haciendo uso de técnicas propias de la programación orientada a objetos y la programación genérica. Por supuesto, este problema tiene un interés puramente didáctico, pues el lenguaje cuenta con su propia biblioteca de números complejos a través de la cabecera estándar <complex>.

A modo de recordatorio: 
El cuerpo de los números complejos es el conjunto de parejas de números reales C = {(x, y)R2} sobre el que se definen las operaciones de suma + y producto • siguientes:
                    Suma + : (x1, y1) + (x2, y2) = (x1 + x2, y1 + y2),
             Producto • : (x1, y1) • (x2, y2) = (x1x2 - y1y2, x1y2 + x2y1),
cumpliéndose las propiedades usuales de conmutatividad, asociatividad y existencia de elementos neutro e inverso para ambas operaciones. A modo de ejemplo, dados dos números complejos z1 = (2,3) y z2 = (1,4), su suma y su producto se calculan como z1 + z2 = (2+1, 3+4) = (3, 7) y z1 • z2 = (2(1) - 3(4), 2(4) + 3(1)) = (-10, 11), respectivamente.
Dado un número real λ∈ R, se define el producto externo λ*(x, y) = (λx, λy), para todo (x, y)∈ C. Se verifica entonces que la terna (C,+,*) posee la estructura de un espacio vectorial de dimensión dos.
Dado un número complejo z = (x, y)∈ C, los números reales Re(z) = x e Im(z) = y reciben el nombre de parte real e imaginaria de z, respectivamente.
Nuestro objetivo en esta serie será el de introducir un nuevo tipo no-primitivo (es decir, definido por el programador) que refleje fielmente el comportamiento de los números complejos y permita operar con ellos de manera natural y eficiente. En particular, como veremos más adelante, la sobrecarga de operadores en C++ nos permitirá realizar operaciones aritméticas tales como la suma, diferencia y producto de números complejos manteniendo la sintaxis usual empleada con tipos numéricos primitivos como los enteros int.


Puedes acceder al siguiente artículo de la serie a través de este enlace.