Подкласс песочница (Subclass Sandbox)

Задача

Определение поведения в подклассе с помощью набора операций, предоставляемых базовым классом.

Мотивация

Каждый мальчишка хотел в детстве быть супергероем, но к сожалению с космическими лучами у нас на Земле не густо. Игры помогают хотя бы немного почувствовать себя супергероем. Так как никто из наших дизайнеров так и не научился говорить «нет», в каждой игре планируются дюжины, если не сотни различных суперспособностей для героев.

Наш план заключается в том, чтобы иметь базовый класс Superpower. Далее мы создаем класс наследник для каждой суперсилы. Делим дизайн документ между программистами поровну и начинаем кодить. Когда все закончили у нас появилась сотня классов суперспособностей.

Когда вы видите, что у вас образуется слишком много подклассов, как в этом примере, это обычно свидетельствует о том, что лучше применить подход управления через данные (data-driven approach). Вместо того, чтобы использовать огромное количество кода для определения различных суперсил, попробуйте определить их поведение с помощью данных.

В этом вам могут помочь шаблоны типа Объект тип (Type Object), Байткод (Bytecode) или Интерпретатор (Interpreter) GoF.

Мы хотим поразить нашего игрока разнообразием нашего мира. Мы хотим, чтобы в игре была каждая способность, о которой он только мог мечтать в детстве. Это значит, что эти подклассы суперсил могут делать практически все: проигрывать звуки, порождать визуальные эффекты, взаимодействовать с ИИ, создавать и уничтожать другие игровые сущности и вмешиваться в работу физики. Нет ни одного уголка кодобазы, до которого они не могли бы добраться.

Предположим, что мы дадим нашей команде волю и позволим заняться написанием классов суперсил. Что в этом случае произойдет?

  • У нас будет куча избыточного кода. Хоть разные суперсилы и различаются между собой довольно сильно, их действие все равно частично перекрывает друг друга. Многие из них будут проигрывать звуки и запускать визуальные эффекты похожими способами. Замораживающий луч, испепеляющий луч и луч горчицы Джинна — довольно похожи если рассмотреть их подробнее. Если реализующие все это люди не будут между собой взаимодействовать, они потратят кучу лишнего времени и сгенерируют кучу дублирующего кода.
  • Каждый уголок кода будет связан с этими классами. Не обладая достаточными знаниями, люди будут писать код, вызывающий подсистемы, которые изначально и не предполагалось связывать с классами суперсил. Если наш рендер организован в виде нескольких хитрых слоев, только один из которых предполагается взаимодействующим с кодом за пределами графического движка, мы можем обнаружить что некоторые суперсилы будут обращаться к каждому из его слоев.
  • Когда эти внешние системы потребуется изменить, связанные с ними суперсилы внезапно могут начать работать неправильно. Как только мы начинаем связывать различные классы суперсил с самыми разными частями нашего движка, не стоит удивляться потом, что их изменение будет влиять на работу классов суперсил. И это совсем не весело, потому что ваши программисты графики, аудио и пользовательского интерфейса совсем не хотят быть еще и программистами геймплея.
  • Сложно определить инварианты, которым подчиняются все суперсилы. Предположим, что мы хотим, чтобы все звуки, проигрываемые нашими суперсилами, попадали в очередь с правильными приоритетами. Если каждый из сотни ваших классов будет самостоятельно обращаться к звуковой системе, сделать это будет довольно проблематично.

Чего мы на самом деле хотим — так это возможность выдать каждому программисту геймплея набор примитивов, из которых он сможет конструировать суперсилы. Хотите чтобы суперсила проиграла звук? Вот вам функция playSound(). Хотите частиц? Вот spawnParticles(). Нам нужно удостовериться что эти операции покрывают все что вам нужно и вам не нужно будет беспорядочно прописывать #include для заголовков и совать нос во все уголки остальной кодобазы.

Мы добьемся этого сделав эти операции защищенными методами (protected methods) базового класса Superpower. То, что мы поместили их все прямо в базовый класс, означает, что у всех классов наследников будет простой к ним доступ. Объявление их защищенными (и вероятно не виртуальными) говорит о том, что они предназначены для того, чтобы вызываться только из классов наследников.

Теперь, когда у нас есть игрушки для игры, нам нужно место где можно с ними играть. Специально для этого определим метод песочницу (sandbox method): абстрактный защищенный метод, который должны реализовывать подклассы. Таким образом, для реализации новой силы нам нужно:

  1. Создать новый класс, унаследованный от Superpower.
  2. Переопределить метод песочницу activate().
  3. Реализовать его тело с помощью вызовов методов, предоставляемых классом Superpower.

Проблему избыточности кода мы можем решить, сделав эти операции как можно больше высокоуровневыми. Каждый раз когда мы видим код, дублирующийся в нескольких подклассах, мы всегда можем поместить его в Superpower в качестве новой операции, которую смогут использовать подклассы.

С проблемой излишней связности мы боремся, сосредотачивая всю связность в одном месте. Superpower в результате сама будет связана с самыми разными системами игры, а вот сотни унаследованных классов — нет. Вместо этого они будут связаны только со своим базовым классом. Когда одна из систем игры изменится, нам придется изменить и Superpower, а вот к десяткам подклассов можно будет не притрагиваться.

Этот шаблон подводит нас к архитектуре с неглубокой, но широкой иерархией классов. Цепочка экземпляров неглубокая, но у нас есть просто уйма классов, завязанных на Superpower. Имея один класс со множеством прямых подклассов, мы получаем в нашей кодовой базе точку приложения усилий. Время и усилия, затраченные на Superpower, окупятся при создании широкого набора классов в игре.

В будущем вы наверняка встретите еще множество людей, критикующих наследование в объектно-ориентированных языках. Наследование сулит проблемы — не существует связывания сильнее в кодобазе, чем между базовым классом и подклассом. При этом работать проще с широким, а не глубоким деревом наследования.

Шаблон

Базовый класс определяет абстрактный метод песочницу и несколько предоставляемых операций (provided operations). Объявление их защищенными явно означает, что они предназначены только для использования классами наследниками. Каждый унаследованный подкласс песочницы реализует метод песочницы с помощью предоставляемых операций.

Когда использовать

Все очень просто. Шаблон легко найти во множестве кодовых баз, даже за пределами игровой индустрии. Если у вас часто встречаются невиртуальные защищенные методы, вы возможно уже используете его подобие. Подкласс песочница следует использовать когда:

  • У вас есть базовый класс и множество дочерних.
  • Базовый класс способен реализовывать все операции, которые нужны для работы дочерним.
  • В поведении подклассов наблюдается много совпадений и вы хотели бы упростить кодовую базу за счет повторного использования кода.
  • Вы хотите минимизировать связность между этими дочерними классами и остальной программой.

Имейте в виду

«Наследование» во многих программистских кругах сегодня стало чуть ли не ругательством и одна из причин заключается в том, что базовые классы имеют тенденцию обрастать все большим и большим количеством кода. И этот шаблон, как никакой другой, подвержен этой тенденции.

Так как подклассам приходится общаться с остальной игрой через базовый класс, базовый класс оказывается связанным со всеми системами, с которыми вынужден общаться хотя бы один его дочерний класс. Конечно подклассы настолько же сильно связаны со своим базовым классом. Эта паутина связей не даст вам легко изменить кодовую базу без риска что-либо разрушить — классическая проблема хрупкости базового класса.

Обратной стороной монеты является то, что связывание сосредоточено на базовом классе, а классы наследники гораздо более явным образом отделены от остального мира. В идеале основная часть вашего поведения будет сосредоточена в этих подклассах. А это значит, что большая часть вашей кодобазы изолирована и ее легче поддерживать.

Так что если вы видите, что ваша кодовая база превращается в гигантскую миску тушенки, попробуйте выделить часть предоставляемых операций в отдельные классы, с которыми базовый класс сможет частично разделить ответственность. В этом вам поможет шаблон Компонент.

Пример кода

Так как этот шаблон довольно прост, примеров кода не будет слишком много. Это не значит что он бесполезен. Этот шаблон о намерении, а не о сложности реализации.

Начнем с базового класса Superpower:

class Superpower
{
public:
  virtual ~Superpower() {}

protected:
  virtual void activate() = 0;

  void move(double x, double y, double z) {
    // Здесь код...
  }

  void playSound(SoundId sound, double volume) {
   // Здесь код...
  }

  void spawnParticles(ParticleType type, int count) {
   // Здесь код...
  }
};

Метод activate() — это метод песочница. Так как он виртуальный и абстрактный, подклассы должны его переопределять. Таким образом, это будет очевидно для тех, кто будет работать над нашими классами сил.

Остальные защищенные методы move()playSound() и spawnParticles() — это предоставляемые операции. Это именно их подклассы будут вызывать в своей реализации activate().

Мы не реализуем предоставляемые операции в этом примере, но в настоящей игре здесь был бы реальный код. Именно в этих методах будет проявляться связность Superpower с остальными частями игры: move() работает с физическим кодом, playSound() общается с аудио движком, и т.д. Так как это все находится в реализации базового класса, вся связность инкапсулируется внутри самого Superpower.

А теперь выпускаем наших радиоактивных пауков и получаем суперсилу. Вот она:

class SkyLaunch : public Superpower
{
protected:
  virtual void activate() {
    // Взмываем в небо.
    playSound(SOUND_SPROING, 1.0f);
    spawnParticles(PARTICLE_DUST, 10);
    move(0, 0, 20);
  }
};

Ну ладно. Возможно способность прыгать — это не слишком супер. Я просто не хочу слишком переусложнять пример.

Эта сила подбрасывает супергероя в воздух, проигрывает сопроводительный звук и порождает облачко пыли. Если бы все суперсилы были такими простыми — просто комбинация звука, эффекта с частицами и движения — нам бы вообще шаблон не понадобился. Вместо этого Superpower мог бы просто содержать готовую реализацию activate(), получающую доступ к полям ID звука, типу частиц и движения. Но это могло бы сработать, если бы силы работали одинаково, но просто с разными данными. Доработаем его немного:

class Superpower
{
protected:
  double getHeroX() {
    // Здесь код...
  }

  double getHeroY() {
    // Здесь код...
  }

  double getHeroZ() {
    // Здесь код...
  }

  // Остальное...
};

Здесь мы добавляем несколько методов для получения позиции игрока. Теперь наш подкласс SkyLaunch может их использовать:

class SkyLaunch : public Superpower
{
protected:
  virtual void activate() {
    if (getHeroZ() == 0) {
      // Мы на земле, значит можем прыгать.
      playSound(SOUND_SPROING, 1.0f);
      spawnParticles(PARTICLE_DUST, 10);
      move(0, 0, 20);
    } else if (getHeroZ() < 10.0f) {
      // Невысоко над землей, значит можем делать двойной прыжок.
      playSound(SOUND_SWOOP, 1.0f);
      move(0, 0, getHeroZ() — 20);
    } else {
      // Находимся в воздухе и можем выполнить подкат.
      playSound(SOUND_DIVE, 0.7f);
      spawnParticles(PARTICLE_SPARKLES, 1);
      move(0, 0, -getHeroZ());
    }
  }
};

Так как у нас появился доступ к части состояния, теперь наш метод песочница может эффективнее управлять потоком выполнения. Всего несколько простых выражений if и вы можете реализовать все что захотите. Когда у вас в качестве метода песочницы будет полноценный метод с необходимым кодом, вас только небо остановит.

Ранее я предлагал применить для суперсил подход с описанием с помощью данных (data-driven approach). А вот и причина почему не стоит этого делать. Если ваше поведение достаточно сложное и императивное, его сложнее будет задавать с помощью данных.

Архитектурные решения

Как вы видите, это довольно «мягкий» шаблон. Он описывает базовую идею, но не слишком акцентируется на деталях механики. Это значит, что каждый раз, когда вы его применяете, вы можете делать интересные решения. Вот над чем стоит поразмыслить.

Какие операции нужно предоставить?

Это самый большой вопрос. От него зависит насколько шаблоном будет удобно пользоваться и насколько он будет полезен. В самом минималистичном варианте, базовый класс вообще не предоставляет никаких операций. Все что у него есть — это метод песочница. Чтобы его реализовать, вам нужно вызывать системы за пределами базового класса. Если вы выберете такую тактику поведения, можно сказать, что вы вообще не используете сам шаблон.

Другая крайность — это базовый класс, предоставляющий любые операции, которые могут понадобиться подклассам. Подклассы привязаны только к базовому классу и вообще не общаются с внешними системами.

В частности, это значит, что в исходнике каждого такого подкласса будет только один #include, подключающий базовый класс.

Между этими двумя крайностями лежит некое среднее решение, когда часть операций предоставляется базовым классом, а остальные используются из других систем напрямую. Чем больше операций предоставляет базовый класс, тем меньше связность подклассов с внешними системами. Это снижает связность у классов наследников, но увеличивает ее у самого базового класса.

Это очень выгодно, если у вас есть множество классов-наследников, связанных со внешними системами. Перенося связность в предоставляемые операции, вы концентрируете её в одном месте: в базовом классе. И чем чаще вы это делаете, тем больше и сложнее для поддержки становится базовый класс.

Так где же провести черту? Вот несколько основных правил:

  • Если предоставляемые операции используются только несколькими подклассами, вы не получите большого выхлопа за свои вложения. Вы увеличите сложность базового класса, которая скажется на всем прочем, но от этого снизится связность всего нескольких наследников.Так стоит делать, если эти операции пересекаются с уже существующими. Но возможно проще и очевиднее будет просто позволить подклассам обратиться к внешним системам напрямую.
  • Когда вы вызываете метод в каком-либо другом месте игры, лучше, если этот метод не изменяет никакого состояния. Связность все равно увеличивается, но это «безопасная» связность, потому что она ничего в игре не ломает.Я не зря беру слово»безопасность» в кавычки, потому что технически даже получение данных может добавить вам проблем. Если ваша игра многопоточная, вы можете пытаться читать какое-то значение в то время, когда оно изменяется. И если не будете достаточно осторожны, у вас окажутся некорректные данные.Еще один хитрый случай — это когда состояние вашей игры строго детерминировано (что практикуют многие онлайновые игры для сохранения синхронизации между игроками). Если вы получаете доступ к чему либо за пределами синхронизированного состояния игры, у вас могут образоваться крайне опасные недетерминированные баги.Вызовы, которые меняют состояние в свою очередь гораздо сильнее связывают части вашей кодобазы и вам сложнее будет анализировать такие связи. Этот факт делает их хорошими кандидатами на включение в список предоставляемых операций в более видимый для анализа базовый класс.
  • Если реализация предоставляемых операций сводится просто к вызову какой-либо внешней системы — большой пользы она не несет. В этом случае может быть проще просто вызвать внешнюю систему напрямую.Однако даже простейшее перенаправление может быть полезным: такие методы зачастую обращаются к состояниям, которые нежелательно напрямую видеть классам наследникам. Предположим что Superpower предоставляет такую операцию: void playSound(SoundId sound, double volume) { soundEngine_.play(sound, volume); } Это просто обращение к одному из полей soundEngine_ из Superpower. Выигрыш здесь в том, что поле осталось инкапсулированным в Superpower и подклассы его не видят.

Следует ли предоставлять методы напрямую или через содержащий их объект?

Сложность этого шаблона заключается в том, что в результате у вас образуется огромное количество методов, сосредоточенное в одном базовом классе. Этого можно избежать, переместив часть методов в отдельные классы. А предоставляемые операции будут просто возвращать один из этих объектов.

Например, чтобы позволить силе проигрывать звук, мы можем добавить такую возможность прямо в Superpower:

class Superpower
{
protected:
  void playSound(SoundId sound, double volume) {
    // Здесь код...
  }

  void stopSound(SoundId sound) {
    // Здесь код...
  }

  void setVolume(SoundId sound) {
    // Здесь код...
  }

  // Метод песочница и другие операции...
};

Но, у нас ведь и так слишком много всего в Superpower, а нам хотелось бы этого избежать. Поэтому мы сделаем отдельный класс SoundPlayer и перенесем эту функциональность в него:

class SoundPlayer
{
public:
  void playSound(SoundId sound, double volume) {
    // Здесь код...
  }

  void stopSound(SoundId sound) {
    // Здесь код...
  }

  void setVolume(SoundId sound) {
    // Здесь код...
  }
};

А теперь Superpower будет просто предоставлять доступ к этому классу:

class Superpower
{
protected:
  SoundPlayer& getSoundPlayer() {
    return soundPlayer_;
  }

  // Метод песочница и другие операции...

private:
  SoundPlayer soundPlayer_;
};

Подобный перенос предоставляемых операций во вспомогательные классы имеет следующие преимущества:

  • Уменьшается количество методов в базовом классе. В нашем примере мы избавились от трех методов за счет одного получателя класса (getter).
  • Код во вспомогательном классе обычно легче поддерживать. Ключевые базовые классы типа Superpower , несмотря на наши лучшие намерения, может быть сложно изменять, потому что от них слишком много всего зависит. Перенос функциональности в другой менее связанный дополнительный класс, упрощает ее изменение без ущерба для других вещей.
  • Снижается связность между базовым классом и остальными системами. Когда метод playSound() находился прямо в Superpower, это значило, что наш класс был напрямую связан с SoundId и остальным аудио кодом, вызываемым реализацией. Перенос всего этого в SoundPlayer снижает связность Superpower до одного класса SoundPlayer, в котором теперь сосредоточены все остальные зависимости.

Как базовый класс будет получать нужно ему состояние?

Вашему базовому классу часто придется получать данные, которые он хочет инкапсулировать и держать невидимыми для своих подклассов. В нашем первом примере, класс Superpower предоставлял метод spawnParticles(). Если для его реализации нужен объект системы частиц, то как нам его получить?

  • Передаем в конструктор базового класса:Проще всего передать его в качестве аргумента конструктора базового класса: class Superpower { public: Superpower(ParticleSystem* particles) : particles_(particles) {} // Метод песочница и другие операции... private: ParticleSystem* particles_; }; В этом случае мы можем быть уверенными, что у каждой суперсилы будет возможность воспользоваться эффектами сразу после создания. Но давайте посмотрим на класс наследник: class SkyLaunch : public Superpower { public: SkyLaunch(ParticleSystem* particles) : Superpower(particles) {} }; Здесь видна очевидная проблема. Каждому классу наследнику придется иметь конструктор, вызывающий конструктор базового и передающий в него этот аргумент. А это значит, что каждый класс наследник буде частью состояния, о котором мы хотим чтобы он вообще не знал.Для поддержки это тоже сплошная головная боль. Если позже мы захотим добавить еще одну часть состояния в базовый класс, нам придется изменить и все конструкторы всех унаследованных от него классов.
  • Выполняем двухшаговую инициализацию:Чтобы не передавать все через конструктор, мы можем разделить инициализацию на два шага. Конструктор не будет принимать никаких параметров и просто создает объект. После этого мы вызываем отдельный метод, объявленный прямо в базовом классе и передаем ему оставшуюся часть необходимых ему данных. Superpower* power = new SkyLaunch(); power->init(particles); Обратите внимание, что раз мы ничего не передаем в конструктор SkyLaunch, он не связан ни с чем, что мы хотели бы оставить личным (private) в Superpower. Проблема с этим подходом в том, что вам всегда нужно помнить о необходимости вызова init(). Если вы когда-нибудь об этом забудете, у вас будет сила, застрявшая в некоем полусозданном состоянии и ничего не делающая.Чтобы исправить это, мы можем инкапсулировать весь процесс внутри одной функции следующим образом: Superpower* createSkyLaunch(ParticleSystem* particles) { Superpower* power = new SkyLaunch(); power->init(particles); return power; } Использовав здесь трюк с приватным конструктором и дружественным классом, вы можете быть уверены что функция createSkylaunch() является единственным способом создания силы. Таким образом вы никогда не забудете ни об одном этапе инициализации.
  • Сделаем состояние статичным:В предыдущем примере мы инициализировали каждый экземпляр Superpower системой частиц. Это имеет смысл, если каждая сила нуждается в собственном уникальном состоянии. Но что, если наша система частиц реализована как Синглтон (Singleton) и используется всеми силами совместно.В этом случае мы можем сделать состояние приватным для базового класса и даже статичным (static). Игра все равно будет проверять инициализацию состояния, но класс Superpower придется инициализировать только один раз, а не для каждого экземпляра.Имейте в виду, что при этом у нас появляется множество проблем из-за синглтона: единое состояние оказывается общим для большого множества объектов (всех экземпляров Superpower). Система частиц инкапсулирована и не видна глобально, что есть хорошо, но она все равно усложняет понимание работы суперсил, потому что они все работают с одним и тем же объектом. class Superpower { public: static void init(ParticleSystem* particles) { particles_ = particles; } // Метод песочница и другие операции... private: static ParticleSystem* particles_; }; Обратите внимание что здесь статичны и init() и particles_. Пока игра вызывает Superpower::init() перед всем остальным, каждая сила сможет получить доступ к системе частиц. В тоже время экземпляры Superpower можно свободно создавать просто вызывая конструктор класса наследника.Что еще лучше, теперь particles_ является статической переменной и нам не нужно сохранять ее в каждом экземпляре Superpower, так что наш класс будет расходовать меньше памяти.
  • Использование поиска службы(service locator):Предыдущий вариант требовал, чтобы внешний код обязательно не забывал о том, чтобы передать состояние в базовый класс, прежде чем его можно будет использовать. Таким образом на окружающий код налагаются определенные обязанности. Еще как вариант, можно позволить базовому классу обрабатывать это, получая нужное состояние самостоятельно. Для этого можно использовать шаблон Поиск cлужбы (Service Locator). class Superpower { protected: void spawnParticles(ParticleType type, int count) { ParticleSystem& particles = ServiceLocator::getParticles(); particles.spawn(type, count); } // Метод песочница и другие операции... };

Здесь для `spawnParticles() нам нужна система частиц. Вместо того, чтобы нам ее давали из внешнего кода, мы сами получаем ее через поиск службы.

Смотрите также

  • Когда вы применяете шаблон Метод обновления (Update Method), ваш метод обновления часто будет представлять из себя и метод песочницу.
  • Роль этого шаблона сходна с ролью шаблона Метод шаблон (Template Method) GoF. В обеих шаблонах вы реализуете метод с помощью набора примитивных операций. В Методе песочнице, метод находится в шаблоне наследнике, а операции примитивы в базовом классе. А в Методе шаблоне, метод содержится в базовом классе, а примитивы операций реализуются в классах наследниках.
  • Также этот шаблон можно рассматривать как вариацию шаблона Фасад (Facade) GoF. Этот шаблон скрывает несколько различных систем за единым упрощенным API. В Подклассе песочнице базовый класс работает как фасад, скрывающий весь движок от подклассов.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *