Программа курса← Программирование на языке C++
1Введение в язык C++
2Как выполняются программы на C++
3Структуры и классы
4Объектно-ориентированное программирование
5Дополнительные возможности C++
6Шаблоны
7Методические материалы

Классы, инкапсуляция и модификаторы доступа

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

class и struct

struct Point {
    double x;      // public по умолчанию
};

class Widget {
    int count_;    // private по умолчанию
};

Это единственное различие. Всё остальное – конструкторы, деструкторы, наследование, виртуальные методы – работает одинаково.

Различие поддерживают соглашением: struct – для наборов данных без инварианта (Point, Config, HttpHeader), class – когда есть что защищать.

Три модификатора

class Account {
public:
    Account(int balance);
    void Deposit(int amount);
    int Balance() const;

protected:
    void LogOperation(const char* what);

private:
    bool IsValid() const;
    int balance_;
};
Модификатор Кто видит
public все
protected сам класс и наследники
private только сам класс (и его друзья)

Секций может быть сколько угодно и в любом порядке; действуют они до следующей секции. Общепринятый порядок – сначала public, потом остальное: читающий код видит сначала то, чем пользуется.

Зачем закрывать поля

Дело не в защите от злоумышленника – её здесь и нет. Дело в инварианте: утверждении, которое верно для объекта всегда.

class Account {
public:
    Account(int balance) : balance_(balance) {
        if (balance < 0) {
            throw std::invalid_argument("отрицательный баланс");
        }
    }

    void Withdraw(int amount) {
        if (amount > balance_) {
            throw std::runtime_error("недостаточно средств");
        }
        balance_ -= amount;
    }

    int Balance() const { return balance_; }

private:
    int balance_;    // инвариант: всегда >= 0
};

Пока balance_ закрыто, инвариант держат конструктор и три метода – и проверять его достаточно там. Откройте поле – и гарантия исчезает: испортить баланс может любая строчка в проекте, а искать её придётся везде.

Второе следствие важнее первого: закрытое можно менять. Замена int balance_ на пару «копейки + валюта» не заденет ни одного пользователя класса, если наружу торчат только методы. Публичное поле – это обязательство на всю жизнь типа.

Геттер и сеттер на каждое поле – не инкапсуляция, а публичное поле с лишним синтаксисом. Смысл появляется тогда, когда наружу торчит операция, а не поле: account.Withdraw(100) вместо account.SetBalance(account.GetBalance() - 100). Во втором варианте инвариант проверять негде.

Доступ проверяется, но не защищает

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

class Cls {
public:
    Cls(char c, double d, int i);

private:
    char c_;
    double d_;
    int i_;
};

// Зная раскладку полей, можно получить адрес приватного поля через reinterpret_cast.
// Такой код опирается на неопределённое поведение и в рабочем проекте недопустим,
// но он показывает, что private – это правило языка, а не замок.

Отсюда практический вывод: private защищает от случайного доступа, а не от намеренного. Секреты в объекте так же доступны, как и в глобальной переменной; шифровать private не умеет.

Что класть в заголовок

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

// widget.hpp – всё это подставится каждому пользователю
class Widget {
public:
    void Draw() const;

private:
    Renderer renderer_;   // и <renderer.hpp> тоже придётся включить
    Cache cache_;
};

Добавили приватное поле – пересобрался весь проект. Классический ответ на это – идиома pImpl: в классе остаётся один указатель на скрытую реализацию.

// widget.hpp
class Widget {
public:
    Widget();
    ~Widget();
    void Draw() const;

private:
    struct Impl;          // объявление без определения
    Impl* impl_;
};

Теперь заголовок не зависит ни от Renderer, ни от Cache, и правка полей никого не задевает. Платите за это одним разыменованием на вызов и выделением в куче – цена, которая окупается на больших проектах и не окупается на маленьких.

Имена приватных полей часто помечают: balance_, m_balance, _balance. Смысл не в красоте: в конструкторе Account(int balance) : balance_(balance) сразу видно, где поле, а где параметр, и не нужен this->. Какой именно вариант – вопрос принятого в команде стиля; в этом курсе используется подчёркивание в конце.

Друзья

Иногда доступ к закрытому нужен коду, который методом класса быть не может, – типичный пример оператор вывода в поток. Для этого есть friend:

class Point {
public:
    Point(double x, double y) : x_(x), y_(y) {}

    friend std::ostream& operator<<(std::ostream& out, const Point& p);

private:
    double x_;
    double y_;
};

std::ostream& operator<<(std::ostream& out, const Point& p) {
    return out << '(' << p.x_ << ", " << p.y_ << ')';
}

Дружба объявляется внутри класса – то есть класс сам решает, кого пускать, снаружи навязать доступ нельзя. Подробнее о friend и о том, почему оператор вывода не может быть методом, – в модуле про дополнительные возможности.