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

79

Tengo una clase que adapta std :: vector para modelar un contenedor de objetos específicos del dominio. Quiero exponer la mayor parte de la API std :: vector al usuario, para que pueda usar métodos familiares (tamaño, claro, 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 en 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 forma más segura de implementar esto sin tener que escribir tanto? ¿Soy un hereje por usar la herencia de implementación? :)

Editar:

¿Qué hay de dejar en claro que el usuario no debe usar MyContainer a través de 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 estas cosas 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
fuente
6
¡Oh Dios! Otra oportunidad para impulsar mi blog en punchlet.wordpress.com - básicamente, escribir funciones gratuitas y olvidar el enfoque de envoltorio "más OO". No es más OO; si lo fuera, usaría la herencia, lo que probablemente no debería en este caso. Recuerde OO! = Clase.
1
@Neil: Pero, pero ... ¡las funciones globales son malas! ¡Todo es un objeto! ;)
Emile Cormier
4
No serán globales si los pones en un espacio de nombres.
1
Si realmente desea exponer toda la interfaz del vector, entonces probablemente sea mejor en C ++ usar la composición y exponer una referencia al vector a través de un captador (con versiones const y no const). En Java simplemente heredaría, pero luego en Java algunos numpty no aparecerán, ignorarán su documentación, eliminarán su objeto a través del puntero incorrecto (o heredarán nuevamente y estropearán), y luego se quejarán. Tal vez para una audiencia limitada, pero si los usuarios pueden ser fanáticos del polimorfismo dinámico, o recientemente ex programadores de Java, está diseñando una interfaz que puede estar bastante seguro de que entenderán mal.
Steve Jessop
1
No puede protegerse contra personas que ignoren completamente la documentación. No me sorprendería descubrir que tal mal uso causa tantos problemas en Java como en C ++.

Respuestas:

75

El riesgo es desasignar mediante un puntero a la clase base ( eliminar , eliminar [] y potencialmente otros métodos de desasignación). Dado que estas clases ( deque , map , string , etc.) no tienen dtors virtuales, es imposible limpiarlas correctamente 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á dispuesto a asegurarse de no hacer esto accidentalmente, heredarlos tiene un pequeño inconveniente importante, pero en algunos casos es un gran si. Otros inconvenientes incluyen chocar con las especificaciones y extensiones de implementación (algunas de las cuales pueden no usar identificadores reservados) y lidiar con interfaces infladas ( cadenas en particular). Sin embargo, la herencia está pensada en algunos casos, ya que los adaptadores de contenedores como la pila tienen un miembro protegido c (el contenedor subyacente al 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 sobre eso. Prácticamente todo <algorithm> 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, use las clases de contenedor para sus tipos de datos y aún llame a las funciones gratuitas para su lógica específica de dominio. Pero aún puede lograr cierta modularidad usando un typedef, lo que le permite simplificar su declaración y proporciona un solo punto 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

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

quietud
fuente
35

Puede combinar la herencia privada y la palabra clave 'using' para solucionar la mayoría de los problemas mencionados anteriormente: La herencia privada es 'se implementa en términos de' y, como es privada, no puede mantener 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
fuente
2
No puedo evitar mencionar que la privateherencia sigue siendo herencia y, por tanto, una relación más fuerte que la composición. En particular, significa que cambiar la implementación de su clase necesariamente romperá la compatibilidad binaria.
Matthieu M.
8
Los miembros de herencia privada y de datos privados rompen la compatibilidad binaria cuando cambian y, a excepción de los amigos (que deberían ser pocos), generalmente no es difícil cambiar entre ellos, lo que a menudo depende de los detalles de implementación. Vea también el "modismo de base de miembro".
Para los curiosos - Idiomas basados ​​en
Emile Cormier
1
@MatthieuM. Romper ABI no es un problema en absoluto para la mayoría de las aplicaciones. Incluso algunas bibliotecas viven sin Pimpl para un mejor rendimiento.
doc
15

Como todos ya han dicho, los contenedores STL no tienen destructores virtuales, por lo que heredarlos no es seguro en el mejor de los casos. Siempre he considerado la programación genérica con plantillas como un estilo diferente de OO, uno sin herencia. Los algoritmos definen la interfaz que requieren. Es lo más parecido a Duck Typing que puede conseguir en un lenguaje estático.

De todos modos, tengo algo que agregar a la discusión. La forma en que he creado mis propias especializaciones de plantilla anteriormente es definir clases como las siguientes para usar como clases base.

template <typename Container>
class readonly_container_facade {
public:
    typedef typename Container::size_type size_type;
    typedef typename Container::const_iterator const_iterator;

    virtual ~readonly_container_facade() {}
    inline bool empty() const { return container.empty(); }
    inline const_iterator begin() const { return container.begin(); }
    inline const_iterator end() const { return container.end(); }
    inline size_type size() const { return container.size(); }
protected: // hide to force inherited usage only
    readonly_container_facade() {}
protected: // hide assignment by default
    readonly_container_facade(readonly_container_facade const& other):
        : container(other.container) {}
    readonly_container_facade& operator=(readonly_container_facade& other) {
        container = other.container;
        return *this;
    }
protected:
    Container container;
};

template <typename Container>
class writable_container_facade: public readable_container_facade<Container> {
public:
    typedef typename Container::iterator iterator;
    writable_container_facade(writable_container_facade& other)
        readonly_container_facade(other) {}
    virtual ~writable_container_facade() {}
    inline iterator begin() { return container.begin(); }
    inline iterator end() { return container.end(); }
    writable_container_facade& operator=(writable_container_facade& other) {
        readable_container_facade<Container>::operator=(other);
        return *this;
    }
};

Estas clases exponen la misma interfaz que un contenedor STL. Me gustó el efecto de separar las operaciones de modificación y no modificación en distintas clases base. Esto tiene un efecto realmente agradable sobre la corrección de la constante. El único inconveniente es que debe ampliar la interfaz si desea utilizarlos con contenedores asociativos. Aunque no me he encontrado con la necesidad.

D.Shawley
fuente
¡Agradable! Podría usar eso. Pero otros han replanteado la idea de adaptar contenedores, así que tal vez no lo use. :)
Emile Cormier
Dicho esto, la programación de plantillas pesadas puede conducir a un código espagueti incorrecto, bibliotecas masivas, un aislamiento deficiente de la funcionalidad y errores de tiempo de compilación que son ininteligibles.
Erik Aronesty
5

En este caso, heredar es una mala idea: los contenedores STL no tienen destructores virtuales, por lo que es posible que se produzcan pérdidas de memoria (además, es una indicación de que los contenedores STL no están destinados a ser heredados 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 de contenedor. Esto, por supuesto, 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
fuente
Aún puede ocultar métodos al no declararlos en el encabezado y, en cambio, solo en la implementación, convirtiéndolos en métodos estáticos no públicos en una clase ficticia (desde la cual puede dar amistad, y esto funciona para plantillas que deben ser solo de encabezado ), o colocándolos en un "detalle" o en un espacio de nombres con un nombre similar. (Los tres funcionan tan bien como los métodos privados convencionales.)
No entiendo cómo cree que puede ocultar un método de 'vector' al no declararlo en su encabezado. Ya está declarado en vector.
Jherico
Jherico: ¿Estás hablando conmigo o stijn? De cualquier manera, creo que has entendido mal a uno de nosotros.
@roger Secundo a Jherico y no creo que te entienda: ¿estás hablando de ocultar métodos de std :: vector o de otra cosa? Además, ¿cómo se oculta un método en otro espacio de nombres? Siempre que se declare en un encabezado al que cualquiera tiene acceso, ¿no está realmente oculto de una manera que se esconde la palabra clave privada?
stijn
stijn: Eso es lo que estaba señalando sobre el acceso privado, tampoco está realmente oculto, ya que cualquier persona con acceso al encabezado puede leer la fuente o usarla -Dprivate=publicen la línea de comandos del compilador. Los especificadores de acceso como privado son en su mayoría documentación, que resulta que se hace cumplir.
4

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

Jherico
fuente
1

Es más fácil de hacer:

typedef std::vector<MyObject> MyContainer;
Martin York
fuente
3
Entiendo, pero estoy buscando hacer: typedef (std :: vector <MuObject> + mods) MyContainer consistentemente.
Emile Cormier
1

De todos modos, los métodos de reenvío se eliminarán. No obtendrá un mejor rendimiento de esta manera. De hecho, es probable que obtenga un peor rendimiento.

Queso Charles Eli
fuente