Un string_view « maison »

C++ 17 amène un type std::string_view, outil d'optimisation qui impactera les interfaces de plusieurs fonctions. Puisqu'il s'agit essentiellement d'un enrichissement à la bibliothèque standard, il est possible de concocter une version « maison » de ce type en attendant que les implémentations officielles ne commencent à se répandre.

Cet article propose une ébauche d'une telle classe, à titre expérimental et pour un expliquer l'intérêt. Je vous invite toutefois à migrer vers la version standard aussi tôt que cela s'avèrera possible, car elle sera presque inévitablement meilleure (et plus complète) que la version à vocation pédagogique présentée ici.

Il est présumé au préalable que vous êtes familière ou familier avec std::basic_string<C>; dans le cas contraire, il serait sans doute utile pour vous de prendre connaissance du fonctionnement de ce type avant de lire ce qui suit.

Le standard C++ 17 est porteur de nouveaux concepts, mais aussi d'un vaste éventail d'améliorations et d'ajouts à la bibliothèque standard. Il se trouve qu'une partie de ces raffinements influencera nos pratiques de programmation en nous enseignant ce que les expertes et les experts ont réfléchi au cours des années qui ont mené à l'élaboration du standard.

Cet article discute du type string_view, qui est un exemple de Non-Owning Container, donc d'adaptateur sur des types existants qui ne cherchent pas à être propriétaires du substrat de ces types. Ces types ont une sémantique de référence (Reference Semantics), au sens où ils jouent pour l'essentiel un rôle d'alias vers les données d'un autre type, et exposent une interface commode pour le code client – le type array_ref est un autre exemple d'un tel type (notez que std::span<T> est une meilleure solution depuis C++ 20!). L'intention est de remplacer const string& par string_view dans une importante proportion de fonctions acceptant des chaînes de caractères en paramètre.

Problème à résoudre

La recommandation traditionnelle lors du passage de paramètres représentant une chaîne de caractères à une fonction qui ne les modifiera pas est de passer ces paramètres const string&. L'idée va comme suit.

Passer un const char* ou un const char(&)[N] compilera car std::string offre un constructeur implicite acceptant ces types. Dans ces deux cas, une std::string temporaire sera créée pour réaliser l'appel, et détruire à la fin de la fonction

void f(const string&);
void g() {
   f("J'aime mon prof"); // const char(&)[16], crée une temporaire
   const char *p = "moi aussi";
   f(p); // const char*, crée une temporaire
}

Passer une std::string ou une const std::string compilera aussi. Dans ce cas, l'objet est passé par référence et aucune copie n'est requise.

void f(const string&);
void g() {
   string s0 = "J'aime mon prof";
   const string s1 = "moi aussi";
   f(s0); // pas de temporaire
   f(s1); // pas de temporaire
}

L'irrirtant est que bien que le code compile et fonctionne dans chaque cas, l'utilisation de const char* et de const char (&)[N] entraîne un coût de par la copie du texte et la possible allocation dynamique de mémoire pour l'entreposer. Si la fonction appelée devait modifier la chaîne reçue en paramètre, ce coût serait justifié, mais ici, avec une fonction qui s'engage à ne pas modifier son paramètre (il est const après tout), il est permis de douter de sa pertinence.

Approche

Le type string_view vise à paller cet irritant en remplaçant le type const string&, traditionnellement préféré pour passer une chaîne de caractères à une fonction, par un adaptateur implémentant une sémantique de référence. Le principe est que cet adaptateur doit modéliser une abstraction efficace, que sa copie doit être essentiellement gratuite, et qu'il doit offir la même interface que la classe string qu'il vise à remplacer de telle sorte qu'il puisse en général remplacer cette dernière partout où cela s'avère pertinent. Je vous rappelle que la classe qui suit est une version maison, conçue pour fins éducatives, et que vous devriez privilégier std::string_view dès que vous y aurez accès.

J'ai limité ,mon implémentation à des outils standards, à une exception près, soit l'en-tête relation.h qui contient des outils pour implémenter >, <= et >= sur la base de < et la négation logique, de même que pour implémenter != sur la base de == et de la négation logique. Ces implémentations reposent sur l'idiome CRTP et sont décrites sur Truc-Barton-Nackman.html

#ifndef STRING_VIEW_H
#define STRING_VIEW_H
#include "relation.h"
#include <cstddef>
#include <string>
#include <istream>
#include <ostream>

Mon basic_string_view<C> définit les types internes et publics qu'il est raisonnable d'attendre d'une std::basic_string<C>.

J'ai fait une contorsion en définissant basic_string_view<C>::npos « manuellement » car je souhaitais qu'il soit constexpr et basic_string<C>::npos ne l'était pas sur le compilateur avec lequel j'ai réalisé  mon implémentation.

template <class C>
class basic_string_view
   : relation::equivalence<basic_string_view<C>>,
     relation::ordonnancement<basic_string_view<C>> {
public:
   using value_type = C;
   using size_type = typename std::basic_string<C>::size_type;
   using pointer = value_type*;
   using const_pointer = const value_type*;
   using reference = value_type&;
   using const_reference = const value_type&;
   static constexpr const auto npos = static_cast<size_type>(-1);

Mon basic_string_view<C> définit les attributs beg_ et end_ comme étant nuls par défaut, ce qui permet d.'avoir un constructeur par défaut qui soit implicitement constexpr dans ce cas.

Notez que, puisque basic_string_view<C> vise une efficacité maximale, ce type modélise le substrat qu'il adapte comme une séquence contiguë en mémoire de type C.

private:
   const_pointer beg_ = {}, end_ = {};

Chaque constructeur de basic_string_view<C> peut être exécuté en temps constant. C'est volontaire.

Remarquez que pour le constructeur qui accepte en paramètre un const std::basic_string<C>& nommé s, beg_ est initialisé à la valeur de s.data() pour qu'il soit clair que l'objet construit interface directement avec le substrat de la chaîne de carctères qu'il adapte. En effet, rien n'assure qu'un itérateur sur une std::basic_string<C> soit un pointeur...

public:
   basic_string_view() = default;
   constexpr basic_string_view(const_pointer beg, const_pointer end) noexcept : beg_{ beg }, end_{ end } {
   }
   constexpr basic_string_view(const_pointer s, size_type n) noexcept : beg_{ s }, end_{ s + n } {
   }
   template <size_t N>
      constexpr basic_string_view(const C(&arr)[N]) noexcept : beg_{ std::begin(arr) }, end_{ std::begin(arr) + N -1 } { // arr[N]=='\0'
      }
   basic_string_view(const std::basic_string<C> &s) noexcept : beg_{ s.data() } {
      end_ = begin() + s.size();
   }
   basic_string_view(std::basic_string<C> &s) noexcept : beg_{ s.data() } {
      end_ = begin() + s.size();
   }
   basic_string_view(std::basic_string<C> &&s) noexcept : beg_{ s.data() } {
      end_ = begin() + s.size();
   }

Les itérateurs sont triviaux à définir, beg_ et end_ étant des pointeurs sur les extrémités d'un substrat contigu en mémoire.

   using iterator = pointer;
   using const_iterator = const_pointer;
   using reverse_iterator = std::reverse_iterator<iterator>;
   using const_reverse_iterator = std::reverse_iterator<const_iterator>;
   constexpr const_iterator begin() const noexcept {
      return beg_;
   }
   constexpr const_iterator end() const noexcept {
      return end_;
   }
   constexpr const_reverse_iterator rbegin() const noexcept {
      return const_reverse_iterator(end());
   }
   constexpr const_reverse_iterator rend() const noexcept {
      return const_reverse_iterator(begin());
   }

Les services simples que sont empty(), size(), front() et back() sont calculés en temps constant.

Notez que size() est calculé, pour faire en sorte que basic_string_view<C> demeure aussi petit et léger que possible.

Je n'ai oas implémenté les versions non-const de front() et de back(), mais rien ne vous empêche de le faire si tel est votre souhait. Les services que vous voudrez à tout prix éviter d'implémenter pour basic_string_view<C> sont ceux qui modifient la structure du substrat (pas de push_back() ou de resize() par exemple).

   constexpr size_type size() const noexcept {
      return end() - begin();
   }
   constexpr bool empty() const noexcept {
      return end() == begin();
   }
   constexpr C front() const {
      return beg_[0];
   }
   constexpr C back() const {
      return *(end()-1);
   }

J'ai exprimé l'implémentation des services sur la base de std::char_traits<C>, comme le ferait sans doute std::basic_string<C>.

   size_type find(C c) {
      auto p = std::char_traits<C>::find(begin(), 1, c);
      return p ? p - begin() : std::basic_string<C>::npos;
   }

Notez qu'en ayant fait le choix d'utiliser CRTP pour implémenter la plupart des opérateurs relationnels, il ne me reste plus qu'à spécifier == et < pour basic_string_view<C>.

   bool operator==(basic_string_view s) const noexcept {
      return size() == s.size() && std::char_traits<C>::compare(begin(), s.begin(), size()) == 0;
   }
   bool operator<(basic_string_view s) const noexcept {
      auto n = std::char_traits<C>::compare(begin(), s.begin(), std::min(size(), s.size()));
      return n < 0 || n == 0 && size() < s.size();
   }

Il est d'usage d'exposer pour un basic_string_view<C> un sous-ensemble des fonctionnalités habituelles pour une std::basic_string<C>, par exemple une méthode substr() donnant une perspective (une autre basic_string_view<C>, bien sûr) sur les données modélisées par l'objet.

La possibilité de convertir (par un geste délibéré, donc explicit) une basic_string_view<C> en std::basic_string<C> peut être utile dans les cas où le code client souhaite réaliser des manipulations sur un objet distinct. Cette opération provoque une copie et est donc plus dispendieuse que la plupart des opérations se limitant à manipuler des basic_string_view<C>.

   constexpr basic_string_view substr(size_type first, size_type n = npos) const {
      return basic_string_view(begin() + first, (n == npos ? end() : begin() + first + n));
   }
   explicit operator std::basic_string<C>() const {
      return{ begin(), end() };
   }
};

Les alias string_view et wstring_view, par symétrie avec les alias std::string et std::wstring, sont offerts pour faciliter la tâche des programmeuses et des programmeurs.

using string_view = basic_string_view<char>;
using wstring_view = basic_string_view<wchar_t>;

Enfin, une fonction de fabrication et un opérateur de projection sur un flux complètent le portrait; il ne serait pas à propos d'exposer un opérateur pour lire d'un flux, car celui-ci devrait être capable de redimensionner l'objet, une opération contreproductive dans le contexte.

template <class C>
   basic_string_view<C> make_string_view(const std::basic_string<C> &s) {
      return{ s.data(), s.size() };
   }
template <class C>
   std::basic_ostream<C>& operator<<(std::basic_ostream<C> &os, basic_string_view<C> s) {
      for (auto c : s) os << c;
      return os;
   }
#endif

Lectures complémentaires

Quelques liens pour enrichir le propos.

Il y a des pièges associés à l'utilisation d'un std::string_view, et ce schéma en illustre quelques-uns :https://twitter.com/walletfox/status/999952827656081408


Valid XHTML 1.0 Transitional

CSS Valide !