Загрузка...
 
Печать

Основы языка С++


Содержание

Язык C++(external link) - компилируемый, объектно-ориентированный, статически типизированный язык программирования общего назначения. До недавнего времени подавляющее большинство коммерческих игр для ПК и консолей создавалось с его помощью. Даже приход платформы .NET и языка программирования C# (читается "Си шарп") не смог кардинально изменить ситуацию.
С языком C++ ты окажешься в новом измерении программирования.
По C++ написана не одна сотня книг (многие из которых на русском языке). А так как данный курс не претендует на полноту изложения, настоятельно рекомендуем прочесть хотя бы пару из них. Не факт, что всё усвоишь, но основные моменты всё равно запомнишь.

Переходим с языка C на C++

Конечно, C++ построен на базе языка C и имеет с ним много общего.1 Но C++ в разы круче. Такие его фишки, как объектно-ориентированный подход позволяют создавать модульный (modular) код, который, написав однажды, можно снова и снова использовать в своих проектах практически без изменений.
Среди других "плюшек" C++:

  • Новые ключевые слова (keywords)
  • Расширенные возможности вызова функций
  • Поддержка структур

и др.

Объектно-ориентированное программирование (ООП). Понятие класса

Объектно-ориентированный подход (ООП) подразумевает под собой создание программного кода, позволяющего группировать наборы инструкций в специальные "паки" (packages) более известные как объекты. Объект может представлять собой что угодно: персонаж игрока, оружия или даже карту локации. В то время как объекты "знают" только о существовании самих себя, они конструируются таким образом, чтобы внешние объекты могли с ними взаимодействовать. В этом вся суть: данные внутри таких объектов обособлены (self-contained) и могут быть доступны другим объектам только через специальные интерфейсы. Это означает, что объекты прячут свои данные, за исключением тех данных, которые имеют статус public, т.е. доступны для других объектов.
Например объект "мир" заботится только о самом себе. Но если я захочу узнать температуру воздуха в указанной точке земного шара, я просто запрошу у объекта "мир" эту информацию. Объект "игрок", например, может сообщить мне свой уровень здоровья (health status) или текущую позицию на карте.
ООП предоставляет абсолютно иной подход к структурированию программного кода и его понимание может занять некоторое время.
Независимо от языка программирования объектно-ориентированный подход имеет ряд общих принципов:

  • Возможность создавать абстрактные типы данных, позволяющая наряду с предопределёнными типами данных (integer, bool, double и др.) вводить свои собственные типы данных (классы) и объявлять совего рода "переменные" таких типов данных (объекты). В этом случае программист оперирует не машинными терминами (переменная, функция), а объектами реального мира, поднимаясь тем самым на новый абстрактный уровень. Напрмер яблоки и людей нельзя умножать друг на друга. Низкоуровневый код запросто может совершить такую логическую ошибку, тогда как при использовании абстрактных типов данных такая операция становится невозможной.
  • ИНКАПСУЛЯЦИЯ допускает взаимодействие пользователя с абстрактными типами данных только через их интерфейс. Реализация объекта остаётся закрытой от посторонних глаз с целью предотвратить модифицирование (изменение) этих ообъектов. Использование инкапсуляции позволяет разработать автономный объект и использовать его, прибегая лишь к небольшому числу интерфейсных методов и не заботясь о его внутренней реализации. Каждый объект касса ответственнен только за свои действия. Поэтому игра не особо "заботится" о том, что происходит с каждым из объектов её классов.2 Игра лишь предоставляет основу (фреймворк) для создания объектов классов. Как правило, в основе данного фреймворка лежит игровое поле (экран) с объектами на нём, а также некий цикл обновления (update loop), который сообщает объектам, что прошло столько-то времени. Для неигровых персонажей (NPC) цикл обновления указывает направление движения/атаки. Для персонажа игрока вдобавок происходит обработка пользовательского ввода. Т.е. несмотря на то, что каждый объект игры в принципе автономен, каждый из них знает, что делать в каждый момент времени.
  • НАСЛЕДОВАНИЕ позволяет на основе существующего класса создавать другие классы, которые автоматически получают его возможности. Зачастую многие классы бывают достаточно сложны, поэтому прибегают к их последовательной разработке, выстраивая иерархию классов от общего к частному.
  • ПОЛИМОРФИЗМ - возможность классов содержать методы с одинаковым названием, но с разным набором аргументов. Очень полезно, например, при обработке массивов данных разного типа.

Абстрактные типы данных (в нашем случае это классы) позволяют программисту вводить в программу переменные с желаемыми свойствами, когда возможностей существующих в языке типов данных не хватает. Связи между объектами реального мира зачастую настолько сложны, что для их эффективного моделирования необходим отдельный язык программирования. Разрабатывать специализированный язык программирования для каждой прикладной задачи - очень дорогое удовольствие. Поэтому во многие языки программирования (C++, PHP, Java, Python и др.) вводится объектно-ориентированный подход, который позволяет создать свой мини-язык путём введения классов и объектов. Переменными здесь уже являются объекты, в качестве типа данных для которого выступает класс. Класс описывает состав объекта - переменные и функции, которые тем самым определяют поведение объекта.
Классы являеются своего рода "шаблонами" для создания на их основе различных объектов.
Одним из самых больших преимуществ ООП является возможность повторного использования ранее написанного кода (т.н. рефакторинг, reusing). Т.е. если вдруг решишь сделать ролевую игру на основе готовой (например на просторах космоса), нет необходимости повторно создавать игровые объекты с нуля. Достаточно отредактировать уже имеющиеся.

Использование заголовочных файлов (header files)

Функционал некоторого исходного файла (с расширением .cpp) может быть представлен его заголовочным фалом (с расширением .h). К заголовочным файлам часто обращаются как к интерфейсам. Например, ты можешь создать класс с названием Animal (от англ. Animal - животное), который воспроизводит функционал некоего "базового" животного в отдельно взятой игре. Класс будет объявлен в заголовочном файле (в C++, перед использованием чего угодно, это "что угодно" необходимо сначала объявить) (например animal.h) и затем уже будет реализован (имплементирован) в исходном файле, ассоциированном с ним (Animal.cpp). Таким образом, заголовочный файл предоставляет интерфейс для класса Animal, предназначенный для того, чтобы класс и функции этого класса были доступны для других файлов в пределах данной гипотетической игры.

Следующий пример показывает объявление нашего класса Animal, которое размещается в заголовочном файле animal.h. Это объявление затем становится интерфейсом класса Animal. Данный код (вообще, он считается "псевдокодом") не нужно отправлять на компиляцию в MS Visual C++. Его надо просто прочитать и попытаться в нём разобраться.

animal.h
class Animal
{
public:
Animal();		//Объявление конструктора
virtual ~Animal();	//Объявление деструктора

private:
	bool m_alive;
};

Наш простой класс имеет конструктор(external link), деструктор(external link) и одну переменную типа bool. Тип Boolean подразумевает всего два значения - true (истина) или false (ложь). Перед деструктором стоит служебное слово virtual, о котором мы расскажем позднее. Пока лишь отметим, что здесь оно также необходимо.

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

Animal.cpp
#include "animal.h"

Animal::Animal()	//Реализация конструктора
{
	m_alive = true;
}

Animal::~Animal()	//Реализация деструктора
{
	m_alive = false;
}

Для того, чтобы компилятор(external link) связал объявление класса с исходным фалом Animal.cpp, где содержится реализация класса, в начале Animal.cpp необходимо указать ссылку на заголовочный файл (animal.h). Для этого в начале Animal.cpp размещают специальное выражение #include (от англ. "вложить"). Оно просто "говорит" компилятору взять всё содержимое указанного файла и разместить его на этом месте. В нашем случае в начале исходного файла мы ставим команду #include "animal.h".

Но зачем вообще нужен заголовочный файл? Нельзя ли обойтись без него, разместив весь исходный код (объявление класса + его реализация) в одном исходном файле (.cpp)? Ответ - да, можно. Более того, ты можешь разместить функции (в нашем случае это конструктор и деструктор) прямо внутри объявления класса:

Animal.cpp
class Animal
{
	public:
		Animal()
		{
			m_alive = true;
		};

	virtual ~Animal()
		{
			m_alive = false;
		};

	private:
		bool m_alive;
}

Но такой подход имеет свои недостатки:

  1. Если ты размещаешь объявление класса в начале исходного файла. то теряешь возможность иметь отдельно вынесенный интерфейс для своего класса. На деле это усложнит код, что, в свою очередь, снизит его пригодность к обслуживанию и внесению измемнений (Maintainability) и, как следствие, усложнит его использование (usability). Подробнее об этом читаем здесь. Это не проблема, когда приложение содержит всего один класс. Но любая игра насчитывает десятки, а то и сотни классов, размещать которые в одном исходном файле будет крайне необдуманно.
  2. Если ты размещаешь реализацию класса внутри его объявления, у тебя нет защищённого интерфейса. Вся суть интерфейсов состоит в том, чтобы позволить разработать класс, который может быть позднее использован в других проектах БЕЗ отображения его реализации. Таким образом, для того чтобы выяснить, что делает данный класс, любой желающий может просмотреть его интерфейс (обычно это заголовочный файл с расширением .h). Но никто не может увидеть исходный код с реализацией этого класса, скрытый, как правило, в недрах динамически подключаемой библиотеки (.dll). Экспонирование функций динамически подключаемой библиотеки (.dll) в заголовочных файлах (.h) - обычная практика в программировании. И хотя, вместо DLL-библиотеки, у нас вполне открытый и доступный для прочтения исходный файл (.cpp), принцип остаётся тот же.


В нашей гипотетической игре, если мы захотим создать ещё один инстанс(external link) (от англ. instance - экземпляр) нашего класса Animal (уже в другом исходном файле), всё, что нам нужно будет сделать, это вставить выражение #include с указанием на интерфейс этого класса где-то в начале исходного файла, который будет создавать этот экземпляр. Само собой, выражение #include необходимо размещать ДО того места в коде, где ты пытаешься использовать данный класс. В следующем примере мы вставляем выражение #include "animal.h" в "какой-то другой исходный файл" и создаём после этого экземпляр класса Animal (изначально определённый в animal.h), назвав его dog:

SomeOtherSourceFile.cpp
#include "animal.h"

int WINAPI WinMain(HINSTANCE instance, HINSTANCE prev, LPSTR cmdLine, int cmdShow)
{
	Animal dog;

	return true;
}

Этот принцип мы возьмём на вооружение и будем повсеместно применять при создании движка и игры. Едва ли не каждый класс будет создаваться со своим внешним интерфейсом (в виде заголовочного файла .h). При программировании движка эти интерфейсы будут отлинкованы (скомпонованы через выражения #include) в один "центральный" заголовочный файл Engine.h, что полностью удовлетворяет нашему требованию о "единой точке контакта". Весь функционал нашего будущего движка будет представлен в одном этом файле. Более того, для использования движка (например при создании игры) достаточно включить в проект его главный заголовочный файл, указав в начале исходного файла (.cpp) всего одно выражение #include "Engine.h". Чуть позднее мы ещё раз обсудим концепцию "единой точки контакта".

Работа с функциями

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

Прототип функции

Самое важное правило в C++ - это необходимость прототипирования всех объявляемых функций. Обычно прототипы функций размещаются в заголовочных файлах с расширением .h .
К примеру возьмём функцию, складывающую два числа. Она принимает 2 числа в качестве аргументов и после своего выполнения возвращает их сумму. Пусть все они имеют тип long.
Её прототип размещается в заголовочном файле .h:

// Function prototype
long AddNumbers(long Argument1, long Argument2);

Её реализация размещается в файле исходного кода .cpp:

// Function implementation
long AddNumbers(long Argument1, long Argument2)
{
  return Argument1 + Argument2;
}

Вместо указания в прототипе функции имён переменных, ты можешь просто указать их тип данных, оставив их имена в реализации:

// Function prototype
long AddNumbers(long, long);
// Function implementation
long AddNum(long Num1, long Num2)
{
  return Num1 + Num2;
}

В языке C не требовалось писать прототип функции для её вызова. Компилятор сам создавал их, выяснял аргументы и возвращал значения нужного типа. Зачем тогда они нужны в C++? Рассмотрим каждую из причин отдельно.

Значения аргументов функций по умолчанию (Default Function Argument Values)

В C++ аргументы функции могут иметь заранее присвоенные значения по умолчанию, что часто сберегает кучу времени и системных ресурсов, т.к. не нужно их явно вбивать при каждом случае.
К примеру допустим, что у нас есть программа, которая берёт сумму денежного займа и прибавляет к ней определённый процент. Предположим, что стандартная сумма займа 10000 руб. и процентная ставка за пользование составляет 8%. В этом случае значения по умолчанию стоит прописать в аргументах прототипа функции:

float AddInterest(float Amount=10000.0, float Interest=0.08);

Здесь видим, что:

  • переменная Amount имеет значение по умолчанию равное 10000.0,
  • переменная Interest имеет значение по умолчанию равное 0.08.

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

// Function prototype
float AddInterest(float Amount=10000.0, float Interest=0.08);

// The actual function - нет необходимости явно указывать значения по умолчанию.
float AddInterest(float Amount, float Interest)
{
  return Amount*Interest;
}

main()
Amount = AddInterest(30000.0, 0.07); // 30000 под 7%
Amount = AddInterest(20000.0); // 20000 под 8%
Amount = AddInterest(); // 10000 под 8%

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

Перегрузка функции (Function overloading)

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

То есть ты можешь создать две (или более) функции у которых будет одинаковое имя, но они по-разному обрабатывают передаваемые параметры в зависимости от их количества.
Например ты можешь создать одну функцию, которая принимает несколько отдельных переменных в качестве параметров (arguments), либо эти параметры могут быть объединены в структуру, а, в свою очередь указатель (pointer) на эту структуру передаётся второй функции (вызываемому прототипу) с тем же именем.
Перегрузка проверяет, что компилятор знает, что за функцию он вызывает.

Закрыть
noteЛюбопытный факт

При компилировании компилятор перекодирует (encode) имена функций вместе со списком типов их параметров. Это позволяет ему отличать одну функцию (с тем же именем) от другой. Такое перекодирование называется name decoration или name mangling ("коверканье" имени).

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

// Прототипы функций

// Складываем 2 числа
long AddNumber(long Num1, long Num2);

// Складываем массив чисел
long AddNumbers(long *NumArray, long NumOfNums);

// Код функций
long AddNumbers(long Num1, long Num2)
{
  return Num1 + Num2;
}

long AddNumbers(long *NumArray, long NumOfNums)
{
  long Result, i;

  Result=0;
  while(NumOfNums--)
    {
     Result+=NumArray[i];

  return Result;
    }
}

Эти две функции могут мирно сосуществовать в одной программе/классе благодаря возможности перегрузки функций. Компилятор распознаёт, какую функцию вызывать, по передаваемым аргументам. Поэтому следующие два вызова функции верны:

Result = AddNumbers(10, 20);  // Result=30
long Array[5]={10, 20, 30, 40, 50};
Result = AddNumbers(Array, 5);  // Result=150

Встроенные (Inline) функции вставляются в компилируемый код, что увеличивает скорость выполнения и размер исполняемого файла.
Встроенные (Inline) функции вставляются в компилируемый код, что увеличивает скорость выполнения и размер исполняемого файла.

Встроенные функции (Inline functions)

Ты можешь существенно ускорить выполнение функции, применив пару проверенных способов. Однако при этом придётся пожертвовать увеличением размера исполняемого файла. Один из таких способов - указание функции в качестве встроенной (inline).
Когда мы указываем перед именем вызываемой функции ключевое слово inline, компилятор помещает текущую копию кода функции в специальную область вызовов (calling location), вместо того, чтобы просто разместить всё в стеке. Это означает, что при вызове функции 5 раз её код будет размещён в области вызовов, расположенной внутри исполняемого файла, 5 раз.
Вот пример встроенной функции, складывающей 2 числа:

inline long AddNumbers(long Num1, long Num2);

long AddNumbers(long Num1, long Num2)
{
  return Num1 + Num2;
}

main()
{
  long Result;
  Result = AddNumbers(10, 20);
  Result = AddNumbers(11, 6);
  Result = AddNumbers(1, 13);
}

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

Работа с переменными (variables)

  • в языке C++ была существенно расширена.

Объявление переменных (Variable declaration)

В C++ ты можешь объявлять переменную в любо месте кода, а не только в его начале. Также можно выбирать тип переменной при присвоении ей значения:

long SomeFunction(long Num1, long Num2)
{
  long Result;  // Доступна для текущей функции

  if(Num1 < Num2)
{
  long AddResult;  // Доступна для следующих двух строк
  AddResult = Num1 + Num2;
  Result = AddResult;
}
else
{
  long SubResult = Num1 - Num2;  // Доступна для следующей строки
  Result = SubResult;
}

SubResult = 0;  // Ошибка! Присвоение возможно только в теле функции!

Return Result;
}

Здесь видно, что переменная AddResult объявлена прямо в блоке условий (conditional block) прямо перед своим применением. Также объявлена переменная SubResult. Оба объявления допустимы, за исключением момента присвоения значения переменной SubResult (см комментарии в коде).

Область видимости и приоритет (старшинство) переменных (Scope and Precedence)

В примере выше представлен пример области видимости переменных. Объявленные переменные действуют только в области видимости того блока, в котором они объявлены. Таким образом переменная, объявленная в начале функции (например в блоке условий) действительная (видима, к ней есть доступ) в пределах данной функции.
В примере выше переменная Result объявлена в самом начале тела функции. Поэтому доступ к ней есть из любого места данной функции. Но с переменными AddResult и SubResult всё по-другому. Они видны только в блоках условного оператора if...else, где они объявлены. Любые попытки обратиться к эти переменным извне приведут к ошибке компиляции.
Благодаря области видимости ты можешь одновременно иметь глобальную и локальную переменные с одинаковым именем. Также как и в C, локальные переменные имеют больший приоритет (старшинство), чем глобальные. В C++ ты можешь явно (explicitly) указать компилятору использовать переменную в качестве глобальной, разместив перед её именем два знака двоеточие (::), как в этом примере:

long Result;  // Глобальная переменная (Global variable)

long SomeFunction(long Num1, long Num2)
{
  long Result;  // Локальная переменная
  Result = Num1 + Num2;  // Локальная переменная = Num1 + Num2
  // Обратив внимание на два двоеточия перед переменной в следующей строке
  ::Result = Num1 + Num2;  // Глобальная переменная = Num1 + Num2
  Result += ::Result;

return Result;
}
Закрыть
noteОбрати внимание

Единственный недостаток заключается в том, что в листинге ты не можешь производить операции с несколькими переменных с одинаковыми именами, но с разными областями видимости. К примеру, если у тебя есть глобальная (global) переменная Result, объявленная в начале функции, а затем ты объявляешь её повторно в блоке условий (conditional block), то по факту они будут расположены в разных областях видимости.
Поэтому для наглядности рекомендуется при объявлении ставить перед именем глобальной переменной префикс g_ (например, g_Result). Тема "говорящих" префиксов не нова и подробно рассмотрена в т.н. венгерской нотации(external link) (Hungarian Notation).

Приоритет (старшинство, precedence) также работает и с функциями.

Статические переменные (Static variables)

Переменные занимают место в памяти. Но что если тебе необходимо отследить значение переменной во время нескольких вызовов функции? Из-за особых свойств области видимости переменная теряет своё значение всякий раз, когда эта самая область видимости исчезает. Вот пример:

long IncreasePeopleCount()
{
  long NumberOfPeople = 0;
  NumberOfPeople += 1;
  return NumberOfPeople;
}

main()
{
  long Num;
  Num = IncreasePeopleCount();  //Num = 1;
  Num = IncreasePeopleCount();  // Num = 1;
}

Здесь представлена функция, которая пытается отследить некотрое число (число людей). Единственная проблема в том, что переменная NumberOfPeople всегда сбрасывается в 0 всякий раз при вызове функции. Конечно, в этом случае можно объявить переменную NumberOfPeople в качестве глобальной (global). Но раз уж эта переменная ассоциирована (применяется) только с одной функцией, её проще объявить как статическую (static):

static long NumOfPeople = 0;

Предварив объявление переменной кючевым словом static, ты информируешь компилятор отслеживать её значение даже при утрате ею области видимости. Значение, присвоенное переменной во время её объявления, является начальным и программа может изменять его в ходе своего выполнения. Теперь всякий раз при вызове функции IncreasePeopleCount (теперь со статической переменной) получаем инкрементный (увеличивающийся на единицу) счёт:
main
{
long Num;
Num = IncreasePeopleCount; // Num=1;
Num = IncreasePeopleCount; // Num=2;
Num = IncreasePeopleCount; // Num=3;
}

Константы (Const, неизменяемые переменные)

Часто функции в ходе своего выполнения изменяют значение переменных, что во многих случаях совсем не желательно. В такой ситуации на помощь приходят константы. Предварив переменную служебным словом const, мы укажем компилятору, что данная переменная (вернее её значение) доступна только для чтения и не может изменяться ни при каких обстоятельствах. Значение константе присваивается всего 1 раз - только при её объявлении.

const long ReadOnlyVariable = 10;  // Присваиваем значение 10 при объявлении
ReadOnlyVariable = 20;  // Ошибка! Переменная доступна только для чтения!

Константы также применимы в качестве параметров функций. Правда, в этом случае переменная остаётся константой только в пределах этой функции. Вот пара примеров:

main()
{
  const long Var=10;
  long i;

  Var=20;  // Ошибка! Только для чтения!
  SomeFunction(i);
}

void SomeFunction(const long Val)
{
  Val=10;  // Ошибка! Только для чтения!
}

Новые ключевые слова (keywords) и улучшения (enhancements)

Недостатки языка C породили улучшения и дополнения в языке C++. Самая большая проблема была связана с выделением (allocating) и освобождением (deallocating) памяти, ссылками на перечисляемые списки (enumerated lists) и разночтения значения NULL в разных компиляторах. И это только начало длинного списка.
Всеми этими улучшениями можно не пользоваться, но они заметно упрощают написание кода. К старым методам уже точно не захочется возвращаться.

Закрыть
noteОбрати внимание

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

Выделение памяти (Memory allocation)

В памяти компьютера содержится весь программный код, графика, звуковые эффекты и т.д. Рассмотрим пример выделения памяти под наши собственные нужды.
Выделение памяти - одно из нововведений C++. Здесь на помощь приходят 2 новых суперинтеллектуальных оператора: new и delete. С их помощью можно выделить любой тип памяти, будь то переменная, класс или структура. Причём это делается всего за один вызов.
Для выделения памяти используй оператор new:

void *Ptr = new DataType;  // для одного элемента
void *Ptr = new DataType[NumElements];  // для массива значений

Ptr представляет собой указатель (pointer) на участок памяти, которая выделяется.
DataType - тип хранимых данных.
Указатель можно создать на что угодно. Обычно он имеет тот же тип данных, что и запрашиваемый участок памяти. К примеру, допустим мы хотим выделить массив (array) длинной 10 элементов, 100 структур, одно значение с плавающей точкой (float) и массив из 20 указателей:

long *Ptr = new long[10];
sMyStruct *Ptr2 = new sMyStruct[100];
float *Ptr3 = new float;  // одно число типа float
char *Ptr4 = new char*[20];

Вся память, выделенная с помощью операторов new должна быть освобождена по завершении выполнения программы/функции. Это выполняет оператор delete. Достаточно просто вызвать его вместе с указателем на освобождаемый участок памяти. Всю остальную грязную работу сделает C++. При удалении массива оператор delete необходимо указывать вместе с квадратными скобками([]):

delete[] Ptr1;  // массив long[10]
delete[] Ptr2;  // структура sMyStruct[100]
delete Ptr3;  // одно значение типа float - без угловых скобок
delete[] Ptr4;  // char*[20]

Оператор delete можно даже вызывать для удаления указателя с присвоенным значением NULL, без необходимости проверять его содержимое:

char *DataPtr=NULL;
delete DataPtr;

Выделение памяти для многомерного (multidimensional) массива выглядит немного по-другому:

long (*Ptr)[10][20];
Ptr = new long[5][10][20];  // Выделяем
delete[] Ptr;  // Освобождаем

Если необходимо выделить память под массив указателей, то просто сделай так:

char **Pointers=NULL;
Pointers = new char*[10];  // Массив из 10 указателей типа char
delete[] Pointers;  // Не забудь освободить память!

Ключевое слово NULL (ноль)

В языке C слово NULL могло означать любую переменную, какая только вздумается производителю компилятора. Но в C++ NULL - это всегда 0 (ноль). В дебрях базовых заголовков можно отыскать соответствующий макрос.

Ключевое слово Enum (перечисление)

При работе со списками перечислений (enumeration lists) после соответствующего объявления не требуется повторно указывать ключевое слово enum.

enum Numbers
{
  First = 1,
  Second,
  Third,
  Tenth = 10
};
short Value = Second;  // Равно 2

В последней строке нет ключевого слова enum, т.к. оно есть в инициализации перечисления выше.

Закрыть
noteОбрати внимание

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

Структуры всегда разрешают свободный доступ к своим данным, в то время как классы - нет.
Структуры всегда разрешают свободный доступ к своим данным, в то время как классы - нет.

Классы (Classes)

Собственно, ради чего и задумывался C++. Будучи объектно-ориентированным языком программирования, C++ оперирует классами как обычными объетками. Такие объекты самодостаточны. Каждый из них имеет свой собственный набор данных и функций. Это делает данные объекты переносимыми (portable) и заточенными под повторное использование (reusable).
Если суть классов до конца не ясна, представь, что класс - это та же структура из языка C, только на "стероидах". Класс объявляется почти так же как и структура. Сперва пишется ключевое слово class а затем имя класса. Данные класса заключены в его тело и обрамляются фигурными скобками ({}).

class cClassName
{
};
Закрыть
noteОбрати внимание

Правилом хорошего тона является указание перед именем любого класса английской буквы с. Это сильно повышает читабельность кода. Сразу видно, что cClassName - это имя класса, а, например, sStructureName - имя структуры. Венгерская нотация и Майкрософт рекомендуют писать имена классов с большой (прописной) английской буквы C.

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

struct sMyStructure
{
  long Var1, Var2, Var3;
};

main()
{
  sMyStructure TestStruct;

  TestStruct.Var1 = 1;
  TestStruct.Var2 = 2;
  TestStruct.Var3 = 3;
}

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

Видимость классов (Class visibility)

При обращении к данным внутри класса как правило запрашиваются переменные и функции. Функции внутри класса могут выполняться как самим классом, так и внешними конструкциями кода за пределами данного класса. Вот пример:

class cClassName
{
 public:
  // Размещаем здесь открытые для чтения/записи извне переменные и функции.
  long m_PublicVar;
  long PublicFunction();

 private:
  // Размещаем здесь закрытые для чтения/записи извне переменные и функции.
  long m_PrivateVar;
  long PrivateFunction();

 protected:
  // Размещаем здесь переменные и функции, доступные только для данного класса и всех ветвящихся (derived) от него классов.
  long m_ProtectedVar;
  long ProtectedFunction();
};

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

public Данные класса с таким идентификатором свободно доступны из других участков исходного кода (вне данного класса).
private Данные класса с таким идентификатором доступны только для функций внутри этого класса. Доступ для кода вне данного класса закрыт.
protected Почти то же самое, что и protected. Данные доступны только для этого класса и всех других, ветвящихся от него классов.

Вот пример доступа к данным этого класса из внешнего кода:

cClassName MyClass;

MyClass.mPublicVar = 10;  // OK!
MyClass.PublicFunction();  // OK!
MyClass.mPrivateVar = 11;  // Недоступно.
MyClass.PrivateFunction();  // Недоступно.
MyClass.mProtectedVar = 12;  // Недоступно.
MyClass.ProtectedFunction();  // Недоступно.
Закрыть
noteОбрати внимание

Имена внутренних переменных класса предваряются префиксом m. Это негласное правило тоже прописано в Венгерской нотации.

Переменные и функции класса

Недостаточно просто объявить переменную или функцию в классе. С переменными всё достаточно просто: достаточно объявить их под определённым идентификатором доступа. Попытка получить доступ к переменным protected или private вызовет ошибку при компиляции.
Внутри класса доступны все объявленные функции. Следующий шаг - написать их реализации в соответствующем .cpp-файле, принадлежащему данному классу. Реализация (или вызов) функций, принадлежащих классу, должны предваряться именем этго класса и двумя двоеточиями:

long cClassName::PublicFunction(void);

Пара двоеточий информирует компилятор о том, что вызываемая функция принадлежит данному классу. Файл исходного кода может содержать в себе функции, принадлежащие тому или иному классу, либо не принадлижащие никакому классу. Поэтому два двоеточия - едиснтвенный способ связать функции с их классами-собственниками.
Остаётся вопрос: как получить доступ к закрытым функциям внутри класса из внешнего кода? Один из способов - через открытые (public) функции данного класса. Разберём пример:

long cClassName::PublicFunction()
{
  return m_PublicVar + ProtectedFunction() + m_PrivateVar;
}

long cClassName::ProtectedFunction()
{
  return m_ProtectedVar;
}

long cClassName::PrivateFunction()
{
  return m_PrivateVar;
}

Как видим, public-функция свободно получает доступ к остальным закрытым переменным и функциям своего класса. При этом она остаётся открытой для внешнего кода.
Другим бонусом использования функций в классах является возможность объявления (defining) функции при определении (declaration) класса. Обычно так объявляют небольшие функции:

class cClass
{
 public:
  long PublicFunction(void)
    {
     return m_PublicVar + ProtectedFunction() + mProtectedVar;
    }
  // Другой код декларации класса.
}

Статические (Static) переменные и функции

Обычно экземпляры класса всегда имеют доступ к собственной копии внутренних данных. Каждый создаваемый экземпляр класса понятия не имеет о других экземплярах того же класса. Для того, чтобы переменные и функции были доступны во всех экземплярах (=инстансах) класса их определяют как static (статические):

class cClassName
{
 public:
  static long m_Variable;
  static long Function();
}

За пределами класса указывается значение статической переменной по умолчанию:

long cClassName::m_Variable=100;

Данное присвоение является глобальным (global). Теперь мы можем получить доуступ к этой переменной точно так же, как это делается для переменных внутри класса. Значение будет оставаться неизменным для всех экземпляров класса. Если какой-либо из экземпляров изменит значение статической переменной, оно изменится для всех экземпляров данного класса.
Что касается функций, объявление оной как статической делает её доступной для всех экземпляров данного класса. Но сама функция "не знает" о том, какой именно экземпляр её вызвал. Как вариант, можно пометить статическую функцию указателем на вызывающий класс. Вот пример программы, где объявляются статические переменная и функция и затем демонстрируется их работа:

class cClassName
{
 public:
  static long m_Var;
  static long Function();
};

// Присваиваем статической переменной значение по умолчанию
long cClassName.m_Var=10;

long cClassName::Function()
{
  m_Var++;  // Инкрементируем (увеличиваем на 1 с каждым проходом) значение статической переменной.
  return m_Var;
}

main()
{
  cClassName Class1, Class2;
  printf("%lu/r/n", Class1.Function());  // Выводит на экран 1
  printf("%lu/r/n", Class2.Function());  // Выводит на экран 2
}

Конструктор (constructor) и деструктор (destructor) класса

Помимо пользовательских, в каждом классе обязательно присутствуют 2 специальные функции:

  • Конструктор (constructor) класса, вызываемый для создания экземпляра класса.
  • Деструктор (destructor), вызываемый для уничтожения экземпляра класса.

Конструктор и деструктор всегда видимы (public). Имя конструктора всегда совпадает с именем класса.
Имя деструктора также совпадает с именем класса, но в целях различия к его имени добавляют префикс `(тильда). Конструктор и деструктор можно перегружать (overload):

class cClassName
{
 public:
  cClassName();  // Конструктор по умолчанию
  cClassName(long Var1, long Var2);  // Перегруженный конструктор
  cClassName(char *Data);  // Перегруженный конструктор
  `cClassName();  // Деструктор
};

cClassName::cClassName()
{
 // Проделываем здесь всякие инициализации.
}

cClassName::cClassname(long Var1, long Var2)
{
 // Проделываем здесь всякие инициализации.
 // В данном случае с двумя переменными параметрами.
 // Даже в том случае, если будет вызван другой конструктор!
 cClassName();
}

cClassName::cClassName(char *Data)
{
 // Проделываем здесь всякие инициализации.
}

cClassName::`cClassName()
{
  // Освобождаем (удаляем из памяти) все ранее задействованные переменные.
}

Констуктор вызывается всякий раз при создании экземпляра класса (instance) или объявления его нового экземпляра с помощью ключевого слова new. Конструктор может быть перегружен для предоставления нещскольких способов инициализации внутренних данных.
Деструктор:

  • всегда вызывается без аргументов (=параметров),
  • доступен извне (public),
  • вызывается автоматически при удалении экземпляра класса,
  • не возвращает значений.

Вот ещё несколько примеров использования конструктора и деструктора класса:

cClassName MyClass;  // Конструктор по умолчанию, вызываемый при первом выполнении программы/класса.

main()
{
 cClassName SomeClass(10, 22); // Первый перегруженный конструктор, вызываемый при выполнении функции main.
 cClassName My2ndClasss("Hello"); // Второй перегруженный конструктор, вызываемый при выполнении функции main.
 cClass *ClassPtr;
 ClassPtr = new cClassName; // Тоже вызов конструктора.

 delete ClassPtr;  // Вызываем деструктор
 // По окончании выполнения функции delete вызывается деструктор `My2ndClass.
}
// Теперь вызывается MyClass, завершаюший выполнение программы.

Функции-операторы (Operator Functions)

Функции класса также могут принимать форму операторов.
Операторы в C++ обычно совпадают с основными математическими операторами. Например такими как сложение (+), вычитание (-), умножение (*) и деление (/).
Вот их полный список:
Image
Для объявления оператора класса:

  1. Сперва указывается возвращаемое значение. Для операторов += ли = - это имя класса, которое передаётся в другой класс того же типа. При операциях сравнения (=) возвращаемое значение имеет один из стандартных типов данных.
  2. Вторым идёт ключевое слово operator.
  3. После символа оператора идёт стандартный набор параметров (=аргументов).

Вот пример:

class cClassName
{
 public:
  cClassName operator= (long Val);
  cClassName operator+ (long Val);
  long Value()
  {
   return m_Value;  // Возвращает значение защищённой (protected) переменной.
  }

 protected:
  long m_Value;
}

// Не требуется указывать тип возвращаемого значения (return type)
cClassName cClassName::operator+=(long Val)
{
 m_Value +- Val;
}

main()
{
 cClassName MyClass;
 MyClass = 10;
 myClass +- 20;
 printf("%lu\r\n", MyClassValue());  // prints 30
}

Ключевое слово this

Иногда классу необходимо знать указатель на самого себя. Для этого применяется ключевое слово this (англ. "этот"). Оно является зарезервированным (=встроенным) указателем, которым автоматически снабжается каждый сосздаваемый класс. Более того: оно автоматически (неявно, скрытно) добавляется при вызове любой функции, принадлежащией данному классу. Рассмотрим такой пример типичной функции класса C++:

long cClassName::SomeFunction(long Val)
{
 return Val + 10;
}

После компилирования и добавления скрытых (hidden) параметров выглядит так:

long cClassName::SomeFunction(cClassName *this, long Val)
{
 return Val + 10;
}

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

class cClassName
{
 public:
  cClassName() {SomeFunction(this);}
  long m_PublicVar;
};

void SomeFunction(cClassName *Ptr)
{
 Ptr->m_PublicVar=10;
}

main()
{
 cClassName cl;  // cl.mPublicVar теперь равно 10.
}

Закрыть
noteОбрати внимание

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

Дружественные классы (Class Friends)

При объвлении нескольких классов часто возникает необходимость в обмене информацией между ними, и, вместе с тем, ограничить её видимость для внешнего кода. К примеру, в классе Class1 есть защищённая (protected) переменная, к которой надо получить доступ из класса Class2. Конечно, можно создать в Class1 открытую (public) функцию, предоставляющую доступ к этой переменной. Но есть способ лучше - объявить Class1 другом классу Class2. Обычно это объявляется в классе, данные которого должны быть расшарены (=доступны классу-другу). Но такое объявление невзаимно: при этом Class1 не получает доступ к данным Class2. К примеру, если первый класс объявляет второй в качестве своего друга, то после этого второй класс получает свободный доступ к данным первого. Но это не даёт первому классу доступ к данным второго (до тех пор, пока второй класс не объявит первый своим другом). Вот пример:

class cClass1
{
 friend cClass2;  // cClass2 получает доступ к моим данным

 protected:
  long m_Value;

 public:
  // Следующая строка кода недоступна для cClass2, т.к. cClass2 не объявлял меня своим другом.
  cClass1()
 {
   cClass2 MyClass;
   MyClass.m_Value=1;
 }
};

class cClass2
{
 // cClass1 не имеет доступа к моим данным, т.к. не объявлен здесь в качестве друга.
 protected:
  long m_Value;

 public:
  // Следующая строка допустима
  cClass()
   {
    cClass1 MyClass;
    MyClass.m_Value=2;
   }
};

Видим, что класс cClass2 имеет доступ к данным cClass1, т.к. объявлен в cClass1 другом. Но это невзаимно. cClass1 не имеет доступа к данным cClass2 до тех пор, пока cClass2 не объявит cClass1 своим другом.

Ветвящие классы образуют на основе базового класса. Ветвящиеся (дочерние) классы можно также создавать на основе уже ответвлённых классов.
Ветвящие классы образуют на основе базового класса. Ветвящиеся (дочерние) классы можно также создавать на основе уже ответвлённых классов.

Ветвящиеся классы (Derived classes)

Когда приходит время добавить функционала в существующий класс, мы создаём дочерний класс, владеющий всеми функциями класса-родителя, и затем добавляем в потомок новые улучшенные функции и переменные. Метод создания нового класса на основе данных существующего класса называется наследованием (inheritance). Именно благодаря ему можно ответвить объект от уже имеющегося. При этом исходный класс называют базовым (base class), а созданный на его основе - дочерний или ответвлённый (derived class). Дочерний класс определяется так:

class cDerivedClass : public cBaseClass
{
 // Данные дочернего класса.
 // Эти данные добавляются к данным, уже имеющимся в базовом (родительском) классе.
}

Раз уж данные исходного (базового) класса cBaseClass автоматически наследуются во всех дочерних, то нет необходимости добавлять их вновь. Достаточно просто добавить новые данные. Дочерний класс легко получает доступ к данным родительского (базового) класса, за исключением тех, что имеют идентификатор доступа private (закрытые).

Закрыть
noteОбрати внимание

Предварив базовый класс ключевым словом public мы указываем компилятору, что дочерние классы могут свободно обращаться данным базового класса. Для предотвращения этого используй ключевые слова private (закрытый) или protected (защищённый).

Закрыть
noteОбрати внимание

Закрытые (private) данные в базовом классе недоступны для дочерних классов. Тем не менее эти данные существуют. Обычно эти данные оч. важны и намеренно закрыты в классе-родителе.

Вот пример создания ветвящегося класса:

class cBaseClass
{
 public:
  long m_PublicVar;
};

class cDerivedClass : public cBaseClass
{
 public:
  long m_PublicVar2;
};

main()
{
 cBaseClass BClass;
 cDerivedClass CClass;
 BClass.m_PublicVar = 10;
 DClass.m_PublicVar2 = 100;
 DClass.m_PublicVar = 11;
}

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

class cBase
{
 public:
  long m_Var;
};

class cDerived : public cBase
{
 public:
  cDerived(long Var)
  {
   m_Var = Var; // Используем m_Var из класса, наследуемого от cBase.
  }
}

long AddValue(cBase *Base, long ToAdd)
{
 m_Var += ToAdd; // Прибавить указанное значение к переменной m_Var класса cBase
               // даже если передаваемый класс ветвится от него.
}

main()
{
 cDerived MyClass(50);
 AddValue(&MyClass, 100); // Переменная MyClass.m_Var теперь имеет значение 150. 
}
Закрыть
noteОбрати внимание

Способность вызывать функцию базового класса (т.н. "базовую" функцию) из дочернего класса называют полиморфизм (polymorphism). В игрокодинге используется почти повсеместно.

Закрыть
noteОбрати внимание

В примере выше видим, как дочерний класс передаёт ссылку (reference = указатель) на базовый класс. Это нормально. Но в целях защиты данных базового класса от вызываемой функции, необходимо в вызываемой функции указать параметр класса в качестве переменной типа const (константа).

Данные базового класса могут быть защищены от доступа из дочернего класса путём указания в определении дочернего класса ключевого слова private или protected:

class cBaseClass{private: long Var1;};
class cDerivedClass : private cBaseClass{private: long Var2;};

class cDerivedAgainClass : public cDerivedClass
{
 public:
  long GetValue() {return Var1;}  // Ошибка! Нет доступа.
}

Здесь, после определения класса cDerivedClass как наследника cBaseClass (в этот раз предварив cBaseClass ключевым словом private), доступ к переменной Var1 имеет только класс cDerivedClass.

Виртуальные функции (Virtual functions)

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

class cBaseClass
{
 public:
  void PrintIt() {PrintNum(1);}
  void PrintNum(long Num) {printf("%lu", Num);}
};

Здесь при вызове функции PrintIt из класса cBaseClass, на экран выводится число 1. Для добавления нового функционала к данной функции базового класса (скажем, для вывода перед числом какого-либо текста) обычно создают дочерний класс, в котором объявляют новую функцию PrintNum:

class cDerivedClass : public cBaseClass
{
 public:
  void PrintNum(long Num) {printf("The number is %lu", Num);}
};

В дальнейшем при создании экземпляра класса cDerivedClass и вызове функции PrintIt (которая, в свою очередь, вызывает функцию PrintNum), ожидаем, что в результате вывод будет такой:

The number is 1

Но на деле выводится только число:

1

А всё из-за того, что вызывается функция PrintNum базового класса, вместо своей тёзки из дочернего класса. Ведь компилятор ничего не знает о второй версии функции PrintNum из дочернего класса. Кроме того, базовый класс ничего не знает о своих потомках: он знает только о самом себе.
Для решения этой проблемы используют ключевое слово virtual. При объявлении функции в базовом классе с данным ключевым словом мы информируем класс о том, что дочерние классы могут (а могут и нет) переопределять (override) её. И если функция переопределена, то в этом случае будет использоваться её версия, переопределённая в дочернем классе.
Зная это, перепишем определение базового класса так:

class cBaseClass
{
 public:
  void PrintIt() {PrintNum();}
  virtual void PrintNum(long Num) {printf("%lu", Num);}
};

Теперь определение дочернего класса (не нужно абсолютно всё определять как virtual) и вызов функции PrintIt теперь принесёт желаемый эффект и выведет число с текстом перед ним:

The number is 1

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

Ключевое слово const перед определением класса

Переменные, функции и даже классы, объявленные с ключевым словом const, предназначены только для чтения (read only). Класс объявляется константным так:

const cClass MyClass(Var);

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

long cClass::ReadValue() const
{
 return m_Value;  // Может только возвращать значение.
}

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

Продвинутые структуры (Advanced structures)

Может показаться, что с появлением классов обычные структуры уже не нужны. Но это не так. В С++ структуры стали продвинутыми (advanced) и, наряду с классами, имеют свой собственный набор встроенных функций, включая конструктор и деструктор! Это может оказаться полезным, например, для задания переменным, хранящимся в структурах, значений по умолчанию. Например, можно создать структуру, которая резервирует (allocate) себе кусок памяти, а затем освобождает его при своём уничтожении:

typedef struct sMyStruct
{
 char *Ptr;
 sMyStruct()
 {
  Ptr = new char[100];
 }
 
 sMyStruct
 {
  delete[] Ptr;
 }
} sMyStruct;

main()
{
 sMyStruct *MyStruct;
 
 MyStruct = new sMyStruct;
 MyStruct->Ptr[50] = 10;
 delete MyStruct;
}

Просто относись к продвинутым структурам как к классам и тогда ты поймёшь, как они работают.

Заключение

Все учебные курсы по программированию игр на C++ почти целиком основаны на вышеизложенном материале. Данная теория является основой основ любого проекта.

Источники:


1. Jim Adams. Programming Role Playing Games with DirectX 8.0. - Premier Press. 2002
2. Morrison M. Sams Teach Yourself Game Programming in 24 hours. - Sams Publishing. 2002

Contributors to this page: slymentat .
Последнее изменение страницы Воскресенье 29 / Март, 2020 23:33:45 MSK автор slymentat.

Помочь проекту

Яндекс-деньги: 410011791055108