La programmation, tous langages confondus, implique souvent manipuler des chaînes de caractères. Pour cette raison, il est important de comprendre comment utiliser correctement les types et les mécanismes en ce sens de votre langage de prédilection, et il se trouve que les chaînes de caractères peuvent être radicalement différentes d'un langage à l'autre.
Cet article porte plus spécifiquement sur l'utilisation du type string de C++.
En fait, string spécialise basic_string<C,T,A> où T est un ensemble de traits exprimant les opérations de bas niveau sur une chaîne de caractères et où A est allocateur (type modélisant une stratégie d'allocation de mémoire). En pratique, les types T et A sont associés à des types par défaut et peuvent la plupart du temps être omis. Vous trouverez plus d'explications sur Traits.html#basic_string
Le type string de C++ n'existe pas vraiment en tant qu'entité autonome. Il s'agit d'un alias pour la spécialisation de std::basic_string<C> pour le cas où C est char. Ceci explique en partie que ce type soit plus général que les types string de plusieurs autres langages, mais aussi qu'il expose moins de services que les équivalents dans d'autres langages.
Cette réalité comporte certains avantages. Par exemple, si votre code doit interfacer avec des séquences de unsigned char, ce qui se produit parfois dans les systèmes répartis, le fait qu'il n'existe pas de string contenant des unsigned char n'est pas un gros obstacle, puisqu'il est possible de générer un tel type (nommons-le ustring) avec une ligne de code :
#include <string> // pour avoir std::basic_string
using ustring = std::basic_string<unsigned char> // voilà!
En pratique, on voudra typiquement faire un peu plus, comme par exemple ajouter des opérateurs d'entrée / sortie sur un flux pour notre type, mais ce n'est rien de difficile comme en témoigne Traits.html#ustring.
Chose importante à retenir de cet état de fait : le type string n'est pas un type primitif de C++. C'est un type de la bibliothèque standard, qui n'est disponible que si l'en-tête approprié est inclus. Par exemple :
// std::string f(std::string s) { // ne compilera pas (type std::string inconnu à ce stade)
// return s;
// }
#include <string>
std::string f(std::string s) { // Ok
return s;
}
Ceci amène la question de savoir de quel type est un littéral tel que "allo", et il s'avère que, de par l'héritage du langage C, il s'agit d'une référence vers un tableau const de cinq char (donc d'un char(&)[5]), le cinquième caractère étant le littéral '\0' (valeur 0 sur un char).
Depuis C++ 14, il existe des littéraux string, définis à même <string> et rendus disponibles par une directive using appropriée :
#include <string>
#include <iostream>
int main() {
using namespace std; // ou std::literals (il y a d'autres options)
auto s0 = "allo"; // "allo" est un const char(&)[5], s0 est un char* (règles du langage C)
auto s1 = "allo"s; // s1 est un std::string
// cout << "J'aime mon prof".size() << endl; // ne compile pas, les tableaux de C++ n'ont pas de méthodes
cout << "J'aime mon prof"s.size() << endl; // compile, affiche 15 (le '\0' de la fin n'est pas compté)
}
Quelques caractéristiques notables du type string de C++ suivent.
Le type string de C++ est un type « valeur », ce qui signifie que dans l'extrait suivant :
string s0 = "J'aime mon prof";
string s1 = s0;
... il y a deux string en pratique : que les objets s0 et s1 sont distincts et peuvent être modifiés indépendamment l'un de l'autre :
string s0 = "J'aime mon prof";
string s1 = s0;
s0.replace(7, 3, "MON"); // modifie s0, mais pas s1
cout << s0 << '\n' // J'aime MON prof
<< s1 << endl; // J'aime mon prof
Les instances de string en C++ sont mutables (à moins d'être qualifiés const). Cela signifie qu'il existe des services permettant de modifier une string de C++ une fois celle-ci construite, mais cela sied au modèle de C++ où les string sont des objets, pas des références sur des objets comme dans certains autres langages.
Cela signifie que retourner une string par valeur d'une méthode en C++ n'est pas un bris d'encapsulation, car l'original et la copie sont deux objets distincts.
Cela signifie aussi que la copie d'une string n'est pas une tâche triviale, comme le sera la copie d'une référence ou d'un pointeur dans certains langages. Copier une string sollicite un constructeur, susceptible d'allouer de la mémoire et de copier des caractères. C++ est un langage basé sur les valeurs plutôt que sur les indirections, et ses mécanismes sont pensés en conséquence : ainsi, ci-dessous, le code à gauche (typique de langages où le type string serait une référence ou un objet) fonctionne en C++ mais n'est pas idiomatique, créant un objet par défaut inutile pour le remplacer par la suite, alors que le code à droit tient compte du fait qu'une string en C++ est un objet, et appelle directement le constructeur approprié :
Légal mais inapproprié pour C++ | Plus idiomatique pour C++ |
---|---|
|
|
Parce que copier une string comporte un coût, il est d'usage de passer les string par référence vers const plutôt que par valeur lorsque la fonction appelée ne compte par modifier son paramètre. Par exemple :
Légal mais inapproprié pour C++ | Plus idiomatique pour C++ |
---|---|
|
|
Depuis C++ 17, le type std::string_view simplifie et rend encore plus efficace cette pratique en permettant de passer à une fonction une perspective non-modifiable sur une chaîne de caractères, sans se limiter à des std::string. Vous trouverez plus d'informations à ce sujet dans string_view_maison.html
#include <string>
#include <string_view>
#include <iostream>
// perspective non-modifiable sur le substrat
void afficher(std::string_view s) {
cout << s << endl;
}
int main() {
std::string s = "J'aime mon prof";
afficher(s); // Ok, pas de copie
afficher("allo"); // Ok, pas de copie (si on passait une const string&, créerait une temporaire
// car "allo" est un tableau de char, pas une string)
}
Le type string de C++ expose une interface analogue à celle d'un tableau ou d'un conteneur comme std::vector dont les éléments sont logés de manière contiguë en mémoire. À titre d'exemple, l'extrait de code suivant affichera chaque caractère de la chaîne s mais en intercalant un ' ' entre chacun d'eux, en utilisant l'expression s[i] pour accéder au i-ème élément de s et en utilisant s.size() pour connaître le nombre de caractères dans s :
// ...
string s = "J'aime mon prof";
cout << s.front(); // ou s[0]
for(string::size_type i = 1; i != s.size(); ++i)
cout << ' ' << s[i];
cout << endl;
Notez le type de l'indice, string::size_type. Il est incorrect d'utiliser int, qui est signé, alors que s.size() est non-signé (le code compilerait quand même, mais avec un avertissement légitime). Notez aussi que string expose deux méthodes faisant précisément la même chose, soit length() et size(); il s'agit d'un accident historique. Préférez size(), qui est offert par tous les conteneurs (contrairement à length()).
Il est aussi possible d'itérer à travers les éléments d'une string de C++ à l'aide des mécanismes applicables à un conteneur, incluant les fonctions de l'en-tête <algorithm> :
// ... j'utilise <locale>, <string>, <iostream> et <algorithm> pour cet exexmple
string s = "J'aime mon prof";
for(char c : s)
cout << c;
cout << endl;
transform(begin(s), end(s), begin(s), [loc = locale{ "" }](char c) { return toupper(c, loc); });
for_each(begin(s), end(s), [](char c) { cout << c; });
cout << endl;
Le type string de C++ expose la méthode d'instance empty(), qui est optimale en ce sens (notez qu'il s'agit de empty() au sens de Is empty, « est-elle vide? », pas au sens de To empty, « vider »). En pratique, préférez ceci :
bool est_vide(const string &s) {
return s.empty(); // bien
}
... à ceci :
bool est_vide(const string &s) {
return s.size() == 0; // correct, mais peut être suboptimal
}
... car au pire l'effet sera identique, et au mieux vous gagnerez au change.
Concaténer des string de C++ est simple : il suffit d'utiliser l'opérateur + comme dans s = s0 + s1; ou encore l'opérateur += comme dans s0 += s1;. Notez qu'en C++, a += b est une opération modifiant directement a, ce qui fait que ceci :
#include <string>
int main() {
string s; // constructeur par défaut, chaîne vide
for(int i = 0; i != 10'000'000; ++i)
s += 'A'; // coûteux!
return s.size();
}
... est radicalement plus efficace que cela :
#include <string>
int main() {
string s; // constructeur par défaut, chaîne vide
for(int i = 0; i != 10'000'000; ++i)
s = s + 'A'; // coûteux!
return s.size();
}
En effet, là où s = s + 'A' crée une nouvelle chaîne, y copie tous les caractères de l'ancienne chaîne, ajoute 'A', puis remplace le contenu de s avec le résultat de ce dispendieux calcul, s += 'A' ajoute simplement 'A' à la fin de s, et redimensionne s au passage si elle est pleine.
La version lente prendra plusieurs dizaines de minutes à s'exécuter, comme dans les langages où string est immuable, alors que la version rapide s'exécutera en quelques millisecondes à peine.
Si vous êtes familière ou familier avec les templates variadiques et les Fold Expressions, une manière simple de concaténer plusieurs chaînes de caractères est :
template <class ... S>
auto concat(S && ... s) {
return (s + ...);
}
#include <string>
#include <iostream>
int main() {
cout << concat("J'aime ", "mon ", "prof!");
}
... et si vous souhaitez avoir de meilleurs messages d'erreurs dans les cas qui ne vous conviennent pas, vous pouvez contraindre les types S (un concept pourrait être utile en ce sens).
Cette section porte sur les chaînes de caractères de C++ au sens large, et n'est pas limitée à string.
Certains symboles dans une chaîne de caractères demandent à être traités de manière particulière. Par exemple, les guillemets doivent être précédés d'un \ pour distinguer le symbole affichable du code fermant la string, comme dans "allo \"toi\"" pour représenter le texte "allo "toi"" incluant les guillemets; certains métacaractères comme '\n' (saut de ligne), '\t' (tabulation), '\b' (backspace), etc. demandent une représentation particulière; et puisque le symbole \ sert pour marquer les métacaractères, il faut écrire '\\' ou "\\" pour représenter ce symbole en tant que tel.
Cela veut dire qu'une expression comme :
// ...
cout << "J'aime mon \"prof\"\n... de \\prog\\\nmalgré tout";
// ...
... affichera à la console ce qui suit :
J'aime mon "prof"
... de \prog\
malgré tout
Pour simplifier ces représentations, le langage C++ supporte les chaînes de caractères brutes (Raw String Literals), où la plupart des symboles sont pris tels quels. Ces chaînes sont préfixées de R"( et suffixées de )" comme dans :
cout << R"(J'aime mon "prof"
... de \prog\
malgré tout)";
... qui affichera aussi à la console ce qui suit :
J'aime mon "prof"
... de \prog\
malgré tout
Si vous souhaitez que les séquences "( ou )" apparaissent dans le littéral, vous pouvez insérer jusqu'à 16 symboles entre les marqueurs " et ( qui délimitent le début du littéral brut, dans la mesure où vous insérez aussi les mêmes symboles entre les marqueurs ) et " qui en délimitent la fin. Autrement dit, ceci :
cout << R"xyz(J'aime mon "prof"
... de "(prog)"
malgré tout)xyz";
... affichera cela :
J'aime mon "prof"
... de "(prog)"
malgré tout
Les services exposés pour la classe string de C++ sont listés sur https://en.cppreference.com/w/cpp/string/basic_string mais quelques services clés méritent probablement un peu d'attention.
Tout d'abord, sur le principe : les services de string exposés sous forme de méthodes utilisent principalement des indices, de type string::size_type, mais les algorithmes de C++ opérent quant à eux sur la base d'itérateurs. Pour cette raison, le type string de C++ offre en quelque sorte une interface duale : si vous préférez opérer sur des indices, utilisez les méthodes; si vous préférez le code générique et les algorithmes, préférez les itérateurs.
À titre d'exemple, le code suivant vise à afficher chaque mot d'une chaîne, en considérant qu'un mot est une séquence de caractères délimités par des blancs ou par les extrémités de la chaîne. La version de gauche utilise des indices, alors que la version de droite utilise des itérateurs. Les deux ont le même effet (je préfère celle de droite, plus générale et qui couvre tous les blancs, mais les deux sont décentes) :
Avec indices | Avec itérateurs |
---|---|
|
|
Il existe des constructeurs pour créer une string à partir d'un tableau de char comme à partir d'un entier et d'un char ou à partir d'une paire d'itérateurs, ce qui permet de simplifier plusieurs tâches courantes :
char * p = "allo"; // note : littéral délimité à la fin par un '\0'
string s = p; // "allo"
char tab[]{ 'c', 'o', 'u', 'c', 'o', 'u' }; // note : six éléments, pas de délimiteur à la fin
s = string{ begin(tab), end(tab) }; // "coucou"
vector<char> v{ 'y', 'o', '!' }; // note : trois éléments
s = string{ begin(v), end(v) }; // "yo!"
s = string(10, '-'); // "----------"
Les accès aux éléments d'une string peuvent se faire avec l'opérateur [] comme dans un tableau, ou avec begin(), end() et autres pour travailler avec des itérateurs, ce qui permet d'appliquer des algorithmes sur une string comme sur les autres conteneurs standards. Des méthodes spécialisées (front(), back()) donnent accès à une référence sur les éléments aux extrémités.
Les opérations comme erase(), replace() ou insert() modifient la chaîne sur laquelle elles sont appliquées.
string s0 = "abc";
s0.replace(0, 2, "ABC");
cout << s0 << endl; // ABCc
Il n'existe pas de méthode de conversion de string en majuscules ou en minuscules (pas de toupper ou de tolower applicables à une string), tout comme il n'existe pas de méthode pour élaguer des symboles en début ou en fin de chaîne (pas de trim), ou pour séparer une chaîne en plusieurs sous-chaînes (pas de split). Il est toutefois trivial de réaliser ces opérations à l'aide des algorithmes de C++ (et, pour split, vous pouvez examiner ../AuSecours/Fonction-split.html si le coeur vous en dit). La méthode substr permet d'obtenir une sous-chaîne d'une chaîne donnée :
string s = "J'aime mon prof";
locale loc{ "" };
auto est_blanc = [&](char c) { return isspace(c, loc); };
auto debut = next(find(begin(s), end(s), est_blanc)); // itérateur sur le 'm' de "mon"
auto fin = find_if_not(pos, end(s), est_blanc); // itérateur sur le blanc suivant "mon"
transform(debut, fin, [&](char c) { return toupper(c, loc); }); // transforme "mon" en "MON"
cout << s.substr(7, 3); // MON
Offrir une gamme riche de constructeurs, conçus au fil des années, entraîne son lot d'erreurs de parcours. En 2020, Barry Revzin (source) a signalé sur Twitter ceci :
[Something I learned is] that we have this overload set
string("hello", 2) == "he"
string("hello"s, 2) == "llo"
Awesome.
Especially since the second is already expressible as "hello"s.substr(2).
Quelques liens pour enrichir le propos.
Comment fonctionne une recherche de Boyer-Moore? Quelques explications sur ce sujet dans https://www.geeksforgeeks.org/boyer-moore-algorithm-for-pattern-searching/ par Atul Kumar