인터페이스와 구현의 분리
c++ 의 class definiton 는 클래스 인터페이스 + 구현 세부사항까지 지정 할 수 있어서
c++ 은 인터페이스와 구현을 깔끔하게 분리하기 힘들다.
예를 들어 아래 코드처럼 Person 구현의 세부사항에 속하는 Date, Address 정의된 정보를 가져오기 위해
"date.h", "address.h" 들을 물어와야 한다.
#include <string>
#include "date.h"
#include "address.h"
class Person {
public:
Person(const Date& birthday, const Address& addr);
private :
std::string name;
Date theBirthDate; //구현 세부사항
Address theAddress; //구현 세부사항
}
전방선언(forward declaration)
위 예제에서 #include
문은 Person 을 정의하기 위한 파일과 헤더 파일들 사이에 컴파일 의존성 을 엮어버린다.
즉, include 한 세개 파일 중 하나라도 바뀌거나 혹은 해당 헤더파일과 엮여있는 다른 헤더파일이 바뀌기만 해도
Person 클래스를 정의한 파일은 컴파일러에게 끌려간다.
이를 방지하기 위해 전방선언(forward declaration) 을 사용하면 된다.
전방선언을 사용하면 위 예제의 include 문은 아래와 같이 바꿀 수 있다.
#include <string> // 표준 library 는 그냥 include
class Date; //forward declration
class Address; //forwrad declration
위 코드의 문제점
컴파일러는 컴파일 도중에 전방선언한 객체들의 크기를 모두 알아야 한다.
int x;
Person p( params);
컴파일러는 x 의 정의를 만나면 int 크기만큼 공간을 할당하고, p 를 만나면 Person 크기만큼 공간을 할당하려 한다.
자바의 경우에는 객체가 정의 될 때 컴파일러가 그 객체의 포인터를 담을 공간만 할당해서 문제가 되지 않는다.
이것처럼 c++ 도 그럼 포인터로 선언하면 문제가 되지 않는다!
즉 포인터 뒤에 실제 객체 구현부 숨기기 하면 된다.
PImpl (Pointer to implementation)
위 전방선언의 문제를 해결하기 위해서는
클래스를 두개로 쪼개고 한쪽은 인터페이스만 제공, 또 한쪽은 인터페이스의 구현을 맡도록 하면 된다.
위와 같이 구현에 대한 부분을 따로 빼놓는 것을 pimple 관용구 라 한다.
코드로 구현하면 아래와 같은 형식이다.
#include<string>
#incldue<memory>
class PersonImpl; //Person 구현 클래스에 관한 전방선언
class Date;
class Address;
class Person {
public:
Person(const Date& birthday, const Address &addr);
private:
std::shared_ptr<PersonImpl> pImpl; //구현 클래스 객체에 관한 포인터. 보통 pImpl 이라고 이름붙임
}
이렇게 되면 Person 클래스 구현부는 마음대로 고칠 수 있고, Person 을 사용하는 쪽은 구현부가 바뀌어도
컴파일을 다시 할 필요가 없다.
이걸 책에서는 정의부에 대한 의존성 을 선언부에 대한 의존성 으로 바꾸어 놓았다고 말하고 있다.
컴파일 의존성을 줄이기 위해서는 다른 파일에 대한 의존성을 갖도록 하되 정의부가 아닌 선언부에 대한 의존성을 갖도록 만들자!
지금까지 내용 정리
객체 참조자 및 포인터로 충분한 경우에는 그 객체를 직접 쓰지 않는다.
참조자 및 포인터 정의시에는 선언부만 필요하다.
할 수 있으면 클래스 정의 대신 클래스 선언에 최대한 의존하도록 만들자.
아래와 같이 어떤 클래스를 사용하는 함수를 선언할 때에는 해당 클래스의 정의는 가져오지 않아도 된다.
class Date; //전방선언으로 충분.
Date today();
void test(Date d);
누군가 저 함수들을 호출한다면 Date 정의가 파악되어야 하지만, Date 정의는 사용자에게 전가한다.
함수 선언이 되어 있는 헤더 파일쪽에 부담을 주지 않는 이점이 있다.
(누군가는 저 api를 영영 호출하지 않을 수 도 있기 때문에, 불필요한 Date 를 include 하지 않은 것)
선언부와 정의부에 대해 별도의 헤더파일을 제공한다.
클래스를 둘로 쪼개는 일을 제대로 하려면, 헤더 파일이 짝으로 있어야 한다.
선언부를 위한 헤더파일과 정의부를 위한 헤더파일을 만들어 놨다면, 사용자는 선언부 헤더파일을 include 해야 한다.
Date 클래스를 선언부와 정의부로 쪼개 놨다면 아래와 같이 써야 한다.
#include "datefwd.h" //Date 클래스를 선언하고만 있는 헤더파일
Date today();
datefwd 이름에서 눈치챘겠지만, c++ 의 표준 라이브러리인 <iosfwd>
에서 따온 것이다.<iosfwd>
는 iostream 관련 함수나 클래스들의 선언부로만 구성된 헤더다.
pImpl 활용법
pImpl 관용구를 사용하는 Person 클래스를 핸들 클래스 라고 부른다.
핸들 클래스에서 어떤 함수를 호출하게 되어 있다면,
구현 클래스 쪽으로 그 함수 호출을 전달해서 구현 클래스가 실제 작업을 수행하게 만들어야 한다.
#include "Person.h" // Person class 를 구현하고 있는 중이여서 클래스 정의를 include
#include "PersonImpl.h" // PersonImpl 를 include 해야 멤버 함수 호출 할 수 있다.
Person::Person(const std::string&name, const Date&. ......) //생략
: pImpl(new PersonImpl(name,.....)
std::string Person::name() const {
return pImpl->name();
}
personImpl 의 멤버 함수는 Person 의 멤버 함수와 일대일 대응되고 있다.
Interface Class 사용
다른 방법을 쓰고 싶다면 interface class 로 만드는 방법도 있다.
인터페이스를 추상 기본 클래스로 마련해 놓고, 이 클래스로부터 파생 클래스를 만들 수 있게 하는 것!
아래 같이 인터페이스 클래스를 정의하고 Person 에 대한 포인터 혹은 참조자로 가지고 있는다.
(순수 가상함수 포함한 클래스는 인스턴스화 불가능. Person 의 파생클래스만 인스턴스화 가능)
이렇게 하면 인터페이스가 수정되지 않는 한 사용자는 다시 컴파일할 필요가 없다.
class Person {
public:
virtual ~Person():
virtual std::string name() const =0;
}
팩토리 함수 , 가상 생성자(virtual constructor)
interface class 를 사용하기 위해서는 객체 생성 수단이 최소한 하나는 필요하다.
이런 역할을 해주는 함수를 팩토리 함수 혹은 가상 생성자라 부른다.
인터페이스 클래스의 인터페이스를 지원하는 객체를 동적으로 할당 후, 해당 객체의 포인터를 반환하는 역할을 한다.
대부분 인터페이스 클래스 내부에 정적 멤버로 선언된다.
class Person {
static std::shared_ptr<Person> create(const std::string&name, const Date&. ......) //생략
}
Person 클래스를 지원하는 concrete class
class RealPerson: public Person {
public:
RealPerson(const string& name, const Date & birthday) : theName(name), theBirthDate(birthday)
{}
virtual ~RealPerson() {}
string name() const;
private:
string theName;
Date theBirthDate;
}
shared_ptr<Person> Person::create(const string& name, const Date& birthday)
{
return shared_ptr<RealPerson>(new RealPerson(name,birthday));
}
사용자 쪽에서는 아래와 같이 쓰면 된다.
std::string name;
Date dateofBirth;
std::shared_ptr<Person> pp(Person::create(name, dateOfBirth));
pp->name();
Person 의 create 함수는 실제 RealPerson 의 객체를 생성해서 반환하고 있다.
RealPerson은 인터페이스 클래스(Person) 으로부터 인터페이스 명세를 물려 받게 한 후에,
그 인터페이스에 들어 있는 함수(virtual 함수)를 구현하고 있다.
인터페이스 클래스를 구현하는 방법 중에는 다중 상속을 이용하는 것도 있다. 이는 후에 다룬다.
정리
핸들 클래스(pImpl 기반)와 인터페이스 클래스 사용시의 장단점을 알아보자.
장점
구현부로부터 인터페이스를 떼어 놓음으로써 파일들 사이의 컴파일 의존성을 완화시키는 효과를 가져다 준다.
단점
핸들 클래스의 경우
1) 멤버 함수를 호출하면 구현부 객체의 데이터까지 가기 위해 포인터를 타야 한다.
즉, 간접화 연산이 한 단계 증가된다.
2) 객체 하나를 저장하는데 필요한 메모리 크기 + 구현부 포인터의 크기가 더해진다.
3) 구현부 포인터가 동적 할당된 구현부 객체를 가리키도록 포인터 초기화가 일어나야 한다. (핸들 클래스의 생성자안에서)
즉, 동적 메모리 할당과 해제에 따르는 연산 오버헤드가 발생한다.
인터페이스 클래스의 경우
1) 호출되는 함수가 전부 가상 함수다. = 함수 호출이 일어날 때마다 가상 테이블 점프에 따르는 비용이 소모된다.
둘다 가지고 있는 약점
핸들 클래스와 인터페이스 클래스 모두 함수 본문과 구현부를 분리하는데 중점을 둔 설계기 때문에,
인라인 함수의 도움을 끌어내기 힘들다.
인라인이 되려면 함수 본문을 대게 헤더 파일에 두어야 한다. (항목 30참조)
결론
개발 도중에는 구현부가 바뀌어도 해당 클래스를 사용하는 쪽에게 미치는 파급 효과를 최소화 하는게 좋다.
핸들 클래스와 인터페이스 클래스로 인해 실행 속력이나 파일 크기에서 많이 손해를 보게 될 경우에,
다시 통짜 클래스로 바꾸는 것을 고민하자!
- 정의 대신에 선언 에 의존하게 만들자!
- 라이브러리 헤더는 그 자체로 모든 것을 갖추어야 하며 선언부만 갖고 있는 형태여야 한다.
템플릿이 쓰이거나 쓰이지 않거나 이 규칙은 동일하게 적용하자.
'C++ > Effective C++' 카테고리의 다른 글
항목 17: new 로 생성한 객체를 스마트 포인터에 저장하는 코드는 별도의 한 문장으로 만들자 (0) | 2020.09.25 |
---|---|
항목 16: new 및 delete 를 사용할 때는 형태를 반드시 맞추자 (0) | 2020.09.24 |
항목 15: 자원 관리 클래스에서 관리되는 자원은 외부에서 접근할 수 있도록 하자 (0) | 2020.09.24 |
항목 14: 자원 관리 클래스의 복사 동작에 대해 진지하게 고찰하자 (0) | 2020.09.23 |
항목 13: 자원 관리에는 객체가 그만! (0) | 2020.09.23 |