En este punto, esta biblioteca es experimental y es una pura curiosidad. No se garantiza la estabilidad de la interfaz o la calidad de la implementación. Use en sus propios riesgos.
Dyno resuelve el problema del polimorfismo de tiempo de ejecución mejor que la vainilla C ++. Proporciona una forma de definir interfaces que se pueden cumplir de manera no inductiva, y proporciona una forma totalmente personalizable de almacenar objetos polimórficos y despachar a métodos virtuales. No requiere herencia, asignación de montón o deja el cómodo mundo de la semántica de valor, y puede hacerlo al tiempo que supera a Vanilla C ++.
DYNO es la implementación de la biblioteca pura de lo que también se conoce como objetos de rasgos de óxido, interfaces GO, clases de tipo Haskell y conceptos virtuales. Debajo del capó, utiliza una técnica C ++ conocida como Type Erasure, que es la idea detrás de std::any , std::function y muchos otros tipos útiles.
# 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
}Alternativamente, si considera que esto es demasiado calderero y puede soportar usando una macro, lo siguiente es equivalente:
# 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
}Esta es una biblioteca C ++ 17. No se harán esfuerzos para apoyar a los compiladores más antiguos (lo siento). Se sabe que la biblioteca trabaja con los siguientes compiladores:
| Compilador | Versión |
|---|---|
| GCC | > = 7 |
| Sonido metálico | > = 4.0 |
| Mipan clang | > = 9.1 |
La biblioteca depende de boost.hana y boost.callabletraits. Las pruebas unitarias dependen de Libawful y los puntos de referencia dependen de Google Benchmark, Boost.TypeeRasure y MPark.Variant, pero no necesita que usen la biblioteca. Para el desarrollo local, el script dependencies/install.sh se puede usar para instalar todas las dependencias automáticamente.
Dyno es una biblioteca solo de encabezado, por lo que no hay nada que construir por-SE. Simplemente agregue el directorio include/ directorio a la ruta de búsqueda de encabezado de su compilador (y asegúrese de que las dependencias estén satisfechas), y que esté listo. Sin embargo, hay pruebas unitarias, ejemplos y puntos de referencia que se pueden construir:
(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 la programación, la necesidad de manipular objetos con una interfaz común pero con un tipo dinámico diferente surge con mucha frecuencia. C ++ resuelve esto con herencia:
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);
}Sin embargo, este enfoque tiene varios inconvenientes. Es
Intruso
Para que Square y Circle cumplan con la interfaz Drawable , ambos necesitan heredar de la clase base Drawable . Esto requiere tener la licencia para modificar esas clases, lo que hace que la herencia sea muy inextensible. Por ejemplo, ¿cómo harías un std::vector<int> cumplir con la interfaz Drawable ? Simplemente no puedes.
Incompatible con la semántica de valor
La herencia requiere que pase punteros polimórficos o referencias a objetos en lugar de los objetos mismos, que juega muy mal con el resto del idioma y la biblioteca estándar. Por ejemplo, ¿cómo copiaría un vector de Drawable s? Necesitaría proporcionar un método clone() virtual, pero ahora acaba de estropear su interfaz.
Bien junto con almacenamiento dinámico
Debido a la falta de semántica de valor, generalmente terminamos asignando estos objetos polimórficos en el montón. Esto es horriblemente ineficiente y semánticamente incorrecto, ya que es probable que no necesitemos la duración de almacenamiento dinámico, y un objeto con duración automática de almacenamiento (por ejemplo, en la pila) hubiera sido suficiente.
Previene la inscripción
El 95% del tiempo, terminamos llamando a un método virtual a través de un puntero o referencia polimórfica. Eso requiere tres indirecciones: una para cargar el puntero al VTable dentro del objeto, uno para cargar la entrada correcta en la VTable y otra para la llamada indirecta al puntero de la función. Todo este salto dificulta que el compilador tome buenas decisiones. Sin embargo, resulta que todas estas indirecciones, excepto la llamada indirecta, se puede evitar.
Desafortunadamente, esta es la elección que C ++ nos ha hecho, y estas son las reglas a las que estamos obligados cuando necesitamos polimorfismo dinámico. ¿O es realmente?
Dyno resuelve el problema del polimorfismo de tiempo de ejecución en C ++ sin ninguno de los inconvenientes enumerados anteriormente, y muchas más goleadas. Es:
No intrusivo
Un tipo puede cumplir una interfaz sin requerir ninguna modificación a ese tipo. ¡Diablos, un tipo puede incluso cumplir la misma interfaz de diferentes maneras! Con Dyno , puedes despedirte de ridículas jerarquías de clase.
100% basado en la semántica de valor
Los objetos polimórficos se pueden pasar tal cual, con su semántica de valor natural. ¿Necesita copiar sus objetos polimórficos? Claro, solo asegúrese de que tengan un constructor de copias. ¿Quieres asegurarte de que no sean copiados? Claro, marquéelo como eliminado. Con Dyno , los métodos tontos clone() y la proliferación de punteros en las API son cosas del pasado.
No junto con ninguna estrategia de almacenamiento específica
La forma en que se almacena un objeto polimórfico es realmente un detalle de implementación, y no debe interferir con la forma en que usa ese objeto. Dyno le brinda un control completo sobre la forma en que se almacenan sus objetos. ¿Tienes muchos objetos polimórficos pequeños? Claro, almacenemoslos en un amortiguador local y evitemos cualquier asignación. ¿O tal vez tiene sentido que almacene las cosas en el montón? Claro, adelante.
Mecanismo de envío flexible para lograr el mejor rendimiento posible
Almacenar un puntero a un VTable es solo una de las muchas estrategias de implementación diferentes para realizar un despacho dinámico. Dyno le brinda un control completo sobre cómo ocurre el despacho dinámico, y de hecho puede vencer a Vtables en algunos casos. Si tiene una función que se llama en un bucle caliente, puede, por ejemplo, almacenarla directamente en el objeto y omitir la indirección VTable. También puede usar conocimiento específico de la aplicación El compilador nunca podría tener que optimizar algunas llamadas dinámicas: devirtualización a nivel de biblioteca.
Primero, comienza definiendo una interfaz genérica y dándole un nombre. Dyno proporciona un lenguaje específico de dominio simple para hacerlo. Por ejemplo, definamos una interfaz Drawable que describe tipos que se pueden dibujar:
# include < dyno.hpp >
using namespace dyno ::literals ;
struct Drawable : decltype(dyno::requires_(
" draw " _s = dyno::method< void (std::ostream&) const >
)) { }; Esto define Drawable como representar una interfaz para cualquier cosa que tenga un método llamado draw en referencia a un std::ostream . Dyno llama a estas interfaces conceptos dinámicos , ya que describen conjuntos de requisitos para ser cumplidos por un tipo (como los conceptos C ++). Sin embargo, a diferencia de los conceptos C ++, estos conceptos dinámicos se utilizan para generar interfaces de tiempo de ejecución, de ahí el nombre dinámico . La definición anterior es básicamente equivalente a lo siguiente:
struct Drawable {
virtual void draw (std::ostream&) const = 0;
};Una vez que se define la interfaz, el siguiente paso es crear un tipo que satisfaga esta interfaz. Con herencia, escribirías algo como esto:
struct Square : Drawable {
virtual void draw (std::ostream& out) const override final {
out << " square " << std::endl;
}
};Con Dyno , el polimorfismo no es intrusivo y en su lugar se proporciona a través de lo que se llama mapa conceptual (después de mapas conceptuales 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;
}
);Esta construcción es la especialización de una plantilla variable C ++ 14 llamada
concept_mapdefinida en el espacio de nombresdyno::. Luego inicializamos esa especialización condyno::make_concept_map(...).
El primer parámetro de la lambda es el implícito *this parámetro que está implícito cuando declaramos draw como un método anterior. También es posible borrar las funciones no miembros (ver la sección relevante).
Este mapa conceptual define cómo el tipo Square satisface el concepto Drawable . En cierto sentido, asigna el tipo Square a su implementación del concepto, lo que motiva la denominación. Cuando un tipo satisface los requisitos de un concepto, decimos que los modelos de tipo (o son un modelo de) ese concepto. Ahora que Square es un modelo del concepto Drawable , nos gustaría usar un Square polimórficamente como un Drawable . Con la herencia tradicional, usaríamos un puntero a una clase base como esta:
void f (Drawable const * d) {
d-> draw (std::cout);
}
f ( new Square{}); Con Dyno , el polimorfismo y la semántica de valor son compatibles, y la forma en que se pasan los tipos polimórficos pueden ser altamente personalizados. Para hacer esto, necesitaremos definir un tipo que pueda contener todo lo que Drawable . Es ese tipo, en lugar de un Drawable* , que pasaremos hacia y desde funciones polimórficas. Para ayudar a definir este envoltorio, Dyno proporciona el contenedor dyno::poly , que puede contener un objeto arbitrario que satisface un concepto dado. Como verá, dyno::poly tiene un doble papel: almacena el objeto polimórfico y se encarga del envío dinámico de los métodos. Todo lo que necesita hacer es escribir un envoltorio delgado sobre dyno::poly para darle exactamente la interfaz deseada:
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_;
};Nota: Técnicamente puede usar
dyno::polydirectamente en sus interfaces. Sin embargo, es mucho más conveniente usar un envoltorio con métodos reales quedyno::poly, por lo que se recomienda escribir un envoltorio.
Desglosemos esto. Primero, definimos un miembro poly_ que es un contenedor polimórfico para cualquier cosa que modela el concepto Drawable :
dyno::poly<Drawable> poly_; Luego, definimos un constructor que permite construir este contenedor a partir de un tipo arbitrario T :
template < typename T>
drawable (T x) : poly_{x} { } La suposición no dicha aquí es que T en realidad modela el concepto Drawable . De hecho, cuando crea un dyno::poly de un objeto de Tipo T , Dyno irá y mirará el mapa conceptual definido para Drawable y T , si lo hay. Si no existe dicho mapa conceptual, la biblioteca informará que estamos tratando de crear un dyno::poly de un tipo que no lo admite, y su programa no se compilará.
Finalmente, la parte más extraña y más importante de la definición anterior es la del método draw :
void draw (std::ostream& out) const
{ poly_. virtual_ ( " draw " _s)(out); } Lo dyno::poly "draw" aquí es que drawable .draw Ahora, para crear una función que acepte cualquier cosa que Drawable , ya no es necesario preocuparse por los punteros y la propiedad en su interfaz:
void f (drawable d) {
d. draw (std::cout);
}
f (Square{});Por cierto, si estás pensando que todo esto es estúpido y que deberías haber estado usando una plantilla, tienes razón. Sin embargo, considere lo siguiente, donde realmente necesita polimorfismo de tiempo de ejecución :
drawable get_drawable () {
if ( some_user_input ())
return Square{};
else
return Circle{};
}
f (get_drawable()); Estrictamente hablando, no necesita envolver dyno::poly , pero hacerlo pone una buena barrera entre Dyno y el resto de su código, que nunca tiene que preocuparse por cómo se implementa su capa polimórfica. Además, ignoramos en gran medida cómo se implementó dyno::poly en la definición anterior. Sin embargo, dyno::poly es un contenedor basado en políticas muy potente para objetos polimórficos que se pueden personalizar a las necesidades de rendimiento de uno. La creación de un envoltorio drawable facilita el ajuste de la estrategia de implementación utilizada por dyno::poly para el rendimiento sin afectar el resto de su código.
El primer aspecto que se puede personalizar en un dyno::poly es la forma en que el objeto se almacena dentro del contenedor. Por defecto, simplemente almacenamos un puntero al objeto real, como uno lo haría con el polimorfismo basado en la herencia. Sin embargo, esta a menudo no es la implementación más eficiente, y es por eso que dyno::poly permite personalizarla. Para hacerlo, simplemente pase una política de almacenamiento a dyno::poly . Por ejemplo, definamos nuestro envoltorio drawable para que intente almacenar objetos hasta 16 bytes en un búfer local, pero luego vuelve al montón si el objeto es más grande:
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
}; Observe que nada excepto la política cambió en nuestra definición. Ese es un principio muy importante de dinamómetro ; Estas políticas son detalles de implementación, y no deben cambiar la forma en que escribe su código. Con la definición anterior, ahora puede crear drawable s como lo hizo antes, y no se producirá una asignación cuando el objeto que esté creando el drawable de ajuste en 16 bytes. Sin embargo, cuando no encaja, dyno::poly asignará un búfer lo suficientemente grande en el montón.
Digamos que en realidad nunca quieres hacer una asignación. No hay problema, solo cambie la política a dyno::local_storage<16> . Si intenta construir un drawable de un objeto que sea demasiado grande para caber en el almacenamiento local, su programa no se compilará. No solo estamos guardando una asignación, sino que también estamos guardando una indirección de puntero cada vez que accedemos al objeto polimórfico si comparamos con el enfoque tradicional basado en la herencia. Al ajustar estos detalles de implementación (importantes) para su caso de uso específico, puede hacer que su programa sea mucho más eficiente que con la herencia clásica.
También se proporcionan otras políticas de almacenamiento, como dyno::remote_storage y dyno::non_owning_storage . dyno::remote_storage es el predeterminado, que siempre almacena un puntero a un objeto asignado por montón. dyno::non_owning_storage almacena un puntero a un objeto que ya existe, sin preocuparse por la vida de ese objeto. Permite implementar vistas polimórficas que no son propias sobre objetos, lo cual es muy útil.
Las políticas de almacenamiento personalizadas también se pueden crear con bastante facilidad. Consulte <dyno/storage.hpp> para más detalles.
Cuando presentamos dyno::poly , mencionamos que tenía dos roles; El primero es almacenar el objeto polimórfico, y el segundo es realizar un despacho dinámico. Al igual que el almacenamiento se puede personalizar, la forma en que se realiza el envío dinámico también se puede personalizar utilizando políticas. Por ejemplo, definamos nuestro envoltorio drawable para que, en lugar de almacenar un puntero al VTable, almacene la vtable en el objeto drawable en sí. De esta manera, evitaremos una indirección cada vez que accedamos a una función virtual:
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_;
}; Observe que nada además de la política VTable debe cambiar en la definición de nuestro tipo drawable . Además, si quisiéramos, podríamos cambiar la política de almacenamiento de forma independiente de la política VTable. Con lo anterior, a pesar de que estamos guardando todas las indirecciones, lo estamos pagando haciendo que nuestro objeto drawable sea más grande (ya que necesita mantener el VTable localmente). Esto podría ser prohibitivo si tuviéramos muchas funciones en el VTable. En cambio, tendría más sentido almacenar la mayor parte de la vtable de forma remota, pero solo en línea esas pocas funciones que llamamos en gran medida. DYNO hace que sea muy fácil hacerlo mediante el uso de selectores , que se pueden usar para personalizar a qué funciones se aplica una política:
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_;
}; Dada esta definición, el VTable en realidad se divide en dos. La primera parte es local para el objeto drawable y contiene solo el método draw . La segunda parte es un puntero a un Vtable en almacenamiento estático que contiene los métodos restantes (el destructor, por ejemplo).
DYNO proporciona dos políticas vTables, dyno::local<> y dyno::remote<> . Ambas políticas deben personalizarse utilizando un selector . Los selectores compatibles con la biblioteca son dyno::only<functions...> , dyno::except<...> , y dyno::everything_else (que también se puede deletrear dyno::everything ).
Al definir un concepto, a menudo es el caso que se puede proporcionar una definición predeterminada para al menos algunas funciones asociadas al concepto. Por ejemplo, por defecto, probablemente tendría sentido usar una función miembro llamada draw (si la hay) para implementar el método abstracto "draw" del concepto Drawable . Para esto, se puede usar 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); }
); Ahora, cada vez que intentamos ver cómo algún tipo T cumple el concepto Drawable , volveremos al mapa conceptual predeterminado si no se definió ningún mapa conceptual. Por ejemplo, podemos crear un nuevo Circle de tipo:
struct Circle {
void draw (std::ostream& out) const {
out << " circle " << std::endl;
}
};
f (Circle{}); // prints "circle" Circle es automáticamente un modelo de Drawable , a pesar de que no definimos explícitamente un mapa conceptual para Circle . Por otro lado, si tuviéramos que definir este mapa conceptual, tendría precedencia sobre el predeterminado:
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" A veces es útil definir un mapa conceptual para una familia completa de tipos a la vez. Por ejemplo, es posible que queramos hacer std::vector<T> un modelo de Drawable , pero solo cuando T se puede imprimir en una secuencia. Esto se logra fácilmente usando este truco secreto (no tan):
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 "Observe cómo no tenemos que modificar
std::vectoren absoluto. ¿Cómo podríamos hacer esto con el polimorfismo clásico? Respuesta: No puede hacerlo.
DYNO permite borrar funciones y funciones que no son miembros que se envían en un argumento arbitrario (pero solo un argumento) también. Para hacer esto, simplemente defina el concepto usando dyno::function en lugar del dyno::method , y use el marcador de posición dyno::T para denotar el argumento que se está borrando:
// Define the interface of something that can be drawn
struct Drawable : decltype(dyno::requires_(
" draw " _s = dyno::function< void (dyno::T const &, std::ostream&)>
)) { }; El dyno::T const& el parámetro utilizado anteriormente representa el tipo de objeto en el que se llama la función. Sin embargo, no tiene que ser el primer parámetro:
struct Drawable : decltype(dyno::requires_(
" draw " _s = dyno::function< void (std::ostream&, dyno::T const &)>
)) { };El cumplimiento del concepto no cambia si el concepto usa un método o una función, pero asegúrese de que los parámetros de su función de implementación coincidan con el de la función declarada en el concepto:
// 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
); Finalmente, al llamar a una function en un dyno::poly , tendrá que pasar todos los parámetros explícitamente, ya que Dyno no puede adivinar en cuál desea enviar. El parámetro que se declaró con un marcador de posición dyno::T en el concepto debe pasar el dyno::poly mismo:
// 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_;
};