[Effective C++] #28_예외 안전성 확보
Scott Meyers의 "Effective C++" 를 통해, C++ 구현에 필요한 개념들을 이해하고, 기록하기 위함입니다. 해당 항목은 5장 '구현', 항목 31 "파일 사이의 컴파일 의존성을 최대로 줄이자"에 해당하는 내용입니다.
인터페이스와 구현 사이의 컴파일 의존성
#inlcude <string>
#include "data.h"
#include "address.h"
class Person {
public:
Person(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; //구현 세부사항
};
"Person"클래스 내부에 세부사항 몇가지를 추가하여 수정을 진행했다고 가정합시다. 이럴 경우, "Person" 클래스의 구현 세부사항에 속하는 stirng, Date, 그리고 Address가 어떻게 정의됐는지를 모르면 컴파일이 불가능하겠죠. 결국 이들이 정의된 정보를 가져오기위해 "#include"문을 살펴보게 되겠죠. #include문은 Person을 정의한 파일과 위의 헤더파일들 사이에 컴파일 의존성을 엮어버립니다. 따라서, 3개의 헤더 파일 중 하나라도 바뀌는 것은 물론, 엮여 있는 헤더 파일들이 바뀌기만 해도, Person 클래스를 정의한 파일도 영향을 받게 되는 것이죠. 그렇다면, 아래와 같이 수정해보면 어떨까요?
실패한 수정 사례
// 전방 선언
namespace std{
class string;
}
class Date;
class Address;
class Person {
Person(const std::string& name, const Date& birthday,
const Address& addr);
std::string name() const;
std::string birthDate() const;
std::string address() const;
...
}
Person 클래스를 정의할 때, 구현 세부사항을 따로 떼어서 "전방 선언"을 통해 지정하는 식으로 수정해봅시다. 이 수정 사례는 두 가지 문제를 안고 있습니다. "string"은 클래스가 아니라 typedef로 정의한 타입동의어입니다. 제대로 전방 선언을 진행하려면, 템플릿을 추가로 끌고 들어와야 하기 때문에 더 복잡해 진다고 합니다. 또한, 표준 라이브러리 헤더는 대부분의 경우 컴파일시 병목요인이 되지 않습니다. 두 번째 문제는 컴파일러가 컴파일 도중에 객체의 크기를 전부 알아야된다는 점입니다. 아래 코드를 살펴보겠습니다.
int main() {
int x;
Person p {params};
...
}
컴파일러는 x의 정의문을 만나면 "int" 하나를 담을 공간을 할당합니다. 다음으로, Person 객체 하나를 담을 공간을 할당해야합니다. 하지만, 이때, Person 객체 하나의 크기가 얼마인지 알아내기 위해, Person 클래스가 정의된 정보를 찾아보는 수 밖에 없겠죠. 만약, Person 클래스 정의에서 구현 세부사항을 빠뜨린다면, 컴파일러는 자신이 할당할 공간을 정확히 확보 할 수 없습니다! 이러한 컴파일 의존성 문제를 제대로 해결하기 위한 방법을 아래에서 살펴보겠습니다.
pimpl 관용구 설계
#include <string>
#include <memory>
class PersonImple;
class Date;
class Address;
class Person { //인터페이스
public:
Person( Person(const std::string& name, const Date& birthday,
const Address& addr);
std::string name() const;
std::string birthDate() const;
std::string address() const;
private:
std::tr1::shared_ptr<PersonImpl> pImpl; //구현 클래스 객체에 대한 포인터
};
우선 주어진 클래스를 두 클래스로 나눕니다. 한쪽은 인터페이스만 제공하고, 다른 한쪽은 그 인터페이스의 구현을 맡도록 만듭니다. 위 코드는 "PersonImpl"은 구현을 맡고, "Person"은 인터페이만 제공합니다. 이때, Person 인터페이스 객체는 데이터 멤버로 "PersonImpl"을 가리키는 스마트 포인터로 갖고, 생일, 주소, 그리고 이름 등의 세부사항과 분리 됩니다. 따라서, Person 클래스의 사용자 쪽에서는 Person 클래스에 대한 구현 클래스가 수정되어도 컴파일을 다시 할 필요가 없어집니다. 이렇게 한 클래스를 인터페이스와 구현으로 나누는 설계 기법은 "pimpl 관용구" 설계 기법이라고 합니다. 이 설계 기법을 관통하는 개념은 "정의부에 대한 의존성"을 "선언부에 대한 의존성"으로 바꾸어 놓는 데 있습니다.
1. 객체 참조자 및 포인터로 충분한 경우에는 객체를 직접 쓰지 않습니다.
어떤 타입에 대한 참조자 및 포인터를 정의 할 때는 그 타입의 선언부만 필요합니다. 반면에 어떤 타입의 객체를 정의 할 때는 그 타입의 정의가 준비되어 있어야 합니다.
2. 정의 대신 클래스 선언에 최대한 의존하도록 만듭니다.
class Date;
Date today();
void clearAppointments(Date d);
Date를 정의하지 않고도, today와 clearAppointment 함수를 선언 할 수 있습니다. 이 함수들을 호출하기 위해서, Date 클래스의 정의가 먼저 파악되겠죠. 이러한 클래스 선언은 함수 선언이 되어 있는 헤터 파일 쪽에 "정의를 제공하는" 부담을 주지 않고, 실제 함수 호출이 일어나는 사용자의 소스 파일 쪽에 부담을 전가하는 방법을 사용하는겁니다. 결국, 실제로 쓰지도 않을 타입 정의에 대해 사용자가 의존성을 끌어오는 일을 방지 할 수 있습니다.
3. 선언부와 정의부에 대해 헤더 파일을 제공합니다.
#include "datefwd.h" // Date에 대한 선언을 대신하는 헤더파일
Date today();
void clearAppointments(Date d);
구현 과정에서 한쪽에서 어떤 선언이 바뀌면 다른 쪽도 똑같이 바꾸어야 한다는 것이죠. 따라서, 어떤 클래스를 두 개의 클래스로 쪼개는 일은 헤더 파일의 필요성을 암시합니다. 위에서 살펴본 코드를 수정한 예제입니다. Date를 전방 선언하는 대신, "#include"를 사용합니다.
1. pImpl 관용구 설계 기법 활용 방법
#include "Person.h"
#include "PersonImpl.h"
Person::Person(const std::string& name, const Date& birthday,
const Address& addr)
: pImpl(new PersonImpl(name, birthday, addr)) {}
std::string Person::name() const{
return pImpl->name();
}
추가적으로 pImpl 관용구 설계 기법의 할용 방법을 살펴보겠습니다. 먼저, 핸들 클래스란, pImpl 관용구를 사용하는 Person 같은 클래스를 지칭합니다. 즉, 인터페이스를 제공하는 쪽의 클래스를 의미합니다. Person 클래스에서 함수를 호출하게 된다면, 핸들 클래스에 대응하는 구현 클래스 쪽으로 그 함수 호출을 전달해서 구현 클래스가 실제 작업을 수행할 수 있도록 합니다. 이때, "#include "Person.h""와 "#include PersonImple.h""는 Person 클래스와 PersonImpl 클래스의 정보를 갖고 있는 헤더파일들입니다.
2. pImpl 관용구 설계 기법 활용 방법
class Person {
public:
virtual ~Person();
virtual std::string name () const = 0;
virtual std::string birthDate () const = 0;
virtual std::string address () const = 0;
...
}
pImpl 관용구 설계 비법의 다른 활용 방법은 특수 형태의 추상 기본 클래스, 즉 인터페이스 클래스입니다. 방법은 인터페이스를 추상 기본 클래스를 통해 마련하고, 이 클래스로부터 파생 클래스를 만들 수 있게 하자는 것입니다. 파생을 목적으로 만들어진 이 클래스는 데이터 멤버가 없고, 가상 소멸자, 그리고 순수 가상 함수만 갖고 있습니다. 위 코드에서 보이는 Person 클래스를 활용하려면, 사용자는 Person을 가리키는 포인터 혹은 참조자를 사용하는 방법만 존재합니다. 순수 가상 함수를 포함한 클래스는 인스턴스화되지 않기 때문이죠! 또한, 인터페이스 클래스를 사용하기 위해서는 객체 생성 수단이 있어야죠. 우리는 "팩토리 함수" 혹은 가상 생성자를 사용합니다. 인터페이스 클래스의 인터페이스를 지원하는 객체를 동적으로 할당 후, 그 객체의 포인터를 반환하는 것입니다. 아래 코드를 살펴보겠습니다.
class Person {
public:
...
static std::tr1::shared_ptr<Person> // Person 타입의 객체를 가리키는 포인터 반환
create(const std::string& name,
const Date& birthdday,
const Address& addr);
...
};
아래는 사용자 예제입니다.
*****************사용자*****************
class RealPerson: public Person {
public:
RealPerson(const std::string& name, const Date& birthday,
const Address& addr)
: theName(name), theBirthDate(birthday), theAddress(addr) {}
virtual ~RealPerson(){}
std:: string name() const; // 가상 함수 이므로 구현
std:: string brithDate() const; // 가상 함수 이므로 구현
std:: string address() const; // 가상 함수 이므로 구현
private:
std::string theName;
Date theBirthDate;
Address theAddress;
};
//인터페이스 클래스로부터 물려 받아, 정의합니다
std::tr1::shared_ptr<Person> Person::create(const std::string name,
const Date& birthday,
const Address& addr)
{
return std::tr1::shared_ptr<Person>(new RealPerson(name, birthday, addr));
}
"std::tr1::shared_ptr<Person> Person::create" 멤버함수를 사용자 입 맛에 맞춰 파생 클래스 객체를 생성할 수 있게끔 설정합니다. 결과적으로, 인터페이스 클래스로부터 인터페이스 명세를 물려받고, 인터페이스 안에 들어있는 가상 함수들을 구현합니다.
pimpl 관용구를 활용하기위해 "핸들 클래스" 방법을 사용하거나, "인터페이스 클래스" 방법을 사용합니다. 이러한 방법들은 파일 사이의 컴파일 의존성을 완화시키는 효과를 가져옵니다.
핸들 클래스 방법의 단점
먼저, 핸들 클래스의 멤버 함수를 호출하게 되면, 구현 클래스까지 타고가는 포인터가 필요하죠. 결과적으로, 간접화 연산이 더해집니다. 또한, 객체 하나씩을 저장하는데 필요한 메모리크기에 구현부 클래스를 가리키는 포인터의 크기가 더해집니다. 마지막으로, 동적 할당된 구현부 객체를 가리키는 포인터의 초기화가 필요하죠. 동적 메모리 할당에 따르는 메모리 오버헤드 발생의 위험을 안고 있습니다.
인터페이스 클래스의 단점
인터페이스 클래스의 경우, 호출되는 함수가 모두 가상 함수라는 것이 약점입니다. 가상 테이블 점프에 따르는 비용 소모와, 파생 클래스 객체들은 가상 테이블 포인터를 갖고 있어야 합니다. 따라서, 메모리 크기를 늘리는 요인들이 존재합니다.
*공통적인 약점은 인라인 함수 사용이 힘들다는 점이죠.*
이러한 기법들은 혹시 모를 미래를 대비한다는 느낌으로 대해야 합니다.
'언어 > Effective C++' 카테고리의 다른 글
[Effective C++] #33_오버라이딩 문제 (0) | 2022.01.03 |
---|---|
[Effective C++] #32_public 상속 (0) | 2022.01.03 |
[Effective C++] #30_인라인 함수 (0) | 2021.12.30 |
[Effective C++] #29_예외 안전성 확보 (0) | 2021.12.28 |
[Effective C++] #28_내부에서 사용하는 객체에 대한 핸들 반환 (0) | 2021.12.28 |