在C++20中比较多态类型

17

我有一些代码,处于C++17和C++20之间。具体来说,我们在启用GCC-9和clang-9上启用了C++20,但它只被部分实现。

在代码中,我们有一个相当大的多态类型层次结构,就像这样:

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

我们现在正在迁移到GCC-10和clang-10,但由于某些原因,我们仍然需要使用版本9(至少是clang-9,因为这是Android NDK当前的版本)。

上述代码停止编译是因为实施了关于比较运算符的新规则。可逆运算符==会导致歧义。我不能使用宇宙飞船操作符,因为其在版本9中没有实现。但我在示例中省略了它——我假设任何与==一起使用的内容都可以与其他运算符一起使用。

那么:

c++20中使用多态类型实现比较运算符的推荐方法是什么?


8
你确定所有这些 dynamic_cast 使用是必需的吗?在我看来,它与多态的本质相反。此外,它会导致额外的运行时开销。肯定有方法可以避免使用 RTTI。 - goodvibration
3
我对这种方法的担忧是,a == b 可能不等同于 b == a示例 - François Andrieux
我明白你的意思,我也不太喜欢这个设计。@FrançoisAndrieux 每种类型只能与相同类型进行比较才会返回 true。我认为只要层次结构的叶子节点具有具体的操作符实现,并且它们的行为都像示例中一样,那么它们应该是等效的。 - MateuszL
1
在这种情况下,将final添加到叶子类,并附上相应的注释可能有助于避免错误。 - François Andrieux
1
这些是否真正具有多态性?您是否拥有 Identifier 的向量,其中您不知道具体类型?还是 Identifier 只是一个用于存储公共代码的仓库?这似乎是使用好奇的递归模板模式的不错应用。 - trent
显示剩余2条评论
4个回答

19

作为一个中间解决方案,您可以重构多态等式operator==,使其成为在基类中定义的非虚拟operator==,该操作符会多态地分派到非操作符虚拟成员函数:

作为一种中间解决方案,您可以重新设计多态等式operator==,将其定义在基类中为非虚拟的operator==,然后由它进行多态分派以调用非操作符的虚拟成员函数:

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

现在,在isEqual虚成员函数上的分派将不再存在歧义,因为它总是在operator==的左侧参数上执行。

const bool result = (user == machine);  // user.isEqual(machine);

2
请注意,此方法可以扩展为使用双重分派,从而消除对dynamic_cast的需求。 - 1201ProgramAlarm

1

好的,我看到在@dfrib给出的答案中没有提到它,所以我将扩展那个答案来展示它。

您可以在Identifier结构中添加一个抽象(纯虚)函数,该函数返回其“标识”。

然后,在每个扩展Identifier结构的结构中,您可以调用该函数而不是动态转换输入对象并检查其类型是否与this对象匹配。

当然,您必须确保完全区分每个结构的标识集。换句话说,任何两个标识集都不能共享任何公共值(即两个集合必须是不相交的)。

这将允许您完全摆脱RTTI,这在我看来几乎是多态性的完全相反,并且还会产生额外的运行时影响。

这里是那个答案的扩展:

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

如果你想要支持一个使用除了 int 以外的其他类型标识符的结构体,那么你可以扩展这个解决方案以使用模板。
作为另一种选择,在每个结构体中强制使用不同的标识符集合的同时,你也可以添加一个 "type" 字段,并确保只有该字段在不同的结构体之间是唯一的。
本质上,这些类型将相当于 dynamic_cast 检查,该检查在输入对象的 V-table 指针和输入结构的 V-table 指针之间进行比较(因此我认为这种方法与多态完全相反)。
以下是修改后的答案:
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;
    }
};

这将带来一个可能无意的影响,即如果userIdmachineId的值匹配,UserIdentifier将与MachineIdentifier相等。 - dfrib
@dfrib:这就是为什么我明确说明了:“当然,您必须确保每个结构的身份集之间完全区分。换句话说,任何两个身份集都不得共享任何公共值(即,这两个集合必须是不相交的)。”。 - goodvibration
在您的示例中,即使默认标识设置为相同的值,因此没有重叠机制(可能会使读者感到困惑)。我认为您的答案涵盖了一种通常很好的替代方法,但您可能需要将id和标识符标签的逻辑拆分开来,其中后者将唯一地与类型的“种类”相关联,而前者仅是可能在不同类型的id之间重叠的id。 - dfrib
@dfrib:啊,是的,那是一个复制粘贴错误。顺便说一下,您还可以添加一个额外的类型字段,并允许自己在不同结构中使用相同的标识,只要类型是唯一的。实质上,这些类型将等同于 dynamic_cast 检查,该检查比较输入对象的 V 表指针和输入类型的 V 表指针。 - goodvibration
我同意,我可能会稍后更新/扩展答案,以展示使用标识符唯一标记的替代dyncast(目前在会议中,稍后再说)。 - dfrib
显示剩余2条评论

1
这似乎不是多态性问题。实际上,我认为任何多态性都是数据模型错误的症状。
如果您有用于标识机器和用户的值,并且这些标识符不可互换¹,则它们不应共享超类型。 “作为标识符”的属性是关于类型在数据模型中如何用于标识另一种类型的值的事实。 MachineIdentifier是标识符,因为它标识机器; UserIdentifier是标识符,因为它标识用户。但是,标识符实际上并不是标识符,因为它不标识任何东西!它是一个破折号。
更直观地说,类型是使标识符有意义的唯一要素。除非您首先将其向下转换为MachineIdentifier或UserIdentifier,否则无法使用裸体Identifier进行任何操作。因此,拥有一个Identifier类很可能是错误的,并且将MachineIdentifier与UserIdentifier进行比较是一种类型错误,应该由编译器检测到。
我认为Identifier存在的最可能原因是有人意识到MachineIdentifierUserIdentifier之间存在共同代码,并得出结论,这种共同行为应该被提取到一个Identifier基类型中,具体类型从中继承。对于那些在学校里学习“继承可以实现代码重用”但还没有意识到其他代码重用方式的人来说,这是可以理解的错误。
他们应该写什么呢?使用模板如何?模板实例化不是模板或彼此的子类型。如果您有代表这些标识符的类型MachineUser,您可以尝试编写一个模板Identifier结构并对其进行特化,而不是对其进行子类化:
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;
  }
};

当您可以将所有数据和行为移动到模板中并且因此不需要专门化时,这可能是最有意义的。否则,这不一定是最佳选择,因为您无法指定Identifier实例必须实现operator==。我认为可能有一种方法可以使用C++20概念来实现这个或类似的东西,但是相反,让我们将模板与继承结合起来以获得两者的某些优点:

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

现在,将MachineIdentifierUserIdentifier进行比较会导致编译时错误。
这种技术被称为奇异递归模板模式(也可以参见)。当你第一次遇到它时,它可能会让你感到困惑,但是它给你的是能够在超类中引用特定子类类型的能力(在这个例子中,作为Id)。与大多数其他选择相比,它可能也是一个不错的选择,因为它对已经正确使用MachineIdentifierUserIdentifier的代码需要的更改相对较少。

如果标识符可以互换,则本答案(以及其他大多数答案)可能不适用。但是,如果是这种情况,则也应该可以在不降级的情况下进行比较。


是的,我同意你对代码重用与多态性的诊断可能是正确的。不幸的是,现在层次结构太大了,无法更改。关于你回答的最后一部分——这似乎很酷 https://en.wikipedia.org/wiki/Barton%E2%80%93Nackman_trick。 - MateuszL

0

你的代码中没有多态性。你可以通过使用标识符指针或引用来强制比较运算符函数(多态性)的动态绑定。

例如,不要写成

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

有了引用,你可以这样做:

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

return iUser == machine ? 1: 0;

相反地,您可以显式调用UserIdentifier的比较运算符:
return user.operator==(machine) ? 1: 0;

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接