18: 인터페이스 설계는 제대로 쓰기엔 쉽게, 엉터리로 쓰기엔 어렵게 하자
인터페이스를 사용했을 때, 결과 코드가 사용자가 생각한 대로 동작하지 않는다면 그 코드는 컴파일되지 않아야 맞다. '제대로 쓰기에 쉽고 엉터리로 쓰기엔 어려운' 인터페이스를 개발하려면 우선 사용자가 저지를 만한 실수의 종류를 머리에 넣어두고 있어야 한다.
새로운 타입을 들여와 인터페이스를 강화하기
class Date {
public:
Data(int month, int day, int year);
};
위의 Data 클래스의 생성자는 사용자가 쉽게 저지를 수 있는 오류 구멍이 존재한다. 우선 매개변수의 전달 순서가 잘못될 여지가 열려 있다.
Date d(3, 40, 1995);
월과 일에 해당하는 숫자가 어이없는 숫자일 수 있다.
Date d(3, 40, 1995);
새로운 타입을 들여와 인터페이스를 강화하면 상당수의 사용자 실수를 막을 수 있다. 일, 월, 연을 구분하는 간단한 랩퍼(wrapper) 타입을 만들고 이 타입을 Date 생성자 안에 둘 수 있다.
struct Day {
explicit Day(int d)
: val(d) {}
int val;
};
struct Month {
explicit Month(int d)
: val(d) {}
int val;
};
struct Year {
explicit Year(int d)
: val(d) {}
int val;
};
class Date {
public:
Data(const Month& month, const Day& day, const Year& year);
};
Date d(30, 3, 1995); // 타입이 틀리다.
Date d(Day(30), Month(3), Year(1995)); // 타입이 틀리다.
Date d(Month(3), Day(30), Year(1995)); // 타입이 전부 맞다.
적절한 타입만 제대로 준비되어 있다면, 각 타입의 값에 제약을 가하더라도 괜찮은 경우가 생긴다. 한 가지 방법으로는 월 표시 값을 나타내는 enum을 넣는 방법이 있는데, enum은 타입 안정성은 그리 믿음직하지 못하다. 타입 안정성이 신경 쓰인다면 유효한 Month의 집합을 미리 정의해 두어도 괜찮다.
class Month {
static Month Jan() { return Month(1); }
static Month Fab() { return Month(2); }
...
static Month Dec() { return Month(12); }
private:
explicit Month(int m); // 자동생성 되지 않도록 private
};
Date d(Month::Jan(), Day(30), Year(1995));
월을 나타내는 데 객체를 쓰지 않고 함수를 쓴 이유는 항목 3의 비지역 정적 객체의 초기화 순서는 정해져 있지 안기 때문이다.
그렇게 하지 않을 번듯한 이유가 없다면 사용자 정의 타입은 기본제공 타입처럼 동작하게 만들자
어떤 타입이 제약을 부여하여 그 타입을 통해 할 수 있는 일들을 묶어 버리는 방법이 있다. 아주 흔히 쓰이는 예가 const 붙이기 이다. 예를 들어 operator*의 반환 타입을 const로 한정하면 다음의 실수를 예방할 수 있다.
if (a * b = c) ...
그렇게 하지 않을 번듯한 이유가 없다면 사용자 정의 타입은 기본제공 타입처럼 동작하게 만들어야 한다. 기본제공 타입과 쓸데 없이 어긋나는 동작을 피하는 실질적인 이유는 일관성 있는 인터페이스를 제공하기 위해서다. 예를 들어 모든 STL 컨테이너는 size란 멤버 함수가 있다. 인터페이스의 이름들이 다르면 개발자의 작업에 심적인 마찰이 더해진다.
사용자 쪽에서 뭔가를 외어야 제대로 쓸 수 있는 인터페이스는 잘못 쓰기 쉽다
항목 13에서의 팩토리 함수를 보자. 이 함수는 Investment 클래스 계통에 속해 있는 어떤 객체를 동적 할당하고 그 객체의 포인터를 반환하는 함수다.
Investment* createInvestment();
이 함수를 사용할 때는 자원 누출을 막기 위해 얻어낸 포인터를 나중에라도 삭제해야 한다. 이 점 때문에 발생할 수 있는 실수가 있다. 포인터 삭제를 잊을 수 있고, 똑같은 포인터에 대해 delete가 두 번 이상 적용될 수 있다.
애초부터 팩토리 함수가 스마트 포인터를 반환하게 만드는게 좋다.
tr1::shared_ptr<Investment> createInvestment();
이렇게 해 두면, 반환 값은 shared_ptr에 넣어둘 수 밖에 없을 뿐더러, 이 객체를 삭제하는 것을 깜빡하고 넘어가는 불상사도 생기지 않을 것이다. shared_ptr은 삭제자를 직접 엮을 수 있기에 여러모로 인터페이스 설계에 좋다.
createInvestment를 통해 얻은 Investment* 포인터를 직접 삭제하지 않게 하고 getRidOfInvestement라는 이름의 함수를 준비해서 여기에 넘기게 하면 어떨까. 더 깔끔해 보이지만 자원 해제 메커니즘을 잘못 사용할 수 있기에 실수가 더 발생할 수 있다. createInvestment를 살짝 고쳐서, getRidOfInvestement가 삭제자로 묶인 shared_ptr을 반환하도록 구현해 둔다면 실수를 방지할 수 있다.
tr1::shared_ptr<Investment> createInvestment()
{
tr1::shared_ptr<Investment> retVal(static_cast<Investment*>(0), getRidOfInvestement);
retVal = ...;
return retVal;
}
위의 코드처럼 retVal을 널로 초기화하고 나서 나중에 대입하는 방법보다 실제 객체의 포인터를 바로 retVal의 생성자에 넘겨버리는게 더 낫다.
shared_ptr에는 엄청 좋은 특징이 하나 있다. 바로 포인터별(per-pointer) 삭제자를 자동으로 씀으로써 사용자가 저지를 수 있는 교차 DLL 문제를 미연에 없애 준다. 객체 생성 시에 어떤 동적 링크 라이브러리(dynamically lineked library: DLL 의 new를 썼는데 그 객체를 삭제할 때는 이전의 DLL과 다른 DLL에 있는 delete를 썼을 경우이다. 이렇게 new/delete 짝이 실행되는 DLL이 달라서 꼬이게 되면 대다수의 플랫폼에서 런타임 에러가 발생한다. 그런데 shared_ptr은 이 문제를 피할 수 있다. 이 클래스의 기본 삭제자는 shared_ptr이 생성된 DLL과 동일한 DLL에서 delete를 사용하도록 만들어져 있다.
이것만은 잊지 말자!
- 좋은 인터페이스는 제대로 쓰기엔 쉬우며 엉터리로 쓰기에 어렵다. 인터페이스를 만들 때는 이 특성을 지닐 수 있도록 고민하고 또 고민하자.
- 인터페이스의 올바른 사용을 이끄는 방법으로는 인터페이스 사이의 일관성 잡아주기, 그리고 기본제공 타입과의 동작 호환성 유지하기가 있다.
- 사용자의 실수를 방지하는 방법으로는 새로운 타입 만들기, 타입에 대한 연산을 제한하기, 객체의 값에 제약 걸기, 자원 관리 작업을 사용자 책임으로 놓지 않기가 있다.
- shared_ptr은 사용자 정의 삭제자를 지원한다. 이 특징 때문에 shared_ptr은 교차 DLL 문제를 막아 주며, 뮤텍스 등을 자동으로 잠금 해제하는 데(항목 14) 쓸 수 있다.
'C++ > Effective C++' 카테고리의 다른 글
[Effective C++] 20: '값에 의한 전달'보다는 '상수객체 참조자에 의한 전달' 방식을 택하는 편이 대개 낫다 (0) | 2023.10.17 |
---|---|
[Effective C++] 19: 클래스 설계는 타입 설계와 똑같이 취급하자 (0) | 2023.10.16 |
[Effective C++] 17: new로 생성한 객체를 스마트 포인터에 저장하는 코드는 별도의 한 문장으로 만들자 (0) | 2023.10.14 |
[Effective C++] 16: new 및 delete를 사용할 때는 형태를 반드시 갖추자 (1) | 2023.10.13 |
[Effective C++] 15: 자원 관리 클래스에서 관리되는 자원은 외부에서 접근할 수 있도록 하자 (2) | 2023.10.12 |