将通用对象存储在容器中然后从容器中获取对象并向下转换对象是否有代码味道?


34

例如,我有一个游戏,其中有一些工具可以提高玩家的能力:

Tool.h

class Tool{
public:
    std::string name;
};

和一些工具:

class Sword : public Tool{
public:
    Sword(){
        this->name="Sword";
    }
    int attack;
};

class Shield : public Tool{
public:
    Shield(){
        this->name="Shield";
    }
    int defense;
};

魔术衣

class MagicCloth : public Tool{
public:
    MagicCloth(){
        this->name="MagicCloth";
    }
    int attack;
    int defense;
};

然后,玩家可能拥有一些攻击工具:

class Player{
public:
    int attack;
    int defense;
    vector<Tool*> tools;
    void attack(){
        //original attack and defense
        int currentAttack=this->attack;
        int currentDefense=this->defense;
        //calculate attack and defense affected by tools
        for(Tool* tool : tools){
            if(tool->name=="Sword"){
                Sword* sword=(Sword*)tool;
                currentAttack+=sword->attack;
            }else if(tool->name=="Shield"){
                Shield* shield=(Shield*)tool;
                currentDefense+=shield->defense;
            }else if(tool->name=="MagicCloth"){
                MagicCloth* magicCloth=(MagicCloth*)tool;
                currentAttack+=magicCloth->attack;
                currentDefense+=magicCloth->shield;
            }
        }
        //some other functions to start attack
    }
};

我认为很难if-else用工具中的虚拟方法替换它,因为每个工具具有不同的属性,并且每个工具都会影响玩家的攻防,因此需要在Player对象内部进行玩家攻防的更新。

但是我对此设计不满意,因为它包含向下转换的内容,且内容冗长if-else。此设计是否需要“校正”?如果是这样,我该怎么做才能纠正它?


4
删除特定子类(和后续下降)的测试的标准OOP技术是在基类中创建一个(在这种情况下可能是两个)虚拟方法来代替if链和强制类型转换。这可以用来完全删除if并将操作委托给子类来实现。您也不必每次添加新的子类时都编辑if语句。
埃里克·埃德特

2
还考虑双重派遣。
蜘蛛鲍里斯(Boris)

为什么不向您的工具类添加一个属性,该属性包含属性类型(即攻击,防御)和为其分配的值的字典。攻击,防御可以列举值。然后,您可以通过枚举常量从工具本身调用值。
user1740075


1
另请参阅“访客”模式。
JDługosz

Answers:


63

是的,这是一种代码气味(在很多情况下)。

我认为很难用工具中的虚拟方法替换if-else

在您的示例中,用虚拟方法替换if / else非常简单:

class Tool{
 public:
   virtual int GetAttack() const=0;
   virtual int GetDefense() const=0;
};

class Sword : public Tool{
    // ...
 public:
   virtual int GetAttack() const {return attack;}
   virtual int GetDefense() const{return 0;}
};

现在您的if区块不再需要,调用者可以像使用它一样

       currentAttack+=tool->GetAttack();
       currentDefense+=tool->GetDefense();

当然,对于更复杂的情况,这样的解决方案并不总是那么明显(尽管如此,但几乎在任何时候都可能)。但是,如果遇到不知道如何使用虚拟方法解决问题的情况,则可以在此处在“程序员”(或者,如果它变成语言或特定于实现,在Stackoverflow上)再次提出一个新问题。


4
或者就此而言,在gamedev.stackexchange.com上
Kromster说支持Monica

7
您甚至Sword在代码库中甚至不需要这种方式的概念。您可以仅从new Tool("sword", swordAttack, swordDefense)JSON文件中获取。
AmazingDreams

7
@AmazingDreams:是正确的(对于我们在此处看到的部分代码),但是我想OP简化了他的问题的实际代码,以专注于他想讨论的方面。
布朗

3
这并没有原始代码更好(嗯,有点)。如果不添加其他方法,则无法创建具有其他属性的任何工具。我认为在这种情况下,应该优先考虑使用组合而不是继承。是的,目前仅存在攻击和防御,但这并不需要保持这种状态。
Polygnome

1
@DocBrown是的,虽然看起来像RPG,角色中的某些统计数据可以通过工具或装备精良的物品进行修改,但确实如此。我将Tool使用所有可能的修饰符制作一个泛型,vector<Tool*>使用从数据文件读取的内容填充一些内容,然后像现在一样循环遍历它们并修改统计信息。当您想要某件物品给予例如10%的攻击额外奖励时,您会遇到麻烦。也许a tool->modify(playerStats)是另一种选择。
AmazingDreams

23

代码的主要问题是,每当引入任何新项目时,不仅需要编写和更新该项目的代码,还必须修改播放器(或在使用该项目的任何地方),这使整个事情成为更复杂。

作为一般经验法则,当您不能依靠常规子类化/继承而必须自己进行转换时,我认为它总是有点可疑。

我可以想到两种可能的方法来使整个事情变得更加灵活:

  • 如其他人所述,将attackdefense成员移至基类,然后将它们初始化为0。这还可以用作检查您是否真的可以摆动该项目进行攻击或使用它来阻止攻击。

  • 创建某种回调/事件系统。有不同的可能方法。

    如何保持简单?

    • 您可以创建像virtual void onEquip(Owner*) {}和这样的基类成员virtual void onUnequip(Owner*) {}
    • 当(取消)装备该物品时,将调用它们的重载并修改统计信息,例如virtual void onEquip(Owner *o) { o->modifyStat("attack", attackValue); }virtual void onUnequip(Owner *o) { o->modifyStat("attack", -attackValue); }
    • 可以以某种动态方式访问统计信息,例如使用短字符串或常量作为键,因此您甚至可以引入新的齿轮特定值或奖金,而不必专门针对玩家或“所有者”。
    • 与仅及时请求攻击/防御值相比,这不仅使整个过程更加动态,而且还为您节省了不必要的呼叫,甚至允许您创建将对角色造成永久影响的项目。

      例如,想象一下一个诅咒的戒指,一旦装备,它就会设置一些隐藏的属性,将您的角色标记为永久被诅咒。


7

尽管@DocBrown给出了很好的答案,但是还远远不够,恕我直言。在开始评估答案之前,您应该评估自己的需求。您真正需要什么?

下面,我将展示两种可能的解决方案,它们针对不同的需求提供不同的优势。

第一个非常简单,并且专门针对您所显示的内容进行了量身定制:

class Tool {
    public:
        std::string name;
        int attack;
        int defense;
}

public void attack() {
    int attack = this->attack;
    int defense = this->defense;
    for (Tool* tool : tools){
        attack += tool->attack;
        defense += tool->defense;
    }
}

这样就可以非常轻松地对工具进行序列化/反序列化(例如,用于保存或联网),并且根本不需要虚拟调度。如果您的代码是您所显示的全部,并且您不希望代码有其他变化,那么您将拥有更多名称,统计信息不同的工具,而且数量不同,那么这就是您的最佳选择。

@DocBrown提供了仍然依赖于虚拟调度的解决方案,如果您以某种方式专用于未显示的代码部分的工具,那么这将是一个优势。但是,如果您确实需要或也想更改其他行为,那么我建议使用以下解决方案:

组成重于继承

如果您以后想要一种可以修改敏捷性的工具怎么办?还是运行速度?在我看来,您似乎在制作RPG。对RPG来说重要的一件事是开放扩展。到目前为止显示的解决方案不提供此功能。Tool每当需要新属性时,您都必须更改类并向其添加新的虚拟方法。

我要展示的第二个解决方案是我在前面的评论中暗示的解决方案-它使用合成而不是继承,并遵循“封闭修改,开放扩展*”的原则。如果您熟悉实体系统的工作方式,则有些事情会看起来很熟悉(我想将组合视为ES的小弟弟)。

请注意,我下面显示的内容在具有运行时类型信息的语言(例如Java或C#)中明显更为优雅。因此,我要显示的C ++代码必须包括一些“簿记”,这对于使合成在这里正常工作是必不可少的。也许具有更多C ++经验的人可以提出更好的方法。

首先,我们再次查看呼叫方。在您的示例中,您作为attack方法内的调用者根本不在乎工具。您关心的是两个属性-攻击点和防御点。您实际上并不在乎这些属性的来源,也不在乎其他属性(例如运行速度,敏捷性)。

首先,我们介绍一个新类

class Component {
    public:
        // we need this, in Java we'd simply use getClass()
        virtual std::string type() const = 0;
};

然后,我们创建我们的前两个组件

class Attack : public Component {
    public:
        std::string type() const override { return std::string("mygame::components::Attack"); }
        int attackValue = 0;
};

class Defense : public Component {
    public:
      std::string type() const override { return std::string("mygame::components::Defense"); }
      int defenseValue = 0;
};

之后,我们使一个工具拥有一组属性,并使其他人可以查询这些属性。

class Tool {
private:
    std::map<std::string, Component*> components;

public:
    /** Adds a component to the tool */
    void addComponent(Component* component) { 
        components[component->type()] = component;
    };
    /** Removes a component from the tool */
    void removeComponent(Component* component) { components.erase(component->type()); };
    /** Return the component with the given type */
    Component* getComponentByType(std::string type) { 
        std::map<std::string, Component*>::iterator it = components.find(type);
        if (it != components.end()) { return it->second; }
        return nullptr;
    };
    /** Check wether a tol has a given component */
    bool hasComponent(std::string type) {
        std::map<std::string, Component*>::iterator it = components.find(type);
        return it != components.end();
    }
};

请注意,在此示例中,我仅支持每种类型只有一个组件-这使事情变得容易。从理论上讲,您还可以允许多个相同类型的组件,但这很快就会变得很丑陋。一个重要方面:Tool现已关闭以进行修改 -我们将再也不会碰到它的源头Tool-而是可以扩展 -我们可以通过修改其他东西并仅将其他组件传递到工具中来扩展工具的行为。

现在,我们需要一种按组件类型检索工具的方法。您仍然可以对工具使用向量,就像在代码示例中一样:

class Player {
    private:
        int attack = 0; 
        int defense = 0;
        int walkSpeed;
    public:
        std::vector<Tool*> tools;
        std::vector<Tool*> getToolsByComponentType(std::string type) {
            std::vector<Tool*> retVal;
            for (Tool* tool : tools) {
                if (tool->hasComponent(type)) { 
                    retVal.push_back(tool); 
                }
            }
            return retVal;
        }

        void doAttack() {
            int attackValue = this->attack;
            int defenseValue = this->defense;

            for (Tool* tool : this->getToolsByComponentType(std::string("mygame::components::Attack"))) {
                Attack* component = (Attack*) tool->getComponentByType(std::string("mygame::components::Attack"));
                attackValue += component->attackValue;
            }
            for (Tool* tool : this->getToolsByComponentType(std::string("mygame::components::Defense"))) {
                Defense* component = (Defense*)tool->getComponentByType(std::string("mygame::components::Defense"));
                defenseValue += component->defenseValue;
            }
            std::cout << "Attack with strength " << attackValue << "! Defend with strenght " << defenseValue << "!";
        }
};

您还可以将其重构为自己的Inventory类,并存储查找表,从而大大简化了按组件类型进行检索的工具,并避免了一次又一次地遍历整个集合。

这种方法有什么优势?在中attack,您将处理具有两个组件的工具-您什么都不关心。

假设您有一种walkTo方法,现在您决定,如果某个工具能够修改步行速度,那是个好主意。没问题!

首先,创建新的Component

class WalkSpeed : public Component {
public:
    std::string type() const override { return std::string("mygame::components::WalkSpeed"); }
    int speedBonus;
};

然后,您只需将此组件的实例添加到要提高唤醒速度的工具中,并更改WalkTo处理刚创建的组件的方法:

void walkTo() {
    int walkSpeed = this->walkSpeed;

    for (Tool* tool : this->getToolsByComponentType(std::string("mygame::components:WalkSpeed"))) {
        WalkSpeed* component = (WalkSpeed*)tool->getComponentByType(std::string("mygame::components::Defense"));
        walkSpeed += component->speedBonus;
        std::cout << "Walk with " << walkSpeed << std::endl;
    }
}

请注意,我们向工具添加了一些行为,而根本没有修改Tools类。

您可以(并且应该)将字符串移动到宏或静态const变量,因此不必一次又一次地键入它。

如果进一步采用这种方法-例如制作可以添加到玩家的Combat组件,并制作一个标记玩家可以参加战斗的组件,那么您也可以摆脱该attack方法,并对其进行处理由组件或在其他地方处理。

使玩家也能够获得组件的好处是,那么,您甚至无需更改玩家即可赋予他不同的行为。在我的示例中,您可以创建一个Movable组件,这样就无需walkTo在播放器上实现该方法即可使他移动。您只需创建组件,将其附加到播放器,然后让其他人对其进行处理。

您可以在此要点中找到完整的示例:https : //gist.github.com/NetzwergX/3a29e1b106c6bb9c7308e89dd715ee20

与已经发布的其他解决方案相比,该解决方案显然要复杂一些。但是,取决于您要灵活多变,想走多远,这可能是一种非常有效的方法。

编辑

其他一些答案建议直接继承(使剑扩展工具,使Shield扩展工具)。我认为在这种情况下继承不能很好地工作。如果您决定以某种方式用盾牌挡住也会损害攻击者怎么办?使用我的解决方案,您可以简单地将一个Attack组件添加到防护罩中,而无需更改代码即可实现这一点。使用继承,您会遇到问题。RPG中的项目/工具从一开始就是构成实体乃至直接使用实体系统的首选。


1

一般而言,如果您需要使用if任何OOP语言(结合要求实例的类型),则表明存在某种臭味。至少,您应该仔细查看模型。

我会以不同的方式为您的域建模。

在您的用例中,Tool有一个AttackBonus和一个DefenseBonus-可能会0万一它对于像羽毛之类的战斗毫无用处。

对于攻击,您使用的武器为baserate+ bonus。防御baserate+也是如此bonus

因此,您Tool必须具有一种virtual计算攻击/防御波尼的方法。

tl; dr

使用更好的设计,可以避免hacky if


有时,如果有必要,例如在比较标量值时。对于对象类型切换,不需要那么多。
安迪

哈哈,如果是一个非常重要的运算符,您不能仅仅说使用是一种代码味道。
tymtam'6

1
@Tymski在某种程度上你是对的。我让自己更清楚了。我不捍卫if更少的编程。通常以类似if instanceof或类似的方式组合。但是有一个位置,它声称ifs是一个代码,并且有多种方法可以解决它。您是对的,那是一个拥有自己权利的重要运营商。
Thomas Junk

1

如所写,它“闻起来”,但这可能仅仅是您所举的例子。将数据存储在通用对象容器中,然后进行强制转换以获取对数据的访问不会自动编码气味。您会看到它在许多情况下都使用过。但是,在使用它时,您应该知道自己在做什么,如何做以及为什么。当我看示例时,使用基于字符串的比较来告诉我什么对象是使我的个人气味计绊倒的东西。这表明您不确定自己在这里做什么(这很好,因为您有足够的智慧来向程序员咨询。SE并说:“嘿,我不认为我喜欢我在做什么,请帮助我出去!”)。

像这样从通用容器中投射数据的模式的根本问题在于,数据的生产者和数据的消费者必须一起工作,但是乍一看,这样做似乎并不明显。 在这种模式的每个示例中,无论有臭还是无臭,这都是根本问题。这是非常有可能在未来开发者完全不知道自己在做这种模式并造成破坏,所以如果你使用这个模式,你必须小心,以帮助在未来开发出来。由于他可能不知道存在的某些细节,您必须使他更容易避免无意间破坏代码。

例如,如果我想复制播放器怎么办?如果我只看播放器对象的内容,它看起来很简单。我只是复制attackdefensetools变量。非常简单!好吧,我很快就会发现您对指针的使用使它变得更难一点(在某些时候,值得研究智能指针,但这是另一个主题)。这很容易解决。我将为每个工具创建新的副本,并将它们放在新tools列表中。毕竟,这Tool是一个非常简单的类,只有一个成员。因此,我创建了一堆副本,包括的副本Sword,但我不知道这是一把剑,所以我只复制了name。后来,该attack()函数查看名称,发现它是“剑”,将其强制转换,然后发生坏事!

我们可以将此情况与使用相同模式的套接字编程中的另一种情况进行比较。我可以这样设置UNIX套接字函数:

int sockfd = socket(AF_INET, SOCK_STREAM, 0);
sockaddr_in serv_addr;
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(portno);
serv_addr.sin_addr.s_addr = INADDR_ANY;
bind(sockfd, (struct sockaddr *) &serv_addr, sizeof(serv_addr));

为什么这是相同的模式?因为bind不接受a sockaddr_in*,所以接受更通用的sockaddr*。如果您查看这些类的定义,我们将看到sockaddr我们分配给sin_family* 的族只有一个成员。家人说您应该将哪个子类型转换sockaddr为。 AF_INET告诉您地址结构实际上是一个sockaddr_in。如果为AF_INET6,则地址将为sockaddr_in6,其中具有更大的字段以支持更大的IPv6地址。

这与您的Tool示例相同,除了它使用整数指定哪个系列而不是std::string。但是,我要声明它没有气味,并尝试这样做的原因不是“它是一种标准的套接字制作方法,所以它不应该'闻'。”显然,它是相同的模式,即为什么我声称将数据存储在通用对象中并进行强制转换并不是自动编写代码气味,但是为什么这样做会有所不同,从而使其更加安全。

使用此模式时,最重要的信息是捕获有关子类的信息从生产者到消费者的传递。这是您对name字段进行的操作,而UNIX套接字对其sin_family字段进行处理。该字段是消费者需要了解生产者实际创造的信息。在这种模式的所有情况下,它都应该是一个枚举(或者至少是一个像枚举一样起作用的整数)。为什么?考虑一下您的消费者将如何处理这些信息。他们将需要写一些重要的if声明或switch就像您所做的那样,在语句中确定正确的子类型,然后将其转换并使用数据。根据定义,这些类型只能是少数。您可以像以前一样将其存储在字符串中,但这有很多缺点:

  • 慢- std::string通常必须做一些动态内存来保留字符串。每当您想弄清楚您拥有的子类时,还必须进行全文比较以匹配名称。
  • 过于通用-当您做极端危险的事情时,要对自己施加约束是有话要说的。我曾经有过这样的系统,它们寻找一个子字符串来告诉它所寻找的对象类型。在对象的名称意外包含该子字符串之前,此方法非常有效,并产生了极为隐秘的错误。由于如上所述,我们只需要少数情况,因此没有理由使用像字符串这样的功能强大的工具。这将导致...
  • 容易出错-假设您想进行一次致命的横冲直撞,尝试调试当一个消费者不小心将魔术布的名称设置为时,为什么事情不起作用的原因MagicC1oth。认真地讲,这样的错误可能要花费数天的时间才能让您意识到发生了什么。

枚举效果更好。它速度快,价格便宜,并且更容易出错:

class Tool {
public:
    enum TypeE {
        kSword,
        kShield,
        kMagicCloth
    };
    TypeE type;

    std::string typeName() const {
        switch(type) {
            case kSword:      return "Sword";
            case kSheild:     return "Sheild";
            case kMagicCloth: return "Magic Cloth";

            default:
                throw std::runtime_error("Invalid enum!");
        }
   }
};

此示例还展示了switch涉及枚举的语句,该语句具有该模式最重要的部分:default抛出一个案例。你应该从来没有在这种情况下得到,如果你做的事情非常完美。但是,如果有人添加了新的工具类型,而您却忘记了更新代码以支持它,那么您将需要一些能够捕获该错误的信息。实际上,我非常推荐它们,即使您不需要它们也应添加它们。

的另一个巨大优势enum是,它可以直接向下一个开发人员提供有效工具类型的完整列表。无需去遍历代码即可找到鲍勃在史诗般的上司大战中使用的专门的长笛类。

void damageWargear(Tool* tool)
{
    switch(tool->type)
    {
        case Tool::kSword:
            static_cast<Sword*>(tool)->damageSword();
            break;
        case Tool::kShield:
            static_cast<Sword*>(tool)->damageShield();
            break;
        default:
            break; // Ignore all other objects
    }
}

是的,我输入了一个“空”默认语句,目的是让下一个开发人员明确知道如果我遇到了一些新的意外类型,我期望发生什么。

如果执行此操作,则图案的气味会减少。但是,要实现无臭味,您需要做的最后一件事就是考虑其他选择。这些强制转换是C ++指令库中一些更强大,更危险的工具。除非有充分的理由,否则不应使用它们。

一种非常流行的替代方法是我所谓的“工会结构”或“工会班级”。对于您的示例,这实际上非常合适。要创建其中的一个,您可以创建一个Tool类,该类的枚举类似于之前,但是Tool我们没有将其子类化,而是将每个子类型的所有字段都放在了该类上。

class Tool {
    public:
        enum TypeE {
            kSword,
            kShield,
            kMagicCloth
        };
    TypeE type;

    int   attack;
    int   defense;
};

现在,您根本不需要子类。您只需要查看该type字段即可查看其他实际有效的字段。这更安全,更容易理解。但是,它有缺点。有时您不想使用此功能:

  • 当对象太相似时-您可能会得到一个洗衣清单字段,并且不清楚哪些对象适用于每种对象类型。
  • 在内存紧张的情况下运行时-如果需要制作10个工具,那么您可能会懒于内存。当您需要制作5亿个工具时,您将开始关心比特和字节。联合结构总是比需要的大。

UNIX套接字不使用该解决方案,这是因为API的开放性加剧了差异性。UNIX套接字的目的是创建各种UNIX风格都可以使用的东西。每种口味都可以定义他们支持的家庭的列表,例如AF_INET,并且每个列表都有一个简短列表。但是,如果出现了新的协议(例如这样AF_INET6做),则可能需要添加新字段。如果使用并集结构执行此操作,最终将有效地创建具有相同名称的结构新版本,从而产生无休止的不兼容问题。这就是UNIX套接字选择使用强制转换模式而不是并集结构的原因。我敢肯定,他们考虑过了,而他们想到的事实就是为什么使用时它不会闻起来的一部分。

您也可以将并集用于真实。工会仅与最大的会员一样大,从而节省了内存,但是工会也有自己的问题。这可能不是代码的选项,但它始终是您应该考虑的选项。

另一个有趣的解决方案是boost::variantBoost是一个很棒的库,里面充满了可重用的跨平台解决方案。它可能是有史以来最好的C ++代码。 Boost.Variant基本上是联合的C ++版本。它是一个容器,可以包含许多不同的类型,但一次只能包含一个。您可以将您的SwordShieldMagicCloth类设为make,然后将tool设为boost::variant<Sword, Shield, MagicCloth>,这意味着它包含这三种类型之一。将来仍然存在相同的问题,即将来的兼容性会阻止UNIX套接字使用它(更不用说UNIX套接字是C,早于boost相当多!),但是这种模式非常有用。变体经常用在例如解析树中,这些树接受一串文本并使用语法规则将其分解。

我建议在尝试并使用通用对象转换方法之前要考虑的最终解决方案是Visitor设计模式。访客是一种强大的设计模式,它充分利用了以下观点:调用虚拟函数可以有效地完成您所需的转换,并且可以为您完成。因为编译器会这样做,所以它永远不会出错。因此,Visitor使用抽象的基类,而不是存储枚举,该基类具有一个vtable,该vtable知道对象是什么类型。然后,我们创建一个简洁的双向调用来完成工作:

class Tool;
class Sword;
class Shield;
class MagicCloth;

class ToolVisitor {
public:
    virtual void visit(Sword* sword) = 0;
    virtual void visit(Shield* shield) = 0;
    virtual void visit(MagicCloth* cloth) = 0;
};

class Tool {
public:
    virtual void accept(ToolVisitor& visitor) = 0;
};

lass Sword : public Tool{
public:
    virtual void accept(ToolVisitor& visitor) { visitor.visit(*this); }
    int attack;
};
class Shield : public Tool{
public:
    virtual void accept(ToolVisitor& visitor) { visitor.visit(*this); }
    int defense;
};
class MagicCloth : public Tool{
public:
    virtual void accept(ToolVisitor& visitor) { visitor.visit(*this); }
    int attack;
    int defense;
};

那么,这神的敬畏模式是什么?好吧,Tool具有虚函数accept。如果您将其传递给访问者,则它会掉头并visit对该类型的访问者调用正确的函数。这就是visitor.visit(*this);每个子类型的作用。很复杂,但是我们可以在上面的示例中显示:

class AttackVisitor : public ToolVisitor
{
public:
    int& currentAttack;
    int& currentDefense;

    AttackVisitor(int& currentAttack_, int& currentDefense_)
    : currentAttack(currentAttack_)
    , currentDefense(currentDefense_)
    { }

    virtual void visit(Sword* sword)
    {
        currentAttack += sword->attack;
    }

    virtual void visit(Shield* shield)
    {
        currentDefense += shield->defense;
    }

    virtual void visit(MagicCloth* cloth)
    {
        currentAttack += cloth->attack;
        currentDefense += cloth->defense;
    }
};

void Player::attack()
{
    int currentAttack = this->attack;
    int currentDefense = this->defense;
    AttackVisitor v(currentAttack, currentDefense);
    for (Tool* t: tools) {
        t->accept(v);
    }
    //some other functions to start attack
}

那么这里发生了什么?我们创建了一个访客,一旦知道要访问的对象类型,它将为我们做一些工作。然后,我们遍历工具列表。为了论证,假设第一个对象是Shield,但是我们的代码尚不知道。它调用t->accept(v),一个虚函数。由于第一个对象是盾牌,因此最终会调用void Shield::accept(ToolVisitor& visitor),然后调用visitor.visit(*this);。现在,当我们仰望它visit打电话,我们已经知道,我们有一个盾(因为这个功能得到了所谓的),所以我们将最终调用void ToolVisitor::visit(Shield* shield)我们的AttackVisitor。现在,它将运行正确的代码以更新我们的防御。

访客很庞大。它是如此笨拙,以至于我几乎认为它有自己的味道。编写错误的访客模式非常容易。但是,它具有一个巨大的优势,而其他都不是。如果添加新的工具类型,则必须为其添加新的ToolVisitor::visit功能。一旦执行此操作,程序中的每个 ToolVisitor程序都会因为缺少虚函数而拒绝编译。这样可以很容易地发现我们错过任何情况的所有情况。如果使用ifswitch语句来完成工作,要保证这一点要困难得多。这些优势足够好,以至于Visitor在3D图形场景生成器中找到了一个不错的小众市场。他们恰好需要访客提供的行为,因此效果很好!

总而言之,请记住,这些模式使下一个开发人员难以承受。花时间让他们更轻松,代码也不会闻起来!

*从技术上讲,如果您查看规格,sockaddr有一个名为的成员sa_family。在C级别上有一些棘手的事情对我们来说无关紧要。欢迎您来看看实际实现,但是对于这个答案,我将使用它sa_family sin_family和其他答案完全互换,使用散文中最直观的一种,并相信C技巧可以处理不重要的细节。


在您的榜样中,连续进攻将使玩家无限强大。而且,如果不修改ToolVisitor的来源,就无法扩展您的方法。不过,这是一个很好的解决方案。
Polygnome

@Polygnome您对这个例子是正确的。我以为代码看起来很奇怪,但是滚动浏览所有这些文本页面却错过了错误。立即修复。关于修改ToolVisitor的源代码的要求,这是Visitor模式的设计特征。这是一种祝福(如我所写)和一个诅咒(如你所写)。处理想要任意扩展版本的情况要困难得多,并且开始挖掘变量的含义,而不仅仅是变量的值,并开辟其他模式,例如弱类型变量和字典以及JSON。
Cort Ammon

1
是的,遗憾的是,我们对OP的偏好和目标了解不足,无法做出明智的决定。是的,很难实现完全灵活的解决方案,因为我的C ++相当生锈,所以我花了将近3个小时来回答我的问题:(
Polygnome

0

通常,如果仅用于传递数据,则避免实现几个类/继承。您可以坚持一个单一的类,然后从那里实现所有内容。对于您的示例,这足够了

class Tool{
    public:
    //constructor, name etc.
    int GetAttack() { return attack }; //Endpoints for your Player
    int GetDefense() { return defense };
    protected:
         int attack;
         int defense;
};

可能您已经预计您的游戏将实现多种剑法等,但是您将拥有其他实现方法。类爆炸很少是最好的架构。把事情简单化。


0

如前所述,这是一个严重的代码气味。但是,您可能会认为问题的根源是在设计中使用继承而不是合成。

例如,给定您给我们看的内容,您显然有3个概念:

  • 项目
  • 可以攻击的物品。
  • 可以具有防御性的物品。

请注意,您的第四堂课只是后两个概念的结合。因此,我建议为此使用合成。

您需要一个数据结构来表示攻击所需的信息。您需要一个数据结构,该结构代表您需要防御的信息。最后,您需要一个数据结构来表示可能具有或不具有这两个属性之一或两者的事物:

class Attack
{
private:
  int attack_;

public:
  int AttackValue() const;
};

class Defense
{
private:
  int defense_

public:
  int DefenseValue() const;
};

class Tool
{
private:
  std::optional<Attack> atk_;
  std::optional<Defense> def_;

public:
  const std::optional<Attack> &GetAttack() const {return atk_;}
  const std::optional<Defense> &GetDefense() const {return def_;}
};

另外:不要使用总是写的方法:)!为什么在这种情况下使用合成?我同意这是一种替代解决方案,但是在这种情况下,创建一个用于“封装”字段(请注意“”)的类似乎很奇怪……
AilurusFulgens

@AilurusFulgens:今天是“领域”。明天会是什么?这种设计允许Attack并且Defense变得更复杂,而无需更改的接口Tool
Nicol Bolas

1
您仍然不能很好地扩展此工具-当然,攻击和防御可能会变得更加复杂,仅此而已。如果您完全使用合成,则可以Tool完全关闭以进行修改,同时仍然可以打开以进行扩展。
Polygnome

@Polygnome:如果您想为像这样的小问题解决整个任意组件系统的麻烦,这取决于您。我个人认为没有理由Tool不进行修改就可以扩展。如果我有权对其进行修改,那么我认为不需要任意组件。
Nicol Bolas

只要工具受您自己控制,就可以对其进行修改。但是“出于修改目的而封闭,要扩展则开放”的原则存在是有充分的理由的(在此不多阐述)。不过,我认为这不是那么简单。如果您花费适当的时间为RPG规划一个灵活的组件系统,从长远来看,您将获得巨大的回报。与仅使用纯字段相比,我没有看到这种组合形式带来的额外好处。能够进一步专门攻防似乎是一个非常理论上的情况。但正如我所写,这取决于OP的确切要求。
Polygnome

0

为什么不创建抽象方法modifyAttack,并modifyDefenseTool上课吗?然后每个孩子都有自己的实现,您可以用这种优雅的方式来称呼它:

for(Tool* tool : tools){
    currentAttack = tool->recalculateAttack(currentAttack);
    currentDefense = tool->recalculateDefense(currentDefense);
}
// proceed with new values for currentAttack and currentDefense

如果可以,将值作为参考传递将节省资源:

for(Tool* tool : tools){
    tool->recalculateAttack(&currentAttack);
    tool->recalculateDefense(&currentDefense);
}
// proceed with new values for currentAttack and currentDefense

0

如果使用多态性,那么最好是所有关心使用哪个类的代码都在类本身内部。这就是我的编码方式:

class Tool{
 public:
   virtual void equipTo(Player* player) =0;
   virtual void unequipFrom(Player* player) =0;
};

class Sword : public Tool{
  public:
    int attack;
    virtual void equipTo(Player* player) {
      player->attackBonus+=this->attack;
    };
    //unequipFrom = reverse equip
};
class Shield : public Tool{
  public:
    int defense;
    virtual void equipTo(Player* player) {
      player->defenseBonus+=this->defense;
    };
    //unequipFrom = reverse equip
};
//other tools
class Player{
  public:
    int baseAttack;
    int baseDefense;
    int attackBonus;
    int defenseBonus;

    virtual void equip(Tool* tool) {
      tool->equipTo(this);
      this->tools.push_back(tool)
    };

    //unequip = reverse equip

    void attack(){
      //modified attack and defense
      int modifiedAttack = baseAttack + this->attackBonus;
      int modifiedDefense = baseDefense+ this->defenseBonus;
      //some other functions to start attack
    }
  private:
    vector<Tool*> tools;
};

这具有以下优点:

  • 轻松添加新类:您只需实现所有抽象方法,其余代码即可正常工作
  • 更容易删除类
  • 更容易添加新的统计信息(不关心该统计信息的类只会忽略它)

您至少还应该包括一个unequip()方法,该方法将从玩家身上删除奖金。
Polygnome

0

我认为认识到这种方法的缺陷的一种方法是将您的想法发展为逻辑上的结论。

这看起来像是一个游戏,因此在某个阶段,您可能会开始担心性能,并将那些字符串比较替换为intor enum。随着项目列表变长,if-else开始变得笨拙,因此您可以考虑将其重构为switch-case。在这一点上,您还可以看到很多文字,因此可以将每个动作case分解为一个单独的函数。

一旦达到这一点,您的代码结构便开始变得熟悉-它开始看起来像一个自制的手工vtable *-通常在其上实现虚拟方法的基本结构。除此之外,这是一个vtable,您每次添加或修改项目类型时必须手动更新和维护自己。

通过坚持使用“真实的”虚拟功能,您可以将每个项目行为的实现保持在项目本身之内。您可以以更独立且一致的方式添加其他项目。在完成所有这些操作后,将由编译器而不是您来负责动态调度的实现。

解决特定问题的方法:您正在努力编写一对简单的虚拟函数来更新攻击和防御,因为某些项目仅影响攻击,而某些项目仅影响防御。在这种简单情况下的技巧无论如何都可以实现两种行为,但在某些情况下无效。GetDefenseBonus()可能会返回0ApplyDefenseBonus(int& defence)可能defence保持不变。如何进行取决于您希望如何处理其他确实起作用的动作。在更复杂的情况下,如果有更多不同的行为,则可以将活动简单地合并为一个方法。

*(尽管,相对于典型实现已转置)


0

拥有知道所有可能的“工具”的代码块并不是很好的设计(特别是因为您最终在代码中会得到许多这样的块);但都没有基本Tool对于所有可能的工具属性,都带有存根:现在,Tool该类必须知道所有可能的用途。

什么每个工具知道是什么它有助于使用它的字符。因此,为所有工具提供一种方法giveto(*Character owner)。它将在不知道其他工具可以做什么的情况下适当地调整玩家的属性,并且最好的是,它也不需要知道角色的无关属性。例如,屏蔽甚至不需要知道的属性attackinvisibilityhealth等应用的工具是字符支持的属性的对象,需要真实需要的全部。如果您尝试给驴子一把剑,而驴子没有任何attack统计信息,则会出现错误。

这些工具还应该有一种remove()方法,可以扭转它们对所有者的影响。这有点棘手(最终可能会产生一些工具,这些工具在给出然后取走时会留下非零的效果),但至少它已针对每个工具进行了本地化。


-4

没有答案说它没有气味,所以我将成为代表该观点的人。此代码是完全可以的!我的观点是基于这样的事实,即有时候您会更轻松地前进,并随着创建更多新内容而逐渐提高您的技能。您可能需要花费数天的时间才能制作出完美的架构,但可能没人能看到它的实际效果,因为您从未完成过该项目。干杯!


4
当然,通过个人经验来提高技能是很好的。但是,通过询问已经具有个人经验的人来提高技能,这样您就不必自己陷入困境,这是更明智的选择。当然这就是人们首先在这里提问的原因,不是吗?
Graham

我不同意 但是我知道这个网站的目的是要深入。有时,这意味着过度学究。这就是为什么我要发表此观点的原因,因为它扎根于现实,并且如果您正在寻找使自己变得更好的技巧并为初学者提供帮助,那么您会错过关于“足够好”这一整章的知识,这对于初学者来说非常有用。
Ostmeistro
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.