Comparando tipos polimórficos em c ++ 20

Oct 27 2020

Eu tenho um código que está em algum lugar entre c ++ 17 e c ++ 20. Especificamente, temos o c ++ 20 habilitado no GCC-9 e no clang-9, onde é apenas parcialmente implementado.

No código, temos uma grande hierarquia de tipos polimórficos como este:

struct Identifier {
    virtual bool operator==(const Identifier&other) const = 0;
};

struct UserIdentifier : public Identifier {
    int userId =0;
    bool operator==(const Identifier&other) const override {
        const UserIdentifier *otherUser = dynamic_cast<const UserIdentifier*>(&other);
        return otherUser && otherUser->userId == userId;
    }
};

struct MachineIdentifier : public Identifier {
    int machineId =0;
    bool operator==(const Identifier&other) const override {
        const MachineIdentifier *otherMachine = dynamic_cast<const MachineIdentifier*>(&other);
        return otherMachine && otherMachine->machineId == machineId;
    }
};

int main() {
    UserIdentifier user;
    MachineIdentifier machine;
    return user==machine? 1: 0;
}

https://godbolt.org/z/er4fsK

Agora estamos migrando para o GCC-10 e o clang-10, mas por motivos ainda precisamos trabalhar nas versões 9 (bem, pelo menos o clang-9, pois é o que o Android NDK tem atualmente).

O código acima para de compilar porque novas regras sobre operadores de comparação são implementadas. Operador reversível == causa ambigüidades. Não posso usar um operador de nave porque ele não está implementado nas versões 9. Mas omiti isso do exemplo - presumo que tudo o que funciona com == funcionará com outros operadores.

Portanto: Qual é a abordagem recomendada para implementar operadores de comparação em c ++ 20 com tipos polimórficos?

Respostas

19 dfrib Oct 27 2020 at 04:13

Como uma solução intermediária, você pode refatorar sua igualdade polimórfica operator==para um não virtual operator==definido na classe base, que despacha polimorficamente para uma função de membro virtual não operador:

struct Identifier {    
    bool operator==(const Identifier& other) const {
        return isEqual(other);
    }
private:
    virtual bool isEqual(const Identifier& other) const = 0;
};

// Note: do not derive this class further (less dyncasts may logically fail).
struct UserIdentifier final : public Identifier {
    int userId = 0;
private:
    virtual bool isEqual(const Identifier& other) const override {
        const UserIdentifier *otherUser = dynamic_cast<const UserIdentifier*>(&other);
        return otherUser && otherUser->userId == userId;
    }
};

// Note: do not derive this class further (less dyncasts may logically fail).
struct MachineIdentifier final : public Identifier {
    int machineId = 0;
private:
    virtual bool isEqual(const Identifier& other) const override {
        const MachineIdentifier *otherMachine = dynamic_cast<const MachineIdentifier*>(&other);
        return otherMachine && otherMachine->machineId == machineId;
    }
};

Agora não haverá mais ambigüidade, pois o despacho na isEqualfunção de membro virtual sempre será feito no argumento do lado esquerdo para operator==.

const bool result = (user == machine);  // user.isEqual(machine);
1 goodvibration Oct 27 2020 at 16:10

OK, vejo que não foi mencionado na resposta dada por @dfrib, então vou estender essa resposta para mostrar isso.

Você pode adicionar uma função abstrata (virtual pura) na Identifierestrutura, que retorna sua "identidade".

Em seguida, em cada estrutura que estende a Identifierestrutura, você pode chamar essa função em vez de converter dinamicamente o objeto de entrada e verificar se seu tipo corresponde ao thisobjeto.

Claro, você terá que garantir uma distinção completa entre o conjunto de identidades de cada estrutura. Em outras palavras, quaisquer dois conjuntos de identidades não devem compartilhar nenhum valor comum (ou seja, os dois conjuntos devem ser separados).

Isso permitirá que você se livre completamente do RTTI, que é praticamente o oposto do polimorfismo IMO, e também gera um impacto adicional no tempo de execução em cima disso.

Aqui está a extensão dessa resposta:

struct Identifier {    
    bool operator==(const Identifier& other) const {
        return getVal() == other.getVal();
    }
private:
    virtual int getVal() const = 0;
};

struct UserIdentifier : public Identifier {
private:
    int userId = 0;
    virtual int getVal() const override {
        return userId;
    }
};

struct MachineIdentifier : public Identifier {
private:
    int machineId = 100;
    virtual int getVal() const override {
        return machineId;
    }
};

Se você deseja oferecer suporte a uma estrutura com identificadores de outro tipo diferente int, você pode estender esta solução para usar modelos.

Como alternativa para impor um conjunto diferente de identidades para cada estrutura, você pode adicionar um typecampo e garantir que apenas esse campo seja único nas diferentes estruturas.

Em essência, esses tipos seriam o equivalente à dynamic_castverificação, que compara entre o ponteiro da tabela V do objeto de entrada e o ponteiro da tabela V da estrutura de entrada (daí a minha opinião sobre esta abordagem ser o oposto completo de polimorfismo).

Aqui está a resposta revisada:

struct Identifier {    
    bool operator==(const Identifier& other) const {
        return getType() == other.getType() && getVal() == other.getVal();
    }
private:
    virtual int getType() const = 0;
    virtual int getVal() const = 0;
};

struct UserIdentifier : public Identifier {
private:
    int userId = 0;
    virtual int getType() const override {
        return 1;
    virtual int getVal() const override {
        return userId;
    }
};

struct MachineIdentifier : public Identifier {
private:
    int machineId = 0;
    virtual int getType() const override {
        return 2;
    virtual int getVal() const override {
        return machineId;
    }
};
1 trentcl Oct 28 2020 at 06:23

Isso não parece ser um problema de polimorfismo. Na verdade, acho que qualquer polimorfismo é um sintoma de um erro do modelo de dados.

Se você tiver valores que identificam máquinas e valores que identificam usuários, e esses identificadores não são intercambiáveis¹, eles não devem compartilhar um supertipo. A propriedade de "ser um identificador" é um fato sobre como o tipo é usado no modelo de dados para identificar valores de outro tipo. A MachineIdentifieré um identificador porque identifica uma máquina; a UserIdentifieré um identificador porque identifica um usuário. Mas Identifierna verdade um não é um identificador, porque não identifica nada! É uma abstração quebrada.

Uma maneira mais intuitiva de colocar isso seria: o tipo é a única coisa que torna um identificador significativo. Você não pode fazer nada com um bare Identifier, a menos que primeiro faça o downcast para MachineIdentifierou UserIdentifier. Portanto, Identifierprovavelmente é errado ter uma classe, e comparar a MachineIdentifiercom a UserIdentifieré um erro de tipo que deve ser detectado pelo compilador.

Parece-me que a razão mais provável Identifieré porque alguém percebeu que havia um código comum entre MachineIdentifiere UserIdentifier, e concluiu que o comportamento comum deve ser extraído para um Identifiertipo base, com os tipos específicos herdando dele. Este é um erro compreensível para qualquer pessoa que aprendeu na escola que "a herança permite a reutilização de código" e ainda não percebeu que existem outros tipos de reutilização de código.

O que eles deveriam ter escrito em vez disso? Que tal um modelo? As instanciações do modelo não são subtipos do modelo ou uns dos outros. Se você tem tipos Machinee Useresses identificadores representam, você pode tentar escrever uma Identifierestrutura de modelo e especializá-la, em vez de subclassificá-la:

template <typename T>
struct Identifier {};

template <>
struct Identifier<User> {
  int userId = 0;
  bool operator==(const Identifier<User> &other) const {
    return other.userId == userId;
  }
};

template <>
struct Identifier<Machine> {
  int machineId = 0;
  bool operator==(const Identifier<Machine> &other) const {
    return other.machineId == machineId;
  }
};

Isso provavelmente faz mais sentido quando você pode mover todos os dados e comportamento para o modelo e, portanto, não precisa se especializar. Caso contrário, esta não é necessariamente a melhor opção porque você não pode especificar que as Identifierinstanciações devem ser implementadas operator==. Acho que pode haver uma maneira de conseguir isso, ou algo semelhante, usando os conceitos do C ++ 20, mas em vez disso, vamos combinar modelos com herança para obter algumas vantagens de ambos:

template <typename Id>
struct Identifier {
  virtual bool operator==(const Id &other) const = 0;
};

struct UserIdentifier : public Identifier<UserIdentifier> {
  int userId = 0;
  bool operator==(const UserIdentifier &other) const override {
    return other.userId == userId;
  }
};

struct MachineIdentifier : public Identifier<MachineIdentifier> {
  int machineId = 0;
  bool operator==(const MachineIdentifier &other) const override {
    return other.machineId == machineId;
  }
};

Agora, comparar a MachineIdentifiercom a UserIdentifieré um erro de tempo de compilação.

Essa técnica é chamada de padrão de modelo curiosamente recorrente (consulte também crtp ). É um tanto desconcertante quando você o encontra pela primeira vez, mas o que ele oferece é a capacidade de se referir ao tipo específico de subclasse na superclasse (neste exemplo, as Id). Também pode ser uma boa opção para você porque, em comparação com a maioria das outras opções, requer relativamente poucas alterações no código que já usa MachineIdentifiere corretamente UserIdentifier.


¹ Se os identificadores forem intercambiáveis, a maior parte desta resposta (e a maioria das outras respostas) provavelmente não se aplica. Mas se for esse o caso, também deve ser possível compará-los sem downcasting.

scohe001 Oct 27 2020 at 04:12

Você não tem nenhum polimorfismo em seu código. Você pode forçar uma vinculação dinâmica da função do operador de comparação (polimorfismo) usando Identifierponteiros ou referências.

Por exemplo, em vez de

UserIdentifier user;
MachineIdentifier machine;
return user==machine? 1: 0;

Com referências, você poderia fazer:

UserIdentifier user;
MachineIdentifier machine;
Identifier &iUser = user;

return iUser == machine ? 1: 0;

Por outro lado, você pode chamar explicitamente UserIdentifiero operador de comparação:

return user.operator==(machine) ? 1: 0;