Це запах коду, щоб зберігати загальні об'єкти в контейнері, а потім отримувати об'єкт і знищувати об'єкти з контейнера?


34

Наприклад, у мене є гра, в якій є деякі інструменти для підвищення можливостей програвача:

Tool.h

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

І деякі інструменти:

Меч.h

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

Щит.h

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

MagicCloth.h

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
Також врахуйте подвійну відправлення.
Борис Павук

Чому б не додати властивість до класу Tool, який містить словник типів атрибутів (тобто атака, захист) та присвоєне йому значення. Атака, захист могли бути перераховані. Тоді ви можете просто викликати значення з самого інструмента за допомогою перерахованої константи.
користувач1740075

8
Я просто залиште це тут: ericlippert.com/2015/04/27/wizards-and-warriors-part-one
Ви

1
Також див. Шаблон відвідувачів.
JDługosz

Відповіді:


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

7
Вам навіть не знадобиться така концепція Swordу вашій базі коду. Ви можете просто new Tool("sword", swordAttack, swordDefense)з, наприклад, файлу JSON.
AmazingDreams

7
@AmazingDreams: це правильно (для частин коду, який ми бачимо тут), але я думаю, що ОП спростила його реальний код для свого питання, щоб зосередити увагу на аспекті, який він хотів обговорити.
Doc Brown

3
Це не так вже й краще, ніж оригінальний код (ну, це трохи). Будь-який інструмент, який має додаткові властивості, неможливо створити без додавання додаткових методів. Я думаю, що в цьому випадку слід віддати перевагу складу над спадщиною. Так, наразі існує лише атака та захист, але це не потрібно залишатися таким.
Полігном

1
@DocBrown Так, це правда, хоча це схоже на RPG, де персонаж має деякі статистичні дані, які модифікуються інструментами або досить обладнаними елементами. Я б створив загальний характер Toolіз усіма можливими модифікаторами, заповнив деякі vector<Tool*>матеріали, прочитані з файлу даних, а потім просто переведіть на них і змініть статистику, як ви робите зараз. Ви потрапите в біду, коли хочете, щоб предмет дав, наприклад, 10% бонус за атаку. Можливо, a tool->modify(playerStats)- це інший варіант.
AmazingDreams

23

Основна проблема вашого коду полягає в тому, що щоразу, коли ви вводите будь-який новий елемент, вам не тільки потрібно писати та оновлювати код товару, ви також повинні змінювати програвач (або там, де цей предмет використовується), що робить всю справу набагато складніше.

Як загальне правило, я думаю, що це завжди своєрідно, коли ви не можете розраховувати на нормальне підкласифікацію / успадкування і доводиться самостійно робити оновлення.

Я міг би подумати про два можливі підходи, які роблять все це більш гнучким:

  • Як згадували інші, перемістіть attackі defenseучасників до базового класу та просто ініціалізуйте їх 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;
    }
}

Зауважте, що ми додали деяку поведінку до наших інструментів, не змінюючи клас Інструменти взагалі.

Ви можете (і повинні) переміщувати рядки до макро- чи статичної змінної const, тому не потрібно вводити її знову і знову.

Якщо ви скористаєтесь цим підходом далі - наприклад, зробіть компоненти, які можна додати до гравця, і зробіть Combatкомпонент, який позначає гравця як можливість брати участь у бою, тоді ви можете позбутися і attackметоду, і дозволити цьому керувати Компонента або обробляються в іншому місці.

Перевага в тому, щоб гравець також міг отримати Компоненти, полягав би в тому, що тоді вам навіть не потрібно буде змінювати програвача, щоб надати йому іншу поведінку. У моєму прикладі ви можете створити Movableкомпонент, тому вам не потрібно реалізовувати walkToметод на програвачі, щоб змусити його рухатися. Ви просто створіть компонент, приєднайте його до програвача та дозвольте комусь ще обробити його.

Ви можете знайти повний приклад у цій суті: https://gist.github.com/NetzwergX/3a29e1b106c6bb9c7308e89dd715ee20

Це рішення, очевидно, трохи складніше, ніж інші, які були розміщені. Але залежно від того, наскільки ви хочете бути гнучким, наскільки далеко ви хочете це зробити, це може бути дуже потужним підходом.

Редагувати

Деякі інші відповіді пропонують успадкування (Створення мечів розширити інструмент, зробити щит розширити інструмент). Я не думаю, що це сценарій, коли спадкування працює дуже добре. Що робити, якщо ви вирішите, що блокування щитом певним чином також може пошкодити нападника? За допомогою мого рішення ви могли просто додати компонент Attack до щита і зрозуміти, що без змін у вашому коді. З спадщиною у вас виникнуть проблеми. Предмети / Інструменти в RPG - це головні кандидати на композицію або навіть прямо з використанням сутнісних систем з самого початку.


1

Взагалі кажучи, якщо у вас коли-небудь виникає потреба використовувати if(у поєднанні з вимогою типу примірника) будь-якою мовою OOP, це знак того, що щось смердюче відбувається. Принаймні, ви повинні більш уважно ознайомитися зі своїми моделями.

Я б моделював ваш Домен по-іншому.

Для вашої фурнітури a Toolє AttackBonusі DefenseBonus- що може бути і 0в тому випадку, якщо воно марне для боротьби, як пір’я чи щось подібне.

Для нападу ви маєте свій baserate+ bonusвід використовуваної зброї. Те саме стосується захисту baserate+ bonus.

Як наслідок, у вас Toolповинен бути virtualметод обчислення боні атаки / оборони.

тл; д-р

Завдяки кращому дизайну, ви могли б уникнути хакі if.


Іноді, якщо це необхідно, наприклад, при порівнянні скалярних значень. Для перемикання типів об'єктів не так багато.
Енді

Ха-ха, якщо це досить важливий оператор, і ви не можете просто сказати, що використання - це кодовий запах.
tymtam

1
@Tymski з певної поваги ви праві. Я зрозумів себе більш чітко. Я не захищаю ifменше програмування. Переважно в комбінаціях, як-от якщо- instanceofнебудь подібне. Але є позиція, яка стверджує, що ifs - це кодмелл, і є способи її обійти. І ви праві, це важливий оператор, який має своє право.
Томас Юнк

1

Як написано, це "пахне", але це можуть бути лише приклади, які ви навели. Зберігання даних у універсальних об'єктових контейнерах, а потім їх передавання для отримання доступу до даних не автоматично пахне кодом. Ви побачите, що він використовується в багатьох ситуаціях. Однак, використовуючи його, ви повинні знати про те, що ви робите, як це робите і чому. Коли я дивлюся на приклад, використання струнних порівнянь, щоб сказати мені, що є предметом, що є тим, що відключає мій особистий лічильник запаху. Це говорить про те, що ти не зовсім впевнений, що ти тут робиш (це прекрасно, оскільки ти мав мудрість прийти сюди до програмістів. СЕ і сказати "ей, я не думаю, що мені подобається те, що я роблю, допоможи мене виходять! »).

Принциповим питанням, що стосується схеми передачі даних із загальних контейнерів, як це, є те, що виробник даних та споживач цих даних повинні працювати разом, але може бути не очевидно, що вони роблять це на перший погляд. У кожному прикладі цієї закономірності, смердючої чи не смердючої, це основне питання. Це дуже можливо для наступного розробника , щоб бути абсолютно не знають , що ви робите цей шаблон , і розірвати його випадково, тому , якщо ви використовуєте цей шаблон ви повинні подбати , щоб допомогти наступному відмови розробників. Ви повинні полегшити йому ненавмисне порушення коду через деякі деталі, які він може не знати, що вони існували.

Наприклад, що робити, якщо я хотів скопіювати програвач? Якщо я просто дивлюся на вміст об’єкта програвача, це виглядає досить легко. Мені просто потрібно скопіювати attack, defenseі toolsзмінні. Легкий як пиріг! Ну, я швидко з’ясую, що використання ваших покажчиків робить його трохи складніше (в якийсь момент варто переглянути розумні покажчики, але це вже інша тема). Це легко вирішити. Я просто створять нові копії кожного інструменту і занесу їх у свій новий 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він не приймає sockaddr_in*, він приймає більш загальне sockaddr*. Якщо ви подивитеся на визначення для цих класів, то ми бачимо, що sockaddrв сім'ї, яку ми призначили sin_family* , є лише один член . Сім'я каже, до якого підтипу слід віднести sockaddr. AF_INETговорить вам, що структура адреси насправді є sockaddr_in. Якби це була AF_INET6, адреса була б a 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 інструментів, ви можете лінуватися пам'яттю. Коли вам потрібно зробити 500 мільйонів інструментів, ви почнете дбати про біти та байти. Структури союзу завжди більші, ніж повинні бути.

Це рішення не використовується UNIX-сокетами через проблему несхожості, що ускладнюється відкритою кінцевістю API. Завданням UNIX-розеток було створити те, з чим міг би працювати кожен аромат UNIX. Кожен аромат міг би визначити список сімей, які вони підтримують, начебто AF_INET, і для кожного буде короткий список. Однак якщо з'явиться новий протокол, як AF_INET6це було, можливо, вам доведеться додати нові поля. Якщо ви зробили це з об'єднавчою структурою, ви б ефективно створили нову версію структури з тим самим іменем, створюючи нескінченні проблеми несумісності. Ось чому сокети UNIX вирішили скористатися схемою кастингу, а не структурою об'єднання. Я впевнений, що вони вважали це, і те, що вони думали про це, є частиною того, чому він не пахне, коли ним користуються.

Ви також можете використати союз для реального. Профспілки економлять пам’ять, будучи лише таким же великим, як найбільший член, але вони мають свій набір питань. Це, ймовірно, не є варіантом для вашого коду, але це завжди варіант, який слід розглянути.

Ще одне цікаве рішення boost::variant. Boost - це чудова бібліотека, повна багаторазових кросплатформних рішень. Це, мабуть, один з найкращих кодів C ++, коли-небудь написаних. Boost.Variant - це в основному версія спілок C ++. Це контейнер, який може містити багато різних типів, але лише один за одним. Ви можете зробити свої Sword, Shieldі MagicClothкласи, а потім зробити інструмент "a" boost::variant<Sword, Shield, MagicCloth>, тобто він містить один із цих трьох типів. Це все ще страждає від тієї ж проблеми з майбутнім сумісністю, яка не дозволяє UNIX-сокетам використовувати його (не кажучи вже про UNIX-сокети C, що передуєboostзовсім небагато!), але цей візерунок може бути неймовірно корисним. Варіант часто використовується, наприклад, у деревах розбору, які беруть рядок тексту та розбивають його, використовуючи граматику для правил.

Остаточне рішення, яке я рекомендую переглянути, перш ніж зануритися і скористатися загальним підходом до кастингу об'єктів, є схема дизайну Visitor . Відвідувач - це потужна модель дизайну, яка користується перевагою зауваження, що виклик віртуальної функції ефективно робить потрібне вам кастинг, і це робить це за вас. Оскільки компілятор це робить, він ніколи не може помилитися. Таким чином, замість того, щоб зберігати перерахунок, Visitor використовує абстрактний базовий клас, у якому є 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 в програмі відмовиться від компіляції, оскільки в ньому відсутня віртуальна функція. Це дозволяє дуже легко зловити всі випадки, коли ми щось пропустили. Набагато складніше гарантувати це, якщо ви користуєтесьif або switchзаяви, щоб виконати роботу. Ці переваги досить хороші тим, що відвідувач знайшов непогану нішу в 3D-генераторах графічних сцен. Вони, як правило, потребують саме такої поведінки, яку пропонує відвідувач, тому це чудово працює!

Загалом, пам’ятайте, що ці зразки ускладнюють роботу наступного розробника. Витратьте час, полегшивши їх, і код не пахне!

* Технічно, якщо ви подивитеся на специфікацію, у sockaddr є один член на ім'я sa_family. Тут проводяться якісь хитрощі на рівні С, які для нас не мають значення. Ви можете ознайомитись із реальною реалізацією, але для цієї відповіді я буду користуватися sa_family sin_familyта іншими людьми повністю взаємозамінно, використовуючи той, який є найбільш інтуїтивним для прози, довіряючи, що ця хитрість C піклується про неважливі деталі.


Атака послідовно зробить гравця нескінченно сильним у вашому прикладі. І ви не можете розширити свій підхід без зміни джерела ToolVisitor. Хоча це чудове рішення.
Полігном

@ Polygnome Ви маєте рацію щодо прикладу. Я думав, що код виглядає дивним, але прокручуючи всі ці сторінки тексту, я пропустив помилку. Виправити це зараз. Що стосується вимоги зміни джерела ToolVisitor, то це дизайн, характерний для шаблону Visitor. Це благо (як я писав) і прокляття (як ви написали). Робота з випадком, коли ви хочете довільно розширювану версію цього набагато складніше, і починає копати значення змінних, а не лише їх значення, і відкриває інші шаблони, такі як слабо типізовані змінні та словники та JSON.
Корт Аммон - Відновіть Моніку

1
Так, на жаль, ми не знаємо достатньо про переваги та цілі ОП, щоб прийняти дійсно обгрунтоване рішення. І так, повністю гнучке рішення важче реалізувати, я працював над своєю відповіддю майже 3 години, оскільки мій C ++ досить іржавий :(
Полігном

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.
Ніколь Болас

1
Ви все ще не можете дуже добре розширити інструмент з цим - звичайно, атака та оборона можуть стати складнішими, але це все. Якщо ви використовуєте композицію на повну потужність, ви можете зробити її Toolповністю закритою для модифікації, залишаючи її відкритою для розширення.
Полігном

@ Polygnome: Якщо ви хочете зіткнутися з проблемою створення цілої довільної компонентної системи для подібного тривіального випадку, це вирішувати вам. Я особисто не бачу причин, чому я хотів би продовжити, Toolне змінюючи його. І якщо я маю право його змінювати, то я не бачу потреби в довільних компонентах.
Ніколь Болас

Поки Інструмент знаходиться під вашим контролем, ви можете його змінювати. Але принцип "закритий для модифікації, відкритий для розширення" існує з поважних причин (тут занадто довго для розробки). Я не думаю, що це так банально. Якщо ви витратите належний час на планування гнучкої компонентної системи для RPG, ви отримаєте величезні нагороди в довгостроковій перспективі. Я не бачу додаткової переваги в такому типі композиції, ніж просто використання простих полів. Вміння спеціалізуватися на атаці та обороні далі здається дуже теоретичним сценарієм. Але, як я писав, це залежить від точних вимог ОП.
Полігном

0

Чому б не створити абстрактні методи modifyAttackі modifyDefenseна Toolуроці? Тоді кожна дитина матиме власну реалізацію, і ви називаєте цей елегантний спосіб:

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 (), який видаляє бонус від гравця.
Полігном

0

Я думаю, що один із способів визнати недоліки в цьому підході - це розвивати свою ідею до її логічного завершення.

Це схоже на гру, тому на певному етапі ви, мабуть, почнете турбуватися про продуктивність та замінити ці порівняння рядків на intабо enum. Оскільки список елементів збільшується, то він if-elseпочинає бути досить непростим, тому ви можете розглянути можливість рефакторингу на «a» switch-case. На цей момент у вас також є стінка тексту, щоб ви могли відключити дію в кожній caseокремій функції.

Як тільки ви досягнете цього пункту, структура вашого коду починає виглядати звично - його початок виглядає як домашня мова, вручну розгорнутий vtable * - основна структура, за якою зазвичай реалізуються віртуальні методи. За винятком цього, це vtable, який потрібно вручну оновлювати та підтримувати кожен раз, коли ви додаєте або змінюєте тип елемента.

Дотримуючись "справжніх" віртуальних функцій, ви можете утримати реалізацію поведінки кожного елемента в межах самого елемента. Ви можете додати додаткові елементи більш автономним та послідовним способом. І коли ви все це робите, саме реалізація вашої динамічної відправки, а не про вас, буде піклуватися про компілятор.

Для вирішення вашої конкретної проблеми: ви намагаєтесь написати просту пару віртуальних функцій для оновлення атаки та захисту, оскільки деякі елементи впливають лише на атаку, а деякі - лише на захист. Трюк у такому простому випадку, як у будь-якому випадку реалізувати обидві поведінки, але в деяких випадках не має ефекту. GetDefenseBonus()може повернутися 0або ApplyDefenseBonus(int& defence)просто залишити defenceбез змін. Як ви будете робити це, залежатиме від того, як ви хочете впоратися з іншими діями, які мають ефект. У більш складних випадках, коли є більш різноманітна поведінка, ви можете просто об'єднати діяльність в єдиний метод.

* (Хоча, перенесено відносно типової реалізації)


0

Наявність блоку коду, який знає про всі можливі "інструменти", не є чудовим дизайном (тим більше, що у вас буде багато таких блоків у коді); але жоден не має базових Toolз заглушками для всіх можливих властивостей інструмента: тепер Toolклас повинен знати про всі можливі варіанти використання.

Що знає кожен інструмент - це те, що він може сприяти персонажу, який його використовує. Таким чином , забезпечує один метод для всіх інструментів, giveto(*Character owner). Він буде коригувати статистику гравця за необхідності, не знаючи, що можуть зробити інші інструменти, і що найкраще, також не потрібно знати про невідповідні властивості персонажа. Наприклад, щит не потрібно навіть знати про атрибути attack, invisibility, і healthт.д. Все , що потрібно застосовувати інструмент для персонажа , щоб підтримати атрибути , що об'єкт вимагає. Якщо ви спробуєте подарувати меч осла, а у осла немає attackстатистики, ви отримаєте помилку.

Інструменти також повинні мати remove()метод, який зворотно впливає на власника. Це трохи хитро (можливо, в кінцевому підсумку можна отримати інструменти, які залишають ненульовий ефект, коли його надають і потім відбирають), але принаймні він локалізований для кожного інструменту.


-4

Немає відповіді, яка б сказала, що вона не пахне, тому я буду такою, яка виступає за цю думку; цей код абсолютно чудовий! Моя думка базується на тому, що іноді легше рухатися далі і дозволяти своїм навичкам поступово збільшуватися, коли створюєш нові речі. Ви можете застрягти цілими днями на створенні ідеальної архітектури, але, напевно, ніхто ніколи не побачить її в дії, тому що ви ніколи не закінчували проект. Ура!


4
Вдосконалення навичок з особистим досвідом добре, точно. Але вдосконалювати навички, запитуючи людей, які вже мають цей особистий досвід, тож вам не потрібно самостійно впадати в ополонку, є розумнішим. Звичайно, саме тому люди задають питання в першу чергу, чи не так?
Грем

Я не згоден. Але я розумію, що на цьому сайті йдеться про заглиблення. Іноді це означає бути надмірно педантичним. Ось чому я хотів опублікувати цю думку, тому що вона закріплена в реальності, і якщо ви шукаєте поради, як стати кращим і допомогти початківцю, ви пропустите всю цю главу про «досить добре», що досить корисно для початківця, щоб знати.
Остмейстро
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.