NSU Programming Программирование на C++ и Python

Классы

Парадигмы программирования

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

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

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

Мы знакомы уже со многими классами, хотя и не называли их этим термином. Все типы стандартной библиотеки, кроме базовых, являются классами. Например, класс string. Переменная типа string является экземпляром класса. Набор символов строки определяет состояние экземпляра string, а взаимодействие с набором символов возможно только посредством методов, определённых в классе string.

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

Теперь мы готовы перейти к обсуждению средств языка C++, реализующих принцип инкапсуляции.

Классы C++: начало

Минимальный класс в C++ выглядит так:

class LorentzVector {};

LorentzVector lv;

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

Чтобы наполнить класс содержанием, в нем определяют поля и методы. Поля класса — это объекты любого типа; набор полей и их значения определяют состояние объекта. Методы — это функции, связанные с классом. Рассмотрим на примере:

class LorentzVector {
    // по умолчанию мы находимся области private
    double t_;  // поле: временная компонента
    double x_;  // поле: пространственная компонента

 public:
    // константный публичный метод
    double t() const {
        return t_;
    }
    // константный публичный метод
    double x() const {
        return x_;
    }
    // публичный метод
    double& t() {
        return t_;
    }
    // публичный метод
    double& x() {
        return x_;
    }
};

Мы добавили в класс поля t_ и x_ и методы для доступа к ним. Поля и методы класса могут быть публичными или приватными. К публичным методам и полям можно обращаться при работе с объектом класса. Приватные поля и методы доступны только внутри методов самого класса. Ключевые слова private и public выполняют переключение на приватную и публичную части класса. В соответствии с принципом инкапсуляции мы поместили поля в приватную часть класса. Это позволит нам в дальнейшем изменить способ хранения данных, например, вместо двух переменных типа double использовать объект std::array<double, 2>, не меняя при этом публичные методы.

Следующая деталь — поля и методы могут быть константными. Делая метод константным, мы обещаем, что при его вызове видимое состояние объекта не изменяется. Методы чтения полей — хороший пример константных методов. Указывать константность метода не обязательно, но хороший стиль программирования подразумевает указание константности везде, где это уместно. Это, во-первых, повышает выразительность кода, и во-вторых, определяет набор методов, которые доступны константному объекту. Например:

LorentzVector lv1;
lv1.t_ = 1.;  // ошибка: поле t_ - приватное
lv1.t() = 1.;
lv1.x() = 0.5;

cout << "(" << lv.t() << ", " << lv.x() << ")\n";

const LorentzVector lv2;
lv2.t() == 1.;  // ошибка: вызов неконстантного метода у константного объекта
cout << "(" << lv2.t() << ", " << lv2.x() << ")\n";  // Здесь все хорошо, поскольку методы чтения определены, как константные

Обратите внимание, что мы определили методы с одинаковыми названиями, но различными сигнатурами. Сигнатура метода определяется:

  • набором и типом аргументов
  • свойством константности

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

Конструктор

При создании объекта вызывается специальный метод — конструктор, который выполняет инициализацию полей объекта. В нашем классе конструктор не был определён, поэтому компилятор сгенерировал его автоматически. Поля t_ и x_ инициализировались значениями по умолчанию типа double. Определим пару конструкторов для нашего класса:

class LorentzVector {
    // ...
 public:
    // конструктор по умолчанию
    LorentzVector() {
        t_ = 0;
        x_ = 0;
    }
    // ещё один конструктор
    LorentzVector(double ti, double xi) {
        t_ = ti;
        x_ = xi;
    }
}

Конструктор — это метод, имя которого совпадает с названием класса, для него не указывается тип возвращаемого значения. Конструкторов может быть несколько, каждый из них должен иметь уникальную сигнатуру. Создавать объекты нашего класса теперь удобнее:

LorentzVector lv(1, 0.5);

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

class LorentzVector {
    // ...
 public:
    LorentzVector() = default;  // конструктор по умолчанию
    // ещё один конструктор
    LorentzVector(double ti, double xi) : t_(ti), x_(xi) {}
}

Ключевое слово default, которое мы использовали с конструктором по умолчанию, говорит компилятору самостоятельно создать этот конструктор. В нашем случае это хорошее решение. Если бы мы определили только конструктор с параметрами, то конструктор по умолчанию не был бы создан.

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

Добавим к классу LorentzVector метод boosted, возвращающий вектор с компонентами в другой системе отсчета:

class LorentzVector {
    // ...
 public:
    // Преобразование Лоренца с фактором beta
    LorentzVector boosted(double beta) {
        double gamma = std::sqrt(1/(1-beta*beta));
        return {
            gamma * (t_ - beta * x_),
            gamma * (x_ - beta * t_)
        };
    }
    // ...
};

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

Перегрузка операторов

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

class LorentzVector {
    // ...
 public:
    LorentzVector Add(const LorentzVector& rhs) const {
        return {t_ + rhs.t_, x_ + rhs.x_};
    }
    // ...
};

Гораздо удобнее, однако, для сложения векторов использовать оператор +. Давайте выполним перегрузку оператора + для работы с типом LorentzVector:

class LorentzVector {
    // ...
 public:
    LorentzVector operator+(const LorentzVector& rhs) {
        return {t_ + rhs.t_, x_ + rhs.x_};
    }
    // ...
};

Аналогично можно перегрузить операторы -, +=, -=, оператор * для умножения вектора на скаляр. Полный список доступных для перегрузки операторов можно найти в документации.

Если оператору необходим доступ к приватным частям класса, то его можно сделать членом класса. Альтернативным решением является явное разрешение использовать приватные части класса с помощью ключевого слова friend. Оператор при этом не является частью класса:

LorentzVector operator+(const LorentzVector& lhs, const LorentzVector& rhs) {
    return {lhs.t() + rhs.t(), lhs.x() + rhs.x()};
}

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

Перегрузка операторов << и >> позволяет использовать класс при работе с потоками:

#include <iostream>

ostream& operator<<(ostream& os, const LorentzVector& lv) {
    os << "(" << lv.t() << ", " << lv.x() << ")";
    return os;
}
#include <iostream>

istream& operator<<(istream& is, LorentzVector& lv) {
    is >> lv.t() >> lv.x();
    return is;
}

Нашим классом уже становится приятно пользоваться:

LorentzVector lv1(1, 0.5);
LorentzVector lv2(0.2, -0.1);

LorentzVector lv3 = lv1 + lv2;

cout << lv1 << " + " << lv2 << " = " << lv3 << endl;

Статические поля и методы

Еще одна возможность классов C++ — создание полей и методов, связанных с самим классом, а не с объектами класса. Такие поля и методы называют статическими, они определяются с помощью ключевого слова static. Давайте добавим к нашему классу счётчик всех созданных объектов. Для этого нам понадобится статическое поле и новая логика в конструкторе:

// файл lvec.h
class LorentzVector {
    // приватное статическое поле
    static size_t counter;

 public:
    LorentzVector(double ti, double xi) : t_(ti), x_(xi) {
        ++counter;  // увеличиваем счётчик объектов
    }
    // публичный статический метод
    static size_t objects_created() {
        return counter;
    }
    // ...
};

// файл lvec.cpp, инициализируем статическое поле
size_t LorentzVector::counter = 0;

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

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

Специальные методы класса

Мы уже знаем, что если в классе не определён ни один конструктор, то компилятор попробует сгенерировать конструктор по умолчанию самостоятельно. Это не единственный специальный метод, который компилятор может создавать автоматически. Полная сигнатура нашего “пустого” класса выглядит так:

class LorentzVector {
 public:
    // конструктор по умолчанию
    LorentzVector();
    // копирующий конструктор
    LorentzVector(const LorentzVector&);
    // перемещающий конструктор
    LorentzVector(LorentzVector&&);
    // копирующий оператор присваивания
    LorentzVector& operator=(const LorentzVector&);
    // перемещающий оператор присваивания
    LorentzVector& operator=(LorentzVector&&);
    // деструктор
    ~LorentzVector();
};

Кроме конструктора по умолчанию мы видим копирующий и перемещающий конструкторы, которые используются следующим образом:

LorentzVector lv1;  // конструктор по умолчанию
LorentzVector lv2;  // конструктор по умолчанию
LorentzVector lv3(lv1);  // копирующий конструктор
LorentzVector lv4(std::move(lv2));  // перемещающий конструктор

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

Сигнатура LorentzVector&& обозначает rvalue ссылку. Она обозначает временный объект, который может находиться в правой части оператора присваивания, но не может находиться в левой. Функция std::move превращает lvalue-объект в rvalue-объект. Подробнее понятия lvalue и rvalue объектов мы обсудим в другой части.

Копирующий и перемещающий операторы присваивания обеспечивают работу следующих выражений:

LorentzVector lv1;  // конструктор по умолчанию
LorentzVector lv2;  // конструктор по умолчанию
lv2 = lv1;  // оператор копирующего присваивания
lv1 = std::move(lv2);  // оператор перемещающего присваивания

Наконец, при выходе объекта из области видимости вызывается еще один специальный метод — деструктор. Деструктор может быть только один, его имя отличается от имени конструктора символом тильда ~ в начале. Деструктор не может иметь параметров. Основное назначение деструктора в освобождении ресурсов, которыми владел объект.

Правило пяти

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

Содержание “правила пяти” состоит в том, что если есть необходимость переопределения поведения одного из пяти специальных методов, то скорее всего следует переопределить поведение всех пяти методов. Более того, если определить лишь часть специальных методов, то остальные методы могут не сгенерироваться, поскольку компилятор сделает вывод о том, что поведение по умолчанию не является подходящим. Таким образом, в подавляющем большинстве случаев мы либо не переопределяем ни один из пяти специальных методов, либо переопределяем их все.

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

Организация файлов

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

  • lvec.h - содержит объявление класса LorentzVector
  • lvec.cpp - содержит определение класса LorentzVector
  • main.cpp - использует файл lvec.h и выполняет логику работы программы

Вот так могут выглядеть файлы lvec.h и lvec.cpp:

// lvec.h
# pragma once

#include <iostream>

class LorentzVector {
    double t_;
    double x_;

    static size_t counter;

public:
    // объявление конструктора
    LorentzVector(double ti, double xi);

    // объявления методов
    double t() const;
    double x() const;
    double& t();
    double& x();
    LorentzVector boosted(double beta);

    static size_t objects_created();
    // ...
};

// объявление операторов
void operator+=(LorentzVector& lhs, const LorentzVector& rhs);
// ...

std::ostream& operator<<(std::ostream& os, const LorentzVector& lv);
std::istream& operator<<(std::istream& is, LorentzVector& lv);
// lvec.cpp
#include "lvec.h"
#include <cmath>  // std::sqrt

size_t LorentzVector::counter = 0;

LorentzVector::LorentzVector(double ti, double xi)  : t_(ti), x_(xi) {}

LorentzVector LorentzVector::boosted(double beta) {
    double gamma = std::sqrt(1/(1-beta*beta));
    return {
        gamma * (t_ - beta * x_),
        gamma * (x_ - beta * t_)
    };
}

size_t LorentzVector::objects_created() {
    return counter;
}

void operator+=(LorentzVector& lhs, const LorentzVector& rhs) {
    lhs.t() += rhs.t();
    lhs.x() += rhs.x();
}
// ...
// main.cpp

#include "lvec.h"
#include <iostream>

using namespace std;

int main() {
    LorentzVector lv1(1.0, 0.2);
    LorentzVector lv2 = lv1.boosted(0.2);

    cout << lv1 << " boost 0.2 -> " << lv2 << endl;
    return 0;
}

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

  • Определение класса стало более кратким, его проще читать
  • Детали реализации — это внутреннее дело нашего класса. Другие части кода подключают заголовочный файл lvec.h. Если изменится заголовочный файл, то изменятся и все файлы, в которые он включен. Выделение определения в отдельный файл позволяет выполнять большинство изменений, касающихся логики работы класса, только в файле lvec.cpp. Это еще одно проявление принципа инкапсуляции: публичную информацию помещаем в lvec.h, а остальное — в lvec.cpp.
  • Последний аргумент касается требований языка C++. Согласно стандарту, переменные и функции должны быть определены (defined) только в одном месте программы. Объявлений же (declaration) может быть несколько. Если бы мы поместили определение статической переменной counter или какого-либо оператора в файл lvec.h, то получли бы ошибку компиляции при попытке скомпилировать совместно файлы lvec.h, lvec.cpp и main.cpp.

Резюме

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

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

Эти знания уже позволяют вам реализовывать сложную логику в рамках объектно-ориентированного подхода. Далее мы продолжим изучение концепций ООП и их реализацию в языке C++.

Документация