C++ en profundidad

Instructivo del lenguaje C++

En las primeras secciones de este instructivo, se aborda el material básico que ya se presentó en los dos últimos módulos y se proporciona más información sobre conceptos avanzados. En este módulo, nos enfocaremos en la memoria dinámica y en más detalles sobre los objetos y las clases. También se presentan algunos temas avanzados, como herencia, polimorfismo, plantillas, excepciones y espacios de nombres. Los estudiaremos más adelante en el curso de C++ avanzado.

Diseño orientado a objetos

Este es un excelente instructivo sobre el diseño orientado a objetos. Aplicaremos la metodología presentada aquí en el proyecto de este módulo.

Aprende del ejemplo 3

En este módulo, nos enfocaremos en adquirir más práctica con punteros, diseño orientado a objetos, arrays multidimensionales y clases/objetos. Revisa los siguientes ejemplos. No podemos enfatizar lo suficiente que la clave para convertirse en un buen programador es la práctica, la práctica y la práctica.

Ejercicio n.° 1: Más práctica con punteros

Si necesitas práctica adicional con los punteros, lee este recurso que abarca todos los aspectos de los punteros y proporciona muchos ejemplos de programas.

¿Cuál es el resultado del siguiente programa? No ejecutes el programa, sino que dibuja la imagen de la memoria para determinar el resultado.

void Unknown(int *p, int num);
void HardToFollow(int *p, int q, int *num);

void Unknown(int *p, int num) {
  int *q;

  q = #
  *p = *q + 2;
  num = 7;
}

void HardToFollow(int *p, int q, int *num) {
  *p = q + *num;
  *num = q;
  num = p;
  p = &q;
  Unknown(num, *p);
}

main() {
  int *q;
  int trouble[3];

  trouble[0] = 1;
  q = &trouble[1];
  *q = 2;
  trouble[2] = 3;

  HardToFollow(q, trouble[0], &trouble[2]);
  Unknown(&trouble[0], *q);

  cout << *q << " " << trouble[0] << " " << trouble[2];
}

Una vez que hayas determinado el resultado de forma manual, ejecuta el programa para ver si tienes razón.

Ejercicio n.° 2: Más práctica con clases y objetos

Si necesitas práctica adicional con clases y objetos, aquí encontrarás un recurso que abarca la implementación de dos clases pequeñas. Tómate un tiempo para hacer los ejercicios.

Ejercicio n.° 3: Arreglos multidimensionales

Considera el siguiente programa: 

const int kStudents = 25;
const int kProblemSets = 10;

// This function returns the highest grade in the Problem Set array.
int get_high_grade(int *a, int cols, int row, int col) {
  int i, j;
  int highgrade = *a;

  for (i = 0; i < row; i++)
    for (j = 0; j < col; j++)
      if (*(a + i * cols + j) > highgrade)  // How does this line work?
        highgrade = *(a + i*cols + j);
  return highgrade;
}

int main() {
 int grades[kStudents][kProblemSets] = {
   {75, 70, 85, 72, 84},
   {85, 92, 93, 96, 86},
   {95, 90, 83, 76, 97},
   {65, 62, 73, 84, 73}
 };
 int std_num = 4;
 int ps_num = 5;
 int highest;

 highest = get_high_grade((int *)grades, kProblemSets, std_num, ps_num);
 cout << "The highest problem set score in the class is " << highest << endl;

 return 0;
}

En este programa, existe una línea marcada "¿Cómo funciona esta línea?" ¿Puedes resolverlo? Aquí encontrarás la explicación al respecto.

Escribe un programa que inicialice un array de 3 atenuaciones y complete el valor de la 3a dimensión con la suma de los tres índices. Esta es nuestra solución.

Ejercicio n.° 4: Ejemplo de diseño exhaustivo fuera de la oficina

Este es un ejemplo de diseño orientado a objetos detallado que abarca todo el proceso de principio a fin. El código final está escrito en el lenguaje de programación Java, pero podrás leerlo dado todo lo que has avanzado.

Tómate el tiempo necesario para trabajar con todo este ejemplo. Es una excelente ilustración del proceso y de las herramientas de diseño que lo respaldan.

Prueba de unidades

Introducción

Las pruebas son una parte fundamental del proceso de ingeniería de software. Una prueba de unidades es un tipo particular de prueba que verifica la funcionalidad de un único módulo pequeño de código fuente.Siempre es el ingeniero quien realiza la prueba de unidades y, por lo general, se hace al mismo tiempo que se codifica el módulo. Los controladores de prueba que usaste para probar las clases Composer y Database son ejemplos de pruebas de unidades.

Las pruebas de unidades tienen las siguientes características. Ellos...

  • probar un componente de forma aislada
  • son deterministas
  • se suelen asignar a una sola clase
  • Evita las dependencias de recursos externos como bases de datos, archivos, redes
  • se ejecuten rápidamente
  • se puede ejecutar en cualquier orden

Existen metodologías y frameworks automatizados que proporcionan asistencia y coherencia para las pruebas de unidades en grandes organizaciones de ingeniería de software. Existen algunos frameworks sofisticados de prueba de unidades de código abierto, que veremos más adelante en esta lección. 

A continuación, se ilustran las pruebas que se realizan como parte de la prueba de unidades.

En un mundo ideal, probamos lo siguiente:

  1. La interfaz del módulo se prueba para garantizar que la información entre y salga correctamente.
  2. Las estructuras de datos locales se examinan para asegurar que almacenen los datos correctamente.
  3. Las condiciones de límite se prueban para garantizar que el módulo funcione correctamente en los límites que limitan o restringen el procesamiento.
  4. Probamos las rutas de acceso independientes a través del módulo para garantizar que cada ruta y, por lo tanto, cada declaración del módulo, se ejecute al menos una vez. 
  5. Por último, debemos verificar que los errores se manejen correctamente.

Cobertura de código

En realidad, no podemos lograr una "cobertura de código completa" con nuestras pruebas. La cobertura de código es un método de análisis que determina qué partes de un sistema de software se ejecutaron (cubiertas) por el paquete de casos de prueba y qué partes no se ejecutaron. Si intentamos alcanzar una cobertura del 100%, dedicaremos más tiempo a escribir pruebas de unidades que a escribir el código real. Considera crear pruebas de unidades para todas las rutas de acceso independientes de lo siguiente. Esto puede convertirse rápidamente en un problema exponencial.

En este diagrama, no se prueban las líneas rojas, mientras que las líneas sin color se prueban.

En lugar de intentar una cobertura del 100%, nos enfocamos en pruebas que aumentan nuestra confianza en que el módulo funciona correctamente. Realizamos pruebas como las siguientes:

  • Casos nulos
  • Pruebas de rango, p.ej., pruebas de valores positivos o negativos
  • Casos extremos
  • Casos de falla
  • Prueba las rutas con más probabilidades de ejecutarse la mayor parte del tiempo

Frameworks de prueba de unidades

La mayoría de los frameworks de prueba de unidades usan aserciones para probar valores durante la ejecución de una ruta de acceso. Las aserciones son afirmaciones que verifican si una condición es verdadera. El resultado de una aserción puede ser exitoso, una falla recuperable o una falla irrecuperable. Después de realizar una aserción, el programa continúa normalmente si el resultado es un error exitoso o recuperable. Si ocurre una falla irrecuperable, se anula la función actual.

Las pruebas consisten en código que establece el estado o manipula tu módulo, junto con una serie de aserciones que verifican los resultados esperados. Si todas las aserciones de una prueba son exitosas (es decir, se muestra un valor verdadero), la prueba se ejecuta correctamente; de lo contrario, falla.

Un caso de prueba contiene una o más pruebas. Agrupamos las pruebas en casos de prueba que reflejan la estructura del código probado. En este curso, usaremos CPPUnit como marco de trabajo de prueba de unidades. Con este framework, podemos escribir pruebas de unidades en C++ y ejecutarlas automáticamente, lo que genera un informe sobre el éxito o el fracaso de las pruebas.

Instalación de CPPUnit

Descarga el código CPPUnit de SourceForge. Busca un directorio adecuado y coloca el archivo tar.gz allí. Luego, ingresa los siguientes comandos (en Linux y Unix) y reemplaza el nombre de archivo cppunit apropiado:

gunzip filename.tar.gz
tar -xvf filename.tar

Si trabajas en Windows, es posible que debas encontrar una utilidad para extraer archivos tar.gz. El siguiente paso es compilar las bibliotecas. Cambia al directorio cppunit. Allí hay un archivo INSTALL que proporciona instrucciones específicas. Por lo general, debes ejecutar lo siguiente:

./configure
make install

Si tienes problemas, consulta el archivo INSTALL. Por lo general, las bibliotecas se encuentran en el directorio cppunit/src/cppunit. Para comprobar que funcionó la compilación, ve al directorio cppunit/examples/simple y escribe “make”. Si todo se compila bien, está todo listo.

Aquí encontrarás un excelente instructivo. Sigue este instructivo y crea la clase de número complejo y sus pruebas de unidades asociadas. Hay varios ejemplos adicionales en el directorio cppunit/examples.

¿Por qué debo hacer esto?

La prueba de unidades es de vital importancia en la industria por varias razones. Ya conoces una razón: necesitamos alguna forma de verificar nuestro trabajo mientras desarrollamos código. Incluso cuando estamos desarrollando un programa muy pequeño, escribimos de forma instintiva algún tipo de verificador o controlador para asegurarnos de que el programa haga lo que se espera.

Con mucha experiencia, los ingenieros saben que las posibilidades de que un programa funcione en el primer intento son muy pequeñas. Las pruebas de unidades se basan en esta idea y hacen que los programas de prueba se verifiquen automáticamente y sean repetibles. Las aserciones reemplazan la inspección manual del resultado. Además, debido a que es fácil interpretar los resultados (la prueba es exitosa o falla), las pruebas se pueden ejecutar una y otra vez, lo que proporciona una red de seguridad que hace que tu código sea más resistente a los cambios.

En palabras concretas: Cuando envías por primera vez tu código terminado a CVS, funciona a la perfección. Y sigue funcionando a la perfección por un tiempo. Luego, un día, otra persona cambia tu código. Pronto o más tarde alguien romperá tu código. ¿Crees que se darán cuenta por sí solas? No es probable. Sin embargo, cuando escribes pruebas de unidades, hay sistemas que pueden ejecutarlas automáticamente todos los días. Estos sistemas se denominan sistemas de integración continua. Por lo tanto, cuando el ingeniero X rompe tu código, el sistema les enviará correos electrónicos desagradables hasta que lo corrijan. Incluso si el ingeniero X eres TÚ

Además de ayudarte a desarrollar software y mantenerlo seguro ante los cambios, haz lo siguiente:

  • Crea una especificación ejecutable y una documentación que se mantiene sincronizada con el código. En otras palabras, puedes leer una prueba de unidades para conocer el comportamiento que admite el módulo.
  • Te ayuda a separar los requisitos de la implementación. Debido a que afirmas un comportamiento visible externamente, tienes la oportunidad de pensar en ello de forma explícita en lugar de combinar ideas sobre cómo implementar el comportamiento.
  • Apoya la experimentación. Si cuentas con una red de seguridad que te alerta cuando se dañó el comportamiento de un módulo, es más probable que intentes hacer algo y reconfigurar tus diseños.
  • Mejora tus diseños. Escribir pruebas de unidades exhaustivas a menudo requiere que tu código sea más fácil de probar. A menudo, el código que se puede probar es más modular que el que no se puede probar.
  • Mantiene la calidad alta. Un pequeño error en un sistema crítico puede hacer que una empresa pierda millones de dólares o, lo que es peor, la satisfacción o confianza del usuario. La red de seguridad que proporcionan las pruebas de unidades disminuye esta posibilidad. Gracias a la detección temprana de errores, también permiten que los equipos de control de calidad dediquen tiempo a situaciones de fallas más sofisticadas y difíciles, en lugar de informar fallas evidentes.

Tómate un tiempo para escribir pruebas de unidades con CPPUnit en la aplicación de base de datos de Composer. Consulta el directorio cppunit/examples/ para obtener ayuda.

Cómo trabaja Google

Introducción

Imagina que un monje de la Edad Media mira los miles de manuscritos en los archivos de su monasterio."¿Dónde está esa de Aristóteles?".

biblioteca monstruosa

Por suerte para él, los manuscritos están organizados según el contenido y con símbolos especiales para facilitar la recuperación de la información que contiene cada uno. Sin esa organización, sería muy difícil encontrar el manuscrito relevante.

La actividad de almacenar y recuperar información escrita de grandes colecciones se denomina recuperación de información (IR). Esta actividad se volvió cada vez más importante a lo largo de los siglos, especialmente con inventos como el papel y la imprenta. Solía ser algo que solo ocupaba unas pocas personas. Sin embargo, en la actualidad, cientos de millones de personas recuperan información todos los días cuando utilizan un motor de búsqueda o hacen búsquedas desde una computadora de escritorio.

Comienza a usar la recuperación de información

gato con sombrero

Dr. Seuss escribió 46 libros infantiles en el transcurso de 30 años. Sus libros hablaban de gatos, vacas y elefantes, de quién es, de los gruñones y del lora. ¿Recuerdas qué criaturas aparecieron en cada historia? A menos que seas madre o padre, solo los niños pueden contarte qué conjunto de historias de Dr. Seuss tienen criaturas:

(COW y BEE) o CROWS

Aplicaremos algunos modelos clásicos de recuperación de información para ayudarnos a resolver este problema.

Un enfoque obvio es la fuerza bruta: obtén las 46 historias de Dr. Seuss y comienza a leer. En cada libro, observa cuáles contienen las palabras COW y BEE y, al mismo tiempo, busca libros que contengan la palabra CROWS. Las computadoras son mucho más rápidas que nosotros. Si tenemos todo el texto de los libros de Dr. Seuss en formato digital, por ejemplo, como archivos de texto, podemos buscarlos en los archivos. Para una pequeña colección como los libros de Dr. Seuss, esta técnica funciona bien.

Sin embargo, hay muchas situaciones en las que necesitamos más. Por ejemplo, la recopilación de todos los datos que se encuentra en línea en este momento es demasiado grande para que grep la maneje. Además, no solo queremos los documentos que coinciden con nuestra condición, sino que también estamos acostumbrados a que se clasifiquen según su relevancia.

Otro método además de grep es crear un índice de los documentos en una colección antes de realizar la búsqueda. Un índice en IR es similar a un índice al final de un libro de texto. Creamos una lista de todas las palabras (o términos) de cada historia de Dr. Seuss, sin incluir las palabras como "el", "y" y otras conexiones, preposiciones, etc. (llamadas palabras irrelevantes). Luego, representamos esta información de una manera que facilita la búsqueda de los términos y la identificación de las noticias en las que se encuentran.

Una representación posible es una matriz con las historias en la parte superior y los términos enumerados en cada fila. Un “1” en una columna indica que el término aparece en la historia de esa columna.

tabla de libros y palabras

Podemos ver cada fila o columna como un vector de bits. El vector de bits de una fila indica en qué historias aparece el término. El vector de bits de una columna indica qué términos aparecen en la historia.

Volviendo al problema original:

(COW y BEE) o CROWS

Tomamos los vectores de bits para estos términos y, primero, hacemos un AND a nivel de bits y, luego, usamos OR a nivel de bits en el resultado.

(100001 y 010011) o 000010 = 000011

La respuesta: "¡El Sr. Brown Can Moo! ¿Puedes?" y "El Lórax". Esta es una ilustración del modelo de recuperación booleana, que es un modelo de "concordancia exacta".

Supongamos que expandiéramos la matriz para incluir todas las historias de Dr. Seuss y todos los términos relevantes en ellas. La matriz crecería considerablemente, y es importante observar que la mayoría de las entradas serían 0. Es probable que una matriz no sea la mejor representación del índice. Necesitamos encontrar una manera de almacenar solo los 1.

Algunas mejoras

La estructura que se usa en IR para resolver este problema se denomina índice invertido. Conservamos un diccionario de términos y, para cada término, tenemos una lista que registra los documentos en los que aparecen. Esta lista se denomina lista de publicaciones. Una lista vinculada de forma individual funciona bien para representar esta estructura, como se muestra a continuación.

Si no estás familiarizado con las listas vinculadas, simplemente haz una búsqueda en Google sobre "lista vinculada en C++", y encontrarás muchos recursos que describen cómo crear una y cómo se usa. Abordaremos esto con más detalle más adelante en otro módulo.

Ten en cuenta que usamos los IDs de documento (DocIDs) en lugar del nombre de la historia. También clasificamos estos DocIDs, ya que facilita el procesamiento de las consultas.

¿Cómo procesamos una consulta? Para el problema original, primero encontramos la lista de publicaciones de COW y, luego, la lista de publicaciones de BEE. Luego, las “fusionamos”:

  1. Mantén los marcadores en ambas listas y recorre las dos listas de publicaciones al mismo tiempo.
  2. En cada paso, compara el DocID al que apuntan ambos punteros.
  3. Si son iguales, coloca ese DocID en una lista de resultados. De lo contrario, haz que el puntero apunte al docID más pequeño.

A continuación, se muestra cómo podemos compilar un índice invertido:

  1. Asigna un DocID a cada documento de interés.
  2. Para cada documento, identifica los términos relevantes (asignar tokens).
  3. Para cada término, crea un registro que contenga el término, el DocID en el que se encuentra y una frecuencia en ese documento. Ten en cuenta que puede haber varios registros para un término específico si aparece en más de un documento.
  4. Ordena los registros por término.
  5. A fin de crear la lista de diccionarios y publicaciones, procesa registros individuales para un término y, además, combina los múltiples registros de términos que aparecen en más de un documento. Crea una lista vinculada de los DocIDs (en orden). Además, cada término tiene una frecuencia, que es la suma de las frecuencias en todos los registros de un término.

El proyecto

Encuentra varios documentos de texto simple extensos con los que puedas experimentar. El proyecto consiste en crear un índice invertido de documentos usando los algoritmos descritos anteriormente. También deberás crear una interfaz para la entrada de consultas y un motor a fin de procesarlas. Puedes encontrar a un socio del proyecto en el foro.

A continuación, se muestra un proceso posible para completar este proyecto:

  1. Lo primero que hay que hacer es definir una estrategia para identificar términos en los documentos. Haz una lista de todas las palabras irrelevantes y escribe una función que lea las palabras de los archivos, guarde los términos y elimine las palabras irrelevantes. Es posible que tengas que agregar más palabras irrelevantes a medida que revisas la lista de términos de una iteración.
  2. Escribe casos de prueba CPPUnit para probar la función y un archivo makefile para reunir todo para tu compilación. Verifica tus archivos en CVS, en especial si trabajas con socios. Te recomendamos investigar cómo abrir tu instancia de CVS a los ingenieros remotos.
  3. ¿Deseas agregar procesamiento para incluir datos de ubicación, es decir, ¿en qué archivo y en qué parte del archivo se encuentra un término? Te recomendamos realizar un cálculo para definir el número de página o de párrafo.
  4. Escribe casos de prueba de CPPUnit para probar esta funcionalidad adicional.
  5. Crea un índice invertido y almacena los datos de ubicación en el registro de cada término.
  6. Escribir más casos de prueba
  7. Diseña una interfaz para permitir que un usuario ingrese una consulta.
  8. Con el algoritmo de búsqueda descrito anteriormente, procesa el índice invertido y muestra los datos de ubicación al usuario.
  9. Asegúrate de incluir también casos de prueba para esta parte final.

Al igual que hicimos con todos los proyectos, usa el foro y el chat para encontrar socios del proyecto y compartir ideas.

Una función adicional

Un paso de procesamiento común en muchos sistemas IR se llama rastrucción. La idea principal de la derivación es que los usuarios que busquen información sobre “recuperación” también estarán interesados en documentos en los que se incluya información que contenga las palabras “recuperar”, “recuperado”, “recuperación”, etcétera. Los sistemas pueden ser susceptibles de errores debido a una derivación deficiente, por lo que esto es un poco complicado. Por ejemplo, un usuario interesado en la "recuperación de información" podría recibir un documento titulado "Información sobre el Golden retrievers" debido a la derivación. Un algoritmo útil para la derivación es el algoritmo de Porter.

Aplicación: Accede a todas partes

Consulta esta una aplicación de estos conceptos en Panoramas.dk.