C++ – le type string

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++.

Ce qu'est ce type

En fait, string spécialise basic_string<C,T,A> 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é)
}

Caractéristiques clés

Quelques caractéristiques notables du type string de C++ suivent.

Type « valeur »

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 

Mutabilité

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++
#include <string>
class Personne {
   std::string nom_; // nom_ est un objet, et doit être construit
                     // pour que le constructeur de Personne puisse
                     // débuter son exécution
public:
   // le constructeur par défaut de nom_ est appelé avant '{'
   Personne(std::string nom) {
      nom_ = nom; // affectation: remplace le contenu (inutile!) de nom_
                  // par une copie de celui de nom
   }
};
#include <string>
class Personne {
   std::string nom_; // nom_ est un objet, et doit être construit
                     // pour que le constructeur de Personne puisse
                     // débuter son exécution
public:
   // on appelle directement le constructeur de nom_
   Personne(std::string nom) : nom_{ nom } {
   }
};

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++
#include <string>
#include <iostream>
// passage par valeur (fait une copie)
void afficher(std::string s) {
   cout << s << endl;
}
int main() {
   std::string s = "J'aime mon prof";
   afficher(s);
}
#include <string>
#include <iostream>
// passage par référence vers const
// (aucune copie, aucun risque)
void afficher(const std::string &s) {
   cout << s << endl;
}
int main() {
   std::string s = "J'aime mon prof";
   afficher(s);
}

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

Plus idiomatique pour C++ (depuis C++ 17)
#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)
}

Interface analogue à celle d'un autre conteneur

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:

// ...
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;

Tester une string pour savoir si elle est vide

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

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).

Chaînes de caractères brutes

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

Quelques services clés

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
#include <string>
#include <iostream>
using namespace std;
void afficher_mots(const string &s) {
   auto blancs = " \t\n"s; // liste non-exhaustive (bof)
   // debut est de type string::size_type
   auto debut = s.find_first_not_of(blancs, 0);
   while (debut != string::npos) {
      auto fin = s.find_first_of(blancs, debut);
      if (fin == string::npos) {
         cout << '\"' << s.substr(debut) << "\"\n";
         debut = fin;
      }
      else {
         cout << '\"' << s.substr(debut, fin - debut) << "\"\n";
         debut = s.find_first_not_of(blancs, fin);
      }
   }
}
int main() {
   afficher_mots(" j'aime mon prof ");
   afficher_mots(" j'aime mon prof ");
   afficher_mots(" j'aime mon prof");
   afficher_mots("j'aime mon prof");
}
#include <string>
#include <iostream>
#include <algorithm>
#include <iterator>
#include <locale>
using namespace std;
void afficher_mots(const string &s) {
   auto est_blanc = [loc = locale{ "" }](char c) { return isspace(c, loc); };
   // debut est de type string::const_iterator
   auto debut = find_if_not(begin(s), end(s), est_blanc);
   while (debut != end(s)) {
      auto fin = find_if(debut, end(s), est_blanc);
      cout << '\"' << string{ debut, fin } << "\"\n";
      debut = fin == end(s)?
         fin : find_if_not(fin, end(s), est_blanc);
   }
}
int main() {
   afficher_mots(" j'aime mon prof ");
   afficher_mots(" j'aime mon prof ");
   afficher_mots(" j'aime mon prof");
   afficher_mots("j'aime mon prof");
}

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).

Lectures complémentaires

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


Valid XHTML 1.0 Transitional

CSS Valide !