Avere un oggetto radice limita ciò che si può fare e ciò che il compilatore può fare, senza molto profitto.
Una classe radice comune rende possibile creare contenitori-di-qualsiasi ed estrarre ciò che sono con un dynamic_cast
, ma se hai bisogno di contenitori-di-qualcosa allora qualcosa di simile a boost::any
può farlo senza una classe radice comune. E boost::any
supporta anche le primitive - può anche supportare la piccola ottimizzazione del buffer e lasciarle quasi "unboxed" nel linguaggio di Java.
C ++ supporta e prospera sui tipi di valore. Entrambi i letterali e i tipi di valori scritti dal programmatore. I contenitori C ++ memorizzano, ordinano, cancellano, consumano e producono in modo efficiente i tipi di valore.
L'ereditarietà, in particolare il tipo di ereditarietà monolitica delle classi di base in stile Java, richiede i tipi di "puntatore" o "riferimento" basati sul free-store. L'handle / puntatore / riferimento ai dati contiene un puntatore all'interfaccia della classe e polimorficamente potrebbe rappresentare qualcos'altro.
Sebbene ciò sia utile in alcune situazioni, una volta che ti sei sposato con una "classe base comune", hai bloccato l'intera base di codici nel costo e nel bagaglio di questo schema, anche quando non è utile .
Quasi sempre si conosce di più su un tipo di "è un oggetto" sul sito chiamante o nel codice che lo utilizza.
Se la funzione è semplice, scrivere la funzione come modello ti dà il polimorfismo basato sul tempo di compilazione tipo anatra, in cui le informazioni sul sito di chiamata non vengono eliminate. Se la funzione è più complessa, è possibile eseguire la cancellazione di un tipo per cui le operazioni uniformi sul tipo che si desidera eseguire (ad esempio, serializzazione e deserializzazione) possono essere compilate e archiviate (in fase di compilazione) per essere consumate (in fase di esecuzione) dal codice in un'altra unità di traduzione.
Supponiamo che tu abbia qualche libreria dove vuoi che tutto sia serializzabile. Un approccio è quello di avere una classe base:
struct serialization_friendly {
virtual void write_to( my_buffer* ) const = 0;
virtual void read_from( my_buffer const* ) = 0;
virtual ~serialization_friendly() {}
};
Ora ogni bit di codice che scrivi può essere serialization_friendly
.
void serialize( my_buffer* b, serialization_friendly const* x ) {
if (x) x->write_to(b);
}
Tranne non un std::vector
, quindi ora devi scrivere ogni contenitore. E non quei numeri interi che hai preso da quella libreria bignum. E non quel tipo che hai scritto che non hai pensato alla serializzazione necessaria. E non un tuple
, o un int
o un double
, o un std::ptrdiff_t
.
Abbiamo un altro approccio:
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 );
}
che consiste, beh, non fare nulla, apparentemente. Tranne ora possiamo estendere write_to
sovrascrivendo write_to
come funzione libera nello spazio dei nomi di un tipo o di un metodo nel tipo.
Possiamo anche scrivere un po 'di codice di cancellazione del 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) );
) {}
e ora possiamo prendere un tipo arbitrario e inserirlo automaticamente in un'interfaccia can_serialize
che ti consente di richiamare serialize
in un secondo momento tramite un'interfaccia virtuale.
void writer_thingy( can_serialize s );
è una funzione che prende tutto ciò che può serializzare, invece di
void writer_thingy( serialization_friendly const* s );
e il primo, a differenza del secondo, può gestire automaticamente int
, std::vector<std::vector<Bob>>
.
Non ci è voluto molto per scriverlo, soprattutto perché questo genere di cose è qualcosa che raramente vuoi fare, ma abbiamo acquisito la capacità di trattare qualsiasi cosa come serializzabile senza richiedere un tipo di base.
Cos'altro possiamo ora rendere std::vector<T>
serializzabile come cittadino di prima classe semplicemente sovrascrivendo write_to( my_buffer*, std::vector<T> const& )
- con tale overload, può essere passato a can_serialize
e la serializabilty di std::vector
ottiene memorizzato in un vtable e accessibile da .write_to
.
In breve, C ++ è abbastanza potente da poter implementare i vantaggi di una singola classe base al volo quando richiesto, senza dover pagare il prezzo di una gerarchia di ereditarietà forzata quando non richiesto. E i tempi in cui la singola base (falsificata o meno) è richiesta è abbastanza rara.
Quando i tipi sono in realtà la loro identità e sai cosa sono, le opportunità di ottimizzazione abbondano. I dati sono archiviati localmente e in modo contiguo (che è molto importante per la cache friendly sui processori moderni), i compilatori possono facilmente capire cosa fa una determinata operazione (invece di avere un puntatore opaco del metodo virtuale che deve saltare sopra, portando a codice sconosciuto sul altro lato) che consente di riordinare le istruzioni in modo ottimale e di ridurre il numero di pioli arrotondati nei fori rotondi.