Perché la base per tutti gli oggetti scoraggiati in C ++

76

Stroustrup dice "Non inventare immediatamente una base univoca per tutte le tue classi (una classe Object). In genere, puoi fare meglio senza di essa per molte / molte classi." (La quarta edizione del linguaggio di programmazione C ++, sezione 1.3.4)

Perché una classe base per tutto è generalmente una cattiva idea e quando ha senso crearne una?

    
posta Matthew James Briggs 15.02.2015 - 17:45
fonte

11 risposte

74

Perché cosa avrebbe quell'oggetto per la funzionalità? In Java tutte le classi Base hanno una toString, un hashCode & uguaglianza e una variabile di condizione monitor +.

  • ToString è utile solo per il debug.

  • hashCode è utile solo se vuoi archiviarlo in una raccolta basata su hash (la preferenza in C ++ è di passare una funzione di hashing al contenitore come parametro del modello o di evitare std::unordered_* del tutto e invece usare std::vector e liste semplici non ordinate).

  • l'uguaglianza senza un oggetto base può essere aiutata in fase di compilazione, se non hanno lo stesso tipo, allora non possono essere uguali. In C ++ questo è un errore in fase di compilazione.

  • il monitor e la variabile di condizione sono meglio inclusi esplicitamente caso per caso.

Tuttavia, quando c'è più che deve fare, c'è un caso d'uso.

Ad esempio in QT esiste la classe radice QObject che costituisce la base dell'affinità del filetto, della gerarchia della proprietà genitore-figlio e del meccanismo di slot del segnale. Impone anche l'uso del puntatore per QObjects, tuttavia molte classi in Qt non ereditano QObject perché non hanno bisogno dello slot del segnale (in particolare i tipi di valore di alcune descrizioni).

    
risposta data 15.02.2015 - 18:04
fonte
98

Perché non ci sono funzioni condivise da tutti gli oggetti. Non c'è niente da mettere in questa interfaccia che avrebbe senso per tutte le classi.

    
risposta data 15.02.2015 - 18:37
fonte
25

Ogni volta che costruisci gerarchie di ereditarietà di oggetti alte tendi a imbatterti nel problema della Classe di base fragile (Wikipedia) .

Avere molte piccole gerarchie di ereditarietà separate (distinte, isolate) riduce le possibilità di incorrere in questo problema.

Rendere tutti i tuoi oggetti parte di una singola gerarchia ereditaria praticamente ti garantisce che hai intenzione di imbatterti in questo problema.

    
risposta data 15.02.2015 - 18:18
fonte
24

A causa:

  1. Non dovresti pagare per ciò che non usi.
  2. Queste funzioni hanno meno senso in un sistema di tipi basato sul valore che in un sistema di tipi basato su riferimenti.

L'implementazione di qualsiasi tipo di funzione virtual introduce una tabella virtuale, che richiede un overhead di spazio per oggetto che non è necessario né desiderato in molte (più?) situazioni.

Implementare toString in modo non virtuale sarebbe piuttosto inutile, perché l'unica cosa che potrebbe restituire è l'indirizzo dell'oggetto, che è molto user-unfriendly e al quale il chiamante ha già accesso, a differenza di Java.
Allo stesso modo, un% non coutente equals o hashCode potrebbe usare solo gli indirizzi per confrontare gli oggetti, che è ancora abbastanza inutile e spesso addirittura completamente sbagliato - a differenza di Java, gli oggetti vengono copiati frequentemente in C ++ e quindi distinguono l''identità' di un oggetto non è nemmeno sempre significativo o utile. (ad esempio, un int dovrebbe davvero non avere un'identità diversa dal suo valore ... due interi dello stesso valore dovrebbero essere uguali.)

    
risposta data 15.02.2015 - 23:13
fonte
16

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.

    
risposta data 16.02.2015 - 22:12
fonte
8

Ci sono molte buone risposte sopra e il chiaro fatto che qualsiasi cosa tu faccia con una classe base di tutti gli oggetti può essere fatta meglio in altri modi, come mostrato dalla risposta di @ ratchetfreak e i commenti su di esso sono molto importante, ma c'è un altro motivo, che è quello di evitare di creare diamanti dell'eredità quando si utilizza l'ereditarietà multipla. Se avessi qualche funzionalità in una classe base universale, non appena avvii l'uso dell'ereditarietà multipla, dovresti iniziare a specificare quale variante desideri accedere, perché potrebbe essere sovraccaricata in modo diverso in diversi percorsi della catena di ereditarietà. E la base non può essere virtuale, perché sarebbe molto inefficiente (richiedendo a tutti gli oggetti di avere una tabella virtuale a un costo potenzialmente enorme nell'uso e nella localizzazione della memoria). Questo diventerebbe un incubo logistico molto rapidamente.

    
risposta data 16.02.2015 - 09:29
fonte
5

In effetti i compilatori e le librerie C ++ di Microsofts precedenti (conosco Visual C ++, 16 bit) avevano una classe chiamata CObject .

Tuttavia devi sapere che in quel momento i "template" non erano supportati da questo semplice compilatore C ++, quindi classi come std::vector<class T> non erano possibili. Invece, un'implementazione di "vettori" poteva gestire solo un tipo di classe, quindi oggi esisteva una classe paragonabile a std::vector<CObject> . Poiché CObject era la classe base di quasi tutte le classi (sfortunatamente non di CString - l'equivalente di string nei moderni compilatori) potresti usare questa classe per memorizzare quasi tutti i tipi di oggetti.

Poiché i compilatori moderni supportano i modelli, questo caso d'uso di una "classe base generica" non viene più fornito.

Devi pensare al fatto che usare una classe base così generica costerà (un po ') memoria e runtime - per esempio nella chiamata al costruttore. Quindi ci sono degli svantaggi quando si usa una classe simile, ma almeno quando si usano i compilatori C ++ moderni non c'è quasi nessun caso d'uso per una classe del genere.

    
risposta data 15.02.2015 - 21:05
fonte
5

Ho intenzione di suggerire un altro motivo che proviene da Java.

Perché tu non puoi creare una classe base per tutto almeno non senza un mazzo di piastre di caldaia.

Potresti riuscire a farla franca per le tue classi, ma probabilmente scoprirai che si finisce per duplicare un sacco di codice. Per esempio. "Non posso usare std::vector qui poiché non implementa IObject - Farò meglio a creare un nuovo derivato IVectorObject che faccia la cosa giusta ...".

Questo succederà ogni volta che avrai a che fare con classi built-in o classi o classi di librerie standard da altre librerie.

Ora, se è stato incorporato nella lingua, finirebbe con cose come la confusione Integer e int che è in java, o una grande modifica alla sintassi del linguaggio. (Intendiamoci, penso che alcune altre lingue abbiano fatto un buon lavoro con la costruzione in ogni tipo - il rubino sembra un esempio migliore.)

Nota anche che se la tua classe base non è polimorfica run-time (cioè utilizzando funzioni virtuali) potresti ottenere lo stesso vantaggio dall'uso di tratti come framework.

es. invece di .toString() potresti avere il seguente: (NOTA: so che puoi fare questo più ordinato usando le librerie esistenti ecc., È solo un esempio illustrativo.)

template<typename T>
struct ToStringTrait;

template<typename T> 
std::string toString(const T & t) {
  return ToStringTrait<T>::toString(t);
}

template<>
struct ToStringTrait<int> {
  std::string toString(int v) {
    return itoa(v);
  }
}

template<typename T>
struct ToStringTrait<std::vector<T>> {
  std::string toString(const std::vector<T> &v) {
    std::stringstream ss;
    ss<<"{";
    for(int i=0; i<v.size(); ++i) {
      ss<<toString(v[i]);
    }
    ss<<"}";
    return ss.str();
  }
}
    
risposta data 16.02.2015 - 03:05
fonte
3

Probabilmente il "vuoto" soddisfa molti dei ruoli di una classe base universale. Puoi lanciare qualsiasi puntatore a void* . È quindi possibile confrontare questi puntatori. Puoi static_cast ritornare alla classe originale.

Tuttavia ciò che non puoi fare con void che puoi fare con Object è usare RTTI per capire che tipo di oggetto hai veramente. In ultima analisi, non tutti gli oggetti in C ++ hanno RTTI e infatti è possibile avere oggetti a larghezza zero.

    
risposta data 16.02.2015 - 18:23
fonte
2

Java prende la filosofia di progettazione che Comportamento indefinito non dovrebbe esistere . Codice come:

Cat felix = GetCat();
Woofer Rover = (Woofer)felix;
Rover.woof();

verificherà se felix contiene un sottotipo di Cat che implementa l'interfaccia Woofer ; se lo fa, eseguirà il cast e invocherà woof() e, in caso contrario, genererà un'eccezione. Il comportamento del codice è completamente definito se felix implementa Woofer o non .

C ++ prende la filosofia che se un programma non dovesse tentare qualche operazione, non dovrebbe importare cosa farebbe il codice generato se quell'operazione fosse tentata, e il computer non dovrebbe perdere tempo a cercare di limitare il comportamento nei casi in cui "dovrebbe" mai sorgere. In C ++, aggiungendo gli operatori di riferimento indiretto appropriati in modo da lanciare un *Cat a *Woofer , il codice produrrebbe un comportamento definito quando il cast è legittimo, ma comportamento indefinito quando non è .

Avere un tipo di base comune per le cose rende possibile convalidare i cast tra derivati di quel tipo di base, e anche fare operazioni di prova, ma la validazione delle conversioni è più costosa del semplice presupposto che siano legittimi e sperano in nulla di male succede. La filosofia del C ++ sarebbe che tale convalida richiede "pagare per qualcosa che [di solito] non è necessario".

Un altro problema relativo al C ++, ma non sarebbe un problema per una nuova lingua, è che se diversi programmatori creano ciascuno una base comune, derivano le loro classi da quello, e scrivono il codice per lavorare con le cose di quel comune classe base, tale codice non sarà in grado di lavorare con oggetti sviluppati da programmatori che hanno utilizzato una classe base diversa. Se una nuova lingua richiede che tutti gli oggetti heap abbiano un formato di intestazione comune e non abbia mai consentito oggetti heap che non lo hanno fatto, allora un metodo che richiede un riferimento a un oggetto heap con tale intestazione accetterà un riferimento a qualsiasi oggetto heap chiunque potrebbe mai creare.

Personalmente, penso che avere un mezzo comune per chiedere un oggetto "sei convertibile per digitare X" è una caratteristica molto importante in un linguaggio / framework, ma se tale funzionalità non è incorporata in una lingua dall'inizio è difficile aggiungerlo in seguito. Personalmente, penso che una tale classe base dovrebbe essere aggiunta a una libreria standard alla prima opportunità, con una strong raccomandazione che tutti gli oggetti che verranno utilizzati polimorficamente dovrebbero ereditare da quella base. Se i programmatori implementassero ciascuno i propri "tipi di base", renderebbe più difficile il passaggio di oggetti tra codice di persone diverse, ma avendo un tipo di base comune da cui molti programmatori ereditavano avrebbe reso più semplice.

APPENDICE

Utilizzando i modelli, è possibile definire un "titolare di un oggetto arbitrario" e chiedergli del tipo di oggetto in esso contenuto; il pacchetto Boost contiene una cosa chiamata any . Pertanto, anche se il C ++ non ha un tipo standard di "tipo di riferimento controllabile per qualsiasi cosa", è possibile crearne uno. Questo non risolve il problema sopra menzionato con il non avere qualcosa nello standard del linguaggio, cioè l'incompatibilità tra le diverse implementazioni dei programmatori, ma spiega come procede C ++ senza avere un tipo di base da cui tutto è derivato: rendendo possibile creare qualcosa che si comporta come tale.

    
risposta data 18.02.2015 - 17:43
fonte
1

Symbian C ++ aveva infatti una classe base universale, CBase, per tutti gli oggetti che si comportavano in un modo particolare (principalmente se allocavano heap). Forniva un distruttore virtuale, azzerava la memoria della classe sulla costruzione e nascondeva il costruttore della copia.

La logica dietro era che era un linguaggio per i sistemi embedded e compilatori C ++ e le specifiche erano davvero una merda 10 anni fa.

Non tutte le classi hanno ereditato da questo, solo alcuni.

    
risposta data 16.02.2015 - 20:01
fonte

Leggi altre domande sui tag