C++은 인터페이스와 구현을 깔끔하게 분리하는 일에는 일가견이 없다고 한다.
C++의 클래스 정의(class definition)는 클래스 인터페이스만 지정하는 것이 아니라 구현 세부사항까지 지정하고 있기 때문이다. 세부사항이란 클래스 안에 선언된 객체들 같은 것들을 말하는것 같다.
클래스에서 STL이든 직접 만든 클래스를 사용하려면 #include를 해야한다.
이번 챕터의 주인공은 #include 문으로 인해 헤더 파일들 사이에 컴파일 의존성(compliation dependency)이다.
Person 클래스에 Date와 Address 파일이 포함되어 있다고 생각해보자.
#include <string>
#include "Date.h"
#include "Address.h"
class Person
{
public:
Person(const std::string& name, const Date& birthday, const Address& addr);
string name() const;
string birthDate() const;
string address() const;
private:
std::string theName; //구현 세부사항
Date theBirthDate; //구현 세부사항
Address theAddress; //구현 세부사항
};
여기서 string, Date, Address 셋 중 하나라도 바뀌면 Person 객체를 사용하는 다른 파일들까지 모두 다시 컴파일 되어야 한다.
학원에서는 class를 붙여서 선언하고 #include를 구현파일(.cpp)에서 하는데 컴파일 의존성과 관련있는지는 모르겠다.
//Person.h
class Person
{
...
class Date theBirthDate; //class 붙임
class Address theAddress; //class 붙임
};
//Person.cpp
#include "Date.h"
#include "Address.h"
어찌 되었던 수정된 부분만 컴파일하면 좋을텐데 엮인 것들을 다시 컴파일한다는건 비효율적이라는 것은 알 수 있다.
아니면 Person 클래스를 정의할 때 구현 세부사항을 따로 지정하는 식으로 하면 어떨까?
namespace std //전방 선언 하지만 오류
{
class string;
}
class Date; //전방 선언
class Address; //전방 선언
class Person
{
public:
Person(const std::string& name, const Date& birthday, const Address& addr);
string name() const;
string birthDate() const;
string address() const;
};
문제는 두 가지가 존재한다.
첫째로 string은 클래스가 아니라 typedef로 정의한 타입동의어이다.(basic_string<char>를 typedef한 것) 따라서 string에 대한 전방 선언은 오류다.
둘째로 컴파일러가 컴파일 도중에 객체들의 크기를 전부 알아야 한다.
Person p(/*param*/); //Person의 크기를 모름
C++에서는 포인터 뒤에 실제 객체 구현부 숨기기가 가능하다.
말을 바꿔서 그렇지 pimpl 관용구라고 생각해도 괜찮을 것같다. pimpl 관용구는 패턴으로 굳어져 있을 정도라고 한다.
한 쪽은 인터페이스만 제공하고, 또 한 쪽은 인터페이스의 구현을 맡도록 하는 것이다.
//PersonImpl.h
class PersonImpl
{
string theName; //구현 세부사항
Date theBirthDate; //구현 세부사항
Address theAddress; //구현 세부사항
};
//Person.h
class PersonImpl; //Person의 구현 클래스에 대한 전방 선언
class Date; //전방 선언
class Address; //전방 선언
class Person
{
public:
Person(const std::string& name, const Date& birthday, const Address& addr);
string name() const;
string birthDate() const;
string address() const;
private:
shared_ptr<PersonImpl> pImpl; //구현 클래스 객체에 대한 포인터
};
여기서 사용한 PersonImpl 클래스를 핸들 클래스(handle class)라고 한다. 핸들 클래스에서 어떤 함수를 호출하게 되어 있다면, 여기서 Person.h에 해당하는 핸들 클래스에 대응되는 구현 클래스쪽으로 그 함수를 호출을 전달하여 구현 클래스가 실제 작업을 수행하게 한다.
//Person.cpp
#include "Person.h"
#include "PersonImpl.h"
Person::Person(const std::string& name, const Date& birthday, const Address& addr)
: pImpl(new PersonImpl(name, birthday, addr))
{ }
string Person::name() const
{
return pImpl->theName;
}
이렇게 인터페이스와 구현을 둘로 나누는 핵심은 정의부에 대한 의존성(dependencies on definitions)을 선언부에 대한 의존성(dependencies on declarations)으로 바꾸는 것이다.
헤더 파일을 만들 때는 실용적으로 의미를 갖는 한 자체조달 형태로 만들고, 정 안되면 다른 파일에 대해 의존성을 갖도록 하되 정의부가 아닌 선언부에 대해 의존성을 갖도록 만드는 것이다. 이것이 컴파일 의존성을 최소화하는 핵심이다.
의존성을 선언부에 두는 것을 핵심으로 두고, 다른 방법으로 크게 3가지가 있다. 다른 방법도 이 세 개 방법을 축으로 시작할 것이다.
1.객체 참조자 및 포인터로 충분한 경우에는 객체를 직접 쓰지 않는다.
어떤 타입에 대한 참조자 및 포인터를 정의할 때는 그 타입의 선언부만 필요하다. 하지만 어떤 타입의 객체를 정의할 때는 그 타입의 정의가 필요하다.
2.할 수 있으면 클래스 정의 대신 클래스 선언에 최대한 의존하도록 하자.
어떤 클래스를 사용하는 함수를 선언할 때는 그 클래스의 정의를 가져오지 않아도 된다. 심지어 그 클래스 객체를 값으로 전달하거나 반환할 때도 클래스 정의가 필요 없다.
class Date; //클래스 전방 선언
class Person
{
...
Date today(); //Date클래스 반환
void claerAppointments(Date d); //매개변수로 Date클래스 사용
...
};
3.선언부와 정의부에 대해 별도의 헤더 파일을 제공한다.
클래스를 둘로 쪼개는 방법을 사용하려면 헤더 파일이 짝으로 있어야 한다. 하나는 선언부를 위한 헤더 파일이고, 또 하나는 정의부를 위한 헤더 파일이다. 이 헤더 파일은 하나가 바뀌면 다른 하나도 바뀌게 되므로 관리도 짝지어진다. 그렇기 때문에 클래스는 전방 선언 하는 것이 아니라 클래스 선언부 헤더 파일을 #include 해야한다.
C++ 라이브러리의 <iosfwd> 헤더(54장 참고)가 <sstream>, <streambuf>, <fstream>, <iostream>등의 클래스 선언부로 구현되어 있다.
pimpl 관용구을 사용하는 것처럼 핸들 클래스 방법 대신 다른 방법으로 인터페이스 클래스(Interface class)로 만드는 방법도 있다.
어떤 기능을 나타내는 인터페이스를 추상 기본 클래스로 선언해두고, 이 클래스로부터 파생 클래스를 만들 수 있게 하는 것이다(34장 참고). 파생이 목적이기에 데이터 멤버도 없고, 생성자도 없으며, 하나의 가상 소멸자(7장 참고)와 인터페이스를 구성하는 순수 가상 함수만 있을 뿐이다.
class Person
{
public:
virtual ~Person();
virtual string name() const = 0;
virtual string birthDate() const = 0;
virtual string address() const = 0;
};
이 클래스를 쓰려면 Person에 대한 포인터 혹은 참조자로 프로그래밍하는 방법밖에 없다. 순수 가상 함수를 포함한 클래스를 인스턴스로 만드는 것은 불가능 하기 때문이다. 단, Perosn의 파생 클래스는 인스턴스로 만들 수 있다. 그리고 인터페이스 클래스의 인터페이스가 수정되지 않는 한 사용자는 다시 컴파일할 필요가 없다.
인터페이스 클래스를 사용하기 위해서 객체 생성 수단이 최소 하나는 있어야 한다.
class Person
{
public:
virtual ~Person();
static shared_ptr<Person> Create(const std::string& name,
const Date& birthday, const Address& addr);
virtual string name() const = 0;
virtual string birthDate() const = 0;
virtual string address() const = 0;
};
파생 클래스의 생성자 역할을 대신하는 어떤 함수를 만들어 두고 이 함수를 호출함으로써 해결한다. 팩토리 함수(13장 참고) 혹은 가상 생성자(virtaul constructor)라고 부른다. 주어진 인터페이스 클래스의 인터페이스를 지원하는 객체를 동적으로 할당한 후, 그 객체의 포인터를 반환하는 것이다.
사용자 측에서는 아래처럼 사용해야 할 것이다.
string name;
Date dateOfBirth;
Address address;
shared_ptr<Person> pp(Person::Create(name, dateOfBirth, addrss));
cout<< pp->name() << pp->birthDate() << pp->address() << endl;
Person 클래스로 부터 파생된 클래스는 상속받은 순수 가상 함수에 대한 구현부를 구현해야 할 것이다.
class RealPerson : public Person
{
public:
RealPerson(const std::string& name, const Date& birthday, const Address& addr)
: theName(name), theBirthDate(birthday), theAddress(addr)
{ }
virtual ~RealPerson() {}
string name() const override;
string birthDate() const override;
string address() const override;
private:
string theName;
Date theBirthDate;
Address theAddress;
};
핸들 클래스와 인터페이스 클래스 정리
핸들 클래스 :
1.멤버 함수를 호출하면 구현부 객체의 데이터까지 가기 위해 구현부 포인터를 타야한다. 즉, 접근할 때마다 요구되는 간접화 연산이 증가한다.
2.객체 하나씩을 저장하는데 필요한 메모리 크기에 구현부 포인터의 크기가 증가한다.
3.구현부 포인터가 동적 할당된 구현부 객체를 가리키도록 어디선가 구현부 포인터의 초기화가 일어나야 한다(핸들 클래스의 생성자 안에서).
결론적으로 동적 메모리 할당에 따르는 연산 오버헤드와 bad_alloc(메모리 고갈) 예외 가능성이 따른다.
인터페이스 클래스 :
1.호출되는 함수가 전부 가상 함수라는 점이 약점이 될 수 있다. 이유는 함수 호출이 일어날 때마다 가상 테이블 점프에 따르는 비용이 소모된다(7장 참고).
2.인터페이스 클래스에서 파생된 객체는 죄다 가상 테이블 포인털르 갖고 있어야 한다(7장 참고).
3.만약 가상 함수를 공급하는 쪽이 인터페이스 클래스밖에 없을 때 가상 테이블 포인터도 객체 하나를 저장하는 데 필요한 메모리 크기를 늘리는 요인이 된다.
핸들 클래스와 인터페이스 클래스의 공통된 약점은 인라인 함수의 도움을 제대로 끌어내기 힘들다는 점이다. 인라인 함수를 쓰려면 헤더 파일에 구현부를 두어야 하는데, 두 방법은 함수 구현부를 없애는게 중점이니 당연하다.
이것만은 잊지 말자
컴파일 의존성을 최소화하는 작업의 배경이 되는 가장 기본적인 아이디어는 정의 대신 선언에 의존하는 것이다.
이 아이디어에 기반한 두 가지 접근 방법은 핸들 클래스와 인터페이스 클래스이다.
라이브러리 헤더는 그 자체로 모든 것을 갖추어야 하며 선언부만 갖고 있는 형태여야 한다. 이 규칙은 템플릿이 쓰이거나 쓰이지 않거나 동일하게 적용하자.
'서적 정리 > Effective C++' 카테고리의 다른 글
35.가상 함수 대신 쓸 것들도 생각해 두는 자세를 시시때때로 길러 두자 (0) | 2021.12.21 |
---|---|
34.인터페이스 상속과 구현 상속의 차이를 제대로 파악하고 구별하자 (0) | 2021.12.21 |
33.상속된 이름을 숨기는 일은 피하자 (0) | 2021.12.20 |
32.public 상속 모형은 반드시 "is-a(...는 ...의 일종이다)"를 따르도록 만들자 (0) | 2021.12.20 |
30.인라인 함수는 미주알고주알 따져서 이해해 두자 (0) | 2021.12.19 |
29.예외 안전성이 확보되는 그날 위해 싸우고 또 싸우자! (0) | 2021.12.19 |
28.내부에서 사용하는 객체에 대한 "핸들"을 반환하는 코드는 되도록 피하자 (0) | 2021.12.19 |
27.캐스팅은 절약, 또 절약! 잊지 말자 (0) | 2021.12.17 |
댓글