Por que base-for-all-objects é desencorajada em C ++

76

A Stroustrup diz: "Não invente imediatamente uma base única para todas as suas turmas (uma classe Object). Normalmente, você pode fazer melhor sem ela para muitas / muitas turmas". (Quarta Edição da Linguagem de Programação C ++, Seção 1.3.4)

Por que uma base-classe-para-tudo geralmente é uma má ideia, e quando faz sentido criar uma?

    
por Matthew James Briggs 15.02.2015 / 17:45
fonte

11 respostas

74

Porque o que esse objeto teria para funcionalidade? Em java, toda a classe Base tem um toString, um hashCode & igualdade e uma variável monitor + condition.

  • ToString é útil apenas para depuração.

  • hashCode só é útil se você quiser armazená-lo em uma coleção baseada em hash (a preferência em C ++ é passar uma função de hashing para o contêiner como parâmetro de modelo ou para evitar std::unordered_* e usar std::vector e listas desordenadas simples).

  • A igualdade sem um objeto base pode ser ajudada em tempo de compilação, se eles não tiverem o mesmo tipo, eles não poderão ser iguais. Em C ++, este é um erro de tempo de compilação.

  • a variável monitor e condição é melhor incluída explicitamente caso a caso.

No entanto, quando há mais que isso, existe um caso de uso.

Por exemplo, em QT, há a raiz QObject class que forma a base da afinidade de thread, hierarquia de propriedade pai-filho e mecanismo de slots de sinal. Também força o uso por ponteiro para QObjects, entretanto muitas Classes no Qt não herdam o QObject porque elas não precisam do slot de sinal (particularmente os tipos de valor de alguma descrição).

    
por 15.02.2015 / 18:04
fonte
98

Porque não há funções compartilhadas por todos os objetos. Não há nada para colocar nesta interface que faça sentido para todas as classes.

    
por 15.02.2015 / 18:37
fonte
25

Sempre que você construir altas hierarquias de herança de objetos, você tende a encontrar o problema da Classe Base Frágil (Wikipedia.) .

Ter muitas pequenas hierarquias separadas (distintas, isoladas) de herança reduz as chances de se deparar com esse problema.

Tornar todos os seus objetos parte de uma única hierarquia de heranças gigantesca praticamente garante que você vai se deparar com esse problema.

    
por 15.02.2015 / 18:18
fonte
24

Porque:

  1. Você não deve pagar pelo que não usa.
  2. Essas funções fazem menos sentido em um sistema de tipo baseado em valor do que em um sistema de tipo baseado em referência.

A implementação de qualquer tipo de função virtual introduz uma tabela virtual, que requer sobrecarga de espaço por objeto que não é necessária nem desejada em muitas situações (da maioria).

Implementar toString não-virtualmente seria inútil, porque a única coisa que poderia retornar é o endereço do objeto, que é muito hostil ao usuário e ao qual o chamador já tem acesso, diferente de Java.
Da mesma forma, um equals ou hashCode não-virtual só poderia usar endereços para comparar objetos, o que é novamente inútil e muitas vezes totalmente errado - ao contrário de Java, os objetos são copiados frequentemente em C ++ e, portanto, distinguindo a "identidade" um objeto nem sempre é significativo ou útil. (por exemplo, um int realmente deve não ter uma identidade diferente de seu valor ... dois inteiros do mesmo valor devem ser iguais.)

    
por 15.02.2015 / 23:13
fonte
16

Ter um objeto raiz limita o que você pode fazer e o que o compilador pode fazer, sem muito retorno.

Uma classe raiz comum possibilita a criação de containers-de-qualquer coisa e extrai o que eles são com dynamic_cast , mas se você precisar de containers-of-anything então algo como boost::any pode fazer isso sem uma classe raiz comum. E boost::any também suporta primitivos - pode até mesmo suportar a pequena otimização de buffer e deixá-los quase "sem caixa" na linguagem Java.

O C ++ suporta e prospera em tipos de valor. Ambos os literais e tipos de valor escritos do programador. Os contêineres C ++ armazenam, classificam, consolam, consomem e produzem tipos de valor com eficiência.

Herança, especialmente o tipo de herança de herança monolítica que as classes básicas de estilo Java implicam, requer tipos de "ponteiro" ou "referência" baseados em armazenamento livre. Seu identificador / ponteiro / referência a dados contém um ponteiro para a interface da classe e pode representar algo diferente polimorficamente.

Embora isso seja útil em algumas situações, uma vez que você tenha se casado com o padrão com uma "classe base comum", você terá bloqueado toda a sua base de códigos no custo e na bagagem desse padrão, mesmo quando isso não for útil. .

Quase sempre você sabe mais sobre um tipo do que "é um objeto" no site de chamada ou no código que o usa.

Se a função for simples, gravar a função como um modelo fornecerá um polimorfismo baseado no tempo de compilação do tipo duck, no qual as informações no site de chamada não serão descartadas. Se a função for mais complexa, o tipo de exclusão pode ser feito de modo que as operações uniformes no tipo que você deseja executar (digamos, serialização e desserialização) possam ser construídas e armazenadas (em tempo de compilação) para serem consumidas (em tempo de execução) pelo código em uma unidade de tradução diferente.

Suponha que você tenha alguma biblioteca onde queira que tudo seja serializável. Uma abordagem é ter uma classe base:

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

Agora, cada código que você escreve pode ser serialization_friendly .

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

Exceto não um std::vector , agora você precisa escrever todos os contêineres. E não aqueles inteiros que você obteve daquela biblioteca bignum. E não esse tipo que você escreveu que não achava necessário serialização. E não um tuple , ou um int ou um double ou um std::ptrdiff_t .

Nós adotamos outra abordagem:

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 em, bem, não fazer nada, aparentemente. Exceto agora, podemos estender write_to sobrescrevendo write_to como uma função livre no namespace de um tipo ou método no tipo.

Podemos até escrever um pouco de código de eliminação de tipos:

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

e agora podemos usar um tipo arbitrário e encaixá-lo automaticamente em uma interface can_serialize que permite invocar serialize posteriormente usando uma interface virtual.

Então:

void writer_thingy( can_serialize s );

é uma função que pega qualquer coisa que possa serializar, em vez de

void writer_thingy( serialization_friendly const* s );

e o primeiro, ao contrário do segundo, ele pode manipular int , std::vector<std::vector<Bob>> automaticamente.

Não demorou muito para escrevê-lo, especialmente porque esse tipo de coisa é algo que você raramente quer fazer, mas ganhamos a habilidade de tratar qualquer coisa como serializável sem precisar de um tipo base.

Além disso, agora podemos tornar std::vector<T> serializável como um cidadão de primeira classe simplesmente substituindo write_to( my_buffer*, std::vector<T> const& ) - com essa sobrecarga, ele pode ser passado para um can_serialize e o serializabilty do std::vector fica armazenado em uma tabela e acessado por .write_to .

Em suma, o C ++ é poderoso o suficiente para que você possa implementar as vantagens de uma única classe base on-the-fly quando necessário, sem ter que pagar o preço de uma hierarquia de herança forçada quando não for necessário. E os momentos em que a base única (falsificada ou não) é necessária é razoavelmente rara.

Quando os tipos são realmente sua identidade e você sabe quais são, as oportunidades de otimização são abundantes. Os dados são armazenados localmente e contíguamente (o que é muito importante para a facilidade de cache nos processadores modernos), os compiladores podem entender facilmente o que uma determinada operação faz (em vez de ter um ponteiro de método virtual opaco para pular, levando a código desconhecido no outro lado) que permite que as instruções sejam otimamente reordenadas, e menos pinos redondos são martelados em buracos redondos.

    
por 16.02.2015 / 22:12
fonte
8

Existem muitas boas respostas acima, e o claro fato de que qualquer coisa que você faria com uma classe-base de todos os objetos pode ser melhor feita de outras maneiras, como mostrado pela resposta do @ratchetfreak e os comentários sobre isso são muito importante, mas há outro motivo, que é evitar a criação de diamantes de herança quando várias heranças são usadas. Se você tivesse alguma funcionalidade em uma classe base universal, assim que você começasse a usar herança múltipla, você teria que começar a especificar qual variante você queria acessar, porque ela poderia ser sobrecarregada de maneira diferente em diferentes caminhos da cadeia de herança. E a base não pode ser virtual, porque isso seria muito ineficiente (exigindo que todos os objetos tenham uma tabela virtual a um custo potencialmente enorme em termos de uso de memória e localidade). Isso se tornaria um pesadelo logístico muito rapidamente.

    
por 16.02.2015 / 09:29
fonte
5

Na verdade, os compiladores e bibliotecas anteriores do Microsoft C ++ (eu sei sobre o Visual C ++, 16 bits) tinham essa classe chamada CObject .

No entanto, você deve saber que, naquela época, "templates" não eram suportados por este compilador C ++ simples, portanto, classes como std::vector<class T> não eram possíveis. Em vez disso, uma implementação de "vetor" só poderia manipular um tipo de classe, de modo que houvesse uma classe comparável a std::vector<CObject> hoje. Como CObject era a classe base de quase todas as classes (infelizmente não de CString - o equivalente a string em compiladores modernos) você poderia usar essa classe para armazenar quase todos os tipos de objetos.

Como os compiladores modernos suportam modelos, este caso de uso de uma "classe base genérica" não é mais fornecido.

Você precisa pensar no fato de que o uso de uma classe base genérica custará (um pouco) memória e tempo de execução - por exemplo, na chamada ao construtor. Portanto, há desvantagens ao usar essa classe, mas, pelo menos, ao usar os compiladores C ++ modernos, não há praticamente nenhum caso de uso para essa classe.

    
por 15.02.2015 / 21:05
fonte
5

Vou sugerir outro motivo que vem do Java.

Porque você não pode criar uma classe base para tudo pelo menos não sem um bando de placa de caldeira.

Você pode conseguir usá-lo para suas próprias aulas - mas provavelmente descobrirá que acaba duplicando muito código. Por exemplo. "Não consigo usar std::vector aqui, pois não implementa IObject - é melhor criar um novo IVectorObject derivado que faça a coisa certa ...".

Este será o caso sempre que você estiver lidando com classes ou classes de biblioteca padrão ou internas de outras bibliotecas.

Agora, se ele foi criado na linguagem, você acabaria com coisas como a confusão Integer e int que está em java ou uma alteração grande na sintaxe da linguagem. (Veja bem, eu acho que algumas outras linguagens fizeram um bom trabalho em construí-lo em cada tipo - o ruby parece um exemplo melhor).

Observe também que, se a sua classe base não for polimórfica em tempo de execução (por exemplo, usando funções virtuais), você poderá obter o mesmo benefício do uso de características como a estrutura.

por exemplo. em vez de .toString() , você pode ter o seguinte: (NOTA: Eu sei que você pode fazer isso mais puro usando bibliotecas existentes, etc, é apenas um exemplo 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();
  }
}
    
por 16.02.2015 / 03:05
fonte
3

Indiscutivelmente, "vazio" cumpre muitas das funções de uma classe base universal. Você pode converter qualquer ponteiro para void* . Você pode então comparar esses ponteiros. Você pode static_cast voltar para a classe original.

No entanto, o que você não pode fazer com void , o que você pode fazer com Object é usar o RTTI para descobrir que tipo de objeto você realmente tem. Isso se resume a como nem todos os objetos em C ++ possuem RTTI e, na verdade, é possível ter objetos de largura zero.

    
por 16.02.2015 / 18:23
fonte
2

Java tem a filosofia de design que Comportamento Indefinido não deveria existir . Código como:

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

testará se felix contém um subtipo de Cat que implementa a interface Woofer ; Em caso afirmativo, ele executará a conversão e invocará woof() e, se isso não ocorrer, lançará uma exceção. O comportamento do código é totalmente definido se felix implementa Woofer ou não .

O C ++ adota a filosofia de que se um programa não deve tentar alguma operação, não importa o que o código gerado faria se a operação fosse tentada, e o computador não deveria perder tempo tentando restringir o comportamento em casos que "deveria" nunca surgir. Em C ++, adicionando os operadores apropriados de modo indireto, de modo a lançar um *Cat para um *Woofer , o código produziria um comportamento definido quando o elenco é legítimo, mas Comportamento indefinido quando não é . / p>

Ter um tipo de base comum possibilita validar casts entre derivativos desse tipo base e também realizar operações try-cast, mas validar casts é mais caro do que simplesmente assumir que eles são legítimos e não esperam nada de ruim acontece. A filosofia C ++ seria que tal validação requer "pagar por algo que você [geralmente] não precisa".

Outro problema relacionado ao C ++, mas que não seria um problema para uma nova linguagem, é que se vários programadores criarem uma base comum, derivem suas próprias classes e escrevam código para trabalhar com coisas comuns. classe base, tal código não poderá trabalhar com objetos desenvolvidos por programadores que usaram uma classe base diferente. Se um novo idioma exigir que todos os objetos heap tenham um formato de cabeçalho comum e nunca tenha permitido objetos heap que não tenham, então um método que requer uma referência a um objeto heap com um cabeçalho desse tipo aceitará uma referência a qualquer objeto heap poderia criar.

Pessoalmente, acho que ter um meio comum de perguntar a um objeto "você é conversível para o tipo X" é um recurso muito importante em uma linguagem / estrutura, mas se esse recurso não for incorporado em uma linguagem desde o início é difícil adicioná-lo depois. Pessoalmente, acho que essa classe base deve ser adicionada a uma biblioteca padrão na primeira oportunidade, com uma strong recomendação de que todos os objetos que serão usados polimorficamente devam herdar dessa base. Fazer com que os programadores implementem seus próprios "tipos de base" tornaria mais difícil a passagem de objetos entre o código de pessoas diferentes, mas ter um tipo de base comum do qual muitos programadores herdaram tornaria isso mais fácil.

ADENDO

Usando modelos, é possível definir um "titular de objeto arbitrário" e perguntar sobre o tipo de objeto contido nele; o pacote Boost contém uma coisa chamada any . Assim, mesmo que o C ++ não tenha um tipo padrão de "referência de verificação de tipo para qualquer coisa", é possível criar um. Isso não resolve o problema acima mencionado de não ter algo no padrão da linguagem, isto é, incompatibilidade entre as implementações de diferentes programadores, mas explica como o C ++ passa sem ter um tipo de base do qual tudo é derivado: tornando possível criar algo que age como um.

    
por 18.02.2015 / 17:43
fonte
1

O Symbian C ++ tinha, de fato, uma classe base universal, CBase, para todos os objetos que se comportavam de uma maneira particular (principalmente se eles alocassem heap). Ele forneceu um destruidor virtual, zerou a memória da classe na construção e ocultou o construtor de cópia.

A razão por trás disso era que era uma linguagem para sistemas embarcados e compiladores e especificações de C ++ eram realmente uma merda 10 anos atrás.

Nem todas as classes são herdadas disso, apenas algumas.

    
por 16.02.2015 / 20:01
fonte