Dans son discours à CppCon 2018, Herb Sutter a présenté au public ses réalisations dans deux directions. Tout d'abord, c'est le contrôle de la durée de vie des variables (Lifetime), qui permet de détecter des classes entières de bugs lors de la compilation. Deuxièmement, il s'agit d'une proposition mise à jour sur les métaclasses , qui permettra d'éviter la duplication de code, décrivant une fois le comportement d'une catégorie de classe, puis la connectant à des classes spécifiques avec une seule ligne.
Avant-propos: plus = plus facile?!
Des accusations C ++ sont entendues que la norme se développe insensément et sans pitié. Mais même les conservateurs les plus ardents ne soutiendront pas que de nouvelles constructions comme range-for (cycle de collecte) et auto (au moins pour les itérateurs) rendent le code plus simple. Vous pouvez développer des critères approximatifs que (au moins un, idéalement tous) les nouvelles extensions linguistiques doivent satisfaire afin de simplifier le code dans la pratique:
- Réduisez, simplifiez le code, supprimez le code en double (plage pour, auto, lambda, métaclasses)
- Rendre le code sûr plus facile à écrire, éviter les erreurs et les cas spéciaux (pointeurs intelligents, durée de vie)
- Remplacer complètement les anciennes fonctionnalités moins fonctionnelles (typedef → utilisation)
Herb Sutter identifie le «C ++ moderne» - un sous-ensemble de fonctionnalités qui sont conformes aux normes de codage modernes (telles que les directives de base C ++ ), et considère le standard complet comme un «mode de compatibilité» que tout le monde n'a pas besoin de connaître. En conséquence, si le "C ++ moderne" ne se développe pas, alors tout va bien.
Vérification de la durée de vie des variables (durée de vie)
Le nouveau groupe de vérification à vie est désormais disponible dans le cadre du Core Guidelines Checker pour Clang et Visual C ++. Le but n'est pas d'atteindre une rigueur et une précision absolues, comme dans Rust, mais d'effectuer des vérifications simples et rapides dans les fonctions individuelles.
Principes de base de la vérification
Du point de vue de l'analyse de la durée de vie, les types sont divisés en 3 catégories:
- La valeur est ce à quoi un pointeur peut pointer.
- Pointeur - fait référence à la valeur, mais ne contrôle pas sa durée de vie. Peut être suspendu (pointeur suspendu). Exemples:
T*
, T&
, itérateurs, std::observer_ptr<T>
, std::string_view
, gsl::span<T>
- Propriétaire - contrôle la durée de vie de la valeur. Peut généralement supprimer sa valeur avant la date prévue. Exemples:
std::unique_ptr<T>
, std::shared_ptr<T>
, std::vector<T>
, std::string
, gsl::owner<T*>
Un pointeur peut se trouver dans l'un des états suivants:
- Pointez sur une valeur stockée sur la pile
- Pointez sur une valeur contenue "à l'intérieur" par un propriétaire
- Être vide (null)
- Accrochez (invalide)
Pointeurs et valeurs
Pour chaque pointeur est suivi - l'ensemble des valeurs auxquelles il peut indiquer. Lors de la suppression d'une valeur, son occurrence dans tous remplacé par . Lors de l'accès à une valeur de pointeur tel que émettre une erreur.
string_view s;
À l'aide d'annotations, vous pouvez configurer les opérations qui seront considérées comme des opérations d'accès à la valeur. Par défaut: *
, ->
, []
, begin()
, end()
.
Veuillez noter que l'avertissement n'est émis qu'au moment de l' accès à l'index invalide. Si la valeur est supprimée, mais que personne n'accède à ce pointeur, tout est en ordre.
Panneaux et propriétaires
Si pointeur indique une valeur contenue dans le propriétaire alors ça .
Les méthodes et fonctions qui prennent les propriétaires, sont divisées en:
- Opérations d'accès à la valeur propriétaire. Par défaut:
*
, ->
, []
, begin()
, end()
- Opérations d'accès au propriétaire lui-même, pointeurs
v.clear()
, comme v.clear()
. Par défaut, ce sont toutes les autres opérations non const - Opérations d'accès au propriétaire lui-même, pointeurs non invalidants, tels que
v.empty()
. Par défaut, ce sont toutes des opérations const.
Annonce d'un ancien propriétaire de contenu lors de la révocation du Propriétaire ou lors de l'application d'opérations invalidantes.
Ces règles suffisent pour détecter de nombreux bogues typiques dans le code C ++:
string_view s;
vector<int> v = get_ints(); int* p = &v[5];
std::string_view s = "foo"s; cout << s[0];
vector<int> v = get_ints(); for (auto i = v.begin(); i != v.end(); ++i) {
std::optional<std::vector<int>> get_data();
Suivi de la durée de vie des paramètres de fonction
Lorsque nous commençons à traiter des fonctions en C ++ qui renvoient des pointeurs, nous ne pouvons que deviner la relation entre la durée de vie des paramètres et la valeur de retour. Si une fonction accepte et renvoie des pointeurs du même type, alors une hypothèse est faite que la fonction "obtient" la valeur de retour à partir d'un des paramètres d'entrée:
auto f(int* p, int* q) -> int*;
Les fonctions suspectes sont facilement détectées et ne produisent le résultat de nulle part:
std::reference_wrapper<int> get_data() {
Puisqu'il est possible de passer une valeur temporaire aux paramètres const T&
, ils ne sont pas pris en compte, sauf si le résultat n'est nulle part ailleurs à prendre:
template <typename T> const T& min(const T& x, const T& y);
using K = std::string; using V = std::string; const V& find_or_default(const std::map<K, V>& m, const K& key, const V& def);
On pense également que si une fonction accepte un pointeur (au lieu d'une référence), alors il peut être nullptr, et ce pointeur ne peut pas être utilisé avant de le comparer avec nullptr.
Conclusion sur le contrôle de la durée de vie
Je répète que Lifetime n'est pas encore une proposition pour la norme C ++, mais une tentative audacieuse d'implémenter des vérifications de durée de vie en C ++, où, contrairement à Rust, par exemple, il n'y a jamais eu d'annotations correspondantes. Au début, il y aura beaucoup de faux positifs, mais au fil du temps, l'heuristique s'améliorera.
Questions du public
Les vérifications de groupe à vie fournissent-elles une garantie mathématiquement précise de l'absence de pointeurs pendants?
Théoriquement, il serait possible (dans le nouveau code) de bloquer un tas d'annotations sur les classes et les fonctions, et en retour le compilateur donnerait de telles garanties. Mais ces vérifications ont été développées selon le principe 80:20, c'est-à-dire que vous pouvez détecter la plupart des erreurs en utilisant un petit nombre de règles et en appliquant un minimum d'annotations.
La métaclasse complète en quelque sorte le code de la classe à laquelle elle est appliquée et sert également de nom à un groupe de classes qui remplissent certaines conditions. Par exemple, comme illustré ci-dessous, la métaclasse d' interface
rendra toutes les fonctions publiques et purement virtuelles pour vous.
L'année dernière, Herb Sutter a réalisé son premier projet de métaclasse ( voir ici ). Depuis lors, la syntaxe actuellement proposée a changé.
Pour commencer, la syntaxe d'utilisation des métaclasses a changé:
Il est devenu plus long, mais il existe maintenant une syntaxe naturelle pour appliquer plusieurs métaclasses à la fois: class(meta1, meta2)
.
Auparavant, une métaclasse était un ensemble de règles pour modifier une classe. Maintenant, une métaclasse est une fonction constexpr qui prend une ancienne classe (déclarée dans le code) et en crée une nouvelle.
À savoir, la fonction prend un paramètre - des méta-informations sur l'ancienne classe (le type de paramètre dépend de l'implémentation), crée des éléments de classe (fragments), puis les ajoute au corps de la nouvelle classe à l'aide de l'instruction __generate
.
Les fragments peuvent être générés à l'aide des constructions __fragment
, __inject
, idexpr(…)
. L'orateur a préféré ne pas se concentrer sur son objectif, car cette partie sera encore modifiée avant d'être soumise au comité de normalisation. Les noms eux-mêmes sont garantis d'être changés, un double soulignement a été ajouté spécifiquement pour clarifier cela. Le rapport met l'accent sur des exemples qui vont plus loin.
interface
template <typename T> constexpr void interface(T source) {
Vous pourriez penser que sur les lignes (1) et (2) nous modifions la classe d'origine, mais non. Veuillez noter que nous parcourons les fonctions de la classe d'origine avec la copie, modifions ces fonctions, puis les insérons dans une nouvelle classe.
Application de métaclasse:
class(interface) Shape { int area() const; void scale_by(double factor); };
Débogage Mutex
Supposons que nous ayons des données non thread-safe protégées par un mutex. Le débogage peut être facilité si, dans un assembly de débogage, à chaque appel, il est vérifié si le processus en cours a verrouillé ce mutex. Pour ce faire, une simple classe TestableMutex a été écrite:
class TestableMutex { public: void lock() { m.lock(); id = std::this_thread::get_id(); } void unlock() { id = std::thread::id{}; m.unlock(); } bool is_held() { return id == std::this_thread::get_id(); } private: std::mutex m; std::atomic<std::thread::id> id; };
De plus, dans notre classe MyData, nous aimerions que chaque domaine public comme
vector<int> v;
Remplacez par + getter:
private: vector<int> v_; public: vector<int>& v() { assert(m_.is_held()); return v_; }
Pour les fonctions, vous pouvez également effectuer des transformations similaires.
Ces tâches sont résolues à l'aide de macros et de la génération de code. Herb Sutter a déclaré la guerre aux macros: elles sont dangereuses, ignorent la sémantique, les espaces de noms, etc. A quoi ressemble la solution sur les métaclasses:
constexpr void guarded_with_mutex() { __generate __fragment class { TestableMutex m_;
Comment l'utiliser:
class(guarded) MyData { vector<int> v; Widget* w; }; MyData& x = findData("foo"); xv().clear();
acteur
Eh bien, même si nous avons protégé un objet avec un mutex, maintenant tout est thread-safe, il n'y a aucune prétention à l'exactitude. Mais si un objet est souvent accessible par de nombreux threads en parallèle, le mutex sera surchargé, et il y aura une surcharge importante pour le prendre.
La solution fondamentale au problème des mutex buggy est le concept d'acteurs, lorsqu'un objet a une file d'attente de demande, tous les appels à l'objet sont mis en file d'attente et exécutés les uns après les autres dans un thread spécial.
Laissez la classe Active contenir une implémentation de tout cela - en fait, un pool / exécuteur de threads avec un seul thread. Eh bien, les métaclasses aideront à se débarrasser du code en double et à mettre en file d'attente toutes les opérations:
class(active) ImageFilter { public: ImageFilter(std::function<void(Buffer*)> w) : work(std::move(w)) {} void apply(Buffer* b) { work(b); } private: std::function<void(Buffer*)> work; }
class(active) log { std::fstream f; public: void info(…) { f << …; } };
propriété
Il existe des propriétés dans presque tous les langages de programmation modernes, et quiconque ne les a tout simplement pas implémentés sur la base de C ++: Qt, C ++ / CLI, toutes sortes de macros laides. Cependant, ils ne seront jamais ajoutés à la norme C ++, car ils sont eux-mêmes considérés comme des fonctionnalités trop étroites, et il y avait toujours l'espoir qu'une proposition les implémenterait comme un cas spécial. Eh bien, ils peuvent être implémentés sur des métaclasses!
Vous pouvez définir votre propre getter et setter:
class Date { public: class(property<int>) MonthClass { int month; auto get() { return month; } void set(int m) { assert(m > 0 && m < 13); month = m; } } month; }; Date date; date.month = 15;
Idéalement, je veux écrire la property int month { … }
, mais même une telle implémentation remplacera le zoo des extensions C ++ inventant des propriétés.
Les métaclasses sont une grande nouveauté pour un langage déjà complexe. Est-ce que ça vaut le coup? Voici certains de leurs avantages:
- Laissez les programmeurs exprimer plus clairement leurs intentions (je veux écrire un acteur)
- Réduisez la duplication de code et simplifiez le développement et la maintenance du code qui suit certains modèles
- Élimine certains groupes d'erreurs courantes (il suffira de s'occuper de toutes les subtilités une fois)
- Permet de se débarrasser des macros? (Herb Sutter est très belliqueux)
Questions du public
Comment déboguer des métaclasses?
Au moins pour Clang, il existe une fonction intrinsèque qui, si elle est appelée, affichera le contenu réel de la classe au moment de la compilation, c'est-à-dire ce qui est obtenu après l'application de toutes les métaclasses.
On disait qu'il était capable de déclarer des non-membres comme swap et hash dans les métaclasses. Où est-elle partie?
La syntaxe sera développée davantage.
Pourquoi avons-nous besoin de métaclasses si des concepts ont déjà été adoptés pour la normalisation?
Ce sont des choses différentes. Des métaclasses sont nécessaires pour définir des parties d'une classe, et les concepts vérifient si une classe correspond à un certain modèle à l'aide d'exemples de classe. En fait, les métaclasses et les concepts fonctionnent bien ensemble. Par exemple, vous pouvez définir le concept d'un itérateur et la métaclasse d'un «itérateur typique» qui définit certaines opérations redondantes à travers le reste.