Классы появились в C++ и добавили новый уровень абстракции, позволяя писать более удобный и лаконичный код, ориентированный на объекты. Соответственно ООП - объектно ориентированное программирование, суть которого состоит в написании программы, основанной на объектах и их взаимодействии друг с другом. Оно противопоставляется обычному, функциональному программированию, в котором есть набор переменных и функций.
Объект - это сущность, в которой есть свои переменные и функции. По сути - обособленный кусочек программы. Например дисплей, кнопка, датчик температуры. Это самостоятельный элемент, с которым можно взаимодействовать из основной программы. Такой подход также называется инкапсуляция - помещение в "капсулу". Капсула имеет конкретный набор инструментов: их можно использовать, не зная как они внутри устроены и как работают.
Как понять, что часть программы можно превратить в объект? Очень просто: если в программе есть набор переменных, для взаимодействия с которыми используется набор отдельных функций - это объект. Его можно и нужно выделить в отдельный модуль и поместить в отдельный файл - работать над проектом станет легче и удобнее
Такой подход позволяет разделять программу на независимые модули, которые можно отдельно дорабатывать, тестировать и использовать в других программах и проектах, как "библиотеки". Это упрощает разработку крупных проектов, а также работу над проектом одновременно нескольких разработчиков.
Лихие 70-е #
На Си тоже можно писать в объектном стиле, но довольно коряво и многословно. В качестве объекта используется экземпляр структуры, для которой существует набор функций.
Давайте рассмотрим такой подход на примере модуля, который должен генерировать числа с заданным шагом, начиная с указанного начального числа. Например начальное 10, шаг 15. Запрашиваем три шага, получаем результат 10+15*3=55. Также пусть будет возможность получать следующее число при каждом новом запросе, то есть модуль будет хранить ещё и последнее значение. При тех же настройках это будет 25, 40, 55...
В обычной программе можно было бы написать так:
int start; // начальное значение
int step; // шаг
int current; // текущее значение
int main() {
// настройка генератора, инициализация
start = 10;
step = 15;
// сброс текущего
current = start;
// получить по шагу 3
int var = start + step * 3; // 55
// получить следующее
current += step; // 25
current += step; // 40
}
Для удобства и лучшей читаемости добавим функций:
// настроить начальное значение и шаг
void config(int nstart, int nstep) {
start = nstart;
step = nstep;
}
// получить значение по шагу
int getByStep(int steps) {
return start + step * steps;
}
// сбросить текущее до начального
void reset() {
current = start;
}
// получить следующее с шагом
int getNext() {
current += step;
return current;
}
int main() {
config(10, 15);
reset();
getByStep(3); // 55
getNext(); // 25
getNext(); // 40
getNext(); // 55
}
Отлично, всё работает! Но как быть, если таких генераторов в программе нужно два? Или пять? Или 100? Делать кучу переменных и кучу функций для них? Может упаковать всё в массивы? А если генератор нужен локально, чтобы поработать с ним внутри функции, а затем удалить из памяти? При функциональном подходе провернуть такое красиво не получится, тут нужен объект.
На Си его можно оформить в виде структуры с набором функций. Можно будет создать экземпляр структуры и передать его в специальную функцию, которая выполнит нужные действия. Таким образом у нас получится "каркас" генератора, по которому можно создать и использовать его:
struct Gen {
int start; // начальное значение
int step; // шаг
int current; // текущее значение
};
// настроить начальное значение и шаг
void config(Gen* gen, int start, int step) {
gen->start = start;
gen->step = step;
}
// получить значение по шагу
int getByStep(Gen* gen, int steps) {
return gen->start + gen->step * steps;
}
// сбросить текущее до начального
void reset(Gen* gen) {
gen->current = gen->start;
}
// получить следующее с шагом
int getNext(Gen* gen) {
return gen->current += gen->step;
}
Теперь можно использовать:
Gen gen; // объект генератора
int main() {
config(&gen, 10, 15);
reset(&gen);
getByStep(&gen, 3); // 55
getNext(&gen); // 25
getNext(&gen); // 40
getNext(&gen); // 55
}
У нас получился объект, который можно создать в нужном месте программы и использовать с нужными настройками. Или даже сделать массив:
Gen gen_g; // глобально
Gen gen_arr[5]; // массив
int main() {
Gen gen_l; // локально
config(&gen_g, 10, 15);
config(&gen_l, 7, 29);
config(&gen_arr[0], 12, 3);
// ...
}
Ну вот, жизнь налаживается! Такой подход можно очень часто встретить в программах и библиотеках на Си, а так же в SDK для некоторых контроллеров (STM32 HAL, ESP-IDF).
В крупном проекте такой код может начать "пересекаться" с остальным кодом программы, так как функции имеют очень тривиальные названия. Можно улучшить ситуацию, если добавить им префикс, например - gen_
:
void gen_config(Gen* gen, int start, int step);
int gen_getByStep(Gen* gen, int steps);
void gen_reset(Gen* gen);
int gen_getNext(Gen* gen);
Для создания объектов такой подход устарел 40 лет назад (на момент написания урока) с выходом языка C++. В нём появились классы.
Классы #
Синтаксис классов позволяет сделать всё то же самое, но внутри одной сущности, без "отдельных" функций - всё внутри Gen
. Это даёт возможность полностью инкапсулировать все инструменты в сущность с одним названием, что упрощает организацию проекта. Классы позволяют создавать очень мощные и удобные инструменты и библиотеки, которые невозможно реализовать без классов.
Класс - это та же структура, но с функциями внутри - такие функции называются методами класса.
Переменные и функции внутри класса считаются членами класса (member) - data member и member function соответственно, функции также называются методами, а переменные - свойствами
class Foo {
int var; // свойство
void func() { // метод
}
};
Объект - экземпляр класса, создаётся с указанием имени класса как типа:
Foo foo; // экземпляр класса Foo, объект типа Foo
Модификаторы доступа #
Главное отличие класса от структуры - наличие уровений, или модификаторов доступа. Это метки в коде, которые задают "область видимости" перечисленных ниже членов и методов. Есть всего три таких уровня:
public:
- публичный код. Может быть вызван в программе от объекта через точкуprivate:
- приватный, системный код. Может использоваться только внутри классаprotected:
- "защищённый" код. Может использоваться внутри класса, а также внутри классов, которые наследуют этот класс. В программе его вызывать нельзя
Некоторые особенности меток:
- Метки могут располагаться в любых количествах в разных сочетаниях
- По умолчанию "активна" метка
private:
class Foo {
// системный код по умолчанию (private)
public:
// публичный код
private:
// системный код
public:
// ещё публичный код
};
Давайте перепишем генератор под класс. Прямой доступ к переменным не нужен, поэтому сделаем их приватными, а доступ оставим только через методы.
Имена приватных членов класса принято начинать с подчёркивания, чтобы визуально отделить их от публичных. В некоторых современных стандартах так делать не рекомендуется
class Gen {
public:
void config(int start, int step) {
_start = start;
_step = step;
}
int getByStep(int steps) {
return _start + _step * steps;
}
void reset() {
_current = _start;
}
int getNext() {
return _current += _step;
}
private:
int _start, _step, _current;
};
int main() {
Gen gen;
gen.config(10, 15);
gen.reset();
gen.getByStep(3); // 55
gen.getNext(); // 25
gen.getNext(); // 40
gen.getNext(); // 55
//gen._start = 3; // ошибка, приватная переменная
}
Ну вот, код основной программы стал более читаемым и лаконичным.
Пространство имён #
Класс создаёт внутри себя пространство имён с названием класса:
class Foo {
public:
int var;
void test() {
var = 3;
Foo::var = 3; // равносильно
}
};
Также имя класса будет работать и снаружи:
Foo f;
f.test();
f.var = 3;
// вот такой синтаксис - корректный
f.Foo::test();
f.Foo::var = 3;
Класс перекрывает внешние имена переменных и функций. Для выхода из него используется тот же оператор ::
:
int var; // #1
int test(int i) {} // #2
class Foo {
public:
int var; // #3
int test(int var) { // #4
var = 3; // параметр метода
Foo::var = 3; // член класса - #3
::var = 3; // глобальная переменная - #1
}
void bar() {
var = 3; // член класса - #3
::var = 3; // глобальная переменная - #1
test(3); // метод - #4
::test(3); // функция - #2
}
};
Объявление и определение #
Методы класса, как и обычные функции, тоже можно разделить на две части - объявление и определение (реализацию), и разделить код на заголовочный и исходный файлы:
- Для ускорения компиляции - исходный файл скомпилируется отдельно один раз. Иначе, при добавлении класса в новый исходный файл, он каждый раз будет компилироваться заново. Отличие во времени компиляции можно заметить только в очень крупном проекте и на слабом компьютере
- Для повышения читаемости - в заголовочном файле будет просто список методов, по которому удобно ориентироваться и использовать в качестве документации
- Для создания инструментов с закрытым исходным кодом - исходный файл компилируется отдельно и идёт в паре с заголовочным, сам исходник в открытый доступ не публикуется
В этом случае имя класса образует "пространство имён" для своих методов и разделение выглядит следующим образом:
// объявление
class Foo {
void method();
};
// реализация
void Foo::method() {
}
Перепишем наш генератор с разделением на объявление и реализацию и сразу поделим на файлы по всем правилам:
// === gen.h
#pragma once
class Gen {
public:
void config(int start, int step); // настроить начальное значение и шаг
int getByStep(int steps); // получить значение по шагу
void reset(); // сбросить текущее до начального
int getNext(); // получить следующее с шагом
private:
int _start, _step, _current;
};
// === gen.cpp
#include "gen.h"
void Gen::config(int start, int step) {
_start = start;
_step = step;
}
int Gen::getByStep(int steps) {
return _start + _step * steps;
}
void Gen::reset() {
_current = _start;
}
int Gen::getNext() {
return _current += _step;
}
Можно подключать в программу и использовать:
// === main.cpp
#include "gen.h"
int main() {
Gen gen;
gen.config(10, 15);
gen.reset();
// ...
}
Конструктор #
У классов есть ещё один удобный инструмент, позволяющий выполнить код при создании объекта - конструктор (constructor). Это функция, которая вызывается при создании объекта и так же может принимать параметры и может быть перегруженной.
Если не создавать свой конструктор - будет автоматически создан конструктор по умолчанию - пустая функция
На конструкторы также распространяются модификаторы доступа: если нужен публичный конструктор - он должен быть под соответствующей меткой
Синтаксис создания конструктора: имя_класса() {}
. Конструктор по сути возвращает тип данных самого класса:
class Foo {
public:
Foo() { // конструктор по умолчанию
}
Foo(int var) { // конструктор с параметрами
}
private:
Foo(int a, int b) { // приватный конструктор
}
};
// вызывается конструктор по умолчанию
Foo foo1;
Foo foo2();
Foo foo3 = Foo();
// вызывается конструктор с параметром (int)
Foo foo4(3);
Foo foo5 = Foo(3);
//Foo foo(1, 2); // ошибка, приватный конструктор
Конструктор часто используется для задания объекту начальных значений переменных и выполнения некоего инициализирующего кода:
class Foo {
public:
Foo(int var) {
_var = var;
// ещё какой-то код...
}
private:
int _var;
};
Фигурные скобки #
Если у класса есть хоть один конструктор (даже пустой, по умолчанию), то такой класс больше нельзя инициализировать через фигурные скобки:
class Foo {
public:
Foo() {}
int a, b, c;
};
class Bar {
public:
int a, b, c;
};
//Foo foo{1, 2, 3}; // ошибка! есть конструктор
Bar bar{1, 2, 3}; // здесь можно
Список инициализации #
У конструктора есть ещё один синтаксис - список инициализации, он позволяет инициализировать переменные класса немного в другом виде - имя_класса() : член(значение) {}
:
class Foo {
public:
Foo(int var1, int var2) : _var1(var1), _var2(var2) {
// равносильно
//_var1 = var1;
//_var2 = var2;
}
private:
int _var1, _var2;
};
- Члены-константы можно инициализировать только в списке инициализации:
class Foo {
public:
Foo(int var) : _var1(var1) {
//_var = var; // ошибка компиляции
}
private:
const int _var;
};
- Одинаковые имена переменных работают корректно и как ожидается -
параметр(член)
:
class Foo {
public:
Foo(int var) : var(var) {
}
private:
int var;
};
- Конструктор члена-объекта можно вызвать только в списке инициализации:
class Foo {
public:
Foo(int i) {}
};
class Bar {
public:
Bar() : foo(3) {} // нужно так
Foo foo;
//Foo foo(3); // ошибка компиляции
};
- Конструктор можно делегировать - вызвать из списка инициализации другой конструктор:
class Foo {
public:
Foo() : Foo(3, 4) {} // #1
Foo(int a, int b) {} // #2
};
Foo foo1; // вызовется конструктор #1, который вызовет конструктор #2
Foo foo2(11, 22); // вызовется конструктор #2
Допишем наш пример с генератором, чтобы было по красоте - добавим конструктор сразу с настройкой и сбросом, а метод config
оставим:
class Gen {
public:
Gen() {} // по умолчанию
Gen(int start, int step) : _start(start), _step(step) {
reset(); // пусть сразу вызывается здесь
}
void config(int start, int step) {
_start = start;
_step = step;
}
int getByStep(int steps) {
return _start + _step * steps;
}
void reset() {
_current = _start;
}
int getNext() {
return _current += _step;
}
private:
int _start, _step, _current;
};
int main() {
Gen gen(10, 15);
gen.getByStep(3); // 55
}
Деструктор #
Деструктор - противоположность конструктору. Это функция, которая вызовется перед удалением объекта из памяти. Синтаксис: ~имя_класса() {}
. Деструктор не может принимать параметры:
class Foo {
public:
Foo() {} // конструктор
~Foo() {} // деструктор
};
int main() {
Foo foo1; // вызывается конструктор
if (true) {
Foo foo2; // вызывается конструктор
}
// здесь foo2 удаляется и вызывается его деструктор
}
// здесь foo1 удаляется и вызывается его деструктор
Также деструктор можно вызвать вручную:
Foo foo;
foo.~Foo(); // деструктор
Делать так не нужно, но может пригодиться для создания каких-то сложных конструкций и сущностей.
Если деструктор не задан вручную - он всё равно будет добавлен компилятором для совместимости
Деструктор используется в ситуациях, когда нужно выполнить какой-то код перед удалением объекта. Например, если объект в процессе работы выделял динамическую память - нужно освободить её вместе с удалением объекта.
class Foo {
public:
Foo(int size) {
buffer = new char[size]; // выделить память
}
~Foo() {
delete[] buffer; // освободить память
}
private:
char* buffer = nullptr;
};
const-методы #
Методы класса могут быть помечены как const
, тогда эти методы можно будет вызывать у const
объектов. Обязательное условие - const-методы не должны изменять переменные класса:
class Foo {
public:
int a;
void test1() {}
void test2() const {}
void test3() const {
//a = 2; // ошибка - меняет переменную
}
};
int main() {
const Foo foo;
foo.test1(); // ошибка, не const метод
foo.test2();
}
Указатель this #
Внутри методов класса можно использовать this
- указатель на текущий объект. К членам объекта можно обратиться через оператор косвенного обращения - this->член
. Часто можно встретить такую запись:
class Foo {
public:
void foo() {
this->var = 3; // обращение к текущему объекту
(*this).var = 4; // или так
}
int var;
};
Это избыточный синтаксис, делать так не нужно. Так часто пишут те, кто пришёл в C++ из других языков, где this
внутри класса обязателен (например JavaScript)
Но бывает полезен в ситуациях с совпадением имён членов и параметров метода:
class Foo {
public:
void foo(int var) {
// здесь имя члена var "перекрыто" параметром var
var = var; // ничего не происходит
this->var = var; // присвоили к члену объекта
Foo::var = var; // так ещё более правильно в C++
}
int var;
};
Вызов по цепочке #
Класс можно организовать так, что методы можно будет вызывать цепочкой - chaining. Для этого достаточно возвращать из метода ссылку на текущий объект через *this
:
class Chain {
public:
Chain& foo() {
// какой-то код...
return *this;
}
Chain& bar() { return *this; }
Chain& test() { return *this; }
};
int main() {
Chain chain;
chain.foo().bar().test().foo().foo();
}
Статические члены #
У класса могут быть статические члены - функции и методы, доступные без создания объекта. Это внешние члены, они не принадлежат объекту, а являются членами класса:
- Статические переменные должны быть определены, то есть созданы уже вне класса, в исходном файле. Статические переменные не находятся внутри объекта, они внешние
- Приватные статические переменные недоступны из программы, но могут быть инициализированы в ней
- Статические переменные являются общими для всех экземпляров класса - если один объект меняет переменную - это изменение отражается для всех остальных объектов
- Статический метод не имеет доступа к НЕстатическим членам класса - это внешняя функция, она не привязана к объекту
- Статический метод имеет доступ к статическим членам класса - он внешний, они внешние
- Статический метод имеет доступ к приватным членам объекта
- Статические методы можно вызывать из программы без объекта, используя имя класса как пространство имён
- Внутри статического метода недоступен указатель
this
, т.к. находится вне контекста объекта
Большой пример, демонстрирующий всё вышесказанное:
class Foo {
public:
static int var; // ОБЪЯВЛЕНИЕ статической переменной КЛАССА
static void foo() {
var = 3; // имеет доступ к статическому члену КЛАССА
_sysStatic = 4; // имеет доступ к приватному статическому члену КЛАССА
//_sysVar = 3; // не имеет доступа к НЕстатическому члену КЛАССА
//this->_sysVar = 3;// this не существует в нестатическом методе
}
static void bar(Foo& f) {
f._sysVar = 3; // имеет доступ к приватным членам ОБЪЕКТА
foo(); // имеет доступ к статическому члену КЛАССА
}
void test() {
// имеет доступ к любым членам КЛАССА и ОБЪЕКТА
_sysVar = 3;
_sysStatic = 4;
foo();
}
private:
int _sysVar; // приватная переменная объекта
static int _sysStatic; // ОБЪЯВЛЕНИЕ статической приватной переменной
};
int Foo::var; // ОПРЕДЕЛЕНИЕ статической переменной
int Foo::_sysStatic = 1; // определение и инициализация статической переменной
int main() {
//Foo::_sysStatic = 3; // ошибка, приватная переменная
Foo::var = 3; // запись в статическую переменную для всех объектов
Foo::foo(); // вызов статического метода
Foo f;
Foo::bar(f); // вызов статического метода с объектом
}
Также статические переменные класса можно определить внутри самого класса при помощи спецификатора inline
:
class Foo {
public:
inline static int static_var = 123;
};
Тогда они будут созданы внутри исходного файла (единицы трансляции), в котором объявлен этот класс. То есть если такой код поместить в заголовочный файл библиотеки и подключить его к нескольким исходным файлам - у каждого из них будет своя независимая переменная. Это может быть не то поведение, которое ожидается.
Перегрузка операторов #
У классов в C++ есть возможность перегрузить почти любой стандартный оператор своей функцией. Это очень мощный инструмент, позволяющий объектам сравниваться, присваивать к себе другие типы данных, конвертироваться в другие типы и прочее прочее. Это позволяет сделать код программы ещё более лаконичным и сократить количество методов. Давайте напишем класс для числа - объект этого класса сможет сравниваться с другими числами и конвертироваться в них, а само число внутри него будет лежать в переменной:
class Number {
public:
int val;
int operator = (int v) { // #1 - перегрузка присваивания
return val = v;
}
bool operator == (int v) { // #2 - перегрузка сравнения
return val == v;
}
int operator += (int v) { // #3 - перегрузка +=
return val += v;
}
operator int() { // #4 - перегрузка преобразования (int)
return val;
}
void operator()(int v) { // #5 - перегрузка как функции
val = v;
}
};
int main() {
Number num;
num = 123; // #1 - теперь член val == 123
num == 123; // #2 - результат true
bum += 10; // #3 - теперь член val == 133
num == 123; // #2 - результат false
int v = num; // #4 - само конвертировалось из Number в int, v == 133
v = (int)num; // #4 - явно вызвали оператор, аналогично
num(456); // #5 - вызвали объект КАК ФУНКЦИЮ с аргументом
// здесь num == 456
}
void operator()(int v)
- когда твоя жизнь слишком коротка, чтобы придумывать оригинальные названия для методов
Или например сделаем возможность объектам сравниваться, внутри оператора сравнения вручную сравним члены:
class Foo {
public:
int a, b, c;
bool operator == (Foo& f) {
return a == f.a && b == f.b && c == f.c;
}
};
int main() {
Foo f1{11, 22, 33};
Foo f2{11, 22, 33};
Foo f3{44, 55, 66};
f1 == f2; // true
f1 == f3; // false
}
Callback #
Объект также может хранить указатель на другую функцию в программе и вызывать её, когда ему нужно. Такая функция называется callback или обработчик - мы "подключаем" к объекту функцию и он сам вызывает её при каких-то условиях:
class Foo {
public:
// подключить обработчик
void attach(void (*callback)()) {
_callback = callback;
}
// вызвать обработчик
void call() {
if (_callback) _callback(); // если подключен
}
private:
void (*_callback)() = nullptr;
};
// сам обработчик
void func() {
}
int main() {
Foo foo;
foo.attach(func); // подключаем
foo.call(); // вызовется func
// можно через лямбду
foo.attach([]() {
// код...
});
foo.call(); // вызовется лямбда
}
Функции также могут быть с параметрами, тогда объект сможет отправить нам какие-то данные или даже себя - через указатель или ссылку. Для удобства и сокращения кода указатель на функцию можно обернуть в новый тип данных через typedef
, это можно сделать прямо в объявлении класса:
class Foo {
// тип Callback - указатель на функцию (Foo&, int)
typedef void (*Callback)(Foo&, int);
public:
// подключить обработчик
void attach(Callback callback) {
_callback = callback;
}
// вызвать обработчик
void call() {
if (_callback) _callback(*this, 123); // отправляем себя и какое-то число например
}
private:
Callback _callback = nullptr;
};
// обработчик
void func(Foo& f, int val) {
// здесь f - объект foo, который вызвал обработчик
// val в данном случае придёт 123
}
int main() {
Foo foo;
foo.attach(func);
foo.call();
}
Это часто используется в библиотеках - для связи объекта с внешней программой. Например для обработки событий или данных от объекта, или задания объекту "своей" функции для обработки данных. Передача самого объекта в обработчик по указателю или ссылке - тоже очень распространённый подход: он позволяет получить доступ к объекту, даже если тот находится вне области определения обработчика, то есть если создан динамически, находится в другом файле или объявлен локально, как в примере выше.
А что там со структурами? #
В C++ структуры равносильны классам, то есть всё, что мы рассмотрели выше с классами, будет работать и со структурами: методы, конструкторы, наследование (следующий урок)... За исключением модификаторов доступа - в структуре они не работают, а доступ по умолчанию - public
:
class Foo {
public:
int var;
void test() {}
};
// равносильно
struct Bar {
int var;
void test() {}
};
// класс
Foo foo;
foo.test();
// структура
Bar bar;
bar.test();
POD #
Есть также отдельный термин POD (Plain Old Data) - "простые старые данные". Такими данными считается класс, который объявлен как структура без фишек C++:
- Без конструктора
- Без деструктора
- Без виртуальных функций
Т.е. это просто набор переменных и методов.