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

업데이트:     Updated:

카테고리:

태그:

이 글은 아래의 책을 자세히 정리한 후, 정리한 글을 GPT에게 요약을 요청하여 작성되었습니다.
이펙티브 C++ 제3판, 스콧 마이어스 저자, 곽용재 번역

📦 5. 구현

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


⚠️ 문제 상황: 과한 컴파일 의존성

클래스 정의는 클래스 인터페이스만 지정하는 것이 아니라, 구현 세부 사항까지 많이 지정한다.

#include <string>
#include "date.h"
#include "address.h"

using namespace std;

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

문제:

  • 한 헤더 파일 내에서 다른 헤더 파일들을 #include 시키면 컴파일 의존성이 커진다
  • 구현부만 살짝 바꾸더라도, 빌드를 하면 연관이 되어 있는 파일들이 몽땅 다시 컴파일 하게 된다
  • #include 한 헤더 파일은 물론, 이들과 엮인 헤더 파일이 변경되는 경우, Person.h와 Person 클래스를 사용하는 파일들이 몽땅 다시 컴파일 되어야 한다

✅ 첫 번째 시도: 전방 선언 (실패)

구현부를 분리하기 위해 전방 선언을 사용하려고 시도해보자:

namespace std {
	class string;
}; // string 전방 선언(틀림)

using std::string;

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

문제점:

  1. string은 클래스가 아닌 typedef로 정의된다
  2. 컴파일 도중 객체들의 크기를 알아야 한다
int main() {
	int x;

	Person p { params };
	...
}
  • 컴파일러는 int와 Person을 담을 공간을 할당해야 함
  • Person 객체 하나의 크기를 알아야 함
  • Person 클래스의 정의를 봐야함

✅ 해결책: pimpl 관용구

이전의 문제점을 pimpl 관용구를 통해 해결할 수 있다.

#include <string>
#include <memory>

using std::string;
using std::shared_ptr;

class PersonImpl;

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

핵심:

  • 인터페이스와 구현이 분리됨
  • Person 구현부를 고쳐도 컴파일을 다시 할 필요가 없어진다

📌 컴파일 의존성 최소화 전략

1. 객체 참조자 및 포인터로 충분하면 객체를 직접 쓰지 않는다

  • 특정 타입에 대한 참조자 및 포인터를 정의할 때 → 특정 타입의 선언부만 필요
  • 특정 타입객체를 정의할 때 → 특정 타입의 정의 필요

2. 할 수 있으면 클래스 정의 대신 선언에 최대한 의존하자

특정 클래스를 사용하는 함수를 선언할 때 → 특정 클래스의 선언부만 필요

class Date; // 클래스 선언

Date today(); // 특정 클래스를 반환해도,
void clearAppointments(Date d); // 특정 클래스를 전달해도,
// → 선언만 해주면 OK

3. 선언부와 정의부에 대해 별도의 헤더 파일을 제공하자

선언부를 위한 헤더파일, 정의부를 위한 헤더파일로 나눠 관리

// date 클래스 선언부 헤더파일(정의 X)
#include "datefwd.h"

Date today();
void clearAppointments(Date d);

📚 알아두면 좋은 것들

** 파일**

  • C++에서 지원하는 iostream 관련 함수 및 클래스들의 선언부만으로 구성된 헤더
  • 각 정의부는 일정한 분류에 따라 , , , 등으로 나뉨
  • 이번 항목 내용이 템플릿이 아닌 파일뿐만 아니라 템플릿에도 들어맞음

export 키워드

  • 템플릿 선언과 템플릿 정의를 분리할 수 있도록 하는 기능
  • 현장에서 잘 쓰이지 않는다

✅ pimpl 관용구 사용 방법

👉🏻 1. 핸들 클래스

핸들 클래스에 대응되는 구현 클래스 쪽으로 함수 호출을 전달하여,
구현 클래스가 실제 작업을 수행하도록 한다.

Person.h (핸들 클래스 헤더)

// Person.h
#pragma once
#include <memory>
#include <string>

class Date;
class Address;
class PersonImpl;   // 전방 선언

class Person {
public:
    Person(const std::string& name, const Date& birthday, const Address& addr);
    ~Person();

    std::string name() const;
    std::string birthDate() const;
    std::string address() const;

private:
    std::shared_ptr<PersonImpl> pImpl;   // 구현부를 가리키는 포인터
};

Person.cpp (핸들 클래스 구현)

// Person.cpp
#include "Person.h"
#include "PersonImpl.h"

Person::Person(const std::string& name, const Date& birthday, const Address& addr)
    : pImpl(std::make_shared<PersonImpl>(name, birthday, addr)) {}

Person::~Person() = default;

std::string Person::name() const {
    return pImpl->name();
}

std::string Person::birthDate() const {
    return pImpl->birthDate();
}

std::string Person::address() const {
    return pImpl->address();
}

PersonImpl.h (구현 클래스 헤더)

// PersonImpl.h
#pragma once
#include <string>
#include "Date.h"
#include "Address.h"

class PersonImpl {
public:
    PersonImpl(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;
};

PersonImpl.cpp (구현 클래스 소스)

// PersonImpl.cpp
#include "PersonImpl.h"

PersonImpl::PersonImpl(const std::string& name, const Date& birthday, const Address& addr)
    : theName(name), theBirthDate(birthday), theAddress(addr) {}

std::string PersonImpl::name() const {
    return theName;
}

std::string PersonImpl::birthDate() const {
    return theBirthDate.toString();
}

std::string PersonImpl::address() const {
    return theAddress.toString();
}

main.cpp (사용 예시)

// main.cpp
#include <iostream>
#include "Person.h"
#include "Date.h"
#include "Address.h"

int main() {
    Date birth(1990, 5, 20);
    Address addr("Seoul", "Gangnam", "123-45");

    Person p("Kim", birth, addr);

    std::cout << p.name() << " was born on "
              << p.birthDate() << " and lives at "
              << p.address() << std::endl;

    return 0;
}

👉🏻 2. 인터페이스 클래스

인터페이스를 추상 기본 클래스를 통해 마련하고,
이 클래스로부터 파생 클래스를 만들 수 있게 한다.

Person.h (인터페이스 클래스 헤더)

// Person.h
#pragma once
#include <string>
#include <memory>

class Date;
class Address;

class Person {
public:
    virtual ~Person() {}

		// 순수 가상 함수
    virtual std::string name() const = 0;
    virtual std::string birthDate() const = 0;
    virtual std::string address() const = 0;

    // 객체 생성을 위한 팩토리 함수
    static std::shared_ptr<Person> create(const std::string& name,
                                          const Date& birthday,
                                          const Address& addr);
};

핵심:

  • 인터페이스 클래스를 사용하기 위해서는, 객체 생성 수단이 최소한 하나 존재해야 한다 (create 함수)

Person.cpp (팩토리 함수 구현)

// Person.cpp
#include "Person.h"
#include "RealPerson.h"

std::shared_ptr<Person> Person::create(const std::string& name,
                                       const Date& birthday,
                                       const Address& addr) {
    return std::make_shared<RealPerson>(name, birthday, addr);
}

RealPerson.h (구현 클래스 헤더)

// RealPerson.h
#pragma once
#include "Person.h"
#include "Date.h"
#include "Address.h"

class RealPerson : public Person {
public:
    RealPerson(const std::string& name, const Date& birthday, const Address& addr);

		// 가상함수 구현
    std::string name() const override;
    std::string birthDate() const override;
    std::string address() const override;

private:
    std::string theName;
    Date theBirthDate;
    Address theAddress;
};

RealPerson.cpp (구현 클래스 소스)

// RealPerson.cpp
#include "RealPerson.h"

RealPerson::RealPerson(const std::string& name, const Date& birthday, const Address& addr)
    : theName(name), theBirthDate(birthday), theAddress(addr) {}

std::string RealPerson::name() const {
    return theName;
}

std::string RealPerson::birthDate() const {
    return theBirthDate.toString(); // Date 클래스에 toString() 있다고 가정
}

std::string RealPerson::address() const {
    return theAddress.toString(); // Address 클래스에 toString() 있다고 가정
}

사용 예시 (main.cpp)

// main.cpp
#include <iostream>
#include "Person.h"
#include "Date.h"
#include "Address.h"

int main() {
    Date birth(1990, 5, 20);
    Address addr("Seoul", "Gangnam", "123-45");

    auto person = Person::create("Kim", birth, addr);

    std::cout << person->name() << " was born on "
              << person->birthDate() << " and lives at "
              << person->address() << std::endl;

    return 0;
}

확장 가능성:

  • Person::create 함수를 제대로 만들려면:
    • enum을 통해 타입 식별자를 정의 (e.g. Real, Student, Employee)
    • create 함수의 매개변수에 타입 식별자 포함
    • 타입 식별자를 통해, 다른 타입의 파생 클래스 객체 생성 가능
  • 인터페이스 클래스 구현 방법에는 위 예제 방식 말고도, 다중 상속을 이용하는 방식도 있다 (항목 40)

❎ pimpl 방식의 단점

  • 공통 단점
    1. 인라인 함수의 도움을 제대로 얻기 힘들다
  • 핸들 클래스의 단점
    1. 구현부 객체에 접근하기 위해 구현부 포인터를 통해야 함
      연산 한단계 증가
    2. 각 객체마다 구현부 포인터만큼 메모리 크기 증가
    3. 구현부 포인터가 구현부 객체를 가리키도록 초기화 필요
      동적 메모리 할당 연산 오버헤드 발생 + bad_alloc 예외 가능성 존재
  • 인터페이스 클래스의 단점
    1. 호출되는 함수가 모두 가상 함수
      → 함수 호출이 일어날 때마다, 가상 테이블 점프 비용 소모
    2. 인터페이스 클래스에서 파생된 객체는 가상 테이블 포인터를 가져야 함
      메모리 크기 증가

📌 실무 적용 방법

  • 개발 도중에는 핸들 클래스 혹은 인터페이스 클래스를 사용하자
  • 단점으로 인해 많은 손해를 보게 되는 경우, 구체 클래스로 변경하자

🧐 정리

  1. 컴파일 의존성을 최소화하기 위해 ‘정의’ 대신 ‘선언’에 의존하게 하자.
  2. 라이브러리 헤더는 모든 것을 갖추어야 하며, 선언부만 갖고 있어야 한다. 템플릿의 사용 여부와 관계없이 적용하자.

Cpp 카테고리 내 다른 글 보러가기

댓글남기기