¿Está bien heredar la implementación de contenedores STL, en lugar de delegar?

Resuelto Emile Cormier asked hace 14 años • 8 respuestas

Tengo una clase que adapta std::vector para modelar un contenedor de objetos de dominio específico. Quiero exponer la mayor parte de la API std::vector al usuario, para que pueda usar métodos familiares (size, clear, at, etc...) y algoritmos estándar en el contenedor. Este parece ser un patrón recurrente para mí en mis diseños:

class MyContainer : public std::vector<MyObject>
{
public:
   // Redeclare all container traits: value_type, iterator, etc...

   // Domain-specific constructors
   // (more useful to the user than std::vector ones...)

   // Add a few domain-specific helper methods...

   // Perhaps modify or hide a few methods (domain-related)
};

Soy consciente de la práctica de preferir la composición a la herencia cuando se reutiliza una clase para la implementación, ¡pero debe haber un límite! Si tuviera que delegar todo a std::vector, ¡habría (según mi cuenta) 32 funciones de reenvío!

Entonces mis preguntas son... ¿Es realmente tan malo heredar la implementación en tales casos? ¿Cuáles son los riesgos? ¿Existe una manera más segura de implementar esto sin escribir tanto? ¿Soy un hereje por utilizar la herencia de implementación? :)

Editar:

¿Qué tal dejar claro que el usuario no debe usar MyContainer mediante un puntero std::vector<>?

// non_api_header_file.h
namespace detail
{
   typedef std::vector<MyObject> MyObjectBase;
}

// api_header_file.h
class MyContainer : public detail::MyObjectBase
{
   // ...
};

Las bibliotecas de impulso parecen hacer esto todo el tiempo.

Edición 2:

Una de las sugerencias fue utilizar funciones gratuitas. Lo mostraré aquí como pseudocódigo:

typedef std::vector<MyObject> MyCollection;
void specialCollectionInitializer(MyCollection& c, arguments...);
result specialCollectionFunction(const MyCollection& c);
etc...

Una forma más OO de hacerlo:

typedef std::vector<MyObject> MyCollection;
class MyCollectionWrapper
{
public:
   // Constructor
   MyCollectionWrapper(arguments...) {construct coll_}

   // Access collection directly
   MyCollection& collection() {return coll_;} 
   const MyCollection& collection() const {return coll_;}

   // Special domain-related methods
   result mySpecialMethod(arguments...);

private:
   MyCollection coll_;
   // Other domain-specific member variables used
   // in conjunction with the collection.
}
Emile Cormier avatar Jan 10 '10 03:01 Emile Cormier
Aceptado

El riesgo es la desasignación a través de un puntero a la clase base ( delete , delete[] y potencialmente otros métodos de desasignación). Dado que estas clases ( deque , map , string , etc.) no tienen controladores virtuales, es imposible limpiarlas adecuadamente con solo un puntero a esas clases:

struct BadExample : vector<int> {};
int main() {
  vector<int>* p = new BadExample();
  delete p; // this is Undefined Behavior
  return 0;
}

Dicho esto, si estás dispuesto a asegurarte de no hacer esto accidentalmente, heredarlas no tiene grandes inconvenientes, pero en algunos casos es un gran si. Otros inconvenientes incluyen chocar con extensiones y detalles de implementación (algunas de las cuales pueden no usar identificadores reservados) y lidiar con interfaces infladas ( cadenas en particular). Sin embargo, en algunos casos se pretende la herencia, ya que los adaptadores de contenedor como la pila tienen un miembro protegido c (el contenedor subyacente que adaptan), y casi solo se puede acceder a él desde una instancia de clase derivada.

En lugar de herencia o composición, considere escribir funciones libres que tomen un par de iteradores o una referencia de contenedor y operen con eso. Prácticamente todo <algoritmo> es un ejemplo de esto; y make_heap , pop_heap y push_heap , en particular, son un ejemplo del uso de funciones gratuitas en lugar de un contenedor específico de dominio.

Por lo tanto, utilice las clases contenedoras para sus tipos de datos y siga llamando a las funciones gratuitas para su lógica específica de dominio. Pero aún puedes lograr cierta modularidad usando un typedef, que te permite simplificar su declaración y proporciona un punto único si parte de ellos necesita cambiar:

typedef std::deque<int, MyAllocator> Example;
// ...
Example c (42);
example_algorithm(c);
example_algorithm2(c.begin() + 5, c.end() - 5);
Example::iterator i; // nested types are especially easier

Observe que el value_type y el asignador pueden cambiar sin afectar el código posterior usando typedef, e incluso el contenedor puede cambiar de un deque a un vector .

 avatar Jan 09 '2010 21:01

Puede combinar la herencia privada y la palabra clave 'using' para solucionar la mayoría de los problemas mencionados anteriormente: La herencia privada se 'implementa en términos de' y, como es privada, no puede contener un puntero a la clase base.

#include <string>
#include <iostream>

class MyString : private std::string
{
public:
    MyString(std::string s) : std::string(s) {}
    using std::string::size;
    std::string fooMe(){ return std::string("Foo: ") + *this; }
};

int main()
{
    MyString s("Hi");
    std::cout << "MyString.size(): " << s.size() << std::endl;
    std::cout << "MyString.fooMe(): " << s.fooMe() << std::endl;
}
Ben avatar Jan 09 '2010 21:01 Ben

Dejando a un lado los controladores virtuales, la decisión de heredar versus contener debe ser una decisión de diseño basada en la clase que se está creando. Nunca debes heredar la funcionalidad del contenedor solo porque es más fácil que contener un contenedor y agregar algunas funciones de agregar y quitar que parecen envoltorios simplistas a menos que puedas decir definitivamente que la clase que estás creando es una especie de contenedor. Por ejemplo, una clase de aula a menudo contendrá objetos de estudiantes, pero una clase de aula no es un tipo de lista de estudiantes para la mayoría de los propósitos, por lo que no debería heredar de la lista.

Jherico avatar Jan 09 '2010 21:01 Jherico

En este caso, heredar es una mala idea: los contenedores STL no tienen destructores virtuales, por lo que podría sufrir pérdidas de memoria (además, es una indicación de que los contenedores STL no deben heredarse en primer lugar).

Si solo necesita agregar alguna funcionalidad, puede declararla en métodos globales o en una clase liviana con un puntero/referencia de miembro contenedor. Por supuesto, esto no le permite ocultar métodos: si eso es realmente lo que busca, entonces no hay otra opción que volver a declarar toda la implementación.

stijn avatar Jan 09 '2010 21:01 stijn