Saltar a contenido

Tipos de datos en C++

Sistemas numéricos

Uno de los datos esenciales que se manejan en programación son los números.

Los números al igual que todos los demás datos se almacenan en la memoria en forma de sucesiones binarias de 0 y 1 llamadas bits.

Cada conjunto de 8 bits se denomina byte.

La fórmula para determinar cuantos dígitos se pueden representar con n bits es 0 ~ 2^n – 1. Por ejemplo:

  • 1 bit 0~2^1-1 = 0~1 (2)
  • 2 bits 0~2^2-1 = 0~3 (4)
  • 3 bits 0~2^3-1 = 0~7 (8)
  • 4 bits 0~2^4-1 = 0~15 (16)
  • 8 bits (1 byte) 0~2^8-1 = 0~255 (256)
  • 16 bits (2 bytes) 0~2^16-1 = 0~65.534 (65.535)
  • 32 bits (4 bytes) 0~2^32-1 = 0~4.294.967.295 (4.294.967.296)
  • 64 bits (8 bytes) 0~2^64-1 = 0~18.446.744.073.709.551.615 (18.446.744.073.709.551.616)

Todo esto sería utilizando la conversión binario-decimal de números conocida como base 10 (decimal), pero los números se pueden representar en diferentes bases, por ejemplo base 8 (octal), base 16 (hexadecimal)…

Esta lista muestra la conversión de los números de 0 a 15 en binario, decimal y hexadecimal:

0000    00    0
0001    01    1
0010    02    2
0011    03    3
0100    04    4
0101    05    5
0110    06    6
0111    07    7
1000    08    8
1001    09    9
1010    10    A
1011    11    B
1100    12    C
1101    13    D
1110    14    E
1111    15    F

En C++ para definir un número entero en base 10 podemos hacerlo en diferentes sistemas:

#include <iostream>

int main()
{
    int num1 = 15;          // Base decimal
    int num2 = 017;         // Base octal
    int num3 = 0x0F;        // Base hexadecimal
    int num4 = 0b00001111;  // Base binaria

    std::cout << "Numero decimal: " << num1 << std::endl;
    std::cout << "Numero octal a decimal: " << num2 << std::endl;
    std::cout << "Numero hexadecimal a decimal: " << num3 << std::endl;
    std::cout << "Numero binario a decimal: " << num4 << std::endl;

    return 0;
}

En conclusión:

  • En la memoria los datos se almacenan en grupos de bits con 0 y 1.
  • Una sucesión agrupada de 8 bits en la memoria se denomina byte.
  • Cuanto más grande es el rango de datos a representar más bits necesitamos.
  • El sistema hexadecimal es una forma intuitiva de representar conjuntos de 16 bits.

Números enteros y variables

Un número entero es un tipo de dato para representar números del sistema decimal. En C++ la palabra para definir un entero es int y ocupan generalmente 4 bytes en la memoria. Podemos consultar el tamaño de un entero en bytes mediante la función sizeof:

std::cout << sizeof(int);

Un número, y los demás tipos de datos, se pueden almacenar en la memoria del programa para manipularlos. Para ello se utilizan las variables, un espacio en la memoria que reservamos mediante un nombre y el tipo de dato que deseamos almacenar en ella:

int numero;  // Inicialización de una variable entera

Una vez tenemos el espacio reservado podemos asignarle el valor:

int numero;  // Inicialización de una variable entera
numero = 100;  // Asignación de un número a la variable

Podemos realizar la inicialización y asignación en una única instrucción:

int numero = 100;  // Inicialización y asignación

Esto se puede realizar sin la igualdad mediante paréntesis o llaves:

int numero (100);  // Inicialización con constructor C++17
int numero {100};  // Inicialización uniforme

Por defecto un entero sin asignar tiene un valor de 0:

Ya que hablamos de asignaciones, podemos asignar múltiples variables en una única línea:

int num1{100}, num2{200};

Y también podemos asignar a una variable una operación a partir de otros datos, siempre y cuando el resultado sea del mismo tipo que asignamos, ya sea literalmente o mediante variables:

int res{num1 + num2 + 300};
std::cout << res;

Si intentamos asignar un valor mayor de 4 bytes o un número fraccionario (mediante una coma), dará error:

Sin embargo sí es posible realizar una conversión de tipo fraccionario a entero con redondeo de medio decimal implícito mediante el operador =:

Todavía hay mucho que hablar sobre los números, seguiremos próximamente.

Modificadores de enteros

En el lenguaje C++ encontramos una serie de funcionalidades conocidas como modificadores que permiten cambiar el funcionamiento de un tipo de dato.

Por ejemplo, un modificador básico de los números enteros es la negación , que nos permite cambiar el signo positivo de un número a un signo negativo:

int num {-99};

¿Cómo puede ser esto? Pues porque los enteros int reciben un modificador implícito llamado signed que les indica que pueden ser positivos o negativos:

signed int num {-99};  // Por defecto tienen signo

Esto implica que también pueden no tener signo, lo cuál se indica mediante el modificador unsigned, y que en esta ocasión al intentar almacenar en él un negativo dará error:

unsigned int num {-99};  // Entero sin signo negativo dará error

¿Qué diferencia hay entre un entero con signo y uno sin signo?

Ya sabéis que un entero int ocupa en memoria 4 bytes, eso son (4*8) 32 bits y por tanto podemos representar cualquier número en el rango (0~32^2-1) **de **[0, 4.294.967.295].

Pues bien, esto sería en el caso en que un número entero sea unsigned, porque si fuera signed en realidad tenemos que dividir el rango en dos partes, los números con signo negativo y los números con signo positivo. Por tanto tendríamos 2.147.483.648 negativos y la misma cantidad de positivos, contando el cero como un positivo y por tanto teniendo el rango de [-2.147.483.648, 2.147.483.647].

En C++ existen dos constantes (una especie de variables no modificables por el usuario), que almacenan el número entero mínimo y máximo con signo:

std::cout << INT_MIN << ", " << INT_MAX;

También existen dos tipos que pueden utilizarse como modificadores llamados short y long para optimizar el uso de la memoria dependiendo del rango de números que necesitemos almacenar.

En la web https://es.cppreference.com/w/cpp/language/types podemos consultar la siguiente tabla donde se muestran todas las combinaciones de short, int, long con y sin signo:

Números fraccionarios

Dejando de lado los números enteros, también podemos trabajar con números fraccionarios, cuyos tipos se conocen como tipos de punto flotante y encontramos esencialmente tres dependiendo de la precisión decimal que necesitemos:

  • float: 4 bytes y 7 dígitos de precisión, se indican con una f al final.
  • double: 8 bytes y 15 dígitos de precisión, son el tipo fraccionario por defecto.
  • long double: 16 bytes y precisión mayor que un double, se indican con una L al final.
std::cout << sizeof(float) << std::endl;  // 4 bytes
std::cout << sizeof(double) << std::endl;  // 8 bytes
std::cout << sizeof(long double) << std::endl;  // 16 bytes

La precisión es el número de dígitos decimales que se pueden representar detrás del punto:

float num1{1.0011223344556677889f};
double num2{1.0011223344556677889};
long double num3{1.0011223344556677889L};

std::cout << num1 << std::endl;  // 1.00112
std::cout << num2 << std::endl;  // 1.00112
std::cout << num3 << std::endl;  // 1.00112

La función std::cout limita por defecto la precisión a 6 dígitos, podemos cambiarla importando la biblioteca <iomanip> y usando la función std::setprecision antes de mostrar el valor.

Lo curioso del ejemplo es que los tipos float y long double, a partir de los 7 y 15 dígitos de precisión, rellenan la salida hasta los 20 dígitos con números aparentemente aleatorios:

std::cout << std::setprecision(20);
std::cout << num1 << std::endl;  // 1.0011223554611206055
std::cout << num2 << std::endl;  // 1.0011223344556678949
std::cout << num3 << std::endl;  // 1.0011223344556677889

En realidad no son aleatorios, pero para entender la razón por la que sucede esto habría que remitirnos al estándar IEEE 754 donde se explica el funcionamiento y gestión de los números en coma flotante, algo demasiado complejo como para explicarlo aquí.

Cabe mencionar que a parte de la notación fija, los números fraccionarios también permiten la notación científica con exponentes, tanto positivos como negativos:

double num1{123e6};  // 123 * 10^6 => 123000000
double num2{123e-6}; // 123 * 10^-6 => 0.000123

Un par de curiosidades para terminar.

Ya vimos que dividir un número entero entre cero provoca un error que finaliza la ejecución:

std::cout << 100 / 0 << std::endl;  // Error

Sin embargo, si en lugar de cero usamos un fraccionario 0.0, nos devolverá algo llamado inf (infinito):

std::cout << 100 / 0.0 << std::endl;  // inf

Según mis conocimientos una división entre cero es indeterminada y no infinita, pero al parecer esto responde al hecho de poder determinar el signo, pues si el dividendo es negativo lo que se devuelve es -inf:

std::cout << -100 / 0.0 << std::endl;  // -inf

Por último, si dividimos 0 entre 0.0, el sistema devolverá nan (not a number), entendible ahora sí como un resultado indeterminado:

std::cout << 0 / 0.0 << std::endl;  // nan

Para más información echad un vistazo a https://es.wikipedia.org/wiki/IEEE_754.

Valores booleanos

Otro tipo de dato esencial en la programación es el que sirve para representar un valor lógico o racional con únicamente dos opciones: verdadero y falso.

En C++ las palabras reservadas para estos valores son true y false respectivamente:

std::cout << true << std::endl;  // Devuelve 1 al imprimirlo
std::cout << false << std::endl; // Devuelve 0 al imprimirlo

Su tipo de dato recibe el nombre de bool:

bool encendido{false};
std::cout << encendido << std::endl;

Utilizando el operador de negación ! podemos negar un booleano, siendo no verdadero igual a falso y no falso igual a verdero:

bool encendido{false};
std::cout << !encendido << std::endl;

Este tipo ocupa 1 byte en la memoria:

std::cout << sizeof(bool) << std::endl;

Como ya sabemos 1 byte son 8 bits (2^8=256 posibilidades) mientras que para almacenar las dos posibilidades de un booleano sería suficiente con 1 bit (0 y 1), esto implica que este tipo no sea precisamente eficiente respecto al espacio que ocupa.

Por último comentar dos opciones que nos permiten configurar la representación en la salida:

std::cout << std::boolalpha;   // Se imprimirán como true y false
std::cout << std::noboolalpha;   // Se imprimirán como 1 y 0

Caracteres y texto

Otro tipo de dato esencial es el que suponen los símbolos de escritura, llamados caracteres.

En C++ el tipo de dato para almacenar un carácter recibe el nombre de char y su tamaño en la memoria es de 1 byte, esto implica que puede almacenar hasta 256 posibilidades, cada una para un símbolo:

std::cout << sizeof(char) << std::endl;

Los caracteres que se pueden representar no son adrede, sino que se encuentran establecidos en el código ASCII https://en.cppreference.com/w/cpp/language/ascii:

Cada símbolo tiene un número entero asociado, a través del cuál podemos consultar el respectivo carácter:

char valor{65}; // Código ASCII del carácter 'A'
std::cout << valor << std::endl;

Aunque más allá de trabajar con el número a nosotros nos interesará trabajar con los propios caracteres, definidos entre comilla simple:

char valor{'A'};
std::cout << valor << std::endl;

Si nos fijamos en la tabla veremos que existen los caracteres en mayúscula y minúscula, eso es obvio dado que son símbolos distintos. Además también están representados los números naturales como caracteres de escritura, que no debemos de confundir con los tipos de datos numéricos.

Por cierto, una curiosidad es que, si os fijáis los símbolos numéricos tienen un valor decimal menor que las mayúsculas y a su vez estas menor que las minúsculas. Eso explica porqué podemos comparar si un carácter es mayor que otro en función de su número en la tabla:

std::cout << ('a' > 'A') << std::endl;  // Devuelve un 1 (true)

Como 'a' tiene un valor de 97 y 'A' 65, al ser 97 mayor que 65 el sistema devuelve 1 en la comparación, haciendo referencia a que ésta es verdadera.

ASCII es uno de los primeros sistemas de codificación para representar texto en las computadoras, el problema es que solo está pensado para representar texto en idioma inglés, por lo que símbolos específicos latinos y otras lenguas no funcionarán y resultarán en un mal funcionamiento del sistema de codificación:

char valor{'á'}; // Error

En la actualidad un sistema de codificación más globalizado es el Unicode que incluye diferentes formas de codificación como UTF-8, UTF-16 y UTF-32.

Si bien en C++ podemos trabajar con Unicode, esto es un tanto complejo, ya que estas tablas son muy extensas y además hay que configurar la salida de datos para decodificar los símbolos y todo esto también depende del sistema operativo.

Por curiosidad, algo como almacenar y mostrar un carácter 'Ñ' (cuyo código según la tabla Unicode es 00D1) en Windows 11 requeriría lo siguiente:

#include <clocale>
#include <iostream>

int main()
{
  setlocale(LC_ALL, "");     // Cambiar la localización del programa
  wchar_t valor = L'\u00D1'; // Tipo wide char con soporte Unicode
  std::wcout << valor;       // Salida wide char con soporte Unicode
  return 0;
}

Cuyo resultado sería el siguiente:

¿Y cómo se almacena no solo un carácter sino varios para representar un texto? Pues los textos, conocidos como cadenas de caracteres, son tradicionalmente arreglos (sucesiones) de múltiples caracteres, un tema que exploraré más adelante.

Tipo automático

Siempre que definimos una variable debemos indicar el tipo de dato para almacenar el espacio necesario en la memoria, sin embargo desde C++11 podemos dejar al propio lenguaje deducir el tipo de dato a partir del valor literal asignado durante la definición.

auto var1 {100};    // int
auto var2 {3.14};   // double
auto var3 {5.4f};   // float
auto var4 {21.5l};  // long double
auto var5 {'a'};    // char

std::cout << var1 << "\t" << sizeof(var1) << " bytes" << std::endl;
std::cout << var2 << "\t" << sizeof(var2) << " bytes" << std::endl;
std::cout << var3 << "\t" << sizeof(var3) << " bytes" << std::endl;
std::cout << var4 << "\t" << sizeof(var4) << " bytes" << std::endl;
std::cout << var5 << "\t" << sizeof(var5) << " bytes" << std::endl;

También podemos indicar mediante sufijos si queremos números enteros con o sin signo:

auto var6 { 256u};   // unsigned int
auto var7 { 256ul};  // unsigned long
auto var8 { 256ll};  // unsigned long long

std::cout << var6 << "\t" << sizeof(var6) << " bytes" << std::endl;
std::cout << var7 << "\t" << sizeof(var7) << " bytes" << std::endl;
std::cout << var8 << "\t" << sizeof(var8) << " bytes" << std::endl;

Este sería el resultado:

Cabe mencionar que si dejamos que el sistema defina el tipo y le asignamos un sufijo unsigned, si sobrescribimos el valor con un negativo, resultará en un funcionamiento no espero del dato:

auto numero{10u};
numero = -21;  // ¡Cuidado!
std::cout << numero << std::endl;

También si establecemos un tipo como un carácter y luego lo sobrescribimos por algo como un flotante:

auto valor{'A'};
valor = 3.14f; // ¡Cuidado!
std::cout << valor << std::endl;

En este caso se devolvería lo siguiente:

En conclusión este tipo es muy cómodo, pero debemos tener cuidado con sus valores de sobreescritura.

Ámbito de las variables

El ámbito de una variable es el espacio donde se encuentra accesible dentro del programa. Básicamente hay dos tipos de ámbito: local y global.

Cuando se define una variable, ésta existe localmente dentro de su bloque {}:

#include <iostream>

int main()
{
    {
        int numero{10};                   // esta variable local
        std::cout << numero << std::endl; // existe en este bloque
    }

    std::cout << numero << std::endl; // pero aquí no existe

    return 0;
}

La variable definida también será accesible por todos los bloques dentro de este bloque, diremos que su ámbito abarca el bloque de su definición y todos sus bloques anidados:

#include <iostream>

int main()
{

    int numero{10}; // esta variable de la función main
    {
        std::cout << numero << std::endl; // existe en este bloque
    }
    std::cout << numero << std::endl; // también existe en este

    return 0;
}

Dado que la función main es un bloque, si creamos otra función, las variables de cada bloque no serán accesibles entre ellas, de hecho podrían tener el mismo nombre pues existen paralelamente en la memoria:

#include <iostream>

void hola()
{
    int numero{999};                  // variable numero en la funcion hola
    std::cout << numero << std::endl; // numero    99
}

int main()
{
    int numero{10};                   // variable numero en la funcion main
    hola();                           // ejecutamos la función
    std::cout << numero << std::endl; // numero    10
    return 0;
}

Sin embargo, si definimos una variable fuera de ambas funciones su ámbito se considerará global y podrá ser manipulable por ambas:

#include <iostream>

int numero{345}; // variable global

void hola()
{
    std::cout << numero << std::endl; // numero    345
}

int main()
{
    hola();
    std::cout << numero << std::endl; // numero    345
    return 0;
}

Pese a todo, si una variable local tiene el mismo nombre que una variable global, tendrá prioridad dentro del bloque y la global dejará de ser accesible:

#include <iostream>

int numero{345}; // variable global

void hola()
{
    int numero{999};
    std::cout << numero << std::endl; // numero    999
}

int main()
{
    hola();
    std::cout << numero << std::endl; // numero    345
    return 0;
}

Tipos de conversiones

Las conversiones implícitas son aquellas hechas por el compilador sin la intervención del propio programador. Se realizan al tipo mayor que interviene en la expresión, por ejemplo la operación entre un int y un float se almacenará en un float y entre un double y un float se almacenará en un double:

auto num1 = 12 * 5.0f;
std::cout << sizeof(num1) << std::endl; // float

auto num2 = 12.0 * 5.0f;
std::cout << sizeof(num2) << std::endl; // double

También es posible forzar una conversión implícita mediante el tipo de dato. Por ejemplo, una suma entre dos double en un int, almacenará el resultado truncando la parte fraccionaria:

double num1 = 5.25, num2 = 10.5;
int res = num1 + num2;
std::cout << res << std::endl; // 15.75 -> 15

Por otra parte tenemos las conversiones explícitas indicadas por el programador. Tradicionalmente en C se indica el tipo resultante del dato justo delante:

double num = 15.75;
std::cout << (int)num << std::endl; // 15.75 -> 15

Este tipo de conversiones son inseguras y en C++ se recomienda utilizar la función static_cast haciendo uso de templates:

double num = 15.75;
std::cout << static_cast<int>(num) << std::endl; // 15.75 -> 15

La ventaja de usar static_cast es que si la conversión no es posible se lanzará un error que podría capturarse para actuar en consencuencia.

Derivado de estas conversiones pueden ocurrir dos situaciones conocidas como overflow y underflow.

Overflows y underflows

Cuando intentamos guardar en una variable un dato con un tamaño mayor del que ésta permite ocurre un overflow o desbordamiento superior. Por el contrario, si intentamos guardar un dato con un tamaño menor del que permite ocurre un underflow o desbordamiento inferior.

En el siguiente ejemplo podemos observar cómo se comporta una variable char con un rango entre [0, 255] de posibilidades, cuando intentamos almacenar en ella un valor mayor que 255:

#include <iostream>

int main()
{
    unsigned char caracter{250}; // Rango char {0, 255}

    for (int i = 0; i < 10; i++)
    {
        std::cout << caracter << "\t"
                  << static_cast<int>(caracter) << std::endl;
        caracter++;
    }
}

Lo que ocurre es ni más ni menos que un overflow, un reinicio a 0 para el 256 y así sucesivamente:

El mismo caso pero por abajo ocurrirá a la inversa, un underflow, pasando de 0 a 255:

#include <iostream>

int main()
{
    unsigned char caracter{4}; // Rango char {0, 255}

    for (int i = 0; i < 10; i++)
    {
        std::cout << caracter << "\t"
                  << static_cast<int>(caracter) << std::endl;
        caracter--;
    }
}

Es muy importante tener en cuenta esta situación en las conversiones entre tipo ya que en estos casos no ocurrirá un error y simplemente se tratará el desbordamiento como un reinicio del tamaño.


Última edición: 08 de Mayo de 2022