Objeto invariantes de por vida versus semántica de movimiento

13

Cuando aprendí C ++ hace mucho tiempo, me enfatizaba mucho que parte del punto de C ++ es que al igual que los bucles tienen "invariantes de bucle", las clases también tienen invariantes asociados a la vida útil del objeto, cosas que deberían ser ciertas mientras el objeto esté vivo. Cosas que deberían ser establecidas por los constructores y preservadas por los métodos. El control de acceso / encapsulación está ahí para ayudarlo a hacer cumplir a los invariantes. RAII es una cosa que puedes hacer con esta idea.

Desde C ++ 11 ahora tenemos semántica de movimiento. Para una clase que admite el movimiento, moverse de un objeto no termina formalmente su vida útil: se supone que el movimiento lo deja en un estado "válido".

Al diseñar una clase, ¿es una mala práctica si la diseñas para que los invariantes de la clase solo se conserven hasta el punto desde el que se mueve? ¿O está bien si te permitirá hacerlo más rápido?

Para hacerlo concreto, supongamos que tengo un tipo de recurso no copiable pero movible como este:

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

public:
  opaque(opaque &&);

  ...

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

Y por alguna razón, necesito hacer un contenedor copiable para este objeto, para que pueda usarse, tal vez en algún sistema de envío existente.

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); }
};

En este copyable_opaqueobjeto, una invariante de la clase establecida en la construcción es que el miembro o_siempre apunta a un objeto válido, ya que no existe un ctor predeterminado, y el único ctor que no es un copiador lo garantiza. Todos los operator()métodos suponen que esta invariante se mantiene y la conservan después.

Sin embargo, si se mueve el objeto, entonces no o_apuntará a nada. Y después de ese punto, llamar a cualquiera de los métodos operator()provocará un bloqueo UB / a.

Si el objeto nunca se mueve, entonces la invariante se conservará hasta la llamada dtor.

Supongamos que, hipotéticamente, escribí esta clase, y meses después, mi compañero de trabajo imaginario experimentó UB porque, en alguna función complicada donde muchos de estos objetos se barajaban por alguna razón, se movió de una de estas cosas y luego llamó a una de sus métodos Claramente es su culpa al final del día, pero ¿esta clase está "mal diseñada"?

Pensamientos:

  1. Por lo general, es una mala forma en C ++ crear objetos zombies que explotan si los tocas.
    Si no puede construir algún objeto, no puede establecer los invariantes, entonces arroje una excepción desde el ctor. Si no puede preservar los invariantes en algún método, entonces señale un error de alguna manera y retroceda. ¿Debería ser diferente para los objetos movidos?

  2. ¿Es suficiente simplemente documentar "después de que se haya movido este objeto, es ilegal (UB) hacer algo con él que no sea destruirlo" en el encabezado?

  3. ¿Es mejor afirmar continuamente que es válido en cada llamada al método?

Al igual que:

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); }
};

Las afirmaciones no mejoran sustancialmente el comportamiento y provocan una desaceleración. Si su proyecto utiliza el esquema de "versión de compilación / depuración de compilación", en lugar de simplemente ejecutarse con aserciones, supongo que esto es más atractivo, ya que no paga las verificaciones en la versión de compilación. Si en realidad no tiene compilaciones de depuración, esto parece bastante poco atractivo.

  1. ¿Es mejor hacer que la clase sea copiable, pero no móvil?
    Esto también parece malo y causa un impacto en el rendimiento, pero resuelve el problema "invariante" de una manera directa.

¿Cuáles consideraría que son las "mejores prácticas" relevantes aquí?

Chris Beck
fuente

Respuestas:

20

Por lo general, es una mala forma en C ++ crear objetos zombies que explotan si los tocas.

Pero eso no es lo que estás haciendo. Estás creando un "objeto zombie" que explotará si lo tocas mal . Lo que en última instancia no es diferente de cualquier otra condición previa basada en el estado.

Considere la siguiente función:

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

¿Es segura esta función? No; El usuario puede pasar un vacío vector . Entonces, la función tiene una precondición de facto que vtiene al menos un elemento en ella. Si no es así, obtienes UB cuando llamas func.

Entonces esta función no es "segura". Pero eso no significa que esté roto. Solo se rompe si el código que lo usa viola la condición previa. Quizás funces una función estática utilizada como ayuda en la implementación de otras funciones. Localizado de tal manera, nadie lo llamaría de una manera que viole sus precondiciones.

Muchas funciones, ya sean de ámbito de nombres o miembros de clase, tendrán expectativas sobre el estado de un valor en el que operan. Si no se cumplen estas condiciones previas, las funciones fallarán, generalmente con UB.

La biblioteca estándar de C ++ define una regla "válida pero no especificada". Esto dice que, a menos que el estándar indique lo contrario, todos los objetos que se muevan serán válidos (es un objeto legal de ese tipo), pero no se especifica el estado específico de ese objeto. ¿Cuántos elementos tiene un mover desde vector? No dice

Esto significa que no puede llamar a ninguna función que tenga alguna condición previa. vector::operator[]tiene la precondición de que vectortiene al menos un elemento. Como no conoce el estado del vector, no puede llamarlo. No sería mejor que llamar funcsin verificar primero que vectorno está vacío.

Pero esto también significa que las funciones que no tienen condiciones previas están bien. Este es un código C ++ 11 perfectamente legal:

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

vector::assignNo tiene condiciones previas. Funcionará con cualquier vectorobjeto válido , incluso uno desde el que se haya movido.

Entonces no estás creando un objeto que está roto. Estás creando un objeto cuyo estado es desconocido.

Si no puede construir algún objeto, no puede establecer los invariantes, entonces arroje una excepción desde el ctor. Si no puede preservar los invariantes en algún método, entonces señale un error de alguna manera y retroceda. ¿Debería ser diferente para los objetos movidos?

Lanzar excepciones de un constructor de movimientos generalmente se considera ... grosero. Si mueve un objeto que posee memoria, entonces está transfiriendo la propiedad de esa memoria. Y eso generalmente no implica nada que pueda arrojar.

Lamentablemente, no podemos hacer cumplir esto por varias razones . Tenemos que aceptar que lanzar-mover es una posibilidad.

También debe tenerse en cuenta que no tiene que seguir el lenguaje "válido pero no especificado". Esa es simplemente la forma en que la biblioteca estándar de C ++ dice que el movimiento para los tipos estándar funciona de manera predeterminada . Ciertos tipos de biblioteca estándar tienen garantías más estrictas. Por ejemplo, unique_ptres muy claro sobre el estado de una unique_ptrinstancia movida desde : es igual a nullptr.

Por lo tanto, puede optar por proporcionar una garantía más sólida si lo desea.

Solo recuerde: el movimiento es una optimización del rendimiento , una que generalmente se realiza en objetos que están a punto de ser destruidos. Considera este código:

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

Esto pasará del vvalor de retorno (suponiendo que el compilador no lo elida). Y no hay forma de referencia vdespués de que el movimiento se haya completado. Entonces, cualquier trabajo que haya realizado para ponerlo ven un estado útil no tiene sentido.

En la mayoría de los códigos, la probabilidad de usar una instancia de objeto movido es baja.

¿Es suficiente simplemente documentar "después de que se haya movido este objeto, es ilegal (UB) hacer algo con él que no sea destruirlo" en el encabezado?

¿Es mejor afirmar continuamente que es válido en cada llamada al método?

El punto de tener precondiciones es no verificar tales cosas. operator[]tiene la precondición de que vectortenga un elemento con el índice dado. Obtiene UB si intenta acceder fuera del tamaño de vector. vector::at no tiene tal condición previa; lanza explícitamente una excepción si vectorno tiene dicho valor.

Existen condiciones previas por razones de rendimiento. Son para que no tenga que verificar las cosas que la persona que llama pudo haber verificado por sí misma. Cada llamada a v[0]no tiene que verificar si vestá vacía; solo el primero lo hace.

¿Es mejor hacer que la clase sea copiable, pero no móvil?

No. De hecho, una clase nunca debe ser "copiable pero no móvil". Si se puede copiar, debería poder moverse llamando al constructor de la copia. Este es el comportamiento estándar de C ++ 11 si declara un constructor de copia definido por el usuario pero no declara un constructor de movimiento. Y es el comportamiento que debe adoptar si no desea implementar una semántica de movimiento especial.

La semántica de movimiento existe para resolver un problema muy específico: tratar con objetos que tienen grandes recursos donde la copia sería prohibitivamente costosa o carecería de sentido (es decir, identificadores de archivos). Si su objeto no califica, copiar y mover es lo mismo para usted.

Nicol Bolas
fuente
55
Agradable. +1. Me gustaría señalar que: "El objetivo de tener condiciones previas es no verificar tales cosas". - No creo que esto sea válido para afirmaciones. Las afirmaciones son en mi humilde opinión una herramienta buena y válida para verificar las condiciones previas (al menos la mayoría de las veces)
Martin Ba
3
La confusión de copia / movimiento se puede aclarar al darse cuenta de que un ctor de movimiento puede dejar el objeto fuente en cualquier estado, incluso idéntico al nuevo objeto, lo que significa que los posibles resultados son un superconjunto del de un ctor de copia.
MSalters