Перейти к содержимому

Абстракция и инкапсуляция

Уточню несколько моментов, связанных с файлами реализации (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 предотвращают повторное включение файла в одной единице трансляции, но порядок включений от этого не становится неважным.

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

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

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++;
}