La librería STL estándar
Introducción
El inventor de C++, Bjarne Stroustrup, trabajó en algunos proyectos de desarrollo muy importantes. Las primeras aplicaciones de C++ fueron las telecomunicaciones, un campo que requiere herramientas de alto nivel. Por supuesto, las clases fomentan la abstracción, pero un programa no se compone sólo de interfaces e implementaciones.
Al diseñar el software, el desarrollador debe determinar el límite de reutilización de proyectos anteriores. El término "reutilización" se utiliza a menudo para referirse a copiar y pegar determinadas partes del código fuente. ¿Qué elementos se prestan mejor a esta operación? Las clases, por supuesto, ya que el modelo orientado a objetos se construye en torno al concepto de reutilización. Pero también hay estructuras de datos, funciones especializadas y partes algorítmicas de diversos tipos que aún no han alcanzado la madurez necesaria para formar clases.
Los módulos describen un desglose bastante físico de los programas. Por ejemplo, un archivo de código fuente .h o .cpp se puede considerar un módulo. Sin embargo, ensamblar módulos no es fácil, sobre todo cuando proceden de proyectos anteriores. Puede haber conflictos de nombres, de funciones, diferencias en la representación de tipos y el programa se puede desestructurar, con todos los miembros...
Organización de programas
1. Espacio de nombres
El lenguaje C sólo tiene dos niveles de ámbito: el nivel global, al que pertenece la función main() y el nivel local, destinado a las instrucciones y variables locales. Con la aparición de las clases, se añadió un nivel adicional, para registrar campos y métodos. La introducción de la derivación (herencia) y los miembros estáticos refinó aún más la gama de niveles de alcance.
Por las razones expuestas en la introducción, se hizo necesario estructurar el espacio global. Por mencionar sólo una de ellas, el espacio global es demasiado arriesgado para almacenar variables y funciones de programas antiguos. Los conflictos son inevitables.
Este espacio global se puede dividir mediante:
namespace Edificio
{
double longitud;
void medir()
{
longitud=50.3;
}
} ;
namespace Cadenas
{
int longitud;
void calculo_longitud(char*s)
{
longitud=strlen(s);
}
} ;
Dos espacios de nombres, Edificio y Cadenas, contienen ambos una variable llamada longitud, que es de un tipo diferente. Las funciones medir() y calculo_longitud() siempre utilizan la versión correcta, porque la regla de accesibilidad también se verifica en los espacios de nombres: el compilador siempre busca la versión más cercana.
Para utilizar cualquiera de estas funciones, la función main() debe utilizar la operación de resolución de ámbito :: o una instrucción using:
int main(int argc, char* argv[])
{
Edificio::medir();
printf("La longitud del edificio es %g\n",Edificio::longitud);
using Cadenas::longitud;
Cadenas::calculo_longitud("hola");
printf("La longitud de la cadena es %d\n",longitud);
return 0;
}
Podemos ver que llamar a una función declarada dentro de un espacio de nombres es muy similar a llamar a un método estático. Esta analogía se extiende al acceso a una variable, que se puede...
Flujos C++ (entrada-salida)
La librería STL gestiona muchos aspectos de la E/S. Introduce una forma de programar para hacer persistentes los nuevos tipos definidos mediante el lenguaje C++.
El equipo que lo diseñó a finales de la década de 1980 se preocupó por respetar las técnicas actuales y producir una obra que resistiera el paso del tiempo.
Hay que decir que la gestión de archivos ha evolucionado enormemente desde la introducción de la librería estándar: las bases de datos relacionales han sustituido a los archivos estructurados y las interfaces gráficas han pasado a primer plano, frente a las consolas orientadas a caracteres.
Sin embargo, la dirección que se tomó al desarrollar la librería STL fue la correcta. Aunque el uso de streams ha caído en desuso, su estudio nos permite ver con más claridad cómo producir una nueva generación de entradas-salidas. El terminal en modo carácter sigue existiendo y la vitalidad de los sistemas Linux es prueba de ello.
1. General
Para empezar el aprendizaje de la E/S con buen pie, debemos distinguir entre archivos y flujos. Un archivo se caracteriza por un nombre, una ubicación, derechos de acceso y a veces también un periférico. Un flujo o stream es contenido, información que es leída o escrita por el programa. Esta información puede ser de nivel superior o inferior. Se empieza naturalmente por el byte, luego se especializa en datos de tipo entero, decimal, booleano, cadena, etc. Por último, podemos crear registros compuestos por información muy variada. Es bastante lógico considerar que la forma de estos registros corresponde a la formación de una clase, es decir, de un tipo en el sentido de C++.
Los flujos C++ se organizan en tres niveles. El primero -el más abstracto- es ios_base, un formato de entrada-salida independiente del estado y el formato. Luego está el nivel basic_ios, una versión que incorpora la noción de argumento regional (locale en inglés).
Por último, está el nivel basic_iostream, un grupo de modelos de clases diseñados para soportar el formateo de todos los tipos básicos conocidos por el lenguaje. Este es el nivel en el que vamos a trabajar.
La información fluye a través de un sistema...
Clase string para representar cadenas de caracteres
Sorprendentemente, la mayoría de los tratados de algoritmia no estudian las cadenas como tales. La estructura de datos más parecida es la matriz, para la que se han ideado numerosos problemas y soluciones.
El lenguaje C se ha mantenido fiel a este enfoque, tratando las cadenas como matrices de caracteres. Sus diseñadores tomaron dos decisiones importantes: la longitud de una cadena se limita a la asignada a la matriz y la codificación es la de los caracteres C, utilizando la tabla ASCII. Como no hay otra forma de determinar el tamaño de una matriz que utilizando una variable adicional, a los diseñadores del lenguaje C se les ocurrió la idea de terminar las cadenas con un carácter especial cero. Es cierto que este carácter no tiene ninguna función en la tabla ASCII, pero las cadenas en C se han vuelto muy especializadas y, por tanto, muy alejadas del algoritmo general.
El autor de C++, Bjarne Stroustrup, quería que su lenguaje fuera compatible con el lenguaje C, pero también mejorar la codificación teniendo en cuenta distintos formatos de codificación, tanto ASCII como no ASCII.
1. Representación de cadenas en la librería STL
Para la librería STL, una cadena es un conjunto ordenado de caracteres. Una cadena es, por tanto, muy similar a vector, clase también presente en la librería. Sin embargo, la cadena desarrolla sus propios accesos y procesamiento, proporcionando un mejor soporte a los algoritmos traducidos a C++.
Las cadenas de la librería estándar utilizan una clase de caracteres para evitar la codificación. La librería STL ofrece soporte para caracteres ASCII (char) y caracteres extendidos (wchar_t), pero sería posible desarrollar otros formatos para algoritmos basados en cadenas. La ingeniería genética utiliza cadenas formadas por caracteres específicos, A, C, G, T. Por tanto, la codificación con un char es muy costosa en términos de espacio, ya que bastan dos bits para expresar un vocabulario de este tipo, sobre todo teniendo en cuenta que las secuencias de genes pueden implicar varios cientos de miles de bases. También es posible especificar caracteres adaptados a alfabetos no latinos, para los que la tabla ASCII resulta ineficaz.
La clase basic_string utiliza un vector para almacenar...
Contenedores dinámicos
Una función esencial de la librería estándar es proporcionar mecanismos para soportar algoritmos de la forma más eficiente posible. Esta afirmación tiene varios objetivos contradictorios. Los algoritmos requieren genericidad, es decir, métodos de trabajo que sean independientes del tipo de datos a manipular. El lenguaje C se complacía en utilizar punteros void* para garantizar la genericidad, pero este enfoque se traduce en una pérdida significativa de eficiencia en el control de tipos, complicación del código y, en última instancia, bajo rendimiento. La eficiencia que se atribuye a STL se consigue a costa de un diseño riguroso y un sutil control de tipos. Es cierto que el resultado es un compromiso entre expectativas a veces contradictorias, pero es lo suficientemente convincente como para ser utilizado en el desarrollo de aplicaciones en las que la fiabilidad es imperativa.
Los diseñadores de la STL han utilizado modelos de clases para desarrollar la genericidad. Los modelos de clases y funciones se tratan en detalle en el capítulo Programación orientada a objetos y su uso es bastante sencillo. Una clase se instancia a partir de su modelo suministrando los argumentos esperados, generalmente el tipo de datos que realmente se tienen en cuenta al implementar la clase. Es esencial distinguir este enfoque del uso de macros (#define), que producen resultados inesperados. El uso de estas macros es muy poco seguro.
Una idea original en la construcción de la librería estándar es la correlación entre los contenedores de datos y los algoritmos que se aplican a estos contenedores. Una lectura comparativa de varios libros de texto sobre algoritmos lleva a la conclusión de que las estructuras de datos son siempre más o menos las mismas, al igual que los algoritmos que se aplican a estas estructuras. Por tanto, no era buena idea diseñar estructuras aisladas, como una pila o una lista, sin pensar en lo que vendría después, la aplicación de algoritmos menos específicos. Los diseñadores de la librería STL supieron evitar este escollo.
1. Contenedores
Los contenedores son estructuras de datos para almacenar objetos de varios tipos. Los contenedores de la librería STL respetan las principales construcciones desarrolladas...
Algoritmos
Un contenedor ya ofrece un resultado interesante, pero la librería estándar extrae su fuerza de otro aspecto: los contenedores se asocian a funciones generales o algoritmos. Estas funciones utilizan casi todos los iteradores para armonizar el acceso a los datos de un tipo de contenedor a otro.
1. Operaciones de secuencia sin modificación
Se trata principalmente de algoritmos de búsqueda y recorrido.
for_each() |
Ejecuta la acción para cada elemento de una secuencia. |
find() |
Busca la primera aparición de un valor. |
find_if() |
Busca la primera coincidencia de un predicado. |
find_first_of() |
Busca en una secuencia un valor de otra secuencia. |
adjacent_find() |
Busca un par de valores adyacentes. |
count() |
Cuenta las ocurrencias de un valor. |
count_if() |
Cuenta las correspondencias de un predicado. |
mismatch() |
Encuentra los primeros elementos en los que difieren dos secuencias. |
equal() |
True si los elementos de dos secuencias son iguales a nivel de cada pareja. |
search() |
Busca la primera aparición de una secuencia como subsecuencia. |
find_end() |
Busca la última aparición de una secuencia como subsecuencia. |
search_n() |
Busca la enésima aparición de un valor. |
He aquí un ejemplo de búsqueda de un valor en un vector de cadenas char*:
#include <iostream>
#include <algorithm>
#include <vector>
using namespace std;
int main(int argc, char* argv[])
{
vector<char*> tab;
tab.push_back("Sonata");
tab.push_back("Partita");
tab.push_back("Cantate");
tab.push_back("Concerto");
tab.push_back("Sinfonía");
tab.push_back("Aria");
tab.push_back("Preludio");
vector<char*>::iterator pos;
char*palabra="Concerto";
pos=find(tab.begin(),tab.end(),palabra);
cout << *pos << endl; // muestra Concerto
return 0;
}
2. Secuencia de operaciones con modificación
transform() |
Aplica una operación a todos los elementos de una secuencia. |
copy() |
Copiar una secuencia. |
copy_backward() |
Copia una secuencia en orden inverso, empezando por el último elemento. |
swap() |
Intercambia... |
Las aportaciones del C++ moderno
El lenguaje C++ está sujeto a constantes revisiones e innovaciones. Desde la versión de referencia, C++ 98, publicada en 1998, otras versiones lo han puesto al día. Las normas C++ 11, C++ 14, C++ 17, C++ 20 y ahora C++ 23 incluyen algunas características muy potentes que, hasta ahora, sólo estaban disponibles en otros lenguajes diseñados mucho después que C++.
Aquí presentamos algunas nuevas posibilidades relacionadas con el lenguaje y los algoritmos.
1. Las expresiones lambda
En C++, una expresión lambda (o lambda) es una función anónima (conocida como closure) llamada en el mismo punto donde se define. Es una forma práctica de describir pequeños algoritmos sin intentar que las funciones sean reutilizables.
Lambda es una notación compuesta por los siguientes elementos:
[capture] (argumentos) mutable exception { corps }
La captura permite a lambda trabajar con variables declaradas en el ámbito que la define, por referencia & o por valor =. La captura por defecto puede ser por valor =, lo que significa que la lambda recibe una "copia" de las variables que aparecen en el cuerpo de la instrucción. Si la captura es por referencia &, la lambda puede modificar las variables.
Los argumentos son opcionales, aunque a menudo una lambda recibe elementos para ser probados, comparados, evaluados, procesados, etc.
La palabra clave mutable también es opcional. Como regla general, el operador de llamada a una lambda es const por valor, pero la presencia de mutable anula esta regla, permitiendo al cuerpo de la instrucción modificar localmente variables capturadas por valor.
La indicación de excepciones mediante throw o noexcept es opcional, como en todas las funciones C++.
El tipo de retorno es deducido por el compilador a partir del cuerpo. Por defecto, es void, a menos que una instrucción return expresión permita al compilador deducir un tipo particular.
Veamos la función ends_with(), que comprueba la terminación de una cadena:
// comprueba si la cadena termina con el sufijo (ejemplo cadena =
prueba.txt termina con sufijo = .txt)
inline bool ends_with(std::string const& cadena, std::string
const& sufijo)
{
if (sufijo.size() > cadena.size())
return...
Introducción a la librería boost
La librería boost no es un competidor de la librería STL, sino más bien un complemento o extensión de la misma. De hecho, varios módulos del repositorio boost se han integrado en la librería estándar a lo largo de las versiones C++ 14 a C++ 17. Por tanto, las librerías STL y boost funcionan muy bien juntas.
1. Instalación de la librería
Hay varias formas de instalar la librería en un proyecto. En Linux, puede seguir las instrucciones del sitio web https://boost.org/ para recuperar un paquete fuente o binario.
Desde Visual Studio, la forma más directa es descargar un paquete NuGet. En el menú Proyecto, utilice el comando Administrar paquetes NuGet. Introduzca boost en el cuadro de búsqueda de la pestaña Examinar. El catálogo de paquetes mostrará entonces la librería completa, así como los volúmenes de esta librería.
Tras pulsar el botón Instalar, el gestor de paquetes le pedirá que confirme que la librería se ha instalado en el directorio:
La confirmación activa el proceso de descarga del paquete NuGet (desde la solución de Visual Studio) y la instalación de la librería en el proyecto.
2. Un primer ejemplo con boost
Este ejemplo sigue perteneciendo al ámbito de los algoritmos y las colecciones. Muestra cómo las boost...
Trabajo práctico
El intérprete tiny-lisp depende en gran medida de la librería STL. He aquí algunos detalles sobre la clase Variant, implementada utilizando objetos de la librería estándar.
1. La clase Variant
Variant es el tipo de datos universal de tiny-lisp. Puede ser un símbolo, un número, una lista (de Variant) o un procedimiento.
En tiny-lisp, el objeto Variant forma parte de un entorno, un contenedor con una tabla de símbolos. Esta estructura es necesaria para ejecutar funciones LISP y expresiones lambda, para pasar argumentos y crear variables locales.
enum variant_type
{
Symbol, Number, List, Proc, Lambda, Cadena
};
// definición ; Variant y Environment se hacen referencia mutuamente
struct Environment;
// un Variant representa cualquier tipo de valor Lisp
class Variant {
public:
// función que devuelve Variant y que recibe como argumento variants
typedef Variant(*proc_type) ( const std::vector<Variant>& );
typedef std::vector<Variant>::const_iterator iter;
typedef std::map<std::string, Variant> map;
// tipos tomados en la enumeración: symbol, number, list, proc o lamda
variant_type type;
// valor escalar
std::string val;
// valor list
std::vector<Variant> list;
// valor lambda
proc_type proc;
// environment
Environment * env;
// constructores
Variant(variant_type type = Symbol) : type(type) , env(0), proc(0) {
}
Variant(variant_type type, const std::string& val) :
type(type), val(val) , env(0) , proc(0) {
}
Variant(proc_type proc) : type(Proc), proc(proc) , env(0) {
}
std::string to_string();
std::string to_json_string();
static...