Invarianti di vita dell'oggetto vs. spostare semantica

12

Quando ho imparato il C ++ molto tempo fa, mi è stato strongmente sottolineato che parte del punto di C ++ è che proprio come i loop hanno "invarianti di loop", le classi hanno anche invarianti associati alla durata dell'oggetto - cose che dovrebbe essere vero per tutto il tempo in cui l'oggetto è vivo. Cose che dovrebbero essere stabilite dai costruttori e preservate dai metodi. L'incapsulamento / controllo degli accessi è lì per aiutarti a rafforzare gli invarianti. RAII è una cosa che puoi fare con questa idea.

Da C ++ 11 ora abbiamo la semantica del movimento. Per una classe che supporta lo spostamento, lo spostamento da un oggetto non termina formalmente la sua vita - la mossa dovrebbe lasciarla in uno stato "valido".

Nel progettare una classe, è cattiva pratica se la si progetta in modo tale che gli invarianti della classe vengano mantenuti solo fino al punto da cui viene spostato? O è ok se ti permetterà di farlo andare più veloce.

Per renderlo concreto, supponiamo di avere un tipo di risorsa non copiabile ma spostabile in questo modo:

class opaque {
  opaque(const opaque &) = delete;

public:
  opaque(opaque &&);

  ...

  void mysterious();
  void mysterious(int);
  void mysterious(std::vector<std::string>);
};

E per qualsiasi ragione, ho bisogno di creare un wrapper copiabili per questo oggetto, in modo che possa essere usato, forse in un sistema di invio esistente.

class copyable_opaque {
  std::shared_ptr<opaque> o_;

  copyable_opaque() = delete;
public:
  explicit copyable_opaque(opaque _o)
    : o_(std::make_shared<opaque>(std::move(_o)))
  {}

  void operator()() { o_->mysterious(); }
  void operator()(int i) { o_->mysterious(i); }
  void operator()(std::vector<std::string> v) { o_->mysterious(v); }
};

In questo oggetto copyable_opaque , un invariante della classe stabilita in costruzione è che il membro o_ punti sempre a un oggetto valido, poiché non esiste un ctor predefinito e l'unico ctor che non è un ctor di copia garantisce questi. Tutti i metodi operator() presumono che questo invariante regga e lo conservino in seguito.

Tuttavia, se l'oggetto viene spostato da, quindi o_ non punta a nulla. E dopo quel punto, chiamare uno qualsiasi dei metodi operator() causerà UB / un arresto anomalo.

Se l'oggetto non viene mai spostato da, l'invariante verrà mantenuto fino alla chiamata dtor.

Supponiamo che ipoteticamente, ho scritto questa lezione, e mesi dopo, il mio collega immaginario ha fatto esperienza di UB perché, in alcune complicate funzioni in cui molti di questi oggetti venivano mescolati per qualche motivo, si è trasferito da una di queste cose e in seguito ha chiamato uno dei suoi metodi. Chiaramente è colpa sua alla fine della giornata, ma questa classe è "mal progettata?"

Pensieri:

  1. Di solito è una cattiva forma in C ++ per creare oggetti zombi che esplodono se li tocchi.
    Se non puoi costruire qualche oggetto, non puoi stabilire gli invarianti, quindi lanciare un'eccezione dal ctor. Se non è possibile conservare gli invarianti in qualche metodo, segnalare un errore in qualche modo e il rollback. Dovrebbe essere diverso per gli oggetti spostati da?

  2. È sufficiente documentare semplicemente "dopo che questo oggetto è stato spostato da, è illegale (UB) fare qualsiasi cosa con esso diverso da distruggerlo" nell'intestazione?

  3. È meglio affermare continuamente che è valido in ogni chiamata di metodo?

Così:

class copyable_opaque {
  std::shared_ptr<opaque> o_;

  copyable_opaque() = delete;
public:
  explicit copyable_opaque(opaque _o)
    : o_(std::make_shared<opaque>(std::move(_o)))
  {}

  void operator()() { assert(o_); o_->mysterious(); }
  void operator()(int i) { assert(o_); o_->mysterious(i); }
  void operator()(std::vector<std::string> v) { assert(o_); o_->mysterious(v); }
};

Le asserzioni non migliorano sostanzialmente il comportamento e provocano un rallentamento. Se il tuo progetto usa lo schema "build build / debug build", invece di essere sempre in esecuzione con asserzioni, immagino che questo sia più allettante, dal momento che non paghi i controlli nella build di rilascio. Se in realtà non hai build di debug, questo sembra piuttosto poco attraente però.

  1. È meglio rendere la classe copiabile, ma non mobile?
    Anche questo sembra male e causa un colpo di prestazioni, ma risolve il problema "invariante" in modo semplice.

Quali considereresti essere le "migliori pratiche" pertinenti qui?

    
posta Chris Beck 15.02.2016 - 02:01
fonte

1 risposta

20

It's usually bad form in C++ to create zombie objects that explode if you touch them.

Ma non è quello che stai facendo. Stai creando un "oggetto zombi" che esploderà se lo tocchi erroneamente . Che in definitiva non è diverso da qualsiasi altra precondizione basata sullo stato.

Considera la seguente funzione:

void func(std::vector<int> &v)
{
  v[0] = 5;
}

Questa funzione è sicura? No; l'utente può passare un vuoto vector . Quindi la funzione ha una precondizione di fatto che v ha almeno un elemento in esso. In caso contrario, ottieni UB quando chiami func .

Quindi questa funzione non è "sicura". Ma questo non significa che sia rotto. È rotto solo se il codice che lo utilizza viola la condizione preliminare. Forse func è una funzione statica usata come aiuto nell'implementazione di altre funzioni. Localizzato in tal modo, nessuno lo chiamerebbe in un modo che viola le sue precondizioni.

Molte funzioni, siano esse dello spazio dei nomi o dei membri della classe, avranno aspettative sullo stato di un valore su cui operano. Se queste precondizioni non vengono soddisfatte, le funzioni falliranno, in genere con UB.

La libreria standard C ++ definisce una regola "valida ma non specificata". Questo dice che, a meno che lo standard non dica altrimenti, ogni oggetto che viene spostato da sarà valido (è un oggetto legale di quel tipo), ma lo stato specifico di quell'oggetto non è specificato. Quanti elementi ha un mosso da vector ? Non dice.

Ciò significa che non è possibile chiamare alcuna funzione che abbia qualsiasi condizione preliminare. vector::operator[] ha la precondizione che il vector abbia almeno un elemento. Poiché non conosci lo stato di vector , non puoi chiamarlo. Non sarebbe meglio che chiamare func senza prima verificare che vector non sia vuoto.

Ma questo significa anche che le funzioni che non hanno precondizioni vanno bene. Questo è un codice C ++ 11 perfettamente legale:

vector<int> v1 = {1, 2, 3, 4, 5};
vector<int> v2{std::move(v1)};
v1.assign({6, 7, 8, 9, 10});

vector::assign non ha precondizioni. Funzionerà con qualsiasi oggetto vector valido, anche uno che è stato spostato da.

Quindi non stai creando un oggetto rotto. Stai creando un oggetto con uno stato sconosciuto.

If you can't construct some object, can't establish the invariants, then throw an exception from the ctor. If you can't preserve the invariants in some method, then signal an error somehow and roll-back. Should this be different for moved-from objects?

Generalmente le eccezioni di lancio da un costruttore di mosse sono considerate ... maleducate. Se sposti un oggetto che possiede memoria, trasferisci la proprietà di quella memoria. E questo di solito non coinvolge nulla che possa gettare.

Purtroppo, non possiamo far rispettare questo per vari motivi . Dobbiamo accettare che il lancio di mosse è una possibilità.

Va inoltre notato che non è necessario seguire la lingua "valida ancora non specificata". Questo è semplicemente il modo in cui la libreria standard C ++ dice che lo spostamento per i tipi standard funziona di default . Alcuni tipi di libreria standard hanno garanzie più severe. Ad esempio, unique_ptr è molto chiaro sullo stato di un'istanza spostata da unique_ptr : è uguale a nullptr .

Quindi puoi scegliere di fornire una garanzia più strong se lo desideri.

Ricorda: il movimento è un ottimizzazione delle prestazioni , che di solito viene fatto sugli oggetti che su devono essere distrutti. Considera questo codice:

vector<int> func()
{
  vector<int> v;
  //fill up 'v'.
  return v;
}

Questo si sposterà da v al valore di ritorno (assumendo che il compilatore non lo elimini). E nessun modo di fare riferimento a v dopo che lo spostamento è stato completato. Quindi qualsiasi cosa tu abbia fatto per mettere v in uno stato utile è priva di significato.

Nella maggior parte del codice, la probabilità di utilizzare un'istanza dell'oggetto spostata da è bassa.

Is it enough to just document "after this object has been moved from, it is illegal (UB) to do anything with it other than destroy it" in the header?

Is it better to continually assert that it is valid in each method call?

L'intero punto di avere precondizioni è di non controllare tali cose. operator[] ha una precondizione che il vector abbia un elemento con l'indice dato. Ottieni UB se provi ad accedere al di fuori della dimensione del vector . vector::at non ha una tale precondizione; esclude esplicitamente un'eccezione se vector non ha tale valore.

Esistono precondizioni per motivi di prestazioni. Sono così che non devi controllare cose che il chiamante potrebbe aver verificato da sé. Ogni chiamata a v[0] non deve controllare se v è vuota; solo il primo fa.

Is it better to make the class copyable, but not movable?

No. In effetti, una classe dovrebbe mai essere "copiabile ma non mobile". Se può essere copiato, dovrebbe poter essere spostato chiamando il costruttore di copie. Questo è il comportamento standard di C ++ 11 se si dichiara un costruttore di copia definito dall'utente ma non si dichiara un costruttore di movimento. Ed è il comportamento che dovresti adottare se non vuoi implementare una speciale semantica del movimento.

La semantica del movimento esiste per risolvere un problema molto specifico: occuparsi di oggetti che hanno grandi risorse in cui la copia sarebbe proibitivamente costosa o priva di significato (ad es. handle di file). Se il tuo oggetto non si qualifica, copiare e spostare è lo stesso per te.

    
risposta data 15.02.2016 - 03:05
fonte

Leggi altre domande sui tag