Comprendre std::string_view de C++17

Comme les user defined literals, je me suis rendu compte récemment que l'existence des string views n'était pas connue de tous. Ce n'est pas vraiment grave : il y a beaucoup d'ajouts depuis 10 ans dans le standard et les développeurs peuvent passer à côté de features non essentielles. Sans être une feature révolutionnaire ou essentielle du C++ moderne, les string views peuvent apporter un gain significatif. Découvrons tout ça ensemble !

const char*, std::string, et maintenant std::string_view

Imaginez que votre application récupère des flux de caractères dans le but de les manipuler pour faire des vérifications ou en extraire des données. Historiquement, vous avez deux solutions pour stocker et manipuler ces chaines de caractères.

La première est d'utiliser les vieilles API du C. Comme il est probable que le code qui récupère les caractères les stocke dans un simple tableau, il suffit de trimballer un pointeur vers le premier élément de ce tableau, de s'assurer qu'il y a bien un '\0' pour avoir une null-terminated byte string, et à nous les strlen(), strcpy() et leurs amies!

Bon OK, ça ne vend du rêve à personne... On préfère tous faire du C++ et utiliser la deuxième solution pour gérer des chaines de caractères : std::string. Le code est alors beaucoup plus simple à écrire, lire et donc à maintenir.

Depuis C++17, vous avez maintenant une troisième solution : std::string_view. Voici la définition de cppreference :

The class template basic_string_view describes an object that can refer to a constant contiguous sequence of char-like objects with the first element of the sequence at position zero.

A typical implementation holds only two members: a pointer to constant CharT and a size.

Leur nom suggère très bien ce que font les string views. Elles servent à manipuler des strings, mais elles ne possèdent pas la donnée : elles regardent celle de quelqu'un d'autre. En anglais, vous verrez souvent qu'elles sont qualifiées de non-owning non-mutating view. Voici un exemple de création d'une std::string_view :

#include <string_view>

const char* s ="hello, world";
std::string_view v{s};

Vous vous demandez certainement : "mais pourquoi avoir inventé cette classe ? quand et comment s'en servir ?". Ca tombe bien, c'est la suite de l'article.

Performances / coûts

Tout n'est pas noir avec les C-style strings et rose avec std::string. Ces dernières apportent un confort et une sécurité indéniables au développeur, mais elles lui masquent certaines opérations qui peuvent être coûteuses. Typiquement, elles sont implémentées en allouant de la mémoire dans le heap. Elles savent combien de caractères sont actuellement stockés et combien pourraient être stockés au maximum. C'est pour ça qu'en plus des fonctions membres size() et length(), on a aussi capacity() :

Returns the number of characters that the string has currently allocated space for.

Toutefois, beaucoup d'implémentations utilisent en plus la Small String Optimization (SSO pour améliorer les performances des petites chaines, notamment pour les copies, en réservant un petit tableau interne à l'instance, évitant ainsi l'allocation dynamique.

Au final, une instance de std::string est clairement plus volumineuse qu'un simple const char*. Dans le cas de chaines en lecture seule, cette capacité à se redimensionner ne sert à rien mais est pourtant disponible. De plus, copier une std::string déclenche une copie de tous les caractères associés, il faut donc faire très attention aux copies cachées dans un code où la performance compte.

std::string_view a été créée pour ça : donner une API plus confortable (voir la section suivante) tout en gardant de bonnes performances pour manipuler des chaines non-modifiables.

On peut comparer l'empreinte mémoire des 3 solutions :

#include <iostream>
#include <string>
#include <string_view>

int main() {
    std::cout << sizeof(const char*) << '\n';
    std::cout << sizeof(std::string) << '\n';
    std::cout << sizeof(std::string_view) << '\n';
}

Ce code affiche avec clang pour x64-86 :

8
32
16

Comme sizeof(std::size_t) donne 8 avec ce compilateur, on retrouve bien l'implémentation typique évoquée par cppreference : un pointeur et une taille. Aucune allocation mémoire n'est faite à l'intérieur de cette classe, mais cela n'empêche pas que le pointeur pointe vers de la mémoire allouée dynamiquement.

Une API plus confortable que const char*

std::string_view offre une API assez semblable à std::string. Puisqu’il s’agit d’une vue sur des données non modifiables, les fonctions membres de std::string qui modifient la chaîne telles que erase(), push_back() ou encore replace() ne sont pas disponibles pour std::string_view.

Il est surtout possible d'y appliquer des algorithmes comme à n'importe quel itérateur.

A partir de C++20, on aura en bonus les très pratiques starts_with() et ends_with().

Null terminated

Il y a une subtilité à connaitre : std::string_view fonctionne avec des chaines qui ne se terminent pas forcément par un '\0'. Cela implique que le pointeur renvoyé par la fonction membre data() n'est pas forcément compatible pour une utilisation avec les API C :

Unlike std::basic_string::data() and string literals, data() may return a pointer to a buffer that is not null-terminated. Therefore it is typically a mistake to pass data() to a routine that takes just a const CharT* and expects a null-terminated string.

Cela se produit bien sûr quand les données d'origine ne contiennent pas ce caractère :

char array[] = {'h', 'e', 'l', 'l', 'o'}; // pas d'\0 ici
std::string_view view{array};

Cela se produit aussi quand on réduit la vue avec remove_prefix() ou remove_suffix(), ou quand on obtient une sous-vue avec substr().

Si on veut s'assurer que la chaine est null terminated, il faut convertir notre std::string_view en std::string. Cela fera une copie des caractères mais cela assurera la présence de l''\0'.

PS : de toute façon, le constructeur à partir d'un const char* ne prend pas le l'\0 de fin :

Constructs a view of the null-terminated character string pointed to by s, not including the terminating null character.

Ainsi, le code suivant affiche 2 fois 4 :

const char* t = "1234";
std::string_view v1{t};
std::cout << v1.size() << '\n';

std::string_view v2{"1234"};
std::cout << v2.size() << '\n';

const std::string& vs std::string_view

Bon... Mais pourquoi ne pas juste utiliser des const std::string& ? Après tout, c'est fait pour manipuler une chaine non modifiable, forcément terminée par un '\0', ça a une API cool, ça se balade facilement. Pourquoi faire des std::string_views ?

Il y a des bonnes discussions sur ce sujet sur stackoverflow :

En résumé, la seule bonne raison d'utiliser des const std::string& est d'avoir besoin de chaînes null terminated. Dans tous les autres cas, std::string_view est un meilleur choix.

Une première raison est très simple mais pas forcément intuitive pour tout le monde : pour obtenir une référence sur une std::string, il faut avoir... une std::string. Et vous n'en avez pas toujours ! Quand vous construisez une instance de std::string à partir d'un char*, tous les caractères vont être copiés, et si la SSO n'est pas possible, il faudra faire une allocation dynamique. En revanche, aucune copie n'est faite à la création d'une std::string_view à partir d'un char*.

La seconde raison est qu'obtenir une sous-chaine avec std::string_view::substr() a un coût négligeable puisqu'aucun caractère n'a besoin d'être copié : il suffit de définir le pointeur vers le début de la sous-chaine, sa longueur, et c'est tout. Ce n'est pas le cas avec std::string puisque là encore, les caractères devront être copiés.

Dangling references

Puisqu'une vue ne possède pas les données, il faut faire attention à synchroniser les durées de vie des 2 objets. Si les données référencées sont détruites avant la vue, alors celle-ci pointe sur des données invalides. Dangling references et undefined behavior en vue ! OK, ce jeu de mots est nul.

Voici un exemple "grossier" :

#include <iostream>

std::string_view get_view() {
    char data[] = "oupsi!";
    // --> ce tableau sera invalide dès qu'on sortira de cette fonction
    return data;
}

int main(){
  auto v = get_view();
  std::cout << v;
}

Ce code m'a affiché :

�@

Il y a des cas plus subtils, comme par exemple std::string_view v{std::string{"Hello"}};. La vue est construite à partir d'une string qui n'existe plus dès la ligne suivante dans le code.

constexpr

Il est possible d'utiliser des string views dans des contextes constexpr.

Prenons par exemple cette fonction (voir la documentation de GCC sur ce qu'est __PRETTY_FUNCTION__) et ces quelques appels :

template <typename T>
void f() {   
    std::cout << __PRETTY_FUNCTION__;
}

f<std::string>();
f<std::string_view>();
f<char*>();

Voici ce qui sera affiché :

void f() [with T = std::__cxx11::basic_string<char>]
void f() [with T = std::basic_string_view<char>]
void f() [with T = char*]

Imaginons maintenant que je souhaite vérifier à la compilation que le nom du type commence bien par "std::" (me demandez pas pourquoi je voudrais faire ça, c'est juste pour l'exemple). Et bien, c'est tout à fait possible :

template <typename T>
void f() {
    constexpr auto prefix = std::string_view{"void f() [with T = "}.length();    
    constexpr auto suffix = std::string_view{"]"}.length();
    constexpr auto typeLength = std::string_view{__PRETTY_FUNCTION__}.length() - prefix - suffix;
    constexpr auto name = std::string_view{__PRETTY_FUNCTION__ + prefix, typeLength};

    static_assert(name.substr(0, 5) == "std::", "Type name doesn't start with 'std::'");

    std::cout << __PRETTY_FUNCTION__;
}

En compilant les 3 appels montrés ci-dessus, j'obtiens une erreur de compilation :

prog.cc: In instantiation of 'void f() [with T = char*]':
prog.cc:22:14:   required from here
prog.cc:16:37: error: static assertion failed: Type name doesn't start with 'std::'
     static_assert(name.substr(0, 5) == "std::", "Type name doesn't start with 'std::'");
                   ~~~~~~~~~~~~~~~~~~^~~~~~~~~~

Conclusion

Si votre application manipule exclusivement des std::strings et que vous n'avez pas besoin d'extraire des sous-chaines, vous n'aurez sans doute pas intérêt à modifier votre code pour utiliser std::string_view. Mais si elle gère également des const char*, cette nouveauté de C++17 pourrait vous permettre de gagner en performance et/ou en confort d'utilisation.

Pour un nouveau développement en revanche, vous aurez sans doute intérêt à utiliser des std::string_view dans de nombreux cas. Gardez en tête que la gestion de la durée de vie de la donnée n'est pas aussi simple qu'avec std::string.

Cette classe apporte un bon compromis entre const char* et std::string tout en permettant des choses supplémentaires, comme travailler sur des chaines qui ne sont pas null terminated ou dans des contextes constexpr.

Si vous avez aimé cette notion de vue, notamment pour travailler avec des plain arrays, vous verrez que C++20 vous réserve quelque chose de bien sympa : std::span.

Pierre

Que la vie de Pierre, expert embarqué Younup, serait terne sans les variadic templates et les fold expressions de C++17. Heureusement pour lui, Python a tué l'éternel débat sur l’emplacement de l’accolade : "alors, à la fin de la ligne courante ou au début de la ligne suivante ?"

Homme de terrain, il est aussi à l’aise au guidon de son VTT à sillonner les chemins de forêt, dans une salle de concert de black metal ou les mains dans les soudures de sa carte électronique quand il doit déboguer du code (bon ça, il aime moins quand même !)

Son vœu pieux ? Il hésite encore... Faire disparaitre le C embarqué au profit du C++ embarqué ? Ou stopper la génération sans fin d'entropie de son bureau ?

Retours aux publications