Создаём компьютерную игру
eng   рус

Классы и объекты

Предыдущий урок: C++ Структуры. Пользовательские типы в С++

В данном уроке мы рассмотрим классы в C++ и познакомимся с объектно-ориентированным программированием. Объектно-ориентированное программирование или ООП - это одна из парадигм программирования. Парадигма - это, другими словами, стиль. Парадигма определяет какие средства используются при написании программы. В ООП используются классы и объекты. Все наши предыдущие программы имели элементы разных парадигм: императивной, процедурной, структурной.

Мы можем написать одинаковую программу в разных парадигмах. Парадигмы не имеют чёткого определения и часто пересекаются.

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

struct Tank { int ammo; }; void TankAttack(Tank* t) { t->ammo -= 1; // другой код } Tank t1 { 10 }; Tank t2; t2.ammo = 5; TankAttack(&t1); TankAttack(&t2);

У нас есть структура, которая содержит поле, представляющее количество снарядов, и есть функция атаки, в которую мы передаём танк. Внутри функции мы меняем количество снарядов. Так может выглядеть игра на языке C: структуры отдельно от функций, которые совершают действия со структурными переменными. Данную ситуацию можно смоделировать по-другому с помощью объектно-ориентированного программирования (Object-Oriented Programming, OOP) - ООП.В ООП действия привязываются к объектам.

Определение классов в C++

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

class Tank { private: int ammo; public: void Attack() { ammo -= 1; } };

В C++ ключевые слова struct и class очень близки и могут использоваться взаимозаменяемо. У них есть только одно отличие (об этом ниже). Вот как можно определить такой же класс с помощью struct:

struct Tank { private: int ammo; public: void Attack() { ammo -= 1; } };

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

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

Переменные и методы класса

Класс состоит из членов класса (class members). Члены класса могут быть переменными (data members) или методами (function members или methods). Переменные класса могут иметь любой тип данных (включая другие структуры и классы). Методы - это действия, которые может выполнять класс. По сути, это обычные функции.

Все методы класса имеют доступ к переменным класса. Обратите внимание, как мы обращаемся к ammo в методе Attack.

Создание объектов класса

Теперь у нас есть свой тип данных и мы можем создавать переменные данного типа. Если после определения структур мы могли создавать структурные переменные, то в случае классов, мы создаём объекты классов (или экземпляры). Разница между классами и структурами только в терминах. Для C++ это почти одно и то же.

Вот так мы можем создать объекты класса Tank и вызвать метод Attack:

Tank t1; Tank t2; t1.Attack(); t2.Attack();

t1 и t2 - объекты класса Tank. Для C++ объект класса - это всего-лишь переменная. Тип данных этих переменных - Tank. Ещё раз повторю, что классы (и структуры) позволяют создавать пользовательские типы данных.

В англоязычной литературе создание объектов классов также называется созданием экземпляров - instantiating.

Мы обращаемся к переменным класса и методам с помощью оператора точки (прямой доступ), также как мы обращались к полям структурных переменных.

В нашем примере каждый объект имеет доступ к своей копии ammo. ammo - переменная класса (data member). Attack - метод класса. У каждого объекта своя копия переменных класса, но все объекты одного класса вызывают одни и те же методы.

Размер объекта включает все данные, но не методы

В памяти переменные класса располагаются последовательно. Благодаря этому мы можем создавать массивы объектов и копировать их все вместе (если в классе этих объектов нет динамического выделения памяти). Это будет важно для нас, когда мы начнём работать с графикой в DirectX/OpenGL. Размер объекта класса можно узнать с помощью функции sizeof. При этом в качестве аргумента можно использовать как объект, так и сам класс:

sizeof(Tank); sizeof(t1);

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

Указатель this

Вот как для компилятора выглядит любой метод:

class Tank { private: int ammo; public: void Attack(Tank* this) { this->ammo -= 1; } };

Это просто иллюстрация. В реальности не нужно указывать аргумент (всё что в круглых скобках). Мы автоматически получаем доступ к указателю this. В данном случае его использование перед ammo необязательно, компилятор автоматически привяжет эту переменную к this.

Указатель this нужен, когда методу необходимо вернуть указатель на текущий объект.

Указатели на объекты

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

Tank* t1 = new Tank(); t1->ammo = 5; t1->Fire(); Tank* t2 = nullptr;

При использовании ссылки на объект, для доступа к его членам используется оператор прямого доступа (точка), т.е. с ссылкой можно обращаться как с обычным объектом:

Tank t1; Tank& t2 = t1; t2.ammo = 10; // t1 меняет тоже значение t2.Move();

Чуть ниже мы увидим один случай, когда не обойтись без ссылок.

Конструктор класса (Constructor)

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

class Point { public: int x; int y; // в public, чтобы не писать методы доступа, об этом ниже Point() { // конструктор x = 10; y = 10; } } Point p1; // в этот момент автоматически вызывается конструктор p1.x; // x = 10 Point* p2 = new Point(); // вызов конструктора явно

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

Перегрузка конструктора класса

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

class Point { public: int x, y; // в public, чтобы не писать методы доступа, об этом ниже Point() { // конструктор без параметров x = 10; y = 10; } Point(int a, int b) { // конструктор с параметрами x = a; y = b; } }; Point p1; Point p2(10, 5); // разные Point p3 = {1, 1}; // варианты Point p4{3,4}; // инициализации

Начальные значения можно задавать в виде списка инициализации. Выше в конструкторе мы инициализировали переменные внутри тела. Список инициализации идёт перед телом конструктора и выглядит так:

Point(int a, int b) : x(a), y(b) { // тело конструктора }

В списке инициализации можно задать значение только части переменных класса.

Копирующий конструктор (Copy Constructor)

Без каких-либо действий с нашей стороны мы можем присваивать объектам другие объекты:

Point p1{10, 5}; Point p2; p2 = p1;

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

Point(Point& point) { x = point.x; y = point.y; // другие действия: динамическое выделение памяти }

В копирующем конструкторе всегда используются ссылки. Это обязательно!!! Параметр point - это объект, стоящий справа от оператора присваивания.

Деструктор класса

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

Допустим в нашем танке есть экипаж, пусть это будет один объект типа Unit. При создании танка мы выделяем память под экипаж. В деструкторе нам нужно будет освободить память:

class Unit { // код } class Tank { private: Unit* crew; public: Tank() { crew = new Unit(); } ~Tank() { delete crew; } }

Имя деструктора совпадает с именем класса и перед ним ставится тильда ~. Деструктор может быть только один.

Объектно-ориентированное программирование в C++ (ООП)

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

Инкапсуляция данных - Encapsulation

Что означает слово Encapsulation? Корень - капсула. En - предлог в. Инкапсуляция - это буквально помещение в капсулу. Что помещается в капсулу? Данные и действия над ними: переменные и функции. Инкапсуляция - связывание данных и функций. Давайте ещё раз взглянем на класс Tank:

class Tank { public: int ammo; void Attack() { ammo -= 1; } };

Собственно, здесь в класс Tank мы поместили переменную ammo и метод Attack. В методе Attack мы изменяем ammo. Это и есть инкапсуляция: члены класса (данные и методы) в одном месте.

В C++ есть ещё одно понятие, которое связано с инкапсуляцией - сокрытие данных. Сокрытие предполагает помещение данных (переменных класса) в область, в которой они не будут видимы в других частях программы. Для сокрытия используются спецификаторы доступа (access specifiers). Ключевые слова public и private и есть спецификаторы доступа. public говорит, что весь следующий блок будет видим за пределами определения класса. private говорит, что только методы класса имеют доступ к данным блока. Пример:

class Unit { private: int x, y; void Update() { // код } public: int ammo; void Move(int dx, int dy) { x += dx; y += dy; } }; Unit u1; u1.ammo = 10; // всё в порядке u1.Move(1,-1); // всё в порядке u1.x = 10; // ошибка u1.Update(); // ошибка

Здесь мы видим, что объект может получить доступ только к членам класса, находящимся в блоке public. При попытке обратиться к членам класса (и переменным, и методам) блока private, компилятор выдаст ошибку. При этом внутри любого метода класса мы можем обращаться к членам блока private. В методе Move мы изменяем скрытые переменные x и y.

Хороший стиль программирования в ООП предполагает сокрытие всех данных. Как тогда задавать значения скрытых данных и получать доступ к ним? Для этого используются методы setters и getters.

Setters and Getters

Setters и Getters сложно красиво перевести на русский. В своих уроках я буду использовать английские обозначения для них. Setter (set - установить) - это метод, который устанавливает значение переменной класса. Getter (get - получить) - метод, который возвращает значение переменной:

class Unit() { private: int x, y; public: void SetX(int value) { x = value; } void SetY(int value) { y = value; } int GetX() { return x; } int GetY() { return y; } }

Имена не обязательно должны включать Set и Get. Использование setters и getters приводит к увеличению количества кода. Можно ли обойтись без инкапсуляции и объявить все данные в блоке public? Да, можно. Но данная экономия кода имеет свои негативные последствия. Мы будем подробно обсуждать данный вопрос, когда будем говорить об интерфейсах.

Следующая концепция ООП - наследование.

Наследование (Inheritance) в C++

Начнём, как обычно с гипотетической ситуации. В нашей игре есть много разновидностей юнитов. Один может стрелять (лучник), другой может колоть (копейщик), третий рубить (мечник). Это всё разные действия и для каждого юнита лучше иметь отдельный класс. Но при этом, у них много общего: каждый из них имеет местоположение в пространстве, ориентацию, скорость. Т.е. при написании трёх классов, существенная часть кода будет повторяться. ООП позволяет решить эту проблему с помощью наследования. При наследовании создаётся базовый класс, в который помещается общий код. Затем создаются производные классы, каждый из которых наследует все члены базового класса и при этом привносит что-то своё. Посмотрим на код:
class Unit { protected: int x, y; public: void Move(int dx, int dy) { x += dx; y += dy; } }; class Archer : public Unit { private: int numArrows; public: void Shoot() { numArrows -= 1; } void SetArrows(int num) { numArrows = num; } }; class Swordsman : public Unit { // для краткости класс пустой }; class Spearman : public Unit { }; Archer a1; a1.Move(5,1); a1.Shoot();

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

При наследовании производный класс имеет доступ ко всем членам (public и protected) базового класса. Именно поэтому мы можем вызвать метод Move для объекта типа Archer.

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

Полиморфизм (Polymorphism)

Наследование открывает доступ к полиморфизму. Poly - много, morph - форма. Это очень мощная техника, которую мы будем использовать постоянно.

Полиморфизм позволяет поместить в массив разные типы данных:

std::vector units; units.push_back(new Archer()); units.push_back(new Spearman()); units.push_back(new Swordsman()); for (int i = 0; i < units.size(); i++) { units[i]->Move(5,5); }

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

Заключение

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

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

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

Комментарии:

20 мая 2020 г. 5:10
8 Guest
Насчёт инкапсуляции, главное надо понимать, что хоть сокрытие данных и входит в инкапсуляцию, но это не одно и тоже. Как пример, язык Си. Он не объектно-ориентированный язык, значит он не имеет такой черты, как инкапсуляция, но в нём также можно скрывать данные, если разбивать код на отдельные модули. В языках C++, Java, C#, эти понятия очень близки к друг другу, но вот в Python уже нет как такового сокрытия данных, но есть чистое инкапсуляция. Merlo Vevito
16 мая 2020 г. 13:05
6 Guest
крутой сайт, куда понятнее, чем на парах
8 марта 2020 г. 21:24
5 roman
Продолжение будет, но неизвестно когда. В конце марта буду подводить итоги и думать, что писать дальше.
4 марта 2020 г. 21:57
3 Guest
Будет ли продолжение?
17 января 2020 г. 17:25
2 roman
Исправил ошибку в описании инкапсуляции