- 컴파일 의존성을 최소화하는 작업의 배경이 되는 가장 기본적인 아이디어는 정의 대신에 선언에 의존하게 만드는 것이다. 이러한 방법으로는 핸들클래스와 인터페이스 클래스이다.
- 라이브러리 헤더는 그 자체로 모든 것을 갖추어야 하며 선언부만 갖고 있는 형태여야 한다. 이 규칙은 템플릿의 여부와 관계없이 동일하게 적용해야 한다.
파일 사이의 컴파일 의존성을 최대로 줄이자
C++은 헤더와 구현부를 두 파일로 분리하여 구현하는 것이 일반적이다.
프로그램의 규모가 커지면 여러 개의 파일이 존재할 것이다. 그러다 보면 파일들 사이의 컴파일 의존성이 올라가 빌드 속도도 느려지고 에러가 발생할 여지가 생긴다.
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 클래스를 컴파일할 수 없다.
Person의 구현 세부사항에는 string, Date, Address가 있기 때문에 이것들이 어디에 정의되었는지 알아야 한다.
따라서, 헤더 파일들을 불러오는 include가 필요하다.
#include <string>
#include "date.h"
#include "address.h"
하지만, 이 부분이 문제의 원인이 된다.
#include문은 Person을 정의한 파일과 위의 헤더 파일들 사이에 컴파일 의존성이 생기게된다.
그러면 위의 헤더 파일 중 하나라도 바뀌거나 이들과 엮여 있는 헤더 파일들이 바뀌기만 해도, Person 클래스를 정의한 파일은 컴파일러에 의해 링크된다.
즉, Person을 사용하는 모든 파일이 다시 컴파일되어야 한다는 말이다.
전방 선언을 이용해도 문제가 있다.
namespace std {
class string;
}
class Date;
class Address;
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;
...
};
우선, string은 클래스가 아니라 typedef로 정의한 타입동의어이기 때문에 전방 선언 자체가 말이 되지 않는다.
또한, 컴파일러가 컴파일 도중에 객체들의 크기를 전부 알아야 한다.
하지만, 전방 선언된 클래스의 크기를 정확히 알 수 없다. 하지만, pimpl 기법을 이용하여 해결할 수 있다.
#include <string>
#include <memory>
class PersonImpl;
class Date;
class Address;
class Person {
public:
Person(cosnt std::string& name, const Date& birthday, const Address& addr);
std::string name() const;
std::string birthDate() const;
std::string address() const;
...
pricate:
std::tr1::shared_ptr<PersonImpl> pImpl;
};
이렇게 하면 Person의 사용자는 생일, 주소, 이름 등 세부사항을 신경 쓰지 않아도 된다. 다시 컴파일할 필요도 없다.
이렇게 인터페이스와 구현을 둘로 나누는 포인트는 정의부에 대한 의존성을 선언부에 대한 의존성으로 바꾸어 놓는 것이다.
이것이 컴파일 의존성을 최소화하는 핵심 원리이다. 즉, 헤더 파일을 만들 때는 가급적으로는 외부 파일과 연관되게 하지 말고 불가피한 경우에만 다른 파일의 선언부에 대해 의존성을 갖도록 만들어야 한다.
정리하면 다음과 같다.
- 객체 참조자 및 포인터로 충분한 경우에는 객체를 직접 쓰지 말아라.
어떤 타입에 대한 참조자 및 포인터를 정의할 때는 그 타입의 선언부만 필요하다. 반면, 어떤 타입의 객체를 정의할 때는 그 타입의 정의가 있어야 한다. - 할 수 있으면 클래스 정의 대신 클래스 선언에 의존하도록 만들어라.
어떤 클래스를 사용하는 함수를 선언할 때는 그 클래스의 정의를 가져오지 않아도 된다. 그 클래스 객체를 값으로 전달하거나 반환하더라도 정의는 필요 없다.
class Date; // 전방 선언
Date today();
void clearAppointments(Date d); // Date 정의 없이 사용 가능
Date의 정의 없이 사용 가능한 이유는 클래스 정의를 제공하는 일은 함수 선언이 되어 있는 라이브러리의 헤더 파일 쪽에 부담을 주지 않고 실제 함수 호출이 일어나는 사용자의 소스 파일 쪽에서 일어나기 때문이다.
- 선언부와 정의부에 대해 별도의 헤더 파일을 제공해라.
선언부와 정의부의 헤더 파일을 분리하여 짝으로 관리해야 한다. 한쪽에서 어떤 선언이 바뀌면 다른 쪽도 똑같이 바꿔야 한다. 그렇기 때문에, 라이브러리 사용자 쪽에서는 전방 선언 대신에 선언부 헤더 파일을 항상 #include 할 수 있고, 라이브러리 제작자 쪽에서는 헤더 파일 두 개를 짝지어 제공하는 일을 잊으면 안 된다.
#include "datefwd.h" // Date의 선언 헤더(정의는 X)
Date today();
void clearAppointments(Date d);
선언부만 들어 있는 헤더 파일의 이름이 "datefwd.h"이다. 이 헤더에는 여러 개의 클래스에 대한 선언부 헤더만 포함하고 있다.
또한, 템플릿에도 적용되는 내용이다. C++에서는 템플릿 선언과 템플릿 정의를 분리할 수 있도록 하는 기능을 export라는 키워드로 제공하고 있다. 문제는 이 키워드를 제대로 지원하는 컴파일러가 별로 없다는 것이다.
핸들 클래스, 인터페이스 클래스
앞에서 본 pimpl을 사용하는 Person 같은 클래스를 핸들 클래스라고 한다.
이 핸들 클래스를 사용하는 방법은, 핸들 클래스에서 어떤 함수를 호출하게 되어 있다면, 핸들 클래스에 대응되는 구현 클래스 쪽으로 그 함수 호출을 전달해서 구현 클래스가 실제 작업을 수행하게 만드는 것이다.
#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();
}
Person 생성자와 name 함수를 살펴보면 Pimpl의 함수를 사용하고 있다. Person은 핸들 클래스이지만 Person의 동작은 바뀐 것이 아니다. 수행 방법이 바뀐 것이다.
핸들 클래스 방법 대신 다른 방법을 쓰고 싶다면 Person을 특수 형태의 추상 기본 클래스인 인터페이스 클래스로 만드는 방법도 있다.
어떤 기능을 나타내는 인터페이스를 추상 기본 클래스를 통해 마련해 놓고, 이 클래스로부터 파생 클래스를 만들 수 있게 만들면 된다. 파생이 목적이기 때문에 이런 클래스에는 데이터 멤버도 없고, 생성자도 없으며, 하나의 가상 소멸자와 인터페이스를 구성하는 순수 가상 함수만 들어 있다.
인터페이스 클래스는 자바 및 닷넷의 인터페이스와 비슷하지만, C++은 인터페이스에 대해 제약을 가하지 않는다.
예를 들면, 자바나 닷넷의 인터페이스는 함수 구현이 불가능하지만 C++은 그렇지 않다.
class Person {
public:
virtual ~Person();
virtual std::string name() const = 0;
virtual std::string birthDate() const = 0;
virtual std::string address() const = 0;
...
};
이 클래스의 사용법은 Person에 대한 포인터 혹은 참조자를 활용하는 것이다.
순수 가상 함수를 포함한 클래스를 인스턴스로 만들기는 불가능하다. 그리고 인터페이스 클래스의 인터페이스가 수정되지 않는 한 사용자는 다시 컴파일할 필요가 없다.
또한, 인터페이스 클래스를 사용하기 위해서는 객체 생성 수단이 최소한 하나는 있어야 한다.
이 문제는 파생 클래스의 생성자 역할을 대신하는 어떤 함수를 만들어 놓고 이것을 호출함으로써 해결하고는 한다.
이런 함수를 팩토리 함수 혹은 가상 생성자라고 부른다. 주어진 인터페이스 클래스의 인터페이스를 지원하는 객체를 동적으로 할당한 후, 그 객체의 포인터를 반환하는 것이다.
보통 인터페이스 클래스 내부에 정적 멤버로 선언되는 경우가 많다.
class Person {
public:
...
static std::tr1::shared_ptr<Person> create(const std::string& name, const Date& birthday, const Address& addr);
};
std::string name;
Date dateOfBirth;
Address address;
...
std::tr1::shared_ptr<Person> pp(Person::create(name, dateOfBirth, address));
...
std::cout << pp->name() << " was born on" << pp->birthDate();
해당 인터페이스 클래스의 인터페이스를 지원하는 구체 클래스가 어디엔가 정의되어야 할 것이고 실행되는 생성자가 호출되어야 하는 것은 당연하다.
실제로 이 부분은 가상 생성자의 구현부를 갖고 있는 파일 안에서 이루어진다.
예를 들어 Person 클래스로부터 파생된 RealPerson이라는 구체 클래스가 있다면, 이 클래스는 자신이 상속받은 가상 함수에 대한 구현부를 제공하는 식으로 만들어졌을 것이다.
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;
};
std::tr1::shared_ptr<Person> Person::create(const std::string& name, const Date& birthday, const Address& addr)
{
return std::tr1::shared_ptr<Person>(new RealPerson(name, birthday, addr));
}
인터페이스 클래스를 구현하는 용도로 가장 많이 쓰이는 메커니즘은 두 가지가 있다.
RealPerson을 구현한 방법은 인터페이스 클래스로부터 인터페이스 명세를 물려받게 만든 후, 그 인터페이스에 들어 있는 함수를 구현하는 것이다.
또 다른 방법은, 다중 상속을 이용하는 것이다.
결론적으로, 핸들 클래스와 인터페이스 클래스는 구현부로부터 인터페이스를 분리함으로써 파일들 사이의 컴파일 의존성을 완화시키는 효과를 가져다준다.
하지만, 실행 시간 비용이 더 들고 저장 공간이 추가로 들어간다.
핸들 클래스부터 보면, 핸들 클래스의 멤버 함수를 호출하면 구현부 객체의 데이터까지 가기 위해 포인터를 타고 가야 한다. 한번 접근할 때마다 요구되는 간접화 연산이 한 단계 증가한다.
또한, 객체 하나씩 저장하는데 필요한 메모리 크기에 구현부 포인터의 크기가 더해지는 것도 필수이다.
마지막으로, 구현부 포인터가 동적 할당된 구현부 객체를 가리키도록 어디선가 그 구현부 포인터의 초기화가 일어나야 한다.
결국 동적 메모리 할당에 따르는 연산 오버헤드는 물론이고, 예외 가능성도 발생한다.
인터페이스 클래스의 경우에는 호출되는 함수가 전부 가상 함수라는 것이 약점이다.
따라서 함수 호출이 일어날 때마다 가상 테이블 점프에 따르는 비용이 소모된다. 게다가 인터페이스 클래스에서 파생된 객체는 가상 테이블 포인터를 지니고 있어야 한다. 만약 가상 함수를 공급하는 쪽이 인터페이스 클래스밖에 없을 때는, 이 가상 테이블 포인터도 객체 하나를 저장하는 데 메모리가 필요하다.
마지막으로, 핸들 클래스와 인터페이스 클래스가 모두 갖고 있는 약점이 있다.
인라인 함수가 효율적으로 동작하지 않는다는 점이다. 인라인이 되게 만들려면 함수 본문을 보통 헤더 파일에 두어야 한다. 그렇지만 핸들 클래스와 인터페이스 클래스는 함수 본문과 같은 구현부를 사용자가 볼 수 없게 하는 데 중점을 둔 설계이다. 즉, 인라인과 잘 맞지 않는다.
하지만 이런 약점을 감안하더라도 충분히 사용할만한 기법들이다.
구현부가 바뀌었을 때 사용자에게 영향을 미치는 것을 줄일 수 있다. 그리고 나중에 개발이 끝나고 성능이 기대 이하일 때 클래스 사이 결합도를 높이는 방법이 유일하다면 바꾸면 된다.