Causerie++ - Episode 6

[C++20] Ranges

Salut les agrumes ! Bienvenue dans ce nouvel épisode de Causerie++ ! Pour l’épisode précédent, c’est ici que ça se passe !

Dans cet épisode, nous allons continuer notre petit tour d’horizon du C++20 avec les Ranges1 !


  1. Si vous avez une idée de traduction, je suis preneur. ;)

[C++20] Ranges

Encore une fonctionnalité très alléchante. Les ranges sont un moyen de gagner encore en abstraction lors d’utilisation d’algorithmes sur les collections, en particulier les algorithmes de la STL.

En effet, jusqu’à maintenant, on donnait deux itérateurs pour délimiter la collection/sous-collection que l’on voulait traiter. Les ranges encapsulent cela dans un concept de plus haut niveau, ce qui a plusieurs intérêts.

Une interface plus naturelle

Nos appels d’algorithmes sur toute la collection seront bien plus naturels grâce aux ranges. En effet, les collections se comportant elles-mêmes comme des ranges, on pourra écrire :

std::vector<std::string> names { "Mehdi", "Alice", "Bob", "Eve", "Oscar", "Alexander", "John" };

std::unordered_map nicknames
{
    { "Alexander", "Sacha" }
    , { "Mehdi", "Mehdidou" }
    , { "Bob", "Bobby" }
    , { "Johnny" }
};

auto has_nickname = [&nicknames](std::string const& name){ return nicknames.find(name) != std::end(nicknames); };
bool all_have_nicknames = std::ranges::all_of(names, has_nickname);
bool one_has_nickname = std::ranges::any_of(names, has_nickname);

Plutôt que :

std::vector<std::string> names { "Mehdi", "Alice", "Bob", "Eve", "Oscar", "Alexander", "John" };

std::unordered_map nicknames
{
    { "Alexander", "Sacha" }
    , { "Mehdi", "Mehdidou" }
    , { "Bob", "Bobby" }
    , { "Johnny" }
};

auto has_nickname = [&nicknames](std::string const& name){ return nicknames.find(name) != std::end(nicknames); };
bool all_have_nicknames = std::all_of(std::begin(names), std::end(names), has_nickname);
bool one_has_nickname = std::any_of(std::begin(names), std::end(names), has_nickname);

En termes d’expressivité, on est à un tout autre niveau. Avec les version « Range » des algorithmes, le code est beaucoup moins verbeux, et il se lit presque comme du langage naturel !

Le concept de Vue

Les Ranges viennent avec un nouveau concept très intéressant, le concept de Vue, ou View en anglais. Les Vues sont des objets qui se comportent aussi comme des Ranges, mais elles sont copiables/assignables/déplaçables en temps constant. Grosso modo, c’est l’équivalent « Ranges » d’une paire d’itérateurs begin/end, finalement. Encore une fois, on gagne en abstraction, pour la même raison que la version « Range » des algorithmes.

On peut faire de très jolies choses avec les vues, comme itérer en partant de la fin d’un « Range », ou appliquer une transformation aux éléments pendant la traversée du conteneur, par exemple. Et ce, sans jamais utiliser des itérateurs.

La vraie puissance des Vues : les Range adaptors

On peut combiner un Range (conteneur ou Vue par exemple) avec une Vue grâce aux Range adaptors, et c’est là que l’on peut exploiter la toute puissance des Vues. On va directement le montrer sur un exemple.

Partons de l’exemple des surnoms du code précédent, et supposons que l’on veuille afficher les surnoms de tous les noms de la liste qui possèdent un surnom.

Sans utiliser d’algorithmes, on peut faire ça :

for(auto const& name : names)
{
    auto nickname = nicknames.find(name);
    if(nickname != std::end(nicknames)
        std::cout << nickname->second << ' ';
}

Avec des algorithmes mais sans Ranges, on obtient ceci :

std::vector<std::string> output {};

auto has_nickname = [&nicknames](std::string const& name){ return nicknames.find(name) != std::end(nicknames); };
std::copy_if(std::begin(names), std::end(names), std::back_inserter(output), has_nickname);

auto get_nickname = [&nicknames](std::string const& name){ return nicknames.find(name)->second; };
std::transform(std::begin(output), std::end(output), std::begin(output), get_nickname);

std::for_each(std::begin(output), std::end(output), [](std::string const& nickname){ std::cout << nickname << ' '; });

On augmente déjà le niveau d’abstraction, mais c’est tout de même très verbeux.

Et enfin, en utilisant à fond les Ranges et les Vues, on a tout simplement :

auto has_nickname = [&nicknames](std::string const& name){ return nicknames.find(name) != std::end(nicknames); };
auto get_nickname = [&nicknames](std::string const& name){ return nicknames.find(name)->second; };
auto display = [](std::string const& s){ std::cout << s << ' '; };

std::ranges::for_each(names | std::view::filter(has_nickname) | std::view::transform(get_nickname), display);

Je trouve ce code incroyablement expressif. On va droit au but, on voit clairement ce qu’on veut faire, ça se lit très naturellement. Notez aussi qu’avec les Ranges, il est beaucoup plus facile d’appliquer des algorithmes à la chaîne.

Peut-être avez-vous remarqué que les deux dernières versions sont moins performantes que la première, car on fait deux fois la recherche dans la table. C’est dommage de devoir faire un sacrifice de performance pour gagner en expressivité.

Si cela vous intéresse, il existe une alternative aux Ranges qui permet de garder un niveau d’abstraction élevé sans faire cette concession : les smart output iterators. Il existe plusieurs articles de Fluent C++ qui abordent ce sujet très intéressant : Smart Output Iterators, The TPOIASI, How Smart Output Iterators Avoid the TPOIASI.


Le mot de la fin

Je suis très heureux que les Ranges aient été ajoutés à la bibliothèque standard, c’est un plus énorme en termes d’abstraction. Pour revenir sur le sujet de la semaine dernière, il existe des Concepts standards spécialement dédiés aux Ranges ; pensez à les utiliser si vous voulez écrire du code générique avec les Ranges !


Et voilà pour cet épisode. Dîtes moi ce que vous pensez de cette fonctionnalité fort sympathique !

A dans deux semaines ! <3

8 commentaires

Pour comparer, la version finale de ton code en OCaml:

let get_nickname name = StringMap.find_opt nicknames name
let display s = Printf.printf "%s " s

let () =
  names
  |> Seq.filter_map get_nickname
  |> Seq.iter display

find_opt renvoie un élément de type string option, qui indique soit qu’aucune valeur n’a été trouvée (None), soit qu’une valeur s a été trouvée (Some s). filter_map est une fonction générique qui itère sur une collection, applique une transformation qui peut échouer (renvoie une option), et garde dans la collection finale seulement les valeurs des appels qui ont réussit; son type est

val filter_map : (´a -> ´b option) -> ´a Seq.t -> ´b Seq.t

La fonction iter prend en paramètre une fonction qui attend une entrée et ne renvoie rien (se contente d’effectuer des effets de bord), et l’applique à tous les éléments d’une collection (son type est ('a -> unit) -> 'a Seq.t -> unit. Enfin, |> est juste un opérateur d’application de fonction à l’envers: x |> f est défini comme f(x).

On peut faire la même chose en Haskell, en Ruby, etc. Un aspect que je trouve intéressant par rapport à la version C++, c’est de voir à quel point les annotations de type et de capture obligatoires sur les lambdas de C++ alourdissent le code. C’est un style très naturel de composer ensemble plein de petites fonctions anonymes en utilisant des combinateurs variés, et même un petit niveau de bruit syntaxique (dans ton exemple on pourrait utiliser auto pour l’argument de la lambda, j’imagine, et une annotation de capture implicite [&]… mais ça resterait du bruit en plus) peut être gênant.

+1 -0

Salut, j’ai l’impression qu’il manque la fin de ta dernière phrase, non ? J’aimerais bien connaître la fin ! ^^

Sinon, c’est vrai que les lambdas du C++ sont un peu verbeuses. Elles sont néanmoins un ajout important en termes d’expressivité et de réduction du boilerplate code. En C++98,il aurait fallu écrire des foncteurs ! o_O

À côté de ce que ça apporte, je trouve la verbosité assez anecdotique.

+0 -0

Désolé; j’ai complété avec un truc bateau: "peut être gênant".

Un truc que je trouve assez intéressant et mystérieux en tant que concepteur de langage de programmation, ce sont les effets de la verbosité sur les pratiques de programmation. À moyenne ou grande échelle (quand on regarde une fonction, une classe, un programme), l’important c’est d’avoir des constructions de programmation qui permettent une structure claire, bien ordonnée et sans redondance (le choix de tel ou tel mot clé ne sont que des "facteurs constants", l’important est de pouvoir structure son code de façon précise et robuste au changement); c’est très confortable. Mais à petite échelle, la verbosité, juste quelques caractères de plus ou une chose à penser en plus, peuvent détourner les gens de tel ou tel motif de programmation; ça devient un casse-tête pour le concepteur car il faut non seulement trouver les bonnes constructions abstraites, mais en plus choisir comment les répartir dans l’espace syntaxique, qui favoriser et qui défavoriser, en jonglant avec les contraintes imposées par l’existant. Cette sorte de micro-syntaxe, c’est vraiment très compliqué, et pas très agréable à discuter quand on fait évoluer le langage, mais c’est aussi un mal nécessaire.

(En C++ cette micro-syntaxe est souvent assez peu plaisante; par exemple std::variant qui a été rajouté récemment est une tentative honorable d’avoir des types sommes/algébriques, mais est en réalité assez pénible à utiliser (pour inspecter la valeur du variant) quand on compare à ce qu’on a dans les langages fonctionnels, ou même à ce qu’a su faire Scala comme rétro-intégration dans un langage objet. J’espère qu’une construction de pattern-matching, par exemple étendant le mot-clé switch, pourra être ajoutée dans une prochaine version du langage.)

Désolé; j’ai complété avec un truc bateau: "peut être gênant".

Je suis déçu. :lol:

Mais à petite échelle, la verbosité, juste quelques caractères de plus ou une chose à penser en plus, peuvent détourner les gens de tel ou tel motif de programmation; ça devient un casse-tête pour le concepteur car il faut non seulement trouver les bonnes constructions abstraites, mais en plus choisir comment les répartir dans l’espace syntaxique, qui favoriser et qui défavoriser, en jonglant avec les contraintes imposées par l’existant. Cette sorte de micro-syntaxe, c’est vraiment très compliqué, et pas très agréable à discuter quand on fait évoluer le langage, mais c’est aussi un mal nécessaire.

Je trouve qu’au vu de la richesse du langage, C++ fait plutôt amende honorable là-dessus. Après, on peut lui reprocher cette richesse, mais personnellement c’est ce qui me fait apprécier ce langage.

(En C++ cette micro-syntaxe est souvent assez peu plaisante; par exemple std::variant qui a été rajouté récemment est une tentative honorable d’avoir des types sommes/algébriques, mais est en réalité assez pénible à utiliser (pour inspecter la valeur du variant) quand on compare à ce qu’on a dans les langages fonctionnels, ou même à ce qu’a su faire Scala comme rétro-intégration dans un langage objet.

Effectivement, c’est un bon exemple. L’accès avec un visiteur est, je trouve, une manière de faire assez élégante, mais c’est vrai qu’elle manque du naturel des pattern matching.

J’espère qu’une construction de pattern-matching, par exemple étendant le mot-clé switch, pourra être ajoutée dans une prochaine version du langage.)

gasche

Effectivement, ça pourrait être un ajout intéressant. Ça me fait penser que ça serait rigolo d’essayer de pondre un pattern-matching édulcoré, juste pour les variants. Je tenterai de faire ça dès que j’ai un moment ! :)

+0 -0

Salut,

Je vais arrêter Causerie++ au moins momentanément. En effet, j’ai moins de temps à consacrer à ça en ce moment, et j’aimerais essayer de me concentrer sur le cours C++ !

J’espère que ces quelques épisodes vous ont plu, en tous cas j’ai beaucoup aimé les écrire et ça m’a apporté. J’espère aussi que j’aurai l’occasion de reprendre un jour, je me laisse aussi la possibilité d’écrire un épisode de temps en temps si ça me botte. En tous cas, merci de m’avoir lu ! :)

En bonus, voici un petit pattern-matching pour les variants comme prévu :

template<typename ArgT, typename FunctionT>
class case_holder
{
private:
    FunctionT func_;

public:
    constexpr case_holder(FunctionT func)
        : func_{ func }
    {
    }

    auto operator()(ArgT const& arg)
    {
        return func_(arg);
    }
};

template<typename ArgT, typename FunctionT>
constexpr auto case_(FunctionT&& func)
{
    return case_holder<ArgT, FunctionT>{std::forward<FunctionT>(func)};
}

template<typename VariantT>
class pattern_matching
{
private:
    VariantT const& v_;

public:
    pattern_matching(VariantT const& v)
        : v_{ v }
    {
    }

    template<typename VisitorT>
    auto operator()(VisitorT&& vis)
    {
        return std::visit(std::forward<VisitorT>(vis), v_);
    }
};

template<typename VariantT>
constexpr auto match(VariantT const& v)
{
    return pattern_matching<VariantT>{v};
}

template<typename FirstCaseT, typename SecondCaseT>
struct case_fusion : public FirstCaseT, public SecondCaseT
{
    constexpr case_fusion(FirstCaseT const& first_case, SecondCaseT const& second_case)
        : FirstCaseT(first_case)
        , SecondCaseT(second_case)
    {
    }
};

template<typename FirstCaseT, typename SecondCaseT>
constexpr auto operator|(FirstCaseT const& first_case, SecondCaseT const& second_case)
{
    return case_fusion<FirstCaseT, SecondCaseT>{first_case, second_case};
}

int main()
{
    std::variant<int, double, float, std::string> v{};
    v = 2;
        std::cout << match(v)
    (
        case_<double>([](auto n) -> int { return n / 2; })
        | case_<float>([](auto n) -> int { return n / 2; })
        | case_<int>([](int n) -> int { return n * 2; })
        | case_<std::string>([](std::string const& s) -> int { return std::size(s); })
    );

    return 0;
}
+2 -0
Connectez-vous pour pouvoir poster un message.
Connexion

Pas encore membre ?

Créez un compte en une minute pour profiter pleinement de toutes les fonctionnalités de Zeste de Savoir. Ici, tout est gratuit et sans publicité.
Créer un compte