Invarianti di durata dell'oggetto vs. semantica di movimento


13

Quando ho imparato il C ++ molto tempo fa, mi è stato fortemente enfatizzato che parte del punto del C ++ è che, proprio come i loop hanno "loop-invarianti", anche le classi hanno invarianti associati alla vita dell'oggetto - cose che dovrebbero essere vere per tutto il tempo in cui l'oggetto è vivo. Cose che dovrebbero essere stabilite dai costruttori e preservate dai metodi. Il controllo di incapsulamento / accesso è lì per aiutarti a far rispettare gli invarianti. RAII è una cosa che puoi fare con questa idea.

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

Nel progettare una classe, è una cattiva pratica progettarla in modo tale che gli invarianti della classe siano preservati fino al punto da cui viene spostata? O va bene se ti permetterà di farlo andare più veloce.

Per renderlo concreto, supponiamo che io abbia un tipo di risorsa non copiabile ma mobile 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 motivo, ho bisogno di creare un wrapper copiabile per questo oggetto, in modo che possa essere utilizzato, forse in un sistema di spedizione 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 copyable_opaqueoggetto, un invariante della classe stabilita durante la costruzione è che il membro o_punta sempre a un oggetto valido, poiché non esiste un ctor predefinito e l'unico ctor che non è un ctor copia garantisce questi. Tutti i operator()metodi presuppongono che questo invariante valga e lo conservano in seguito.

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

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

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

Pensieri:

  1. Di solito è una cattiva forma in C ++ creare oggetti zombi che esplodono se li tocchi.
    Se non puoi costruire un oggetto, non puoi stabilire gli invarianti, quindi getta un'eccezione dal ctor. Se non riesci a preservare gli invarianti in qualche modo, allora segnala un errore in qualche modo e ripristina. Dovrebbe essere diverso per gli oggetti spostati?

  2. È sufficiente documentare "dopo che questo oggetto è stato spostato, è illegale (UB) farci qualcosa di 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 causano un rallentamento. Se il tuo progetto utilizza lo schema "build di rilascio / build di debug", piuttosto che solo sempre in esecuzione con asserzioni, immagino che questo sia più interessante, dal momento che non paghi per gli assegni nella build di rilascio. Se in realtà non hai build di debug, questo sembra abbastanza poco attraente.

  1. È meglio rendere la classe copiabile, ma non mobile?
    Anche questo sembra negativo e provoca un calo delle prestazioni, ma risolve il problema "invariante" in modo semplice.

Quali considereresti le "migliori pratiche" pertinenti qui?



Risposte:


20

Di solito è una cattiva forma in C ++ creare oggetti zombi che esplodono se li tocchi.

Ma non è quello che stai facendo. Stai creando un "oggetto zombi" che esploderà se lo tocchi in modo errato . Che alla fine non è diverso da qualsiasi altra condizione preliminare 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 un presupposto di fatto che vcontiene almeno un elemento. In caso contrario, ottieni UB quando chiami func.

Quindi questa funzione non è "sicura". Ma ciò non significa che sia rotto. È rotto solo se il codice che lo utilizza viola il prerequisito. Forse funcè una funzione statica utilizzata come supporto nell'implementazione di altre funzioni. Localizzato in questo modo, nessuno lo chiamerebbe in modo tale da violarne i presupposti.

Molte funzioni, sia nell'ambito dei namespace sia nei membri della classe, avranno aspettative sullo stato di un valore su cui operano. Se queste condizioni preliminari non vengono soddisfatte, le funzioni falliranno, in genere con UB.

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

Ciò significa che non è possibile chiamare alcuna funzione che abbia delle condizioni preliminari. vector::operator[]ha il presupposto che il vectorabbia almeno un elemento. Dal momento che non conosci lo stato di vector, non puoi chiamarlo. Non sarebbe meglio che chiamare funcsenza prima verificare che vectornon sia vuoto.

Ma ciò 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::assignnon ha precondizioni. Funzionerà con qualsiasi vectoroggetto valido , anche uno da cui è stato spostato.

Quindi non stai creando un oggetto che è rotto. Stai creando un oggetto il cui stato è sconosciuto.

Se non puoi costruire un oggetto, non puoi stabilire gli invarianti, quindi getta un'eccezione dal ctor. Se non riesci a preservare gli invarianti in qualche modo, allora segnala un errore in qualche modo e ripristina. Dovrebbe essere diverso per gli oggetti spostati?

Generare eccezioni da un costruttore di mosse è generalmente considerato ... maleducato. Se sposti un oggetto che possiede memoria, stai trasferendo la proprietà di quella memoria. E ciò di solito non implica nulla che possa lanciare.

Purtroppo, non possiamo far valere questo per vari motivi . Dobbiamo accettare che la mossa di lancio sia una possibilità.

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

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

Basta ricordare: il movimento è un'ottimizzazione delle prestazioni , uno che di solito è stato fatto su oggetti che sono circa per essere distrutti. Considera questo codice:

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

Questo si sposterà dal vvalore di ritorno (supponendo che il compilatore non lo eluda). E non c'è modo di fare riferimento vdopo che la mossa è stata completata. Quindi qualsiasi lavoro tu abbia fatto per mettere vin uno stato utile non ha senso.

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

È sufficiente documentare "dopo che questo oggetto è stato spostato, è illegale (UB) farci qualcosa di diverso da distruggerlo" nell'intestazione?

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

Il punto fondamentale di avere precondizioni è non controllare tali cose. operator[]ha una condizione preliminare che vectorabbiano un elemento con l'indice dato. Ottieni UB se provi ad accedere al di fuori delle dimensioni di vector. vector::at non ha una tale condizione preliminare; genera esplicitamente un'eccezione se vectornon ha un tale valore.

Presupposti per motivi di prestazioni. Sono così che non devi controllare cose che il chiamante avrebbe potuto verificare da solo. Ogni chiamata a v[0]non deve controllare se vè vuota; solo il primo lo fa.

È meglio rendere la classe copiabile, ma non mobile?

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

La semantica di Move esiste per risolvere un problema molto specifico: gestire oggetti che hanno grandi risorse in cui la copia sarebbe proibitivamente costosa o priva di significato (ad esempio: handle di file). Se il tuo oggetto non è idoneo, la copia e lo spostamento sono uguali per te.


5
Bello. +1. Vorrei notare che: "Il punto fondamentale di avere precondizioni è non controllare tali cose". - Non credo che ciò valga per le asserzioni. Le asserzioni sono IMHO uno strumento valido e valido per verificare i presupposti (almeno il più delle volte).
Martin Ba

3
La confusione di copia / spostamento può essere chiarita realizzando che un ctor di movimento può lasciare l'oggetto sorgente in qualsiasi stato, incluso identico al nuovo oggetto, il che significa che i possibili risultati sono un superinsieme di quello di un ctor di copia.
Saluti
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.