순환 참조의 까다로운 오류

순환 참조란?

다음은 순환참조의 대표적인 예시이다.

// A.h
#pragma once

#include "B.h" // 인클루드

class A
{
public:
    B* b; // B.h에 의존
};
// B.h
#pragma once

#include "A.h" // 인클루드

class B
{
public:
    A* a; // A.h에 의존
};

위와 같은 상황에서는 두 헤더가 서로의 정의에 의존되고 있다.

그리고 A.h에서 컴파일러의 전처리기가 #include 처리를 완료한다면 다음과 같다. (전처리기에서 #include는 코드를 모두 복사-붙여넣기를 진행한다.)

// A.h
#pragma once


// #include "B.h" 영역 시작
  class B
  {
  public:
      A* a;
  };
// #include "B.h" 영역 끝


class A
{
public:
    B* b;
};

A.h에 B.h 코드가 복사되었다.

이러한 상황에서 컴파일러가 전처리를 완료하고 본격적으로 컴파일(C++ -> 어셈블리어)을 시작할 때, 컴파일러는 위에서 아래로 한 줄 한 줄, 줄 단위로 파싱을 하기 시작한다. A클래스를 컴파일할 땐, B 클래스가 상단에 위치하기 때문에 컴파일러는 A클래스의 멤버 데이터 b를 처리할 수 있다.

하지만 B클래스를 컴파일할 땐, 멤버데이터인 a의 클래스 A가 파싱 전이라, 클래스 A의 존재를 아직 모른다. 이 상황에서 컴파일 에러가 발생한다.

#pragma once와 인클루드 가드의 한계

#pragma once와 인클루드 가드는 파일이 여러 번 중복 include되는 것을 방지한다. 이를 통해 다중 중복 include를 방지할 수 있지만, 순환 참조 문제 자체를 해결하진 못한다. 위 예시처럼 두 헤더 파일이 서로를 의존하게 된다면, 여전히 컴파일 오류가 발생한다.

즉, 서로 참조할 때 발생하는 순환 참조 문제를 해결하는 데는 한계가 있다.

이를 해결하기 위한 방법으로 전방 선언이 있다.

전방 선언이란?

C/C++를 처음에 배울 때 이런 상황을 배운 경험이 있을것이다.


int main()
{
  func();
}

void func()
{
  int a = 0;
}

이 코드에서는 컴파일러가 한 줄 한 줄, 파싱할 때 3번 라인의 func()을 처리하기 위해 func()의 정보를 찾아야하는데, 구현부가 하단에 위치하고 있어, 아직 func()은 컴파일 전이다. 그럼 컴파일러는 “func()이라는 놈은 본적도 없습니다!” 라면서 컴파일 에러가 발생한다.

이를 해결하기 위한 방법이 바로 전방 선언이다.

void func(); // 전방 선언

int main()
{
  func();
}

void func()
{
  int a = 0;
}

위와 같이 func()이 호출되기 전에 1번 라인처럼 void func()이라는 함수가 존재한다는 것을 미리 컴파일러에게 알려주는 것이다.

저렇게 상단에 배치하는 이유는 컴파일러가 위에서부터 아래로 한 줄 한 줄 컴파일하기 때문에, 미리 알려주기 위해선, 위에다가 미리 써놓는 것이다. 그럼 컴파일러는 “아! 아까 봤습니다. 제가 컴파일해드리죠~!”라고 정상적으로 컴파일해준다.

이러한 원리를 바탕으로 순환참조로 인한 의존성 문제를 해결할 수 있다. 다시 위의 문제가 되는 코드를 가져오면

// A.h
#pragma once


// #include "B.h" 영역 시작
  class B
  {
  public:
      A* a;
  };
// #include "B.h" 영역 끝


class A
{
public:
    B* b;
};

여기서 문제는 클래스 B가 클래스 A의 존재를 아직 모른다는 것이다. 그렇다면 전방 선언으로 다음과 같이 해결할 수 있다.

// A.h
#pragma once


// #include "B.h" 영역 시작
  class A;
  class B
  {
  public:
      A* a;
  };
// #include "B.h" 영역 끝


class A
{
public:
    B* b;
};

위와 같이 B.h에서 B를 정의하기 전에 미리 클래스 A가 존재한다는 것을 컴파일러에게 알려주는 것이다.

설명했던 대로 모든 상황에서 이러한 전방선언이 필요한 것은 아니고, 두 헤더가 서로를 쌍방으로 의존하고 있을 경우 전방선언이 필요하다.

전방선언의 또다른 장점이자 단점

이러한 상황이 아니더라도 전방선언의 장점은 또 있다. 헤더에서 사용하는 전방선언은 헤더 인클루드를 대신할 수 있다. 헤더 인클루드를 제외해버리고 전방선언만으로 헤더를 작성한다면 컴파일 시간이 단축되기도 한다. 하지만 전방선언만으로 작성한 경우 잘못 작성된 코드가 존재할 때, 헤더 인클루드를 통해 힌트를 얻어 IDE가 걸러줄 수 있는 오류들을 잡지 못하고 컴파일 단계에서 오류가 발생하게 된다.(C++의 컴파일 시간은 매우 길기때문에 생산성에 문제가 된다고 생각한다.)

이러한 이유로 구글 C++ 코딩 규칙에서도 전방선언보다 헤더 인클루드를 사용할 것은 권장하고 있다.

첨언할만한 내용?

요즘 언어는 모듈 시스템으로 구현되어있다. 하지만 C++이 만들어졌을 당시에는 헤더 시스템으로 C++가 설계되었다. 헤더 시스템은 모듈 시스템 이전의 방식이라 여러가지 문제가 많다. 대표적으로 매우 오래걸리는 전처리 단계(C/C++ 빌드가 오래걸리는 이유는 헤더 인클루드 복붙때문) , 순환 참조 문제, 전역 네임스페이스 오염, 등등이 존재한다. C++20부터 모듈시스템이 지원하지만 아직 많은 라이브러리가 헤더 시스템을 사용하고 기존 코드와의 호환성 문제도 존재한다. 그리고 모듈시스템을 사용할 줄 아는 사람들도 적은 것 같다. 도서, 전문가를 위한 C++에 설명이 잘 되어있으니 궁금한 사람들은 한번 확인해봐도 좋을 것 같다. 언리얼 엔진같은 볼륨이 큰 프로젝트에서 빌드가 오래걸릴 때마다 모듈시스템이 시급하다고 느껴지곤 한다.

Categories:

Updated:

Leave a comment