스토리텔링 개발자

[Effective C++] 31. 컴파일 의존성 줄이기 본문

개발/Effective C++

[Effective C++] 31. 컴파일 의존성 줄이기

김디트 2024. 6. 27. 13:34
728x90

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

 

 

 

C++ 컴파일 의존성 문제
  • C++은 인터페이스와 구현을 깔끔하게 분리하지 못한다.
    • 구현 세부사항이 헤더에 포함되기 때문이다.
    • 구현 세부사항이란, 멤버 변수, 암시적 인라인 함수 등.
  • #include 문을 통한 헤더 파일들 사이의 컴파일 의존성(compilation dependency)이 발생할 수 있다.
    • 구현부의 외부로 노출되지 않은 코드를 수정했음에도 건들지 않은 다른 곳까지 몽땅 컴파일 / 링크되는 상황이 발생할 수 있다.
#include <string>
#include "date.h"
#include "address.h"
// #include 문을 통한 컴파일 의존성 발생

class Person
{
public:
    Person(const string& name, const Date& birthday, const Address& addr);
    string name() const;
    string birthDate() const;
    string address() const;
    ...
private:
    string theName; // 구현 세부사항
    Date theBirthDate; // 구현 세부사항
    Address theAddress; // 구현 세부사항
};

 

 

 

 

컴파일 의존성을 해결해보자
  • 전방선언을 통해서 해결이 가능하지 않을까?
namespace std
{
    class string; // 전방 선언(틀렸지만)
} 
class Date; // 전방 선언
class Address; // 전방 선언

class Person 
{ 
    ...
}
  • 두 가지의 문제점
    • string 은 클래스가 아니라 typedef로 정의한 타입 동의어이다.
      • basic_string<char>를 typedef 한 것일 뿐이다.
      • string 자체는 클래스가 아니므로 당연히 전방 선언이 동작하지 않는다.
      • 보통은 표준 라이브러리 일부가 변경될 일은 없으니 그저 #include로 사용하면 될 것이다.
      • 만약의 만약, string을 커스텀 했다면?
        • 표준 라이브러리 구성요소 중 원치 않는 #include가 생기게 하는 것들을 사용하지 않게끔 직접 손을 봐야 할 것이다.
    • 컴파일러가 컴파일 중 객체들의 크기를 전부 알아야 하므로 전방 선언으로는 불충분할 수 있다.
      • class Date;
        class Address;
        // 전방 선언을 했지만..
        
        class Person
        {
        public:
            ...
        private:
            // 전방 선언으로는 각 객체의 크기를 컴파일러가 알 수 없다.
            // 그렇기에 해당 방법으로는 컴파일 에러가 발생한다.
            Date theBirthDate;
            Address theAddress;
        };
      • 컴파일러가 객체 하나의 크기가 얼마인지 알려면 정의된 정보를 보는 수밖에 없다.(#include)
      • 스몰토크 / 자바 등의 언어에서는 포인터 뒤에 실제 객체 구현부를 숨기는 식의 구현이므로 이런 문제가 발생하지 않는다.
      • 이는 pImpl 관용구를 통해서 해결할 수 있을 것이다.
      • class PersonImpl;
        // pImpl을 위한 전방 선언
        
        class Date;
        class Address;
        // 클래스 인터페이스들이 해당 클래스들을 사용하므로 전방 선언.
        
        class Person
        {
        public:
            Person(const string& name, const Date& birthday, const Address& addr);
            string name() const;
            string birthDate() const;
            string address() const;
            ...
        private:
            shared_ptr<PersonImpl> pImpl; // pImpl 관용구
        };

 

 

 

pImpl 관용구
  • 주어진 클래스를 두개로 쪼개어 한쪽은 인터페이스만, 한쪽은 그 인터페이스의 구현만 맡게 한다.
  • 구현 클래스를 마음대로 고쳐도 인터페이스 변경이 없으면, 사용자 쪽에서 컴파일을 다시 할 필요가 없다.

 

 

 

컴파일 의존성을 최소화기 위한 핵심 원리

정의부에 대한 의존성을 선언부에 대한 의존성으로 바꾼다.

  1. 객체 참조자 및 포인터로 충분한 경우에는 객체를 직접 쓰지 않는다.
  2. 할 수 있으면 클래스 정의 말고 클래스 선언에 최대한 의존하게 만든다.
    • 다음 경우 클래스 정의가 필요 없다.
      • 객체를 사용해 함수를 선언할 때
      • 객체를 값으로 전달하거나 반환할 때
      • class Date; // 전방 선언
        
        void clearAppointments(Date d); // 클래스를 사용한 함수 선언
        Date today(); // 클래스 객체 반환
      • 이것이 가능한 이유는, 모두가 해당 함수를 호출하진 않기 때문이다.
        • 부담을 실제 함수 호출이 일어나는 사용자의 소스파일 쪽으로 전가한다.
        • 즉, 이를 통해 실제 쓰지 않을 타입 정의에 대해 사용자가 의존성을 끌어오는 것이 예방된다.
  3. 선언부와 정의부에 대해 별도의 헤더 파일을 제공한다.
    • 선언부를 위한 헤더 파일과 정의부를 위한 헤더 파일을 쪼개서 관리한다.
    • 클래스 전방 선언 대신 선언부를 위한 헤더 파일을 include 한다.

 

 

 

핸들 클래스
  • pImpl 관용구를 사용하는 클래스는 핸들 클래스라고 부른다.

 

 

 

컴파일 의존성을 최소화하는 방법
  1. pImpl 관용구의 핸들 클래스를 사용한다.
    #include "Person.h"
    
    #include "PersonImpl.h" // PersonImpl 함수를 사용하므로 include 해줘야 한다.
    
    Person::Person(const string& name) : pImpl(new PersonImpl(name)) // pImpl 초기화
    {
    }
    
    string Person::name() const 
    { 
        return pImpl->name(); 
    }
  2. 인터페이스 클래스(interace class)로 만든다. (항목 34 참조)
    class Person 
    { 
    public: 
        virtual ~Person(); 
        virtual string name() const = 0; 
        ... 
    }
    • 인스턴스화하지 못하기 때문에 포인터 / 참조자로 프로그래밍 할 수밖에 없다.
    • 인터페이스 클래스의 인터페이스가 수정되지 않는 한 다시 컴파일할 필요가 없다.
    • 이를 사용하기 위해서는 객체 생성 수단이 최소 하나 있어야 한다.
      • 팩트리 함수(가상 생성자)
    • 인터페이스 클래스를 구현하는 방법
      • 인터페이스 클래스를 만들고, 그를 상속받아 가상함수를 구현하는 방법.(대중적)
      • 다중 상속을 사용하는 방법.(항목 40 참조)

 

 

 

컴파일 의존성을 챙길 때의 단점
  • 실행 시간 비용이 들어간다.
  • 객체 한 개당 필요한 저장 공간이 추가로 늘어난다.
  • 핸들 클래스의 약점
    1. 멤버 함수 호출 시 구현부 객체 데이터까지 포인터를 타고 들어가야 한다.
      • 간접화 연산이 하나 증가한다.
    2. 객체 하나를 저장하는데 필요한 메모리 크기에 더해 구현부 포인터의 크기가 추가로 필요하다.
    3. 구현부 포인터의 초기화를 해야한다.
      • 동적 메모리 할당과 해제에 따르는 연산 오버헤드가 동반된다.
      • bad_alloc(메모리 고갈) 예외 발생의 가능성까지 대비해야 한다.
  • 인터페이스 클래스의 약점
    1. 호출되는 함수가 전부 가상함수이다.
      • 함수 호출 시에 가상 테이블을 점프하는 데 대한 비용이 발생한다. (항목 7 참조)
    2. 파생 객체 모두가 가상 테이블 포인터를 지녀야 한다. (항목 7 참조)
    3. 가상함수를 공급하는게 인터페이스 클래스 뿐이라면
      • 이 가상 테이블 포인터의 크기 역시 메모리 크기를 늘리는 데 일조한다.
  • 핸들 클래스와 인터페이스 클래스의 공통적인 약점
    • 인라인 함수를 활용하기 힘들어진다.
728x90
Comments