L1: TADs con funciones
Esta práctica no utiliza el Jutge! Deberás ser tú mismo quien compruebe que todo funciona correctamente 😋. Pide ayuda al profesor con todos los problemas que tengas.
Objetivos de esta práctica:
- Entender qué es un TAD implementado con funciones.
- Saber dividir código en un fichero
.hh
y otro.cc
. - Usar
Makefile
s para compilar y gestionar las tareas de un proyecto.
Parte I: fecha.cc
Descarga el fichero fecha.cc
y familiarízate primero con el programa, sin muchísimo detalle, pero en su estructura general.
En particular, fíjate en:
-
Los comentarios tienen una estructura especial (es el formato de Doxygen). Este tipo de comentarios son útiles dentro de VSCode porque si paras el cursor del ratón en una función, verás que se muestra la ayuda sobre esa función, que proviene de esos comentarios.
-
Todas las funciones y acciones tienen un prefijo común:
fecha_
. -
Hay dos funciones interesantes:
fecha_actual
, que obtiene la fecha del ordenador, yfecha_leer_de_fichero
, que lee una secuencia de fechas. -
Hay dos funciones muy curiosas cuyo nombre es
operator<<
yoperator>>
, que lo que hacen es definir qué pasa cuando escribes y lees una fecha:Fecha f1; cin >> f1; // <- Aquí se llama a `operator>>` cout << f1 << endl; // <- Aquí se llama a `operator<<`
Fíjate en qué parámetros reciben las funciones e intenta deducir a qué se corresponden en la instrucción de lectura o escritura. Lo otro curioso es: ¿porqué devuelven el parámetro de tipo
ostream
oistream
que reciben? (Pista: hay que ver<<
y>>
como operadores binarios)
Compila el programa en el terminal y ejecútalo. (También puedes presionar F5 en VSCode.) El programa principal simplemente hace una demostración de cómo usar las diferentes funciones.
Ejercicio: Implementa la función
fecha_mayor
, que no está hecha, intentando escribir el código de la misma manera y poniendo, si puedes, los comentarios tipo Doxygen fijándote en los de las otras funciones. Usa la función en elmain
y comprueba que haga lo esperado (y mira también si sale la ayuda al posar el cursor del ratón).
Parte II: Separación del programa en dos unidades de compilación
Aunque el fichero fecha.cc
sea un programa auto-contenido, está claro que las funciones para fechas son útiles en otros programas, y nos gustaría que fuese fácil reutilizarlas.
Partimos en programa en 3 partes
Ahora coge el fichero fecha.cc
y corta la función main
de éste y ponla en el fichero main.cc
. Es decir, todas las funciones que tratan con fechas están en fecha.cc
y luego el programa principal está en main.cc
.
Ficheros de cabecera
Para resolver los errores de main.cc
, hay que hacer un fichero de cabecera. Se llamará fecha.hh
, y debe contener:
- La declaración de la tupla
Fecha
, y, más abajo, - las cabeceras de las funciones que hay en
fecha.cc
.
Por ejemplo, la cabecera de fecha_menor
es:
bool fecha_menor(const Fecha& a, const Fecha& b);
Fíjate en el punto y coma que hay al final de la declaración (que viene a decir que la función existe, pero que su implementación está en otro sitio).
Uso de fecha.hh
Ahora, hay que modificar tanto fecha.cc
como main.cc
para que empiecen con #include "fecha.hh"
. Es como que los ficheros .cc
comparten un trozo de código (el fichero fecha.hh
), que dice cómo son las funciones de las fechas. Si el fichero de cabecera es compartido, entonces tenemos la seguridad de que el código que implementa las funciones y el que las usa son compatibles entre sí.
El hecho de usar comillas dobles para el include le indica al compilador que el fichero está en el mismo directorio que el .cc
correspondiente.
El resultado es, entonces:
fecha.hh
: Declaración de la tuplaFecha
más las declaraciones de las funciones.fecha.cc
: Implementaciones de las funciones con un#include "fecha.hh"
al principio.main.cc
: Implementación del programa principal con un#include "fecha.hh"
al principio.
Includes dentro de includes
Ahora mismo, aún hay errores en el fichero fecha.hh
, porque faltan declaraciones. Se declaran funciones que usan tipos como ostream
, vector
, o string
, así que hay que añadir 2 includes dentro del propio fichero fecha.hh
:
#include <iostream> // que a su vez incluye `string`
#include <vector>
Esto puede parecer raro, pero para que las declaraciones de la funciones fecha_...
no tengan errores, hay que incluir los ficheros de cabecera necesarios. La siguiente sección, explica una técnica, la "protección del doble include", que nos protege del caos que es permitir hacer #include
s unos dentro de otros.
Protección de doble include
Ahora le pondremos al fichero lo que se llama el "include guard" o protección de doble include:
#ifndef FECHA_HH
#define FECHA_HH
struct Fecha {
int dia, mes, anyo;
};
// Aquí las declaraciones de funciones `fecha_...`
// ...
#endif
que lo que hace es impedir que si haces #include
del fichero fecha.hh
dos veces, la duplicación de las declaraciones dé error. Con esta protección, puedes hacer algo como esto:
#include "fecha.hh"
#include "fecha.hh"
y no habría ningún error (básicamente porque la segunda inclusión sería estéril). En realidad, nunca harás semejante cosa, pero como un fichero de cabecera A puede incluir a otro B y a B tú no lo ves, no es difícil que se acabe poniendo un fichero dos veces. Por ejemplo:
#include <iostream> // Hace `#include <string>`, lo necesita
#include <string> // Sería duplicación! Pero está protegido ;)
using namespace ...
??
La cosa ha mejorado bastante, pero... ¿¿no habría que poner using namespace std;
??
Pues no! Una regla muy importante sobre ficheros de cabecera, como el fecha.hh
que estamos haciendo, es que no deben tener ningún using namespace ...
, de ningún tipo.
La razón de esto es que usar using namespace ...
abre el espacio de nombres y hace visibles (utilizables solo con usar su nombre) todas las funciones que hay en el namespace
en el entorno donde te encuentras. Esto puede llevar a colisiones de nombres, es decir, que los nombres que hayas usado en tu programa puedan coincidir con los nombres de las cosas que hay en el #include
.
No queremos provocar colisiones de nombres inadvertidas en los programas que usen fechas (que son los que pondrán #include "fecha.hh"
), así que no podemos decidir algo que es crítico para esos programas. El hecho de abrir espacios de nombres con using namespace ...
es la responsabilidad de los ficheros .cc
, no de los .hh
.
Por tanto, al no poder hacer using namespace std;
deberemos prefijar todas las ocurrencias de nombres de la librería de C++ con "std::
", para que se sepa de dónde provienen. Edita, entonces, todos los símbolos que provienen de los #include
de la librería de C++ así:
Original | Con prefijo |
---|---|
vector | std::vector |
string | std::string |
ostream | std::ostream |
istream | std::istream |
Esto es algo más de trabajo, pero vale la pena hacer las cosas bien.
Funciones inline
Esta parte es opcional, pero es útil saberla (y tiene que ver con la eficiencia desde el punto de vista del compilador).
A veces ocurre que hay funciones muy cortas. Cuando esto ocurre, el coste de la llamada a la función, para la CPU, es mucho mayor que simplemente incrustar la instrucción que realizan en el punto donde se llamaría, que sería poner la función "en línea" (o inline
) con el código circundante a la llamada.
Para conseguir esto:
-
Hay que mover la implementación de la función corta al fichero de cabecera. En nuestro caso, se podrían mover las dos funciones que permiten leer y escribir (
operator<<
yoperator>>
). -
Hay que poner, antes del tipo de retorno, la palabra reservada
inline
:inline ostream& operator<<(ostream& o, const Fecha& f) { // No solo la cabecera, la implementación también! }
Llegados aquí, ya tenemos perfectamente partido el código en dos trozos: las declaraciones y las implementaciones (así como la protección, sin using ...
y añadiendo funciones inline
). Fiu...
Vamos a compilar!
Hecho todo esto, y si tienes la extensión de C++ instalada en VSCode, deberías ver que el programa no parece tener errores. Para esto, es importante haber abierto con VSCode solamente la carpeta que tiene los 3 ficheros y no mezclar con otras cosas. (Los dos problemas típicos son: no tener una carpeta abierta, o bien tener otros ficheros no relacionados en ésta.)
Pero, un momento: ¿¡¿cómo se compila un programa con dos ficheros?!? 🤔
Hay la versión que compila cada parte por separado:
g++ -c fecha.cc
g++ -c main.cc
g++ -o demo_fecha fecha.o main.o
Esta versión demuestra cada paso del proceso de compilación:
- Compilar cada fichero
.cc
por separado, dando lugar a un fichero.o
. Esto ocurre dos veces. - Enlazar ("linkar") los dos ficheros
.o
en un ejecutable (demo_fecha
). Esto ocurre una vez, al final.
Hay otra versión para ir más rápido que hace lo mismo:
g++ -o demo_fecha fecha.cc main.cc
Es importante ver que no se hace mención de fecha.hh
en la línea de comandos pero cuando el compilador ve los #include "fecha.hh"
que hay en ambos ficheros .cc
entonces ese código acaba en el texto que el compilador procesa.
Ejercicio: Debes provocar dos errores de enlazado muy comunes. El primero ocurre cuando hay una función implementada dos veces (copia una de las funciones de
fecha.cc
amain.cc
). El enlazador, que pone todo el código en común, no sabe qué hacer, entonces. El otro ocurre cuando falta una función declarada, que se utiliza en algún sitio (para esto puedes comentar alguna de las funciones defecha.cc
que se utilize enmain.cc
). Observa los mensajes que da el enlazador (o "linker"), que son bastante reconocibles y distintos de los errores de compilación.
Parte II: Makefile
s
Cuando empecemos a hacer programas más grandes, y si uno quiere distribuir un programa en varios ficheros, compilar el programa se vuelve un engorro.
Para esto hay herramientas que facilitan el proceso y que permiten decir qué comandos hay que hacer para compilar cada parte. Además, en un programa con muchos ficheros, no se suelen modificar todos, y entonces para compilar el programa a menudo es necesario solamente compilar una fracción muy pequeña de todos los ficheros y simplemente enlazar los .o
que ya teníamos y que no hayan cambiado.
Para esto está la herramienta make
, muy extendida en general por el mundo del código abierto.
Crear un Makefile
Lo que queremos es automatizar los comandos que hemos puesto al compilar demo_fechas
, por separado, en 3 pasos. Eran estos:
g++ -c fecha.cc
g++ -c main.cc
g++ -o demo_fecha fecha.o main.o
Un fichero para make
está compuesto por reglas que indican cómo conseguir un fichero resultado (por ejemplo, fecha.o
), y de qué otros ficheros depende, que quiere decir, qué ficheros se utilizan para crear ese resultado. Si éstos cambian, el resultado cambiará, así que make
mira las fechas de los ficheros para saber qué comandos tiene que repetir.
Reglas de Makefile
Entonces, crea un fichero de nombre Makefile
(en la misma carpeta que los otros ficheros) y escribe la siguiente regla:
fecha.o: fecha.cc fecha.hh
g++ -c fecha.cc
Esta regla de make
dice:
- El resultado es
fecha.o
. - Depende de
fecha.cc
y defecha.hh
(por tanto, si éstos cambian, habrá que repetir el comando para actualizarfecha.o
). Aquí es interesante pensar que, aunque el comando no mencionafecha.hh
, nosotros sabemos que éste se utiliza enfecha.cc
, por eso lo ponemos. - El comando para crear
fecha.o
a partir de sus dependencias esg++ -c fecha.cc
.
Ejercicio: Escribe tu mism@ la regla para
main.o
.
La regla para el ejecutable es:
demo_fechas: fecha.o main.o
g++ -o demo_fechas fecha.o main.o
Esta regla es conveniente que salga la primera del fichero, porque entonces make
la considera la principal, y será más cómodo invocar el proceso de compilación.
Invocamos make
Ahora en el terminal, desde la carpeta que contiene los 4 ficheros de trabajo (Makefile
, fecha.hh
, fecha.cc
y main.cc
), simplemente escribe el comando:
make
Esto buscará la primera regla del Makefile
(que es demo_fechas
). Al mirar las dependencias, make
verá que no tiene fecha.o
ni main.o
e irá a buscar reglas para estos dos ficheros. Si no estuvieran, se quejaría, pero las hemos puesto justo debajo (salvo que haya algun pequeño errorcillo). Por tanto make
podrá construir fecha.o
y main.o
antes que demo_fechas
. Cuando esos pasos estén hechos, entonces ya podrá ejecutar el comando final, que produce demo_fechas
.
El resultado es que se ejecutan los comandos que hemos entrado al principio, y esta vez con un esfuerzo mucho menor.
Ejercicio: Altera un poquito el
main.cc
(ni que sea poner un espacio o un comentario) para que la fecha de modificación del fichero cambie. Ahora vuelve a hacermake
. ¿Qué comandos se ejecutan? Se corresponde con tu intuición de qué debería hacerse para construirdemo_fechas
en los mínimos pasos posibles?
Ejercicio: ¿Porqué si pones
make
y luego otra vezmake
, éste contesta con una mensaje del tipo "demo_fechas
ya está actualizado"?
Otras reglas
Aparte de la construcción en sí, al Makefile
se le pueden añadir reglas que hagan otras cosas, como limpiar el directorio, o pasar unos tests para comprobar el programa, etc.
Vamos a añadir una regla para limpiar, llamada clean
(muy típica por otro lado en ficheros Makefile
):
clean:
rm -f demo_fechas main.o fecha.o
.PHONY: clean
De hecho hay dos reglas. En la primera, decimos que el resultado es clean
(cosa que no es totalmente cierta porque no se produce ningún fichero clean
). El comando que hay en esa regla, claramente borra todos los ficheros que se pueden reconstruir compilando. Esta regla es a veces interesante cuando quieres limpiar el directorio de binarios, ya sea porque ocupan espacio, o porque vas a hacer un .zip
del directorio y quieres que sea lo más pequeño posible.
La regla .PHONY
precisamente matiza la regla clean
, ya que le dice a make
que clean
no es un fichero de resultado, que es un "falso" (o phony en inglés). Esto asegura que make
no busque un fichero clean
como resultado de la regla.
Ejercicio: busca un comando que te permita comprimir el directorio entero del programa, con sus 4 ficheros, en un solo fichero
.zip
o.tar.gz
, o parecido, y pon una regla en elMakefile
. ¿Qué nombre debería tener la regla? ¿Tiene que ser.PHONY
?
Parte IV: Fechas y números aleatorios
En esta parte, hay que crear un programa que genere fechas al azar usando un nuevo fichero rng.cc
(de Random Number Generator), que es muy parecido al fecha.cc
con el que hemos empezado.
Para preparar el proyecto, se trataría de hacer lo siguiente:
- Compilar
rng.cc
para ver que todo está bien. - Separar
rng.cc
enrng.cc
yrng.hh
. (La funciónmain
es de prueba y se puede descartar.) - Integrar
rng.cc
en el proyecto añadiendo una regla de compilación en elMakefile
y usar alguna función derng.cc
enmain.cc
, para ver que todo funciona correctamente.
Ejercicio: Una vez todo a punto, se trata de implementar un programa, usando las funciones
fecha_...
yrng_...
, que muestra una secuencia de fechas en orden cronológico, todas en el futuro, pero donde la diferencia en días entre dos fechas consecutivas sea al azar. El programa debe tener dos constantes arriba de todo en el código para controlar dos aspectos:
const int NUM_FECHAS = 10000; // Cantidad de fechas que se generan const int MAX_DISTANCIA = 30; // Distancia máxima en días entre // dos fechas consecutivas