KoreanFoodie's Study

Effective C++ | 항목 31 : 파일 사이의 컴파일 의존성을 최대로 줄이자 본문

Tutorials/C++ : Advanced

Effective C++ | 항목 31 : 파일 사이의 컴파일 의존성을 최대로 줄이자

GoldGiver 2022. 10. 25. 16:26

C++ 프로그래머의 필독서이자 바이블인, 스콧 마이어스의 Modern Effective C++ 를 읽고 기억할 내용을 요약하고 있습니다. 꼭 읽어보시길 추천드립니다!

항목 31 : 파일 사이의 컴파일 의존성을 최대로 줄이자

핵심 :

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


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

위와 같은 코드를 컴파일 하기 위해선, string, Date, Address 클래스의 구현 세부사항을 알고 있어야 한다. 즉, 수많은 #include 가 필요하다는 뜻이다. 이는 헤더 파일들 사이에 컴파일 의존성(compilation dependency)란 것을 엮어 버린다. 파일 하나만 바뀌어도 해당 파일을 사용하는 모든 파일을 몽땅 다시 컴파일해야 하는 것이다.
전방선언으로 위의 문제를 회피한다면 어떨까? 아래 예시를 보자.

class Person;

int main()
{
  int x;
  Person p( params );
}

int 는 4바이트 공간을 할댕해야 한다는 것을 알고 있지만, Person 의 경우는 어떨까? 자바라면 포인터 크기 만큼만 공간을 할당하겠지만, C++ 는 그렇지 않다. 이 문제는 pImpl 관용구 기법으로 해결해볼 수는 있다.

class PersonImpl;
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::shared_ptr<PersonImpl> pImpl;
};

위 코드에서 실제 데이터 멤버는 포인터(shared_ptr) 하나 이다. 이제 Person 의 사용자는 Person 클래스의 구현부가 변경되더라도 다시 컴파일할 필요가 없어진다. 이렇게 인터페이스와 구현을 둘로 나누는 열쇠는 '정의부에 대한 의존성(dependencies on definitions)'을 '선언부에 대한 의존성(dependencies on declarations)' 로 바꾸어 놓는 데 있다. 이제 내용을 정리해 보자.

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

반면, 어떤 타입의 객체를 정의할 때는 그 타입의 정의가 준비되어 있어야 한다.

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

예시를 보자.

// 클래스 선언
class Date;
// 클래스의 정의를 가져오지 않아도 된다
Date today();
void ClearAppointments(Date d);

 

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

"클래스를 둘로 쪼개자" 라는 지침을 제대로 쓰려면 헤더 파일이 짝으로 있어야 한다. 하나는 선언부를 위한 헤더 파일이고, 하나는 정의부를 위한 헤더 파일이다. 즉, 라이브러리 사용자 쪽에서는 전방 선언 대신 선언부 헤더 파일을 #include 하면 된다.

// Date 클래스를 선언하는(정의는 아님) 헤더 파일
#include "datefwd.h"
// 클래스의 정의부는 가져오지 않아도 된다
Date today();
void ClearAppointments(Date d);

실제로 C++ 은 <iosfwd> 라는 iostream 관련 선언부를 제공하고, 정의부는 <sstream>, <streambuf>, <fstream>, <iostream> 등으로 나누어 제공하고 있다.
이는 템플릿에 대해서도 마찬가지이다. C++ 에서는 템플릿 선언과 정의를 분리할 수 있는 기능을 export 라는 키워드로 지원하고 있다.

위의 pimpl 관용구를 사용하는 Person 같은 클래스를 핸들 클래스(handle class) 라고 한다. 활용 예시를 한 번 보자.

// Person 의 클래스 정의 #include
#include "Person.h"
// PersonImpl 의 멤버 함수는 Person의 멤버 함수와 
// 일대응으로 대응 (인터페이스가 같음)
#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 은 핸들 클래스이지만 그렇다고 Person 의 동작이 바뀐 것은 아니다. Person 의 동작을 수행하는 방법이 바뀐 것 뿐이다!

혹은 핸들 클래스 방법 대신 인터페이스 클래스(Interface class)를 만드는 방법도 있다.

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

이제 Person 클래스의 파생 클래스를 만들어 사용하면 된다. 위 방식을 활용할 때는, 팩토리 함수 혹은 가상 생성자(virtual constructor) 라고 부르는 함수를 만드는 경우가 많다. 이 함수는 인터페이스 클래스의 인터페이스를 지원하는 객체를 동적으로 할당한 후, 그 객체의 포인터를 반환하는 것이다. 예시를 보자.

// 팩토리 함수 선언
class Person
{
public:
  ...
  // 아래 같은 함수는 정적으로 선언하는 경우가 많음
  static std::shared_ptr<Person>
  create(const std::string& name,
         const Date& birthday,
          const Address& addr);
  ...
};

////////////////////////////
// 사용자 코드 /////////////
///////////////////////////
std::string name;
Date dateOfBirth;
Address address;

// Person 인터페이스를 지원하는 객체 한 개를 생성
std::shared_ptr<Person> pp(Person::create(name, dateOfBirth, address));
...
std::cout << pp->name()
          << " was born on "
          << pp->birthDate()
          << " and now lives at "
          << pp->address();

실제로 위의 create 함수는 다음처럼 구현될 것이다.

class RealPerson: public Person { ... };

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

즉, 인터페이스의 명세를 물려 받고, 파생 클래스에서 이를 구현(가상 함수를 구현) 하면 된다. 두 번째 방법은 다중 상속이 있는데, 이는 항목 40을 참고하자.
물론 핸들 클래스나 인터페이스 클래스는 추가적인 메모리 할당이나 가상 함수 호출에 대한 비용적인 문제가 있다. 그러나 컴파일 의존성을 줄이면서, 클래스 사이의 결합도를 낮출 수 있으므로 개발 도중에는 위의 두 가지 방식을 적극적으로 활용하는 것이 좋다. 최적화 문제는 결합도를 높여도 된다는 결론이 나온 후, 통짜 구체 클래스로 바꾸어도 늦지 않다는 뜻이다!

Comments