This does not look like a problem of polymorphism. Actually, I think that there is any polymorphism at all is a symptom of a data model error.
If you have values that identify machines, and values that identify users, and these identifiers are not interchangeable¹, they should not share a supertype. The property of "being an identifier" is a fact about how the type is used in the data model to identify values of another type. A MachineIdentifier is an identifier because it identifies a machine; a UserIdentifier is an identifier because it identifies a user. But an Identifier is in fact not an identifier, because it doesn't identify anything! It is a broken abstraction.
A more intuitive way to put this might be: the type is the only thing that makes an identifier meaningful. You cannot do anything with a bare Identifier, unless you first downcast it to MachineIdentifier or UserIdentifier. So having an Identifier class is most likely wrong, and comparing a MachineIdentifier to a UserIdentifier is a type error that should be detected by the compiler.
It seems to me the most likely reason Identifier exists is because someone realized that there was common code between MachineIdentifier and UserIdentifier, and leapt to the conclusion that the common behavior should be extracted to an Identifier base type, with the specific types inheriting from it. This is an understandable mistake for anyone who has learned in school that "inheritance enables code reuse" and has not yet realized that there are other kinds of code reuse.
What should they have written instead? How about a template? Template instantiations are not subtypes of the template or of each other. If you have types Machine and User that these identifiers represent, you might try writing a template Identifier struct and specializing it, instead of subclassing it:
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;
}
};
This probably makes the most sense when you can move all the data and behavior into the template and thus not need to specialize. Otherwise, this is not necessarily the best option because you cannot specify that Identifier instantiations must implement operator==. I think there may be a way to achieve that, or something similar, using C++20 concepts, but instead, let's combine templates with inheritance to get some advantages of both:
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;
}
};
Now, comparing a MachineIdentifier to a UserIdentifier is a compile time error.
This technique is called the curiously recurring template pattern (also see crtp). It is somewhat baffling when you first come across it, but what it gives you is the ability to refer to the specific subclass type in the superclass (in this example, as Id). It might also be a good option for you because, compared to most other options, it requires relatively few changes to code that already correctly uses MachineIdentifier and UserIdentifier.
¹ If the identifiers are interchangeable, then most of this answer (and most of the other answers) probably does not apply. But if that is the case, it should also be possible to compare them without downcasting.