Устранить ошибки сборки из-за циклической зависимости между классами


353

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

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


  • A.h

    class B;
    class A
    {
        int _val;
        B *_b;
    public:
    
        A(int val)
            :_val(val)
        {
        }
    
        void SetB(B *b)
        {
            _b = b;
            _b->Print(); // COMPILER ERROR: C2027: use of undefined type 'B'
        }
    
        void Print()
        {
            cout<<"Type:A val="<<_val<<endl;
        }
    };

  • B.h

    #include "A.h"
    class B
    {
        double _val;
        A* _a;
    public:
    
        B(double val)
            :_val(val)
        {
        }
    
        void SetA(A *a)
        {
            _a = a;
            _a->Print();
        }
    
        void Print()
        {
            cout<<"Type:B val="<<_val<<endl;
        }
    };

  • main.cpp

    #include "B.h"
    #include <iostream>
    
    int main(int argc, char* argv[])
    {
        A a(10);
        B b(3.14);
        a.Print();
        a.SetB(&b);
        b.Print();
        b.SetA(&a);
        return 0;
    }

23
При работе с Visual Studio флаг / showInclude очень помогает при устранении подобных проблем.
WIP

Ответы:


288

Способ думать об этом - «думать как компилятор».

Представьте, что вы пишете компилятор. И вы видите такой код.

// file: A.h
class A {
  B _b;
};

// file: B.h
class B {
  A _a;
};

// file main.cc
#include "A.h"
#include "B.h"
int main(...) {
  A a;
}

Когда вы компилируете файл .cc (помните, что .cc, а не .h - это единица компиляции), вам нужно выделить место для объекта A. Итак, сколько же места тогда? Хватит хранить B! Каков размер Bтогда? Хватит хранить A! К сожалению.

Ясно, что круговая ссылка, которую вы должны сломать.

Вы можете сломать его, позволив компилятору зарезервировать столько места, сколько ему известно о начальном этапе - например, указатели и ссылки всегда будут 32 или 64 битами (в зависимости от архитектуры), и поэтому, если вы заменили (любой из них) на указатель или ссылка, все было бы здорово. Допустим, мы заменим в A:

// file: A.h
class A {
  // both these are fine, so are various const versions of the same.
  B& _b_ref;
  B* _b_ptr;
};

Теперь все стало лучше. В некотором роде. main()все еще говорит:

// file: main.cc
#include "A.h"  // <-- Houston, we have a problem

#includeДля всех экстентов и целей (если вы удалите препроцессор) просто скопируйте файл в .cc . Так что на самом деле .cc выглядит так:

// file: partially_pre_processed_main.cc
class A {
  B& _b_ref;
  B* _b_ptr;
};
#include "B.h"
int main (...) {
  A a;
}

Вы можете понять, почему компилятор не может справиться с этим - он понятия не имеет, что это Bтакое - он никогда раньше не видел этот символ.

Итак, давайте расскажем компилятору о B. Это известно как предварительная декларация и обсуждается далее в этом ответе .

// main.cc
class B;
#include "A.h"
#include "B.h"
int main (...) {
  A a;
}

Это работает . Это не здорово . Но в этот момент у вас должно быть понимание проблемы циклических ссылок и того, что мы сделали, чтобы «исправить» ее, хотя это и исправление плохо.

Причина, по которой это исправление плохое, заключается в том, что следующий человек должен #include "A.h"будет объявить, Bпрежде чем сможет его использовать, и получит ужасную #includeошибку. Так давайте перейдем к декларации в Ач сам.

// file: A.h
class B;
class A {
  B* _b; // or any of the other variants.
};

И в Bh , на данный момент, вы можете просто #include "A.h"напрямую.

// file: B.h
#include "A.h"
class B {
  // note that this is cool because the compiler knows by this time
  // how much space A will need.
  A _a; 
}

НТН.


20
«Рассказывать компилятору о B» называется предварительным объявлением B.
Питер Айтай

8
О, мой бог! полностью упустил тот факт, что ссылки известны с точки зрения занимаемого пространства. Наконец, теперь я могу правильно оформить!
kellogs

47
Но все равно Вы не можете использовать любую функцию на B (как в вопросе _b-> Printt ())
rank1

3
Это проблема, с которой я столкнулся. Как вы вводите функции с предварительным объявлением без полной перезаписи заголовочного файла?
Сидан


101

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

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

//A.h
#ifndef A_H
#define A_H
class B;
class A
{
    int _val;
    B* _b;
public:

    A(int val);
    void SetB(B *b);
    void Print();
};
#endif

//B.h
#ifndef B_H
#define B_H
class A;
class B
{
    double _val;
    A* _a;
public:

    B(double val);
    void SetA(A *a);
    void Print();
};
#endif

//A.cpp
#include "A.h"
#include "B.h"

#include <iostream>

using namespace std;

A::A(int val)
:_val(val)
{
}

void A::SetB(B *b)
{
    _b = b;
    cout<<"Inside SetB()"<<endl;
    _b->Print();
}

void A::Print()
{
    cout<<"Type:A val="<<_val<<endl;
}

//B.cpp
#include "B.h"
#include "A.h"
#include <iostream>

using namespace std;

B::B(double val)
:_val(val)
{
}

void B::SetA(A *a)
{
    _a = a;
    cout<<"Inside SetA()"<<endl;
    _a->Print();
}

void B::Print()
{
    cout<<"Type:B val="<<_val<<endl;
}

//main.cpp
#include "A.h"
#include "B.h"

int main(int argc, char* argv[])
{
    A a(10);
    B b(3.14);
    a.Print();
    a.SetB(&b);
    b.Print();
    b.SetA(&a);
    return 0;
}

Спасибо. Это решило проблему легко. Я просто переместил циркулярные включения в файлы .cpp.
Ленар Хойт

3
Что если у вас есть шаблонный метод? Тогда вы не сможете переместить его в файл CPP, если не создадите экземпляры шаблонов вручную.
Малкольм

Вы всегда включаете «А» и «Bh» вместе. Почему бы вам не включить «Ah» в «Bh», а затем включить только «Bh» в «A.cpp» и «B.cpp»?
Гусев Слава

28

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

Лучшая практика: заголовки форвардной декларации

Как показано в <iosfwd>заголовке Стандартной библиотеки , правильный способ предоставления предварительных объявлений для других - наличие заголовка прямой декларации . Например:

a.fwd.h:

#pragma once
class A;

ах:

#pragma once
#include "a.fwd.h"
#include "b.fwd.h"

class A
{
  public:
    void f(B*);
};

b.fwd.h:

#pragma once
class B;

БХ:

#pragma once
#include "b.fwd.h"
#include "a.fwd.h"

class B
{
  public:
    void f(A*);
};

Сопровождающие эти Aи Bбиблиотек должны быть друг друг ответственности за сохранение их вперед заголовков декларации в синхронизации с их заголовками и файлами реализация, а значит - к примеру - если сопровождающие «Б» приходит и переписывает код , чтобы быть ...

b.fwd.h:

template <typename T> class Basic_B;
typedef Basic_B<char> B;

БХ:

template <typename T>
class Basic_B
{
    ...class definition...
};
typedef Basic_B<char> B;

... тогда перекомпиляция кода для "A" будет вызвана изменениями во включенном b.fwd.hи должна завершиться чисто.


Бедная, но обычная практика: вперёд объявлять вещи в других библиотеках

Скажите - вместо использования заголовка прямого объявления, как объяснено выше, - введите код a.hили a.ccвместо этого объявите class B;:

  • если a.hили a.ccвключил b.hпозже:
    • компиляция A завершится с ошибкой, как только она попадет в конфликтующее объявление / определение B(то есть вышеупомянутое изменение в B сломало A и любые другие клиенты, злоупотребляющие предварительными объявлениями, вместо того, чтобы работать прозрачно).
  • в противном случае (если A в конечном итоге не включает b.h- возможно, если A просто хранит / передает Bs по указателю и / или ссылке)
    • Инструменты сборки, основанные на #includeанализе и измененных временных метках файлов, не будут перестраиваться A(и его зависимый от кода) после изменения на B, вызывая ошибки во время соединения или выполнения. Если B распространяется как загруженная во время выполнения библиотека DLL, код в «A» может не найти символы с различными искажениями во время выполнения, что может или не может быть обработано достаточно хорошо, чтобы вызвать упорядоченное завершение работы или приемлемо сниженную функциональность.

Если код А имеет шаблонные специализации / «черты» для старых B, они не вступят в силу.


2
Это действительно чистый способ обработки предварительных деклараций. Единственный «недостаток» - дополнительные файлы. Я предполагаю , что вы всегда включают a.fwd.hв a.h, чтобы убедиться , что они остаются синхронизированными. Пример кода отсутствует там, где используются эти классы. a.hи b.hоба должны будут быть включены, так как они не будут работать изолированно: `` `//main.cpp #include" ah "#include" bh "int main () {...}` `` Или один из них должен быть полностью включен в другой, как в первом вопросе. Где b.hвключает a.hи main.cppвключаетb.h
Фаруэй

2
@ Farway Право по всем пунктам. Я не стал показывать main.cpp, но приятно, что вы задокументировали, что это должно содержать в вашем комментарии. Приветствия
Тони Делрой

1
Один из лучших ответов с хорошим подробным объяснением того, почему с преимуществами и недостатками из-за плюсов и минусов ...
Фрэнсис Куглер

1
@RezaHajianpour: имеет смысл иметь заголовок предварительной декларации для всех классов, для которых вы хотите пересылать объявления, циклические или нет. Тем не менее, вы будете хотеть их только тогда, когда: 1) фактическое объявление (или, как ожидается, может стать впоследствии) дорогостоящим (например, оно включает в себя много заголовков, которые могут не понадобиться вашей единице перевода), и 2) клиентский код вероятно, сможет использовать указатели или ссылки на объекты. <iosfwd>классический пример: может быть несколько потоковых объектов, на которые ссылаются из разных мест, и их <iostream>очень много.
Тони Делрой

1
@RezaHajianpour: Я думаю, у вас есть правильная идея, но в вашем утверждении есть терминологическая проблема: «нам просто нужно, чтобы тип был объявлен », было бы правильно. Объявленный тип означает, что предварительное объявление было замечено; он определяется после того, как полное определение было проанализировано (и для этого вам может потребоваться больше #includes).
Тони Делрой

20

То, что нужно запомнить:

  • Это не будет работать, если в качестве члена class Aесть объект class Bили наоборот.
  • Форвардная декларация - это путь.
  • Порядок декларации имеет значение (именно поэтому вы удаляете определения).
    • Если оба класса вызывают функции другого, вы должны удалить определения.

Прочитайте FAQ:


1
ссылки, которые вы предоставили, больше не работают, знаете ли вы новые ссылки?
Рамья Рао

11

Однажды я решил эту проблему, переместив все строки после определения класса и поместив их #includeдля других классов непосредственно перед строками в заголовочном файле. Таким образом, убедитесь, что все определения + строки установлены перед анализом строк.

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

Нравится

// File: A.h
#ifndef __A_H__
#define __A_H__
class B;
class A
{
    int _val;
    B *_b;
public:
    A(int val);
    void SetB(B *b);
    void Print();
};

// Including class B for inline usage here 
#include "B.h"

inline A::A(int val) : _val(val)
{
}

inline void A::SetB(B *b)
{
    _b = b;
    _b->Print();
}

inline void A::Print()
{
    cout<<"Type:A val="<<_val<<endl;
}

#endif /* __A_H__ */

... и делать то же самое в B.h


Почему? Я думаю, что это элегантное решение сложной проблемы ... когда кто-то хочет встроить. Если кто-то не хочет вставлять строки, он не должен был писать код, как он был написан с самого начала ...
epatel

Что произойдет, если пользователь включит B.hпервым?
Мистер Фуз

3
Обратите внимание, что ваша защита заголовка использует зарезервированный идентификатор, все, что с двойным подчеркиванием зарезервировано.
Ларс Виклунд

6

Я написал пост об этом однажды: Разрешение циклических зависимостей в C ++

Основная техника состоит в разделении классов с использованием интерфейсов. Итак, в вашем случае:

//Printer.h
class Printer {
public:
    virtual Print() = 0;
}

//A.h
#include "Printer.h"
class A: public Printer
{
    int _val;
    Printer *_b;
public:

    A(int val)
        :_val(val)
    {
    }

    void SetB(Printer *b)
    {
        _b = b;
        _b->Print();
    }

    void Print()
    {
        cout<<"Type:A val="<<_val<<endl;
    }
};

//B.h
#include "Printer.h"
class B: public Printer
{
    double _val;
    Printer* _a;
public:

    B(double val)
        :_val(val)
    {
    }

    void SetA(Printer *a)
    {
        _a = a;
        _a->Print();
    }

    void Print()
    {
        cout<<"Type:B val="<<_val<<endl;
    }
};

//main.cpp
#include <iostream>
#include "A.h"
#include "B.h"

int main(int argc, char* argv[])
{
    A a(10);
    B b(3.14);
    a.Print();
    a.SetB(&b);
    b.Print();
    b.SetA(&a);
    return 0;
}

2
Обратите внимание, что использование интерфейсов и virtualоказывает влияние на производительность во время выполнения.
cemper93

4

Вот решение для шаблонов: Как обрабатывать циклические зависимости с помощью шаблонов

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


2

Простой пример, представленный в Википедии, работал на меня. (вы можете прочитать полное описание на http://en.wikipedia.org/wiki/Circular_dependency#Example_of_circular_dependencies_in_C.2B.2B )

Файл '' 'a.h' '':

#ifndef A_H
#define A_H

class B;    //forward declaration

class A {
public:
    B* b;
};
#endif //A_H

Файл '' 'b.h' '':

#ifndef B_H
#define B_H

class A;    //forward declaration

class B {
public:
    A* a;
};
#endif //B_H

Файл '' 'main.cpp' '':

#include "a.h"
#include "b.h"

int main() {
    A a;
    B b;
    a.b = &b;
    b.a = &a;
}

1

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

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

  • решение точно такое же, как первоначально предполагалось
  • встроенные функции по-прежнему встроены
  • пользователи Aи Bмогут включать А и В в любом порядке

Создайте два файла, A_def.h, B_def.h. Они будут содержать только определения A's и B':

// A_def.h
#ifndef A_DEF_H
#define A_DEF_H

class B;
class A
{
    int _val;
    B *_b;

public:
    A(int val);
    void SetB(B *b);
    void Print();
};
#endif

// B_def.h
#ifndef B_DEF_H
#define B_DEF_H

class A;
class B
{
    double _val;
    A* _a;

public:
    B(double val);
    void SetA(A *a);
    void Print();
};
#endif

И тогда А и Бх будут содержать это:

// A.h
#ifndef A_H
#define A_H

#include "A_def.h"
#include "B_def.h"

inline A::A(int val) :_val(val)
{
}

inline void A::SetB(B *b)
{
    _b = b;
    _b->Print();
}

inline void A::Print()
{
    cout<<"Type:A val="<<_val<<endl;
}

#endif

// B.h
#ifndef B_H
#define B_H

#include "A_def.h"
#include "B_def.h"

inline B::B(double val) :_val(val)
{
}

inline void B::SetA(A *a)
{
    _a = a;
    _a->Print();
}

inline void B::Print()
{
    cout<<"Type:B val="<<_val<<endl;
}

#endif

Обратите внимание, что A_def.h и B_def.h являются «частными» заголовками, пользователи Aи Bне должны их использовать. Публичный заголовок - А и Б


1
Есть ли у этого какие-либо преимущества перед решением Тони Делроя ? Оба основаны на «вспомогательных» заголовках, но у Тони они меньше (они просто содержат предварительную декларацию) и, похоже, работают одинаково (по крайней мере, на первый взгляд).
Фабио говорит восстановить Монику

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

0

В некоторых случаях можно определить метод или конструктор класса B в заголовочном файле класса A для разрешения циклических зависимостей, включающих определения. Таким образом, вы можете избежать необходимости помещать определения в .ccфайлы, например, если вы хотите реализовать библиотеку только с заголовками.

// file: a.h
#include "b.h"
struct A {
  A(const B& b) : _b(b) { }
  B get() { return _b; }
  B _b;
};

// note that the get method of class B is defined in a.h
A B::get() {
  return A(*this);
}

// file: b.h
class A;
struct B {
  // here the get method is only declared
  A get();
};

// file: main.cc
#include "a.h"
int main(...) {
  B b;
  A a = b.get();
}

0

К сожалению, я не могу прокомментировать ответ от Геза.

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

Но его иллюстрация не очень хороша. Потому что оба класса (A и B) нуждаются только в неполном типе друг друга (поля / параметры указателя).

Чтобы понять это лучше, представьте, что класс A имеет поле типа B, а не B *. Кроме того, классы A и B хотят определить встроенную функцию с параметрами другого типа:

Этот простой код не будет работать:

// A.h
#pragme once
#include "B.h"

class A{
  B b;
  inline void Do(B b);
}

inline void A::Do(B b){
  //do something with B
}

// B.h
#pragme once
class A;

class B{
  A* b;
  inline void Do(A a);
}

#include "A.h"

inline void B::Do(A a){
  //do something with A
}

//main.cpp
#include "A.h"
#include "B.h"

Это приведет к следующему коду:

//main.cpp
//#include "A.h"

class A;

class B{
  A* b;
  inline void Do(A a);
}

inline void B::Do(A a){
  //do something with A
}

class A{
  B b;
  inline void Do(B b);
}

inline void A::Do(B b){
  //do something with B
}
//#include "B.h"

Этот код не компилируется, потому что B :: Do требуется полный тип A, который будет определен позже.

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

//main.cpp
class A;

class B{
  A* b;
  inline void Do(A a);
}

class A{
  B b;
  inline void Do(B b);
}

inline void B::Do(A a){
  //do something with A
}

inline void A::Do(B b){
  //do something with B
}

Это точно возможно с этими двумя заголовочными файлами для каждого класса, который должен определять встроенные функции. Единственная проблема заключается в том, что циклические классы не могут просто включать «публичный заголовок».

Чтобы решить эту проблему, я хотел бы предложить расширение препроцессора: #pragma process_pending_includes

Эта директива должна отложить обработку текущего файла и завершить все ожидающие включения.

Используя наш сайт, вы подтверждаете, что прочитали и поняли нашу Политику в отношении файлов cookie и Политику конфиденциальности.
Licensed under cc by-sa 3.0 with attribution required.