Ce qui est proposé ici est incomplet (bien que fonctionnel), éminemment perfectible et impropre à tout usage commercial. C'est toutefois amusant, à coder comme à réfléchir, et pas inintéressant du tout. Prenez le tout pour ce que c'est, sans plus.
Une optimisation possible pour l'implémentation de certains conteneurs, en particulier celle des chaînes de caractères, est l'optimisation dite SSO, pour Small String Optimization.
L'idée derrière cette optimisation est relativement simple :
Un exemple d'organisation possible pour une « grosse chaîne » suit. Ici, uint32_t signifie « entier non-signé sur bits ». Pour les besoins de l'illustration, supposons qu'un pointeur occupe aussi bits en mémoire. Ce ne sont que des choix pour fins d'illustration – l'optimisation SSO ne dépend pas de ces valeurs spécifiquement.
Si compilé en 32 bits (quatre bytes par pointeur) :
uint32_t
|
uint32_t
|
pointeur
|
---|---|---|
Nombre d'éléments |
Capacité |
Pointeur sur 1er élément |
Grosse chaîne de caractères, espace requis : bytes
|
Si compilé en 64 bits (huit bytes par pointeur) :
uint32_t
|
uint32_t
|
pointeur
|
---|---|---|
Nombre d'éléments |
Capacité |
Pointeur sur 1er élément |
Grosse chaîne de caractères, espace requis : bytes
|
Si on utilise un espace équivalent pour une petite chaîne, il est possible d'éviter toute allocation dynamique de mémoire et de conserver les données et le nombre de caractères sur une seule et même Cache Line.
Si compilé en 32 bits (quatre bytes par pointeur) :
uint32_t | Texte |
---|---|
Nombre d'éléments |
Séquence de caractères d'au plus bytes |
Petite chaîne de caractères, espace requis : bytes
|
Si compilé en 64 bits (huit bytes par pointeur) :
uint32_t | Texte |
---|---|
Nombre d'éléments |
Séquence de caractères d'au plus bytes |
Petite chaîne de caractères, espace requis :
bytes
|
La question, donc : comment coderiez-vous une chaîne correcte implémentant SSO? C'est un exercice amusant, surtout si l'on prend soin de coder push_back().
Ce qui suit propose deux ébauches de solution à ce problème (deux options parmi plusieurs), accompagnées d'une discussion des compromis faits dans chaque cas.
J'ai testé chaque implémentation avec ce qui suit.
Ce programme met en relief un certain nombre de particularités du type sso_string proposé en exemple. En effet, soit une instance s de ce type :
Notez que la classe sso_string que j'utilise pour cette démonstration est générique sur la base du type d'un caractère (avec spécialisations, nommées respectivement sso_string pour le type char et wsso_string pour le type wchar_t, à l'image du type string), ce qui nous permet :
Ceci nous permet de réaliser les mêmes tests sur des caractères étendus et sur des caractères d'un byte chacun, comme le montre l'exemple de code à droite. Notez que les gains de SSO sont plus grands si la représentation d'un élément est plus petite, du fait que les structures de contrôle (pointeurs, entiers sur bits), elles, ne changent pas de taille si les caractères sont plus gros. |
|
L'affichage attendu serait alors :
yo
I like my instructor, he's really sweet and he barely makes my head hurt on occasion
hey
hey!
heille!
aAbBcCdDeEfFgGhHiIjJkKlLmMnNoOpPqQrRsStTuUvVwWxXyYzZ
yo
I like my instructor, he's really sweet and he barely makes my head hurt on occasion
hey
hey!
heille!
aAbBcCdDeEfFgGhHiIjJkKlLmMnNoOpPqQrRsStTuUvVwWxXyYzZ
Évidemment, la gamme de tests pourrait être plus riche encore, mais ceci nous permettra d'examiner les conséquences et les ramifications de l'implémentation d'une optimisation SSO sur un type de données.
L'implémentation proposée en exemple expose quelques défis intéressants. J'essaierai dans cette section de mettre en relief les plus importants parmi ceux-ci, dans le but de clarifier votre lecture par la suite.
J'ai choisi d'utiliser deux classes internes à v0::sso_string, soit une pour les « petites » chaînes et une autre pour les « grosses ». Les « petites » chaînes seront faites d'une séquence de caractères et d'une taille, leur capacité étant fixée à la compilation. Les « grosses » chaînes seront quant à elles faites d'un pointeur sur la séquence de caractères, d'une taille (en nombre de caractères) et d'une capacité (idem).
Le défi sera de représenter les deux chaînes dans le même espace, donc d'entreposer à même cet espace la capacité de déterminer laquelle des deux représentations aura été choisie.
Parce que j'utiliserai des types distincts pour les deux représentations, j'aurai recours à des constructeurs et à des destructeurs dans chaque cas. En effet, les « grosses » chaînes seront responsables de ressources, ayant recours à de l'allocation dynamique de mémoire, et ont un besoin clair de procéder à du nettoyage.
Ce choix d'implémentation entraîne deux conséquences techniques :
La méthode classique (dans le langage C, du moins) pour superposer deux données en mémoire est d'avoir recours à un union. Par « superposer », ici, je fais un demi-abus de langage, au sens où les membres d'une instance donnée d'un union débutent tous à la même adresse mais n'occupent pas nécessairement la même plage mémoire (un union ayant pour membres un char et un int serait un bon exemple d'un cas où parler de « superposition » serait quelque peu abusif).
Les union constituent un outil fort pratique, mais dont les résultats ne sont pas portables. Dans l'exemple à droite, si un tableau de char et un entier de même taille que ce tableau sont superposés, écrire dans l'entier modifiera le tableau entier, mais écrire dans une entrée du tableau ne modifiera qu'une partie de l'entier. La partie de l'entier qui sera modifiée variera toutefois d'une plateforme à l'autre, du fait que l'ordre des bytes dans un entier dépend de la plateforme. Dans le code à droite, l'affichage en fin de programme pourra prendre plusieurs formes, en fonction du signe de char sur une plateforme donnée, de la taille d'un short et de l'ordre des bytes dans un entier. Sachant que x.entier valait au préalable zéro, ou 0x0000 si on l'exprime sous forme hexadécimale (et si on présume sizeof(short)==2), l'affectation x.bytes[0]=0xff changera la valeur de x.entier, qui vaudra alors 0x00ff ou 0xff00 en fonction de l'ordre des bytes dans le short. |
|
Ce côté éminemment non portable des union réduit leur utilité en général, outre dans quelques situations bien circonscrites. On aurait pu croire que l'implémentation SSO que nous cherchons à faire ici aurait été l'une de ces situations très ciblées, du fait que les manipulations sur une sso_string seront internes à un programme dans un ordinateur donné, or ce n'est pas le cas, du moins pas avec les choix techniques mentionnés plus haut.
En effet, le recours à des constructeurs et à des destructeurs disqualifie d'office le recours à un union. Il suffit d'y penser : dans un union, si au moins un membre a un constructeur, quoi faire lorsqu'une instance de cet union est construite? Donner priorité au membre ayant un constructeur? Mais de quel droit? Et si au moins deux membres ont des constructeurs, alors lequel privilégier? La même question se pose pour les destructeurs, d'ailleurs. Pour qu'un union soit un choix raisonnable, il faut que l'initialisation et la finalisation soient triviales (au sens mathématique du terme), ce qui ne sera pas le cas ici.
J'ai donc eu recours à une gestion manuelle d'un espace circonscrit, à l'aide d'allocation positionnelle (placement new), ce qui me permet d'utiliser à plein les facilités du langage (constructeurs, destructeurs, méthodes de toute sorte) tout en contrôlant le lieu où les objets sont placés, de même que l'espace qu'ils occupent.
Voir la version v1 pour une implémentation utilisant un union.
Il y a des exceptions à la règle. Pensons par exemple au cas où un service serait implémenté exactement à travers la même interface et serait accessible exactement à la même adresse pour toutes les représentations.
La classe sso_string devra faire des choix, dynamiquement, en fonction du type de représentation sous-jacente.
En effet, règle générale, lors d'un appel d'une de ses méthodes d'instance, il faudra que la classe sso_string identifie la représentation sous-jacente actuelle et délègue à cette dernière la tâche associée au service sollicité. C'est de cette sélection d'un service sur la base d'un type que le polymorphisme nous libère, mais ici, en implémentant SSO, nous souhaitons respecter des contraintes de taille et de disposition binaire extrêmement strictes, et nous portons conséquemment le fardeau de la sélection dynamique des services sur nos épaules.
Prenons par exemple le pseudocode ci-dessous :
// ...
iterator begin() {
/*
si la représentation sous-jacente est celle d'une grosse chaine
- traiter le bloc de bytes brut servant à entreposer les
représentations sous-jacentes comme une référence sur la
représentation d'une grosse chaine
- déléguer le travail à sa méthode begin()
sinon
- traiter le bloc de bytes brut servant à entreposer les
représentations sous-jacentes comme une référence sur la
représentation d'une petite chaine
- déléguer le travail à sa méthode begin()
*/
}
// ...
Ici, une alternative (un if) est nécessaire pour réaliser le bon transtypage du bloc de bytes où les représentations peuvent se superposer et, par la suite, invoquer le service souhaité à partir de la représentation obtenue.
On peut parfois escamoter ce transtypage, comme le montre un cas patent à même le pseudocode ci-dessus : la méthode permettant de savoir si la représentation sous-jacente est celle d'une grosse ou d'une petite chaîne ne doit pas dépendre d'un transtypage puisque c'est elle qui guide le transtypage à réaliser. L'implémentation de cette méthode est particulièrement importante pour l'ensemble de nos décisions techniques.
Comment représenter ce savoir? Plusieurs options sont possibles. La première, peut-être la plus évidente pour la plupart des gens, serait d'utiliser un booléen à même la classe sso_string. Il y a un coût associé à ce choix, cependant : pour des raisons de contraintes d'alignement des objets en mémoire, le booléen pourrait occuper un mot mémoire entier (quatre bytes sur une machine bits comme celle supposée dans cet exemple), ce qui serait dispendieux pour implémenter un optimisation justement axée sur une plus grande efficacité dans la manipulation des petites chaînes.
On pourrait aussi se baser sur la taille, en termes de nombre de caractères dans la chaîne, pour déterminer s'il s'agit d'une grosse ou d'une petite chaîne. Ceci impliquerait soit de placer cette taille à même la classe sso_string puis de la passer en paramètre aux méthodes des représentations sous-jacentes, ce qui est contestable sur le plan de l'esthétique structurelle, soit de placer au même endroit dans les deux représentations sous-jacentes cette information, de telle sorte que convertir dans l'une ou l'autre des deux représentations donne accès à la taille de la même manière. J'ai utilisé cette deuxième approche longtemps (je le fais encore, d'ailleurs, avec une nuance dont nous reparlerons plus loin), mais elle a un défaut important : elle est fautive!
En effet, ce qui nous préoccupe en pratique pour déterminer la représentation sous-jacente est non pas le nombre de caractères dans la chaîne, mais bien la capacité de la mémoire allouée pour déposer ces caractères – la capacité d'un conteneur est toujours supérieure ou égale à sa taille.
Il pourrait être raisonnable d'ajouter un attribut pour la capacité, même dans le cas où celui-ci serait redondant, dans la mesure où notre compilateur générerait des objets plus gros que le minimum requis, par exemple pour des raisons d'alignement en mémoire. Dans un tel cas, plutôt que de « perdre » cet espace, mieux vaudrait l'utiliser à bon escient.
Cela dit, dans le cas de l'implémentation d'une optimisation SSO, il est moins clair que l'on veuille avoir recours à utiliser un attribut d'instance pour représenter la capacité de la chaîne. En effet, pour une petite chaîne, la capacité sera très probablement une constante statique, ne dépendant que de la taille des données servant à représenter une grosse chaîne. Il ne serait alors par nécessaire de l'entreposer dans un attribut. Par contre, la capacité d'une grosse chaîne est quant à elle presque assurément dynamique, nécessitant du même coup un attribut.
Cette dichotomie laisse entendre que si la taille pourrait servir de discriminant entre petite et grosse chaîne, il se trouve que la capacité, quant à elle, s'y prête moins.
Mon approche, vous le verrez, a été de tricher. Plutôt que d'utiliser un mot mémoire entier pour un booléen ou de forcer la représentation de la capacité dans un attribut, même si cela n'est nécessaire que dans l'un des deux cas à couvrir, j'ai choisi de limiter la taille maximale de mes grosses chaînes. Ayant choisi d'utiliser bits pour cette taille, j'aurais normalement des chaînes d'une taille variant entre et . J'utilise le bit le plus haut de la taille à titre d'état booléen, pour représenter le choix d'une petite représentation ou non. Ce faisait, la taille maximale d'une grosse chaîne passe de à . J'ai décidé que je pouvais vivre avec ce choix.
Voyons maintenant comment la classe sso_string (en fait, basic_sso_string<C> pour divers types de caractères C) est implémentée, en procédant un fichier à la fois. Notez que basic_sso_string est une classe générique, donc est essentiellement décrite dans un fichier d'en-tête, mais que nous aurons besoin d'un fichier source pour certaines spécialisations non-génériques d'outils dont dépend notre classe.
Maintenant que nos a priori ont été étalés sur la place publique, abordons l'implémentation à proprement dit.
Tout d'abord, une sso_string pourra être construite de différentes manières. L'un des types de données sources sera une classique séquence ASCIIZ du type de caractère sous-jacent. Lorsqu'une telle chaîne de caractères est reçue en paramètre par un constructeur, il est utile d'en calculer la longueur, exprimée en nombre de caractères. Il se trouve que C ne permet pas à deux fonctions d'avoir le même nom mais de différer sur le plan du nombre de paramètres ou du type de certains paramètres. Pour cette raison, une fonction comme std::strlen(), excellente pour calculer la longueur d'une séquence ASCIIZ de char, ne fonctionnera pas correctement sur une séquence ASCIIZ de wchar_t. Toutefois, ma classe basic_sso_string est générique sur la base du type de caractères, et j'aimerais que le code de cette classe soit chaque fois le même, au type de caractère près. Pour y arriver, j'aurais pu spécialiser la classe toute entière pour le type char, mais j'ai plutôt choisi d'offrir la fonction string_length(), qui offre un comportement générique, à travers le type std::basic_string_view<C>. |
|
La classe générique elle-même se nomme basic_sso_string<C>, à l'image de std::basic_string<C>. Elle pourrait évidemment être enrichie par des traits, de même que par un ensemble de méthodes supplémentaires (opérateurs de comparaison, d'ordonnancement, de recherche de sous-chaînes, etc.), mais elle suffira pour démontrer que notre implémentation de SSO fonctionne. Ma classe expose des types internes et publics. Certains des plus simples sont visibles à droite. |
|
Examinons maintenant la classe par laquelle les grosses chaînes seront représentées. J'ai nommé cette classe big_data, mais je conviens que c'est un choix de nomenclature des plus discutables. Je débute ma présentation par celle-ci pour deux raisons :
Cela dit, la définition du type basic_sso_string<C>::big_data, à droite, est plutôt simple. Notez la fonction add(), qui permettra à une basic_sso_string<C> et à elle seule (car elle est amie de big_data) d'ajouter des éléments à une big_data existante; cette méthode est d'ailleurs insuffisamment protégée pour être exposée de manière publique au code client. Les autres méthodes de cette classe sont « ordinaires ». Elles respectent les idiomes connus du langage (RAII, par exemple, ou l'idiome d'affectation sécuritaire) et font un travail honnête. Comme la plupart des types implémentés à travers de l'allocation dynamique de mémoire, d'ailleurs, la sémantique de mouvement et l'idiome d'affectation sécuritaire y sont tout à fait appropriés. |
|
Petite parenthèse technique : il aurait été possible d'utiliser la constante littérale (1<<31) pour représenter le bit le plus haut d'un size_type, mais une telle implémentation aurait dépendu du fait que size_type soit un type entier encodé sur bits. Ceci aurait été quelque peu fragile dans les circonstances. J'ai plutôt choisi d'établir une constante symbolique smallness_mark_bit qui s'adapte statiquement à la taille effective de size_type. Pour y arriver :
|
|
Pour ce qui est de la classe représentant une petit chaîne, classe nommée ici small_data, l'espace de représentation est un bloc de bytes de taille fixée à la compilation et équivalent à la taille d'un big_data. Contrairement à un big_data, un small_data n'a pas recours à de l'allocation dynamique de mémoire. Sa capacité est fixée a priori. Cette implémentation privilégie la copie; la sémantique de mouvement n'y est pas vraiment à propos. Remarquez que les manipulations ayant trait à la taille de la chaîne doivent tenir compte de ce que j'y ai nommé la Smallness Mark, soit le bit le plus haut de la taille, qui est à 1 dans le cas où la chaîne est petite. Je couvre le problème d'une représentation de grosse chaîne dont le bit le plus haut serait allumé (mon signal de petite chaîne) dans resize(), plus bas. |
|
Quelques outils d'ordre général pour basic_sso_string :
|
|
Pour ce qui est des services de basic_sso_string, la plupart sont des délégations directes (comme dans le cas de capacity()) ou indirectes (comme dans le cas de full()) vers des services de la représentation sous-jacente. J'aurais voulu alléger cette écriture, mais les types des deux représentations sous-jacentes sont distincts l'un de l'autre, et je rappelle que, pour implémenter une optimisation telle que SSO, je préférais ne pas avoir recours au polymorphisme dynamique. Pour resize(), qui permet de déterminer une nouvelle capacité pour la représentation sous-jacente, j'ai choisi d'utiliser une opération unaire prenant l'ancienne capacité et retournant la nouvelle capacité à titre de stratégie de croissance. C'est une option parmi plusieurs. |
|
Pour begin() et end(), le code est banal et repose sur de simples délégations. Pour size(), du fait que la taille se trouve au même endroit (un choix délibéré et, dans cette implémentation, une considération essentielle, tel que discuté plus haut) pour les deux représentations possibles, les manipulations pour éliminer le bit indiquant une petite chaîne sont faites directement ici. |
|
Suivent les opérations propres à l'identité d'une basic_sso_string et à sa copie. J'ai couvert les cas suivants :
Je n'ai pas couvert la sémantique de mouvement, du fait qu'au sens de basic_sso_string, cette sémantique n'est pas appropriée (la représentation effective de ce type reposant sur un bloc de bytes bruts dont la taille est connue à la compilation). Pour des raisons analogues, je n'ai pas eu recours ici à l'idiome d'affectation sécuritaire. Les opérations de copie me semblaient trop ciblées pour chercher à aller dans cette direction. Mon implémentation de push_back() repose sur un redimensionnement général suivi d'un ajout spécialisé. L'opération permettant de déterminer la nouvelle capacité est une λ locale à la fonction. Ce qui est probablement subtil dans tout cela est qu'il faut souvent laisser la représentation sous-jacente faire les opérations essentielles (copier les données, allouer des ressources, les libérer, etc.) mais qu'il faut aussi parfois copier un bloc de bytes bruts dans un autre, ces blocs bruts n'étant pas responsables de leur contenu. Dans le doute sur votre compréhension de l'une ou l'autre de ces manoeuvres, contactez votre chic prof! |
|
L'implémentation proposée en exemple expose quelques défis intéressants. J'essaierai dans cette section de mettre en relief les plus importants parmi ceux-ci, dans le but de clarifier votre lecture par la suite.
Le code suit. Explications à venir :
namespace v1 {
template <class C>
class basic_sso_str {
public:
using char_type = C;
using value_type = C;
using size_type = std::size_t;
private:
static constexpr auto data_size = 16;
union {
value_type small[data_size];
char_type *ptr;
} rep;
size_type nelems{}, cap{ data_size };
public:
size_type size() const noexcept {
return nelems;
}
size_type capacity() const noexcept {
return cap;
}
bool full() const noexcept {
return size() == capacity();
}
using iterator = char_type * ;
using const_iterator = const char_type*;
iterator begin() noexcept {
return uses_small_rep() ? std::begin(rep.small) : rep.ptr;
}
const_iterator begin() const noexcept {
return uses_small_rep() ? std::begin(rep.small) : rep.ptr;
}
iterator end() noexcept {
return begin() + size();
}
const_iterator end() const noexcept {
return begin() + size();
}
private:
template <class Pol>
void resize(Pol policy) {
using namespace std;
size_type newcap = policy(capacity());
assert(newcap >= capacity());
if (newcap > data_size) {
auto p = new char_type[newcap];
copy(begin(), end(), p);
if (!uses_small_rep()) delete[] rep.ptr;
rep.ptr = p;
cap = newcap;
}
}
bool uses_small_rep() const {
return capacity() == data_size;
}
public:
basic_sso_str() = default;
basic_sso_str(const basic_sso_str &other)
: nelems{ other.size() }, cap{ other.uses_small_rep() ? data_size : other.size() } {
if (!uses_small_rep())
rep.ptr = new char_type[capacity()];
std::copy(other.begin(), other.end(), begin());
}
void swap(basic_sso_str &other) {
using std::swap;
swap(nelems, other.nelems);
swap(cap, other.cap);
swap(rep, other.rep);
}
basic_sso_str& operator=(const basic_sso_str &other) {
basic_sso_str{ other }.swap(*this);
return *this;
}
template <int N>
basic_sso_str(const char_type(&p)[N]) {
assert(!p[N - 1]);
if constexpr (N > data_size) {
rep.ptr = new char_type[N];
cap = N;
}
nelems = N - 1;
std::copy(std::begin(p), std::end(p) - 1, begin());
}
~basic_sso_str() {
if (!uses_small_rep()) delete rep.ptr;
}
void push_back(char_type c) {
if (full()) resize([](auto n) { return n * 2; }); // no need for the 0 capacity case
if (uses_small_rep()) {
rep.small[size()] = c;
} else {
rep.ptr[size()] = c;
}
++nelems;
}
};
}
L'implémentation proposée en exemple expose quelques défis intéressants. J'essaierai dans cette section de mettre en relief les plus importants parmi ceux-ci, dans le but de clarifier votre lecture par la suite.
Le code suit. Explications à venir :
namespace v2 {
template <class C>
class basic_sso_str {
public:
using char_type = C;
using value_type = C;
using size_type = std::size_t;
using iterator = char_type * ;
using const_iterator = const char_type*;
private:
static constexpr auto data_size = 16;
class small {
value_type data[data_size / sizeof(value_type)];
size_type nelems{};
bool full() const noexcept { return size() == capacity(); }
public:
small() = default;
template <class It>
small(It debut, It fin) : nelems(std::distance(debut, fin)) {
assert(size() < capacity());
std::copy(debut, fin, begin());
}
auto size() const noexcept { return nelems; }
auto capacity() const noexcept { return std::size(data); }
iterator begin() noexcept { return std::begin(data); }
const_iterator begin() const noexcept { return std::begin(data); }
iterator end() noexcept { return begin() + size(); }
const_iterator end() const noexcept { return begin() + size(); }
void push_back(value_type c) {
assert(!full());
data[size()] = c;
++nelems;
}
};
class big {
value_type *data;
size_type nelems{}, cap{ };
bool full() const noexcept { return size() == capacity(); }
public:
big(size_type cap) : data{ new value_type[cap] }, cap{ cap } {
}
template <class It>
big(It debut, It fin) : nelems(std::distance(debut, fin)) {
cap = size();
data = new value_type[capacity()];
std::copy(debut, fin, begin());
}
// prudence : le choix de dupliquer la capacité est délibéré ici
big(const big &other)
: data{ new value_type[other.capacity()] }, nelems{ other.size() }, cap{ other.capacity() } {
std::copy(other.begin(), other.end(), begin());
}
big(big &&other) noexcept
: data{ std::exchange(other.data, nullptr) },
nelems{ std::exchange(other.nelems, 0) },
cap{ std::exchange(other.cap, 0) } {
}
void swap(big &other) noexcept {
using std::swap;
swap(data, other.data);
swap(nelems, other.nelems);
swap(cap, other.cap);
}
big& operator=(const big &other) {
big{ other }.swap(*this);
return *this;
}
big& operator=(big &&other) noexcept {
if (this != &other) {
delete[] data;
data = std::exchange(other.data, nullptr);
nelems = std::exchange(other.nelems, 0);
cap = std::exchange(other.cap, 0);
}
return *this;
}
template <class It>
void assign(It debut, It fin) {
size_type n = std::distance(debut, fin);
assert(n <= capacity());
std::copy(debut, fin, begin());
nelems = n;
}
auto size() const noexcept { return nelems; }
auto capacity() const noexcept { return cap; }
iterator begin() noexcept { return data; }
const_iterator begin() const noexcept { return data; }
iterator end() noexcept { return begin() + size(); }
const_iterator end() const noexcept { return begin() + size(); }
void push_back(value_type c) {
assert(!full());
data[size()] = c;
++nelems;
}
};
std::variant<small, big> rep {}; // initializes small
public:
size_type size() const noexcept {
return visit([](const auto &rep) { return rep.size(); }, rep );
}
size_type capacity() const noexcept {
return visit([](const auto &rep) { return rep.capacity(); }, rep);
}
bool full() const noexcept {
return size() == capacity();
}
iterator begin() noexcept {
return visit([](auto &rep) { return rep.begin(); }, rep);
}
const_iterator begin() const noexcept {
return visit([](const auto &rep) { return rep.begin(); }, rep);
}
iterator end() noexcept {
return visit([](auto &rep) { return rep.end(); }, rep);
}
const_iterator end() const noexcept {
return visit([](const auto &rep) { return rep.end(); }, rep);
}
private:
template <class Pol>
void resize(Pol policy) {
using namespace std;
size_type newcap = policy(capacity());
assert(newcap >= capacity());
big s(newcap);
s.assign(begin(), end());
rep = s;
}
public:
basic_sso_str() = default;
template <int N>
basic_sso_str(const char_type(&p)[N]) {
assert(!p[N - 1]);
if constexpr (N < data_size / sizeof(char_type)) {
rep = small{ std::begin(p), std::end(p) };
} else {
rep = big{ std::begin(p), std::end(p) };
}
}
void push_back(char_type c) {
if (full()) resize([](auto n) { return n * 2; }); // no need for the 0 capacity case
visit([c](auto &rep) { rep.push_back(c); }, rep);
}
};
}
Voilà, donc. Un exercice fort amusant!
L'affichage sur un flux est banal, et repose sur une algorithmique d'itérateurs, pleinement détachée du substrat. Ceci met d'ailleurs en relief la beauté de cette métaphore. |
|
À l'image des chaînes de caractères standards, exprimées de manière générique par std::basic_string et spécialisées sous la forme de std::string pour les char et sous la forme de std::wstring pour le type wchar_t, j'ai spécialisé basic_sso_string pour les types char et wchar_t. Simple et efficace. |
|
L'ami Jérôme Rampon, qui s'est amusé avec cet exemple, m'a écrit la remarque suivante (je paraphrase légèrement) :
« [...] Dans ton exemple, sur small_data, tu utilises le premier bit MSB (bit le plus plus significatif) du premier byte de data_ pour indiquer qu'il s'agit d'un small_data. Ce choix te coûte un byte au complet pour avoir utilisé un bit.
Pour le fun, même si c'est un peu plus lent à l'exécution, tu aurais pu aussi utiliser un truc un peu différent en jouant sur un masquage de l'adresse de data_. En effet, vu que tu as a priori un alignement en mémoire des données sur au moins 4 bytes minimum, il te reste deux bits de masque pour « faire du booléen ». C'est pas une approche qui est forcément appréciée par tout le monde, mais SSO est déjà une optimisation de bas niveau qui joue fort le transtypage.
Avec cette technique, il y a une perte en vitesse lors des accès aux éléments de data_ parce qu'il faut masquer à chaque fois l'adresse; par contre, tu peux ainsi gagner un byte sur small_data. ».
Si vous en avez envie, implémenter SSO de cette manière peut être amusant : plutôt que de jouer sur un bit du byte le plus significatif, vous pouvez mentir sur l'adresse vers laquelle mène le pointeur de données en masquant l'un de ses bits pour représenter le concept de « petite » ou de « grosse » chaîne. Un chouette exercice... Merci!
Pour des textes d'autres sources :