Table des matières
- Date de la séance de TP : vendredi 11 octobre 2024 - 13h45
- Date limite de prise en compte de votre version sur votre dépôt GitLab : dimanche 27 octobre 2024 - 23h59
Utilisez les fichiers tris.cpp
(pour la partie 1) et listes.cpp
(pour la partie 2 et la partie avancée) du dossier tp/clp_tp1
de votre dépôt Git, seul le contenu de ce dossier sera pris en compte pour l'évaluation de votre travail sur ce TP.
Vous allez utiliser des composants de la bibliothèque standard C++ qui utilisent la généricité : ces deux aspects seront vus plus en détail dans un prochain cours ; les quelques éléments nécessaires à leur utilisation dans ces exercices vous sont donnés, mais n'hésitez pas à demander d'autres explications à votre encadrant.
1. Tris (1 point)
Vous allez manipuler des tableaux pour ces exercices.
Vous n'utiliserez pas les tableaux « basiques » qui existent en C et en C++, mais ceux (vector
) de la bibliothèque standard.
#include <vector> // Nommons notre type pour plus de facilité de lecture et d'écriture using int_tab_t = std::vector< int >;
Pour créer un tableau non initialisé de 10 entiers, on peut écrire :
// IL EST IMPORTANT D'UTILISER DES PARENTHÈSES ICI, ET PAS DES ACCOLADES : // int_tab_t tab{ 10 }; // Tableau de 1 seul entier valant 10 int_tab_t tab( 10 ); // Tableau de 10 entiers
L'accès à un élément en lecture ou en écriture se fait avec l'opérateur d'indexation, la taille s'obtient via la méthode size()
(ces opérations ont bien sûr une compléxité constante) :
int premier{ tab[0] }; // Accès au premier élément tab[tab.size() - 1] = 5; // Modification du dernier élément
Si nécessaire, la documentation complète de cette classe est disponible ici.
1.1. Affichage d'un tableau
- Écrire une fonction
print_tab()
qui reçoit en argument un tableau d'entiers passé par référence constante et qui affiche ce tableau sur la sortie standard (les éléments dans l'ordre séparés par des espaces, le tout encadré par des crochets : voir l'exemple ci-dessous).
Dans certains langages, Java par exemple, il est classique que l'affichage d'une donnée quelconque se fasse avec une première étape de conversion en chaîne de caractères (méthode toString()
), avant l'affichage effectif de cette chaîne.
Ce n'est pas l'approche retenue en C++ : en effet, les flux de sortie, comme std::cout
, peuvent être configurés avec un certain nombre d'options, comme l'affichage des entiers en héxadécimal ou l'utilisation de la notation scientifique pour les flottants. C'est donc le flux de sortie qui se charge de la conversion en chaîne de caractères en tenant compte de ces options.
- Écrire une fonction
test_11()
qui affiche son nom, qui crée un tableautab
avec l'instruction
// Tableau de 6 entiers ayant les valeurs indiquées const int_tab_t tab{ 1, -2, 3, -4, 5, -6 };
et qui affiche ce tableau.
- Écrire la fonction
main()
qui appelletest_11()
. Compiler et exécuter votre programme. Le résultat attendu est :
*** test_11 ***
[ 1 -2 3 -4 5 -6 ]
add, commit & push
1.2. Remplissage d'un tableau avec des nombres aléatoires
- Écrire une fonction
random_tab()
qui reçoit en argument un tableau d'entiers passé par référence non constante et qui remplace tous les éléments de ce tableau par des valeurs aléatoires comprises entre-10
et10
.
Comment obtenir des nombres aléatoires ?
- Écrire une fonction
test_12()
qui crée un tableau de 10 entiers et appellerandom_tab()
puisprint_tab()
avec le tableau comme argument.
- Ajouter à la fonction
main()
un appel àtest_12()
. Exécuter plusieurs fois votre programme pour vérifier que tous les nombres demandés, et seulement eux, peuvent apparaître dans le tableau.
add, commit & push
1.3. Tri par ordre croissant
- Écrire une fonction
sort_tab_1()
qui reçoit en argument un tableau d'entiers passé par référence non constante et qui tri les éléments de ce tableau par ordre croissant.
Vous ne devez pas utiliser une fonction de tri de la bibliothèque standard, vous devez écrire vous-même l'algorithme de tri, et celui-ci ne doit pas supposer que les nombres du tableau à trier sont compris entre -10
et 10
.
Quel algorithme de tri ?
Vous pouvez utiliser ici n'importe quel algorithme de tri (nous ne nous préoccupons pas des performances ici), donc de préférence basique, par exemple un tri par sélection. Notez aussi que l'échange de 2 éléments du tableau peut se faire avec swap()
.
- Écrire une fonction
test_13()
qui crée un tableau de 10 entiers, appelle dans l'ordrerandom_tab()
,print_tab()
,sort_tab_1()
, et de nouveauprint_tab()
avec le tableau comme argument.
- Ajouter à la fonction
main()
un appel àtest_13()
. Vérifier la bonne exécution en exécutant plusieurs fois votre programme.
add, commit & push
1.4. Tri selon un autre critère
Un algorithme de tri est en général indépendant de la façon de comparer les éléments, les bibliothèques utilisent diverses solutions selon les langages pour indiquer comment doit être effectuée la comparaison :
- En Python, la fonction
sort()
utilise 2 arguments passés par mot-clé : une fonction permettant d'extraire la clef de comparaison d'un élément et un booléen pour demander un tri en ordre inverse. - En Java, le second argument de la méthode
Arrays.sort()
est un objet dédié à la comparaison (implémentant l'interfaceComparator
). - En C, le dernier argument de la fonction
qsort()
est un pointeur sur une fonction de comparaison. - En C++, le dernier argument de la fonction
sort()
est un objet fonction de comparaison.
- Nous allons commencer par utiliser un pointeur sur fonction. Écrire deux fonctions
less()
etgreater()
qui reçoivent en argument 2 entiers et qui retournenttrue
si le premier est plus petit (pourless()
) ou plus grand (pourgreater()
) que le second,false
sinon.
- Écrire une fonction
sort_tab_2()
qui reprend le code desort_tab_1()
, mais qui reçoit comme second argument un pointeur sur une fonction de comparaison ; le tri se fera en utilisant cette fonction.
C et C++ effectuent des conversions implicites entre fonction et pointeur sur fonction.
- Écrire une fonction
test_14()
qui crée un tableau de 10 entiers et l'initialise avecrandom_tab()
et l'affiche, puis le trie avecsort_tab_2()
et l'affiche à nouveau, une première fois en utilisantless()
, une seconde avecgreater()
.
- Ajouter à la fonction
main()
un appel àtest_14()
. Vérifier la bonne exécution en exécutant plusieurs fois votre programme.
add, commit & push
1.5. Tri avec fonction lambda
Dans les langages fonctionnels, les fonctions sont dites Fonctions de première classe et il n'est pas nécessaire de passer par l'intermédiaire d'un pointeur.
En C++, c'est le type std::function
qui permet de définir des variables contenant des fonctions (ou autres entités qui se comportent comme des fonctions). Comme C++ est un langage fortement typé, le type de la fonction est pris en compte et apparait comme paramètre de généricité de std::function
:
double plus_un( int x ) { return x + 1.0; } void f() { double six{ plus_un( 5 )}; // double( int ) est le type d'une fonction qui prend un int en argument // et retourne un double std::function< double( int ) > int_to_double_f{ plus_un }; // static_cast est utilisé pour rendre explicite la conversion du flottant en entier double sept{ int_to_double_f( static_cast< int >( six ))}; // Une fonction lambda (voir ci-dessous) est une fonction, et peut donc être // utilisée dans une affectation d'une variable ayant le bon type. int_to_double_f = []( int y ) { return y - 1.0; }; // Les crochets indiquent le début de la définition de la fonction lambda (et // remplacent donc le nom de la fonction). Le type de retour est ici automatiquement // déduit, il pourrait être explicitement indiqué en ajoutant -> double avant // l'accolade ouvrante. six = int_to_double_f( static_cast< int >( sept )); }
- Définir une fonction
sort_tab_3()
, identique àsort_tab_2()
sauf que son second argument est de typestd::function
.
Cette solution n'apporte pour l'instant aucun avantage : elle nécessite toujours d'avoir nos fonctions less()
et greater()
.
Une autre facilité offerte par les langages fonctionnels est la possibilité de définir des fonctions sans nom à l'endroit où on en a besoin, on parle de fonction anonyme ou fonction lambda (voir un exemple de définition ci-dessus).
- Écrire une fonction
test_15()
, identique àtest_14()
sauf qu'elle utilisesort_tab_3()
et des fonctions lambda (à la place deless()
et degreater()
) pour faire un tri, croissant puis décroissant, sur la valeur absolue des nombres.
- Ajouter à la fonction
main()
un appel àtest_15()
. Vérifier la bonne exécution en exécutant plusieurs fois votre programme.
add, commit & push
2. Listes (1,5 point)
La programmation fonctionnelle est souvent associée aux listes, en général définies sous la forme : une liste est soit vide, soit constituée d'un élément suivi d'une liste ; contrairement aux tableaux, il n'est pas possible d'accéder à un élément (ou de connaître le nombre d'éléments) en temps constant (ces opérations ont une complexité linéaire), par contre l'ajout d'un élément n'importe où se fait en temps constant (complexité linéaire pour les tableaux).
2.1. Création, affichage
Utiliser le fichier listes.cpp
pour cette partie et la partie avancée.
Pour cet exercice, nous utiliserons des listes simplement chainées (forward_list
) d'entiers.
#include <forward_list> // Type liste unidirectionnelle d'entiers using int_list_t = std::forward_list< int >;
L'ajout d'un élément en tête d'une liste se fait avec la méthode push_front()
:
int_list_t list; // Liste vide list.push_front( 1 ); // Liste avec un élément : ( 1 ) list.push_front( 2 ); // Liste avec deux éléments : ( 2 1 )
Le parcours d'une liste peut se faire avec un for_each :
// Addition de tous les éléments de la liste int somme{ 0 }; for( auto i : list ) somme += i;
Si nécessaire, la documentation complète de cette classe est disponible ici.
- Écrire une fonction
random_list()
, sans argument, qui retourne une liste d'entiers ayant des valeurs aléatoires entre 0 et 99 ; le nombre d'éléments de la liste retournée sera lui-même une valeur aléatoire entre 0 et 9.
On pourrait craindre que le retour par une fonction d'une liste provoque sa copie, opération potentiellement coûteuse.
Cela n'est pas le cas en règle générale, car le compilateur sait optimiser (RVO : Return Value Optimisation) en construisant directement la valeur de retour dans la variable qui reçoit le résultat de la fonction.
Cette fonction random_list()
peut donc retourner une liste vide : il faudra exécuter les différents tests qui suivent plusieurs fois afin d'obtenir au moins une fois une liste vide et ainsi vérifier que votre solution est correcte y compris dans ce cas.
- Écrire une fonction
print_list()
qui reçoit en argument une liste d'entiers passée par référence constante et qui affiche cette liste sur la sortie standard (les éléments dans l'ordre séparés par des espaces, le tout encadré par des parenthèses : voir l'exemple ci-dessous).
- Écrire une fonction
test_21()
qui crée une liste avecrandom_list()
puis l'affiche avecprint_list()
.
- Écrire la fonction
main()
qui appelletest_21()
. Compiler et exécuter votre programme. Le résultat attendu est, par exemple :
*** test_21 ***
( 51 29 48 11 42 65 )
add, commit & push
2.2. Application
- Écrire une fonction
map_iter()
qui reçoit en argument une liste d'entiers passée par référence constante et une fonction de typeint( int )
, et qui retourne par valeur une liste contenant les résultats de l'application de la fonction aux éléments de la liste initiale.
Vous allez vous en apercevoir à l'exécution, ou peut-être que vous l'aurez prévu lors de la réflexion sur la conception de cette fonction : avec une écriture itérative de la fonction, la liste résultat contiendra les éléments dans l'ordre inverse le la liste initiale.
En effet, le premier élément de la liste initiale est (après application de la fonction) inséré en tête de la liste résultat. Le deuxième élément de la liste initiale est aussi inséré en tête de la liste résultat, donc l'ex-premier de la liste résultat devient second et restera le dernier : la liste résultat est donc inversée par rapport à la liste initiale.
Ne chercher pas à corriger ce défaut (cf. version récursive).
- Écrire une fonction
test_22()
qui crée une liste d'entiers aléatoires, l'affiche avecprint_list()
, lui appliquemap_iter()
avec une fonction lambda multipliant par 3 et affiche le résultat obtenu.
- Modifier
main()
pour tester le fonctionnement detest_22()
.
add, commit & push
2.3. Filtrage
- Écrire une fonction
filter_iter()
qui reçoit en argument une liste d'entiers passée par référence constante et une fonction de typebool( int )
(un prédicat), et qui retourne par valeur une liste contenant uniquement les éléments de la liste initiale vérifiant le prédicat. Vous ignorerez là aussi que la liste retournée est inversée.
- Écrire une fonction
test_23()
qui crée une liste d'entiers aléatoires et l'affiche avecprint_list()
, lui appliquemap_iter()
avec une fonction lambda multipliant par 3 et affiche le résultat obtenu, puis appliquefilter_iter()
sur le résultat demap_iter()
afin de ne conserver que les éléments pairs et affiche le résultat ; vérifier le bon fonctionnement en ajoutant l'appel àmain()
.
add, commit & push
2.4. Capture
- Le coefficient de multiplication (
3
) est pour l'instant connu à la compilation. Écrire une fonctiontest_24()
identique àtest_23()
mais qui utilise comme coefficient de multiplication un nombre compris entre 1 et 5 déterminé aléatoirement via un appel àrand()
; ce coefficient doit être calculé, mémorisé dans une variable locale et affiché avant l'appel àmap_iter()
. Vérifier le bon fonctionnement en ajoutant l'appel àmain()
.
Besoin d'aide ?
Sans précaution particulière, le compilateur va vous indiquer une erreur pouvant ressembler à error: variable 'coef' cannot be implicitly captured in a lambda with no capture-default specified
.
Le problème provient du fait que la fonction lambda essaye d'accéder à la valeur d'une variable locale à test_24()
alors qu'elle s'exécute dans le contexte de map_iter()
.
Pour résoudre ce problème, il faut faire en sorte que la fonction lambda capture la valeur de la variable locale à test_24()
, cela se fait, en C++, en indiquant le nom de cette variable entre les crochets dans la définition de la fonction lambda.
Une autre solution serait que la variable contenant le coefficient soit globale ; il est inutile d'expliquer pourquoi une telle solution n'est pas satisfaisante.
add, commit & push
2.5. Réduction
- Écrire une fonction
reduce()
qui reçoit en argument une liste d'entiers passée par référence constante, un entier et une fonction de typeint( int, int )
; la fonction reçue est appliquée à tous les éléments de la liste de la manière suivante : son premier argument est l'entier reçu en argument parreduce()
la première fois, le résultat du précédent appel les autres fois ; son second argument est l'élément courant de la liste ;reduce()
retourne le résultat obtenu par le dernier appel de la fonction.
- Écrire une fonction
test_25()
qui crée une liste d'entiers aléatoires, l'affiche avecprint_list()
, et effectue un appel àreduce()
afin de calculer le plus petit des éléments de la liste, et un autre pour le plus grand, vérifier le bon fonctionnement en affichant ces valeurs.
Quelle valeur initiale pour le second argument de reduce()
?
Pour la recherche du plus petit élément, il faut commencer avec la plus grande valeur d'un entier. Vous pouvez l'obtenir via le composant limites numériques de la bibliothèque standard.
add, commit & push
3. Récursion (1 point)
3.1. Récursion pour reduce()
reduce()
peut-être écrite de manière récursive si on sait déterminer la fin de la récursion, ce qui revient ici à savoir si on est arrivé à la fin de la liste. L'utilisation d'un foreach ne permet pas cette détection, il faut donc utiliser les itérateurs qui sont sous-jacents.
Un itérateur permet de parcourir un conteneur et d'accéder aux éléments contenus. Tout conteneur de la bibliothèque standard C++ doit avoir une méthode begin()
qui retourne un itérateur sur le début du conteneur et une méthode end()
qui retourne un itérateur sur la fin du conteneur1. Voici un exemple qui va multiplier par 3 tous les éléments d'une forward_list
l
:
// Le typage par inférence pourrait être utilisé ici pour la variable it for( std::forward_list< int >::iterator it{ l.begin() }; it != l.end(); ++it ) { *it = *it * 3; }
On voit que l'opérateur *
permet d'accéder en lecture et en écriture à l'élément courant alors que l'opérateur ++
fait progresser l'itérateur vers l'élément suivant.
Avec notre liste d'entiers, nous allons utiliser des itérateurs constants (ne permettant pas la modification de l'élément courant) :
using int_list_iter_t = int_list_t::const_iterator;
Un conteneur fournit des itérateurs constants via les méthodes cbegin()
et cend()
.
- Écrire une fonction
fold_left_aux()
de manière récursive ; elle effectue le même calcul quereduce()
(l'appel récursif doit se faire après l'application de la fonction reçue en argument), mais ses arguments sont un itérateur constant sur l'élément courant, un itérateur constant de fin, un entier et la fonction de réduction.
L'ordre d'évaluation des arguments d'une fonction C++ n'est pas imposée par le langage ; un programme qui peut donner des résultats différents en fonction de cet ordre d'évaluation, en particulier à cause d'effets de bords sur les arguments, est réputé incorrect :
int add( int a, int b ) { return a + b; } int bad() { int i{ 5 }; return add( i++, i ); // Peut retourner 10 ou 11 }
Pour une écriture concise mais correcte lors d'utilisation d'itérateurs, il est possible d'utiliser std::next()
.
- Écrire une fonction
fold_left()
2 avec la même signature quereduce()
et qui se contente d'appelerfold_left_aux()
avec les bons arguments. - Définir une fonction
test_31()
, identique àtest_25()
mais qui remplace l'appel àreduce()
par celui àfold_left()
.
add, commit & push
3.2. Récursion pour map()
et filter()
En utilisant le même principe (fonction auxiliaire et itérateurs), il est possible d'écrire map()
et filter()
de manière récursive, ce qui a aussi comme conséquence que la liste résultat n'ait pas les éléments inversés (à condition de faire les appels dans le bon ordre !).
- Écrire la fonction
map_aux()
qui reçoit en argument un itérateur constant sur l'élément courant, un itérateur constant de fin et la fonction à appliquer. Définirmap()
pour qu'elle appellemap_aux()
avec les bons arguments.
Les habitués de la programmation fonctionnelle ont l'habitude d'utiliser une approche dite à accumulateur : la liste résultat (pour cet exemple de map_aux()
) est un (dernier en général) argument de la fonction, le résultat y est construit par les différents appels récursifs. L'utilisation d'une telle approche permet souvent d'écrire la fonction avec une récursivité terminale qui est mieux optimisée par le compilateur (transformation en boucle).
// Récursivité non terminale unsigned fac1( unsigned n ) { return n == 0 ? 1 : n * fac1( n - 1 ); } // Fonction auxiliaire avec récursivité terminale void fac2aux( unsigned n, unsigned & res ) { if( n != 0 ) fac2aux( n - 1, res *= n ); } unsigned fac2( unsigned n ) { unsigned res{ 1 }; fac2aux( n, res ); return res; }
L'utilisation de cette approche avec récursivité terminale pour map_aux()
a le même inconvénient que la version itérative : la liste résultat est inversée.
On pourrait aussi penser que cette approche par accumulateur, mais sans récursivité terminale, de map_aux()
permet d'éviter que cette fonction retourne une liste, ce qui peut sembler peu performant.
Là aussi, les mécanismes d'optimisation en C++ (RVO déjà mentionné, mais aussi constructeur et opérateur d'affectation par déplacement) évitent ces copies superflues, et la version avec accumulateur est, certes, plus performante que la version sans, mais l'écart n'est pas si important (facteur 2 environ).
- Écrire la fonction
filter_aux()
qui reçoit en argument un itérateur constant sur l'élément courant, un itérateur constant de fin et le prédicat. Définirfilter()
pour qu'elle appellefilter_aux()
avec les bons arguments.
- Définir une fonction
test_32()
, identique àtest_24()
mais qui remplace les appels àmap_iter()
etfilter_iter()
par ceux àmap()
etfilter()
.
add, commit & push
4. Partie avancée (0,5 point)
4.1. Application partielle
L'exemple de l'application (map()
) a été réalisé en utilisant l'opérateur de multiplication dans une fonction lambda. Il est bien sûr possible d'utiliser, à la place de cette dernière, n'importe quelle autre fonction ayant le bon type.
Il existe par ailleurs, dans la bibliothèque standard C++, une définition sous forme fonctionnelle de la multiplication (std::multiplies
) et des autres opérateurs (techniquement, ce sont des objets fonctions).
Du fait du typage statique fort en C++, la définition fonctionnelle de la multiplication (et des autres opérateurs) sous la forme d'objet fonction prend en compte le type des opérandes. Voici un exemple d'utilisation :
void g() { // Création d'un objet fonction std::multiplies< double > mul_double; // Il est bien sûr possible de stocker cet objet fonction dans une // variable de type std::function std::function< double( double, double ) > f_double{ mul_double }; // Utilisation de l'objet fonction double res{ mul_double( 3.0, -7 )}; // Utilisation via la variable std::function res = f_double( 3.0, -7 ); }
Cependant, cette fonction std::multiplies()
a besoin de 2 arguments, alors que, dans notre cas, l'un des arguments est fixé. Il est en général possible, dans les langages fonctionnels, de créer une nouvelle fonction en fixant un ou plusieurs arguments d'une fonction existante, cela s'appelle l'application partielle.
- Définir une fonction
test_41()
, identique àtest_32()
mais qui utilise comme second argument demap()
une fonction obtenue par application partielle surstd::multiplies()
. Vérifier le bon fonctionnement.
Application partielle en C++ ?
Le plus simple est de définir une fonction lambda qui reçoit un argument et qui effectue la multiplication via un objet instance de std::multiplies
avec comme arguments celui reçu par la fonction lambda et la variable coef
capturée par valeur.
Une autre solution est d'utiliser bind()
.
add, commit & push
4.2. Réduction inversée
- Écrire une fonction
fold_right()
qui récurse avant d'appliquer la fonction reçue en argument. - Écrire une fonction
test_42()
qui montre sur un exemple quefold_left()
etfold_right()
ne donnent pas toujours le même résultat pour une même fonction utilisée pour faire la réduction.
add, commit & push