Советы по применению ООП и шаблонов проектирования

в 9:36, , рубрики: game development, Gamedev, ооп, паттерны проектирования, Программирование, разработка

Легко понять популярность языка C++ среди профессиональных разработчиков игр. Этот язык недалеко ушел от переносимости и эффективности языка C, но при этом предлагает определённые конструктивные преимущества объектно-ориентированного языка. Хоть язык и обладает такой мощью, для эффективной работы потребуется правильный подход и хорошая реализация кода. Несмотря на парадигму объектно-ориентированного программирования (ООП), программы, написанные на С++, могут работать несколько хуже, чем написанные на языке С.

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

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

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

Стилистика кода

Споры о стиле программирования часто доходят до уровня религиозных. Я не собираюсь поднимать холивар на тему где нужно ставить фигурные скобки, но однозначно то, что внутри компании важно использовать единый стиль.

В компаниях, не говоря уже об отдельных программистах, должны стремиться к согласованности имен классов, функций и переменных. Многие используют упрощенную венгерскую нотацию, некогда изобретенную доктором Чарльзом Симони, главным архитектором программного обеспечения в Microsoft, чтобы помочь стандартизировать название переменных. Некоторые утверждают, что такая схема именования необходима в строго типизированных языках, вроде C++, но создает больше работы при изменяемых типах данных (так как потребуется изменение префикса переменной), другим же нравится та легкость и скорость, с которой можно определить тип данных переменной по её имени.

Основное правило венгерской нотации — поставить префикс к имени переменной, описывающий её тип данных. Например, целочисленная переменная (тип int) с именем SomeVariable в этой нотации будет называться iSomeVariable. В дополнение к типу переменной, своим префиксом обладают указатели. Так, указатель (pointer) на класс Foo может быть назван pFooObj. В случае наличия нескольких префиксов — они объединяются, чтобы обеспечить больше информации, чем один префикс с типом данных. Например, указатель на целое будет обозначаться приставкой pi, а указатель на указатель будет представлен в виде pp.

Другие виды информации о переменных часто ставятся в начале ее префикса, например область видимости. Переменные-члены класса (member) помечаются m_, поэтому целочисленная переменная-член может быть помечена m_iSomeVar. Глобальные переменные (тсс, лучше их вообще не использовать) обозначаются с префиксом g_, и некоторые еще выделяют статические переменные с префиксом s_, хотя это довольно редкая практика. Так как формально венгерская нотация может показаться сложной, многие пользуются её упрощенной версией. В таблице ниже представлен пример одного из таких вариантов. Помимо этого можно найти другие описания в книгах (например Petzold, Charles, Programming Windows 95, Microsoft Press, Inc., 1996), или найти оригинальное описание Симони в различных источниках сети Интернет.

Пример упрощенной венгерской нотации

Тип Description Описание
I Integer Целочисленное
F Float С плавающей точкой
D Double (float) Двойной точности с плавающей точкой
L Long (integer) Длинное целочисленное
C Character Символ
B Boolean Булево значение
Dw Double word Двойное слово
W Word Слово
by or byte Byte Байт
Sz C-style (null-terminated) string Строка в стиле С (с нулем на конце)
Расширение Description Описание
Str C++ string object Объект-строка С++
H Handle (user-defined type) Обработчик (пользовательский тип данных)
V Vector (user-defined class) Вектор
Pt Point (user-defined class) Указатель
Rgb RGB triplet (user-defined struct or type) RGB цвет (пользовательская структура или тип)
Модификатор Description Описание
P Pointer to Указатель на
R Reference to Ссылка на
U Unsigned Беззнаковое
a or ary Array of Массив из
Область видимости Description Описание
m_ Member variable Переменная-член класса
g_ Global variable Глобальная переменная
s_ Static variable Статическая переменная

Наиболее часто используемые обозначения типов приведены в таблице. Объекты, как правило, не получают свой собственный префикс, за исключением нескольких общих классов, таких как те, которые представляют 3D векторы и точки. Конечно же, в вашей компании может быть принято соглашение об обозначении других часто используемых служебных классов. Обратите внимание, что большинство описательных тегов вполне логичны и не потребуют поиска их значения в таблице.

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

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

Помимо этого нужно единообразно именовать классы, чтобы с ними было просто работать и легче понять. Нотация, нашедшая некоторую популярность среди Windows разработчиков, заключается в использовании префиксов к имени класса для обозначения общей его идеи. Классы, начинающиеся с буквы C, предназначены как Concrete классы или классы с конкретным, определенным использованием и реализацией. Классы, начинающиеся с I — это классы интерфейса или классы, предназначенные для использования в качестве дизайн шаблонов. Такие классы не используются приложением напрямую, они предназначены для наследования от них других классов.

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

Проектирование классов

Безграничная гибкость в разработке, обеспечиваемая классами языка С++, не только плюс, но и минус языка. В С++ отсутствуют требования по именованию классов и их методов, за исключением имен конструкторов и деструкторов. Однако, вы можете определить собственные правила именования классов. Например:

class Sample
{
public:
	Sample() {Clear();}
	~Sample() {Destroy();}
	
	void Clear();

	bool Create();
	void Update();
	void Destroy();
};

Первое, что вы увидите в этом классе — тривиальный конструктор. Такая реализация класса хороша по ряду причин. Начнем с того, что конструктор в С++ не имеет возвращаемого значения. Будет нехорошо, если мы в него напишем нечто, что в дальнейшем приведет к ошибке. Вместо этого, мы просто вызываем Clear() — функцию, которая очищает значения всех внутренних переменных-членов. Дополнительное преимущество очистки значений переменных в отдельной функции в том, что вы это можете сделать это когда угодно. Немного позже будет понятно почему это важно.

Бывает, что вам не нужно «активировать» класс сразу же с момента его создания. Это часто случается для классов-оберток, являющихся членами другого класса. Также существует проблема в эффективности. Убрав из конструктора фактическое создание объекта, мы можем создать объект динамически один раз, но после этого неоднократно вызывать Create() и Destroy() для повторного использования памяти, занимаемой этим объектом. Динамическое выделение памяти довольно дорогая операция, поэтому по возможности его лучше избегать. Как уже упоминалось, Create() и Destroy() фактически создают и разрушают представление объекта. Функция Create() возвращает логическое значение, указывающее на успех или неудачу этой операции. Оно является одновременно интуитивным и простым для реализации. Другим популярным типом возвращаемого значения является тип стандартных кодов ошибок (обычно целочисленный тип). Логический тип легок в использовании, но требует дополнительных механизмов по обработке ошибочных ситуаций. Обработка исключений, теоретически, превосходит простые возвращаемые значения, но дорога в процессе выполнения программы и может быть легко упущена из вида программистами. Кроме того, обработка исключений не самодокументируется, как коды ошибок или возвращаемые значения, расположенные в одном из заголовочных файлов.

Есть также важное замечание по функции Destroy(). Так как мы хотим удобную автоматическую очистку и гибкое “разрушение и новое создание по требованию”, нам нужно убедиться, что функция Destroy() может безопасно вызываться несколько раз подряд без вызовов функции Create(). Обязательно стоит вызывать Clear() в конце разрушения объекта, чтобы сбросить все переменные в исходное состояние.

Создание игр часто подразумевает создание системы реального времени, а не более общей событийной модели программирования, используемой в большинстве коммерческих приложений. Можно увидеть эту разницу и в проекте нашего класса. Последняя его часть — функция Update() — функция “шага” или функция, вызываемая один раз на каждом кадре. Будет полезно прийти к одному имени для этой функции. В зависимости от класса, вам захочется или не захочется сделать возвращение логической переменной из Update(), чтобы проверять ошибки выполнения в функции на каждом шаге.

Проектирование иерархии классов

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

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

Определить какой способ нужно использовать очень просто: если объект имеет связь с другим типа это-что-то — следует использовать наследование. Если эта связь лучше описывается отношением типа обладает-чем-то — лучше использовать наслоение. Что именно означают связи это-что-то и обладает-чем-то? В значительной степени именно то, что они значат в обычном языке. Чтобы легче понять идею, продемонстрируем её на примере:

Класс Корвет это разновидность класса Машина.
Класс Корвет обладает разновидностью класса Радио.

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

Шаблоны проектирования

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

Шаблон Singleton (одноэлементный класс)

Шаблон singleton используется, если нужно обеспечить доступность какого-то глобального объекта в ряде классов и/или модулей. Также будет работать и простое создание нелокального статического объекта, но это приведет к ряду проблем, одной из которых будет незнание о фактическом создании объекта, особенно при необходимости создания других объектов с глобальной областью видимости. Данный шаблон решает эту проблему, обеспечивая доступ к объекту через класс, хранящий статический объект. Пример базовой реализации может выглядеть следующим образом:

class Singleton1
{
public:
	Singleton1& Instance() 
	{
		static Singleton Obj;
		return Obj;		
	}
private:
	Singleton1();
};

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

class SingletonBase
{
public:
	SingletonBase()		
	{  cout << "SingletonBase created!" << endl;  }
	virtual ~SingletonBase()				
	{  cout << "SingletonBase destroyed!" << endl; }
	virtual void Access()	
	{  cout << "SingletonBase accessed!" << endl;  }
	static SingletonBase* GetObj()	
	{  return m_pObj;  }
	static void SetObj(SingletonBase* pObj)
	{  m_pObj = pObj;  }
protected:
	static SingletonBase* m_pObj;
};

SingletonBase* SingletonBase::m_pObj;

inline SingletonBase* Base()
{  
	assert(SingletonBase::GetObj());  
	return SingletonBase::GetObj();  
}

// Создаем наследованный от Singleton класс 
class SingletonDerived : public SingletonBase
{
public:
	SingletonDerived()		
	{  cout << "SingletonDerived created!" << endl;  }
	virtual ~SingletonDerived()				
	{  cout << "SingletonDerived destroyed" << endl;  }
	virtual void Access()	
	{  cout << "SingletonDerived accessed!" << endl;  }
protected:
};

inline SingletonDerived* Derived()
{  
	assert(SingletonDerived::GetObj());
	return (SingletonDerived*)SingletonDerived::GetObj();
}

// Сложный singleton требует больше работы, но в то же время
// он более гибкий, а также обеспечивает лучшее управление 
// созданием объекта, что необходимо в некоторых случаях.
SingletonDerived::SetObj(new SingletonDerived);

// Заметим, что функциональность была переписана в новом классе,
// хотя доступ осуществляется через оригинальные методы.
Base()->Access();
Derived()->Access();

// Этот вариант singleton к сожалению требует явного
// создания и удаления.
delete SingletonDerived::GetObj();

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

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

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

Для того, чтобы сделать большую часть таких расширений, вам следует представить сценарий наподобие этого: Библиотека А использует одноэлементный класс, описанный ранее. Библиотека Б должна использовать Библиотеку А для своего функционирования, таким образом она зависит от ее классов, включая заголовочные файлы. Приложение С использует обе библиотеки А и Б, но требует внесения некоторых изменений в А для соответствия своим задачам. Вместо создания новой версии библиотеки (и потери улучшений, внесенных в эту библиотеку, например, из другого параллельного проекта), приложение С может просто создать новый класс (Класс Д) из библиотеки А. Если мы требуем, в рамках соглашения об одноэлементном классе, чтобы приложение отвечало за выделение объектов, мы можем заменить производным классом Д наш класс А. Создавая новую функцию доступа с новым именем, которая возвращает указатель на класс Д вместо класса А, мы можем получить доступ ко всем новым функциям Д. Однако, класс Б будет продолжать использовать старую функцию доступа, которая возвращает указатель на А и будет ожидать старые функции аналогичные тем, что и были раньше. Обращаем ваше внимание, что поведение виртуальных функций может быть переопределено, но вам следует убедиться, что новые функциональные возможности работоспособны со старыми, для сохранения обратной совместимости.

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

Шаблон фасад

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

Фасад, или менеджер, необходим для сведения взаимозависимостей между классами, известных как связанность, к минимуму. Можно представить худший случай, когда каждый класс проекта “знает” о других и требует к ним явный доступ, как показано на рисунке 1. Максимальное количество взаимозависимостей между классами можно выразить как (n-1)2, где n — количество классов в проекте.

Советы по применению ООП и шаблонов проектирования

Рисунок 1. Пример неудачных взаимозависимостей классов.

Такого рода задача с взаимозависимостями возникает в случае необходимости серьезных изменений или замены целой подсистемы. ООП защищает против изменения реализации отдельных классов, но необходима новая парадигма для защиты против более глобальных изменений. Шаблон фасад решает такой тип задач, обеспечивая защиту системы в более крупном масштабе, чем предусмотрено в подходе ООП.

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

Советы по применению ООП и шаблонов проектирования

Рисунок 2. Использование классов-фасадов для уменьшения взаимозависимостей.

Шаблон состояние

Почти каждый программист игр сталкивался с задачей постоянного отслеживания изменения состояния игры в режиме реального времени. Состояния обычно начинаются с простых перечислений, и переключаемого поведения, реализованного управляющей конструкцией switch … case. Однако, задачи развиваются, и число состояний может сильно вырасти, при этом требуя некоторую общую функциональность в нескольких состояниях. В такие моменты может случиться ужас копипасты, когда программист будет искать все места использования кода, который он только что немного подкорректировал в одном из состояний.

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

Советы по применению ООП и шаблонов проектирования
Рисунок 3. Использование шаблона состояние.

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

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

Шаблон фабрика

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

Это значит, что один объект отвечает за создание ряда других объектов, обычно связанных общим базовым классом. Часто этот класс имеет только один метод, принимающий на вход некоторый идентификатор класса и возвращающий созданный объект. Преимущество группировки выделения объектов в одно место особенно примечательно для разработчиков игр:

  • Динамическое выделение памяти дорого, таким образом необходимо внимательно наблюдать за ним. Размещение всех объектов в одной области позволяет проще это сделать.
  • Часто общая инициализация или метод создания должен вызываться для всех объектов в иерархии классов. Если всё создание объектов происходит в одном месте, легко выполнять любые операции (например, добавление их в менеджер ресурсов) для всех объектов.
  • Фабрика обеспечивает расширяемость с помощью наследования новых объектов от текущей фабрики. Передавая идентификатор нового класса (который может быть получен из данных или кода), вы можете обеспечить расширяемость новыми классами на этапе выполнения без изменения существующего кода.

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

BaseClass* ClassFactory::CreateObject(int id)
{
	BaseClass* pClass = 0;
	switch(id)
	{
	case 1:
		pClass = new Class1;
		break;
	case 2:
		pClass = new Class2;
		break;
	case 3:
		pClass = new Class3;
		break;
	default:
		assert(!"Error! Invalid class ID passed to factory!");
	};

	// perhaps perform some common initialization is needed
	pClass->Init();

	return pClass;
}

Несложно заметить, что технически ничего сложного в методе создания объектов фабрикой нет. Однако, с помощью централизации создания объектов достигается хорошая организация и механизм расширяемости.

Шаблон фабрика используется при необходимости динамического создания большого количества различных объектов в процессе выполнения программы. Она может использоваться для создания объектов искусственного интеллекта, ресурсов (например текстуры или звуки) или даже абстрактных объектов, вроде состояний игры (как в предыдущей главе).

Заключение

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

Автор: sir06Will

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js