Avere un oggetto root limita ciò che puoi fare e ciò che il compilatore può fare, senza molto guadagno.
Una classe radice comune consente di creare contenitori di qualsiasi cosa ed estrarre ciò che sono con un dynamic_cast
, ma se hai bisogno di contenitori di qualsiasi cosa, allora qualcosa di simile boost::any
può farlo senza una classe radice comune. E boost::any
supporta anche primitive - si può anche sostenere la piccola ottimizzazione buffer e lasciarle quasi "unboxed" in Java gergo.
C ++ supporta e prospera sui tipi di valore. Entrambi i letterali e i programmatori hanno scritto tipi di valore. I contenitori C ++ memorizzano, ordinano, hash, consumano e producono in modo efficiente tipi di valore.
L'ereditarietà, in particolare il tipo di eredità monolitica che implicano le classi base in stile Java, richiede tipi di "puntatore" o "riferimento" basati su negozio libero. Il tuo handle / pointer / 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 il modello con una "classe base comune", hai bloccato l'intera base di codice nel costo e nel bagaglio di questo modello, anche quando non è utile.
Quasi sempre sai di più su un tipo che su "è un oggetto" nel sito chiamante o nel codice che lo utilizza.
Se la funzione è semplice, scrivere la funzione come modello fornisce un polimorfismo basato sul tempo di compilazione di tipo duck in cui le informazioni sul sito chiamante non vengono eliminate. Se la funzione è più complessa, è possibile eseguire la cancellazione del tipo per cui le operazioni uniformi sul tipo che si desidera eseguire (ad esempio serializzazione e deserializzazione) possono essere costruite e memorizzate (in fase di compilazione) per essere consumate (in fase di esecuzione) dal codice in un'altra unità di traduzione.
Supponiamo di avere una libreria in cui 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 è necessario scrivere ogni contenitore. E non quegli interi che hai preso da quella libreria di Bignum. E non quel tipo che hai scritto che non pensavi fosse necessario serializzare. E non a tuple
, o a int
o a double
, o a std::ptrdiff_t
.
Adottiamo 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, nel non fare nulla, apparentemente. Tranne ora possiamo estenderlo write_to
sostituendo write_to
come una funzione libera nello spazio dei nomi di un tipo o 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 can_serialize
un'interfaccia che ti consente di invocare serialize
in un secondo momento attraverso un'interfaccia virtuale.
Così:
void writer_thingy( can_serialize s );
è una funzione che accetta tutto ciò che può serializzare, anziché
void writer_thingy( serialization_friendly const* s );
e il primo, a differenza del secondo, in grado di gestire int
, std::vector<std::vector<Bob>>
automaticamente.
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.
Inoltre, ora possiamo rendere std::vector<T>
serializzabile come un cittadino di prima classe semplicemente ignorando write_to( my_buffer*, std::vector<T> const& )
- con quel sovraccarico, può essere passato a un can_serialize
e la serializzazione delle cose std::vector
viene memorizzata in una tabella e vi si accede .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 ereditaria forzata quando non richiesto. E i tempi in cui è richiesta la singola base (falsa o no) sono ragionevolmente rari.
Quando i tipi sono in realtà la loro identità e sai cosa sono, le opportunità di ottimizzazione abbondano. I dati vengono archiviati localmente e contigui (il che è molto importante per la compatibilità con la cache dei moderni processori), i compilatori possono facilmente capire cosa fa una determinata operazione (invece di avere un puntatore di metodo virtuale opaco su cui deve saltare, portando a un codice sconosciuto sul dall'altro lato) che consente di riordinare le istruzioni in modo ottimale e un numero inferiore di pioli circolari viene martellato in fori rotondi.