Notice
Recent Posts
Recent Comments
Link
«   2026/05   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
31
Archives
Today
Total
관리 메뉴

로또

18. 인터페이스 설계는 제대로 쓰기엔 쉽게, 엉터리로 쓰기엔 어렵게 하자. 본문

책/Effective C++

18. 인터페이스 설계는 제대로 쓰기엔 쉽게, 엉터리로 쓰기엔 어렵게 하자.

아롱로또 2023. 11. 14. 16:33

다루는 내용

우리가 만든 인터페이스를 누군가가 사용할 때, 잘못 사용했을 경우에 틀렸음을 알려주어야 하며, 사용자가 작성한 코드가 생각한 대로 동작하지 않는다면 그 코드는 컴파일되어서는 안된다. 반대로, 어떤 코드가 컴파일된다면 그 코드는 사용자가 원하는 대로 동작해야 할 것이다. 사용자가 저지를만한 실수를 알아두어 제대로 쓰기에 쉽고, 엉터리로 쓰기에 어려운 인터페이스 개발 방법을 알아보자.

 

Type 시스템 이용하기

날짜를 다루는 클래스 Date의 생성자를 설계해보자.

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

 

해당 클래스를 사용하는 사용자는, 다음과 같은 실수를 저지를 수 있다.

  • 매개 변수의 전달 순서 : Date(2012, 5, 20)      2012년 5월 20일은 로또의 생일이다.
  • 범위에 맞지 않는 숫자 : Date(5, 200, 2012)

이러한 실수를 저지른다 해도, 컴파일러는 문제없이 컴파일할 것이다. 이를 막기 위해 Type 시스템을 사용할 수 있다.

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& day, const Year& year);
    ...
};

int main(){
    Date d(5, 12, 2012); // Type Error (int type 사용)
    Date d(Day(12), Month(5), Year(2012)); // Type Error (매개변수 순서 오류)
    Date d(Month(5), Day(12), Year(2012)); // 컴파일 성공
}

위 코드와 같이 년, 월, 일에 따른 적절한 Type을 준비하기만 해도 잘못된 사용을 막을 수 있다. 이에 더해 각 Type에 대해 값의 범위를 제한하는 방법도 가능할 것이다.

 

Month Type은 '월'에 대해 유효한 값이 12개뿐이므로, 값에 대해 제약을 둘 수 있다.

enum을 사용하는 방법도 존재하지만, 때로는 int처럼 사용될 수 있어 Type 안전성은 믿음직하지 못하다. 대신, 유효한 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 멤버로 둔다.
    int val;
};

int main(){
    Date d(Month::May(), Day(12), Year(2012));
}

 

별다른 이유가 없다면, 사용자 정의 타입은 기본제공 타입처럼 동작하게 만들자.

예상되는 사용자 실수를 막기 위해 Type에 제약을 부여하여 해당 Type을 통해 할 수 있는 일들을 금지할 수도 있다.

그 예시로, operator*의 return type에 const를 부여한 것이다.

#include <iostream>
using namespace std;
int main(){
    int a = 10;
    int b = 10;
    int c = 100;
    if(a * b = c){ // 원래 비교를 위한 코드
        cout << "Same" << endl;        
    }
    else{
        cout << "Not Same" << endl;
    }
}

 

컴파일 결과

 

위 코드에서, a와 b가 int 라면 a * b에 값을 대입하는 행동은 말이 되지 않는다. 사전에 operator*가 const type를 반환함으로써 이러한 문장에서 컴파일 오류를 낼 수 있다.

 

int type은 대부분의 사용자가 그 성질을 알고 있다.

우리가 만드는 사용자 정의 타입도 별다른 이유가 없다면 int type처럼 동작하게 만들자.

 

기본제공 타입과 어긋나는 동작을 피하는 이유는 일관성 있는 인터페이스를 적용하기 위해서이다. 우리가 자주 사용하는 STL도 완벽하진 않지만 일관성을 갖고 있다. 예를 들어, 모든 STL 컨테이너는 size() 라는 이름을 갖는 멤버 함수를 가져 현재 컨테이너에 들어있는 원소의 개수를 알려준다.

 

shared_ptr는 사용자가 저지를 수 있는 실수를 쉽게 제거해준다.

Investment* CreateInvestment();

항목 13에서 예시로 사용했던 팩토리 함수이다. Investment 클래스 계통에 속해 있는 어떤 객체를 동적 할당하고 그 객체의 포인터를 반환한다.

해당 함수를 사용할 때 사용자는 자원 누출을 피하기 위해 사용한 포인터를 사용이 끝난 후 delete해야 한다. 이러한 점은 사용자가 다음과 같은 실수를 저지를 가능성을 만든다.

  • 포인터를 사용한 후, delete를 잊는다.
  • 동일한 포인터에 대해 delete를 두 번 이상 적용한다. 

이러한 가능성을 제공하는 대신, 다음과 같이 스마트 포인터를 사용해 사용자가 실수를 저지를 가능성을 제거하자.

shared_ptr<Investment> CreateInvestment();

 

스마트 포인터를 반환하는 구조는 자원 해제에 관련된 사용자의 실수를 사전 봉쇄할 수 있어 여러므로 좋은 방법이다.

 

 

 

이러한 가정도 해보자.

CreateInvestment()를 통해 얻은 포인터를 직접 삭제하지 않고, GetRidOfInvestment() 함수를 준비해 넘기도록 하면 어떨까?

깔끔해 보이지만, 이러한 인터페이스는 사용자에게 역시 다음과 같은 실수를 할 가능성을 준다.

  • GetRidOfInvestment 함수 대신 delete를 사용한다.

shared_ptr는 생성 시점에 다음과 같이 deleter(삭제자)를 직접 엮을 수 있다. 이러한 특징을 사용해 GetRidOfInvestment 함수를 deleter로 가지는 shared_ptr를 return하도록 변경할 수 있다.

shared_ptr<Investment> CreateInvestment(){
    shared_ptr<Investment> ret_val(new Investment, GetRidOfInvestment);
    return ret_val;
}

 

shared_ptr는 포인터별 삭제자를 자동으로 사용하여 교차 DLL(Dynamically Linked Library) 문제를 해결할 수 있다. 

객체 생성 시에 어떤 DLL의 new를 사용했는데, 해당 객체를 삭제할 때는 new할 때와는 다른 DLL의 delete를 사용하는 것이다.  new / delete 쌍이 일치하지 않으므로 대다수의 플랫폼에서 런타임 에러를 유발한다.

하지만 shared_ptr의 기본 삭제자는 shared_ptr이 생성된 DLL과 동일한 DLL에서 delete를 사용하도록 만들어져 있으므로 이러한 문제를 피할 수 있다.

 

shared_ptr의 사용은 사용자가 저지를 수 있는 실수 몇 가지를 쉽게 없앰으로써 "제대로 쓰기엔 쉽고 엉터리로 쓰기엔 어려운 인터페이스"를 만드는 데 쉽게 다가갈 수 있다.

참고로 shared_ptr는 크기가 원시 포인터의 두 배이고 내부 관리 데이터 및 삭제자 매커니즘을 돌릴 데이터를 위해 동적 할당 메모리를 사용하며, 멀티스레드로 돌아가는 프로그램을 지원할 경우에는 참조 카운트를 변경할 때 스레드 동기화 오버헤드를 일으킨다.(비활성화 가능) 즉, 원시 포인터보다 크고 느리며 내부 관리용 동적 메모리까지 추가된다는 것이다.

그럼에도 불구하고 런타임 비용이 눈에 띄게 늘어나지 않음과 동시에 사용자 실수를 크게 줄여주어 사랑받고 있다.

 

이것만은 잊지 말자!

  • 좋은 인터페이스는 제대로 쓰기에 쉬우며 엉터리로 쓰기에 어렵습니다. 인터페이스를 만들 때는 이 특성을 지닐 수 있도록 고민하고 또 고민합시다.
  • 인터페이스의 올바른 사용을 이끄는 방법으로는 인터페이스 사이의 일관성 잡아주기, 그리고 기본제공 타입과의 동작 호환성 유지하기가 있습니다.
  • 사용자의 실수를 방지하는 방법으로는 새로운 타입 만들기, 타입에 대한 연산을 제한하기, 객체의 값에 대해 제약 걸기, 자원 관리 작업을 사용자 책임으로 놓지 않기가 있습니다.
  • shared_ptr은 사용자 정의 삭제자를 지원합니다. 이 특징 때문에 shared_ptr는 교차 DLL 문제를 막아주며, 뮤텍스 등을 자동으로 잠금 해제하는 데 쓸 수 있다.