728x90
반응형

컴파일 의존성을 줄이자는 의미

컴파일 의존성이란 #include 관계를 의미하고 이 #include 관계를 줄이자는 것이다.

 

줄여야 하는 이유

선언 한 헤더 파일 중 변경되는 것이 있다면 그 파일은 컴파일되어야 하며, 그럼 이렇게 컴파일된 파일과 연관된 다른 파일들이 모두 컴파일되고.... 컴파일 시간이 말도 못 하게 늘어날 것이다.

 

구현 세부사항을 따로 떼어서 지정하는 경우의 문제

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;
};

1. string은 클래스가 아니라 typedef로 정의한 타입 동의어(basic_string <char>를 typedef 한 것)이다. 따라서 전방 선언이 맞지 않고, 제대로 된 전방 선언을 하기 위해선 템플릿을 추가로 끌고 들어와야 한다.

그리고 표준 라이브러리 헤더는 어지간한 경우가 아니면 컴파일 시 병목요인이 되지 않는다.

 

2. 컴파일 도중에 객체들의 크기를 전부 알아야 한다. 기본 제공 타입에 대해선 컴파일러가 그 크기를 알지만 사용자 정의 객체는 그 정의부를 주지 않는 한 컴파일러가 알 도리가 없기 때문에 문제가 된다.

하지만 스몰토크(Smalltalk) 및 자바의 경우는 객체가 정의될 때 컴파일러가 그 객체의 포인터를 담을 공간만 할당하기 때문에 문제가 되지 않는데, 이 방법을 활용하여 문제를 해결해보자.

 

포인터 뒤에 실제 객체 구현부 숨기기

클래스를 2개로 쪼개서 한쪽은 인터페이스만 제공하고 한쪽은 인터페이스의 구현을 맡도록 만든다.

#include <string>
#include <memory>
 
class PersonImpl; // Person의 구현 클래스에 대한 전방 선언

// Person 클래스 안에서 사용되는 것들에 대한 전방선언
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;
private:
    std::tr1::shared_ptr<PersonImpl> pImpl; // 구현 클래스 객체에 대한 포인터
};

이렇게 클래스를 나누게 되면 인터페이스를 제공하는 Person클래스는 데이터 멤버로 구현을 맡는 PersonImpl클래스의 포인터인 pimpl만 가지게 된다.

그리고 이렇게 설계하면 Person의 사용자는 구현 세부사항과 완전히 갈라서게 되고 구현 클래스 부분을 바꾸더라도 컴파일을 다시 할 필요가 없게 된다. (#include하지 않았기 때문)

 

이렇게 인터페이스와 구현을 둘로 나누는 열쇠는 "정의부에 대한 의존성"을 "선언부에 대한 의존성"으로 바꾸어 놓는 데 있다. 이게 바로 컴파일 의존성을 최적화하는 핵심원리이다. 즉, 헤더 파일을 만들 때는 실용적으로 의미를 갖는 한 자체조달 형태로 만들고 안되면 다른 파일에 의존성을 갖도록 하되, 정의부가 아닌 선언부에 대한 의존성을 갖도록 만드는 것이다.

 

전략 정리

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

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

 

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

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

이 방법은 누군가는 정의를 제공해야 하지만 모든 사용자가 해당 함수를 사용하는 것이 아니기 때문에 부담을 헤더 파일에 주는 것이 아닌 함수를 호출하는 사용자에게 정의부를 전가하는 방법을 사용한 것이다.

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

class Person
{
	...
	Date today(); // Date클래스 반환
	void claerAppointments(Date d); // 매개변수로 Date클래스 사용
	...
};

 

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

"클래스를 둘로 쪼개자"라는 지침을 제대로 쓸 수 있도록 하라면 헤더 파일이 짝으로 있어야 한다. 하나는 선언부를 위한 헤더 파일이고, 또 하나는 정의부를 위한 헤더 파일이다. 이 두 파일은 관리도 짝 단위로 해야 한다. 한쪽에서 어떤 선언이 바뀌면 다른 쪽도 똑같이 바꾸어야 한다는 것이다. 그렇기 때문에 직접 클래스 선언을 하는 것이 아니라 클래스 선언 부만 들어 있는 헤더 파일을 #include 해야 한다.

 

핸들 클래스(handle class)

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 관용구를 통해 구현되고 있다.

생성자는 new를 통해서 PersonImpl의 생성자를 호출하고 그 객체를 pimpl에 전달하고 있고 name함수는 pimpl의 name 함수를 호출하고 있다.

핸들 클래스가 직접 함수 호출을 한다고 해도 실제 함수 호출은 관용구인 pimpl에서 호출하는 것이 된다.

이러한 방법은 다른 방법으로도 구현할 수 있다.

 

인터페이스 클래스

어떤 기능을 나타내는 인터페이스를 추상 기본 클래스를 통해 만들어 놓고, 그 클래스로부터 파생 클래스를 만들 수 있게 하는 것이다.

파생이 목적이기 때문에 데이터 멤버도 없고, 생성자도 없고, 하나의 가상 소멸자와 인터페이스를 구성하는 순수 가상 함수만 들어있다. 비가상 함수의 구현이 주어진 클래스 계통 내의 모든 클래스에 대해 똑같아야 하므로 인터페이스 클래스의 일부로서 구현해두는 방법이다.

class Person
{
public:
    virtual ~Person();
    virtual std::string name() const = 0;
    virtual std::string birthDate() const = 0;
    virtual std::string address() const = 0;
};

다음 클래스는 순수 가상 함수를 1개이상 포함하기 때문에 객체를 생성할 수 없고 따라서 참조자나 포인터로 접근을 해야 한다. 또한 핸들 클래스와 마찬가지로 인터페이스 클래스의 인터페이스가 수정되지 않는 한, 사용자는 다시 컴파일할 필요가 없다. 이 인터페이스 클래스를 사용하기 위해서 객체 생성 수단이 최소한 하나는 있어야 하는데, 이 문제는 유도 클래스의 생성자 역할을 대신하는 어떤 함수를 만들어놓고 이것을 호출함으로써 해결한다.

 

이 함수를 가상 생성자 혹은 팩토리 함수라고 부른다. 주어진 인터페이스 클래스의 인터페이스를 지원하는 객체를 동적으로 할당한 후, 그 객체의 포인터를 반환하는 것이다. 대개 스마트 포인터가 좋으며, 이런 함수는 인터페이스 클래스 내부에 정적 멤버로 선언되는 경우가 많다.

class Person
{
public:
    static std::tr1::shared_ptr<Person> create(const std::string& name, const Date& birthday, const Address& addr);
};

인터페이스 클래스인 Person 클래스 내부에 이 클래스의 객체에 대해 조작할 수 있는 포인터를 반환하는 함수가 있으므로 사용자는 외부에 포인터를 만들어서 이 함수로 그 포인터를 넘겨받는 방법을 이용한다.

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() << " and now lives at " << pp->address();

pp라는 스마트 포인터 객체를 생성하여 create함수를 통해 포인터를 넘겨받는 방식이다. 이 객체를 통해서 원하는 기능을 활용할 수 있다.

실제로 이 기능이 작동하게 하는 것은 이 추상 클래스인 Person 클래스를 상속받는 RealPerson 클래스가 있다고 했을 때 이 클래스가 구현하는 기능을 실현시키는 것이다. 이 클래스는 가상 함수를 구현하는 말 그대로 "구현부"이므로 이 구현부에 따라 동작한다. 따라서 create함수는 Person 클래스의 유도 클래스의 객체를 가리키는 포인터를 반환해야 한다. 이 방식은 직접적으로 RealPerson 객체의 포인터를 반환할 수도 있지만 매개변수를 하나 더 전달받거나 아니면 외부에서 데이터를 읽거나 하는 방법으로 동적으로 타입을 결정하여 그 타입의 객체에 대한 포인터를 넘겨주는 방식이 더 괜찮을 것이다.

 

비용

포인터 연산과 가상 함수로 인해 시간과 공간의 비용이 드는 것은 당연하다.

하지만 비용이 소모됨에도 불구하고 구현부가 바뀌었을 때 사용자에게 미칠 파급 효과를 최소로 만드는 것이 좋기 때문에 실행 속도나 파일 크기에서 많이 손해를 보지 않는 이상은 통짜 구체 클래스로 바꾸지 않는 것이 좋다.

핸들 클래스

  • 포인터로 구현부에 접근하기 위해 필요한 간접화 연산
  • 구현부 포인터의 크기까지 더해짐
  • 구현부 포인터의 초기화 즉, 동적 할당에 따르는 연산 오버헤드와 메모리 부족 예외인 bad_alloc(메모리 고갈)이 발생할 수 있는 가능성

 

인터페이스 클래스

  • 함수 호출 때마다 가상 테이블 점프에 따르는 비용
  • 이 클래스로부터 유도된 모든 객체는 모두 가상 테이블 포인터를 지니고 있어야 함
  • 가상 테이블 포인터의 메모리

 

요약

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

 

728x90
반응형