Entendiendo los objetos en C++

En C++, todo lo que los programas hacen es manipular objetos: los crean y destruyen, los leen, escriben y operan. Necesitamos comprender y dominar cómo los objetos se representan en memoria y cómo se manejan sus propiedades. Veamos qué es un objeto en C++, qué características tiene, cómo se gestiona su memoria y cómo funciona.

Objetos en C++

  • Definición: un objeto es una región de memoria con una semántica asociada.
  • Región de memoria: es un término genérico, pero se refiere a un espacio de memoria accesible por la CPU, es decur,  RAM, Registros o incluso ROM. Como la unidad mínima es el byte, este espacio tendrá un número entero de bytes.
  • Semántica asociada: define qué significan los posibles valores almacenados en el objeto, y qué operaciones están lógicamente permitidas sobre ese objeto (y, por tanto, qué otras cosas no lo están). Es la forma de explicar qué significa cualquier valor concreto de ese objeto y, también, de definir el conjunto de valores permitidos (llamados invariantes de un objeto).

Los programas crean, destruyen, se refieren a, y manipulan objetos.

Nota: para referirse a un objeto en memoria, sin importar cuanto ocupa, se indica siempre la dirección de su primer byte. Como sabemos su tamaño, sabiendo su primer byte, podemos leerlo o modificarlo sin equivocarnos.

Un objeto en C++ tiene:

  • Tamaño (size): número de bytes que ocupa. Se puede saber usando el operador sizeof.
    • Ejemplo:
          int vidas = 25;
      std::print("Tamaño de 'vidas' = {}\n", sizeof(vidas));
  • Alineamiento requerido (alignment): múltiplo de byte en memoria donde puede estar el inicio del objeto. Podemos saberlo con el operador alignof.
  • Duración de almacenamiento (storage duration): es una condición que determina cuánto tiempo será válido el objeto.
    • Automático (automatic): se crea en un ámbito (scope) y se destruye al terminar ese ámbito. Solemos llamarlo "local",
    • Estático (static): se crea antes de empezar main() y se destruye tras terminar main(). Tiene la misma duración que el programa.
    • Dinámico (dynamic): lo creamos y destruimos manualmente a conveniencia (con new/malloc y  delete/free ).
    • Local a hilo (thread-local): que pertence exclusivamente a un hilo de ejecución, y su creación y destrucción dependerá de la vida del hilo.
  • Tiempo de vida (lifetime): intervalo de tiempo desde que un objeto se crea hasta que se destruye. Tiene dos posibles condiciones:
    • Está limitado por el tipo de duración de almacenamiento (storage duration) del objeto.
    • Puede ser temporal: en objetos que sólo existen durante la evaluación de la expresión en que están involucrados.
      • Ejemplo:
         // 25 es un objeto temporal. Deja de existir tras ejecutar esta línea
         int vidas = 25;
         
         // Se llama a random(1,10) y eso devuelve un int temporal.
         // Se suma 100 + el int temporal y eso produce otro int temporal.
         // El objeto health se inicializa con el último int temporal.
         // Todos los temporales dejan de existir tras esta línea.
         float health = 100 + random(1, 10); 
  • Tipo (type): clase dentro de los posibles tipos de objeto. Determina los invariantes y el conjunto de operaciones válidas.
  • Valor (value): el valor que tiene el objeto en un instante, que puede ser indeterminado, por ejemplo, en la inicialización por defecto (default-initialized).
    • Nota: indeterminado quiere decir que tiene un valor, pero que no podemos saber cuál es. Si se usa una posición de memoria para el objeto, pero no se escribe en ella un valor inicial (inicializar), tendrá un valor previo que no sabemos cuál es. Es indeterminado.
  • Nombre (name) --Opcional--: los objetos pueden tener un nombre y, entonces, pueden ser variables.

Variable: un objeto o referencia que se introduce con una (declaration) y que no es ni un dato miembro, ni un miembro no-estatico.

Nota: un dato miembro es un campo de una estructura o clase, que no es estático. Los datos estáticos, aunque se declaren dentro de una clase, son únicos y globales como cualquier estático. Se crean antes de que empiece el programa y existen hasta su terminación. Por eso son únicos y no dependen de la creación de objetos de estructura o clase. Son, a todos los efectos, datos estáticos globales a los que limitamos su visibilidad.

Ejemplos

Considera el siguiente código:

10 | int main () {
11 |    int vidas = 25; // Un objeto tipo int que ocupa 4 bytes
...|    //...
56 | }

En este ejemplo, vidas es un objeto en C++ con:

  • Nombre: vidas (al tener nombre, es una variable)
  • Tamaño: 4 bytes
  • Valor: 25
  • Tipo: int (indica sus semánticas asociadas: se representará en binario como un número aritmético estándar, y admitirá operaciones aritméticas como suma, resta, multiplicación, etc.) 
  • Alineamiento: 4 bytes (sólo puede estar en memoria en celdillas múltiplo de 4 bytes)
  • Storage duration: Automático (se crea en el stack y se libera al terminar su scope/ámbito)
  • Tiempo de vida: Líneas 11 a 56 (se crea al ejecutar la 11, se libera al ejecutar la 56)

Considera ahora este otro código:

04 | // Definición de estructura Entity que ocupará 32 bytes
05 | struct Entity { 
06 |    int x, y, vx, vy;    // 4 ints = 16 bytes
07 |    double attrs[2];     // 2 doubles = 16 bytes
08 | };
.. |
40 | int main () {
41 |    static Entity* ep = new Entity();
.. |    //...
56 | }

En este ejemplo se crean 2 objetos en la línea 41:

  • La variable ep
  • Un objeto de tipo Entity sin nombre

El objeto sin nombre se crea en el free-store (Heap) y sólo podemos acceder a él a través de su dirección de memoria. Para eso usamos la variable ep, para almacenar el valor del puntero a Entity que nos devuelve la expresión new y que nos dice dónde está el nuevo objeto sin nombre.

Así pues, hemos creado 2 objetos:

Propiedad ep Objeto Sin-Nombre (OSN)
Tamaño 8 bytes (en CPUs de 64 bits) 32 bytes
Alineamiento 8 bytes (en CPUs de 64 bits) 8 bytes (campo más grande, double)
Storage duration Estático (global) Dinámico (gestión manual)
Tiempo de Vida Desde la línea 41 hasta el final del programa Desde la línea 41 hasta que se llame a delete
Tipo Entity* Entity
Nombre ep No tiene nombre
Valor Dirección de memoria del OSN Indeterminado (Entity no tiene constructor ni valores por defecto)

Importante: cosas que NO son objetos

Estas "entidades" no son objetos:

  • Un valor (value)
  • Una referencia (reference)
  • Una función (function)
  • Un enumerador (enumerator)
  • Un tipo (type)
  • Un miembro de clase no-estático (non-static class member)
  • Una plantilla (template)
  • Una clase (class)
  • Una especialización de plantilla de función (function template specialization)
  • Un espacio de nombres (namespace)
  • Un pack de parámetros (parameter pack)
  • La expresión this (this)

Creación y Destrucción de Objetos en C++

Las palabras crear y destruir en C++ suelen dar problemas. Es importante que las conozcamos con precisión.

  • Crear: decidir un lugar exacto de memoria donde estará el objeto e inicializarlo (escribir su valor inicial). A partir de ese momento, se dice que el objeto ha sido creado y existe.
  • Destruir: dejar de usar la memoria del objeto, quedando libre para otros usos.

Esto es lo básico, pero hay matices y detalles. Vamos a explorarlos con precisión.

Creación de Objetos

La creación de un objeto implica reservar una región de memoria (elegir un sitio, decidir dónde va) e inicializar esa región con un valor específico. A la inicialización la llamamos construcción, y es la labor del constructor. Los objetos que no tienen un constructor por defecto, ni se les asigna un valor inicial, quedan sin inicializar, por lo que su valor es indeterminado.

Existen diversas formas de crear objetos dependiendo de la duración de almacenamiento:

  1. Automático: Los objetos se crean automáticamente cuando se entra en su ámbito (scope). Se elige un espacio en la pila (stack, que está en la RAM). 

  2. Estático: Los objetos con almacenamiento estático se crean antes de la ejecución del main() y se destruyen después de terminarlo. La región de memoria que usan suele elegirse (reservarse) al cargar el programa en memoria.

  3. Dinámico: Los objetos se crean cuando se ejecuta new o malloc. Se elige un sitio para ellos en el free-store (reserva en el Heap). Este espacio permanece disponible hasta que ejecuta delete o free, indicando que queda libre para otros usos.

  4. Local a hilo: Se crean cuando un hilo comienza y existen solo mientras el hilo esté activo.

Destrucción de Objetos

La destrucción de un objeto implica dejar de usar su región de memoria, por lo que quedará libre para otros usos. Algunos objetos complejos son responsables de la gestión de otros objetos o recursos, por lo que necesitan ejecutar código antes de destruirse, para liberar los recursos que gestionan. Esta lógica es la que va en su destructor. A estos objetos se les llama no-trivialmente-destruibles.

Según la duración de sus almacenamientos:

  1. Automático: se destruyen automáticamente cuando se sale del ámbito (scope) donde fueron creados. Es al final del bloque donde el objeto está definido.

  2. Estático: se destruyen después de finalizar main()

  3. Dinámico: deben ser liberados explícitamente usando delete o free. De lo contrario, se producirá una fuga de memoria (memory leak), pues sus regiones de memoria permanecen en uso.

  4. Local a hilo: se destruyen cuando el hilo termina.


Referencias

  1. Object en CPPReference
  2. sizeof en CPPReference.
  3. alignof en CPPReference
  4. storage duration en CPPReference
  5. lifetime en CPPReference
  6. type en CPPReference
  7. default-initialized en CPPReference
  8. declaration en CPPReference
  9. this en CPPReference