Les attributs (C++11)
- Qu’est-ce qu’un attribut ?
- Historique
- Syntaxe et placement
- La règle d’ignorabilité
-
Les attributs standards
- [[noreturn]] (C++11)
- [[carries_dependency]] (C++11, retiré en C++26)
- [[deprecated]] et [[deprecated("explanation")]] (C++14)
- [[nodiscard]] (C++17) et [[nodiscard("explanation")]] (C++20)
- Le symbole _ n’est pas spécial
- [[maybe_unused]] (C++17)
- [[fallthrough]] (C++17)
- [[likely]] et [[unlikely]] (C++20)
- [[no_unique_address]] (C++20)
- [[assume(expression)]] (C++23)
- [[clang::lifetimebound]] / [[msvc::lifetimebound]] (Extensions propriétaires)
- [[indeterminate]] (C++26)
- [[optimize_for_synchronized]] (TM TS)
Les attributs, des informations à destination du compilateur et du programmeur qui disparaissent à la compilation.
Qu’est-ce qu’un attribut ?
Les attributs sont des métadonnées standardisées (syntaxe: [[attribute]]) servant de directives ou d’indices pour le compilateur (pour optimiser le binaire final ou lever des warnings ciblés) et d’indications pour les développeurs.
Définir une variable, une fonction ou un type n’est parfois pas suffisant. On souhaiterait préciser qu’une variable n’est potentiellement pas utilisée (sans que ce soit considéré comme une erreur), qu’une fonction est obsolète ou qu’il ne faut pas ignorer la valeur retournée par une fonction.
En somme, il s’agit d’expliciter une intention qui n’altère pas la logique pure de l’application mais qui guide le compilateur dans ses optimisations tout en apportant des informations cruciales aux développeurs quant à l’usage de nos variables, fonctions ou types.
Historique
Avant l’arrivée des attributs (en C++11), chaque compilateur proposait sa propre manière de définir ces métadonnées, avec des syntaxes qui leur sont propres, spécifiques à chaque compilateur et incompatibles entre elles:
-
Microsoft (MSVC) utilise le mot-clef
__declspec(...). Exemples:align(N)(alignement),dllexport/dllimport(exportation/importation),novtable(optimisation désactivant la vtable). -
GNU (GCC) et Clang utilisent le mot-clef
__attribute__((...)). Exemples:packed(layout compact),always_inline(force l’inlining),section("nom")(voir la liste complète). - IBM possède également ses propres attributs de types et attributs de fonctions².
Ces syntaxes alternatives et non standard posent problème pour l’écriture de codes portables. Une écriture normalisée a donc été ajoutée au standard depuis C++11, offrant aux compilateurs une manière uniforme de spécifier des attributs.
Syntaxe et placement
La syntaxe standardisée utilise les doubles crochets: [[attribute]]. L’emplacement de l’attribut détermine l’entité sur laquelle il s’applique:
-
Sur une fonction:
[[attribute]] void fonction();s’applique à la déclaration de la fonction. -
Sur une variable:
int variable [[attribute]];ou[[attribute]] int variable;s’applique à la variable. -
Sur un type:
struct [[attribute]] Structure {};s’applique à la définition du type (et donc à toutes ses instances). -
Sur une structure de contrôle:
if (condition) [[attribute]]. -
Sur un bloc:
[[attribute]] { ... }.
Les attributs ne doivent pas être confondus avec les annotations de métadonnées (comme
[[=serialisable]]), introduites en C++26 pour la réflexion statique. Bien que partageant la même syntaxe de doubles crochets, les annotations ne guident pas le compilateur et sont traitées séparément. Pour plus de détails, consultez l’article sur la reflection et les metaclasses.
Namespaces (Depuis C++11)
Le standard (depuis C++11) permet d’organiser les attributs au sein de namespaces. Cela permet d’une part d’identifier clairement l’autorité ou le fournisseur (compilateur, outil d’analyse statique…) qui définit et prend en charge l’attribut, et d’autre part d’éviter les collisions de noms entre les attributs standards et ces extensions spécifiques.
-
Les attributs standards (comme
[[nodiscard]]ou[[deprecated]]) sont placés dans le namespace global et s’écrivent sans préfixe. -
Les attributs spécifiques à un compilateur ou à un outil (comme
[[gnu::always_inline]],[[msvc::no_unique_address]]ou[[clang::fallthrough]]) sont préfixés par le namespace correspondant au fournisseur.
Attention à la confusion: Ces namespaces, bien qu’ayant une syntaxe (
::) et un rôle similaire aux namespaces classiques du C++, ces namespaces sont totalement indépendants et n’ont rien à voir l’un avec l’autre. Ils sont traités à part par le compilateur et n’interviennent pas dans la résolution de portée de vos variables ou fonctions.
Cet article n’a pas vocation à détailler l’intégralité des attributs non-standards (à l’exception de quelques extensions majeures comme
[[clang::lifetimebound]]abordé en fin d’article). Je vous laisse vous référer aux liens ci-dessus et faire vos propres recherches pour le reste. En revanche, nous aborderons bien l’ensemble des attributs standards.
Factorisation (Depuis C++17)
Il est possible de déclarer plusieurs attributs au sein d’une même paire de doubles crochets en les séparant par des virgules (par exemple [[attribut1, attribut2]]).
Lorsque vous utilisez plusieurs attributs provenant d’un même namespace, répéter le préfixe pour chaque attribut peut être laborieux:
[[gnu::always_inline, gnu::hot, gnu::const]] void function();C++17 introduit la syntaxe using au début de la liste d’attributs pour spécifier un namespace commun à appliquer à tous les attributs de la liste:
[[using gnu : always_inline, hot, const]] void function();Si un
usingest présent au début d’un spécificateur d’attribut, aucun autre attribut de cette liste ne peut spécifier de namespace.
Par exemple, combinerusinget un attribut préfixé comme dans[[using CC: CC::opt(1)]]provoque une erreur de compilation.
Si vous devez mélanger des attributs de différents namespaces ou des attributs standards sans namespace, vous devez utiliser des séquences d’attributs distinctes:[[using gnu : always_inline, hot]] [[nodiscard]].
Positionnement dans les lambdas (Depuis C++11 / C++23)
Le positionnement des attributs dans une lambda dépend de la version du standard et de la cible de l’attribut (cppreference):
[captures] <tparams>(optional) t-requires(optional) front-attr(optional) (params) specs(optional) except(optional) back-attr(optional) trailing(optional) requires(optional) contract-specs(optional) { body }
-
Les
back-attr(Depuis C++11): positionnés après les paramètres et les spécificateurs (commemutableouconstexpr) et les spécifications d’exceptions (commenoexcept). Ces attributs s’appliquent au type de l’operator().Pour cette raison, il est impossible d’utiliser des attributs comme
[[nodiscard]]ou[[deprecated]]à cet emplacement (ils s’appliquent à la fonction elle-même, pas à son type). Seul[[noreturn]]y était toléré par dérogation historique (bien que cela fût considéré comme un défaut de conception du standard). -
Les
front-attr(Depuis C++23): positionnés après les captures[...](ou après les paramètres template<...>etrequiressi présents), ou plus simplement juste avant les paramètres de la lambda (proposal). Cet emplacement s’applique directement à l’operator()lui-même. C’est ce nouvel emplacement qui permet d’appliquer proprement[[nodiscard]],[[deprecated]]ou[[noreturn]]sur une lambda.
// Avant C++23: Aucun moyen standard de marquer une lambda comme [[nodiscard]]
auto lambda0 = [] (int value) [[nodiscard]] { return value * 2; }; // Erreur de syntaxe
// Depuis C++23 (placement en front-attr, avant les paramètres)
auto lambda1 = [] [[nodiscard]] (int value) { return value * 2; };
// Depuis C++23 (placement de [[noreturn]] en front-attr):
auto lambda2 = [] [[noreturn]] () { throw std::runtime_error("Erreur"); };La règle d’ignorabilité
Les attributs obéissent à la règle d’ignorabilité (ignorability rule). Celle-ci garantit que l’ajout ou le retrait d’un attribut ne doit jamais altérer la sémantique de conformité du programme (c’est-à-dire que le code doit compiler et produire le même résultat fonctionnel, avec ou sans l’attribut). Et qu’un attribut non reconnu par le compilateur doit simplement être ignoré.
Cette règle s’applique de deux manières complémentaires:
-
Ignorabilité sémantique: Le comportement d’un programme valide ne doit pas dépendre de la présence ou de l’absence d’un attribut. Même si le compilateur reconnait un attribut, sa suppression ne doit pas casser le bon fonctionnement logique de l’application. Ils ne servent qu’à ajouter des sécurités (via des warnings) ou des optimisations de performances.
-
Ignorabilité syntaxique (Depuis C++17): Les compilateurs sont tenus de reconnaître la syntaxe globale des attributs (
[[...]]) même s’ils ne comprennent pas ce qu’il y a dedans. Avant C++17, un compilateur qui rencontrait un namespace propriétaire inconnu (comme[[gnu::always_inline]]compilé avec MSVC) était autorisé à rejeter le code et à interrompre la compilation. Depuis C++17 (proposal), le standard exige qu’un attribut inconnu (qu’il soit standard ou propriétaire avec namespace) soit ignoré sans bloquer la compilation. Cela garantit la portabilité du code source (bien que le compilateur conserve le droit d’émettre un warning pour tout attribut non reconnu).
Compatibilité entre versions et compilateurs
Grâce à cette règle, vous pouvez tout à fait utiliser des attributs récents (qu’ils soient standards ou non) sans casser le build sur des configurations qui ne les supportent pas encore:
-
Utiliser un attribut standard récent (comme
[[assume]]standardisé en C++23) ou un attribut propriétaire (comme[[clang::lifetimebound]]) sur un compilateur plus ancien ou configuré dans une norme précédente (comme C++11). N’étant pas reconnu, l’attribut sera simplement ignoré (le compilateur conservant le droit d’émettre un warning). -
Utiliser des attributs spécifiques à un compilateur (comme
[[gnu::always_inline]]ou[[msvc::no_unique_address]]) sur un autre compilateur (comme Clang compilant du code GCC, ou inversement). L’attribut propriétaire sera simplement ignoré sans erreur de build (et potentiellement avec un warning). Ceci permet à chaque compilateur de proposer ses propres attributs spécifiques, en plus de supporter les attributs standards en suivant une syntaxe uniforme.
-Werror et warnings d’attributs inconnus
Comme nous venons de le voir, exploiter la règle d’ignorabilité apporte des avantages pour la portabilité des projets d’un compilateur à un autre et d’une version du standard à une autre.
Mais le compilateur peut émettre un warning pour signaler qu’il ne reconnaît pas un attribut.
Cela peut être utile pour nous avertir d’une faute de frappe (par exemple un [[nodiscar]] dans lequel on aurait oublié le d).
Mais en dehors de ce cas précis, ce warning est plus une entrave à la portabilité qu’un message utile.
De plus, si l’option de compilation -Werror est activée (flag de compilation recommandé, qui transforme tout warning en erreur bloquante), alors le moindre warning d’attribut non reconnu bloquera la compilation. Ce n’est pas souhaitable.
Pour cette raison, je recommande de désactiver systématiquement les warnings pour attributs inconnus avec le flag -Wno-unknown-attributes sur GCC/Clang, et /wd5030 sur MSVC, afin de bénéficier pleinement de la portabilité syntaxique de C++17.
Une exception à la règle D’une manière générale, désactiver des warnings de compilation est une mauvaise pratique qu’il faut absolument éviter. Cette recommandation constitue toutefois une exception rationnelle: le bénéfice apporté par la portabilité et l’écriture fluide d’attributs natifs l’emporte ici sur le compromis (le risque de faute de frappe, qui peut de toute façon être intercepté par des linters ou outils d’analyse statique).
Une approche alternative, plus verbeuse, consiste à passer par des macros pour vérifier le support de chaque attribut:
Détecter le support d’un attribut
Pour gérer finement la compatibilité et éviter les warnings sur les compilateurs ne supportant pas un attribut, le standard fournit la macro de préprocesseur __has_cpp_attribute(nom_attribut).
Introduite formellement dans le standard en C++20 (proposal), elle faisait déjà l’objet de recommandations depuis 2014 (SD-6 (Standing Document 6)), ce qui explique sa présence et son support par Clang, GCC et MSVC bien avant C++20.
Elle renvoie une valeur entière non nulle (généralement la date de standardisation sous la forme AAAAMML, avec “AAAA” pour l’année et “MM” pour le mois suivi du literal L) si l’attribut est supporté, ou 0 s’il ne l’est pas:
#if __has_cpp_attribute(nodiscard)
# define NODISCARD [[nodiscard]]
#else
# define NODISCARD
#endif
NODISCARD int* getData();Cela fonctionne également pour tester le support des attributs propriétaires en incluant leur namespace (ex:
__has_cpp_attribute(gnu::always_inline)).
Cette approche par macros présente toutefois des inconvénients notables. En C++, l’usage de macros préprocesseur est généralement considéré comme une solution inélégante (les macros étant globales et dépourvues de typage). Elles obligent à déclarer et importer ces définitions presque partout dans votre base de code. Cette mécanique est d’autant plus indésirable et “sale” dans un projet moderne exploitant les modules (C++20).
C’est pourquoi je recommande plutôt de s’affranchir de cette tuyauterie de macros en privilégiant l’approche par désactivation spécifique du warning au niveau de votre compilateur.
Les attributs standards
Nous allons maintenant voir les différents attributs standards à connaitre (dans l’ordre de leur intégration au langage).
[[noreturn]] (C++11)
Contrairement à ce que son nom peut laisser penser, cet attribut ne veut pas dire que la fonction ne retourne rien (void), mais qu’elle ne retourne pas, c’est-à-dire que la fonction ne se termine pas de manière conventionnelle (en atteignant la fin de son scope ou en atteignant un return).
Concrètement, l’attribut [[noreturn]] informe le compilateur qu’une fonction ne rend jamais la main à son appelant.
Il est utilisé pour les fonctions qui terminent le programme (std::exit, std::abort), qui lèvent systématiquement des exceptions ou qui contiennent des boucles infinies.
En garantissant qu’aucun flux d’exécution ne sortira de la fonction, il permet au compilateur de considérer tout code situé après l’appel comme du code mort, supprimant ainsi les warnings sur les chemins de retour manquants et autorisant des optimisations de branchement agressives.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[[noreturn]] void fatalError(std::string_view message)
{
std::cerr << message << std::endl;
std::abort();
}
int selectValue(int option)
{
switch(option)
{
case 1: return 42;
case 2: return 100;
default: fatalError("Option inconnue!");
}
}
Sans [[noreturn]] sur fatalError(), le compilateur lèverait ici un warning car la branche default ne retourne rien:
- “control reaches end of non-void function” sur GCC
- “non-void function does not return a value in all control paths” sur Clang
Attention, si une fonction marquée
[[noreturn]]finit par retourner normalement, le comportement est indéfini (UB).
[[carries_dependency]] (C++11, retiré en C++26)
Cet attribut est l’un des plus complexes du standard. Son objectif était de propager les dépendances à travers les appels de fonctions afin de permettre des optimisations de synchronisation sur certaines architectures.
Il est exclusivement lié au modèle mémoire release-consume (std::memory_order_consume) utilisé en programmation multithreadée bas niveau.
Il faut bien distinguer le fait que c’est le modèle mémoire std::memory_order_consume qui apporte des optimisations, et non [[carries_dependency]]. Cet attribut sert uniquement à apporter des garanties de propagation de dépendances entre fonctions.
Nous allons détailler ce que ça signifie.
Le modèle Release-Consume
Pour comprendre cet attribut, il faut d’abord comprendre le modèle de mémoire Release-Consume.
Sur des architectures matérielles faiblement ordonnées comme ARM ou PowerPC, le compilateur (lors de l’optimisation du code à la compilation) ou le processeur (au runtime pour maximiser l’efficacité du pipeline) peuvent agressivement réordonner l’ordre des accès mémoire.
// Ordre logique (écrit par le développeur)
data = 42;
ready = true;// Ordre d'exécution (réordonné par le compilateur ou le CPU)
ready = true;
data = 42;Dans un programme mono-threadé (comme en langage C), le compilateur et le CPU fonctionnent sous le principe de la règle du “as-if” (“comme si”):
Ils ont le droit de réordonner toutes les instructions pour gagner en performance, tant que le comportement final effectué sur ce thread reste identique à une exécution séquentielle, ligne par ligne.Note sur le multithreading: Historiquement, cette règle a été conçue avec l’hypothèse d’une exécution à un seul thread.
Avec l’introduction d’un modèle de mémoire formel en C++11 (et C11), les limites du as-if ont été redéfinies. Le compilateur et le CPU conservent leur liberté d’optimisation, mais celle-ci est désormais contrainte: elle ne doit jamais introduire de data races ni altérer l’ordre d’accès requis par les mécanismes de synchronisation et les opérations atomiques (commestd::memory_order).Dans l’exemple ci-dessus, puisque
dataetreadysont indépendantes, intervertir leur écriture n’a aucun impact pour le thread qui exécute ce code. C’est uniquement lorsque d’autres threads lisent ces variables en parallèle que le réordonnancement provoque un bug.
Quand le réordonnancement est impossible: La dépendance de données
Il existe des cas où le compilateur et le processeur n’ont pas le droit de réordonner deux instructions, même pour optimiser les performances: lorsque la seconde instruction dépend directement du résultat de la première.
auto x = 10;
auto y = x + 5; // Impossible à réordonner. y dépend de xPuisque le calcul de y nécessite la valeur de x, le CPU et le compilateur sont forcés d’exécuter l’initialisation de x en premier. C’est ce qu’on appelle une dépendance de données. C’est important de connaitre ce terme pour la suite.
Le matériel et la dépendance de données
Sur ARM et PowerPC, cette garantie de dépendance de données s’applique au niveau matériel, y compris pour les accès mémoire via des pointeurs. Si vous lisez un pointeur puis lisez une valeur pointée par ce pointeur, le processeur garantit naturellement l’ordre des lectures sans barrière mémoire.
Imaginons le scénario d’une liste chaînée partagée entre deux threads:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct Node
{
int value;
Node* next;
};
auto head = std::atomic<Node*>{nullptr};
// Thread A (Producteur)
Node* node = new Node{42, nullptr};
head.store(node, std::memory_order_release); // Publie le noeud
// Thread B (Consommateur)
Node* node = head.load(std::memory_order_consume); // Charge le pointeur
if (node)
{
auto value = node->value; // Accès dépendant!
}
Ici, l’accès à node->value nécessite de connaître la valeur de node. Il y a une dépendance de données évidente (on appelle cela une address dependency). Sur ARM et PowerPC, le processeur garantit que node sera lu avant node->value (et pas réordonné avant).
Mais le point que je voudrais soulever ici n’est pas simplement: “lire
nodeavantnode->value”.
Ca, tous les processeurs le font.La propriété importante ici est que les écritures publiées avec un
store(release)deviennent visibles lors d’un chargement dépendant (load(consume)).
Grâce à cette dépendance d’adresse, ARM et PowerPC garantissent quenode->valueverra bien les écritures publiées avant lestore(release)(donc l’affectation de la valeur42).
Avec
std::memory_order_acquire, le compilateur peut insérer une barrière matérielle (comme une instructionLDARsur ARMv8, ouDMBsur ARMv7) garantissant que toutes les écritures publiées avant unstore(release)soient visibles après le chargement.
Cette synchronisation peut réduire les possibilités d’optimisation du processeur et entraîner un coût en performances.
std::memory_order_consumeavait précisément pour objectif d’éviter cette synchronisation explicite en s’appuyant uniquement sur les dépendances d’adresse déjà garanties par le matériel. En pratique, cette approche s’est révélée trop complexe à implémenter correctement dans les compilateurs, qui traitent aujourd’hui presque tous lesconsumecomme desacquire.
Le problème des frontières de fonctions
Le compilateur sait suivre cette chaîne de dépendances à l’intérieur d’une même fonction. Cependant, dès que le pointeur est passé à une autre fonction définie dans une autre translation unit (par exemple, une bibliothèque compilée), le compilateur fait face à une opacité totale:
// myLibrary.h
// Fonction définie dans un autre fichier, le compilateur n'a pas accès à son code
auto readValue(Node* node) -> int;
// myProject.cpp
// Opacité: le compilateur ne sait pas si la fonction préserve la dépendance.
// Dans le doute, il doit insérer une barrière acquire (comportement équivalent à std::memory_order_acquire).
auto value = readValue(node);Afin de garantir la correction du code, le compilateur est contraint de générer une barrière mémoire d’acquisition avant l’appel de fonction, détruisant tout le gain de performance du modèle consume.
La solution: [[carries_dependency]]
L’attribut [[carries_dependency]] permet d’indiquer explicitement au compilateur que l’on souhaite propager la dépendance de données à travers les frontières d’appels de fonctions:
- Sur un paramètre: Il promet au compilateur que la fonction utilisera cet argument d’une manière qui préserve la dépendance matérielle.
- Sur le retour de la fonction: Il promet que le pointeur retourné propage la dépendance vers l’appelant.
Voici un exemple d’usage classique pour une fonction de parcours dans une liste chaînée:
struct Node
{
int value;
Node* next;
};
// Indique que la dépendance portée par 'current' entre dans la fonction,
// et que le pointeur retourné propage également cette dépendance chez l'appelant.
[[carries_dependency]] Node* getNext(Node* current [[carries_dependency]])
{
return current->next;
}Grâce à cette déclaration, le compilateur sait qu’il peut compiler l’appel de fonction sans insérer de barrière mémoire intermédiaire.
Pourquoi a-t-il été retiré en C++26 ?
En pratique, l’analyse statique des dépendances de données à travers les optimiseurs s’est révélée d’une complexité insurmontable pour les concepteurs de compilateurs. Suivre précisément le graphe d’instructions sans interrompre la dépendance (ce qui arrive par exemple si un pointeur est converti en entier puis restauré) s’est avéré trop instable.
C’est pourquoi, depuis l’introduction de cette mécanique en C++11, la totalité des compilateurs modernes (GCC, Clang, MSVC) ont choisi de ne pas suivre ces chaînes de dépendances: le modèle consume y est silencieusement promu en modèle mémoire acquire, et l’attribut [[carries_dependency]] y est tout simplement ignoré.
Puisque cet attribut n’était plus qu’une coquille vide jamais implémentée de manière effective, le comité du C++ a officiellement voté sa suppression de la norme C++26 (proposal).
Bien que cet attribut ait été supprimé, son étude reste intéressante pour comprendre le fonctionnement sous-jacent du modèle de mémoire du C++.
[[deprecated]] et [[deprecated("explanation")]] (C++14)
Au fil du cycle de vie d’un projet, les APIs évoluent: certaines fonctions, classes ou variables deviennent obsolètes.
Le concepteur de la bibliothèque fait alors face à un dilemme:
- Supprimer immédiatement l’ancienne API, cassant la compilation de tous les projets en dépendants.
- Laisser l’ancienne API intacte, laissant les développeurs continuer d’utiliser des éléments obsolètes sans savoir qu’ils doivent entâmer une migration.
Avant C++14, il n’existait aucun moyen standard d’avertir les utilisateurs à la compilation. On devait recourir à des commentaires, des précisions dans la documentation ou des macros préprocesseurs spécifiques à chaque compilateur (comme #pragma message).
Les commentaires et la documentation n’étant pas optimaux, car ils sont passifs et espèrent que les développeurs les lisent et relisent entre chaque version, il fallait trouver une approche active dans laquelle le compilateur averti lui-même de la future suppression des éléments obsolètes.
L’attribut [[deprecated]] a été ajouté en C++14 en tant qu’outil pratique pour ces situations, permettant de marquer officiellement une entité comme obsolète.
Le compilateur émettra un warning (ou une erreur de compilation, si -Werror est activé) à chaque fois que cette entité est utilisée. Un message peut être ajouté dans l’attribut pour accompagner le warning d’un message explicatif pour guider la migration. Par exemple en orientant les développeurs vers la nouvelle fonction à utiliser à la place.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// Sur une fonction ou méthode
[[deprecated("Use newFunction() instead.")]]
void oldFunction();
// Sur une classe, structure ou union
struct [[deprecated("Cette structure sera supprimée en v2.0.")]] OldStruct
{
int x;
};
// Sur un namespace
namespace [[deprecated("Use NewNamespace instead.")]] OldNamespace
{
void f();
}
// Sur une variable
[[deprecated]] inline int oldVariable = 42;
// Sur un alias
using OldAlias [[deprecated("Use NewAlias instead.")]] = int;
// Sur une valeur d'enum
enum class Status
{
Ok,
Ko,
[[deprecated("Use Ko instead.")]] Unknown
};
-
-Wno-deprecated-declarations(ou/wd4996sur MSVC): ignore les warnings liés aux entités dépréciées (déconseillé, car retire l’intérêt préventif de l’attribut[[deprecated]]).Note sur le warning C4996 (MSVC) Le warning
C4996de MSVC est utilisé à la fois pour signaler l’usage d’entités dépréciées par[[deprecated]]et pour les fonctions de la bibliothèque standard (ou POSIX) jugées “non sécurisées” par Microsoft (ex:strcpy,fopen). Activer/wd4996masquera donc également ces alertes de sécurité de la bibliothèque standard. -
-Wno-error=deprecated-declarations: empêche les warnings de dépréciation de bloquer la compilation sous-Werror. Je le recommande fortement dans tous les projets.⚠️ Comportement avec MSVC Microsoft Visual C++ ne possède pas d’équivalent direct à
-Wno-error=pour exempter un warning spécifique de l’effet global de/WX(warnings as errors). Si vous utilisez MSVC avec/WX, vous devez soit accepter que les warnings de dépréciation bloquent la compilation, soit désactiver complètement le warning globalement avec/wd4996(ou localement par pragma).
Astuce de migration: Si votre projet utilise le flag
-Werror, vous pouvez utiliser-Wno-error=deprecated-declarations(GCC/Clang) pour maintenir ces warnings au stade de simples messages sans bloquer la compilation, laissant ainsi le temps aux développeurs de migrer progressivement.
[[nodiscard]] (C++17) et [[nodiscard("explanation")]] (C++20)
L’attribut [[nodiscard]] impose une contrainte de traitement sur la valeur de retour. Il s’utilise sur les fonctions dont la valeur de retour ne doit pas être ignorée. Il peut s’appliquer également sur des types, auquel cas il s’applique automatiquement à toutes les fonctions retournant ce type.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Sur une fonction ou méthode
[[nodiscard]] int* getData();
// Sur une classe ou structure (toute fonction retournant ce type hérite de nodiscard)
struct [[nodiscard]] ErrorCode
{
int code;
};
ErrorCode getStatus(); // L'appelant doit récupérer la structure retournée
// Sur un constructeur (Depuis C++20)
struct Lock
{
[[nodiscard("Ressource RAII: doit être stockée dans une variable pour être active")]]
explicit Lock(std::mutex& m);
};
// Appeler Lock(m); (temporaire détruit immédiatement) lèvera un warning
Ainsi il est possible d’exprimer au développeur appelant une fonction (et au compilateur) qu’ignorer la valeur retournée par celle-ci constitue une erreur de logique, une mauvaise utilisation de la fonction (code d’erreur important ignoré, ou gestion de ressources RAII non-exploitée).
Prévention des fuites mémoire (RAII non exploité)
Ici, la fonction alloue une ressource et en transfère la propriété à l’appelant. Si la valeur de retour est ignorée, la seule référence à la ressource allouée est perdue, ce qui provoque immédiatement une fuite de mémoire:
1
2
3
4
5
6
7
8
9
[[nodiscard]] auto makeData() -> int*
{
return new int{42}; // Allocation dynamique
}
auto main() -> int
{
makeData(); // Warning du compilateur: le pointeur retourné est perdu, provoquant une fuite de mémoire
}
Erreur de logique ou fonction sans effet de bord
Si une fonction a pour unique rôle de renvoyer une valeur calculée ou d’inspecter l’état d’un objet (comme un getter, ou une fonction pure comme std::vector::empty()), l’appeler sans exploiter son retour constitue une erreur de logique évidente puisqu’aucun effet de bord n’est produit par l’appel:
1
2
3
4
5
6
7
8
9
[[nodiscard]] auto getNumber() -> int
{
return 42; // Le seul intérêt de la fonction réside dans sa valeur de retour
}
auto main() -> int
{
getNumber(); // Warning du compilateur: le résultat du calcul est jeté inutilement
}
L’attribut
[[nodiscard]]n’a de sens que si la fonction retourne une valeur. L’appliquer sur une fonction qui retournevoidpeut produire un warning (ou une erreur, si-Werrorest activé) de la part du compilateur (ex:error: attribute 'nodiscard' cannot be applied to functions without return value).
Message d’explication personnalisé (Depuis C++20) Il est possible d’associer un message explicatif en argument de l’attribut (ex :
[[nodiscard("Raison")]]). Ce texte sera alors affiché par le compilateur au sein du warning afin de clarifier la raison pour laquelle la valeur de retour ne doit pas être ignorée.
Applications étendues dans libc++ (LLVM)
L’implémentation de la bibliothèque standard par LLVM, libc++, adopte une politique très proactive vis-à-vis de [[nodiscard]]. Elle applique cet attribut de manière beaucoup plus large que ce qui est strictement exigé par la norme C++.
Par exemple, std::vector::empty() n’est pas officiellement [[nodiscard]] mais LLVM a décidé de lui appliquer quand même cet attribut dans la libc++.
Notez que c’est également le cas d’un certain nombre de fonctions similaires autour de cette portion de leur code (
begin,end,size,capacity, etc). Les fonctions y sont précédées d’un[[__nodiscard__]]non standard.J’ai essayé de remonter la source de ce
__nodiscard__pour savoir si c’est une macro définie comme#define __nodiscard__ nodiscardsous certaines conditions. Mais mes recherches s’avèrent sans succès, je n’ai pas trouvé de déclaration. C’est donc probablement un attribut propriétaire reconnu directement par Clang.
LLVM a pris la liberté de ce choix pour détecter les bugs fréquents liés à des oublis pour les appels qui n’ont à priori pas de sens d’être appelés en étant ignorés. Ce n’est donc pas une mauvaise chose.
Mais ça peut surprendre, notamment dans le cas de std::unexpected. Il m’est personnellement arrivé de vouloir en ignorer dans le cadre de tests unitaires très précis. J’ai donc dû trouver une alternative pour ignorer la valeur de retour.
A noter que c’est aussi le cas des constructeurs de std::unique_lock.
Si vous souhaitez désactiver cette liberté prise par LLVM, il est possible d’agir dessus en définissant une ou plusieurs des macros suivantes (à définir avant l’inclusion des headers standards):
_LIBCPP_ENABLE_NODISCARD: Active les applications étendues de[[nodiscard]]par la libc++ sur les éléments non spécifiés (inclut à la fois les extensions propres à la libc++ et le rétroportage de[[nodiscard]]issus de standards plus récents)._LIBCPP_DISABLE_NODISCARD_EXT: Désactive uniquement les extensions propres à la libc++, mais conserve le rétroportage de[[nodiscard]]issus de standards plus récents (comme le rétroportage de C++20 vers C++17)._LIBCPP_DISABLE_NODISCARD_AFTER_CXX17: Désactive les warnings pour les éléments marqués[[nodiscard]]uniquement depuis les standards postérieurs à C++17.
Ignorer un [[nodiscard]]
Il arrive parfois (rarement) qu’on veuille ignorer une valeur [[nodiscard]] retournée par une fonction (par exemple dans un test unitaire qui n’a pas besoin d’exploiter une donnée retournée).
Il existe 5 manières d’ignorer explicitement la valeur [[nodiscard]] retournée par une fonction, et faire taire volontairement le warning du compilateur:
-
(void)function();: Cast C envoid. Les casts C sont fortement déconseillés en C++ (car il existe un équivalent C++ plus propre: le suivant dans cette liste); -
static_cast<void>(function());: Cast C++ envoid. Très efficace, c’est la seule manière propre de faire avant C++11; -
std::ignore = function();: L’utilitairestd::ignore(C++11) apporte une solution plus explicite sur l’intention du développeur. Son utilisation ici est détournée, car initialement conçu pourstd::tie(std::tie(std::ignore, result) = set.insert(value);).std::ignoreest un objet proxy utilisé pour ignorer une assignation.[[nodiscard]]n’est donc pas ignoré mais sa valeur retournée est assignée à un objet proxy. Retenez questd::ignoren’est pas conçu pour ça et que les compilateurs n’ont pas d’obligation stricte à adopter ce comportement; -
[[maybe_unused]] auto variable = function();: N’ignore pas[[nodiscard]]mais permet de stocker le résultat dans une variable en la signalant comme non utilisée, sans que ce soit considéré comme une erreur par le compilateur. Voir la section sur[[maybe_unused]] (C++17); -
auto _ = function();: Les name-independent declarations (C++26) permettent d’ignorer la valeur en déclarant une variable anonyme.
Name-independent declaration (C++26)
En C++26, le nom de variable _ prend un sens particulier. Connu sous le nom “name-independent declaration” ou “placeholder variable with no name” (C++26) (Proposal), il permet d’exprimer explicitement l’intention d’ignorer une valeur (construite ou retournée par une fonction). Cette variable ne doit pas être utilisée comme une variable normale.
Les name-independent declarations peuvent être déclarées plusieurs fois dans le même scope:
auto _ = foo();
auto _ = bar(); // OK depuis C++26 (pas avant)être utilisées dans des structured bindings pour ignorer une ou plusieurs composantes:
auto [x, _] = pair();être utilisées dans l’init-capture des lambdas:
[ _ = f() ]{};Attention, il ne s’agit pas d’un réel placeholder comme en Rust (placeholder en assignation ou en tant que pattern (wildcard)).
En C++,_est un pseudo-placeholder. Ca reste considéré comme un nom de variable.
auto _ = foo();
bar(_); // valide syntaxiquement
auto x = _; // valide égalementCette “variable” n’est pas destinée à être utilisée. Et depuis C++26, le compilateur comprend l’intention d’ignorer la valeur, et accepte plusieurs variables portant ce nom dans un même scope.
Par exemple, le code suivant appelle bien le constructeur de l’objet retourné. Aucune optimisation n’est faite pour supprimer l’objet de la compilation:
struct Foo
{
Foo() { std::cout << "Foo{};" << std::endl; }
};
auto _ = Foo{};Son destructeur sera aussi appelé automatiquement à la fin du scope de la variable _. Et ce, même si plusieurs variables _ coexistent dans le même scope.
Il en va de même pour les fonctions qui retournent un objet.
A noter qu’utiliser une variable
_comme une variable normale n’est pas UB en C++. Son comportement est défini mais est spécial, différent du comportement des variables normales:
Les déclarations sont sans identité nominale stable (name-independent):
auto _ = 42;
auto x = _; // valideLes règles de conflits changent, plusieurs _ peuvent exister sans collision:
auto _ = 1;
auto _ = 2;
auto x = _; // Erreur: ambiguïté/ill-formed (pas UB)En bref, l’intention du standard est d’en faire une variable utile pour ignorer des valeurs sans changer fondamentalement son fonctionnement, tout en déconseillant fortement son utilisation après initialisation. Cette variable n’est plus faite pour cela depuis C++26.
Le symbole _ n’est pas spécial
Le nom _ ne devient pas “spécial” en C++26. Il n’a pas de sémantique globale absolue.
Il devient “spécial” uniquement s’il est utilisé dans une déclaration name-independent valide. Dans les autres cas, il reste un identifiant normal.
Cas global (non spécial)
Dans la portée d’un namespace (ce qui inclut le scope global), _ reste un nom de variable classique. Il suit les règles normales de linkage et de déclaration.
int _;
int _; // ❌ Erreur: redefinition of '_'Aucun mécanisme name-independent ne s’applique ici. L’identifiant _ subit un conflit de redéfinition comme n’importe quel autre nom.
Contraste avec le scope local (C++26)
Dans un scope local, le comportement est fondamentalement différent:
auto _ = 1;
auto _ = 2; // OK depuis C++26La différence réside dans le contexte:
-
Global scope:
_est un identifiant normal, les collisions sont interdites. -
Local name-independent declaration:
_est une entité sans identité nominale stable, les collisions sont autorisées.
Ce n’est donc pas “le symbole _ qui est spécial en C++26”, mais plutôt “certaines déclarations portant le nom _ qui sont traitées comme name-independent”.
[[maybe_unused]] (C++17)
Par défaut, les compilateurs modernes émettent des warnings (-Wunused-variable ou -Wunused-parameter) lorsqu’une variable, une fonction ou un paramètre est déclaré mais n’est pas utilisé dans le code. C’est utile d’en être averti pour repérer du code mort ou des erreurs de logique.
Cependant, il existe des situations légitimes où une entité peut (d’où le maybe) rester inutilisée. Parmi ces situations:
- Le développement de bibliothèques (APIs): Une bibliothèque peut exposer des fonctions destinés à ses utilisateurs, mais sans qu’elle-même n’ai besoin de les utiliser en interne.
- La programmation générique (héritage ou template): Dans une fonction surchargée (override, par héritage) ou templatée, certains paramètres peuvent être ignorés, car inutiles dans certaines surcharges ou instantiations, mais utilisés pour d’autres.
Avant C++17, pour faire taire ces warnings, les développeurs devaient utiliser des solutions de contournement inélégantes comme un cast vers void:
void process(int option)
{
static_cast<void>(option); // Ancienne méthode pour supprimer le warning
}L’attribut [[maybe_unused]] résout ce problème de manière propre et standardisée en indiquant explicitement au compilateur (et aux autres développeurs) que cette absence d’utilisation est intentionnelle. Il peut être placé sur un objet, une classe, une fonction, une variable ou un argument.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// Sur une classe ou structure
struct [[maybe_unused]] Status
{
int code;
};
// Sur une fonction
[[maybe_unused]] void disableLogger()
{
// Cette fonction n'est appelée nulle part, mais ne déclenchera pas de warning.
}
// Sur un argument de fonction
void process([[maybe_unused]] int option)
{
// option n'est pas lue ici, mais aucun warning n'est émis.
}
// Sur une variable locale ou globale
[[maybe_unused]] auto debugMode = true;
Il s’avère particulièrement utile dans les templates:
template<class ReturnType, class... Args, std::size_t... I>
[[nodiscard]] constexpr auto call([[maybe_unused]] std::span<std::byte> stack, ReturnType(*function)(Args...), [[maybe_unused]] std::index_sequence<I...> indexSequence) -> decltype(auto)
{
[[maybe_unused]] auto arguments = std::make_tuple(get<std::remove_reference_t<Type::NthType<I, Args...>>>(stack, getPaddingAfterT<I, std::remove_reference_t<Args>...>())...);
return std::invoke(function, std::forward<Args>(std::get<I>(arguments))...);
}Depuis C++26, déclarer une variable avec le wildcard _ la rend implicitement [[maybe_unused]].
C++26 et le wildcard
_: Depuis C++26, nommer une variable_(underscore seul) la rend implicitement[[maybe_unused]].
auto [value, _] = getPair(); // Le second élément est ignoré sans warning
[[fallthrough]] (C++17)
Dans un switch, chaque case est généralement terminé par une instruction break;, sans quoi le code du case suivant est également exécuté, et ainsi de suite.
Il arrive qu’un break; soit oublié par inadvertance, mais il peut aussi être omis volontairement pour enchaîner l’exécution sur le case suivant.
switch (status)
{
case Status::Initialization:
prepare();
// Pas de break; ? Est-ce intentionnel ?
case Status::Processing:
execute();
break;
}Cela pose un problème pour les relecteurs du code qui ne savent pas s’il s’agit d’une intention ou d’un oubli.
L’attribut [[fallthrough]] permet de rendre cette intention explicite. Couplé avec le flag de compilation -Wimplicit-fallthrough, le compilateur signalera tout oubli de break; non annoté avec [[fallthrough]].
L’attribut [[fallthrough]] doit être placé à la fin du case concerné, juste avant le case suivant:
switch (status)
{
case Status::Initialization:
prepare();
[[fallthrough]]; // Débordement (sur le "case" suivant) voulu: les autres développeurs savent que c'est intentionnel.
case Status::Processing:
execute();
break;
}
[[likely]] et [[unlikely]] (C++20)
Intuitivement, lorsqu’on est face à une portion de code, on imagine son exécution se faire séquentiellement:
void process(Data* pointer)
{
if (pointer == nullptr) // 1: vérifier le pointeur
{
return; // 2: Quitter la fonction
}
calculate(pointer); // 2: Lancer un calcul
}Dans ce code, chaque exécution de cette fonction impose au processeur d’évaluer la condition: il doit charger l’adresse contenue dans pointer, vérifier si elle est nulle, puis décider de sauter ou non à l’instruction suivante.
On voit que quelle que soit la branche empruntée, le processeur fera séquentiellement deux actions.
Or, ce n’est pas forcément le cas. Le compilateur dispose d’une multitude de règles pour s’autoriser des exécutions parallèles ou réordonner les instructions comme bon lui semble, afin de maximiser les performances sans altérer le comportement du code par rapport à une exécution séquentielle.
Même si cette vérification semble insignifiante, elle représente des instructions processeur supplémentaires. Répété des millions de fois par seconde dans une boucle, ce test génère un surcoût systématique.
void process(Data* pointer)
{
if (pointer == nullptr)
{
return; // Chemin d'erreur (très rare)
}
calculate(pointer); // Chemin nominal (ultra-fréquent)
}Parmi ces optimisations, la prédiction de branches permet au processeur de deviner à l’avance quelle branche empruntera le code lors de son exécution. Ainsi, il pré-charge spéculativement les instructions du chemin deviné dans son pipeline d’exécution pour commencer à les traiter.
Si la conjecture est correcte, l’exécution est presque gratuite. Mais si le processeur se trompe (un branch misprediction), le coût est très lourd: le processeur doit vider tout son pipeline d’instructions pré-chargées, annuler les calculs spéculatifs entamés, et charger le bon bloc d’instructions depuis la mémoire vive.
Le compilateur fait déjà ces prédictions sans qu’on ait à s’en soucier. Mais pour l’aider à prédire les branches souvent ou rarement empruntées, C++20 a introduit les attributs [[likely]] et [[unlikely]].
Ces attributs permettent d’indiquer au compilateur la probabilité statistique de chaque chemin. Le compilateur réorganise alors le code machine (l’assembleur généré) pour placer les instructions du chemin le plus fréquent directement dans la continuité linéaire du test (en fall-through), évitant ainsi un saut d’instruction (jump) pour le cas nominal et maximisant la localité du cache d’instructions.
Un seul attribut suffit généralement. Le compilateur comprend la probabilité relative: si vous marquez une condition comme [[unlikely]], le chemin alternatif est implicitement traité comme [[likely]], et inversement.
Voici comment optimiser notre exemple précédent:
void process(Data* pointer)
{
if (pointer == nullptr) [[unlikely]]
{
return; // Le compilateur sait que cette branche est rare
}
calculate(pointer); // Chemin nominal (implicitement traité comme probable)
}Si l’on écrit un bloc if/else complet, on peut utiliser [[likely]] et [[unlikely]] de cette façon (ou ne renseigner qu’un seul sur les deux):
if (pointer == nullptr) [[unlikely]]
{
return;
}
else [[likely]]
{
calculate(pointer);
}Les compilateurs modernes et les processeurs sont déjà extrêmement intelligents pour prédire les branchements.
N’utilisez ces attributs que dans des zones où la différence de fréquence entre les branches est écrasante (par exemple 99% contre 1%).
En abuser peut fausser les analyses de l’optimiseur et réduire les performances globales.Dans notre exemple précédent,
[[unlikely]]était utilisé pour de la gestion d’erreur, qui en principe devrait s’exécuter très rarement. C’est donc une situation où son utilisation est pertinente. Ca peut également l’être dans certaines boucles de recherche dans lesquelles les performances sont critiques.
Ces attributs ne s’appliquent pas aux
if constexpr.
Unif constexprétant évalué à la compilation, la branche non prise est purement supprimée du binaire final. La notion de prédiction de branchement n’a donc aucun sens ici.
[[no_unique_address]] (C++20)
En C++, chaque objet en mémoire doit avoir un emplacement unique. Afin de s’assurer que deux variables possèdent des adresses mémoire distinctes, tout objet doit avoir une taille minimale d’au moins 1 octet.
Ainsi, même une classe ou structure complètement vide ne fait pas 0 octet:
struct Empty {};
// sizeof(Empty) vaut 1 (1 octet)Le problème apparaît lorsqu’on intègre ces classes vides comme membres d’autres classes. C’est un cas fréquent dans les bibliothèques templatées (comme la STL: Standard Template Library) qui stockent des classes policies², des allocateurs ou des comparateurs sans état:
struct Container
{
int value; // 4 octets
Empty empty; // 1 octet
};
// sizeof(Container) vaut 8 octets (à cause de l'alignement et du padding)Ici, 4 octets sont gaspillés en mémoire uniquement pour réserver une adresse à empty.
Avant C++20, la seule manière d’éviter ce gaspillage de place consistait à utiliser l’Empty Base Optimization (EBO), en faisant hériter
Containerde la classeEmptyplutôt que de la stocker comme un membre. C’était une technique lourde et inélégante.
L’attribut [[no_unique_address]] résout ce problème proprement. Il indique au compilateur qu’un membre vide n’a pas besoin d’avoir une adresse unique par rapport aux autres membres de la classe, ce qui lui permet d’occuper 0 octet:
struct Empty {};
struct Container
{
int value;
[[no_unique_address]] Empty empty;
};
// sizeof(Container) est égal à sizeof(int), soit 4 octets. L'objet vide ne consomme plus d'espace.⚠️ Piège de compatibilité avec MSVC: Pour des raisons de compatibilité d’ABI, l’implémentation MSVC de Microsoft ignore silencieusement l’attribut standard
[[no_unique_address]]. Si vous compilez sous MSVC, vous devez obligatoirement utiliser l’attribut propriétaire[[msvc::no_unique_address]]à la place pour obtenir l’optimisation de taille.
[[assume(expression)]] (C++23)
Le compilateur fait son maximum pour optimiser votre code machine, mais il est contraint de générer des instructions pour gérer tous les cas possibles, même ceux qui ne se produiront jamais en pratique selon la logique de votre application.
Par exemple, considérons cette division par 4:
auto divideByFour(int value) -> int
{
return value / 4;
}Une division entière (
/) est une opération très coûteuse en cycles d’horloge CPU (souvent entre 10 et 40 cycles selon l’architecture²), tandis qu’un décalage de bits (>>) est extrêmement économique (généralement 1 seul cycle CPU).
En C++, diviser un entier signé par une puissance de 2 (2n) équivaut parfaitement à un décalage de bits vers la droite de n positions (value >> n) pour les nombres positifs (ex:8 / 4=2, soit0b1000 >> 2=0b10=2).
Cependant, ce n’est pas le cas pour les nombres négatifs, car la division entière arrondit vers zéro (-1 / 4 = 0) tandis que le décalage de bits arrondit vers l’infini négatif (-1 >> 2 = -1).
Sans l’attribut[[assume]], le compilateur est donc obligé de générer des instructions machines d’ajustement arithmétique supplémentaires pour corriger le signe et l’arrondi dans le cas oùvalueserait négatif.
En insérant[[assume(value >= 0)]];, on garantit au compilateur que la valeur sera positive. Il supprime instantanément ce code correctif pour ne conserver qu’une unique instruction CPU de décalage binaire (sar(Shift Arithmetic Right)), optimisant le code final.
Avant C++23, pour forcer le compilateur à optimiser le code en fonction de ces invariants, les développeurs devaient utiliser des fonctions intrinsèques non portables et propriétaires (comme __builtin_assume sur Clang, __builtin_unreachable sur GCC ou __assume sur MSVC).
L’attribut [[assume(expression)]] introduit un moyen standard et portable de fournir une vérité axiomatique à l’optimiseur. L’expression passée en argument est garantie d’être toujours vraie sans jamais être évaluée au runtime. Le compilateur peut alors s’appuyer sur cette hypothèse pour supprimer agressivement des vérifications de sécurité redondantes ou des calculs qu’il sait désormais impossibles.
auto divideByFour(int value) -> int
{
[[assume(value >= 0)]];
return value / 4;
}L’optimiseur sait que value est positif. Il peut donc optimiser la division par un simple et rapide décalage de bits vers la droite (value >> 2).
⚠️ Attention: Si l’expression s’avère fausse à l’exécution, le comportement est immédiatement indéfini (UB: Undefined Behavior).
Ne pas confondre avec les Contrats: Vous avez peut-être croisé dans d’anciennes documentations ou articles les syntaxes
[[expects: expression]](préconditions) ou[[ensures: expression]](postconditions). Ces attributs faisaient partie d’une proposition acceptée pour le C++20 puis avortée juste avant finalisation. Pour C++26, le standard a adopté un nouveau modèle de contrats (proposal) qui abandonne totalement la syntaxe sous forme d’attributs au profit de mots-clés dédiés:pre(expression),post(expression)etcontract_assert(expression).
[[clang::lifetimebound]] / [[msvc::lifetimebound]] (Extensions propriétaires)
Ces attributs ne sont pas standards et il n’existe aucune proposition pour ajouter un attribut standard
[[lifetimebound]]à la norme C++. Il s’agit d’extensions majeures de sécurité mémoire fournies par les compilateurs Clang et MSVC pour détecter à la compilation les dangling references.
L’attribut propriétaire [[clang::lifetimebound]] de Clang a été directement inspiré par la proposition P0936R0 comme indiqué dans ce commit de LLVM (“This attribute provides an experimental implementation of the facility described in the C++ committee paper P0936R0.”).
Cette proposition (P0936R0) visait à apporter un nouveau mot clef lifetimebound dédié à la sécurité des lifetimes.
// Syntaxe originale proposée dans P0936R0
struct string_view
{
// Le mot-clé est placé en tant que modificateur de la fonction membre:
const char* data() const lifetimebound;
};
// Ou sur des paramètres de fonction:
int& max(int& a lifetimebound, int& b lifetimebound);Cependant, il faut noter deux nuances majeures par rapport au papier original:
- Le papier P0936R0 propose l’introduction de
lifetimeboundsous forme de mot clef et non sous forme d’attribut C++[[...]]. - Bien qu’ils répondent au même besoin, il n’existe aucune proposition active à ce jour pour ajouter l’attribut
[[lifetimebound]]dans le standard C++ (voir l’index officiel du WG21 ou le suivi des propositions de l’ISO C++).
Pour les utiliser, il faut donc spécifier le nom du compilateur dans le namespace de l’attribut:
-
Clang:
[[clang::lifetimebound]] -
MSVC:
[[msvc::lifetimebound]](depuis VS 2022 17.7), nécessite d’activer l’analyse de code / C++ Core Check)
Ces attributs servent à indiquer au compilateur, aux outils d’analyse statique et aux développeurs: “La valeur de retour d’une fonction, ou l’instance courante, ne doit pas survivre à l’argument marqué”. Ils permettent de prévenir les dangling references en liant la lifetime d’une valeur retournée à celle d’un paramètre ou de l’objet this.
(Dans les exemples suivants, nous utiliserons la syntaxe de Clang [[clang::lifetimebound]] à titre d’illustration).
struct Owner
{
std::string data;
// Indique que la vue retournée ne doit pas survivre à l'objet Owner
[[nodiscard]] std::string_view getView() const [[clang::lifetimebound]]
{
return data;
}
};
[[clang::lifetimebound]] et deducing this (C++23)
Avec l’arrivée du Deducing This en C++23, la question du placement de l’attribut se pose. Les compilateurs comme Clang ou MSVC permettent de le placer directement sur le paramètre self.
Pourquoi sur self ?
L’attribut [[clang::lifetimebound]] sert à indiquer au compilateur que “la valeur de retour d’une fonction, ou l’instance courante, ne doit pas survivre à l’argument marqué.
-
En syntaxe classique: L’objet
thisest implicite. Quand on place l’attribut à la fin de la fonction, le compilateur comprend par convention qu’il s’applique authisinvisible. -
Avec le Deducing This: L’objet
thisdevient un paramètre explicite (self). En le mettant surself, on indique clairement que le retour est lié à l’objet lui-même.
Bug classique prévenu
Imaginons le code suivant dans une bibliothèque:
[[nodiscard]] inline auto operator[](this auto&& self [[clang::lifetimebound]], std::string_view key) -> const Value&;Et que l’utilisateur fasse ceci:
const auto& value = Json::parse("{...}")["myKey"];
// value est une dangling reference !⚠️ Erreur critique: La valeur retournée pointe dans un objet temporaire qui meurt à la fin de la full-expression.
Grâce à [[clang::lifetimebound]] sur self, le compilateur (Clang notamment) va générer un warning:
warning: temporary bound to local reference 'value' will be destroyed at the end of the full-expression
La précision des catégories de valeur
L’avantage de le mettre sur self, c’est que la vérification est extrêmement précise selon la catégorie de la valeur appelante:
- Si
selfest déduit commeValue&(lvalue), le retour est lié à un objet durable. - Si
selfest déduit commeValue&&(rvalue temporaire), le compilateur sait que conserver la valeur de retour est une opération critique et dangereuse.
Comparaison de syntaxe et pièges de Clang
Sur une fonction membre, avec la syntaxe classique, l’attribut doit se placer après les qualificatifs (const, &, &&) mais avant noexcept ou le trailing return type (->):
Dès qu’une fonction membre devient complexe, la syntaxe devient un champ de mines:
auto get() const & [[clang::lifetimebound]] noexcept -> T& // Clang peut être capricieux sur l'ordreLes compilateurs, et Clang en particulier, sont particulièrement stricts sur l’ordre exact de ces éléments.
Bien que Clang l’accepte sur la méthode, dès qu’une référence sur la fonction (& ou &&) est présente, le parseur devient extrêmement instable et peut émettre des erreurs de syntaxe cryptiques (comme expected ';') même si l’ordre semble logique.
Une alternative plus lisible consiste à suivre la syntaxe du deducing this:
auto get(this auto&& self [[clang::lifetimebound]]) const & noexcept -> T&En plaçant [[clang::lifetimebound]] directement sur self (grâce au deducing this), on sort de cette zone grise de la grammaire pour repasser sur une déclaration de paramètre classique, ce qui est 100% robuste pour tous les compilateurs.
De plus, la cible de cet attribut devient directement plus clair (l’element lui-même, self).
Et ne pas oublier qu’il s’agit aussi d’une information très utile pour le développeur appelant vos fonctions. Ce doit être clair aussi pour les développeurs.
| Style | Syntaxe | Cible de l’attribut |
|---|---|---|
| Classique | const T& get() const [[clang::lifetimebound]] |
Appliqué à la fonction (cible this implicite) |
| Moderne | const T& get(this auto&& self [[clang::lifetimebound]]) |
Appliqué explicitement au paramètre self
|
En résumé: C’est la manière la plus propre de lier le retour non pas à “la fonction”, mais spécifiquement à l’objet qui l’appelle.
[[indeterminate]] (C++26)
Avant C++26, les variables locales automatiques (non static ni thread_local) (comme les types fondamentaux ou les tableaux) déclarées sans initialiseur n’étaient pas initialisées par défaut: elles contenaient des valeurs arbitraires (déchets de la stack) et leur lecture accidentelle provoquait un comportement indéfini (UB).
void crashOrVulnerability()
{
int x; // Non initialisé (stack garbage)
int y = x + 1; // Undefined Behavior (UB)
}Pour éradiquer ce problème de sécurité majeur, C++26 introduit le concept de comportement erroné (erroneous behavior) (un comportement officiellement incorrect dans la logique du programme, mais dont l’effet reste déterministe et sécurisé pour éliminer les failles mémoire, contrairement aux optimisations agressives et imprévisibles du comportement indéfini (UB)).
Désormais, le compilateur initialise automatiquement à une valeur fixe définie par l’implémentation (généralement zéro, mais les compilateurs peuvent choisir des valeurs de “poisoning” différentes selon le mode de build) toutes les variables locales non initialisées explicitement, y compris les types fondamentaux et les tableaux. Si le programme lit cette variable avant d’y avoir écrit, le comportement reste déterministe (il utilise cette valeur d’initialisation par défaut) mais est classifié comme une erreur de programmation (le comportement erroné), permettant aux compilateurs et aux outils d’analyse (comme MemorySanitizer / MSAN) de le signaler de manière fiable.
void secureBehavior()
{
int x; // Automatiquement initialisé (possiblement avec la valeur 0)
int y = x + 1; // Erroneous behavior: lecture déterministe, mais étiqueté comme une erreur
}L’attribut [[indeterminate]] sert de déclaration d’exemption.
Appliqué à une variable locale, il demande au compilateur de désactiver l’initialisation automatique de sécurité pour des raisons de performance.
void readNetworkData(char* destination, std::size_t size);
void process()
{
[[indeterminate]] char buffer[4096];
readNetworkData(buffer, sizeof(buffer));
}Notez l’absence d’initialisation avec
autoici (auto buffer = std::array<char, 4096>{};), syntaxe qui aurait empêché de déclarer la variable sans l’initialiser (ce que nous recherchons ici pour des raisons de performances).
Le compilateur reste libre de l’ignorer et d’initialiser quand même la variable par sécurité, conformément à la règle d’ignorabilité des attributs.
Si l’exemption est prise en compte, la variable retrouve son comportement historique: son contenu est indéterminé, et toute lecture avant écriture redevient un comportement indéfini (UB).
[[optimize_for_synchronized]] (TM TS)
Cet attribut fait partie des extensions définies par la Transactional Memory Technical Specification (ISO/IEC TS 19841:2015), un document technique visant à introduire le concept de mémoire transactionnelle en C++.
La mémoire transactionnelle permet d’exécuter des blocs de code de manière atomique à l’aide de blocs synchronized. En cas de conflit d’accès mémoire entre threads, la transaction échoue et redémarre automatiquement, sans nécessiter de verrous (locks) explicites.
Le mot clef experimental synchronized
Pour délimiter les zones critiques sans manipuler manuellement de verrous (comme std::mutex), cette spécification introduit le mot clef expérimental synchronized. Un bloc de code marqué synchronized { ... } s’exécute sous exclusion mutuelle: le résultat final est équivalent à une exécution séquentielle (un bloc synchronized après l’autre).
void process(int value)
{
synchronized
{
int current = getGlobalCounter();
setGlobalCounter(current + value);
}
}En coulisses, plutôt que de bloquer les threads avec un verrou physique, le compilateur et le processeur tentent d’exécuter ce bloc avec une concurrence optimiste (en surveillant les adresses lues et écrites). Si aucun autre thread n’a modifié les mêmes adresses durant l’exécution, les changements sont validés (commit). En cas de conflit (si un autre thread écrit sur l’une de ces adresses), la transaction est annulée (avortement de transaction ou transaction abort) et recommence automatiquement.
Lorsqu’un bloc synchronized appelle une fonction non inlinée, le compilateur, par sécurité, doit généralement abandonner l’exécution concurrente optimiste et acquérir un verrou physique bloquant. Cela force les threads à s’attendre et à s’exécuter un par un, éliminant tout parallélisme et dégradant fortement les performances.
L’attribut experimental [[optimize_for_synchronized]]
L’attribut [[optimize_for_synchronized]] résout ce problème. Il est indispensable lorsque le corps de la fonction n’est pas connu dans la translation unit courante: il indique au compilateur (si l’attribut est honoré) qu’une version optimisée pour les transactions sera bien disponible lors de l’édition de liens.
// Indique au compilateur d'optimiser cette fonction pour l'appel transactionnel
[[optimize_for_synchronized]] void updateData();
void process()
{
synchronized
{
updateData(); // Appel sécurisé sans bloquer la concurrence optimiste
}
}Grâce à cet attribut, le compilateur génère deux versions distinctes de la fonction dans le binaire (un mécanisme appelé transaction clone ou clonage transactionnel):
- Une version standard pour les appels classiques hors transactions.
- Un clone transactionnel conçu pour optimiser les transactions. Dans cette version, chaque accès mémoire est tracé par le compilateur (injection de barrières logicielles de lecture/écriture). Le compilateur y élimine les barrières de transaction redondantes et optimise le code de manière à ce qu’il s’exécute le plus rapidement possible. Cela réduit la durée globale de la transaction, limitant ainsi la probabilité qu’un autre thread écrive en même temps et provoque un avortement de transaction (transaction abort).
Statut expérimental
Les spécifications de mémoire transactionnelle n’ayant encore jamais été intégrées au standard C++ officiel, cet attribut reste expérimental. GCC est actuellement le seul compilateur majeur à proposer un support expérimental (via l’option -fgnu-tm), tandis que Clang et MSVC ne l’implémentent pas.
Aller plus loin: