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


여러분이 오랬동안 묵혀두었던 어떤 클래스를 다시 작업하였다고 가정해봅시다. 여러분이 프로그램 빌드를 실행한 순간 몇줄 고치지 않는 코드가 예상과는 달리 몽땅 다시 컴파일 되고 다시 링크 되는 것을 마주하게 되었습니다. 이 문제의 핵심은 C++가 인터페이스와 구현을 깔끔하게 분리하는 일에 별로 일가견이 없다는데 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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가 어떻게 정의됐는지를 모르면 컴파일 자체가 불가능합니다. 따라서 대개 아래와 비슷한 코드를 발견하게 될 것 입니다.

1
2
3
#include <string>
#include "date.h"
#include "address.h"

유감스럽게도, 이 녀석들이 바로 골칫덩이들 입니다. 위의 #include문은 Person을 정의한 파일과 위의 헤더 파일들 사이에 컴파일 의존성이란 것을 엮어 버립니다. 그러면 위의 헤더 파일 셋 중 하나라도 바뀌거나, 이들과 또 엮여 있는 헤더 파일들이 바뀌기만 해도, Person 클래스를 정의한 파일은 코 꿰이듯 컴파일러에게 끌려가야 합니다. 심지어 Person을 사용하는 다른 파일들까지도 몽땅 다시 컴파일 되어야 합니다.

클래스 전방선언


클래스 전방 선언이라는 개념에 대해 알아보겠습니다. 클래스 전방 선언은 불필요한 컴파일 의존성을 줄일 수 있는 방법입니다. 아래 예제를 통해 알아봅시다.

1
2
3
4
5
6
7
8
9
10
11
12
13
/*
* /ObjectB/ObjectB.hpp
* ObjectB 클래스에서는 ObjectA를 전방 선언만 하고 정의부를 include 하지 않습니다.
* 단, 컴파일러는 객체의 정의부를 만나면 객체의 크기를 알아야 하므로, 전방 선언된 클래스는 포인터 형으로만 선언하여 사용 가능합니다.
*/

class ObjectA;

class ObjectB
{
public:
    ObjectA* objA;
};
1
2
3
4
5
6
7
8
/*
* ObjectA.hpp
*/

class ObjectA
{
    int a;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/*
* main.cpp
* 그리고 실제로 ObjectB를 사용하는 부분에서 ObjectA의 정의부를 include하여 사용합니다.
*/

#include "ObjectA.hpp"
#include "ObjectB/ObjectB.hpp"

int main()
{   
    ObjectB objB;
    objB.objA = new ObjectA();
    
    return 0;
}

위의 예제를 통해 전방 선언을 사용해서 불필요한 컴파일러 의존성을 낮출 수 있습니다. 이렇게 하면 여러분이 만든 라이브러리를 사용하는 사용자 입장에서 실제로 사용하지 않는 객체에 대한 컴파일 의존성을 낮추고 나아가 라이브러리 사용자가 직접 조작할 수 있다는 장점이 있습니다. 이번에는 이 전방 선언을 이용한 pimpl idom을 알아봅시다.

pimpl idom(관용구)


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include "Person.h"
#include <memory>

class PersonImpl; // Person의 구현 클래스, 전방 선언
                  // Person을 상속 받습니다.

class Date;     // 전방 선언
class Address;  // 전방 선언
                // PersonImpl이 수정하더라도 실제로 PersonImpl을 사용하지 않는 Person의 경우 컴파일을 다시할 필요가 없습니다.

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::shared_ptr<PersonImpl> pImpl;
};
1
2
3
4
5
6
7
8
9
10
#include "Person.h"
#include "PersonImpl.h"

Person::Person(const std::string& name, const Date& birthday, const Address& addr)
: pImpl(new PersonImpl(name, birthday, addr))

std::string Person::name() const
{
    return pImpl->name();
}
1
2
3
4
5
6
7
8
9
class PersonImpl : public Person
{
public:
    PersonImpl(const std::string& name, const Date& birthday, const Address& addr)
    : theName(name), theBirthDate(birthDay), theAddress(addr))
    ()

}

이렇게 설계해두며느 Person 클래스에 대한 구현 클래스 부분은 언제든지 마음대로 고칠 수 있고, Person의 사용자 쪽에서는 컴파일을 다시할 필요가 없습니다. 게다가 Person이 어떻게 구현되어 있는지를 들여다볼 수 없기 때문에, 구현 세부 사항에 발을 걸치는 코드를 작성할 여지가 사라집니다. 그야말로 인터페이스와 구현이 뼈와 살이 분리되듯 떨어지는 것 입니다. 이와 같은 클래스 구현 방식을 handle 클래스라고 합니다.

이렇게 인터페이스와 구현을 둘로 나누는 열쇠는 정의부에 대한 의존성을 선언부에 대한 의존성으로 바꾸어 놓는데 있습니다. 객체 참조자 및 포인터로 충분한 경우에는 객체를 직접 쓰지 않습니다

  • 어떤 타입에 대한 참조자 및 포인터를 정의할 때는 그 타입의 선언부만 필요합니다. 반면, 어떤 타입의 객체를 정의할 때는 그 타입의 정의가 준비되어 있어야 합니다. 할 수 있으면 클래스 정의 대신 클래스 선언에 최대한 의존하도록 만듭니다
  • 어떤 클래스를 사용하는 함수를 선언할 때는 그 클래스의 정의를 가져오지 않아도 됩니다. 심지어 그 클래스 객체를 값으로 전달하거나 반환하더라도 클래스 정의가 필요하지 않습니다.

인터페이스 클래스(Interface Class) / Factory 함수.


handle 클래스 방식이 아닌 다른 방법 중에는 인터페이스 클래스를 사용하는 방법이 있습니다. 아래와 같이 Factory 패턴으로 구현하는 방법인데요. 코드를 보시면 어떻게 사용하는 방식인지 바로 아실 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/*
* 선언부
*/
class Person
{
public:
    virtual ~Person();
    virtual std::string name() const = 0;
    virtual std::string birthDate() const = 0;
    virtual std::string address() const = 0;
    ...
    static std::shared_ptr<Person> create(const std::string& name, const Date& birthday, const Address& addr);
    ...
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/*
* 정의부 - 1
*/
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;
    std::string birthDate() const;
    std::string address() const;

private:
    std::string theName;
    Date theBirthDate;
    Address theAddress;
}
1
2
3
4
5
6
7
/*
* 정의부 - 2
*/
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));
}
1
2
3
4
5
6
7
8
9
10
11
12
13
...
...
std::string name;
Date dateOfBirth;
Address address;

std::shared_ptr<Person> person(Person::create(name, dateOfBirth, address));
std::cout << person->name() << std::endl;
std::cout << person->birthDate() << std::endl;
std::cout << person->address() << std::endl; 
...
...
// 위에서 생성한 객체는 person이 유효범위를 벗어나면 자동으로 delete 됩니다.

End Note


  • 컴파일 의존성을 최소화하는 작업의 배경이 되는 가장 기본적인 아이디어는 ‘정의’ 대신에 ‘선언’에 의존하게 만들자는 것입니다. 이 아이디어에 기반한 두 가지 접근 방법은 핸들 클래스와 인터페이스 클래스입니다.
  • 라이브러리 헤더는 그 자체로 모든 것을 갖추어야 하며 선언부만 갖고 있는 형태여야 합니다. 이 규칙은 템플릿이 쓰이거나 쓰이지 않거나 동일하게 적용합니다.

Reference


  • Effective C++ (Scott Meyers)

Updated:

Leave a comment