Абстракция и инкапсуляция
Файлы реализации (cpp)
Заголовок раздела «Файлы реализации (cpp)»Уточню несколько моментов, связанных с файлами реализации (cpp-файлами).
cpp-файлы обычно содержат определения функций и/или глобальных переменных.
Они могут предоставлять эти функции для использования в других единицах трансляции (других cpp-файлах),
но могут также определять static-функции и глобальные переменные,
которые служат помощниками для предоставляемых функций, то есть
помогают реализовывать их логику.
Как думаете, что произойдет, если cpp-файл попадет в компиляцию дважды?
Предположим, мы скомпилировали два файла main.cpp и f.cpp командой zig c++ main.cpp f.cpp,
где файлы имеют следующее содержимое:
main.cpp:
#include "f.cpp"
int main(){ f(1); return 0;}f.cpp:
void f(int a){}Ответ: получится ошибка линковки,
потому что функция f оказывается определена дважды:
один раз в main.cpp (из-за того, что мы включили содержимое f.cpp)
и второй раз в f.cpp (ведь мы компилируем и этот файл тоже).
Из этого по сути следует, что каждый файл реализации допустимо компилировать не более одного раза.
Совместное использование объявлений
Заголовок раздела «Совместное использование объявлений»Что делать, если одну и ту же функцию f нужно использовать в нескольких разных файлах?
Нужно просто объявить её во всех этих файлах, а затем слинковать их с реализацией.
См. пример 1
Проблема в том, что теперь, если мы захотим изменить, скажем, тип параметра f
с int на float, придется:
- Изменить файл реализации,
- Изменить объявление
fвmain.cpp, - Изменить объявление
fвother_file.cpp.
А если файлов больше, придется пройтись по всем.
Чтобы изменять объявление только в одном файле,
обычно выносят объявление в отдельный файл и вставляют его
в нуждающиеся файлы с помощью #include.
Файлы с такими объявлениями называются заголовочными.
Разумеется, определение все равно придется менять отдельно.
См. пример 2
Если определение функции очевидно и мало, например возвращает какую-то константу,
обычно помещают определение прямо в заголовочный файл.
Если бы мы просто сделали это, то получили бы
ту же проблему, что и в абзаце выше.
Вспомним модификатор inline, который разрешает определять одну и ту же функцию в каждой единице трансляции, включившей заголовок, — линковщик соберет эти определения в одну.
Этот случай идеален для inline.
Фактически, если все функции можно сделать inline, файл реализации вообще не нужен.
См. пример 3
Другой способ избежать здесь ошибок линковки — сделать все функции static.
Обычно так делать не рекомендуется: исполняемый файл будет включать их определения
столько раз, сколько импортируется заголовок.
Интерфейс
Заголовок раздела «Интерфейс»Модулем можно назвать пару «заголовок + файл реализации». Видно, что заголовочный файл, содержащий объявления, фактически задает публичный интерфейс модуля, а файл реализации задает реализацию интерфейса.
Абстракция означает, что вы взаимодействуете с модулем только через его публичный интерфейс. Инкапсуляция означает, что вы можете работать с модулем как с целым, не разбираясь, как он устроен и какими данными пользуется под капотом.
Вопреки распространенному мнению, ни того, ни другого ООП не изобрело. Этими принципами можно пользоваться и без классов. ООП лишь добавляет более тонкую инкапсуляцию на уровне класса в виде системы доступности (члены public–private) и более тонкую абстракцию на уровне класса через виртуальные методы (возможно, описаны позже?).
То есть ООП дает оба этих понятия и на уровне модулей, и на уровне классов, тогда как обычное процедурное программирование — только на уровне модулей.
Избегание дублирующихся объявлений
Заголовок раздела «Избегание дублирующихся объявлений»Предположим, у нас есть такой код:
void f(int a);void f(int a);void f(int a){ std::cout << a;}Несколько одинаковых объявлений одной функции допустимы, ошибки не будет.
Ошибкой стали бы несовместимые объявления (скажем, с другими типами параметров)
или несколько определений одной функции.
Теперь представим ситуацию: main.cpp включает a.h и b.h,
а b.h тоже включает a.h.
Это значит, a.h оказался включен дважды.
Если a.h объявляет какие-то функции, программа получит эти объявления несколько раз.
Пока объявления идентичны, это не проблема, но в a.h могут оказаться и определения,
для которых двойное включение фатально.
Этого можно избежать с помощью #pragma once, которая поручает препроцессору
включить этот файл только один раз.
Если добавить её в a.h, файл будет включен только в main.cpp,
а b.h пропустит его импорт.
При этом b.h все равно увидит объявления из a.h,
потому что он подключен после того, как a.h уже был включен в main.cpp.
Очень похожая ситуация в примере с inline.
Если удалить #pragma once из f.h, компиляция не пройдет.
Циклические включения
Заголовок раздела «Циклические включения»Само по себе циклическое включение не запрещено: препроцессор просто раскрывает файлы последовательно.
Проблемы создает порядок: если a.h включает b.h, а b.h — a.h,
то при первом проходе один из них увидит другой неполным, что приведет к ошибкам компиляции.
Include guards и #pragma once предотвращают повторное включение файла в одной единице трансляции,
но порядок включений от этого не становится неважным.
Поля private
Заголовок раздела «Поля private»Как я упоминал раньше, типичное процедурное программирование не допускает инкапсуляции на уровне класса (структуры), а только на уровне модуля. Это значит, что данные в структурах, которые интерфейс ожидает от пользователя, могут быть изменены пользователем.
Пример: предположим, у нас есть тип, представляющий динамически выделенный буфер фиксированного размера. Код мог бы выглядеть примерно так:
struct DynamicBuffer{ int* firstItemPointer; size_t length;};
DynamicBuffer createBuffer(size_t length){ int* pointer = new int[length]; return {pointer, length};}
void setItem(DynamicBuffer* buffer, size_t index, int value){ assert(index < buffer->length); buffer->firstItemPointer[index] = value;}
void destroyBuffer(DynamicBuffer* buffer){ delete[] buffer->firstItemPointer;};Поскольку инкапсуляции данных на уровне класса (структуры) нет, следующий код скомпилируется, но сломается во время выполнения.
int main(){ DynamicBuffer buffer = createBuffer(10); buffer.firstItemPointer = 0; // нам ничто не мешает это сделать. setItem(&buffer, 0, 5); // ошибка времени выполнения: нарушение доступа return 0;}Конечно, здесь очевидно, что так делать неправильно: это грубо ломает внутреннее состояние буфера, но компилятор по-прежнему разрешает это без вопросов. Смысл системы доступности как раз в том, чтобы дать возможность явно запретить такое.
class DynamicBuffer{ // Закрытые данные int* _firstElementPointer; size_t _length;
public: // Мы все же хотим уметь *читать* эти данные, // но не *писать* в них напрямую. // Для этого можно определить *свойства*. // Свойства — это методы, которые возвращают или устанавливают значения полей. // Заметьте, я назвал поля с подчеркиванием в начале, чтобы избежать // конфликта имен со свойствами. const int* firstElementPointer() { return this->_firstElementPointer; }
size_t length() { return this->_length; }
DynamicBuffer(size_t length) : _length(length) { this->_firstElementPointer = new int[length]; }
~DynamicBuffer() { delete[] this->_firstElementPointer; }
void setItem(size_t index, int value) { assert(index < this->_length); this->_firstElementPointer[index] = value; }};int main(){ DynamicBuffer buffer{10}; buffer._firstElementPointer = 0; // не компилируется const int* firstElement = buffer.firstElementPointer(); // внутренний указатель по-прежнему можно *читать* return 0;}Но она также запрещает определять любые дополнительные операции,
не включенные в класс DynamicBuffer, которым нужен доступ к его закрытому состоянию.
Так что в ООП-версии следующее не сработает:
int resize(DynamicBuffer& buffer, size_t newSize){ if (buffer.length() < newSize) { buffer._length = newSize; } else { int* newBufferPointer = new int[newSize]; for (size_t i = 0; i < buffer.length(); i++) newBufferPointer[i] = buffer.firstElementPointer()[i]; delete[] buffer.firstElementPointer(); buffer._firstElementPointer = newBufferPointer; }}А эквивалентная процедурная версия сработает без проблем.
Думаю, это можно обойти с помощью
friend-функций, но обычно этого от вас не ждут.
Закрытые члены в заголовочном файле
Заголовок раздела «Закрытые члены в заголовочном файле»Должно быть понятно, почему поля обязаны находиться в заголовочном файле: знание о них нужно, чтобы знать размер класса, а размер класса нужно знать, потому что вы управляете памятью, в которой будет храниться объект.
Однако закрытые методы попадают в заголовочный файл только ради обхода проблемы инкапсуляции: определить статические функции в файле реализации так, чтобы они имели доступ к закрытым полям класса, нельзя, поэтому приходится делать их известными пользователю в объявлении (заголовочном файле).
buffer.h содержит объявление класса (конструкторы и прочее опущены):
class DynamicArray{ int* pointer; size_t count; size_t capacity;
public: void addItem(int item);
private: void ensureCapacity(size_t newMinimumSize);};buffer.cpp содержит определения:
void DynamicArray::addItem(int item){ this->ensureCapacity(this->count + 1); this->pointer[this->count] = item; this->count++;}
void DynamicArray::ensureCapacity(size_t newMinimumSize){ if (this->capacity >= newMinimumSize) return;
int* oldMemory = this->pointer; size_t newSize = std::max(newMinimumSize, this->capacity * 2); int* newMemory = new int[newSize]; for (size_t i = 0; i < this->count; i++) newMemory[i] = oldMemory[i]; this->pointer = newMemory; delete[] oldMemory;}Однако это противоречит идее о том, что заголовочный файл должен содержать только публичный интерфейс, обеспечивая инкапсуляцию на уровне модуля. В идеале закрытой функции хотелось бы вообще не быть в заголовочном файле: она используется только внутри модуля и является деталью реализации. Причин оставаться в заголовке у неё нет.
Если попробовать просто сделать её static и использовать в файле реализации, вот так:
buffer.h:
class DynamicArray{ int* pointer; size_t count; size_t capacity;
public: void addItem(int item);
// Закрытых методов здесь нет...};buffer.cpp:
void DynamicArray::addItem(int item){ ensureCapacity(this, this->count + 1);}
// static, поэтому видна и доступна только внутри этой единицы трансляции.static void ensureCapacity(DynamicArray* self, size_t newMinimumSize){ // Нет доступа к self->capacity, потому что оно закрытое. if (self->capacity >= newMinimumSize) return;
// ...}то, конечно, ничего не выйдет из-за правил доступности.
Обойти это можно, воспользовавшись тем, что типы, для которых вы не выделяете память и не узнаете размер, могут не объявлять свои поля, в сочетании с тем, что типы можно использовать как пространства имен и они имеют доступ к закрытым членам объемлющего типа. Более подробное объяснение см. здесь.
Так что поведение вышеописанной статической функции можно получить так:
class DynamicArray{ // ... поляpublic: void addItem(int item);
private: // Это называется *предварительным объявлением* (forward declaration). struct Impl;};// `static` допустим только для функций и переменных, но не для определений типов.// Поэтому определение вложенного типа просто находится в файле реализации.struct DynamicArray::Impl{ // Экземпляр нам не нужен. // `static` здесь не означает внутреннюю линковку, // он означает отсутствие неявного параметра `this`. static void ensureCapacity(DynamicArray* self, size_t newMaximumSize) { // Теперь это работает, потому что мы формально находимся внутри DynamicArray. if (self->capacity >= newMaximumSize) return;
// ... }};
void DynamicArray::addItem(int item){ DynamicArray::Impl::ensureCapacity(this, this->count + 1); // или так // Impl::ensureCapacity(this, this->count + 1); // потому что мы формально уже находимся в области видимости DynamicArray.
this->pointer[this->count] = item; this->count++;}