Вирішіть помилки побудови через кругову залежність між класами


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 прапор / showIncludes дуже допомагає налагоджувати подібні проблеми.
витер

Відповіді:


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

HTH.


20
"Розповідання компілятора про B" відоме як пряма декларація Б.
Пітер Айтай

8
О Боже мій! повністю пропустив той факт, що посилання відомі з точки зору займаного простору. Нарешті, тепер я можу правильно конструювати!
kellogs

47
Але все одно Ви не можете використовувати жодну функцію на B (як у питанні _b-> Printt ())
rank1

3
Це питання, яке я маю. Як привести функції з попереднім оголошенням без повного перезапису файла заголовка?
sydan


101

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

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

//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, якщо шаблони не інстанціювати вручну.
Малькольм

Ви завжди включаєте "Ah" і "Bh" разом. Чому ви не включаєте "Ah" у "Bh" і не включаєте лише "Bh" і в "A.cpp", і в "B.cpp"?
Гусєв Слава

28

Я спізнююсь відповісти на це, але на сьогодні немає жодної обґрунтованої відповіді, незважаючи на те, що це популярне запитання з висококваліфікованими відповідями ....

Найкраща практика: заголовки декларації вперед

Як показано в <iosfwd>заголовку бібліотеки Standard , правильним способом надання прямих декларацій для інших є наявність заголовка прямого оголошення . Наприклад:

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;

bh:

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

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

Супроводжувачі AтаB бібліотек бібліотек повинні відповідати за збереження заголовків їхнього прямого декларування синхронізувати їх із заголовками та файлами реалізації, так - наприклад, - якщо утримувач "B" йде разом і переписує код, який буде ...

b.fwd.h:

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

bh:

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

... тоді перекомпіляція коду для "А" буде спровокована змінами до включеного 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
Farway

2
@Farway Право за всіма пунктами. Я не заважав показувати main.cpp, але приємно, що ви задокументували, що це має містити у вашому коментарі. Ура
Тоні Делрой

1
Один з кращих відповідей із приємним детальним поясненням того, чому з «чи» чи «не» через плюси і мінуси ...
Френсіс Куглер

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

1
@RezaHajianpour: Я думаю, ви маєте правильне уявлення, але є термінологічна проблема з вашим твердженням: "нам просто потрібен тип, щоб оголосити " було б правильно. Тип, який оголошується, означає перегляд оголошення; це визначено після того, як буде проаналізовано повне визначення (і для цього вам може знадобитися більше #includes).
Тоні Делрой

20

Що слід пам’ятати:

  • Це не спрацює, якщо class Aє об’єктclass B є членом або навпаки.
  • Попередня декларація - це шлях.
  • Порядок декларації має значення (саме тому ви висуваєте визначення).
    • Якщо обидва класи викликають функції іншого, вам доведеться перемістити визначення.

Прочитайте поширені запитання:


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_dependency_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

На жаль, у всіх попередніх відповідях відсутні деякі деталі. Правильне рішення трохи громіздке, але це єдиний спосіб зробити це правильно. І це масштабується легко, впорається і з більш складними залежностями.

Ось як це можна зробити, точно зберігаючи всі деталі та зручність використання:

  • рішення точно таке, як спочатку передбачалося
  • inline функції все ще вбудовані
  • Користувачі Aта Bможуть включати Ah та Bh у будь-якому порядку

Створіть два файли, A_def.h, B_def.h. Вони містять лише визначення As та B's:

// 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

І тоді, Ah і Bh буде містити це:

// 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не повинні ними користуватися. Публічний заголовок - Ah та Bh


1
Чи має це переваги перед рішенням Тоні Делроя ? Обидва базуються на «помічникових» заголовках, але у Тоні менше (вони просто містять пряму декларацію) і вони, здається, працюють однаково (принаймні, на перший погляд).
Фабіо каже: Відновити Моніку

1
Ця відповідь не вирішує оригінальну проблему. Це просто говорить "висунути декларації в окремий заголовок". Нічого про вирішення кругової залежності (питання потребує вирішення там, де Aє визначення та Bвизначення, для прямого декларування недостатньо).
geza

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

На жаль, я не можу прокоментувати відповідь від geza.

Він не просто каже "висунути декларації в окремий заголовок". Він говорить, що вам потрібно розлити заголовки визначення класів та вбудовані визначення функцій у різні файли заголовків, щоб дозволити "захищені залежності".

Але його ілюстрація не дуже хороша. Тому що обидва класи (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.