Comprendre std::string_view de C++17
Tags: cpp, programming
Published: 2021-03-09 16:51:31

[Comme les _user defined literals_](https://dev.to/younup/user-defined-literals-en-c-1jlh), 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](https://en.cppreference.com/w/cpp/string/byte), et à nous les `strlen()`, `strcpy()` et [leurs amies](https://fr.wikipedia.org/wiki/String.h)! 😍

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`](https://en.cppreference.com/w/cpp/string/basic_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](https://en.cppreference.com/w/cpp/string/basic_string_view) :

> 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` :

```cpp
#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()`](https://en.cppreference.com/w/cpp/string/basic_string/size), on a aussi [`capacity()`](https://en.cppreference.com/w/cpp/string/basic_string/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](https://stackoverflow.com/questions/10315041/meaning-of-acronym-sso-in-the-context-of-stdstring/)) 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 :

```cpp
#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()`](https://en.cppreference.com/w/cpp/string/basic_string_view/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 :

```cpp
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](https://en.cppreference.com/w/cpp/string/basic_string_view/basic_string_view) à 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` :

```cpp
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_view`s ?

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

- [How exactly is std::string_view faster than const std::string&?](https://stackoverflow.com/questions/40127965/how-exactly-is-stdstring-view-faster-than-const-stdstring)
- [When would I pass const& std::string instead of std::string_view?](https://stackoverflow.com/questions/39564457/when-would-i-pass-const-stdstring-instead-of-stdstring-view)

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" :

```cpp
#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__`](https://gcc.gnu.org/onlinedocs/gcc/Function-Names.html)) et ces quelques appels :


```cpp
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 :

```cpp
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::string`s 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`](https://dev.to/pgradot/let-s-try-c-20-std-span-1682).