순환 참조의 까다로운 오류
순환 참조란?
다음은 순환참조의 대표적인 예시이다.
// 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++
에 설명이 잘 되어있으니 궁금한 사람들은 한번 확인해봐도 좋을 것 같다. 언리얼 엔진같은 볼륨이 큰 프로젝트에서 빌드가 오래걸릴 때마다 모듈시스템이 시급하다고 느껴지곤 한다.
Leave a comment