성공할 게임개발자

[Effective c++] [5. 구현] 31. 파일 사이의 컴파일 의존성을 최대로 줄이자 본문

C++

[Effective c++] [5. 구현] 31. 파일 사이의 컴파일 의존성을 최대로 줄이자

fn000 2025. 6. 12. 10:58
class Person
{
public:
    Person(const std::string& name, const Date& birthday, const Address& addr);
    std::string name() const;
    std::string birthDate() const;
    std::string address() const;
    ...
    
private:
    std::string theName;
    Date theBirthDate;
    Address theAddress;
};

위 코드만 가지고 Person클래스가 컴파일 될 수 있을까? 아니다. 구현 세부사항인 string, Date, Address가 어떻게 정의됐는지 모르기 때문이다. 결국 이들이 정의된 정보를 써야하고 이때 쓰는 것이 #include이다.

 

이렇게 include하는 것이 Person을 정의한 파일과 헤더파일 사이의 컴파일 의존성을 유발한다. 헤더파일이 셋중 하나라도 바뀌거나 이들과 엮여있는 헤더 파일이 바뀌기만해도 Person클래스를 정의한 파일은 컴파일 해주어야한다.

 

 

 

구현 세부 사항을 지정하는 방식

namespace std
{
    class string;
}

class Date;
class Address;

class Person
{
public:
    Person(const string& name, const Date& birthday, const Address& addr);
    string name() const;
    string birthDate() const;
    string address() const;
    ...
};

 

이런식으로 전방선언을 통해서 구현하면 어떨까?

 

두가지 문제점이 있다.

 

1. string은 클래스가 아니라 typedef로 정의한 타입동의어이다.(basic_string<char>를 typedef 한것) 제대로 전방선언하려면 템플릿을 끌고와야해 더 복잡하다. 하지만 STL은 어지간한 경우아니면 컴파일 시 병목요인이 되지 않는다.

 

 

2. 컴파일러가 컴파일 도중에 객체들의 크기를 전부 알아야한다는 것(중요)

int main()
{
    int x;		//int 정의
    	
    Person p( params );		//Person하나 정의
    ...
}

 

컴파일러가 int를 만나면 int 담을 공간을 할당해야한다. 이건문제가 안되는데 Person p를 만나면 객체의 크기를 알기 위해서 Person이 정의된 클래스를 보는 방법 밖에 없다.

이러한 문제 때문에 C++에서도 스몰토크(프로그래밍 언어) 및 자바의 경우처럼 포인터를 이용하여 실제 객체 구현부를 숨길 수 있다.

 

 

 

해결방법 : pimpl관용구 사용

#include <string>
#include <memory>

class PersonImpl;		//person구현 클래스에 대한 전방선언
class Date;				//person구현 클래스에 대한 전방선언
class Address;

class Person
{
public:
    Person(const string& name, const Date& birthday, const Address& addr);
    std::string name() const;
    std::string birthDate() const;
    std::string address() const;
    ...
private:
    std::shared_ptr<PersonImpl> pImpl;
};

 

주 클래스(Person)에 들어있는 데이터 멤버라곤 구현 클래스(Person-impl)에 대한 포인터 밖에 없다. 이런 설계를 pimpl관용구 라고 한다.

이렇게 설계하면 구현클래스를 고쳐도 Person사용자는 컴파일을 다시할 필요가 없고 Person이 어떻게 구현되었는지 모르기 때문에 구현 세부사항에 연관되어있는 코드를 작성할 여지가 사라진다. 즉, 인터페이스와 구현이 뼈와 살이 분리되듯 떨어지는 것이다.

 

인터페이스와 구현을 둘로 나누는 것은 "정의부에 대한 의존성"을 "선언부에 대한 의존성"으로 바꾸어 놓는데 있다. 이것이 컴파일 의존성을 최소화하는 핵심원리이다.

 

헤더파일을 만들 때는 실용적으로 의미를 갖는 한 자체조달(self-sufficient)형태로 만들고 안되면 다른 파일에 대해 의존성을 갖도록 하되 정의부가 아닌 선언부에 대해 의존성을 갖도록 만드는 것

 

객체 참조자 및 포인터로 충분한 경우에는 객체를 직접쓰지 않는다.

어떤 타입에 대한 참조자 및 포인터를 정의할 때는 그 타입의 선언부만 필요하다. 하지만 객체를 정의할 때는 그 타입의 정의가 준비되야한다.

 

할 수 있으면 클래스 정의 대신 클래스 선언에 최대한 의존하도록 만든다.

어떤 클래스를 사용하는 함수를 선언할 때는 그 클래스의 정의를 가져오지 않아도 됨. 심지어 클래스 객체를 값으로 전달하거나 반환하더라도 클래스 정의가 필요없다.

즉, 쉽게말하면 정의부가 아닌 선언부에 의존한다는건 다른 타입의 전체구현을 include하지 않고 전방선언으로만 해결하라는 뜻. 헤더안에서 string을 사용한다면 <string>을 포함해야함.

 

 

class Date;		//클래스 전방선언

Date today();
void clearAppointments(Date d);		//Date클래스의 정의를 가져오지 않아도 됨

위와 같이 값에 의한 전달이 좋은방법이 아니다. 값에 의한 전달은 복사본을 만드는데 컴파일러는 Date 클래스 크기와 멤버 변수 정보, 복사 생성자 즉, 정의가 필요한데 위 코드는 정의 없이 작성돼 실제로는 컴파일되지 않는다.

 

그러면 왜 이렇게 아무도 호출할 것 같지 않은 함수를 이렇게까지 애써서 선언할까? 호출하는 사람이 아무도 없어서가 아니라 호출하는 사람이 모두가 아니기 때문이다.

즉, 클래스 정의는 어딘가에서 필요한데 이 부담을 함수 선언이 되어있는 우리코드(라이브러리)에 부담을 주는 것이 아니라 실제 함수 호출이 일어나는 사용자의 소스파일에 전가하는 방법을 사용한 것이다. 이렇게 하면 쓰지도 않는 사용자는 의존성을 끌어오는 리스크를 줄일 수 있다.

 

쉽게 설명하면,

라이브러리(헤더) : 나는 선언만하고 정의는 안해 무거우니까. 쓸사람은 알아서 정의해서 써~

사용자1(필요한사람) : 정의해서 씀

사용자2(안필요한사람) : 안씀(다른기능만씀)

 

전방선언 + 최소 의존설계의 핵심개념이다.

 

 

선언부와 정의부에 대해 별도의 헤더파일을 제공한다

"클래스를 둘로 쪼개자"라는 지침을 지키려면 선언부를 위한 헤더파일, 정의부를 위한 헤더파일 두가지가 있어야한다. 따라서 한쪽에서 선언이 바뀌면 다른쪽도 똑같이 바꾸어야한다. 라이브러리 사용자 쪽에서는 전방선언 대신에 선언부 헤더 파일을 항상 include해야하고 라이브러리 제작자는 헤더파일 두개를 짝지어 제공하는 일을 잊으면 안된다.

 

#include "datefwd.h"		// Date 클래스를 선언하고 정의하지는 않는 헤더파일

Date today();
void clearAppointments(Date d);

 

fwd는 C++라이브러리인 <iosfwd> 헤더를 보고 따온 것 <iosfwd>헤더는 C++에서 지원하는 iostream 관련 함수 및 클래스들의 선언부 만으로 구성된 헤더이다.

 

 

 

핸들 클래스의 활용법

#include "Person.h"		//구현클래스 include

#include "PersonImpl.h"		//멤버 변수를 가져오는 pimpl헤더

Person::Person(const string& name, const Date& birthday, const Address& addr)
	: pImpl(new PersonImpl(name, birtday, addr))
    {}
    
string Person::name() const
{
	return pImpl->name();
}

pimpl 관용구를 사용하는 클래스를 핸들 클래스라고 한다. 생성자로 new를 줘서 동적할당한다. Person은 핸들 클래스이지만 Person의 동작이 바뀐게 아니라 동작수행방법이 바뀐것이다.(pImpl이용해 호출)

 

 

 

인터페이스 클래스

기능을 나타내는 인터페이스를 추상 기본 클래스를 통해 마련하고 이 클래스로부터 파생 클래스를 만들 수 있게 한다.

class Person
{
public:
    virtual ~Person();
    
    virtual std::string name() const = 0;
    virtual std::string birthDate() const = 0;
    virtual std::string address() const = 0;
    ...
    
    //팩토리or가상 생성자 : 인터페이스를 지원하는 객체를 동적으로 할당 후, 객체의 포인터를 반환
    static std::shared_ptr<Person> create(const string& name, Date& birthday, Address& addr);
    ...
};

 

 

사용자 쪽에서는 이렇게 사용하면 된다.

std::string name;
Date dateOfBirth;
Address address;
...

//Person 인터페이스를 지원하는 객체 한 개를 생성한다.
std::shared_ptr<Person> pp(Person::create(name, dateOfBirth, address));
...
cout << pp->name() << " was born on" << pp->birthDate() << " and now lives at " << pp->address();
...

구체(concrete) 클래스는 어디엔가 반드시 정의되어야 하고 실행되는생성자가 호출되어야하는 것은 당연하다.

 

 

class RealPerson : public Person {
public:
    RealPerson(const std::string& name, const Date& birthday, const Address& addr) : theName(name), theBirthDate(birthday), theAddress(addr) {}
    
    virtual ~RealPerson() {}

    std::string name() const { return theName; }
    std::string birthDate() const { return theBirthDate; }
    std::string address() const { return theAddress; }

private:
    std::string theName;
    Date theBirthDate;
    Address theAddress;
};

std::shared_ptr<Person> Person::create(const std::string& name, const Date& birthday, const Address& addr) {
    return std::shared_ptr<Person>(new RealPerson(name, birthday, addr));
}

이렇게 RealPerson이 정의되어 있다면 

 

 

std::shared_ptr<Person> Person::create(const std::string& name, const Date& birthday, const Address& addr)
{
	return std::shared_ptr<Person>(new RealPerson(name, birthday, addr));
}

이런식으로 간단하게 만들 수 있다. 

 

 

리스크

비용

객체 한 개당 저장 공간이 추가로 늘어난다. 

핸들클래스의 경우 멤버 함수를 호출하면 데이터까지 가기위해 pimpl포인터를 타야하므로 간접화 연산이 한 단계 더 증가한다. 

객체 하나씩 저장하는데 필요한 메모리 크기에 구현부 포인터 크기까지 더해짐. 구현부 포인터의 초기화를 해줘야 하므로 동적 메모리 할당에 따른 연산 오버헤드 리스크가 있다.

 

또한 인터페이스 에서 호출되는 함수가 가상함수라 가상테이블 점프에 따른 비용이 소모된다.(항목7)

 

인라인

인라인함수의 도움을 받기 힘들다. 인라인이 되게 만드려면 함수 본문을 헤더파일에 두어야하는데(항목30) 핸들 클래스와 인터페이스 클래스는 함수 본문과 같은 구현부를 분리하기 때문에 인라인함수에 적합하지 않다.

 

 

 

중요포인트!

컴파일 의존성을 최소화하는 작업의 배경이 되는 가장 기본적인 아이디어는 "정의" 대신에 "선언"에 의존하게 만들자는 것임. 이 아이디어에 기반한 두 가지 접근 방법은 핸들 클래스와 인터페이스 클래스이다.

 

라이브러리 헤더는 그 자체로 모든 것을 갖추어야하면 선언부만 갖고 있는 형태여야한다. 이 규칙은 템플릿이 쓰이거나 쓰이지 않거나 동일하게 적용