제대로 쓰기엔 쉽고 엉터리로 쓰기엔 어려운 인터페이스를 개발하려면 사용자가 실수할 만한 종류를 알고있어야한다.
만약 날짜를 나타내는 클래스에 넣을 생성자를 생성한다고 가정해보자.
class Date
{
public:
Date(int month, int day, int year);
};
별 문제 없어 보이지만, 사용자가 저지를 수 있는 오류는 존재한다. 매개변수 순서가 잘못될 수있고, 월과 일에 해당하는 숫자가 범위에 벗어날 수 있다.
Date d(30, 3, 1995); //3, 30 1995여야함
Date d(3, 40, 1995); //40일은 존재하지 않음
오타 혹은 실수로 저지를법한 실수다.
어처구니 없는 코드가 컴파일되는 상황을 막는 방법으로 타입 시스템을 들 수있다.
struct Day
{
explicit Day(int d)
: val(d)
{}
int val;
};
struct Month
{
explicit Month(int m)
: val(m)
{}
int val;
};
struct Year
{
explicit Year(int y)
: val(y)
{}
int val;
};
class Date
{
public:
Date(const Month& m, const Day& d, const Year& y);
};
Day, Month, Year에 데이터를 이것저것 숨겨 넣어 제몫을 하는 온전한 클래스로 만들면 위 코드보다 확실히 낫지만(22장 참고), 타입을 적절히 사용하게끔 준비만 해도 인터페이스 사용 에러를 막는데 통한다.
솔직히 이렇게 까지 할 필요가 있나 싶다. 예시를 들기 위해 쓴 코드라고 하지만, 일일히 이렇게 구현하면 코드 길이 감당이 가능한가?
다른 방법으로 Month가 가질 수 있는 유효한 값은 1 ~ 12뿐이므로, 이 사실을 제약으로 사용할 수 있다.
예를 들어 enum을 넣는 방법이 있는데, 타입 안전성은 믿음직하지 못하다. int처럼 쓰일 수 있다는 사실이 있기 때문이다(2장 참고).
enum Month { JAN = 1, FEB, MAR, APR, MAY, JUN, JUL, AUG, SEP, OCT, NOV, DEC };
class Date
{
public:
Date(Month m, int d, int y) { }
};
Date d(Month::JAN, 3, 1995);
타입 안전성이 신경 쓰인다면 유효한 Month의 집합을 미리 정의해 둘 수 있다.
class Month
{
public:
static Month JAN() { return Month(1); }
static Month FEB() { return Month(2); }
static Month MAR() { return Month(3); }
static Month APR() { return Month(4); }
static Month MAY() { return Month(5); }
static Month JUN() { return Month(6); }
static Month JUL() { return Month(7); }
static Month AUG() { return Month(8); }
static Month SEP() { return Month(9); }
static Month OCT() { return Month(10); }
static Month NOV() { return Month(11); }
static Month DEC() { return Month(12); }
private:
explicit Month(int m) {} //Month값이 새로 생성되지 않도록 명시호출 생성자가 private
};
class Date
{
public:
Date(Month m, int d, int y) {}
};
Date(Month::JAN(), 3, 1995);
이것도 난 모르겠다. 방대해지는 코드길이를 감수하면서까지 이렇게 해야하는가?
제대로 인터페이스를 만들어 주는 요인중 일관성 만큼 똑 부러지는 것이 없으며, 구진 인터페이스를 더 나쁘게 만들어 버리는 요인 중 비일관성만한 것도 없다. 즉 일관성이 중요한 요소이다.
사용자 쪼겡서 뭔가를 외어야 제대로 쓸 수 있는 인터페이스는 잘못 쓰기 쉽다.
예를 들어 C++의 STL 컨테이너는 size란 멤버 함수를 사용할 수있다. 컨테이너에 들어 있는 원소의 개수를 반환하는 함수인데, 자바의 경우 배열에 대해선 length 프로퍼티를 사용하고, string에 대해선 length메서드를 불러야하며 List에서는 size 메서드를 쓰도록 되어있다. 이정도 이름이야 얼추 유추할 수 있어서 사용하는데 큰 지장이 없겠다 생각 할 수 있지만, 잘못 쓸 여지는 존재한다는 것이 요점이다.
다른 예로 13장에서 사용했던 팩토리 함수가 있었다.
Investment* createInvestment();
이 함수를 사용할 때는, 자원 누출을 피하기 위해 createInvestment에서 얻어낸 포인터를 나중에라도 삭제해야한다.
여기서 두가지 실수를 범할 수 있는데, 포인터 삭제를 안하거나, 똑같은 포인터를 두번 삭제할 수있다.
우리는 이를 방지하기 위해 스마트 포인터로 선언하는 방법을 사용했었다. 하지만 스마트 포인터를 사용해야 한다는 사실을 사용자가 잊어버리면? 차라리 애초부터 팩토리 함수가 스마트 포인터를 반환하게 만들면 방지할 수 있을 것이다.
shared_ptr<Investment> createInvestment();
shared_ptr을 반환하는 구조는 자원 해제에 관련된 상당수의 사용자 실수를 사전 봉쇄할 수 있어서 인터페이스 설계자에게 좋다. shared_ptr은 생성 시점에 자원 해제 함수를 직접 엮을 수 있는 기능을 갖고 있기 때문이다(14장 참고).
auto_ptr은 이런 기능이 없다.
만약 createInvestment를 통해 얻은 Investment* 포인터를 직접 삭제하지 않게 하고, getRidOfInvestment라는 함수를 준비해서 여기에 넘기게 하면 어떨까?
왠지 더 깔끔해 보이지만 사용자 실수를 하나 더 열어놓는 결과를 가져온다. getRidOfInvestment를 잊고 직접 delete를 한다는 등 자원 해제 메커니즘을 잘못 사용할 수 있기 때문이다.
createInvestment 함수를 살짝 고쳐서, getRidOfInvestment가 삭제자로 묶인 shared_ptr을 반환하도록 구현해 둔다면 이런 문제는 고민할 가치도 없어진다.
shared_ptr에는 두 개의 인자를 받는 생성자가 있다. 첫 번째 인자는 이 스마트 포인터로 관리할 실제 포인터, 두 번째 인자는 참조 카운트가 0이 될 때 호출될 삭제자이다(14장 참고).
shared_ptr<Investment> pInv(0, getRidOfInvestment);
결과적으로 컴파일 에러가 발생한다.
첫 번째 인자인 0은 포인터가 아니라 int이다. 0 자체는 포인터로 변환할 수 있지만, 지금의 경우 shared_ptr이 요구하는 포인터는 Investment* 타입의 실제 포인터이기 때문이다. 그래서 캐스팅하여 사태를 해결한다.
shared_ptr<Investment> pInv(static_cast<Investment*>(0), getRidOfInvestment);
이 코드를 기반으로 createInvestment 함수를 구현한다면
shared_ptr<Investment> createInvestment()
{
shared_ptr<Investment> reval(static_cast<Investment*>(0), getRidOfInvestment);
reval = ...; //reval이 실제 객첼르 가르키도록
return reval;
}
만약 reval로 관리할 실제 객체의 포인터를 결정하는 시점이 reval을 생성하는 시점보다 앞설 수 있다면, 위 코드처럼 reval을 NULL로 초기화하고나서 나중에 대입하는 방법보단, 실제 객체의 포인터를 바로 reval의 생성자에 넘겨버리는게 낫다(26장 참고).
shared_ptr에는 특징이 하나 더있다. 포인터별(per-pointer) 삭제자를 자동으로 씀으로써 사용자가 저지를 수 있는 잘못을 미연에 방지해 준다는 점이다. 이 잘못은 교차 DLL 문제(cross-DLL problem)이다. 이 경우는 객체 생성 시에 어떤 동적 링크 라이브러리(Dynamic linked library: DLL)의 new를 사용했는데 그 객체를 삭제할 때는 이전 DLL과 다른 DLL에 있는 delete를 썼을 경우다. 이렇게 new/delete처럼 짝이 실행되는 DLL이 달라서 꼬이게 되면 런타임 에러가 발생한다.
하지만 shared_ptr은 이 문제를 피할 수 있다. 이 클래스의 기본 삭제자가는 shared_ptr이 생성된 DLL과 동일한 DLL에서 delete를 사용하도록 만들어져 있기 때문이다.
예를 들어 Stock이란 클래스가 Investment에서 파생된 클래스고 createInvestmnet 함수가 아래와 같이 구현되어 있을 때
shared_ptr<Investment> createInvestment()
{
return shared_ptr<Investment>(new Stock);
}
이 함수가 반환하는 shared_ptr은 다른 DLL들 사이에 이리저리 넘겨지더라도 교차 DLL 문제를 걱정하지 않아도 된다.
Stock객체를 가리키는 shared_ptr은 그 Stock객체의 참조 카운트가 0이 될 떄 어떤 DLL의 delete를 사용해야 하는지 잊지 않는다.
결론적으로 shared_ptr을 사용하면 사용자가 무심코 저지를 법한 실수 몇 가지를 없앨 수 있다.
shared_ptr을 구현한 제품 중 가장 흔히 쓰이는 것은 부스트 라이브러리이다(55장 참고). 부수트의 shared_ptr은 크기가 원시 포인터의 두 배이고, 내부 관리 데이터 및 삭제자 매커니즘을 돌릴 데이터를 위해 동적 할당 메모리를 사용하고, 다중스레드로 돌아가는 프로그램을 지원할 경우 참조 카운트를 변경할 떄 스레드 동기화 오버헤드를 일으킨다.
간단히 말해 이 클래스를 사용하면 원시 포인터보다 크고 느리며 내부 관리용 동적 메모리까지 추가로 생긴다. 하지만 런타임 비용이 눈에 띄게 늘어나는 경우는 어지간해선 존재하지 않기에 실보단 득이 많다고 볼 수 있다.
이것만은 잊지 말자
좋은 인터페이스는 제대로 쓰기에 쉬우며 엉터리로 쓰기에 어렵다.
인터페이스의 올바른 사용을 이끄는 방법으로 1.인터페이스 사이의 일관성 잡아주기, 2.기본제공 타입과의 동작 호환성 유지하기가 있다.
사용자의 실수를 방지하는 방법으로 1.새로운 타입 만들기, 2.타입에 대한 연산을 제한하기, 3.객체의 값에 대해 제약 걸기, 4.자원 관리 작업을 사용자 책음으로 두지 않기가 있다.
shared_ptr은 사용자 정의 삭제자를 지원한다. 이 특징 덕에 shared_ptr은 교차 DLL 문제를 막아주고, 뮤텍스 등을 자동으로 잠금 해제하는데(14장 참고) 사용 가능하다.
'서적 정리 > Effective C++' 카테고리의 다른 글
22.데이터 멤버가 선언될 곳은 private 영역임을 명심하자 (0) | 2021.12.14 |
---|---|
21.함수에서 객체를 반환해야 할 경우에 참조자를 반환하려고 들지 말자 (0) | 2021.12.14 |
20.'값에 의한 전달'보다는 '상수객체 참조자에 의한 전달' 방식을 택하는 편이 대개 낫다 (0) | 2021.12.14 |
19.클래스 설계는 타입 설계와 똑같이 취급하자 (0) | 2021.12.14 |
17.new로 생성한 객체를 스마트 포인터에 저장하는 코드는 별도의 한 문장으로 만들자 (0) | 2021.12.11 |
16.new 및 delete를 사용할 때는 형태를 반드시 맞추자 (0) | 2021.12.11 |
15.자원 관리 클래스에서 관리되는 자원은 외부에서 접근할 수 있도록 하자 (0) | 2021.12.11 |
14.자원 관리 클래스의 복사 동작에 대해 진지하게 고찰하자 (0) | 2021.12.11 |
댓글