C++:对于这种(多调度)运行时多态性,是不是有更优雅的解决方案?

Posted

技术标签:

【中文标题】C++:对于这种(多调度)运行时多态性,是不是有更优雅的解决方案?【英文标题】:C++: Is there a more elegant solution to this (multiple dispatch) runtime polymorphism?C++:对于这种(多调度)运行时多态性,是否有更优雅的解决方案? 【发布时间】:2021-12-02 19:40:40 【问题描述】:

主要问题很简单,真的。给定一个基类(更抽象的)和多个需要相互交互的派生类,你会怎么做呢?

举一个更具体的例子,这是一个用于 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);
;

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

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

    对于这个看似简单的问题来说太复杂了。

    导致的问题多于解决方案。

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

【问题讨论】:

我个人会完全避免这样的继承和 OOP。相反,我只有一个“hitbox”结构,它存储所有可能类型的 hitbox 的数据(可能通过标记的联合进行优化) 另一个选项(如在例如:Box2D 中所做的)是在基类中存储一个“类型”枚举,然后您可以轻松地switch ifdynamic_casts) 如何使用模板参数并避免动态转换? 您想了解访问者模式。这是一个经典的用例。在正确的实现中,您不需要任何强制转换,无论是动态的还是其他的。 @UnholySheep 枚举和切换解决方案不可扩展且很容易出错。它违反了 SRP 和 OCP,因为在添加新枚举值时需要修改所有使用开关的位置。在一个大型项目中,人们肯定会错过一些地方...... 【参考方案1】:

这是经典双重调度的简化示例(未经测试)。

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*/;
  
;

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

注意事项:

    return intersectWith(*this); 需要在每个派生类中重复。方法的文本每次都是一样的,只是this的类型不一样。这可以被模板化以避免重复,但它可能不值得。 Shape 基类(当然还有它的每个派生类)需要了解所有Shape 派生类。这会在类之间产生循环依赖。有一些方法可以避免它,但这些确实需要强制转换。

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

【讨论】:

看起来很有趣,但我觉得它不适用于禁用的 RTTI,并且(如果是的话)你应该在你的回答中提到它。 godbolt.org/z/4onqrd3cP 您的示例(已测试)。有趣的是,它可以与 -fno-rtti 一起使用。你能解释一下没有 rtti 是如何工作的吗? 好吧,我一下子没注意到,但现在我明白了。实现类正在调用传递*this 的其他方法,因此在调用时类型是已知的,不需要向上转换。非常感谢您的回答! @SergeyKolesnik 这实际上是一个完全标准的双重调度实现,我觉得所有 C++/OO 程序员都应该知道。 不知何故,这对我来说仍然是新事物,我很高兴知道这样的事情。顺便说一句,这个解决方案与使用std::variant 几乎相同,因为您必须将每种类型“介绍”给另一种类型。此外,如果 OP 决定动态更改 hitbox 的类型,他将不得不拥有一个基类,从而面临堆分配损失。 std::variant 不需要任何堆分配,但允许运行时配置。【参考方案2】:

使用 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 rectanglehitbox::rectangle(),
            circlehitbox::circle();

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

    return 0;

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


您的问题来自您对删除类型的必要性的假设。当您删除类型时(在您的情况下,通过将它们简化为基本抽象类),您会删除有关其属性的信息(例如它们的几何图形)。 但是为什么您首先要使用类型擦除?因为您想将所有需要的对象的引用存储在一个容器中,这要求它们具有相同的类型强>. 那么,您需要吗?对于您在编译时已知的对象类型之间的碰撞计算的特定问题,这是一个选择不当的抽象。因此,除非您没有获得在运行时“创建”的对象类型,否则不要擦除类型

将您的对象存储在几个容器中,以便在您需要了解类型时使用。它将减少运行时反射的冗余成本(通过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 意味着抽象基类后面的每个对象都是可互换的 并且具有完全相同的属性 - 在你之前,这些是相同的做一些类型转换。

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

【讨论】:

我很担心收到像你这样的答案 :D 对于我提供的代码,这是一个有效的观点和一个完全可行的解决方案。该代码是我尝试实现的代码的“提炼”版本,它有一个游戏世界,其中包含 entities 和成员,例如位置、网格和 hitbox。一些实体是墙壁、敌人等,我选择给他们一个通用的 Hitbox*,但最后我没时间,使用了与你类似的解决方案。理想情况下,hitbox 类型将在运行时分配,我的问题的目的是学习将来这样做,不一定针对这个特定示例。 @Andrei-Info 在这种情况下,一个安全、简单和强大的解决方案将类似于 std::variant&lt;HitBoxTypes...&gt; 与访客。但它会更复杂,因为需要有一个“双重访问者”,因为碰撞函数有两个参数。也可以有一个类型擦除的解决方案,但它只对this 类型很简单。我将尝试添加一些具有类型擦除的解决方案,尽可能少的运行时开销(没有堆分配)【参考方案3】:

交互可以由基类本身管理。像这样的:

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

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

【讨论】:

以上是关于C++:对于这种(多调度)运行时多态性,是不是有更优雅的解决方案?的主要内容,如果未能解决你的问题,请参考以下文章

virtual虚函数

不使用 instanceof 的重载方法的动态调度(运行时多态性)

C/C++编程笔记:C++多态性知识详解

分析多继承下的动态多态。

C++中值语义多态的一种实现方法

[c++]第六章概念题 | 多态