C++:对于这种(多重分派)运行时多态性,是否有更优雅的解决方案?

C++: Is there a more elegant solution to this (multiple dispatch) runtime polymorphism?

主要问题很简单,真的。给定一个基础(更抽象)class 和多个需要相互交互的派生基础,你将如何进行?

举一个更具体的例子,这里是一个 2d 视频游戏的 hitboxes 实现:

#include <stdio.h>
#include <vector>

#include "Header.h"


bool Hitbox::isColliding(Hitbox* otherHtb) {
    printf("Hitbox to hitbox.\n");
    return this->isColliding(otherHtb);
}

bool CircleHitbox::isColliding(Hitbox* otherHtb) {
    printf("Circle to hitbox.\n");

    // Try to cast to a circle.
    CircleHitbox* circle = dynamic_cast<CircleHitbox*>(otherHtb);
    if (circle) {
        return this->isColliding(circle);
    }

    // Try to cast to a square.
    SquareHitbox* square = dynamic_cast<SquareHitbox*>(otherHtb);
    if (square) {
        return this->isColliding(square);
    }

    // Default behaviour.
    return 0;
}

bool CircleHitbox::isColliding(CircleHitbox* otherHtb) {
    printf("Circle to circle.\n");

    // Suppose this function computes whether the 2 circles collide or not.
    return 1;
}

bool CircleHitbox::isColliding(SquareHitbox* otherHtb) {
    printf("Circle to square.\n");

    // Suppose this function computes whether the circle and the square collide or not.
    return 1;
}

// This class is basically the same as the CircleHitbox class!
bool SquareHitbox::isColliding(Hitbox* otherHtb) {
    printf("Square to hitbox.\n");

    // Try to cast to a circle.
    CircleHitbox* circle = dynamic_cast<CircleHitbox*>(otherHtb);
    if (circle) {
        return this->isColliding(circle);
    }

    // Try to cast to a square.
    SquareHitbox* square = dynamic_cast<SquareHitbox*>(otherHtb);
    if (square) {
        return this->isColliding(square);
    }

    // Default behaviour.
    return 0;
}

bool SquareHitbox::isColliding(CircleHitbox* otherHtb) {
    printf("Square to circle.\n");

    // Suppose this function computes whether the square and the circle collide or not.
    return 1;
}

bool SquareHitbox::isColliding(SquareHitbox* otherHtb) {
    printf("Square to square.\n");

    // Suppose this function computes whether the 2 squares collide or not.
    return 1;
}


int main() {
    CircleHitbox a, b;
    SquareHitbox c;
    std::vector<Hitbox*> hitboxes;

    hitboxes.push_back(&a);
    hitboxes.push_back(&b);
    hitboxes.push_back(&c);
    
    // This runtime polymorphism is the subject here.
    for (Hitbox* hitbox1 : hitboxes) {
        printf("Checking all collisions for a new item:\n");
        for (Hitbox* hitbox2 : hitboxes) {
            hitbox1->isColliding(hitbox2);
            printf("\n");
        }
    }

    return 0;
}

与头文件:

#pragma once

class Hitbox {
public:
    virtual bool isColliding(Hitbox* otherHtb);
};

class CircleHitbox : public Hitbox {
public:
    friend class SquareHitbox;

    bool isColliding(Hitbox* otherHtb) override;
    bool isColliding(CircleHitbox* otherHtb);
    bool isColliding(SquareHitbox* otherHtb);
};

class SquareHitbox : public Hitbox {
public:
    friend class CircleHitbox;

    bool isColliding(Hitbox* otherHtb) override;
    bool isColliding(CircleHitbox* otherHtb);
    bool isColliding(SquareHitbox* otherHtb);
};

我对此的主要问题是每个派生 class 都需要在重写函数中进行“is-a”检查。

我看到的替代方案是访问者设计模式,但这可能:

  1. 这个看似简单的问题太复杂了。

  2. 导致问题多于解决方案。

一个 属性 应该从此代码中保留的是,没有派生 class 被迫实现与每个(或任何,就此而言)其他派生 class 的交互.另一个是无需任何对象切片即可将所有派生对象存储在基类型数组中的能力。

交互可以由基地 class 本身管理。像这样:

struct CircleHitBox;
struct SquareHitBox;

struct HitBox
{

template <class HITBOX>
bool is_colliding(HITBOX) const
{
    if constexpr (std::is_same_v<HITBOX, CircleHitBox>)
    {
        std::cout << "A CircleHitBox hit me.\n";
        return true;
    }
    else if constexpr (std::is_same_v<HITBOX, SquareHitBox>)
    {
        std::cout << "A SquareHitBox hit me.\n";
        return true;
    }
}            
};

此外,每个 subclass 都可以在 map 或某些结构中注册自己,因此您可以使用循环(扫描 map)而不是 if else陈述。

使用 C++ 17 有一个简单而优雅的解决方案,它允许您 运行 次多态性而无需虚函数开销:

#include <iostream>

namespace hitbox
{
    struct rectangle
    {
        double h,
                w;
    };

    struct circle
    {
        double r;
    };
}

bool is_colliding(const hitbox::rectangle &, const hitbox::circle &) {
    std::cout << "Rectangle + Circle" << std::endl;
    return true;
}

bool is_colliding(const hitbox::rectangle &, const hitbox::rectangle &) {
    std::cout << "Rectangle + Rectangle" << std::endl;
    return true;
}

bool is_colliding(const hitbox::circle &, const hitbox::circle &) {
    std::cout << "Circle + Circle" << std::endl;
    return true;
}

#include <variant>

using hitbox_variant = std::variant<hitbox::rectangle, hitbox::circle>;

bool is_colliding(const hitbox_variant &hitboxA, const hitbox_variant &hitboxB)
{
    return std::visit([](const auto& hitboxA, const auto& hitboxB)
                      {
                          return is_colliding(hitboxA, hitboxB);
                      }, hitboxA, hitboxB);
}

int main()
{
    hitbox_variant rectangle{hitbox::rectangle()},
            circle{hitbox::circle()};

    is_colliding(rectangle, rectangle);
    is_colliding(rectangle, circle);
    is_colliding(circle, circle);

    return 0;
}

https://godbolt.org/z/KzPhq5Ehr - 你的例子


您的问题来自于您关于必要性 类型被擦除的假设。当您擦除类型时(在您的情况下,通过将它们减少为基础抽象 class),您擦除有关它们的属性的信息(如它们的几何形状)。
但是为什么你首先使用类型擦除?
因为您想将对所有需要的对象的引用存储在一个容器中,这要求它们属于同一类型
那么,您需要吗?对于编译时已知的对象类型之间的碰撞计算的特定问题,这是一个糟糕的抽象选择。因此,除非您没有获得在 运行 期间“创建”的对象类型,否则 请勿删除类型

将您的对象存储在几个容器 中,以便在您需要了解类型时使用。它将减少 运行 时间反射的冗余成本(通过 dynamic_cast、枚举等)。

// you HAVE to implement them because your program KNOWS about them already
bool has_collision(const CircleHitBox& circle, const CircleHitBox& circle);
bool has_collision(const CircleHitBox& circle, const SquareHitbox& square);
bool has_collision(const SquareHitbox& square, const SquareHitbox& square);

struct scene
{  
  template <typename T>
  using ref = std::reference_wrappet<T>;
  std::vector<ref<const CircleHitBox>> circleHitBoxes;
  std::vector<ref<const SquareHitbox>> squareHitBoxes;
  std::vector<ref<const HitBox>> otherHitBoxes;
};

// here you create an object for your scene with all the relevant objects of known types
void calc_collisions(scene s)
{
  // do your calculations here
}

您可以使用某种注册表,例如实体组件系统 (EnTT)。


切记:
您在这里解决了碰撞问题,因此您 了解特定对象的属性。这意味着在不违反 Liskov 替换原则 的情况下,您不能 运行-时间多态性。 LSP 意味着抽象基础 class 后面的每个对象都可以互换 并且具有 完全相同的属性 - 这些是 相同,直到你进行一些类型转换。

另外,HitBox类型最好只是一个POD类型来存储数据。您在那里不需要任何非静态成员函数,尤其是虚函数。不要混合数据和行为,除非你需要(例如有状态的功能对象)。

这里是 classical 双重分派的简化示例(未经测试)。

struct Circle;
struct Rectangle;

struct Shape {
  virtual bool intersect (const Shape&) const = 0;
  virtual bool intersectWith (const Circle&) const = 0;
  virtual bool intersectWith (const Rectangle&) const = 0;
};

struct Circle : Shape {
  bool intersect (const Shape& other) const override { 
     return other.intersectWith(*this);
  }
  bool intersectWith (const Circle& other) const override {
     return /* circle x circle intersect code */;
  }
  bool intersectWith (const Rectangle& other) const override {
     return /* circle x rectangle intersect code*/;
  }
};

struct Rectangle : Shape {
  bool intersect (const Shape& other) const override { 
     return other.intersectWith(*this);
  }
  bool intersectWith (const Circle& other) const override {
     return /* rectangle x circle intersect code */;
  }
  bool intersectWith (const Rectangle& other) const override {
     return /* rectangle x rectangle intersect code*/;
  }
};

如你所见,你离得并不远。

备注:

  1. return intersectWith(*this);需要在每个导出的class中重复。方法的文字每次都是一样的,只是this的类型不一样。这可以被模板化以避免重复,但它可能不值得。
  2. Shape 基础 class(当然,它的每个派生 classes)需要了解所有 Shape 派生的 class es。这在 class 之间创建了循环依赖。有一些方法可以避免它,但这些确实需要转换。

这不是多重分派问题的解决方案,而是一个解决方案。基于变体的解决方案可能更可取,也可能不是更可取,具体取决于您的代码中还有哪些其他内容。