12.4 – Функции доступа и инкапсуляция

Добавлено 3 июля 2021 в 14:11

Зачем делать переменные-члены закрытыми?

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

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

По тем же причинам разделение реализации и интерфейса полезно и в программировании.

Инкапсуляция

В объектно-ориентированном программировании инкапсуляция (также называемая сокрытием информации) – это процесс скрытия деталей о том, как реализован объект, от пользователей этого объекта. Вместо этого пользователи получают доступ к объекту через открытый, общедоступный интерфейс. Таким образом, пользователи могут использовать объект, не понимая, как он реализован.

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

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

Преимущество: инкапсулированные классы проще в использовании и уменьшают сложность ваших программ

При полностью инкапсулированном классе для его использования вам нужно только знать, какие функции-члены открыты, какие аргументы они принимают и какие значения возвращают. Неважно, как этот класс был реализован внутри. Например, класс, содержащий список имен, мог быть реализован с использованием динамического массива строк в стиле C, std::array, std::vector, std::map, std::list или одной из многих других структур данных. Чтобы использовать этот класс, вам не нужно знать (или беспокоиться), какую именно структуру данных он использует. Это значительно снижает сложность ваших программ, а также уменьшает количество ошибок. Это ключевое преимущество инкапсуляции.

Все классы стандартной библиотеки C++ инкапсулированы. Представьте себе, насколько сложнее был бы C++, если бы вам нужно было понять, как были реализованы std::string, std::vector или std::cout, чтобы их можно было использовать!

Преимущество: инкапсулированные классы помогают защитить ваши данные и предотвратить неправильное использование

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

Например, предположим, что мы пишем строковый класс. Мы могли бы начать так:

class MyString
{
    char *m_string; // здесь мы динамически разместим нашу строку
    int m_length;   // нам нужно отслеживать длину строки
};

Эти две переменные имеют внутреннюю связь: m_length всегда должна быть равна длине строки, содержащейся в m_string (это соединение называется инвариантом). Если бы m_length была открытой, любой мог бы изменить длину строки, не изменяя m_string (или наоборот). Это поставило бы класс в несогласованное состояние, что могло бы вызвать всевозможные странные проблемы. Делая m_length и m_string закрытыми, пользователи вынуждены для работы с этим классом использовать любые доступные открытые функции-члены (а эти функции-члены могут гарантировать, что m_length и m_string всегда устанавливаются правильно).

Мы также можем помочь защитить пользователя от ошибок при использовании нашего класса. Рассмотрим класс с открытой переменной-членом массива:

class IntArray
{
public:
    int m_array[10];
};

Если пользователи могут получить доступ к массиву напрямую, они могут попытаться использовать его с недопустимым индексом, что приведет к неопределенным результатам:

int main()
{
    IntArray array;
    array.m_array[16] = 2; // недопустимый индекс массива, теперь мы
                           // перезаписали память, которой мы не владеем
}

Однако если мы сделаем массив закрытым, то сможем заставить пользователя использовать функцию, которая сначала проверяет, что индекс корректен:

#include <iterator> // для std::size()
 
class IntArray
{
private:
    int m_array[10]; // пользователь больше не может получить к нему доступ напрямую
 
public:
    void setValue(int index, int value)
    {
        // Если значение индекса недопустимо, ничего не делать
        if (index < 0 || index >= std::size(m_array))
            return;
 
        m_array[index] = value;
    }
};

Таким образом, мы защитили целостность нашей программы. Кстати, функции at() классов std::array и std::vector делают нечто очень похожее!

Преимущество: инкапсулированные классы легче изменять

Рассмотрим простой пример:

#include <iostream>
 
class Something
{
public:
    int m_value1;
    int m_value2;
    int m_value3;
};
 
int main()
{
    Something something;
    something.m_value1 = 5;
    std::cout << something.m_value1 << '\n';
}

Хотя эта программа работает нормально, что произойдет, если мы решим переименовать переменную m_value1 или изменить ее тип? Мы сломаем не только эту программу, но, вероятно, и большинство программ, которые также используют класс Something!

Инкапсуляция дает нам возможность изменить способ реализации классов, не нарушая работу всех программ, которые их используют.

Вот инкапсулированная версия этого класса, который использует функции для доступа к m_value1:

#include <iostream>
 
class Something
{
private:
    int m_value1;
    int m_value2;
    int m_value3;
 
public:
    void setValue1(int value) { m_value1 = value; }
    int getValue1() { return m_value1; }
};
 
int main()
{
    Something something;
    something.setValue1(5);
    std::cout << something.getValue1() << '\n';
}

Теперь давайте изменим реализацию класса:

#include <iostream>
 
class Something
{
private:
    int m_value[3]; // обратите внимание: мы изменили реализацию этого класса!
 
public:
    // Мы должны обновить все функции-члены, чтобы отразить новую реализацию
    void setValue1(int value) { m_value[0] = value; }
    int getValue1() { return m_value[0]; }
};
 
int main()
{
    // Но наша программа по-прежнему работает нормально!
    Something something;
    something.setValue1(5);
    std::cout << something.getValue1() << '\n';
}

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

Точно так же, если бы гномы пробрались ночью в ваш дом и заменили внутренности вашего пульта от телевизора на другую (но совместимую) технологию, вы, вероятно, даже не заметили бы этого!

Преимущество: инкапсулированные классы легче отлаживать

И, наконец, инкапсуляция помогает вам отлаживать программу, когда что-то идет не так. Часто, когда программа работает некорректно, это происходит потому, что одна из наших переменных-членов имеет неправильное значение. Если получить доступ к переменной напрямую могут все, то отследить, какой фрагмент кода изменил эту переменную, может быть трудно (это может быть любой, и вам нужно будет установить точки останова везде, чтобы выяснить, где же ее изменили). Однако если для изменения значения все должны вызывать одну и ту же открытую функцию, вы можете просто установить точку останова на этой функции и наблюдать, как каждый вызывающий меняет значение, пока не увидите, где что-то пошло не так.

Функции доступа

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

Функция доступа – это короткая открытая функция, задачей которой является получение или изменение значения закрытой переменной-члена. Например, в классе MyString вы можете увидеть что-то вроде этого:

class MyString
{
private:
    char *m_string; // здесь мы динамически размещаем нашу строку
    int m_length;   // нам нужно отслеживать длину строки
 
public:
    int getLength() { return m_length; } // функция доступа для получения значения m_length
};

getLength() – это функция доступа, которая просто возвращает значение m_length.

Функции доступа обычно бывают двух видов: геттеры и сеттеры. Геттеры (англ. «getter», также иногда называемые аксессорами) – это функции, возвращающие значение закрытой переменной-члена. Сеттеры (англ. «setter», также иногда называемые мутаторами) – это функции, которые устанавливают значение закрытой переменной-члена.

Вот пример класса, у которого есть геттеры и сеттеры для всех его членов:

class Date
{
private:
    int m_month;
    int m_day;
    int m_year;
 
public:
    int getMonth() { return m_month; }            // геттер для месяца
    void setMonth(int month) { m_month = month; } // сеттер для месяца
 
    int getDay() { return m_day; }        // геттер для дня
    void setDay(int day) { m_day = day; } // сеттер для дня
 
    int getYear() { return m_year; }          // геттер для года
    void setYear(int year) { m_year = year; } // сеттер для года
};

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

Приведенный выше класс MyString используется не только для передачи данных – он имеет более сложную функциональность и инвариант, который необходимо поддерживать. Для переменной m_length не было предусмотрено никакого сеттера, потому что мы не хотим, чтобы пользователь мог устанавливать длину напрямую (длина должна устанавливаться только при изменении строки). В этом классе имеет смысл разрешить пользователю напрямую получать длину строки, поэтому был предоставлен метод-геттер длины.

Геттеры должны предоставлять доступ к данным «только для чтения». Поэтому лучше всего возвращать их по значению или по константной ссылке (а не по неконстантной ссылке). Геттер, который возвращает неконстантную ссылку, позволит вызывающему изменить реальный объект, на который та ссылается, что нарушает суть геттера «только для чтения» (и нарушает инкапсуляцию).

Лучшая практика

Геттеры должны возвращать результат по значению или константной ссылке.

Проблемы с функциями доступа

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

А пока мы рекомендуем прагматичный подход. При создании классов учитывайте следующее:

  • Если никому за пределами вашего класса не требуется доступ к члену, не предоставляйте функции доступа для этого члена.
  • Если кому-то за пределами вашего класса требуется доступ к члену, подумайте, можете ли вы вместо этого предоставить поведение или действие (например, вместо сеттера setAlive(bool), реализуйте функцию kill()).
  • Если не можете, подумайте, можете ли вы предоставить только геттер.

Резюме

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

Теги

C++ / Cppgetter / геттер / аксессорLearnCppsetter / сеттер / мутаторДля начинающихИнкапсуляцияОбучениеПрограммированиеФукнция доступа

На сайте работает сервис комментирования DISQUS, который позволяет вам оставлять комментарии на множестве сайтов, имея лишь один аккаунт на Disqus.com.

В случае комментирования в качестве гостя (без регистрации на disqus.com) для публикации комментария требуется время на премодерацию.