Algoritmos (II)

Tipos de iteradores


Los iteradores son fundamentales en la implementación de algoritmos genéricos de tratamientos de datos, pues permiten el acceso secuencial a los elementos almacenados en una colección sin necesidad de exponer la estructura interna de la misma.

En las implementaciones habituales de los vectores (matrices unidimensionales de datos almacenadas en el free store, cuyo tamaño puede variar durante el tiempo de ejecución), los iteradores suelen definirse como simples alias de punteros tradicionales que apuntan al tipo de objeto contenido (para más información, puedes consultar Stroustrup B., "The C++ Programming Language". Addison-Wesley, 4th Edition, 2013):

Algoritmos (I)

Algoritmos, contenedores e iteradores. Insertion Sort


Última actualización de la serie: Diciembre de 2019

Consideremos el problema de implementar un algoritmo de ordenación simple, por ejemplo el bien conocido método de inserción, de manera que éste pueda emplearse tanto con matrices unidimensinales (arrays con elementos adyacentes en memoria) como con listas doblemente enlazadas.

Sea S un conjunto cualquiera a ordenar de acuerdo con un determinado predicado binario C, que deberá establecer un orden débil estricto sobre los operandos que compare. El orden debe cumplir, por tanto, las siguientes propiedades:
  • Irreflexiva: not C(a,a).
  • Antisimétrica: C(a,b) implica not C(b,a).
  • Transitiva: C(a,b) y C(b,c) implica C(a,c).
Consideremos un caso genérico en el que el conjunto posea al menos dos elementos. El método de inserción deberá ordenar, en su primera iteración, los dos primeros elementos del conjunto S. Es decir, si C(S[0],S[1]) se evalúa como falso, ambos elementos deberán intercambiarse; en caso contrario, permanecerán en sus posiciones originales. A continuación, insertaremos el tercer elemento S[2] del conjunto (si existe) en la posición correcta dentro del subconjunto ya ordenado {S[0],S[1]}. Los tres primeros elementos del conjunto S quedan, así, ordenados adecuadamente. A continuación, se insertará el cuarto elemento S[3] (si existe) en la posición correcta con respecto al subconjunto ya ordenado {S[0],S[1],S[2]}, y así sucesivamente hasta que el conjunto S quede ordenado en su totalidad.

Puedes consultar un estudio detallado de la complejidad algorítmica de este método en Cormen T. H., et al., "Introduction to Algorithms" (3rd Edition, MIT Press). En particular, el algoritmo es O(n) en el caso más favorable (lista ya ordenada) y O(n2) en los casos medio y menos favorable (conjunto de partida en orden inverso al deseado), siendo n el número de elementos a ordenar.

Ciertamente, el interés por la implementación del método de inserción descansa en su relativa facilidad de codificación, recomendándose el uso de algoritmos de ordenación más eficientes en código real, tales como quick-sort o merge-sort.

GCC 5.1

El pasado 22 de abril fue publicada la nueva versión 5.1 de la colección de compiladores GCC para los lenguajes C, C++ y Fortran, entre otros. Este lanzamiento supone un hito en la trayectoria de GCC, al presentar un compilador 100% conforme con el nuevo estándar del lenguaje C++2014 a pocos meses de su publicación oficial. Entre las muchas funcionalidades implementadas, cabría destacar muy especialmente:
  • Funciones lambda genéricas.
  • Expresiones constantes de mayor complejidad.
  • Biblioteca estándar conforme con los estándares C++11 y C++14. La funcionalidad ofrecida para expresiones regulares a través del fichero de cabecera <regex> es, sin embargo, aún incompleta, por lo que recomiendo el uso de la biblioteca del mismo nombre ofrecida por Boost (visítese www.boost.org para mayor información).
  • Implementación experimental de futuras adiciones al lenguaje (en la forma de especificaciones técnicas), tales como las plantillas de clase std::experimental::any<> y std::experimental::not_fn<>, entre otras novedades.
Es de esperar la pronta disponibilidad del compilador en el sistema MS Windows, por ejemplo a través de la distibución proporcionada por el proyecto mingw-w64 (visítese mingw-w64.yaxm.org para mayor información).

Punteros inteligentes (V): Propiedad compartida (segunda parte)


std::weak_ptr


La plantilla de clase std::weak_ptr<>, el tercer tipo de puntero inteligente que analizaremos en esta serie de posts, se encuentra intrínsecamente ligada al uso de la propiedad compartida, permitiendo interrumpir referencias circulares de objetos std::shared_ptr<>.

A modo de ejemplo, consideremos el patrón de diseño de nombre observador (observer pattern en Inglés), propio de la programación orientada a objetos. Se desea implementar un notificador al que pueda suscribirse un número arbitrario de observadores. El notificador deberá informar de forma automática a todos los observadores registrados de cualquier cambio en su estado mediante el envío de un mensaje, sin que esto afecte sustancialmente a la implementación de sus clases.

Se recomienda al lector interesado en profundizar en éste y otros patrones de diseño que consulte la obra clásica "Design Patterns", de E. Gamma et al., referencia obligada en este campo.

Gestión de memoria (VII)

Registros de la CPU


Como se ha mencionado en posts anteriores de esta serie de artículos dedicados a la gestión eficiente de la memoria, los registros de la CPU son espacios de memoria de acceso rápido integrados en el microprocesador, típicamente del tamaño de una palabra (64 bits en la arquitectura x86-64). En ellos pueden almacenarse copias tanto de instrucciones como de datos contenidos en la memoria física de la computadora.

Gestión de memoria (VI)

La importancia de la localidad


La mayoría de nuestros algoritmos y aplicaciones satisfacen una o ambas de las siguientes propiedades [1]:
  • Localidad espacial: Una vez se referencia una determinada dirección en memoria, es altamente probable que en breve se referencie una dirección contigua o próxima a ella.
  • Localidad temporal: Una vez se referencia una determinada dirección en memoria, es altamente probable que ésta sea referenciada nuevamente en un futuro próximo.
En base a ello, una computadora puede hacer uso de una jerarquía de memorias caché con el fin de reducir el tiempo de acceso a los datos residentes en memoria y/o para almacenar instrucciones de uso frecuente. 

Punteros inteligentes (IV): Propiedad compartida (primera parte)


Propiedad compartida. Bloques de control


La plantilla de clase uniparamétrica std::shared_ptr<> implementa una semántica de propiedad compartida: uno o más objetos std::shared_ptr<> pueden ser propietarios no-exclusivos de un mismo recurso (normalmente, memoria asignada dinámicamente mediante expresiones new). Dicho recurso es liberado una vez que todos los objetos std::shared_ptr<> que lo referencien sean destruidos, reasignados o renuncien a su propiedad, siendo su último propietario el encargado de liberarlo (la política de liberación por defecto es una expresión delete, aunque ésta puede ser personalizada por el usuario). Este comportamiento se consigue a través de un contador de referencias interno, lo que convierte a std::shared_ptr<> en un mecanismo básico de recolección de basura cuando el recurso compartido sea memoria [1].

Punteros inteligentes (III): Propiedad exclusiva (segunda parte)

Políticas de liberación


Ha llegado el momento de analizar cómo configurar la política de liberación a seguir con el objeto apuntado por std::unique_ptr al finalizar el tiempo de vida del puntero inteligente. A modo de ejemplo, empleemos std::unique_ptr para garantizar que un flujo a fichero std::FILE sea cerrado automáticamente al término de un ámbito:

   using File_ptr = std::unique_ptr<std::FILE,                                     decltype([](std::FILE* f){ if (f) std::fclose(f); })>;    {       auto ifile = File_ptr{std::fopen("text.txt", "r")};                 // usamos el fichero en modo lectura...              } // el destructor de File_ptr cierra el fichero automáticamente en este punto

Hemos hecho uso aquí del estándar C++20, que permite la aparición de expresiones lambda en contextos no-evaluados (unevaluated contexts) como decltype. En nuestro código, el objeto std::unique_ptr se hace cargo del puntero std::FILE* retornado por la función std::fopen e inicializa un deleter a partir de la expresión lambda. Dicho deleter es el encargado de cerrar el fichero una vez que el puntero inteligente finalice su tiempo de vida. Observemos, en particular, que en ningún punto se ha efectuado alojamiento dinámico de memoria.

Punteros inteligentes (II): Propiedad exclusiva (primera parte)

Puntero inteligente std::unique_ptr


La plantilla de clase std::unique_ptr<> implementa una semántica de propiedad exclusiva del objeto apuntado. Concretamente, un objeto u de tipo std::unique_ptr<T> almacena un puntero miembro a un segundo objeto de tipo T o derivado, sobre el que se sigue una determinada política de destrucción (configurable por el usuario) cuando u es destruido. La semántica de propiedad exclusiva implica que el puntero inteligente u no pueda copiarse, pues de poder hacerlo acabaríamos con dos punteros propietarios del mismo objeto. Existe, sin embargo, un mecanismo de transferencia de la propiedad que analizaremos en este post.

Punteros inteligentes (I): Introducción

Última actualización de esta serie: 1 de agosto de 2020

Artículos de la serie:
  1. Introducción.
  2. Propiedad exclusiva (primera parte).
  3. Propiedad exclusiva (segunda parte).
  4. Propiedad compartida (primer parte).
  5. Propiedad compartida (segunda parte).

Introducción


Una de las principales lecciones aprendidas tras décadas de uso de C++ es la de que los punteros tradicionales no-encapsulados no deberían emplearse como agentes propietarios de los objetos a los que apuntan. 

En efecto, consideremos el uso no-encapsulado de una expresión new para alojar un objeto en el free store. Ello exige al programador el uso posterior de delete para destruir el objeto y liberar su memoria, así como la introducción de engorrosos bloques de limpieza try-cacth para evitar fugas de memoria ante la posible emisión de excepciones. Por ejemplo:

Gestión de memoria (V)

Fragmentación del free store


En la mayoría de los compiladores del lenguaje C++, el alojamiento dinámico en el espacio de almacenamiento libre (mediante expresiones newdelete) suele venir implementado en torno a las funciones malloc() y free() propias del lenguaje C. Por cada nueva petición de almacenamiento, el sistema debe realizar una búsqueda efectiva de un bloque en memoria sin utilizar de un tamaño igual o superior al solicitado. De no existir suficiente espacio en memoria, se notifica el error emitiendo por defecto una excepción de tipo std::bad_alloc. Existen múltiples algoritmos de alojamiento posibles, cada uno de los cuales posee sus ventajas y sus inconvenientes en relación a su eficiencia en la búsqueda y uso de la memoria [1,2].

Gestión de memoria (IV)

Almacenamiento libre (free store)


El lenguaje C++ permite la asignación dinámica de memoria en el sector de almacenamiento libre (free store) mediante expresiones de tipo new. En contraste con el alojamiento de variables locales en la pila del usuario, el tiempo de vida de los objetos alojados dinámicamente no se encuentra limitado al ámbito en que fueron creados, de forma que la memoria debe ser reclamada explícitamente a través de una expresión delete o, muy raramente, mediante un recolector de basura [1].

Ante una expresión

   X* p = new X{/* argumentos */};

el código generado por el compilador resulta ser básicamente equivalente a:

Gestión de memoria (III)

Alineación (alignment)


El proceso de alineación de datos en memoria implica el alojamiento de variables de un tipo determinado en direcciones de memoria que sean múltiplos enteros de un cierto valor L (normalmente 1, 2, 4, 8, ó 16) [1]. Concretamente, sea D la dirección en memoria de una variable cualquiera. La alineación L de dicha variable (medida en bytes) se define como la mayor potencia de 2 tal que

D mod L = 0.

Con carácter general, el compilador GCC impone la siguiente tabla de alineaciones para tipos primitivos y punteros en un sistema Linux de 64 bits:

Tamaño (bytes) Alineación (bytes)
char 1 1
short 2 2
int 4 4
long int 4 4
long long int 8 8
float 4 4
double 8 8
long double 16* 16
Puntero a cualquier tipo 8 8
* En el caso de long double, sólo 10 bytes son realmente utilizados para la representación numérica (80 bits de precisión); los 6 bytes restantes son añadidos para garantizar una alineación que sea múltiplo entero del tamaño de una palabra [2]. 

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.