클래스를 아주 살짝 손보았다고 생각합시다. 인터페이스도 아니고, 구현부에 있는 코드 몇 줄인데다가, 외부에 노출되지도 않는 부분이었죠. 이제 수정을 마친 후 프로그램을 다시 빌드하기로 합니다. 클래스 하나만 바뀌었을 뿐이니 기껏해야 몇 초 안 걸릴 것 같지만, 건드리지도 않은 다른 코드들까지 몽땅 다시 컴파일되고 다시 링크되어 오래 걸릴 수도 있습니다.
문제의 핵심은 C++이 인터페이스와 구현을 깔끔하게 분리하는 일에 별로 일가견이 없다는데 있습니다. C++의 클래스 정의(class definition)는 클래스 인터페이스만 지정하는 것이 아니라 구현 세부사항까지 상당히 많이 지정하고 있거든요.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | class Person { public: Person(const stdLLstring& 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; // 구현 세부사항 }; | cs |
위의 코드만 가지고 Person 클래스는 컴파일될 수 없습니다. Person의 구현 세부사항에 속하는 것들, 다시 말해 string, Date, Address가 어떻게 정의됐는지를 모르면 컴파일 자체가 불가능합니다. 결국 이들이 정의된 정보를 가져 와야 하고, 이때 쓰는 것이 #include 지시자이죠. 따라서 Person 클래스를 정의하는 파일을 보면 대개 아래와 비슷한 코드를 발견하게 되는 것입니다.
1 2 3 | #include <string> #include "date.h" #include "address.h" | cs |
유감스럽지만 이 녀석들이 바로 골칫덩이입니다. 위의 #include 문은 Person을 정의한 파일과 위의 헤더 파일들 사이에 컴파일 의존성(compilation dependency)이란 것을 엮어 버립니다. 그러면 위의 헤더 파일 셋 중 하나라도 바뀌는 것은 물론이고 이들과 또 엮여 있는 헤더 파일들이 바뀌기만 해도, Person 클래스를 정의한 파일은 코 꿰이듯 컴파일러에게 끌려가야 합니다. 심지어 Person을 사용하는 다른 파일들까지 몽땅 다시 컴파일되어야 합니다.
Person 클래스를 정의할 때 구현 세부사항을 따로 떼어서 지정하는 식으로 해결을 시도해 봅시다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | namespace std { class string; // 전방 선언 } class Date; // 전방 class Address; // 전방 선언 class Person { public: Person(const stdLLstring& name, const Date& birthday, const Address& addr); std::string name() const; std::string birthDate() const; std::string address() const; ... }; | cs |
아이디어만 놓고 보면 될 것 같지만 문제가 두 가지나 있습니다. 첫 번째, string은 사실 클래스가 아니라 typedef로 정의한 타입동의어입니다(basic_string<char>를 typedef한 것이죠). 그러니 string에 대한 전방 선언이 맞을 리가 없죠. 제대로 전방 선언을 하려면 템플릿을 추가로 끌고 들어와야 하기 때문에 더 복잡합니다. 표준 라이브러리 헤더는 어지간한 경우만 아니면 컴파일 시 병목요인이 되진 않지만, 표준 헤더를 구문분석하는 단계가 문제가 되면, 표준 라이브러리의 구성요소 중에 원치 않는 #include가 생기게 하는 것들을 사용하지 않게끔 인터페이스 설계를 직접 고치는 방법밖에는 없습니다. 필요한 요소들을 모두 전방 선언할 때의 두 번째 문제는(첫 번째 문제보다 훨씬 중요합니다), 컴파일러가 컴파일 도중에 객체들의 크기를 전부 알아야 한다는 데 있습니다.
1 2 3 4 5 6 | int main() { int x; // int 하나를 정의합니다. Person p { params }; // Person 하나를 정의합니다. ... } | cs |
컴파일러는 x의 정의문을 만나면 일단 int 하나를 담을 충분한 공간을 할당해야(대개 스택에) 한다는 것을 알고 있습니다. 컴파일러가 int의 크기가 얼마나 되는지 알고 있기 때문에 아무 문제 없습니다. 자, 이제 그 컴파일러가 p의 정의문을 만납니다. 역시 Person 하나를 담을 공간을 할당해야 한다는 것은 알고 있지만, 객체 하나의 크기가 얼마인지를 컴파일러가 알아낼 방법이 없습니다.
스몰토크(Smalltalk) 및 자바의 경우로 눈을 돌려보면 지금의 고민은 고민거리조차 안 됩니다. 이들 언어에서는 객체가 정의될 때 컴파일러가 그 객체의 포인터를 담을 공간만 할당하거든요. 물론 C++에서도 '포인터 뒤에 실제 객체 구현부 숨기기'를 하실 수 있습니다. 우선 주어진 클래스를 두 클래스로 쪼개십시오. 한쪽은 인터페이스만 제공하고, 또 한쪽은 그 인터페이스의 구현을 맡도록 만드는 것입니다. 구현을 맡은 클래스의 이름이 PersonImpl이라고 하면, Person 클래스는 다음과 같이 정의할 수 있을 거예요.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | #include <string> // 표준 라이브러리 구성요소는 // 전방 선언을 하면 안 됩니다. #include <memory> // tr1::shared_ptr을 위한 헤더입니다. class PersonImpl; // Person의 구현 클래스에 대한 전방 선언 class Date; // Person 클래스 안에서 사용하는 class Address; // 것들에 대한 전방 선언 class Person { public: Person(const stdLLstring& name, const Date& birthday, const Address& addr); std::string name() const; std::string birthDate() const; std::string address() const; ... private: std::tr1::shared_ptr<PersonImpl> pImpl; // 구현 클래스 객체에 대한 포인터 }; | cs |
위의 코드를 보면 주 클래스(Person)에 들어 있는 데이터 멤버라고는 구현 클래스(PersonImpl)에 대한 포인터(tr1::shared_ptr)뿐입니다. 이런 설계는 거의 패턴으로 굳어져 있을 정도여서 pimpl 관용구("pointer to implementation")라는 이름도 있는데, 이때 포인터의 이름은 대개 pImpl이라고 붙이는 것이 일반적입니다. 어쨌든 이렇게 설계해 두면, Person의 사용자는 생일, 주소, 이름 등의 자질구레한 세부사항과 완전히 갈라서게 됩니다. Person 클래스에 대한 구현 클래스 부분은 생각만 있으면 마음대로 고칠 수 있지만, 그래도 Person의 사용자 쪽에서는 컴파일을 다시 할 필요가 없습니다. 게다가 Person이 어떻게 구현되어 있는지를 들여다볼 수 없기 때문에, 구현 세부사항에 어떻게든 직접 발을 걸치는 코드를 작성할 여지가 사라집니다.
이렇게 인터페이스와 구현을 둘로 나누는 열쇠는 '정의부에 대한 의존성(dependencies on definitions)'을 '선언부에 대한 의존성(dependencies on declarations)'으로 바꾸어 놓는 데 있습니다. 이게 바로 컴파일 의존성을 최소화하는 핵심 원리이죠. 즉, 헤더 파일을 만들 때는 실용적으로 의미를 갖는 한 자체조달(self-sufficient) 형태로 만드시고, 정 안 되면 다른 파일에 대해 의존성을 갖도록 하되 정의부가 아닌 선언부에 대해 의존성을 갖도록 만드는 것입니다. 매우 간결한 전략이지만 이 외의 나머지 전략들은 이것을 축으로 해서 흘러가게 되어 있습니다.
객체 참조자 및 포인터로 충분한 경우에는 객체를 직접 쓰지 않습니다.
어떤 타입에 대한 참조자 및 포인터를 정의할 때는 그 타입의 선언부만 필요합니다.
반면, 어떤 타입의 객체를 정의할 때는 그 타입의 정의가 준비되어 있어야 합니다.
할 수 있으면 클래스 정의 대신 클래스 선언에 최대한 의존하도록 만듭니다.
어떤 클래스를 사용하는 함수를 선언할 때는 그 클래스의 정의를 가져오지 않아도 됩니다.
심지어 그 클래스 객체를 값으로 전달하거나 반환하더라도 클래스 정의가 필요 없습니다.
물론 '값에 의한 전달' 방식은 좋은 방법이라고 보긴 힘들지만, 피치 못할 사정 때문에 써야 할 경우도 있을 텐데, 이런 경우에도 불필요한 컴파일 의존성을 끌고 들어오는 건 좋지 않습니다.
1 2 3 4 | class Date; // 클래스 Date today(); // Date 클래스의 정의를 void clearAppointments(Date d); // 가져오지 않아도 됩니다. | cs |
Date를 정의하지 않고도 today와 clearAppointments 함수를 선언할 수 있다는 건 보기만큼 이상한 것도 아닙니다. 누군가가 이들 함수를 호출한다면 호출하기 전에 Date의 정의가 파악되어야 하겠죠. 그렇다면, 어째서 아무도 호출할 것 같지 않은 함수를 이렇게까지 애써서 선언하려는 걸까요? 이는 호출하는 사람이 아무도 없어서가 아니라, 호출하는 사람이 모두가 아니기 때문입니다. 제품을 만들려면 클래스 정의를 제공하는 일을 어딘가에서 해야 하겠지만, 함수 선언이 되어 있는 여러분(라이브러리)의 헤더 파일 쪽에 그 부담을 주지 않고 실제 함수 호출이 일어나는 사용자의 소스 파일 쪽에 전가하는 방법을 사용한 것입니다. 이렇게 하면 실제로 쓰지도 않을 타입 정의에 대해 사용자가 의존성을 끌어오는 거추장스러움을 막을 수 있지요.
선언부와 정의부에 대해 별도의 헤더 파일을 제공합니다.
"클래스를 둘로 쪼개자"라는 지침을 제대로 쓸 수 있도록 하려면 헤더 파일이 짝으로 있어야 합니다.
하나는 선언부를 위한 헤더 파일이고, 또 하나는 정의부를 위한 헤더 파일이겠지요.
당연한 이야기이겠지만 이 두 파일은 관리도 짝 단위로 해야 합니다.
한쪽에서 어떤 선언이 바뀌면 다른 쪽도 똑같이 바꾸어야 한다는 거죠.
그렇기 때문에, 라이브러리 사용자 쪽에서는 전방 선언 대신에 선언부 헤더 파일을 항상 #include해야 할 것이고, 라이브러리 제작자 쪽에서는 헤더 파일 두 개를 짝지어 제공하는 일을 잊으면 안 됩니다.
그러니까 예를 들어, Date의 사용자가 today 함수와 clearAppointments 함수를 선언하고 싶다고 해서 위의 코드에 나온 대로 Date를 직접 전방 선언하면 난감해진다는 뜻입니다.
그렇게 하지 말고, Date 클래스에 대한 선언부 헤더를 #include해야 합니다.
1 2 3 4 | #include "datefwd.h" // Date 클래스를 선언하고 있는 // (그러나 정의하진 않는) 헤더 Date today(); void clearAppointments(Date d); | cs |
위에서 쓰인 <iosfwd> 파일을 잘 알아두면 유익한데 이는 C++에서 지원하는 iostream 관련 함수 및 클래스들의 선언부로만 구성된 헤더이기 때문입니다.
또한 이번 항목의 내용이 템플릿이 아닌 파일뿐만 아니라 템플릿에도 들어맞는다는 사실을 확인하기 딱 좋습니다.
<iosfwd>는 선언부 전용 헤더의 대표인 셈입니다.
사실 C++에서는 템플릿 선언과 템플릿 정의를 분리할 수 있도록 하는 기능을 export라는 키워드로 제공하지만, 현재 이 키워드를 제대로 지원하는 컴파일러가 별로 없고, 현장에서 쓰는 예가 너무 드뭅니다.
앞에서 보신 pimpl 관용구를 사용하는 Person 같은 클래스를 가리켜 핸들 클래스(handle class)라고 합니다. 핸들 클래스에서 어떤 함수를 호출하게 되어 있다면, 핸들 클래스에 대응되는 구현 클래스 쪽으로 그 함수 호출을 전달해서 구현 클래스가 실제 작업을 수행하게 만드세요.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | #include "Person.h" // Person 클래스를 구현하고 있는 중이기 때문에, // 이 Person의 클래스 정의를 #include해야 합니다. #include "PersonImpl.h" // 이와 동시에 PersonImpl의 클래스 정의도 // #include해야 하는데, 이렇게 하지 않으면 // 멤버 함수를 호출할 수 없습니다. 잘 보시면 // PersonImpl의 멤버 함수는 Person의 멤버 함수와 // 일대일로 대응되고 있음을 알 수 있습니다. // 인터페이스가 똑같습니다. Person::Person(const stdLLstring& name, const Date& birthday, const Address& addr); : pImpl(new PersonImpl(name, birthday, addr)) {} std::string Person::name() const { return pImpl->name(); } | cs |
Person 생성자가 어떻게 PersonImpl 생성자를 호출하는지를 놓치자 말고 보시기 바랍니다. 그리고 Person::name이 PersonImpl::name을 호출하는 부분도 잘 보시고요. Person은 핸들 클래스이지만 그렇다고 Person의 동작이 바뀐 것은 아니라, Person의 동작을 수행하는 방법이 바뀌었을 뿐입니다.
핸들 클래스 방법 대신에 다른 방법을 쓰고 싶다면 Person을 특수 형태의 추상 기본 클래스, 이른 바 인터페이스 클래스(Interface class)로 만드는 방법도 생각해 볼 수 있겠습니다. 어떤 기능을 나타내는 인터페이스를 추상 기본 클래스를 통해 마련해 놓고, 이 클래스로부터 파생 클래스를 만들 수 있게 하자는 거죠. 파생이 목적이기 때문에 이런 클래스에는 데이터 멤버도 없고, 생성자도 없으며, 하나의 가상 소멸자와 인터페이스를 구성하는 순수 가상 함수만 들어 있습니다.
인터페이스 클래스는 자바 및 닷넷의 '인터페이스'와 사뭇 흡사하지만, C++은 자바 및 닷넷이 인터페이스에 대해 제약을 가하는 것처럼 인터페이스 클래스에 제약을 가하지 않습니다. 이를테면 자바 혹은 닷넷의 인터페이스는 언어 차원에서 데이터 멤버나 함수 구현을 아예 가질 수 없지만, C++에는 이런 것들에 대한 제약이 없습니다. C++이 좀더 융통성이 있다고 볼 수 있는데, 가끔 이 점이 유용하게 쓰이기도 한답니다. Person 클래스의 인터페이스 클래스는 다음과 같이 만들어 볼 수 있겠습니다.
1 2 3 4 5 6 7 8 9 | class Person { public: virtual ~Person(); virtual std::string name() const = 0; virtual std::string birthDate() const = 0; virtual std::string address() const = 0; ... }; | cs |
이 클래스를 코드에 써먹으려면 Person에 대한 포인터 혹은 참조자로 프로그래밍하는 방법밖에 없습니다. 순수 가상 함수를 포함한 클래스를 인스턴스로 만들기는 불가능하니까요(단, Person의 파생 클래스는 인스턴스로 만들 수 있습니다). 그리고 핸들 클래스와 마찬가지로, 인터페이스 클래스가 수정되지 않는 한 사용자는 다시 컴파일할 필요가 없습니다.
또한 인터페이스 클래스를 사용하기 위해서는 객체 생성 수단이 최소한 하나는 있어야 합니다.
대개 이 문제는 파생 클래스의 생성자 역할을 대신하는 어떤 함수를 만들어 놓고 이것을 호출함으로써 해결하고는 합니다.
이런 함수를 가리켜 팩토리 함수 혹은 가상 생성자(virtual constructor)라고 부릅니다.
주어진 인터페이스 클래스의 인터페이스를 지원하는 객체를 동적으로 할당한 후, 그 객체의 포인터를 반환하는 역할입니다.
항상은 아니지만, 이런 함수는 인터페이스 클래스 내부에 정적 멤버로 선언되는 경우가 많습니다.
1 2 3 4 5 6 7 8 9 10 11 | class Person { public: ... static std::tr1::shared_ptr<Person> // 주어진 매개변수로 초기화되는 Person create(const std::string& name, // 객체를 새로 생성하고, 그것에 대한 const Date& birthday, // tr1::shared_ptr을 반환합니다. const Address& addr); ... }; | cs |
사용자 쪽에서는 다음과 같이 사용하면 됩니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | std::string name; Date dateOfBirth; Address address; ... // Person 인터페이스를 지원하는 객체 한 개를 생성합니다. std::tr1::shared_ptr<Person> pp(Person::create(name, dateOfBirth, address)); ... std::cout << pp->name() // Person 인터페이스를 통해 << " was born on " // 그 객체를 사용합니다. << pp->birthDate() << " and now lives at " << pp->address(); ... // 위에서 생성한 객체는 pp가 유효범위를 // 벗어날 때 자동으로 삭제됩니다. | cs |
해당 인터페이스 클래스의 인터페이스를 지원하는 구체 클래스(concrete class)가 어디엔가 정의되어야 할 것이고 정말로 실행되는 생성자(구체 클래스의 생성자)가 호출되어야 하는 것은 당연합니다. 실제로 이 부분은 위의 코드에서는 보이지 않지만, 가상 생성자의 구현부를 갖고 있는 파일 안에서 이루어집니다. 예를 들어 Person 클래스로부터 파생된 RealPerson이라는 구체 클래스가 있다면, 이 클래스는 자신이 상속받은 가상 함수(순수 가상 함수)에 대한 구현부를 제공하는 식으로 만들어졌을 것입니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | class RealPerson: public Person { public: RealPerson(const stdLLstring& 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; }; | cs |
이제 RealPerson은 준비되었고 남은 것은 Person::create 함수인데, 정말 간단하게 만들 수 있습니다.
1 2 3 4 5 | 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)); } | cs |
인터페이스 클래스를 구현하는 용도로 가장 많이 쓰이는 메커니즘이 두 가지 있는데, 여기서 알아본 RealPerson 예제는 그 중 하나입니다.
다시 정리하면, 인터페이스 클래스(Person)로부터 인터페이스 명세를 물려받게 만든 후에, 그 인터페이스에 들어 있는 함수(가상 함수)를 구현하는 것이라고 말할 수 있겠습니다.
한편, 인터페이스 클래스를 구현하는 두 번째 방법은 다중 상속을 사용하는 것입니다.
결론적으로, 핸들 클래스와 인터페이스 클래스는 구현부로부터 인터페이스를 뚝 떼어 놓음으로써 파일들 사이의 컴파일 의존성을 완화시키는 효과를 가져다줍니다. 이런저런 부분에서 핸들 클래스와 인터페이스 클래스가 비용 소모를 가져오긴 하나, 이런 기법들은 미래를 대비한다는 느낌으로 대했으면 합니다. 개발 도중에는 핸들 클래스와 인터페이스 클래스를 사용하여 구현부가 바뀌었을 때 사용자에게 미칠 파급 효과를 최소로 만드는 것이 좋습니다. 핸들 클래스와 인터페이스 클래스를 구체 클래스로 바꾸는 일은 제품을 출시해야 될 때 다시 고민을 하는 거죠.
컴파일 의존성을 최소화하는 작업의 배경이 되는 가장 기본적인 아이디어는 '정의' 대신에 '선언'에 의존하게 만들자는 것입니다.
이 아이디어에 기반한 두 가지 접근 방법은 핸들 클래스와 인터페이스 클래스입니다.
라이브러리 헤더는 그 자체로 모든 것을 갖추어야 하며 선언부만 갖고 있는 형태여야 합니다.
이 규칙은 템플릿이 쓰이거나 쓰이지 않거나 동일하게 적용합시다.