À ce stade, cette bibliothèque est expérimentale et c'est une pure curiosité. Aucune stabilité de l'interface ou de la qualité de mise en œuvre n'est garantie. Utilisez à vos propres risques.
Le dyno résout mieux le problème du polymorphisme d'exécution que la vanille C ++. Il fournit un moyen de définir les interfaces qui peuvent être remplies de manière non intrusive, et il fournit un moyen entièrement personnalisable de stocker des objets polymorphes et de répartir aux méthodes virtuelles. Il ne nécessite pas d'hérédité, d'allocation de tas ou de quittant le monde confortable de la sémantique de valeur, et il peut le faire tout en surpassant la vanille C ++.
Dyno est une implémentation de bibliothèque pure de ce qui est également connu sous le nom d'objets de trait Rust, d'interfaces GO, de classes de type Haskell et de concepts virtuels. Sous le capot, il utilise une technique C ++ connue sous le nom d'effacement de type, qui est l'idée derrière std::any , std::function et de nombreux autres types utiles.
# include < dyno.hpp >
# include < iostream >
using namespace dyno ::literals ;
// Define the interface of something that can be drawn
struct Drawable : decltype(dyno::requires_(
" draw " _s = dyno::method< void (std::ostream&) const >
)) { };
// Define how concrete types can fulfill that interface
template < typename T>
auto const dyno::default_concept_map<Drawable, T> = dyno::make_concept_map(
" draw " _s = [](T const & self, std::ostream& out) { self. draw (out); }
);
// Define an object that can hold anything that can be drawn.
struct drawable {
template < typename T>
drawable (T x) : poly_{x} { }
void draw (std::ostream& out) const
{ poly_. virtual_ ( " draw " _s)(out); }
private:
dyno::poly<Drawable> poly_;
};
struct Square {
void draw (std::ostream& out) const { out << " Square " ; }
};
struct Circle {
void draw (std::ostream& out) const { out << " Circle " ; }
};
void f (drawable const & d) {
d. draw (std::cout);
}
int main () {
f (Square{}); // prints Square
f (Circle{}); // prints Circle
}Alternativement, si vous trouvez que c'est trop de passe-partout et que vous pouvez vous tenir à l'aide d'une macro, ce qui suit est équivalent:
# include < dyno.hpp >
# include < iostream >
// Define the interface of something that can be drawn
DYNO_INTERFACE (Drawable,
(draw, void (std::ostream&) const )
);
struct Square {
void draw (std::ostream& out) const { out << " Square " ; }
};
struct Circle {
void draw (std::ostream& out) const { out << " Circle " ; }
};
void f (Drawable const & d) {
d. draw (std::cout);
}
int main () {
f (Square{}); // prints Square
f (Circle{}); // prints Circle
}Il s'agit d'une bibliothèque C ++ 17. Aucun effort ne sera fait pour soutenir des compilateurs plus âgés (désolé). La bibliothèque est connue pour fonctionner avec les compilateurs suivants:
| Compilateur | Version |
|---|---|
| GCC | > = 7 |
| Bruit | > = 4.0 |
| Apple Clang | > = 9.1 |
La bibliothèque dépend de boost.hana et boost.CallableTtaitts. Les tests unitaires dépendent de Libawful et les repères dépendent de Google Benchmark, Boost.Typeerasure et Mpark.Variant, mais vous n'en avez pas besoin pour utiliser la bibliothèque. Pour le développement local, le script dependencies/install.sh peut être utilisé pour installer automatiquement toutes les dépendances.
Dyno est une bibliothèque d'en-tête uniquement, il n'y a donc rien à construire par SE. Ajoutez simplement l' include/ répertoire au chemin de recherche d'en-tête de votre compilateur (et assurez-vous que les dépendances sont satisfaites), et vous êtes prêt à partir. Cependant, il existe des tests unitaires, des exemples et des repères qui peuvent être construits:
(cd dependencies && ./install.sh) # Install dependencies; will print a path to add to CMAKE_PREFIX_PATH
mkdir build
(cd build && cmake .. -DCMAKE_PREFIX_PATH= " ${PWD} /../dependencies/install " ) # Setup the build directory
cmake --build build --target examples # Build and run the examples
cmake --build build --target tests # Build and run the unit tests
cmake --build build --target check # Does both examples and tests
cmake --build build --target benchmarks # Build and run the benchmarks En programmation, la nécessité de manipuler des objets avec une interface commune mais avec un type dynamique différent se produit très fréquemment. C ++ résout cela avec l'héritage:
struct Drawable {
virtual void draw (std::ostream& out) const = 0;
};
struct Square : Drawable {
virtual void draw (std::ostream& out) const override final { ... }
};
struct Circle : Drawable {
virtual void draw (std::ostream& out) const override final { ... }
};
void f (Drawable const * drawable) {
drawable-> draw (std::cout);
}Cependant, cette approche présente plusieurs inconvénients. C'est
Intrusif
Pour que Square et Circle remplissent l'interface Drawable , ils doivent tous deux hériter de la classe de base Drawable . Cela nécessite d'avoir la licence pour modifier ces classes, ce qui rend l'héritage très indispensable. Par exemple, comment feriez-vous un std::vector<int> réalisez l'interface Drawable ? Vous ne pouvez tout simplement pas.
Incompatible avec la sémantique de valeur
L'héritage vous oblige à passer des pointeurs polymorphes ou des références à des objets au lieu des objets eux-mêmes, qui joue très mal avec le reste de la langue et la bibliothèque standard. Par exemple, comment copiez-vous un vecteur de sr Drawable s? Vous devez fournir une méthode Virtual clone() , mais maintenant vous venez de gâcher votre interface.
Étroitement associé à un stockage dynamique
En raison du manque de sémantique de valeur, nous finissons généralement par allouer ces objets polymorphes sur le tas. C'est à la fois horriblement inefficace et sémantiquement mauvais, car il y a de fortes chances que nous n'ayons pas du tout besoin de la durée de stockage dynamique, et un objet avec une durée de stockage automatique (par exemple sur la pile) aurait été suffisant.
Empêche l'inlin
95% du temps, nous finissons par appeler une méthode virtuelle via un pointeur ou une référence polymorphe. Cela nécessite trois indirections: une pour charger le pointeur vers le VTable à l'intérieur de l'objet, un pour charger la bonne entrée dans le VTable, et une pour l'appel indirect au pointeur de fonction. Tout ce saut de saut rend difficile pour le compilateur de prendre de bonnes décisions enracinées. Cependant, il s'avère que toutes ces indirections, à l'exception de l'appel indirect, peuvent être évitées.
Malheureusement, c'est le choix que C ++ a fait pour nous, et ce sont les règles auxquelles nous sommes liés lorsque nous avons besoin d'un polymorphisme dynamique. Ou est-ce vraiment?
Dyno résout le problème du polymorphisme d'exécution en C ++ sans aucun des inconvénients énumérés ci-dessus, et bien d'autres goodies. C'est:
Non intrusif
Une interface peut être remplie par un type sans nécessiter aucune modification de ce type. Heck, un type peut même réaliser la même interface de différentes manières! Avec Dyno , vous pouvez embrasser des hiérarchies de classe ridicules au revoir.
100% basé sur la sémantique de valeur
Les objets polymorphes peuvent être passés tels quels, avec leur sémantique de valeur naturelle. Vous devez copier vos objets polymorphes? Bien sûr, assurez-vous simplement qu'ils ont un constructeur de copie. Vous voulez vous assurer qu'ils ne sont pas copiés? Bien sûr, marquez-le comme supprimé. Avec le dyno , les méthodes idiots clone() et la prolifération des pointeurs dans les API sont des choses du passé.
Non associé à une stratégie de stockage spécifique
La façon dont un objet polymorphe est stocké est vraiment un détail d'implémentation, et il ne devrait pas interférer avec la façon dont vous utilisez cet objet. Dyno vous donne un contrôle complet sur la façon dont vos objets sont stockés. Vous avez beaucoup de petits objets polymorphes? Bien sûr, conservons-les dans un tampon local et évitez toute allocation. Ou peut-être qu'il est logique pour vous de stocker des choses sur le tas? Bien sûr, allez-y.
Mécanisme de répartition flexible pour obtenir les meilleures performances possibles
Le stockage d'un pointeur vers un VTable n'est qu'une des nombreuses stratégies de mise en œuvre différentes pour effectuer une répartition dynamique. Dyno vous donne un contrôle complet sur la façon dont la répartition dynamique se produit et peut en fait battre les VTables dans certains cas. Si vous avez une fonction qui est appelée dans une boucle chaude, vous pouvez par exemple le stocker directement dans l'objet et sauter l'indirection VTable. Vous pouvez également utiliser des connaissances spécifiques à l'application que le compilateur ne pourrait jamais avoir à optimiser certains appels dynamiques - Devirtualisation au niveau de la bibliothèque.
Tout d'abord, vous commencez par définir une interface générique et lui donner un nom. Dyno fournit un langage spécifique au domaine simple pour ce faire. Par exemple, définissons une interface Drawable qui décrit les types qui peuvent être dessinés:
# include < dyno.hpp >
using namespace dyno ::literals ;
struct Drawable : decltype(dyno::requires_(
" draw " _s = dyno::method< void (std::ostream&) const >
)) { }; Cela définit Drawable comme représentant une interface pour tout ce qui a une méthode appelée draw prenant une référence à un std::ostream . Dyno appelle ces interfaces concepts dynamiques , car ils décrivent des ensembles d'exigences à remplir par un type (comme les concepts C ++). Cependant, contrairement aux concepts C ++, ces concepts dynamiques sont utilisés pour générer des interfaces d'exécution, d'où la dynamique du nom. La définition ci-dessus est fondamentalement équivalente à ce qui suit:
struct Drawable {
virtual void draw (std::ostream&) const = 0;
};Une fois l'interface définie, l'étape suivante consiste à créer un type qui satisfait cette interface. Avec l'héritage, vous écririez quelque chose comme ceci:
struct Square : Drawable {
virtual void draw (std::ostream& out) const override final {
out << " square " << std::endl;
}
};Avec Dyno , le polymorphisme n'est pas intrusif et il est plutôt fourni via ce qu'on appelle une carte conceptuelle (après les cartes conceptuelles C ++ 0x):
struct Square { /* ... */ };
template <>
auto const dyno::concept_map<Drawable, Square> = dyno::make_concept_map(
" draw " _s = [](Square const & square, std::ostream& out) {
out << " square " << std::endl;
}
);Cette construction est la spécialisation d'un modèle de variable C ++ 14 nommé
concept_mapdéfini dans l'espace de nomsdyno::. Nous initialisons ensuite cette spécialisation avecdyno::make_concept_map(...).
Le premier paramètre du lambda est l'implicite *this paramètre implicite lorsque nous avons déclaré draw comme méthode ci-dessus. Il est également possible d'effacer les fonctions non membres (voir la section pertinente).
Cette carte de concept définit comment le type Square satisfait le concept Drawable . Dans un sens, il mappe le type Square à sa mise en œuvre du concept, qui motive l'appellation. Lorsqu'un type satisfait aux exigences d'un concept, nous disons que le type modèle (ou est un modèle de) ce concept. Maintenant que Square est un modèle du concept Drawable , nous aimerions utiliser un Square polymorphiquement comme Drawable . Avec l'héritage traditionnel, nous utiliserions un pointeur vers une classe de base comme celle-ci:
void f (Drawable const * d) {
d-> draw (std::cout);
}
f ( new Square{}); Avec le dyno , le polymorphisme et la sémantique de valeur sont compatibles, et la façon dont les types polymorphes sont passés peuvent être hautement personnalisés. Pour ce faire, nous devons définir un type qui peut contenir tout ce qui est Drawable . C'est ce type, au lieu d'un Drawable* , que nous allons passer à et depuis des fonctions polymorphes. Pour aider à définir cet emballage, Dyno fournit le dyno::poly Container, qui peut contenir un objet arbitraire satisfaisant un concept donné. Comme vous le verrez, dyno::poly a un double rôle: il stocke l'objet polymorphe et s'occupe de la répartition dynamique des méthodes. Tout ce que vous avez à faire est d'écrire un emballage mince sur dyno::poly pour lui donner exactement l'interface souhaitée:
struct drawable {
template < typename T>
drawable (T x) : poly_{x} { }
void draw (std::ostream& out) const
{ poly_. virtual_ ( " draw " _s)(out); }
private:
dyno::poly<Drawable> poly_;
};Remarque: vous pouvez techniquement utiliser
dyno::polydirectement dans vos interfaces. Cependant, il est beaucoup plus pratique d'utiliser un emballage avec de vraies méthodes quedyno::poly, et donc l'écriture d'un emballage est recommandée.
Décomposons cela. Tout d'abord, nous définissons un membre poly_ qui est un récipient polymorphe pour tout ce qui modélise le concept Drawable :
dyno::poly<Drawable> poly_; Ensuite, nous définissons un constructeur qui permet de construire ce conteneur à partir d'un type arbitraire T :
template < typename T>
drawable (T x) : poly_{x} { } L'hypothèse non dites ici est que T modélise réellement le concept Drawable . En effet, lorsque vous créez un dyno::poly à partir d'un objet de type T , le dyno ira regarder la carte conceptuelle définie pour Drawable et T , le cas échéant. S'il n'y a pas une telle carte de concept, la bibliothèque rapportera que nous essayons de créer un dyno::poly à partir d'un type qui ne le soutient pas et que votre programme ne se compile pas.
Enfin, la partie la plus étrange et la plus importante de la définition ci-dessus est celle de la méthode draw :
void draw (std::ostream& out) const
{ poly_. virtual_ ( " draw " _s)(out); } Ce qui se passe ici, c'est que lorsque .draw est appelé sur notre objet drawable , nous effectuerons en fait une répartition dynamique à l'implémentation de la fonction "draw" pour l'objet actuellement stocké dans le dyno::poly , et appelez cela. Maintenant, pour créer une fonction qui accepte tout ce qui est Drawable , plus besoin de s'inquiéter des pointeurs et de la propriété dans votre interface:
void f (drawable d) {
d. draw (std::cout);
}
f (Square{});Au fait, si vous pensez que tout cela est stupide et que vous auriez dû utiliser un modèle, vous avez raison. Cependant, considérez ce qui suit, où vous avez vraiment besoin du polymorphisme d'exécution :
drawable get_drawable () {
if ( some_user_input ())
return Square{};
else
return Circle{};
}
f (get_drawable()); À proprement parler, vous n'avez pas besoin d'envelopper dyno::poly , mais cela met une belle barrière entre le dyno et le reste de votre code, qui n'a jamais à s'inquiéter de la façon dont votre couche polymorphe est mise en œuvre. De plus, nous avons largement ignoré la façon dont dyno::poly a été mis en œuvre dans la définition ci-dessus. Cependant, dyno::poly est un conteneur basé sur des politiques très puissant pour les objets polymorphes qui peuvent être personnalisés selon ses besoins de performance. La création d'un wrapper drawable permet de modifier facilement la stratégie de mise en œuvre utilisée par dyno::poly pour les performances sans avoir un impact sur le reste de votre code.
Le premier aspect qui peut être personnalisé dans un dyno::poly est la façon dont l'objet est stocké à l'intérieur du conteneur. Par défaut, nous stockons simplement un pointeur vers l'objet réel, comme celui qui le ferait le ferait avec le polymorphisme basé sur l'héritage. Cependant, ce n'est souvent pas la mise en œuvre la plus efficace, et c'est pourquoi dyno::poly permet de le personnaliser. Pour ce faire, transmettez simplement une politique de stockage à dyno::poly . Par exemple, définissons notre wrapper drawable afin qu'il essaie de stocker des objets jusqu'à 16 octets dans un tampon local, mais redevient ensuite au tas si l'objet est plus grand:
struct drawable {
template < typename T>
drawable (T x) : poly_{x} { }
void draw (std::ostream& out) const
{ poly_. virtual_ ( " draw " _s)(out); }
private:
dyno::poly<Drawable, dyno::sbo_storage< 16 >> poly_;
// ^^^^^^^^^^^^^^^^^^^^^ storage policy
}; Notez que rien sauf que la politique n'a changé dans notre définition. C'est un principe très important de Dyno ; Ces politiques sont des détails d'implémentation et ne devraient pas modifier la façon dont vous rédigez votre code. Avec la définition ci-dessus, vous pouvez désormais créer drawable S comme vous l'avez fait auparavant, et aucune allocation ne se produira lorsque l'objet que vous créez le drawable à partir de 16 octets. Quand cela ne convient pas, cependant, dyno::poly alloue un tampon suffisamment grand sur le tas.
Disons que vous ne voulez jamais faire une allocation. Pas de problème, changez simplement la politique en dyno::local_storage<16> . Si vous essayez de construire un drawable à partir d'un objet trop grand pour s'adapter au stockage local, votre programme ne se compile pas. Non seulement nous enregistrons une allocation, mais nous enregistrons également une indirection de pointeur chaque fois que nous accédons à l'objet polymorphe si nous nous comparons à l'approche traditionnelle basée sur l'héritage. En peaufinant ces (importants) détails d'implémentation pour votre cas d'utilisation spécifique, vous pouvez rendre votre programme beaucoup plus efficace qu'avec l'héritage classique.
D'autres politiques de stockage sont également fournies, comme dyno::remote_storage et dyno::non_owning_storage . dyno::remote_storage est le par défaut, qui stocke toujours un pointeur vers un objet alloué au tas. dyno::non_owning_storage stocke un pointeur vers un objet qui existe déjà, sans se soucier de la durée de vie de cet objet. Il permet de mettre en œuvre des vues polymorphes non acquises sur des objets, ce qui est très utile.
Des politiques de stockage personnalisées peuvent également être créées assez facilement. Voir <dyno/storage.hpp> pour plus de détails.
Lorsque nous avons introduit dyno::poly , nous avons mentionné qu'il avait deux rôles; La première consiste à stocker l'objet polymorphe, et le second est d'effectuer une répartition dynamique. Tout comme le stockage peut être personnalisé, la façon dont la répartition dynamique est effectuée peut également être personnalisée à l'aide de politiques. Par exemple, définissons notre wrapper drawable afin qu'au lieu de stocker un pointeur vers le VTable, il stocke plutôt le VTable dans l'objet drawable lui-même. De cette façon, nous éviterons une indirection chaque fois que nous accédons à une fonction virtuelle:
struct drawable {
template < typename T>
drawable (T x) : poly_{x} { }
void draw (std::ostream& out) const
{ poly_. virtual_ ( " draw " _s)(out); }
private:
using Storage = dyno::sbo_storage< 16 >; // storage policy
using VTable = dyno::vtable<dyno::local<dyno::everything>>; // vtable policy
dyno::poly<Drawable, Storage, VTable> poly_;
}; Notez que rien d'autre que la politique VTable ne doit changer dans la définition de notre type drawable . De plus, si nous le voulions, nous pourrions changer la politique de stockage indépendamment de la politique VTable. Avec ce qui précède, même si nous économisons toutes les indirections, nous le payons en agrandissant notre objet drawable (car il doit maintenir le VTable localement). Cela pourrait être prohibitif si nous avions de nombreuses fonctions dans le VTable. Au lieu de cela, il serait plus logique de stocker la majeure partie de la VTable à distance, mais uniquement en ligne ces quelques fonctions que nous appelons fortement. Dyno facilite le fait de le faire en utilisant des sélecteurs , qui peuvent être utilisés pour personnaliser les fonctions auxquelles une stratégie s'applique:
struct drawable {
template < typename T>
drawable (T x) : poly_{x} { }
void draw (std::ostream& out) const
{ poly_. virtual_ ( " draw " _s)(out); }
private:
using Storage = dyno::sbo_storage< 16 >;
using VTable = dyno::vtable<
dyno::local<dyno::only<decltype( " draw " _s)>>,
dyno::remote<dyno::everything_else>
>;
dyno::poly<Drawable, Storage, VTable> poly_;
}; Compte tenu de cette définition, le VTable est en fait divisé en deux. La première partie est locale à l'objet drawable et ne contient que la méthode draw . La deuxième partie est un pointeur vers un VTable en stockage statique qui contient les méthodes restantes (le destructeur, par exemple).
Dyno fournit deux politiques VTable, dyno::local<> et dyno::remote<> . Ces deux politiques doivent être personnalisées à l'aide d'un sélecteur . Les sélecteurs pris en charge par la bibliothèque sont dyno::only<functions...> , dyno::except<...> , et dyno::everything_else (qui peut également être orthographié dyno::everything ).
Lors de la définition d'un concept, il est souvent le cas que l'on peut fournir une définition par défaut pour au moins certaines fonctions associées au concept. Par exemple, par défaut, il serait probablement logique d'utiliser une fonction membre nommée draw (le cas échéant) pour implémenter la méthode "draw" abstraite du concept Drawable . Pour cela, on peut utiliser dyno::default_concept_map :
template < typename T>
auto const dyno::default_concept_map<Drawable, T> = dyno::make_concept_map(
" draw " _s = []( auto const & self, std::ostream& out) { self. draw (out); }
); Maintenant, chaque fois que nous essayons de voir comment un type T remplit le concept Drawable , nous nous repousserons sur la carte du concept par défaut si aucune carte conceptuelle n'a été définie. Par exemple, nous pouvons créer un nouveau Circle de type:
struct Circle {
void draw (std::ostream& out) const {
out << " circle " << std::endl;
}
};
f (Circle{}); // prints "circle" Circle est automatiquement un modèle de Drawable , même si nous ne avons pas explicitement défini une carte conceptuelle pour Circle . D'un autre côté, si nous devions définir une telle carte de concept, cela aurait la priorité sur la par défaut:
template <>
auto dyno::concept_map<Drawable, Circle> = dyno::make_concept_map(
" draw " _s = [](Circle const & circle, std::ostream& out) {
out << " triangle " << std::endl;
}
);
f (Circle{}); // prints "triangle" Il est parfois utile de définir une carte conceptuelle pour une famille complète de types à la fois. Par exemple, nous pourrions vouloir faire std::vector<T> un modèle de Drawable , mais uniquement lorsque T peut être imprimé en flux. Ceci est facilement réalisé en utilisant cette astuce secrète (pas si):
template < typename T>
auto const dyno::concept_map<Drawable, std::vector<T>, std:: void_t <decltype(
std::cout << std::declval<T>()
)>> = dyno::make_concept_map(
" draw " _s = [](std::vector<T> const & v, std::ostream& out) {
for ( auto const & x : v)
out << x << ' ' ;
}
);
f (std::vector< int >{ 1 , 2 , 3 }) // prints "1 2 3 "Remarquez comment nous n'avons pas du tout à modifier
std::vector. Comment pourrions-nous faire cela avec le polymorphisme classique? Réponse: Non ne peut faire.
Dyno permet d'effacer les fonctions et les fonctions non membres qui sont envoyées sur un argument arbitraire (mais un seul argument) également. Pour ce faire, définissez simplement le concept en utilisant dyno::function au lieu de dyno::method , et utilisez l'espace réservé dyno::T pour désigner l'argument effacé:
// Define the interface of something that can be drawn
struct Drawable : decltype(dyno::requires_(
" draw " _s = dyno::function< void (dyno::T const &, std::ostream&)>
)) { }; Le dyno::T const& paramètre utilisé ci-dessus représente le type de l'objet sur lequel la fonction est appelée. Cependant, il ne doit pas être le premier paramètre:
struct Drawable : decltype(dyno::requires_(
" draw " _s = dyno::function< void (std::ostream&, dyno::T const &)>
)) { };L'accomplissement du concept ne change pas si le concept utilise une méthode ou une fonction, mais assurez-vous que les paramètres de la mise en œuvre de votre fonction correspondent à celui de la fonction déclarée dans le concept:
// Define how concrete types can fulfill that interface
template < typename T>
auto const dyno::default_concept_map<Drawable, T> = dyno::make_concept_map(
" draw " _s = [](std::ostream& out, T const & self) { self. draw (out); }
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ matches the concept definition
); Enfin, lorsque vous appelez une function sur un dyno::poly , vous devrez passer explicitement tous les paramètres, car Dyno ne peut pas deviner celui sur lequel vous souhaitez envoyer. Le paramètre qui a été déclaré avec un dyno::T Planholder dans le concept doit être passé le dyno::poly lui-même:
// Define an object that can hold anything that can be drawn.
struct drawable {
template < typename T>
drawable (T x) : poly_{x} { }
void draw (std::ostream& out) const
{ poly_. virtual_ ( " draw " _s)(out, poly_); }
// ^^^^^ passing the poly explicitly
private:
dyno::poly<Drawable> poly_;
};