Tous droits réservés

Des fonctions somme toute lambdas

Vous rappelez-vous du chapitre sur les algorithmes ? Nous avons découvert que nous pouvons personnaliser, dans une certaine mesure, leur comportement. Mais nous n’avions pas d’autres choix que d’utiliser les prédicats déjà fournis par la bibliothèque standard. Ce chapitre va corriger ça.

Nous allons découvrir comment écrire nos propres prédicats en utilisant les lambdas.

Une lambda, c'est quoi ?

Une lambda est une fonction, potentiellement anonyme, destinée à être utilisée pour des opérations locales. Détaillons ces différentes caractéristiques.

  • Une lambda est une fonction, c’est-à-dire un regroupement de plusieurs instructions, comme nous l’avons vu au chapitre dédié. Rien de bien nouveau.
  • Une lambda peut être anonyme. Contrairement aux fonctions classiques qui portent obligatoirement un nom, un identificateur, une lambda peut être utilisé sans qu’on lui donne de nom.
  • Une lambda s’utilise pour des opérations locales. Cela signifie qu’une lambda peut être directement écrite dans une fonction ou un algorithme, au plus près de l’endroit où elle est utilisée, au contraire d’une fonction qui est à un endroit bien défini, à part.
Les lambdas sont un concept universel

Si les syntaxes que nous allons découvrir sont celles de C++, les lambdas sont répandues et utilisées par de nombreux langages, comme C#, Haskell ou encore Python. Ce concept vient de la programmation fonctionnelle, une autre façon de programmer et dont C++ a repris plusieurs éléments.

Pourquoi a-t-on besoin des lambdas ?

En effet, c’est une question à se poser. Après tout, nous avons déjà les fonctions et donc rien ne nous empêche d’utiliser les fonctions pour personnaliser nos algorithmes. C’est tout à fait possible. Seulement voilà, les fonctions présentent deux inconvénients.

  • D’abord, le compilateur n’arrive pas à optimiser aussi bien que si on utilise les lambdas. Ceci est l’argument mineur.
  • L’argument majeur, c’est qu’on crée une fonction visible et utilisable par tout le monde, alors qu’elle n’a pour objectif que d’être utilisée localement. On dit que la fonction a une portée globale, alors qu’il vaudrait mieux qu’elle ait une portée locale.

Les lambdas sont donc une solution à ces deux inconvénients. Bien entendu, C++ vous laisse libre de vos choix. Si vous voulez utiliser des fonctions là où des lambdas sont plus adaptées, rien ne vous l’interdit. Mais s’il y a des outils plus efficaces, autant les utiliser. :)

Syntaxe

La syntaxe pour déclarer une lambda a des points communs avec celles des fonctions classiques, mais aussi quelques différences. Examinons la ensemble.

[zone de capture](paramètres de la lambda) -> type de retour { instructions }
  • La zone de capture : par défaut, une lambda est en totale isolation et ne peut manipuler aucune variable de l’extérieur. Grace à cette zone, la lambda va pouvoir modifier des variables extérieures. Nous allons y revenir.
  • Les paramètres de la lambda : exactement comme pour les fonctions, les paramètres de la lambda peuvent être présents ou non, avec utilisation de références et/ou const possible.
  • Le type de retour : encore un élément qui nous est familier. Il est écrit après la flèche ->. Il peut être omis dans quelques cas, mais nous allons l’écrire à chaque fois dans une volonté d’écrire un code clair et explicite.
  • Les instructions : je ne vous ferai pas l’affront de détailler. :)

Quelques exemples simples

Ainsi, l’exemple le plus simple et minimaliste que je puisse vous donner serait celui-ci.

int main()
{
    []{};
    // Ou version explicite :
    []() -> void {};

    return 0;
}

Personnalisons la lambda pour qu’elle prenne un message à afficher.

#include <iostream>
#include <string>

int main()
{
    [](std::string const & message) -> void { std::cout << "Message reçu : " << message << std::endl; };
    return 0;
}

Maintenant, que diriez-vous de la tester, avec un algorithme, pour en voir tout l’intérêt. Disons que l’on veut parcourir un tableau de chaînes de caractères.

J’ai formaté le code pour qu’il soit plus lisible, notamment en allant à la ligne pour les instructions de la lambda. Bien évidemment, vous n’êtes pas obligés de faire comme ça, mais sachez que c’est une écriture lisible et répandue.

#include <algorithm>
#include <iostream>
#include <string>
#include <vector>

int main()
{
    std::vector<std::string> const chaines { "Un mot", "Autre chose", "Du blabla", "Du texe", "Des lettres" };

    std::for_each(std::begin(chaines), std::end(chaines), [](std::string const & message) -> void
    {
        std::cout << "Message reçu : " << message << std::endl;
    });

    return 0;
}

L’exemple précédent montre une lambda qui ne prend qu’un paramètre. En effet, std::for_each attend un prédicat unaire, c’est-à-dire que l’algorithme travaille sur un seul élément à la fois. La lambda ne prend donc qu’un seul paramètre.

L’exemple suivant reprend le tri par ordre décroissant, qu’on a déjà vu avec des foncteurs, mais cette fois avec une lambda. Notez que celle-ci prend deux paramètres. C’est parce que std::sort attend un prédicat binaire, parce que l’algorithme manipule et compare deux éléments en même temps.

#include <algorithm>
#include <array>
#include <iostream>

int main()
{
    std::array<double, 4u> tableau { 1, 3.1415, 2.1878, 1.5 };

    // Tri dans l'ordre décroissant.
    std::sort(std::begin(tableau), std::end(tableau), [](double a, double b) -> bool
    {
        return a > b;
    });

    for (auto nombre : tableau)
    {
        std::cout << nombre << std::endl;
    }

    return 0;
}

Continuons avec l’algorithme std::find_if. Celui-ci fonctionne exactement comme std::find, mais prend en plus un prédicat pour rechercher tout élément le vérifiant. Que diriez-vous, du coup, de chercher tous les éléments impairs d’une collection d’entiers ? Comme on regarde élément par élément, il s’agit d’un prédicat unaire.

#include <algorithm>
#include <iostream>
#include <vector>

int main()
{
    std::vector<int> const nombres { 2, 4, 6, 8, 9, 10, 11, 13, 16 };
    auto iterateur { std::begin(nombres) };

    do
    {
        iterateur = std::find_if(iterateur, std::end(nombres), [](int nombre) -> bool
        {
            return nombre % 2 != 0;
        });

        if (iterateur != std::end(nombres))
        {
            std::cout << "Nombre impair : " << *iterateur << std::endl;
            // On n'oublie pas d'incrémenter, sinon on bouclera indéfiniment.
            ++iterateur;
        }

    } while (iterateur != std::end(nombres));

    return 0;
}

Avez-vous noté, dans ces exemples, l’élégance des lambdas ? On l’a juste sous les yeux, on voit directement ce que fait l’algorithme. Et en plus, on ne pollue pas l’espace global avec des fonctions à usage unique.

Exercices

Vérifier si un nombre est négatif

Nous avons vu, dans le chapitre consacré aux algorithmes, std::all_of, qui permet de vérifier que tous les éléments respectent un prédicat. Il y a un algorithme semblable, std::any_of, qui s’utilise pareil mais regarde si au moins un élément vérifier le prédicat.

Le but est donc de dire si une collection d’entiers est entièrement positive ou non.

Correction vérifier si un nombre est négatif
#include <algorithm>
#include <iostream>
#include <vector>

int main()
{
    std::vector<int> const nombres { 1, 2, 3, 4, 5, 6, -7 };

    if (std::any_of(std::cbegin(nombres), std::cend(nombres), [](int nombre) -> bool { return nombre < 0; }))
    {
        std::cout << "Au moins un nombre est négatif." << std::endl;
    }
    else
    {
        std::cout << "Tous les nombres sont positifs." << std::endl;
    }

    return 0;
}

Tri par valeur absolue

Rien de très compliqué, mais qui diriez-vous de trier un tableau dans l’ordre croissant en utilisant la valeur absolue des nombres ? Cela se fait en utilisant la fonction std::abs, disponible dans le fichier d’en-tête <cmath>.

Correction tri par valeur absolue
#include <algorithm>
#include <cmath>
#include <iostream>
#include <string>
#include <vector>

int main()
{
    std::vector<int> nombres { -8, 5, 97, -789, 0, 87, -88, -8555, 10000, 3 };
    std::sort(std::begin(nombres), std::end(nombres), [](int a, int b) -> bool
    {
        return std::abs(a) < std::abs(b);
    });

    // Affichons pour vérifier.
    for (int nombre : nombres)
    {
        std::cout << nombre << std::endl;
    }

    return 0;
}

Ne soyez par surpris par la sortie du programme. Rappelez-vous que nous avons trié le résultat en utilisant la valeur absolue, mais que l’affichage nous montre les éléments triés, mais originaux. ;)

On va stocker ça où ?

Imaginons maintenant que l’on veuille appliquer une lambda plusieurs fois. Va-t-on copier-coller le code ? Vous connaissez déjà la réponse. Il suffit de créer une variable, tout simplement, comme nous l’avons fait jusque-là. La façon la plus simple est d’utiliser auto.

Syntaxe et question de goût

On peut, comme le démontre le code, utiliser la syntaxe de C++ moderne, à base d’accolades {}, ou bien utiliser l’initialisation héritée du C à base de égal =. Tout est une question de goût.

J’ai choisi la deuxième syntaxe car je la trouve plus lisible, mais vous restez libres de vos choix.

#include <algorithm>
#include <iostream>
#include <vector>

int main()
{
    auto lambda_avec_accolades { [](int nombre) -> void { std::cout << "Nombre reçu : " << nombre << std::endl; } };
    auto lambda_avec_egal = [](int nombre) -> void { std::cout << "Nombre reçu : " << nombre << std::endl; };

    // S'utilise comme une fonction classique, de façon totalement transparente.
    lambda_avec_accolades(5);

    std::vector<int> const nombres { 1, 2, 3, 8, 94, 548 };
    // Ou bien dans un algorithme, aucun problème.
    std::for_each(std::cbegin(nombres), std::cend(nombres), lambda_avec_egal);

    return 0;
}

Stocker une lambda permet de garder les avantages de localité tout en évitant de répéter du code, dans le cas où elle est utilisée plusieurs fois.

Par curiosité, quel est le type explicite d’une lambda ?

C’est un type complexe qui n’est pas spécifié par la norme. Le compilateur transforme la lambda en autre chose, et cet « autre chose » dépend donc du compilateur que vous utilisez. Les lambdas nous donnent donc un excellent exemple de l’intérêt et de la simplicité apportée par auto. :)

Paramètres génériques

Tout comme il est possible de laisser le compilateur deviner le type de retour, on peut lui laisser le soin de déduire les paramètres de la lambda, en utilisant auto à la place du type explicite des paramètres. Bien entendu, le fait de l’utiliser pour certains paramètres ne nous empêche absolument pas d’avoir des paramètres au type explicitement écrit.

#include <iostream>
#include <string>

int main()
{
    // Le premier paramètre est générique, mais le deuxième est explicitement marqué comme étant une chaîne.
    auto lambda = [](auto const & parametre, std::string const & message) -> void
    {
        std::cout << "Paramètre générique reçu : " << parametre << std::endl;
        std::cout << "Message reçu : " << message << std::endl;
    };

    lambda(1, "Test avec un entier.");
    lambda("str", "Test avec une chaîne de caractères.");
    lambda(3.1415, "Test avec un flottant.");

    return 0;
}

Du coup, grâce à ce nouveau pouvoir que nous avons, nous sommes en mesure d’utiliser un même lambda pour afficher deux collections de types différents.

#include <algorithm>
#include <iostream>
#include <string>
#include <vector>

int main()
{
    auto enumeration = [](auto const & parametre) -> void
    {
        std::cout << "Paramètre reçu : " << parametre << std::endl;
    };

    // On affiche une collection de std::string.
    std::vector<std::string> const chaines { "Un mot", "Autre chose", "Du blabla", "Du texe", "Des lettres" };
    std::for_each(std::begin(chaines), std::end(chaines), enumeration);

    std::cout << std::endl;

    // Pas de problème non plus avec des entiers.
    std::vector<int> const nombres { 1, 5, -7, 67, -4454, 7888 };
    std::for_each(std::begin(nombres), std::end(nombres), enumeration);

    return 0;
}

[T.P] Gérer les erreurs d'entrée — Partie VI

Avec les connaissances que vous avez, nous pouvons reprendre le code où nous l’avons laissé et commencer à réduire la duplication en utilisant une lambda. Faites-moi une entrée sécurisée mais sans dupliquer le code. Bonne chance. :)

#include <iostream>

int main()
{
    int jour { 0 };
    std::cout << "Quel jour es-tu né ? ";
    // Entrée.

    int mois { 0 };
    std::cout << "Quel mois ? ";
    // Entrée.

    int annee { 0 };
    std::cout << "Quelle année ? ";
    // Entrée.

    double taille { 0.0 };
    std::cout << "Quelle taille ? ";
    // Entree.

    std::cout << "Tu es né le " << jour << "/" << mois << "/" << annee << " et tu mesures " << taille << "m." << std::endl;
    return 0;
}
Correction T.P partie VI
#include <iostream>
#include <limits>
#include <stdexcept>

int main()
{
    auto entree_securisee = [](auto & variable) -> void
    {
        while (!(std::cin >> variable))
        {
            if (std::cin.eof())
            {
                throw std::runtime_error("Le flux a été fermé !");
            }
            else if (std::cin.fail())
            {
                std::cout << "Entrée invalide. Recommence." << std::endl;
                std::cin.clear();
                std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
            }
        }
    };

    int jour { 0 };
    std::cout << "Quel jour es-tu né ? ";
    entree_securisee(jour);

    int mois { 0 };
    std::cout << "Quel mois ? ";
    entree_securisee(mois);

    int annee { 0 };
    std::cout << "Quelle année ? ";
    entree_securisee(annee);

    double taille { 0.0 };
    std::cout << "Quelle taille ? ";
    entree_securisee(taille);

    std::cout << "Tu es né le " << jour << "/" << mois << "/" << annee << " et tu mesures " << taille << "m." << std::endl;
    return 0;
}

Le code est déjà mieux qu’avant. Il reste cependant un problème : la lambda est locale à la fonction, donc si nous voulons utiliser notre fonction sécurisée à plein d’endroits différents, il va falloir trouver une autre solution. Nous verrons cela très bientôt.

[T.P] Gérer les erreurs d'entrée — Partie VII

Eh oui, nous allons encore enchaîner deux T.P d’affilée ! Je l’ai découpé pour que vous puissiez l’aborder avec plus de facilité. :)

Relisons le chapitre sur les erreurs. Nous y avions vu une méthode pour non seulement vérifier que l’entrée ne faisait pas planter std::cin, mais en plus que la valeur donnée avait un sens. Nous pouvions ainsi vérifier qu’un jour était compris entre 1 et 31, ou qu’un mois est inclus entre 1 et 12. Mais pour cela, nous avions dû extraire notre code de la fonction entree_securisee.

Cependant, nous venons de voir que les lambdas acceptent des paramètres génériques avec auto. Alors, soyons fous, pourquoi ne pas essayer de passer une autre lambda ?

#include <iostream>

int main()
{
    auto lambda = [](auto parametre, auto fonction) -> void
    {
        fonction(parametre);
    };

    lambda(42, [](int entier) -> void { std::cout << entier << std::endl; });
    return 0;
}

Ça marche parfaitement ! Et si nous utilisions ce concept puissant en combinaison avec notre T.P précédent ? On pourrait ainsi vérifier que le mois et le jour sont corrects, ou bien même que la personne est née après 1900. Vous avez à présent tous les indices qu’il vous faut pour résoudre ce cas. Bonne chance.

Correction T.P partie VII
#include <iostream>
#include <limits>

int main()
{
    auto entree_securisee = [](auto & variable, auto predicat) -> void
    {
        while (!(std::cin >> variable) || !predicat(variable))
        {
            if (std::cin.eof())
            {
                throw std::runtime_error("Le flux a été fermé !");
            }
            else if (std::cin.fail())
            {
                std::cout << "Entrée invalide. Recommence." << std::endl;
                std::cin.clear();
                std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
            }
            else
            {
                std::cout << "Le prédicat n'est pas respecté !" << std::endl;
            }
        }
    };

    int jour { 0 };
    std::cout << "Quel jour es-tu né ? ";
    entree_securisee(jour, [](int jour) -> bool { return jour > 0 && jour <= 31; });

    int mois { 0 };
    std::cout << "Quel mois ? ";
    entree_securisee(mois, [](int mois) -> bool { return mois > 0 && mois <= 12; });

    int annee { 0 };
    std::cout << "Quelle année ? ";
    entree_securisee(annee, [](int annee) -> bool { return annee > 1900; });

    double taille { 0.0 };
    std::cout << "Quelle taille ? ";
    entree_securisee(taille, [](double taille) -> bool { return taille > 0.0; });

    std::cout << "Tu es né le " << jour << "/" << mois << "/" << annee << " et tu mesures " << taille << "m." << std::endl;
    return 0;
}

Même si le problème soulevé dans le T.P précédent, à savoir qu’on ne peut pas utiliser la lambda ailleurs, reste vrai, notez comme nous avons amélioré notre fonction pour lui faire vérifier que non seulement la valeur est du bon type, mais qu’elle est correcte.

Capture en cours…

Capture par valeur

Abordons maintenant cette fameuse zone de capture. Par défaut, une lambda est en totale isolation du reste du code. Elle ne peut pas, par exemple, accéder aux variables de la fonction dans laquelle elle est écrite… sauf si on utilise la zone de capture pour dire à quels objets la lambda peut accéder. On peut bien sûr en capturer plusieurs, sans problème.

#include <iostream>

int main()
{
    bool const un_booleen { true };
    int const un_entier { 42 };
    double const un_reel { 3.1415 };

    auto lambda = [un_booleen, un_entier, un_reel]() -> void
    {
        std::cout << "Le booléen vaut " << std::boolalpha << un_booleen << "." << std::endl;
        std::cout << "L'entier vaut " << un_entier << "." << std::endl;
        std::cout << "Le réel vaut " << un_reel << "." << std::endl;
    };

    lambda();

    return 0;
}

Remarquez que si l’on supprime un_booleen de la zone de capture, le code ne compile plus. On parle donc de zone de capture car la lambda a capturé notre variable, afin de l’utiliser. Cette capture est dite par valeur, ou par copie, car chaque variable est copiée.

Pour être exact

Il est possible, notamment si vous utilisez GCC ou MinGW (avec Qt Creator, entre autre) que vous avez deux warnings comme ceux ci-dessous.

warning: lambda capture 'un_booleen' is not required to be capture for this use. [-Wunused-lambda-capture]
warning: lambda capture 'un_entier' is not required to be capture for this use. [-Wunused-lambda-capture]

Selon la norme, il y a effectivement certains cas où l’on n’est pas obligé de capturer une variable pour l’utiliser. Cependant, ces cas ne sont pas très nombreux, difficiles à expliquer à ce stade de votre apprentissage et présents surtout par volonté de compatibilité avec l’ancienne norme C++03.

Visual Studio 2019 ne respecte pas la norme sur ce point et donc nous sommes obligés de capturer toutes les variables que nous voulons utiliser, si nous voulons que notre code compile avec. Voilà pourquoi je vous invite, très exceptionnellement, à ignorer le warning et à utiliser les mêmes codes que dans ce cours.

Modifier une variable capturée

Reprenons le code précédent et modifions-le pour ne garder qu’un entier modifiable (sans const donc). Capturez-le et essayer de le modifier. Vous allez obtenir une belle erreur, et je l’ai fait exprès. :diable:

Je ne comprends pas pourquoi je n’ai pas le droit de les modifier. Si c’est une capture par valeur, alors je manipule des copies bien distinctes des objets originaux, il ne devrait pas y avoir de problème. Pourquoi ?

Il y a plusieurs réponses à cette question. L’une est apportée par Herb Sutter, un grand programmeur, très investi dans C++ et l’écriture de sa norme.

Le fait qu’une variable capturée par copie ne soit pas modifiable semble avoir été ajouté parce qu’un utilisateur peut ne pas se rendre compte qu’il modifie une copie et non l’original.

Herb Sutter

Une autre sera détaillées plus tard dans le cours, car il nous manque encore des notions pour aborder ce qui se passe sous le capot.

Retenez qu’on ne peut pas, par défaut, modifier une variable capturée par copie. Heureusement, il suffit d’un simple mot-clef, mutable, placé juste après la liste des arguments et avant le type de retour, pour rendre cela possible.

#include <iostream>

int main()
{
    int un_entier { 42 };

    // Le mot-clef 'mutable' se place après les paramètres de la lambda et avant le type de retour.
    auto lambda = [un_entier]() mutable -> void
    {
        un_entier = 0;
        std::cout << "L'entier vaut " << un_entier << " dans la lambda." << std::endl;
    };

    lambda();
    std::cout << "L'entier vaut " << un_entier << " dans main." << std::endl;

    return 0;
}

Comme vous le montre le résultat de ce code, la modification est locale à la lambda, exactement comme avec les fonctions. Mais ce n’est pas le seul mode de capture existant, comme vous vous en doutez peut-être.

Capture par référence

Le principe est exactement le même, sauf que cette fois, on a affaire directement à la variable capturée et non à sa copie et pour ce faire, on rajoute l’esperluette & devant. Cela permet, comme pour les paramètres, d’éviter une copie potentiellement lourde, ou bien de sauvegarder les modifications faites à la variable.

#include <algorithm>
#include <iostream>
#include <vector>

int main()
{
    std::vector<int> const nombres { 8, 7, 5, 4, 31, 98, 2, 77, 648 };

    int const diviseur { 2 };
    int somme { 0 };

    // Il n'y a aucun problème à mélanger les captures par valeur et par référence.
    std::for_each(std::cbegin(nombres), std::cend(nombres), [diviseur, &somme](int element) -> void
    {
        if (element % diviseur == 0)
        {
            somme += element;
        }
    });

    std::cout << "La somme de tous les éléments divisibles par " << diviseur << " vaut " << somme << "." << std::endl;
    return 0;
}

Notez qu’on peut capturer certaines variables par référence, d’autres par valeur. C’est exactement comme pour les paramètres d’une fonction, donc cela ne doit pas vous surprendre. :)


En résumé

  • Les lambdas sont des fonctions anonymes, destinées à être utilisées localement.
  • Elles réduisent la taille du code, le rendent plus lisible et évitent de créer des fonctions à portée globale alors qu’elles sont à usage local.
  • Les paramètres peuvent être explicitement typés ou automatiquement déduit en utilisant auto.
  • La véritable nouveauté des lambdas, par rapport aux fonctions, est la zone de capture, qui permet de manipuler, par référence et/ou valeur des objets de la portée englobante.
  • On peut capturer explicitement ou bien certaines variables, ou bien capturer toutes celles de la portée englobante.