Взаимозависимые классы

При проектировании классов может возникнуть ситуация, при которой два класса одновременно ссылаются друг на друга. Например, у нас есть два класса 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) и другой шаблонной магии, но это дополнительные усилия. Использование же неправильного типа может приводить к трудночитаемым ошибкам компиляции.

Возможно существуют и другие способы решения этой проблемы, но мне они неизвестны. Если Вы их знаете — напишите мне, я дополню материал.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *