31: 파일 사이의 컴파일 의존성을 최대로 줄이자
파일 사이의 컴파일 의존성이 높다면 하나의 파일에 연쇄적으로 컴파일되어 시간이 오래 걸린다. 문제의 핵심은 C++가 인터페이스와 구현을 깔끔하게 분리하는 일에 별로 일가견이 없다는데 있다. C++의 클래스 정의는 클래스 인터페이스만 지정하는 것이 아니라 구현 세부사항까지 상당히 많이 지정하고 있기 때문이다.
class Person {
Person(const string& name, const Date& birthday, const Address& addr);
string name() const;
string birthday() const;
string address() const;
private:
string theName; // 구현 세부사항
Data theBirthDate; // 구현 세부사항
Address theAddress; // 구현 세부사항
};
위의 코드는 Person클래스만으로는 컴파일되지 않는다. string, Date, Address가 어떻게 정의되었는지를 모르기 때문이다. 이들이 정의된 정보를 가져 와야 하고, 이때 쓰는 것이 #include이다.
#include <string>
#include "date.h"
#include "address.h"
이 것들에 의해 Person을 정의한 파일과 위의 헤더 파일들 사이의 컴파일 의존성(compilation dependency)가 엮어 버린다.
Person 클래스를 정의할 때 구현 세부사항을 따로 떼어서 지정하는 식으로 하면 어떨까?
namespace std
{
class string;
}
class Date;
class Address;
class Person {
Person(const string& name, const Date& birthday, const Address& addr);
string name() const;
string birthday() const;
string address() const;
private:
string theName; // 구현 세부사항
Data theBirthDate; // 구현 세부사항
Address theAddress; // 구현 세부사항
};
이러면 Person 클래스의 인터페이스가 바뀌었을 때만 컴파일을 다시 하면 되니 참 좋다. 하지만 문제가 있다. 첫 번째, string은 사실 클래스가 아니라 typedef로 정의한 타입동의어이다. 두번째 문제는 컴파일러가 컴파일 도중에 객체들의 크기를 전부 알아야 한다는 데 있다.
int main()
{
int x;
Person p(params);
}
위의 Person 객체 하나의 크기가 얼마인지를 컴파일러가 어떻게 알아낼 수 있을까? 이 Person 클래스가 정의된 정보를 보는 수밖엔 없다.
포인터 뒤에 실제 객체 구현부를 숨기는 방법을 사용해보자. 우선, 주어진 클래스를 두 클래스로 쪼갠다. 한쪽은 인터페이스만 제공하고, 나머지는 그 인터페이스의 구현을 맡도록 만드는 것이다.
#include <string>
#include <memory>
class PersonImpl; // 구현 클래스에 대한 전방 선언
class Date;
class Address;
class Person {
Person(const string& name, const Date& birthday, const Address& addr);
string name() const;
string birthday() const;
string address() const;
private:
tr1::shared_ptr<PersonImpl> pImpl; // 구현 클래스 객체에 대한 포인터
};
위의 코드를 보면 주 클래스(Person)에 들어 있는 데이터 멤버라고는 구현 클래스(Person-Impl)에 대한 포인터뿐이다. 이런 설계는 pimpl 관용구(point to implementation)으로 불린다. 이렇게 설계하면 자질구레한 세부사항과 완전히 갈라서게 된다. Person 클래스에 대한 구현 클래스 부분은 생각만 있으면 마음대로 고칠 수 있지만, 그래도 Person의 사용자 쪽에서는 컴파일을 다시 할 필요가 없다.
이렇게 인터페이스와 구현을 둘로 나누는 열쇠는 '정의부에 대한 의존성(dependencies on definitions)'을 '선언부에 대한 의존성(dependencies on declearations)'으로 바꾸어 놓는 데 있다. 이게 바로 컴파일 의존성을 최소화하는 핵심 원리이다.
정리를 해보자.
- 객체 참조자 및 포인터로 충분한 경우에는 객체를 직접 쓰지 않는다.
- 어떠 타입에 대한 참조자 및 포인터를 정의할 때는 그 타입의 선언부만 필요하다. 반면, 어떤 타입의 객체를 정의할 때는 그 타입의 정의가 준비되어 있어야 한다.
- 할 수 있으면 클래스 정의 대신 클래스 선언에 최대한 의존하도록 만든다.
- 어떤 클래스를 사용하는 함수를 선언할 때는 그 클래스의 정의를 가져오지 않아도 된다.
class Date;
Date today();
void clearAppointments(Date d);
- 선언부와 정의부에 대해 별도의 헤더 파일을 제공한다.
- 헤더 파일은 짝으로 있어야 한다. 하나는 선언부를 위한 헤더 파일이며, 또 하나는 정의부를 위한 헤더 파일이다. 한쪽에서 어떤 선언이 바뀌면 다른 쪽도 똑같이 바꾸어야 한다는 거다. 그렇기 때문에 라이브러리 사용자 쪽에서는 전방 선언 대신에 선언부 헤더 파일을 항상 #include해야 할 것이고, 라이브러리 제작자 쪽에서는 헤더 파일 두개를 짝지어 제공하는 일을 잊으면 안 된다.
#include "datefwd.h" // Date 클래스를 선언하고 있는 헤더 파일
// 전방 선언을 하지 않는다.
Date today();
void clearAppointments(Date d);
위에서 본 pimpl 관용구를 사용하는 Person 같은 클래스를 가리켜 핸들 클래스(handle class)라고 한다. 핸들 클래스에서 어떤 함수를 호출하게 되어 있다면, 핸들 클래스에 대응되는 구현 클래스 쪽으로 그 함수 호출을 전달해서 구현 클래스가 실제 작업을 수행하게 만들자.
#include "Person.h"
#include "PersonImpl.h"
Person::Person(const string& name, const Date& birthday, const Address& addr)
: pImpl(new PersonImpl(name, birthday, addr))
{}
string Person::name() const
{
return pImpl->name();
}
핸들 클래스 방법 대신에 다른 방법을 쓰고 싶다면 Person을 특수 형태의 추상 기본 클래스, 인터페이스 클래스(interface class)로 만드는 방법도 생각해 볼 수 있다. 어떤 기능을 나타내는 인터페이스를 추상 기본 클래스를 통해 마련해 놓고, 이 클래스로부터 파생 클래스를 만들 수 있게 한다.
class Person {
public:
virtual ~Person();
virtual string name() const = 0;
virtual string birthDate() const = 0;
virtual string address() const = 0;
};
이 클래스를 코드에 써먹으려면 Person에 대한 포인터 혹은 참조자로 프로그래밍하는 방법밖에 없다. 순수 가상 함수를 포함한 클래스를 인스턴스로 만드는 것은 부가능하기 때문이다. 그리고 인터페이스 클래스의 인터페이스가 수정되지 않는 한 사용자는 다시 컴파일할 필요가 없다.
또한 인터페이스 클래스를 사용하기 위해서는 객체 생성 수단이 최소한 하나는 있어야 한다. 대개 이 문제는 파생 클래스의 생성자 역할을 대신하는 어떤 함수를 만들어 놓고 이것을 호출함으로써 해결하고는 한다. 이런 함수를 가리켜 팩토리 함수 혹은 가상 생성자(virtual constructo)라고 부른다. 역할은 주어진 인터페이스 클래스의 인터페이스를 지원하는 객체를 동적으로 할당한 후, 그 객체의 포인터를 반환하는 것이다.
class Person {
public:
static tr1::shared_ptr<Person> create(
const string& name, const Date& birthday, const Address& addr);
};
다음과 같이 사용하면 된다.
string name;
Date dateOfBirth;
Address address;
// Person 인터페이스를 지원하는 객체 한 개를 생성
tr1::shared_ptr<Person> pp(Person::create(name, dateOfBirth, address));
Person 클래스로부터 파생된 구체 클래스가 있다면, 이 클래스는 자신이 상속받은 가상함수에 대한 구현부를 제공하는 식으로 만들어졌을 것이다.
class RealPerson : public Person {
public:
RealPerson(const string& name, const Date& birthday, const Address& addr)
: theName(name), theBirthDate(birthday), theAddress(addr)
{}
virtual ~RealPerson() {}
string name() const;
string birthDate() const;
string address() const;
private:
string theName;
Date theBirthDate;
Address theAddress;
};
Person::create함수는 다음과 같이 만들 수 있다.
tr1::shared_ptr<Person> Person::create(
const string& name, const Date& birthday, const Address& addr)
{
return tr1::shared_ptr<Person>(new RealPerson(name, birthday, addr));
}
다시 정리하면, 인터페이스 클래스로부터 인터페이스 명세를 물려받게 만든 후에, 그 인터페이스에 들어 있는 함수를 구현하는 것이다. 두 번째 방법은 다중 상속을 사용하는 것이다.
결론적으로, 핸들 클래스와 인터페이스 클래스는 구현부로부터 인터페이스를 떼어 놓음으로써 파일들 사이의 컴파일 의존성을 완화시키는 효과를 가져다 준다.
비용을 생각해보자. 핸들 클래스의 경우 알맹이 객체의 데이터까지 가기 위해 포인터를 타야 한다. 둘째, 객체 하나씩을 저장하는데 필요한 메모리 크기에 구현부 포인터의 크기가 더해지는 것도 필수이다. 마지막으로, 구현부 포인터가 동적 할당된 구현부 객체를 가리키도록 어디선가 그 표현부 포인터의 초기화가 일어나야 한다.
인터페이스 클래스의 경우에는 호출되는 함수가 전부 가상 함수라는 것이 약점이다. 호출이 일어날 때마다 가상 테이블 점프에 따르는 비용이 소모된다. 인터페이스 클래스에서 파생된 객체는 죄다 가상 테이블 포인터를 지니고 있어야 한다.
마지막으로, 둘 다 갖고 있는 단점은 인라인 함수의 도움을 제대로 끌어내기 힘들다는 점이다.
이것만은 잊지 말자!
- 컴파일 의존성을 최소화하는 작업의 배경이 되는 가장 기본적인 아이디어는 '정의' 대신에 '선언'에 의존하게 만들자는 것이다. 이 아이디어에 기반한 두 가지 접근 방법은 핸들 클래스와 인터페이스 클래스이다.
- 라이브러리 헤더는 그자체로 모든 것을 갖추어야 하며 선언부만 갖고 있는 형태여야 한다. 이 규칙은 템플릿이 쓰이거나 쓰이지 않거나 동일하게 적용한다.
'C++ > Effective C++' 카테고리의 다른 글
[Effective C++] 33: 상속된 이름을 숨기는 일은 피하자 (0) | 2023.11.02 |
---|---|
[Effective C++] 32: public 상속 모형은 반드시 "is-a(...는 ...의 일종이다)"를 따르도록 만들자 (1) | 2023.10.31 |
[Effective C++] 30: 인라인 함수는 미주알고주알 따져서 이해해 두자 (0) | 2023.10.29 |
[Effective C++] 29: 예외 안정성이 확보되는 그날 위해 싸우고 또 싸우자! (0) | 2023.10.28 |
[Effective C++] 28: 내부에서 사용하는 객체에 대한 '핸들'을 반환하는 코드는 되도록 피하자 (0) | 2023.10.26 |