При проектировании классов может возникнуть ситуация, при которой два класса одновременно ссылаются друг на друга. Например, у нас есть два класса A и B. И мы хотим иметь возможность создавать экземпляр класса A из экземпляра класса B. При этом нам нужна и противоположная возможность, создавать экземпляр класса B из экземпляра класса A.
Эту задачу нельзя решить в лоб, так как имеет место циклическая зависимость. Для её решения нужно использовать обходные пути. Сегодня я расскажу о двух таких путях: с помощью опережающего объявления (forward declaration) и с помощью шаблонов.
Постановка задачи
Сформулируем задачу более конкретно. Пусть у нас есть два класса: Integer1 и Integer2 (да, названия мне тоже не нравятся, но для целей поста подойдут). Нам нужно, чтобы класс Integer1 имел конструктор вида:
Integer1::Integer1(const Integer2 &int2)
Одновременно с этим класс Integer2 должен иметь конструктор вида:
Integer2::Integer2(const Integer1 &int1)
Проблема здесь в том, что мы не можем объявить класс Integer1, не объявив класс Integer2 (параметр конструктора). И наоборот, мы не можем объявить класс Integer2, не объявив Integer1. Получается замкнутый круг.
Нам нужно его как-то разорвать. И для этого есть два способа.
Опережающее объявление
Опережающее объявление (forward declaration, неполное объявление, предварительное объявление) — это возможность языка C++, позволяющая нам сказать компилятору, что существует некий тип Type, не давая при этом полное объявление этого типа. Выглядит это так:
class Type;
Объявленный таким образом тип называется неполным (incomplete). Существует целый ряд ограничений на работу с такими типами. Например, код ниже не скомпилируется.
class Type;
struct MyStruct
{
Type m_type;
};
int main()
{
MyStruct ms;
return 0;
}
Причина в том, что компилятор не может создать экземпляр структуры MyStruct, так как он не знает размер её поля m_type. Для этого ему нужна полная информация о типе Type.
Но если мы чуть-чуть изменим наш пример, то всё заработает.
class Type;
struct MyStruct
{
Type *m_p_type;
};
int main()
{
MyStruct ms;
return 0;
}
Единственное, что мы поменяли — это тип поля структуры MyStruct. Раньше это был Type, а теперь — указатель на Type. Размер указателя зависит от архитектуры ЭВМ, под которую осуществляется компиляция программы, а не от типа, на который он указывает. Поэтому компилятор может создать экземпляр структуры MyStruct, даже не имея полной информации о типе Type.
Вообще работа с неполными типами — отдельная большая тема. Интересующимся рекомендую статью Дмитрия Пономарева «Использование неполных объявлений в С++» (https://habr.com/ru/articles/889808/).
Решение задачи
Для решения задачи нам нужно разделить объявление класса и его реализацию. На примере класса Integer1 его объявление может выглядеть так.
//forward declaration
class Integer2;
class Integer1
{
public:
explicit Integer1(int i = 0) : m_int(i)
{}
explicit Integer1(const Integer2 & );
int get() const noexcept { return m_int; }
private:
int m_int;
};
Здесь мы даём полное объявление класса Integer1, а его зависимость от класса Integer2 вводим через опережающее объявление последнего. Так как тип Integer2 здесь является неполным, мы не можем привести тело использующего его конструктора. Оно находится в отдельном cpp файле.
#include <integer_1.hpp>
#include <integer_2.hpp>
Integer1::Integer1(const Integer2 &int2) : m_int(int2.get())
{
}
Хотя этот метод и тривиален, его тело должно располагаться в отдельном cpp файле. Так как компилятор не может проверить правильность вызова метода get для неполного типа.
Необходимость вынесения в отдельные cpp файлы даже простейших методов (функций) — является недостатком этого подхода. Подход на основе шаблонов не имеет такого недостатка.
Решение с помощью шаблонов
Идея этого подхода состоит в том, что тип Integer2 в объявлении типа Integer1 вводится не с помощью опережающего объявления, а в виде шаблонного параметра. Тогда полное объявление типа Integer1 примет вид.
class Integer1
{
public:
explicit Integer1(int i = 0) : m_int(i)
{}
template <typename T>
explicit Integer1(const T & t) : m_int(t.get())
{}
int get() const noexcept { return m_int; }
private:
int m_int;
};
Начиная с С++20 пример выше можно записать чуть иначе
class Integer1
{
public:
explicit Integer1(int i = 0) : m_int(i)
{}
explicit Integer1(const auto & t) : m_int(t.get())
{}
int get() const noexcept { return m_int; }
private:
int m_int;
};
Класс Integer1 теперь ничего не знает о классе Integer2. По сути он в качестве параметра конструктора может принимать любой тип, у которого есть константный метод get без входных параметров и возвращающий значение, неявно приводимое к типу int (такая вариативность может быть нежелательной). Если это условие нарушено, произойдет ошибка компиляции.
Репозиторий
Оба этих подхода я оформил в виде репозитория, в котором приводятся полные примеры их использования. Он доступен по ссылке: https://gitflic.ru/project/norseev_blog/interdependent_classes.
В нём всего два каталога:
forward_declaration— решение задачи с помощью опережающего объявления;template— решение задачи с помощью шаблона.
Заключение
Я описал два подхода к решению проблемы взаимозависимых классов. Каждый из них имеет свои достоинства и недостатки.
Подход на основе опережающего объявления четко задаёт типы, для которых это должно работать, но требует вынесения в отдельный cpp файл тела даже простейших методов (функций). Это может помешать компилятору встроить вызовы этих методов (функций) и применить другие оптимизации.
Подход на основе шаблонов не требует вынесения тела методов (функций) в cpp файлы, но не ограничивает типы, для которых это должно применяться. Да, наложить такое ограничение можно с помощью концептов (начиная с C++20) и другой шаблонной магии, но это дополнительные усилия. Использование же неправильного типа может приводить к трудночитаемым ошибкам компиляции.
Возможно существуют и другие способы решения этой проблемы, но мне они неизвестны. Если Вы их знаете — напишите мне, я дополню материал.
