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

Minsu._.Lighting·2023년 12월 7일
0

[ Effective C++ ] 정리 모음집
" C++ 프로그래머의 필독서, 스콧 마이어스의 Effective C++ 를 읽고 내용 요약 / 정리 "

[핵심]

" 개발 도중에는 사용자에게 미칠 파급 효과를 최소로 만드는 것이 좋다! "

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

💡 C++의 클래스 정의(class definition)

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

[ 예시 코드 ]

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;
};
  • 위 코드는 컴파일이 불가능 하다
    - string, Date, Address와 같은 Person의 세부사항들이 어떻게 정의 됐는지 모르기 때문

  • 세부사항들의 정보를 가져오기 위해선 #include 지시자를 사용해야 한다
    #include <string>, #include "Date.h", #include "Address.h"
    - 하지만 #include는 Person 정의 파일과 헤더 파일들 사이의 컴파일 의존성을 증가시켜, 헤더 파일 중 하나라도 바뀌거나 헤더 파일들과 엮여있는 파일들이 바뀌기만 해도 컴파일을 다시 해야한다.



💡 구현 세부사항을 따로 지정해주면 안되나?

namespace std
{
	class 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로 정의한 타입동의어 이다
    basic_string <char>
    - string을 전방선언 하기 위해선 템플릿을 추가로 끌고 들어와야해서 더 복잡해진다.

  • 컴파일러는 컴파일 도중 객체들의 크기를 전부 알아야 한다
    Person p(params);
    - 컴파일러는 위 정의문을 만나면 객체 p에 대한 공간을 할당해야 하는데 Person의 크기를 알기 위해선 Person이 어떻게 정의되어 있는지를 알아야 한다



💡 포인터 뒤에 실제 객체 구현부 숨기기(pimpl 관용구)

#include <string>
#include <memory>

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;
};
  • 정의 하고자 하는 클래스를 인터페이스 제공, 인터페이스 구현 클래스로 두 개로 나누어 만든다

  • 이런 설계는 패턴으로 굳어져 pimpl 관용구(pointer to implementation)라는 이름으로 불린다.
    - 포인터 변수의 이름은 pImpl이라고 붙이는 것이 일반적이다.

  • '정의부에 대한 의존성'을 '선언부에 대한 의존성'으로 바꾸는 것
    - 컴파일 의존성을 최소화 하는 핵심 원리



💡 컴파일 의존성을 최소화 하기 위한 전략

  • 객체 참조자 및 포인터로 충분한 경우에는 객체를 직접 쓰지 않는다
    - 참조자, 포인터를 정의할 때는 선언부만 필요하지만 객체를 정의할 때는 해당 타입의 정의가 필요하다

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

class Date;

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

- 어떤 클래스를 사용하는 함수를 선언할 때는 해당 클래스의 정의가 필요하지 않다, 심지어 해당 클래스를 값으로 전달 하거나 반환해도 필요 없다

  • 선언부와 정의부에 대해 별도의 헤더 파일을 제공한다
#include "datefwd.h"

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

- 사용자 입장에서는 선언부의 헤더를 #include 해야 한다.
- 두 헤더 파일은 짝으로 관리 되어야 한다.

📌 < iosfwd > 파일에 대해 알아두자!

  • C++ 에서 지원하는 iostream 관련 함수 및 클래스들의 선언부 만으로 구성된 헤더
    - sstream, streambuf, fstream, iostream 등

  • 선언부 전용 헤더를 제공하는 것은 템플릿에도 적용된다는 대표적인 예시

📢 C++에서는 템플릿 선언과 정의를 분리할 수 있도록 하는 export 키워드도 제공되지만 현장에서 잘 쓰이지 않는다...



💡 핸들 클래스

#include "Person.h"

#include "PersonImpl.h"

Person::Person(const string& name, const Date& birthday, const Address& addr)
	: pImpl(new PersonImpl(name, birtday, addr))
    {}
    
string Person::name() const
{
	return pImpl->name();
}
  • pimpl 관용구를 사용하는 클래스를 핸들 클래스 라고 한다

  • 핸들 클래스에서 어떤 함수를 호출하게 되어 있다면, 구현 클래스 쪽으로 함수 호출을 전달해 구현 클래스가 작업을 수행하게 만든다



💡 인터페이스 클래스

📌 인터페이스 클래스로부터 상속을 받고 가상함수를 구현하는 방법

class Person
{
public:
	virtual ~Person();
    
    virtual string name() const = 0;
    virtual string birthDate() const = 0;
    virtual string address() const = 0;
    ...
    
    static shared_ptr<Person> create(const string& name, Date& birthday, Address& addr);
    
 ...
};

[사용자 쪽 코드]
string name;
Date dateOfBirth;
Address address;
...

shared_ptr<Person> pp(Person::create(name, dateOfBirth, address));
...
cout << pp->name() << " was born on" << pp->birthDate() << " and now lives at " << pp->address();
...
  • 어떤 기능을 나타내는 인터페이스를 추상 기본 클래스로 마련, 이 클래스로부터 파생 클래스를 만들 수 있게 함
    - 파생이 목적이기에 데이터 멤버, 생성자가 없으며, 하나의 가상 소멸자와 인터페이스를 구성하는 순수 가상 함수만 들어있다

  • 이 클래스를 쓰기 위해선 포인터, 참조자로 프로그래밍 해야 함
    - 추상 클래스는 인스턴스로 만들 수 없으니

  • 인터페이스 클래스를 사용하기 위해서는 객체 생성수단이 최소한 하나는 있어야 한다

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

- 파생 클래스의 생성자 역할을 대신하는 팩토리 함수(혹은 가상 생성자)를 호출

📌 다중 상속을 통한 방법



💡 핸들 클래스와 인터페이스 클래스의 단점

  • 실행 시간 비용 발생
  • 객체 한 개당 필요한 저장 공간이 추가로 늘음
  • 인라인 함수의 도움을 받기 힘듬
    - 인라인이 되게 만드려면 함수 본문을 대개 헤더 파일에 두어야 하지만 핸들 클래스와 인터페이스 클래스는 구현부를 사용자의 눈으로부터 차단하는 데 중점을 둔 설계

📌 핸들 클래스의 경우

  • 한번 접근할 때마다 요구되는 간접화 연산이 한 단계 더 증가
    - 핸들 클래스의 함수 호출 시 구현부의 데이터에 접근하기 위해 포인터를 거쳐야 한다

  • 객체 하나씩을 저장하는데 필요한 메모리 크기에 구현부 포인터의 크기가 더해짐

  • 구현부 포인터가 동적 할당된 구현부 객체를 가리키도록 그 구현부 포인터의 초기화가 발생해야 한다
    - 동적 메모리 할당에 따르는 연산 오버헤드, bad-alloc 예외와도 마찰 가능성 생김

📌 인터페이스 클래스의 경우

  • 호출 함수가 전부 가상 함수 이다
    - 함수 호출이 일어날 때마다 가상 테이블 점프에 따른 비용 발생
    - 파생 클래스는 전부 가상 테이블 포인터를 지닌다
profile
오코완~😤😤

0개의 댓글

관련 채용 정보