[C++] 항목 31: 파일 사이의 컴파일 의존성을 최대로 줄이자
카테고리: Cpp
태그: Cpp
이 글은 아래의 책을 자세히 정리한 후, 정리한 글을 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;
...
};
문제점:
- string은 클래스가 아닌 typedef로 정의된다
- 컴파일 도중 객체들의 크기를 알아야 한다
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 방식의 단점
- 공통 단점
- 인라인 함수의 도움을 제대로 얻기 힘들다
- 핸들 클래스의 단점
- 구현부 객체에 접근하기 위해 구현부 포인터를 통해야 함
→ 연산 한단계 증가 - 각 객체마다 구현부 포인터만큼 메모리 크기 증가
- 구현부 포인터가 구현부 객체를 가리키도록 초기화 필요
→ 동적 메모리 할당 연산 오버헤드 발생 + bad_alloc 예외 가능성 존재
- 구현부 객체에 접근하기 위해 구현부 포인터를 통해야 함
- 인터페이스 클래스의 단점
- 호출되는 함수가 모두 가상 함수
→ 함수 호출이 일어날 때마다, 가상 테이블 점프 비용 소모 - 인터페이스 클래스에서 파생된 객체는 가상 테이블 포인터를 가져야 함
→ 메모리 크기 증가
- 호출되는 함수가 모두 가상 함수
📌 실무 적용 방법
- 개발 도중에는 핸들 클래스 혹은 인터페이스 클래스를 사용하자
- 단점으로 인해 많은 손해를 보게 되는 경우, 구체 클래스로 변경하자
🧐 정리
- 컴파일 의존성을 최소화하기 위해 ‘정의’ 대신 ‘선언’에 의존하게 하자.
- 라이브러리 헤더는 모든 것을 갖추어야 하며, 선언부만 갖고 있어야 한다. 템플릿의 사용 여부와 관계없이 적용하자.
댓글남기기