Pregunta ¿El constructor de copia predeterminado de C ++ es intrínsecamente inseguro? ¿Los iteradores son fundamentalmente inseguros también?


Solía ​​pensar que el modelo de objetos de C ++ es muy robusto cuando se siguen las mejores prácticas.
Hace solo unos minutos, sin embargo, me di cuenta de que no había tenido antes.

Considera este código:

class Foo
{
    std::set<size_t> set;
    std::vector<std::set<size_t>::iterator> vector;
    // ...
    // (assume every method ensures p always points to a valid element of s)
};

He escrito un código como este. Y hasta hoy, no había visto un problema con eso.

Pero, al pensarlo un poco más, me di cuenta de que esta clase es muy roto:
Su copia-constructor y copia-asignación copiar los iteradores dentro de vector, lo que implica que aún señalarán el antiguo  set! ¡El nuevo no es una copia verdadera después de todo!

En otras palabras, Debo implementar manualmente el copy-constructor ¡aunque esta clase no está gestionando ningún recurso (sin RAII)!

Esto me parece asombroso. Nunca me he encontrado con este problema, y ​​no conozco ninguna forma elegante de resolverlo. Pensando en ello un poco más, me parece que la construcción de copias no es segura por defecto - de hecho, me parece que las clases deberían no se puede copiar de forma predeterminada, porque cualquier tipo de acoplamiento entre sus variables de instancia pone en riesgo la invalidación del constructor de copias predeterminado.

¿Los iteradores son fundamentalmente inseguros de almacenar? O, ¿deberían las clases ser realmente no copiables por defecto?

Las soluciones que puedo pensar a continuación son indeseables, ya que no me permiten aprovechar el constructor de copias generado automáticamente:

  1. Implementar manualmente un constructor de copia para cada clase no trivial que escribo. Esto no solo es propenso a errores, sino también doloroso escribir para una clase complicada.
  2. Nunca almacene iteradores como variables miembro. Esto parece ser severamente limitante.
  3. Inhabilitar la copia por defecto en todas las clases que escribo, a menos que pueda demostrar explícitamente que son correctas. Esto parece funcionar completamente en contra del diseño de C ++, que para la mayoría de los tipos tiene semántica de valores y, por lo tanto, se puede copiar.

¿Es este un problema bien conocido? De ser así, ¿tiene una solución elegante / idiomática?


35
2018-06-07 00:23


origen


Respuestas:


¿Es este un problema bien conocido?

Bueno, se sabe, pero no diría que es bien conocido. Punteros hermanos no ocurre a menudo, y la mayoría de las implementaciones que he visto en la naturaleza se han roto de la misma manera que la tuya.

Creo que el problema es lo suficientemente infrecuente como para haber escapado al aviso de la mayoría de la gente; Curiosamente, ahora que sigo más Rust que C ++, aparece con bastante frecuencia debido a la rigurosidad del sistema tipográfico (es decir, el compilador rechaza esos programas, lo que provoca preguntas).

¿tiene una solución elegante / idiomática?

Hay muchos tipos de punteros hermanos situaciones, así que realmente depende, sin embargo sé de dos soluciones genéricas:

  • llaves
  • elementos compartidos

Vamos a revisarlos en orden.

Señalando a un miembro de la clase, o apuntando a un contenedor indexable, entonces uno puede usar un compensar o llave en lugar de un iterador. Es un poco menos eficiente (y podría requerir una búsqueda) sin embargo, es una estrategia bastante simple. Lo he visto con gran efecto en la situación de memoria compartida (donde el uso de punteros es un no-no, ya que el área de memoria compartida se puede mapear en diferentes direcciones).

La otra solución es utilizada por Boost.MultiIndex, y consiste en un diseño de memoria alternativo. Se deriva del principio del contenedor intrusivo: en lugar de colocar el elemento en el contenedor (moviéndolo en la memoria), un contenedor intrusivo utiliza ganchos que ya están dentro del elemento para conectarlo al lugar correcto. A partir de ahí, es bastante fácil de usar diferente ganchos para conectar un solo elemento en contenedores múltiples, ¿verdad?

Bueno, Boost.MultiIndex lo impulsa dos pasos más:

  1. Utiliza la interfaz de contenedor tradicional (es decir, mueve el objeto), pero el nodo a la cual se mueve el objeto es un elemento con múltiples ganchos
  2. Usa varios ganchos / contenedores en una sola entidad

Puedes comprobar varios ejemplos y notablemente Ejemplo 5: Índices secuenciados se parece mucho a tu propio código.


14
2018-06-07 14:29



C ++ copy / move ctor / assign son seguros para tipos de valores regulares. Los tipos de valores regulares se comportan como enteros u otros valores "regulares".

También son seguros para los tipos semánticos del puntero, siempre que la operación no cambie a lo que el puntero "debería" apuntar. Señalar algo "dentro de usted" u otro miembro es un ejemplo de dónde falla.

Son algo seguros para los tipos semánticos de referencia, pero mezclar semántica de puntero / referencia / valor en la misma clase tiende a ser inseguro / con errores / peligroso en la práctica.

La regla de cero es que crea clases que se comportan como tipos de valores regulares o como tipos semánticos de punteros que no necesitan volverse a sentar al copiar / mover. Entonces no tiene que escribir ctors / mover ctors.

Los iteradores siguen la semántica del puntero.

Lo idiomático / elegante a este respecto es acoplar estrechamente el contenedor del iterador con el contenedor señalado, y bloquear o escribir el controlador de copia allí. No son realmente cosas separadas una vez que uno contiene punteros en el otro.


21
2018-06-07 01:25



Sí, este es un "problema" bien conocido: cada vez que guarde punteros en un objeto, probablemente necesite algún tipo de constructor de copia personalizada y operador de asignación para asegurarse de que los punteros sean todos válidos y señalen las cosas esperadas .

Como los iteradores son solo una abstracción de punteros de elementos de colección, tienen el mismo problema.


18
2018-06-07 00:30



Es este un problema bien conocido

Sí. Cada vez que tenga una clase que contenga punteros o datos tipo puntero como un iterador, debe implementar su propio constructor de copias y el operador de asignaciones para asegurarse de que el nuevo objeto tenga punteros / iteradores válidos.

y si es así, ¿tiene una solución elegante / idiomática?

Quizás no sea tan elegante como te gustaría, y probablemente no sea el mejor en rendimiento (pero, a veces, las copias no lo son, por lo que C ++ 11 agregó semántica de movimientos), pero tal vez algo así te funcione (suponiendo que std::vectorcontiene iteradores en el std::set del mismo objeto principal):

class Foo
{
private:
    std::set<size_t> s;
    std::vector<std::set<size_t>::iterator> v;

    struct findAndPushIterator
    {
        Foo &foo;
        findAndPushIterator(Foo &f) : foo(f) {}

        void operator()(const std::set<size_t>::iterator &iter)
        {
            std::set<size_t>::iterator found = foo.s.find(*iter);
            if (found != foo.s.end())
                foo.v.push_back(found);
        }
    };

public:
    Foo() {}

    Foo(const Foo &src)
    {
        *this = src;
    }

    Foo& operator=(const Foo &rhs)
    {
        v.clear();
        s = rhs.s;

        v.reserve(rhs.v.size());
        std::for_each(rhs.v.begin(), rhs.v.end(), findAndPushIterator(*this));

        return *this;
    }

    //...
};

O, si usa C ++ 11:

class Foo
{
private:
    std::set<size_t> s;
    std::vector<std::set<size_t>::iterator> v;

public:
    Foo() {}

    Foo(const Foo &src)
    {
        *this = src;
    }

    Foo& operator=(const Foo &rhs)
    {
        v.clear();
        s = rhs.s;

        v.reserve(rhs.v.size());
        std::for_each(rhs.v.begin(), rhs.v.end(),
            [this](const std::set<size_t>::iterator &iter)
            {
                std::set<size_t>::iterator found = s.find(*iter);
                if (found != s.end())
                   v.push_back(found);
            } 
        );

        return *this;
    }

    //...
};

9
2018-06-07 00:56



Sí, por supuesto es un problema bien conocido.

Si su clase almacena punteros, como desarrollador experimentado, intuitivamente sabría que los comportamientos de copia predeterminados mayo no será suficiente para esa clase.

Su clase almacena iteradores y, dado que también son "identificadores" de datos almacenados en otros lugares, se aplica la misma lógica.

Esto no es "asombroso".


7
2018-06-07 00:54



La afirmación de que Foo no gestionar ningún recurso es falso

Copia-constructor a un lado, si un elemento de set se elimina, debe haber código en Foo que maneja vector para que el iterador respectivo se elimine.

Creo que la solución idiomática es usar solo un contenedor, un vector<size_t>y compruebe que el recuento de un elemento sea cero antes de insertar. Entonces los valores predeterminados de copiar y mover están bien.


5
2018-06-07 01:22



"Inherentemente inseguro"

No, las características que menciona no son inherentemente inseguras; el hecho de que hayas pensado en tres posibles soluciones seguras al problema es evidencia de que no hay una falta de seguridad "inherente" aquí, aunque creas que las soluciones son indeseables.

Y si, ahí es RAII aquí: los contenedores (set y vector) están manejando recursos. Creo que su punto es que el RAII "ya está resuelto" por el std contenedores. Pero debes considerar las instancias del contenedor sí mismos ser "recursos", y de hecho tu clase los está administrando. Tienes razón de que no eres directamente manejando la memoria del montón, porque este aspecto del problema de gestión es cuidada por la biblioteca estándar. Pero hay más en el problema de gestión, del que hablaré un poco más a continuación.

Comportamiento predeterminado "mágico"

El problema es que aparentemente esperas que puedas confiar en que el constructor de copia predeterminado "hará lo correcto" en un caso no trivial como este. No estoy seguro de por qué esperaba el comportamiento correcto, quizás esté esperando que la memorización de reglas empíricas como la "regla del 3" sea una forma robusta de asegurarse de no dispararse en el pie. ? Ciertamente eso sería bonito (y, como se señala en otra respuesta, Rust va mucho más allá que otros lenguajes de bajo nivel para hacer el foot-shooting mucho más difícil), pero C ++ simplemente no está diseñado para el diseño de clase "irreflexiva" de ese tipo, ni debería ser.

Conceptualizando el comportamiento del constructor

No voy a tratar de abordar la cuestión de si este es un "problema bien conocido", porque realmente no sé cuán bien caracterizado está el problema de los datos "hermanos" y el almacenamiento de iteradores. Pero espero poder convencerlo de que, si se toma el tiempo para pensar en el comportamiento de copiar y construir para cada clase que escriba que pueda copiarse, esto no debería ser una sorprendenteproblema.

En particular, al decidir usar el constructor de copia predeterminado, debes pensar en lo que hará el constructor de copia predeterminado: a saber, llamará al constructor de copias de cada miembro no primitivo, no sindicalizado (es decir, miembros que tienen constructores de copias) y copiará bit a bit el resto.

Al copiar su vector de iteradores, ¿qué std::vectorconstructor de copia de hacer? Realiza una "copia profunda", es decir, los datos dentro el vector está copiado Ahora, si el vector contiene iteradores, ¿cómo afecta eso la situación? Bueno, es simple: los iteradores son los datos almacenados por el vector, por lo que los iteradores mismos serán copiados. ¿Qué hace un copy-constructor de un iterador? No voy a buscar esto realmente, porque no necesito saber los detalles: solo necesito saber que los iteradores son como punteros en esto (y en otros aspectos), y al copiar un puntero simplemente copia el puntero en sí mismo, no la datos apuntados a. Es decir, iteradores y punteros hacen no tener una copia profunda por defecto.

Tenga en cuenta que esto no es sorprendente: por supuesto los iteradores no hacen copias profundas por defecto. Si lo hicieran, obtendrías un diferente, nuevo conjunto para cada iterador que se está copiando. Y esto tiene aún menos sentido de lo que parece inicialmente: por ejemplo, ¿qué significaría realmente si los iteradores unidireccionales hicieran copias en profundidad de sus datos? Presumiblemente obtendrías un parcial copie, es decir, todos los datos restantes que aún estén "en frente" de la posición actual del iterador, más un nuevo iterador que apunte al "frente" de la nueva estructura de datos.

Ahora considere que no hay forma de que un constructor de copias conozca el contexto en el que se llama. Por ejemplo, considere el siguiente código:

using iter = std::set<size_t>::iterator;  // use typedef pre-C++11
std::vector<iter> foo = getIters();  // get a vector of iterators
useIters(foo);    // pass vector by value

Cuando getIters se llama, el valor de retorno podría ser movido, pero también podría ser copiado. La asignación a foo también invoca un copia-constructor, aunque esto también puede ser eliminado. Y a menos que useIters toma su argumento por referencia, entonces has además consiguió una llamada de constructor de copia allí.

En alguna de estos casos, ¿esperaría que el constructor de copia cambie cual  std::set es señalado por los iteradores contenidos por el std::vector<iter>? ¡Por supuesto no! Así que, naturalmente std::vector's copy-constructor no se puede diseñar para modificar los iteradores de esa manera particular, y de hecho std::vector's copy-constructor es exactamente lo que necesitas en la mayoría de los casos en que realmente se utilizará.

Sin embargo, supongamos std::vector  podría trabaje así: supongamos que tiene una sobrecarga especial para "vector-de-iteradores" que podría volver a colocar los iteradores, y que el compilador de alguna manera podría ser "contado" solo para invocar este constructor especial cuando los iteradores realmente necesitan ser re -sentado (Tenga en cuenta que la solución de "invocar solamente la sobrecarga especial cuando se genera un constructor predeterminado para una clase contenedora que ademáscontiene una instancia del tipo de datos subyacente de los iteradores "no funcionaría; ¿y si el std::vector iteradores en su caso estaban apuntando a una diferente conjunto estándar, y estaban siendo tratados simplemente como referencia a datos gestionados por alguna otra clase? Diablos, ¿cómo se supone que el compilador sabe si los iteradores apuntan a la mismo  std::set?) Ignorando este problema de cómo el compilador sabría cuando invocar este constructor especial, ¿cómo se vería el código de constructor? Probémoslo, usando _Ctnr<T>::iterator como nuestro tipo de iterador (usaré C ++ 11 / 14isms y seré un poco descuidado, pero el punto general debería ser claro):

template <typename T, typename _Ctnr>
std::vector< _Ctnr<T>::iterator> (const std::vector< _Ctnr<T>::iterator>& rhs)
  : _data{ /* ... */ } // initialize underlying data...
{
    for (auto i& : rhs)
    {
        _data.emplace_back( /* ... */ );  // What do we put here?
    }
}

De acuerdo, entonces queremos que cada nuevo, copiado iterador para ser re-sentado para referirse a un diferente en vez de _Ctnr<T>. Pero de dónde vendría esta información? Tenga en cuenta que el constructor de copias no puede tomar el nuevo _Ctnr<T> como argumento: entonces ya no sería un constructor de copias. Y, en cualquier caso, ¿cómo sabría el compilador qué _Ctnr<T> ¿Para proveer? (Tenga en cuenta también que para muchos contenedores, encontrar el "iterador correspondiente" para el nuevo contenedor puede no ser trivial).

Gestión de recursos con std:: contenedores

Esto no es solo una cuestión de que el compilador no sea tan "inteligente" como podría o debería ser. Esta es una instancia en la que usted, el programador, tiene un diseño específico en mente que requiere una solución específica. En particular, como se mencionó anteriormente, tiene dos recursos, ambos std:: contenedores. Y tienes un relación entre ellos. Aquí llegamos a algo que la mayoría de las otras respuestas han establecido, y que a estas alturas debería ser muy, muy claro: relacionado los miembros de la clase requieren cuidado especial, ya que C ++ no gestiona este acoplamiento por defecto. Pero lo que espero es además claro en este punto es que no se debe pensar que el problema surge específicamente debido al acoplamiento de los miembros de datos; el problema es simplemente que la construcción predeterminada no es mágica, y el programador debe conocer los requisitos para copiar correctamente una clase antes de decidir dejar que el constructor generado implícitamente maneje la copia.

La solución elegante

... Y ahora llegamos a la estética y las opiniones. Parece que le resulta poco elegante obligarse a escribir un constructor de copias cuando no tiene ningún puntero o matriz en su clase que deba gestionarse manualmente.

Pero los constructores de copia definidos por el usuario son elegante; permitiéndote escribirlos es La elegante solución de C ++ para el problema de escribir clases correctas no triviales.

Es cierto que esto parece un caso en el que la "regla de 3" no se aplica del todo, ya que existe una clara necesidad de =delete el copy-constructor o escríbalo usted mismo, pero no hay una necesidad clara (todavía) para un destructor definido por el usuario. Pero, una vez más, no puede simplemente programar según las reglas generales y esperar que todo funcione correctamente, especialmente en un lenguaje de bajo nivel como C ++; debe conocer los detalles de (1) lo que realmente desea y (2) cómo se puede lograr eso.

Entonces, dado que el acoplamiento entre su std::set y tu std::vector en realidad crea un problema no trivial, resolviendo el problema envolviéndolos en una clase que implemente correctamente (o simplemente elimine) el constructor de copia es en realidad una muy solución elegante (e idiomática).

Definición explícita versus eliminación

Usted menciona una posible "nueva regla" a seguir en sus prácticas de codificación: "Inhabilitar la copia por defecto en todas las clases que escribo, a menos que pueda demostrar explícitamente que son correctas". Si bien esta podría ser una regla práctica más segura (al menos en este caso) que la "regla de 3" (especialmente cuando su criterio para "¿Necesito implementar el 3?" Es para verificar si se requiere un eliminador), mi anterior precaución contra confiar en las reglas generales todavía se aplica.

Pero creo que la solución aquí es en realidad más simple que la regla general propuesta. No es necesario formalmente probar la exactitud del método predeterminado; simplemente necesita tener una idea básica de lo que haría y de lo que necesita hacer.

Arriba, en mi análisis de su caso particular, entré en muchos detalles, por ejemplo, mencioné la posibilidad de "iteradores de copia profunda". No necesita entrar en tantos detalles para determinar si el constructor de copia predeterminado funcionará correctamente o no. En su lugar, simplemente imagine cómo se verá el constructor de copia creado manualmente; debería ser capaz de decir con bastante rapidez qué tan similar es su constructor imaginario explícitamente definido al que el compilador generaría.

Por ejemplo, una clase Foo que contiene un solo vector data tendrá un constructor de copia que se ve así:

Foo::Foo(const Foo& rhs)
  : data{rhs.data}
{}

Sin siquiera escribir eso, sabes que puedes confiar en el generado implícitamente, porque es exactamente lo mismo que lo que escribiste arriba.

Ahora, considere el constructor de su clase Foo:

Foo::Foo(const Foo& rhs)
  : set{rhs.set}
  , vector{ /* somehow use both rhs.set AND rhs.vector */ }  // ...????
{}

De inmediato, dado que simplemente copiando vectorLos miembros de 's no funcionarán, puedes ver que el constructor predeterminado no funcionará. Entonces ahora debe decidir si su clase necesita ser copiable o no.


3
2018-06-07 21:42