Классы в C++ принято использовать как более сложное и семантически нагруженное представление данных по сравнению со struct.
Как я говорил ранее структуры и классы имеют одинаковые возможности для реализации всех представленных в C++ механик, за исключением нескольких отличий, которые так же могут быть нивелированы.
Об этом мы поговорим позже, а сейчас нужно сделать акцент на остальных функциональных возможностях, которые попросту не поместились в материал по struct, но касаются и class в такой же степени.
Доступность полей
Поля структур и классов могут иметь модификаторы. Одни из них это модификаторы доступа private и public.
Изначально все поля и методы в структуре имеют модификатор public, даже если мы этого явно не указывали.
По этой причине мы спокойно могли обращаться к полям struct.
В случае class поля и методы по умолчанию имеют модификатор private и мы получим ошибку при обращении к таким полям.
class Player {
int health;
};
int main()
{
Player p;
//member "Player::health" (declared at line 4) is inaccessible
//'Player::health': cannot access private member declared in class 'Player'
std::cout << p.health << std::endl;
return 0;
}
При этом член класса будет доступен внутри класса, для методов, но всё ещё не доступен при обращении извне.
class Player {
int health;
int getHealth() {
return health;
}
};
int main()
{
Player p;
//'Player::getHealth': cannot access private member declared in class 'Player'
//function "Player::getHealth" (declared at line 6) is inaccessible
std::cout << p.getHealth() << std::endl;
return 0;
}
Такое решение сделать все члены класса изначально приватными было принято для поддержки инкапсуляции, чтобы внутреннее состояние объекта по умолчанию было недоступно для внешнего кода.
Тут мы плавно начинаем соприкасаться с объектно-ориентированным программированием.
Для того, чтобы избежать этого нам необходимо явно указать модификатор доступа public.
class Player {
public:
int health;
public:
int getHealth() {
return health;
}
};
int main()
{
Player p;
p.health = -100000;
std::cout << p.getHealth() << std::endl;
return 0;
}
-100000
Зачем вообще нужно было делать все поля изначально private?
В примере выше мы без препятствий задали отрицательное значение в поле health. Может ли значение здоровья принимать отрицательные значения - отдельный вопрос.
Дело в том, что во всех местах где персонаж получает урон мы можем без дополнительных условий менять состояние экземпляра объекта.
Если мы захотим сделать так, чтобы задаваемое значение здоровья не могло быть меньше нуля нам придётся в каждом месте писать проверку и сравнивать текущее значение с нулём. Вместо этого мы можем написать метод takeDamage и инкапсулировать эту логику.
class Player {
private:
int health = 100;
public:
int getHealth() {
return health;
}
void takeDamage(int damage) {
if (damage > 0) {
health -= damage;
}
if (health < 0) {
health = 0;
}
}
};
int main()
{
Player p;
//p.health = -100000; //member "Player::health" (declared at line 5) is inaccessible
std::cout << p.getHealth() << std::endl;
p.takeDamage(25);
std::cout << p.getHealth() << std::endl;
p.takeDamage(90);
std::cout << p.getHealth() << std::endl;
return 0;
}
При такой реализации поле health можно изменить только через метод takeDamage и не нужно дублировать код. Но на самом деле такой подход не только предотвращает дублирование кода, но и повышает безопасность, поскольку вы не сможете случайно изменить это поле и сломать задуманную логику.
class Player {
private:
int value1 = 100;
int value2 = 100;
public:
int value3 = 100;
private:
int value4 = 100;
};
Мы можем чередовать поля с разными модификаторами, а так же объявлять несколько полей подряд с одним и тем же модификатором.
Существует ещё один модификатор доступа protected, но я бы его разобрал отдельно, поскольку он ещё более тесно связан с ООП и сейчас нам не очень важен.
Сложность взлома public/private/protected
У многих людей может возникнуть заблуждение по поводу этих модификаторов доступа.
Оно заключается в том, что они считают что их в каком-то смысле "сложнее" взломать.
Но такое заблуждение возникает как догадка, потому что если бы они знали каким образом данные хранятся в памяти они бы не выдвигали таких предположений.
Давайте создадим экземпляр класса с полями имеющими разные модификаторы и выведем их адреса.
class Player {
private:
int value1 = 0xF;
public:
int value2 = 0xFF;
protected:
int value3 = 0xFFF;
};
int main()
{
Player p;
std::cout << &p << std::endl;
//std::cout << &p.value1 << std::endl;
std::cout << &p.value2 << std::endl;
//std::cout << &p.value3 << std::endl;
while (true) {}
return 0;
}
0000007FB1B3F7E0
0000007FB1B3F7E4
Значения адресов полей value1 и value3 мы не выводим из-за того, что они инкапсулированы внутри класса.
Открываем этот участок памяти через Cheat Engine (или любой другой подобный софт) и наблюдаем следующую картину:
0000007FB1B3F7E0 0x0000000F
0000007FB1B3F7E4 0x000000FF
0000007FB1B3F7E8 0x00000FFF
Как мы видим никакой разницы или дополнительной "защиты" эти модификаторы не дают. Это касается как чтения этих значений, так и записи по этим адресам.
Из этого делаем вывод - модификаторы доступа при компиляции остаются только на уровне исходного кода и нужны только при разработке.
На уровне процессора не существует никаких public или private.
В данном конкретном примере компилятор разместил поля последовательно, но нужно полагать, что это не строгое правило и не стандарт языка, хотя в большинстве случаев в Windows+x64 логика будет такая.
Конструктор
При работе с обычными переменными мы привыкли вручную присваивать значения используя = или использовать агрегатную инициализацию.
Последняя, напомню, работает как с переменными, структурами, так и с классами.
Ещё один способ задавать значения при создании - конструкторы.
class Player {
public:
int health;
};
int main()
{
Player p;
std::cout << p.health << std::endl; //uninitialized local variable 'p' used
return 0;
}
Такой код у нас скомпилировать не получится (MSVC). Причина - вывод неинициализированного поля health.
Обратите внимание, что он ругается именно на неинициализированную переменную p, а не на конкретное поле.
Ситуация изменится, если мы добавим конструктор по умолчанию и в нём зададим значение поля.
class Player {
public:
int health;
Player(){
health = 100;
}
};
int main()
{
Player p;
std::cout << p.health << std::endl;
return 0;
}
100
Как вы можете заметить конструктор похож на метод, но он не имеет возвращаемого значения, даже void, а его имя - это имя класса.
Особенностью конструктора по умолчанию является то, что он может быть вызван без аргументов.
В конструктор, как и в метод, мы можем передавать параметры.
class Player {
public:
int health;
Player(int health){
this->health = health;
}
};
int main()
{
Player p;
//no default constructor exists for class "Player"
//'Player': no appropriate default constructor available
std::cout << p.health << std::endl;
return 0;
}
В этом случае такой конструктор перестаёт быть конструктором по умолчанию, следовательно старый код объявления переменной p у нас работать не будет - нужно вызвать этот единственный конструктор.
Это происходит потому, что компилятор в этом случае перестаёт генерировать конструктор по умолчанию.
Player p = Player(50);
std::cout << p.health << std::endl;
50
Вызвать конструктор можно ещё несколькими способами, ещё один это запись без знака равно и имени класса.
Player p(50);
std::cout << p.health << std::endl;
Не забывайте, что блок кода в конструкторе - полноценный блок кода, в котором можно выполнять различные действия, выполнять вычисления.
const int baseDamage = 10;
const int levelDamageMultiplier = 5;
class Player {
private:
int level;
int damage;
public:
Player(int level) {
this->level = level;
damage = baseDamage + levelDamageMultiplier * level;
std::cout << "player created" << std::endl;
std::cout << "level: " << level << std::endl;
std::cout << "damage: " << damage << std::endl;
}
};
int main()
{
Player p(18);
return 0;
}
player created
level: 18
damage: 100
Список инициализации
В C++ можно задавать поля у класса через список инициализации.
class Player {
public:
int health;
Player(int health) : health(health) {}
};
int main()
{
Player p(25);
std::cout << p.health << std::endl;
return 0;
}
25
Здесь тело конструктора может остаться пустым и нет необходимости использовать this, если имена параметров и полей совпадают.
Перегрузка конструкторов
У одного класса может существовать несколько конструкторов с разным набором параметров и разной логикой.
class Player {
public:
int health;
Player() {
health = 100;
}
Player(int health) : health(health) {}
Player(bool strong) {
health = strong ? 1000 : 100;
}
};
int main()
{
Player p1;
Player p2(25);
Player p3(true);
std::cout << p1.health << std::endl;
std::cout << p2.health << std::endl;
std::cout << p3.health << std::endl;
return 0;
}
100
25
1000
В этом примере у нас три разных конструктора и каждый из них предоставляет разные варианты создания экземпляра класса - по умолчанию, с конкретным значением здоровья и отдельным флагом.
Как вы уже могли догадываться тип std::string не является встроенным типом. Это именно класс из стандартной библиотеки, реализованный на основе шаблонного класса std::basic_string.
Он так же имеет несколько конструкторов.
Именно эта механика позволяет нам разными способами задавать значения строки при её создании.
std::string a("Hello");
std::string b(5, 'A');
std::string c(a);
Конструктор копирования
Если мы инициализируем объект другим объектом того же типа, то может быть вызван конструктор копирования.
Если он не определён, то как и в случае с конструктором по умолчанию он создаётся автоматически компилятором.
class Player {
public:
int health;
};
int main()
{
Player p1{100};
Player p2 = p1;
std::cout << p2.health << std::endl;
return 0;
}
100
Давайте объявим свой конструктор копирования Player(const Player& other).
class Player {
public:
int health = 100;
Player() {}
Player(const Player& other) {
health = other.health;
}
};
int main()
{
Player p1;
Player p2 = p1;
std::cout << p2.health << std::endl;
return 0;
}
Обратите внимание, что мы объявили помимо конструктора копирования так же конструктор по умолчанию.
Как только в классе объявлен любой пользовательский конструктор (в том числе копирования), компилятор перестаёт генерировать конструктор по умолчанию.
Поэтому для Player p1; конструктор нужно будет объявить явно Player() {} или Player() = default;.
При объявлении конструктора копирования мы используем константную ссылку, потому что нам не нужно создавать ещё одну копию в момент копирования и изменять изначальный объект тем более.
Но главная причина использования передачи по ссылке заключается в том, что для вызова Player(Player other) для копирования p1, нужно сначала создать параметр other как копию p1.
Для этого нужно вызвать Player(Player other), а ему снова нужен параметр по значению и возникает бесконечная рекурсия.
Компилятор не даст собрать такой код.
Поверхностное копирование
При копировании классов, если поле является указателем может произойти поверхностное копирование.
class Player {
public:
int* health;
Player(int health){
this->health = new int{ health };
}
void takeDamage(int damage) {
*health = *health - damage;
}
};
int main()
{
Player p1(100);
Player p2 = p1;
p1.takeDamage(20);
std::cout << *p1.health << std::endl;
std::cout << *p2.health << std::endl;
std::cout << p1.health << std::endl;
std::cout << p2.health << std::endl;
return 0;
}
80
80
0000022CC4297000
0000022CC4297000
В этом случае у нас два экземпляра класса будут иметь значение поля health указывающее на один и тот же адрес.
Следовательно метод takeDamage при применении к одному из экземпляров класса будет менять одно и тоже значение.
Деструктор
Деструктор это отдельный метод, который автоматически вызывается при уничтожении объекта класса.
В отличии от конструктора у класса может быть только один деструктор.
Он нужен в нескольких случаях, например, если нужно освободить память.
#include <Windows.h>
#include <iostream>
class Player {
public:
int* items;
Player() {
items = new int[1000000] {};
}
};
void f() {
Player p;
Sleep(1000);
}
int main()
{
Sleep(1000);
f();
while (true) {}
return 0;
}
Если вы скомпилируете этот код и посмотрите на занимаемую процессом память, то вы увидите скачок при её выделении, но освобождения не произойдет.
Тут при выходе из функции f обычная переменная прекратила бы своё существование.
Но мы имеем класс Player, в котором существует динамически выделяемая память, указатель на которую хранится в поле items.
После завершения функции f выделенная память продолжает существовать.
Чтобы не предусматривать логику её освобождения за пределами класса нужно это сделать в деструкторе.
class Player {
public:
int* items;
Player() {
items = new int[1000000] {};
}
~Player() {
delete[] items;
}
};
void f() {
Player p;
Sleep(1000);
}
int main()
{
Sleep(1000);
f();
while (true) {}
return 0;
}
Теперь память освобождается корректно.
Оформите подписку, чтобы иметь доступ ко всем публикациям без ограничений. Вы получите мгновенный доступ к полным версиям материалов, исходному коду и новым публикациям сразу после их выхода.
Я регулярно публикую материал и разборы ситуаций для различных игр, движков, а так же подходов разработки включая Internal, External и DMA.