¿Por qué no se recomienda la base para todos los objetos en C ++?

76

Stroustrup dice "No inventes de inmediato una base única para todas tus clases (una clase de Objeto). Por lo general, puedes hacerlo mejor sin muchas / la mayoría de las clases". (The C ++ Programming Language Fourth Edition, Sec 1.3.4)

¿Por qué una clase base para todo generalmente es una mala idea y cuándo tiene sentido crear una?

    
pregunta Matthew James Briggs 15.02.2015 - 17:45

11 respuestas

74

Porque, ¿qué tendría ese objeto para la funcionalidad? En java, toda la clase base tiene un toString, un hashCode & igualdad y una variable monitor + condición.

  • ToString solo es útil para la depuración.

  • hashCode solo es útil si desea almacenarlo en una colección basada en hash (la preferencia en C ++ es pasar una función de hashing al contenedor como parámetro de plantilla o evitar std::unordered_* y en su lugar usar std::vector y listas desordenadas simples).

  • la igualdad sin un objeto base puede ayudarse en tiempo de compilación, si no tienen el mismo tipo, entonces no pueden ser iguales. En C ++, esto es un error de tiempo de compilación.

  • el monitor y la variable de condición se incluyen mejor explícitamente en una base de caso por caso.

Sin embargo, cuando hay más cosas que necesita hacer, entonces hay un caso de uso.

Por ejemplo, en QT, está la clase root QObject que forma la base de la afinidad del hilo, la jerarquía de propiedad padre-hijo y el mecanismo de ranuras de señal. También fuerza el uso del puntero para QObjects, sin embargo, muchas Clases en Qt no heredan QObject porque no necesitan la ranura de señal (particularmente los tipos de valor de alguna descripción).

    
respondido por el ratchet freak 15.02.2015 - 18:04
98

Porque no hay funciones compartidas por todos los objetos. No hay nada que poner en esta interfaz que tenga sentido para todas las clases.

    
respondido por el DeadMG 15.02.2015 - 18:37
25

Siempre que construyas jerarquías de herencia altas de objetos, tiendes a encontrarte con el problema de la Clase base frágil (Wikipedia) .

Tener muchas pequeñas jerarquías de herencia separadas (distintas, aisladas) reduce las posibilidades de encontrar este problema.

Hacer que todos sus objetos formen parte de una única jerarquía de herencia prácticamente garantiza que se encontrará con este problema.

    
respondido por el Mike Nakis 15.02.2015 - 18:18
24

Porque:

  1. No debes pagar por lo que no usas.
  2. Estas funciones tienen menos sentido en un sistema de tipos basado en valores que en un sistema de tipos basado en referencias.

La implementación de cualquier tipo de función virtual introduce una tabla virtual, que requiere una sobrecarga de espacio por objeto que no es necesaria ni deseada en muchas (¿la mayoría?) situaciones.

Implementar toString de manera no virtual sería bastante inútil, porque lo único que podría devolver es la dirección del objeto, que es muy poco amigable para el usuario, y a la que la persona que llama ya tiene acceso, a diferencia de Java.
De manera similar, un equals o hashCode no virtual solo podría usar direcciones para comparar objetos, lo que nuevamente es bastante inútil e incluso a menudo equivocado, a diferencia de Java, los objetos se copian con frecuencia en C ++ y, por lo tanto, distinguen la "identidad" de Un objeto ni siquiera es siempre significativo o útil. (por ejemplo, un int realmente debería no tener una identidad distinta de su valor ... dos enteros del mismo valor deberían ser iguales).

    
respondido por el Mehrdad 15.02.2015 - 23:13
16

Tener un objeto raíz limita lo que puedes hacer y lo que puede hacer el compilador, sin mucho beneficio.

Una clase raíz común hace posible crear contenedores de cualquier cosa y extraer lo que son con un dynamic_cast , pero si necesitas contenedores de cualquier cosa, entonces algo similar a boost::any puede hacerlo sin una clase raíz común. Y boost::any también admite primitivas, incluso puede admitir la optimización de búfer pequeño y dejarlos casi "sin caja" en el lenguaje de Java.

C ++ admite y prospera en tipos de valor. Ambos literales, y programadores escriben tipos de valor. Los contenedores de C ++ almacenan, clasifican, codifican, consumen y producen tipos de valor de manera eficiente.

La herencia, especialmente el tipo de herencia monolítica que implican las clases base de Java, requiere tipos de "puntero" o "referencia" basados en tienda libre. Su identificador / puntero / referencia a los datos tiene un puntero a la interfaz de la clase, y polimorfamente podría representar otra cosa.

Aunque esto es útil en algunas situaciones, una vez que se haya casado con el patrón con una "clase base común", ha bloqueado toda su base de código en el costo y el equipaje de este patrón, incluso cuando no es útil .

Casi siempre sabe más sobre un tipo que "es un objeto", ya sea en el sitio de la llamada o en el código que lo usa.

Si la función es simple, escribir la función como una plantilla le proporciona un polimorfismo basado en el tiempo de compilación de tipo pato donde la información en el sitio de la llamada no se desecha. Si la función es más compleja, se puede realizar un borrado de tipo mediante el cual las operaciones uniformes en el tipo que desea realizar (por ejemplo, serialización y deserialización) se pueden crear y almacenar (en tiempo de compilación) para que sean consumidas (en tiempo de ejecución) por el Código en una unidad de traducción diferente.

Supongamos que tiene alguna biblioteca donde desea que todo sea serializable. Un enfoque es tener una clase base:

struct serialization_friendly {
  virtual void write_to( my_buffer* ) const = 0;
  virtual void read_from( my_buffer const* ) = 0;
  virtual ~serialization_friendly() {}
};

Ahora, cada bit de código que escribes puede ser serialization_friendly .

void serialize( my_buffer* b, serialization_friendly const* x ) {
  if (x) x->write_to(b);
}

Excepto no un std::vector , por lo que ahora necesita escribir cada contenedor. Y no esos enteros que obtuviste de esa gran biblioteca. Y no es el tipo que escribiste que no creías que fuera necesario serializar. Y no un tuple , o un int o un double , o un std::ptrdiff_t .

Tomamos otro enfoque:

void write_to( my_buffer* b, int x ) {
  b->write_integer(x);
}    
template<class T,
  class=std::enable_if_t< void_t<
    std::declval<T const*>()->write_to( std::declval<my_buffer*>()
  > >
>
void write_to( my_buffer* b, T const* x ) {
  if (x) x->write_to(b);
}
template<class T>
void serialize( my_buffer* b, T const& t ) {
  write_to( b, t );
}

que consiste en, bueno, no hacer nada, aparentemente. Excepto que ahora podemos extender write_to anulando write_to como una función gratuita en el espacio de nombres de un tipo o un método en el tipo.

Incluso podemos escribir un poco de código de borrado de tipo:

namespace details {
  struct can_serialize_pimpl {
    virtual void write_to( my_buffer* ) const = 0;
    virtual void read_from( my_buffer const* ) = 0;
    virtual ~can_serialize_pimpl() {}
  };
}
struct can_serialize {
  void write_to( my_buffer* b ) const { pImpl->write_to(b); }
  void read_from( my_buffer const* b ) { pImpl->read_from(b); }
  std::unique_ptr<details::can_serialize_pimpl> pImpl;
  template<class T> can_serialize(T&&);
};
namespace details { 
  template<class T>
  struct can_serialize : can_serialize_pimpl {
    std::decay_t<T>* t;
    void write_to( my_buffer*b ) const final override {
      serialize( b, std::forward<T>(*t) );
    }
    void read_from( my_buffer const* ) final override {
      deserialize( b, std::forward<T>(*t) );
    }
    can_serialize(T&& in):t(&in) {}
  };
}
template<class T> can_serialize::can_serialize<T>(T&&t):pImpl(
  std::make_unique<details::can_serialize<T>>( std::forward<T>(t) );
) {}

y ahora podemos tomar un tipo arbitrario y colocarlo automáticamente en una interfaz can_serialize que te permite invocar serialize en un punto posterior a través de una interfaz virtual.

Entonces:

void writer_thingy( can_serialize s );

es una función que toma cualquier cosa que pueda serializarse, en lugar de

void writer_thingy( serialization_friendly const* s );

y el primero, a diferencia del segundo, puede manejar int , std::vector<std::vector<Bob>> automáticamente.

No tomó mucho escribirlo, especialmente porque este tipo de cosas es algo que rara vez quieres hacer, pero obtuvimos la capacidad de tratar cualquier cosa como serializable sin requerir un tipo de base.

Más aún, ahora podemos hacer que std::vector<T> sea serializable como ciudadano de primera clase simplemente anulando write_to( my_buffer*, std::vector<T> const& ) . Con esa sobrecarga, se puede pasar a un can_serialize y la serialización de std::vector obtiene almacenado en un vtable y accedido por .write_to .

En resumen, C ++ es lo suficientemente poderoso como para que pueda implementar las ventajas de una sola clase base sobre la marcha cuando sea necesario, sin tener que pagar el precio de una jerarquía de herencia forzada cuando no es necesario. Y los momentos en que se requiere la base única (falsificada o no) son razonablemente raros.

Cuando los tipos son realmente su identidad, y usted sabe lo que son, las oportunidades de optimización abundan. Los datos se almacenan localmente y de forma contigua (lo cual es muy importante para la facilidad de uso de la memoria caché en los procesadores modernos), los compiladores pueden entender fácilmente lo que hace una operación dada (en lugar de tener un puntero de método virtual opaco, tiene que saltar, dando lugar a un código desconocido en el otro lado) que permite que las instrucciones se reordenen de manera óptima, y que se peguen menos clavijas redondas en los agujeros redondos.

    
respondido por el Yakk 16.02.2015 - 22:12
8

Hay muchas buenas respuestas arriba, y el hecho claro de que cualquier cosa que harías con una clase base de todos los objetos se puede hacer mejor de otras maneras, como lo demuestra la respuesta de @ratchetfreak y los comentarios son muy importantes. importante, pero hay otra razón, que es evitar crear diamantes de herencia cuando se utiliza la herencia múltiple. Si tuviera alguna funcionalidad en una clase base universal, tan pronto como comenzara a usar la herencia múltiple, tendría que comenzar a especificar a qué variante desea acceder, ya que podría estar sobrecargada de manera diferente en diferentes rutas de la cadena de herencia. Y la base no puede ser virtual, porque esto sería muy ineficiente (requerir que todos los objetos tengan una mesa virtual a un costo potencialmente enorme en el uso de memoria y la localidad). Esto se convertiría en una pesadilla logística muy rápidamente.

    
respondido por el Jules 16.02.2015 - 09:29
5

De hecho, los compiladores y bibliotecas de C ++ de Microsofts (que conozco sobre Visual C ++, 16 bits) tenían una clase denominada CObject .

Sin embargo, debe saber que en ese momento las "plantillas" no eran compatibles con este simple compilador de C ++, por lo que no era posible usar clases como std::vector<class T> . En su lugar, una implementación "vectorial" solo podía manejar un tipo de clase, por lo que había una clase comparable a std::vector<CObject> hoy. Como CObject era la clase base de casi todas las clases (desafortunadamente no de CString , el equivalente de string en compiladores modernos), podría usar esta clase para almacenar casi todos los tipos de objetos.

Debido a que los compiladores modernos admiten plantillas, ya no se da este caso de uso de una "clase base genérica".

Debe pensar en el hecho de que usar una clase base tan genérica costará (un poco) memoria y tiempo de ejecución, por ejemplo, en la llamada al constructor. Por lo tanto, existen inconvenientes cuando se usa una clase de este tipo, pero al menos cuando se usan compiladores C ++ modernos, casi no hay caso de uso para dicha clase.

    
respondido por el Martin Rosenau 15.02.2015 - 21:05
5

Voy a sugerir otra razón que viene de Java.

Porque usted no puede crear una clase base para todo al menos no sin un manojo de caldera-placa.

Es posible que puedas salirte con la tuya para tus propias clases, pero es probable que encuentres que terminas duplicando una gran cantidad de código. P.ej. "No puedo usar std::vector aquí porque no implementa IObject . Será mejor que cree un nuevo IVectorObject derivado que haga lo correcto ...".

Este será el caso cuando estés tratando con clases integradas o bibliotecas estándar o clases de otras bibliotecas.

Ahora, si estuviera integrado en el idioma, terminaría con cosas como la confusión Integer y int que está en java, o un gran cambio en la sintaxis del idioma. (Tenga en cuenta que creo que otros idiomas han hecho un buen trabajo con la construcción de cada tipo, ruby parece ser un mejor ejemplo).

También tenga en cuenta que si su clase base no es polimórfica en tiempo de ejecución (es decir, mediante el uso de funciones virtuales), podría obtener el mismo beneficio al usar rasgos como el marco.

por ejemplo en lugar de .toString() podría tener lo siguiente: (NOTA: Sé que puedes hacerlo mejor usando bibliotecas existentes, etc., es solo un ejemplo ilustrativo).

template<typename T>
struct ToStringTrait;

template<typename T> 
std::string toString(const T & t) {
  return ToStringTrait<T>::toString(t);
}

template<>
struct ToStringTrait<int> {
  std::string toString(int v) {
    return itoa(v);
  }
}

template<typename T>
struct ToStringTrait<std::vector<T>> {
  std::string toString(const std::vector<T> &v) {
    std::stringstream ss;
    ss<<"{";
    for(int i=0; i<v.size(); ++i) {
      ss<<toString(v[i]);
    }
    ss<<"}";
    return ss.str();
  }
}
    
respondido por el Michael Anderson 16.02.2015 - 03:05
3

Podría decirse que "vacío" cumple muchos de los roles de una clase base universal. Puedes lanzar cualquier puntero a un void* . A continuación, puede comparar esos punteros. Puede static_cast volver a la clase original.

Sin embargo, lo que no puede hacer con void que puedes hacer con Object es usar RTTI para averiguar qué tipo de objeto realmente tienes. En última instancia, esto se debe a que no todos los objetos en C ++ tienen RTTI y, de hecho, es posible tener objetos de ancho cero.

    
respondido por el pjc50 16.02.2015 - 18:23
2

Java toma la filosofía de diseño de que Undefined Behavior no debería existir . Código tal como:

Cat felix = GetCat();
Woofer Rover = (Woofer)felix;
Rover.woof();

comprobará si felix tiene un subtipo de Cat que implementa la interfaz Woofer ; si lo hace, realizará la conversión e invocará woof() y si no lo hace, lanzará una excepción. El comportamiento del código está completamente definido si felix implementa Woofer o no .

C ++ toma la filosofía de que si un programa no debería intentar alguna operación, no debería importar lo que haría el código generado si se intentara esa operación, y la computadora no debería perder tiempo tratando de restringir el comportamiento en casos que "debería" nunca surgir. En C ++, al agregar los operadores de direccionamiento apropiados para convertir un *Cat en un *Woofer , el código generará un comportamiento definido cuando el lanzamiento es legítimo, pero un comportamiento indefinido cuando no es .

Tener un tipo de base común para las cosas hace posible validar los lanzamientos entre los derivados de ese tipo de base, y también realizar operaciones de prueba, pero validar los lanzamientos es más costoso que simplemente asumir que son legítimos y no esperar nada malo pasa La filosofía de C ++ sería que dicha validación requiere "pagar por algo que [normalmente] no necesita".

Otro problema que se relaciona con C ++, pero no sería un problema para un nuevo lenguaje, es que si varios programadores crean cada uno una base común, derivan sus propias clases de eso y escriben código para trabajar con cosas comunes. clase base, dicho código no podrá trabajar con objetos desarrollados por programadores que usaron una clase base diferente. Si un nuevo idioma requiere que todos los objetos del montón tengan un formato de encabezado común, y nunca ha permitido objetos del montón que no lo hicieran, entonces un método que requiera una referencia a un objeto del montón con dicho encabezado aceptará una referencia a cualquier objeto del montón. podría crear.

Personalmente, creo que tener un medio común para preguntar a un objeto "si eres convertible al tipo X" es una característica muy importante en un lenguaje / marco, pero si tal característica no está incorporada en un idioma desde el principio Es difícil agregarlo más tarde. Personalmente, creo que tal clase base debería agregarse a una biblioteca estándar en la primera oportunidad, con una fuerte recomendación de que todos los objetos que se usarán de forma polimórfica deben heredar de esa base. Hacer que cada programador implemente sus propios "tipos básicos" dificultaría más la transferencia de objetos entre diferentes códigos de personas, pero tener un tipo base común del cual heredarían muchos programadores lo haría más fácil.

ADDENDUM

Usando plantillas, es posible definir un "portador de objeto arbitrario" y preguntarle sobre el tipo del objeto que contiene; el paquete Boost contiene una cosa llamada any . Por lo tanto, a pesar de que C ++ no tiene un tipo estándar de "referencia de tipo que se pueda verificar con cualquier tipo", es posible crear uno. Esto no resuelve el problema mencionado anteriormente de no tener algo en el estándar de lenguaje, es decir, la incompatibilidad entre las implementaciones de diferentes programadores, pero explica cómo C ++ se las arregla sin tener un tipo base del cual todo se deriva: haciendo posible la creación algo que actúa como uno.

    
respondido por el supercat 18.02.2015 - 17:43
1

Symbian C ++, de hecho, tenía una clase base universal, CBase, para todos los objetos que se comportaban de una manera particular (principalmente si se asignaban montones). Proporcionó un destructor virtual, puso a cero la memoria de la clase en construcción y ocultó el constructor de copia.

La razón detrás era que era un lenguaje para sistemas integrados y compiladores y especificaciones de C ++ que realmente eran una mierda hace 10 años.

No todas las clases se heredan de esto, solo algunas.

    
respondido por el James 16.02.2015 - 20:01

Lea otras preguntas en las etiquetas