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

  • 구현부의 외부로 노출되지 않은 코드를 수정했음에도 건들지 않은 다른 곳까지 몽땅 컴파일 / 링크되는 상황이 발생할 수 있다.
  • #include문은 헤더 파일들 사이의 컴파일 의존성을 발생시킨다.
  • 클래스 정의 시 구현 세부사항을 떼고 전방선언을 한다?
  • ex)
    namespace std{ class string; } 
    .... 
    class Person 
    { 
      .... 
      std::string name() const; 
      .... 
    } 
    
  • 문제가 되는 부분
    1. string은 클래스가 아니라 typedef로 정의한 타입 동의어이다.(basic_string를 typedef)
      • 표준 라이브러리 구성요소 중 원치 않는 #include가 생기게 하는 것들을 사용하지 않게끔 직접 손을 봐야 한다.
    2. 컴파일러가 컴파일 중 객체들의 크기를 전부 알아야 한다.
      • 객체 하나의 크기가 얼마인지 정의된 정보를 보는 수밖에 없다.
      • 스몰토크 / 자바는 그 객체의 포인터를 담을 공간만 할당한다.(포인터 뒤에 실제 객체 구현부를 숨긴다.)
  • pImpl 관용구 : 주어진 클래스를 두개로 쪼개어 한쪽은 인터페이스만, 한쪽은 그 인터페이스의 구현만 맡게 한다.
    • 구현 클래스 포인터의 이름은 대개 pImpl이라 불린다.
    • 구현 클래스를 마음대로 고칠 수 있지만 인터페이스로 쓰는 사용자 쪽은 컴파일을 다시 할 필요가 없다.
  • 컴파일 의존성을 최소화하는 핵심 원리 : 정의부에 대한 의존성을 선언부에 대한 의존성으로 바꾼다.
    1. 객체 참조자 및 포인터로 충분한 경우에는 객체를 직접 쓰지 않는다.
    2. 할 수 있으면 클래스 정의 대신 클래스 선언에 최대한 의존하게 만든다.
      • 어떤 클래스를 사용하는 함수 선언 / 어떤 클래스 객체를 값으로 전달하거나 반환 -> 클래스 정의가 필요 없다.
      • 모두가 그 함수를 호출하진 않으므로 부담을 실제 함수 호출이 일어나는 사용자의 소스파일 쪽으로 전가한다. 실제 쓰지 않을 타입 정의에 대해 사용자가 의존성을 끌어오는 것을 예방한다.
    3. 선언부와 정의부에 대해 별도의 헤더 파일을 제공한다.
      • 클래스 전방 선언 대신 헤더 파일(선언만 있는)을 include한다.
  • pImpl 관용구를 사용하는 클래스는 핸들 클래스라고 부른다.

  • 컴파일 의존성을 최소화하는 방법
    1. pImpl 관용구의 핸들 클래스 사용
      • ex)
        Person::Person(const std::string& name) : pImpl(new PersonImpl(name) {} 
        std::string Person::name() const 
        { 
         return pImpl->name(); 
        } 
        
    2. 인터페이스 클래스로 만든다.
      • ex)
        class Person 
        { 
        public: 
         virtual ~Person(); 
         virtual std::string name() const = 0; 
         .... 
        } 
        
        • 인스턴스화하지 못하기 때문에 포인터 / 참조자로 프로그래밍 할 수밖에 없다.
        • 인터페이스 클래스의 인터페이스가 수정되지 않는 한 다시 컴파일할 필요가 없다.
        • 객체 생성 수단이 최소 하나 있어야 한다.
        • 가상 생성자(팩토리 함수) : 주어진 인터페이스 클래스의 인터페이스 지원 객체를 동적 할당한 후 포인터를 반환한다.(물론 스마트포인터면 좋다.) -> 보통 인터페이스 클래스 내부 정적 멤버로 존재한다.
  • 실행 시간 비용과 객체 한 개당 필요한 저장 공간이 추가로 증가한다.

  • 핸들 클래스의 약점
    1. 멤버 함수 호출 시 구현부 객체 데이터까지 포인터를 타야 한다. -> 간접화 연산이 하나 증가한다.
    2. 저장하는 데 필요한 메모리 크기에 구현부 포인터의 크기가 더해진다.
    3. 구현부 포인터가 동적 할당된 구현부 객체를 가리키게 어디선가 그 구현부 포인터의 초기화를 해야한다.
      • 동적 메모리 할당에 따르는 연산 오버헤드 존재
      • bad_alloc(메모리 고갈) 예외 발생 가능성 존재
  • 인터페이스 클래스의 약점
    1. 호출되는 함수가 전부 가상함수 -> 함수 호출 시 가상 테이블 점프 비용을 소모한다.
    2. 파생 객체 모두 가상 테이블 포인터를 지녀야 한다.
    3. 만약 가상함수를 공급하는게 인터페이스 클래스 뿐일 때는 이 가상 테이블 포인터도 객체 하나를 저장하는 데 필요한 메모리 크기를 늘리는 요인이 된다.
  • 공통적인 약점 : 인라인 함수의 도움을 제대로 이끌기 힘들다.

항목 32 : public 상속 모형은 반드시 “is-a(~는 ~의 일종이다)”를 따르도록 만들자

  • 자식 클래스의 모든 객체는 부모 클래스 타입 객체이지만 부모 클래스의 객체는 자식 클래스의 타입의 객체가 아니다.
  • 최고의 설계는 제작하려는 소프트웨어 시스템이 기대하는 바에 따라 달라진다.
  • 유효하지 않은 코드를 컴파일 단계에서 막아주는 인터페이스가 좋은 인터페이스이다.
  • ex)
    class Penguin : public Bird 
    { 
    public: 
      virtual void fly() 
      { 
          error("can't fly!"); 
      } 
    } 
    
    • 프로그램이 실행될 때만 발견할 수 있다.
  • public 상속은 기본 클래스 객체가 가진 ‘모든 것들이’ 파생 클래스 객체에도 그대로 적용된다고 단정하는 상속

  • is-a 외에도 두 가지 관계가 더 존재한다. 1) has-a(~는 ~를 가진다) 2) is-implemented-in-terms-of(~는 ~를 써서 구현한다)