[Effective C++] 항목18 : 인터페이스 설계는 제대로 쓰기엔 쉽게, 엉터리로 쓰기엔 어렵게 하자

Jangmanbo·2023년 4월 5일
0

Effective C++

목록 보기
18/33

인터페이스

  • C++에서 함수, 클래스, 템플릿 등이 모두 인터페이스에 해당한다.
  • 인터페이스를 사용했는데 결과 코드가 사용자가 생각한 대로 동작하지 않는다면 애초에 컴파일되지 않아야 한다.
  • 반대로 어떤 코드가 컴파일된다면 사용자가 원하는 대로 동작해야 한다.

일관성 있는 인터페이스

그렇게 하지 않을 이유가 없다면, 사용자 정의 타입을 기본제공 타입처럼 동작하게 만들어야 한다.
이는 사용자에게 일관성 있는 인터페이스를 제공해야 하기 때문이다.

예를 들어 모든 STL 컨테이너는 size라는 멤버 함수를 제공한다. 만약 어떤 컨테이너는 size를, 또 어떤 컨테이너는 length를 제공한다면 개발자의 작업이 어려워진다. 또한 외워야 제대로 쓸 수 있는 인터페이스는 잘못 사용하기 쉽다.


class Date {
public:
	Date(int month, int day, int year);
    ...
};

// 사용자가 저지를 수 있는 실수 예시
Date d1(30, 3, 1995);	// 월, 일을 바꿔서 넣음
Date d2(3, 40, 1995);	// day에 있을 수 없는 숫자를 넣음

Date 클래스는 별 문제 없어 보이지만, 사용자가 쉽게 저지를 수 있는 실수를 방지할 수 없는 인터페이스다.

사용자의 실수를 사전에 차단하기

1. 타입 적용하기

struct Day {
	explicit Day(int d):val(d) {}
    int val;
};

struct Month {
	explicit Month(int d):val(d) {}
    int val;
};

struct Year {
	explicit Day(int d):Year(d) {}
    int val;
};

class Date {
public:
	Date(const Month& month, const Day& day, const Year& year);
    ...
};

Date d1(30, 3, 1995);	// Error. (타입이 틀림)
Date d2(Day(30), Month(3), Year(1995));	// Error. (타입이 틀림)
Date d3(Month(3), Day(30), Year(1995));	// Success.

이렇게 적절한 타입을 만들어두면, 각 타입의 값에 제약을 가하기도 좋다.

1-1. 각 타입의 값에 제약 걸기

예를 들어 Month의 경우 12개의 값만 가질 수 있도록 제약한다면, 유효한 Month의 집합을 미리 정의해둘 수 있다. (enum을 사용할 수도 있지만, 타입 안정성이 떨어지는 단점이 있다. enum<->int 변환이 쉬움)

class Month {
public:
	static Month Jan() { return Month(1); }
	static Month Feb() { return Month(1); }
	...
	static Month Dec() { return Month(1); }
	...
private:
	explicit Month(int m);	// 생성자를 private으로 선언하여 새로운 Month값이 호출되지 않도록 함
};

Date d(Month::Mar(), Day(30), Year(1995));

참고로 특정 월을 나타내는 데 객체가 아닌 함수를 쓰는 이유는 항목 4 비지역 정적 객체 초기화에서 알 수 있다.

2. const 붙이기

if (a * b = c) { ... }	// == 으로 비교하려던 했지만 사용자가 실수한 코드

항목 3에서 배웠듯이 operator*의 반환 타입을 const로 한정한다면 위의 코드는 애초에 컴파일되지 않는다.


3. 자원 관리를 사용자 책임으로 돌리지 않기

Investment* createInvestment();	// 팩토리 함수. 항목 13 참고

createInvestment를 사용한 후에는 자원 누출이 일어나지 않도록 반환값(Investment*)을 나중에 반드시 삭제해야 한다. 그러나 삭제하기 전에 에러가 발생할 수도, 깜박 잊을 수도 있기 때문에 반환값을 스마트 포인터에 저장해야 한다는 것까지 항목 13에서 배웠다.

그런데 이 스마트 포인터를 사용해야 한다는 것조차 사용자가 잊는다면 어떻게 해야 할까?
애초에 팩토리 함수가 스마트 포인터를 반환하게 만들면 된다.

std::shared_ptr<Investment> createInvestment();

이렇게 하면 나중에 Investment 객체가 필요 없어지더라도 이 객체가 생성될 때부터 스마트 포인터에 의해 관리되므로 삭제되지 않을 염려가 없다.

shared_ptr를 반환하는 구조는 자원 해제와 관련된 사용자의 실수를 사전 봉쇄하기에 좋아 여러모로 인터페이스 설계에 자주 쓰인다. 항목 14에서 배웠듯이 shared_ptr는 생성 시 삭제자를 지정할 수 있기 때문이다.


shared_ptr의 특징 : 삭제자

createInvestment에서 얻은 반환값 Investment* 포인터를 직접 삭제하지 않고 getRidOfInvestment라는 함수에게 넘겨서 삭제하면 어떨까?

이러한 인터페이스는 자원 해제 메커니즘을 잘못 사용하는 실수를 유발하기에 좋다. (ex. 어떨 때는 getRidOfInvestment를, 어떨 때는 delete를 사용)

이는 createInvestment가 그냥 shared_ptr이 아니라 getRidOfInvestment가 삭제자로 묶인 shared_ptr를 반환하도록 구현하면 해결된다.

// Error. 사용자 정의 삭제자를 가진 널 shared_ptr 생성하기
std::shared_ptr<Investment> pInv(0, getRidOfInvestment);

그러나 null 포인터를 가리키면서 getRidOfInvestment를 삭제자로 갖는 shared_ptr 생성을 시도했지만 컴파일 에러 발생.

이는 shared_ptr가 첫 번째 인자로 포인터를 받아야 하는데 int를 받았기 때문이다. 물론 0을 포인터로 변환할 수 있지만, shared_ptr<Investment>가 요구하는 포인터는 Investment* 타입의 포인터이므로 캐스트를 통해 명시적으로 변환해야 한다.

// Success.
std::shared_ptr<Investment> pInv(static_cast<Investment*> (0), getRidOfInvestment);

완성한 코드는 다음과 같다.

std::shared_ptr<Investment> createInvestment()
{
	// retVal 생성 시점
	std::shared_ptr<Investment> retVal(static_cast<Investment*> (0), getRidOfInvestment);
    
    // retVal이 가리킬 실제 객체를 결정하는 시점
    retVal = ...;
    
    return retVal;
}

이때 retVal로 관리할 실제 객체의 포인터를 결정하는 시점이 retVal의 생성 시점보다 앞설 수 있다면, 위의 코드보다는 실제 객체의 포인터를 바로 retVal의 생성자에 넘기는 것이 더 낫다. (항목 26)

per-pointer deleter

shared_ptr는 포인터별 삭제자(per-pointer deleter)를 자동으로 사용함으로써 교차 DLL 문제를 미연에 방지한다.
shared_ptr의 기본 삭제자는 shared_ptr가 생성된 DLL과 동일한 DLL에서 delete를 사용하도록 설계되어 있기 때문이다.

std::shared_ptr<Investment> createInvestment()
{
	// Stock은 Investment의 파생 클래스
    return shared_ptr<Investment> (new Stock);
}

createInvestment가 이와 같이 구현되어 있다면,
이 함수가 반환하는 shared_ptr는 다른 DLL들 사이에서 쓰이게 되더라도 어떤 DLL의 delete를 사용해야 하는지를 꼭 붙들고 있다.
따라서 교차 DLL 문제를 걱정하지 않아도 된다.

교차 DLL 문제 (cross-DLL problem)


정리

  • 좋은 인터페이스 : 제대로 쓰기에 쉬우며 엉터리로 쓰기는 어려운 인터페이스
  • 좋은 인터페이스를 만드는 방법
    • 인터페이스 사이의 일관성 유지
    • 기본제공 타입과의 동작 호환성 유지
  • 사용자의 실부를 방지하는 방법
    • 새로운 타입 만들기
    • 타입에 대한 연산 제한하기
    • 객체의 값에 대해 제약 걸기
    • 자원 관리 작업을 사용자 책임으로 놓지 않기
  • shared_ptr은 사용자 정의 삭제자를 지원
    • 교차 DLL 문제 방지
    • 자동으로 잠금 해제하기(ex. 뮤텍스, 항목 14 참고)에 용이

0개의 댓글