View Categories

Классы

Классы появились в 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++:

  • Без конструктора
  • Без деструктора
  • Без виртуальных функций

Т.е. это просто набор переменных и методов.

0 0 голоса
Рейтинг статьи
Подписаться
Уведомить о
guest

0 комментариев
Старые
Новые Популярные
Межтекстовые Отзывы
Посмотреть все комментарии
Прокрутить вверх