Tener un objeto raíz limita lo que puede hacer y lo que puede hacer el compilador, sin mucha recompensa.
Una clase raíz común hace posible crear contenedores de cualquier cosa y extraer lo que son con un dynamic_cast
, pero si necesita contenedores de cualquier cosa, algo similar boost::any
puede hacerlo sin una clase raíz común. Y boost::any
también admite primitivas: incluso puede admitir la pequeña optimización del búfer y dejarlos casi "sin caja" en lenguaje Java.
C ++ admite y prospera en los tipos de valor. Ambos literales y tipos de valores escritos del programador. Los contenedores C ++ almacenan, clasifican, combinan, consumen y producen de manera eficiente tipos de valor.
La herencia, especialmente el tipo de herencia monolítica que implican las clases base de estilo Java, requiere tipos de "puntero" o "referencia" basados en la tienda libre. Su identificador / puntero / referencia a datos contiene un puntero a la interfaz de la clase, y polimórficamente podría representar algo más.
Si bien esto es útil en algunas situaciones, una vez que se ha casado con el patrón con una "clase base común", ha bloqueado toda su base de código en el costo y el equipaje de este patrón, incluso cuando no es útil.
Casi siempre sabe más sobre un tipo que "es un objeto" en el sitio de llamada o en el código que lo utiliza.
Si la función es simple, escribir la función como una plantilla le proporciona un polimorfismo basado en el tiempo de compilación tipo pato donde la información en el sitio de llamada no se desecha. Si la función es más compleja, se puede borrar el tipo mediante el cual las operaciones uniformes en el tipo que desea realizar (digamos, serialización y deserialización) se pueden construir y almacenar (en tiempo de compilación) para que el consumidor las consuma (en tiempo de ejecución) código en una unidad de traducción diferente.
Supongamos que tiene una biblioteca donde desea que todo sea serializable. Un enfoque es tener una clase base:
struct serialization_friendly {
virtual void write_to( my_buffer* ) const = 0;
virtual void read_from( my_buffer const* ) = 0;
virtual ~serialization_friendly() {}
};
Ahora cada bit de código que escribes puede ser serialization_friendly
.
void serialize( my_buffer* b, serialization_friendly const* x ) {
if (x) x->write_to(b);
}
Excepto no un std::vector
, así que ahora necesitas escribir cada contenedor. Y no esos enteros que obtuviste de esa biblioteca bignum. Y no ese tipo que escribió que no creía que fuera necesario serializar. Y no a tuple
, o a int
o a double
, o a std::ptrdiff_t
.
Tomamos otro enfoque:
void write_to( my_buffer* b, int x ) {
b->write_integer(x);
}
template<class T,
class=std::enable_if_t< void_t<
std::declval<T const*>()->write_to( std::declval<my_buffer*>()
> >
>
void write_to( my_buffer* b, T const* x ) {
if (x) x->write_to(b);
}
template<class T>
void serialize( my_buffer* b, T const& t ) {
write_to( b, t );
}
que consiste en, bueno, no hacer nada, aparentemente. Excepto que ahora podemos extender write_to
anulando write_to
como una función libre en el espacio de nombres de un tipo o un método en el tipo.
Incluso podemos escribir un poco de código de borrado de tipo:
namespace details {
struct can_serialize_pimpl {
virtual void write_to( my_buffer* ) const = 0;
virtual void read_from( my_buffer const* ) = 0;
virtual ~can_serialize_pimpl() {}
};
}
struct can_serialize {
void write_to( my_buffer* b ) const { pImpl->write_to(b); }
void read_from( my_buffer const* b ) { pImpl->read_from(b); }
std::unique_ptr<details::can_serialize_pimpl> pImpl;
template<class T> can_serialize(T&&);
};
namespace details {
template<class T>
struct can_serialize : can_serialize_pimpl {
std::decay_t<T>* t;
void write_to( my_buffer*b ) const final override {
serialize( b, std::forward<T>(*t) );
}
void read_from( my_buffer const* ) final override {
deserialize( b, std::forward<T>(*t) );
}
can_serialize(T&& in):t(&in) {}
};
}
template<class T> can_serialize::can_serialize<T>(T&&t):pImpl(
std::make_unique<details::can_serialize<T>>( std::forward<T>(t) );
) {}
y ahora podemos tomar un tipo arbitrario y colocarlo automáticamente en una can_serialize
interfaz que le permite invocar serialize
en un punto posterior a través de una interfaz virtual.
Entonces:
void writer_thingy( can_serialize s );
es una función que toma cualquier cosa que pueda serializar, en lugar de
void writer_thingy( serialization_friendly const* s );
y la primera, a diferencia de la segunda, que puede manejar int
, std::vector<std::vector<Bob>>
de forma automática.
No tardó mucho en escribirlo, especialmente porque este tipo de cosas es algo que rara vez quieres hacer, pero obtuvimos la capacidad de tratar cualquier cosa como serializable sin requerir un tipo base.
Además, ahora podemos hacer que se pueda std::vector<T>
serializar como un ciudadano de primera clase simplemente anulando write_to( my_buffer*, std::vector<T> const& )
: con esa sobrecarga, se puede pasar a can_serialize
ay la serialización de los std::vector
archivos se almacena en una tabla virtual y se accede a ella .write_to
.
En resumen, C ++ es lo suficientemente potente como para implementar las ventajas de una sola clase base sobre la marcha cuando sea necesario, sin tener que pagar el precio de una jerarquía de herencia forzada cuando no es necesario. Y los tiempos en que se requiere la base única (falsa o no) es razonablemente rara.
Cuando los tipos son en realidad su identidad, y usted sabe cuáles son, abundan las oportunidades de optimización. Los datos se almacenan localmente y de forma contigua (lo cual es muy importante para la compatibilidad de la memoria caché en los procesadores modernos), los compiladores pueden comprender fácilmente lo que hace una operación determinada (en lugar de tener un puntero de método virtual opaco que debe saltar, lo que lleva a un código desconocido en el otro lado) que permite reordenar las instrucciones de manera óptima, y se clavan menos clavijas redondas en agujeros redondos.