z Chapter 4. Designs and Declarations :: C++, 그래픽 기술 블로그

18. Make interfaces easy to use correctly and hard to use incorrectly

제대로 쓰기에는 쉽고 엉터리로 쓰기에는 어려운 인터페이스를 개발하려면 사용자가 저지를 수 있는 실수의 종류를 머리에 생각해두고 있어야 합니다. 예시로 날짜를 입력 받는 인터페이스가 있다면, 이를 단순히 int로 받기 보다는 새로운 데이터 구조를 만들어 확실하게 명시하여 넣어주도록 유도할 수 있습니다. 또한 이를 위해 유효한 집합을 미리 정의 해두어 이를 사용하게도 만들 수 있습니다.

 그 외로는 if (a * b = c)와 같은 이상한 코드를 짤 수도 있는데 이 때 operator*의 반환 값을 const로 제한하지 않는다면 이는 컴파일될 수도 있습니다. 특별하지 않다면 연산은 보통 int의 동작 원리대로 만들면 됩니다. 기본 제공 타입과 쓸데없이 어긋나는 동작을 피하는 실질적인 이유는 일관성 있는 인터페이스를 제공하기 위해서 입니다.

 팩토리 함수를 사용하여 포인터를 만들 때 할당된 공간을 해제해주어야만 합니다. 이 때 shared_ptr로 값을 바로 넣어주게 된다면, 이 shared_ptr이 소멸할 때 할당된 공간도 적절하게 해제해줄 것입니다. 이러한 shared_ptr은 사용자 정의 삭제자를 지원하는데, 이 특징 때문에 교차DLL 문제를 막아줄 수 있습니다.

[교차 DLL 문제(cross-DLL problem)]

객체 생성 시에 한 동적 링크 라이브러리의 new를 사용하였는데, delete는 또 다른 라이브러리에서 가져온 경우에 발생합니다. new/delete 짝이 꼬여버리기 떄문인데, 이를 shared_ptr을 사용한다면 new/delete가 같은 라이브러리에 존재해야하기 떄문에 이런 문제가 해결됩니다.

이것만은 잊지 말자!

  • 좋은 인터페이스는 제대로 쓰기에 쉬우며 엉터리에 쓰기에 어렵습니다. 인터페이스를 만들 때는 이 특성을 지닐 수 있도록 고민하고 또 고민합시다.
  • 인터페이스 사이의 일관성 잡아주기, 그리고 기본제공 타입과의 동작 호환성 유지하기가 좋은 인터페이스로 이끕니다.
  • 사용자의 실수를 막기 위해서는 새로운 타입 만들기, 연산 제한, 값 제한, 작원 관리 작업을 사용자 책임으로 놓지 않기 등이 있습니다.

19. Treat class design as type design

클래스 설계는 타입 설계와 비슷하게 생각을 해야하는데 다음과 같은 주의점들이 존재합니다.

  • 새로 정의한 타입의 객체 생성 및 소멸은 어떻게 이루어져야 하는가?
  • 객체 초기화는 객체 대입과 어떻게 달라야 하는가?
  • 새로운 타입으로 만든 객체가 값에 의해 전달되는 경우에 어떤 의미를 줄 것인가?
  • 새로운 타입이 가질 수 있는 적법한 값에 대한 제약은 무엇으로 잡을 것인가?
  • 기존의 클래스 상속 계통망에 맞출 것인가?
  • 어떤 종류의 타입 변환을 허용할 것인가?
  • 어떤 연산자와 함수를 두어야 의미가 있을까?
  • 표준 함수들 중 어떤 것을 허용하지 말 것인가?
  • 새로운 타입의 멤버에 대한 접근권한을 어느 쪽에 줄 것인가?
  • '선언되지 않은 인터페이스'로 무엇을 둘 것인가?
  • 새로 만드는 타입이 얼마나 일반적인가?
  • 정말로 꼭 필요한 타입인가?

이것만은 잊지 말자!

  • 클래스 설계는 타입 설계입니다. 위의 항목들로 체크를 해보는 것이 좋습니다.

20. Prefer pass-by-reference-to-const to pass-by-value

C++은 함수로부터 객체를 ㅓㄴ달받거나 함수에 객체를 전달할 때 값에 의한 전달 방식을 사용합니다. 이 때 함수의 매개변수는 실제 인자의 사본을 통해 초기화되며, 호출한 쪽은 리턴 값의 사본을 돌려받게 됩니다. 이를 만드는 것이 복사 생성자인데, 이는 비용이 비싼 동작입니다. 또한 그 안에 인자가 많고, string과 같은 객체가 존재할 떄는 더더욱 그렇습니다. 하지만 bool validate(const Student& s)와 같이 const 참조 객체로 받게 된다면, 이를 복사하지도 않을 뿐더러, 참조 받은 객체에 변화를 주는 것이 불가능하기 때문에 안전합니다.

 참조에 의한 전달 방식으로 매개변수를 넘기면 복사손실 문제도 해결이 됩니다. 파생 클래스 객체가 기본 클래스 객체로서 넘어가는 경우 기본 클래스의 복사 생성자가 발생하게 되고 여기서 파생 클래스 객체로서 동작하게 해주는 특징들이 잘려나가게 됩니다. 복사 손실 문제에서 도망가려면 위와 같이 상수 객체에 대한 참조를 수행하면 됩니다.

 특정 객체의 복사 비용이 작더라도 이를 고려해봐야하는 이유는 많습니다. 먼저 string과 같은 객체는 구현에 따라 그 크기가 최고 7배 차이 날 수도 있을 뿐더러,  컴파일러 중에서는 기본제공 타입과 사용자 정의 타입을 아예 다르게 취급하는 것들이 존재하기 때문에 좀 더 고려를 해보야아만 합니다.

이것만은 잊지 말자!

  • 값에 의한 전달보다는 상수 객체 참조자에 의한 전달을 선호합시다. 대체적으로 효율적일 뿐만 아니라 복사손실 문제까지 막아줍니다.
  • 이번 항목에서 다룬 법칙은 기본제공 타입 및 STL 반복자, 그리고 함수 객체 타입에는 맞지 않습니다. 이들에 대해서는 값에 의한 전달이 더 적절합니다.

21. Don’t try to return a reference when you must return an object

class Rational {
public:
    Rational (int numerator =0, int denomiator = 1);
private:
    int n, d;
friend:
    const Rational operator*(const Rational& lhs, const Rational& rhs);
};

 간단하게 생각했을 때 operator*이 반환하는 값이 Rational의 값을 반환하기에 이를 참조를 사용한다면 복사 비용을 줄일 수 있을 것만 같습니다. 하지만 참조를 이용하여 보내는 경우에는 어떠 한 Rational 객체를 참조하고 있어야 하는데, 함수에서 만든 값은 operator* 함수 스택에 쌓여있고 이는 함수가 종료될 때 메모리가 해제되게 됩니다. 따라서 온전한 Rational 객체에 대한 참조자를 반환하지 않기 떄문에 문제가 발생합니다. 만약 이 값을 힙 영역에 할당하여 반환한다고 했을 경우는, w = x * y * z 처럼 연속적으로 값을 받아서 실행하는 경우에는 메모리 누수가 발생하고 만다. 또한 정적 객체를 사용하게 된다면 operator==를 통해 이를 결과를 유도하는 경우 같은 결과가 나오게 하기 떄문에 이도 사용 불가능하다.

따라서 실제적으로 할 수 있는 함수 구현은 다음과 같습니다

inline const Rational operator*(const Rational& lhs, const Rational &rhs)
{
    return Rational(lhs.n * rhs.n, lhs.d & rhs.d);
}

와 같이 구현 가능합니다. 이는 C++ 컴파일러에서 기존 코드의 수행 성능을 높이는 최적화를 통해 operator* 반환 값에 대한 복사가 일어나지 않고 이 자체를 반환하도록 만들어 줍니다.

이것만은 잊지 말자!

  • 지역 스택 객체에 대한 포인터나 참조자를 반환하거나 힙에 할당된 객체에 대한 참조자를 반환하거나 지역 정적 객체에 대한 포인터나 참조자를 반환하는 일은 하지 말아야 합니다.

22. Declare data members private

데이터 멤버가 public이면 안되는 이유가 무엇일까에 대해서 고찰을 해봅시다. 접근 제어를 하는 flag를 세우고 이를 통해 데이터 접근을 제어할 수 있습니다.

 데이터 멤버를 인터페이스 뒤에 감추게 되면, 구현상의 융통성을 전부 누리기 쉬워집니다. 데이터를 사용 및 변경에 있어 알림, 사전 조건, 사후조건 등 스레딩 환경에서 동기화를 거는 등의 여러 가지를 수행하기 쉬워집니다.

 게다가 보통 C++ 세상에서 public은 캡슐화되지 않았다는 것을 의미하며 이는 Protected에 대해서도 비슷하게 돌아갑니다. 어떤 클래스가 public 데이터 멤버가 있고 이를 제거할 때 이를 사용하는 사용자 코드는 전부 무사할 수 없을 것입니다. 이는 protected에서도 비슷하게 돌아갑니다. 따라서 캡슐화의 관점에서 쓸모 있는 접근 수준은 private이며 그 외는 캡슐화가 없다고 봐도 무방합니다.

이것만은 잊지 말자!

  • 데이터 멤버는 private 멤버로 선언합시다. 이를 통해 클래스 제작자는 문법적으로 일관성 있는 데이터 접근 통로를 제공할 수 있고, 필요에 따라서는 세밀한 접근 제어도 가능하며, 클래스의 불변속성을 강화할 수 있을 뿐만 아니라, 내부 구현의 융통성도 발휘할 수 있습니다.
  • protectedsms public보다 더 많이 보호받고 있지 않습니다.

23. Prefer non-member non-friend functions to member functions

class WebBrowser {
public:
    void clearCache();
    void clearHistory();
    void removeCookies();
    void clearEverything(); // clearCache, clearHistory, removeCookies 호출
};

void clearBrowser(WebBrowser& wb)
{
    clearCache();
    clearHistory();
    removeCookies();
};

위와 같이 한 클래스에 대해서 다른 함수를 부르는 멤버 함수와 비멤버 비프렌드 함수 두개를 구현하였습니다. 이 때 어떤 함수가 더 유용할 까요? 보통 멤버 함수가 더 낫다고 생각될 수 있지만, 멤버 버전이 비멤버 버전보다 캡슐화 정도에서 오히려 형편없습니다. 또한 비멤버 함수를 사용하면 WebBrowser 관련 기능을 구성하는 데 있어서 패키징 유연성이 높아지는 장점도 있습니다. 이로 인해 컴파일 의존도도 낮추고 확장성도 높일 수 있습니다.

 데이터를 직접 볼 수 있는 코드가 적으면 캡슐화가 잘 되었다고 합니다. 캡슐화 입장에서 보면 멤버 함수는 private 자료 멤버에 접근할 수 있고 비멤버인 경우는 그렇지 못하기 때문에 비멤버 비프렌드 함수 쪽이 좀 더 캡슐화가 잘 되어 있다고 볼 수 있습니다. 이를 좀 더 자연스럽게 구성하기 위해서는 같은 namespace에 넣어 구현하는 것도 좋습니다. 이는 파일을 구성할 떄도 좋은데, 클래스를 구성하는 소스 파일을 두고 이들을 참조하는 클래스의 특정 부분을 사용하는 비멤버 함수들을 각각 파일을 나누어서 저장할 수 있습니다. 이는 C++ 표준 라이브러리가 구성된 방식이며 이렇게 하면 사용자가 실제 사용하는 구성요소에 대해서만 컴파일 의존성을 고려할 수 있게 됩니다. 또한 편의 함수 집합의 확장도 손쉽게 이루어질 수 있습니다.

이것만은 잊지 말자!

  • 멤버 함수보다는 비멤버 비프렌드 함수를 자주 쓰도록 합시다. 캡슐화 정도가 높아지고 패키징 유연성도 커지며, 기능적인 확장성도 늘어납니다.

24. Declare non-member functions when type conversions should apply to all parameters

class Rational {
public:
    Rational (int numerator =0, int denomiator = 1);
    int numerator() const;
    int denominator() const;
    
    const Rational operator*(const Rational& rhs) const; ---- (1)
private:
    ...
};

const Rational operator*(const Rational& lhs, const Rational& rhs)
{
    return Rational(lhs.numerator() * rhs.numerator(), lhs.denominator() * rhs.denominator()); --- (2)
};

다시 Rational 객체를 생각해봅시다. 먼저 암시적 타입 변환을 지원하는 것은 별로 좋지 않지만 숫자 타입을 만들 때는 예외로 생각됩니다. 유리수를 나타내는 클래스이니 수치 연산을 기본적으로 지원하고 싶지만 이 때 비멤버 함수를 이용할 지 멤버 함수를 이용할 지가 큰 고민이 됩니다. 

 만약 (1)과 같이 멤버 함수로 정의하였다고 생각해봅시다. 이떄 우리는 생성자에 explicit를 쓰지 않아 암시적 생성을 지원하게 됩니다. 기본적인 연산은 지원하게 되지만,  곱셈의 교환 법칙이 성립하지 않게 됩니다. 코드는 아래와 같습니다

Rational oneHalf;

result = oneHalf * 2;           // Good!
result = 2 * oneHalf;           // Error!
result = oneHalf.operator*(2);  // == oneHalf * 2
result = 2.operator*(oneHalf);  // == 2 * oneHalf

이처럼 수행된 경우 Rational 객체가 뒤에 온 경우에 대해서는 제대로 연산이 수행되지 않는다. 또한 앞을 int로 받되 뒤를 Rational 객체를 받는 함수도 존재하지 않게 된다. 2가 뒤에 오는 경우에는 operator*에 맞도록 int가 Rational로 암시적 변환을 하면 수행하지만, 반대인 경우는 접근할 수 있는 함수 중에서 해당 함수 자체를 찾지 못하기 때문에 암시적 타입 변환이 일어나지 않습니다. 따라서 이를 위해서는 (2)와 같이 비멤버 함수를 통해서 operator*에 접근할 수 있도록 돕고, 이에 맞게 암시적 타입 변환이 일어날 수 있도록 합니다.

이것만은 잊지 말자!

  • 어떤 함수에 들어가는 모든 매개변수에 대해 타입 변환을 해줄 필요가 있다면, 그 함수는 비멤버이어야 합니다.

25. Consider support for a non-throwing swap

swap은 구현 코드를 보면 알겠지만 복사만 제대로 지원하면 어떤 타입의 객체이든 맞바꾸기 동작을 수행해 줍니다. 하지만 객체의 복사 비용이 비싸다면 swap을 사용할 때의 비용도 비쌀 것입니다. 따라서 pimpl 관용구(pointer to implementation idiom)을 사용하여, 설계하면 swap에 대한 비용을 줄일 수 있습니다.

class WidgetImpl {
private:
    int a, b, c;
    std::vector<double> v;
};

class Widget {
public:
    Widget(const Widget& rhs);
    Widget& operator=(const Widget& rhs)
    {
        *pImpl = *(rhs.pImpl);
    }
private:
    WidgetImpl *pImpl;
};

다음과 같이 실질적인 내용에 해당하는 WidgetImpl의 복사 비용은 비쌉니다. 하지만 std::swap을 수행할 때 Widget 자체의 복사를 수행하기 보다는 포인터만 교환한다면 실질적으로 swap이 일어난 것과 다르지 않습니다. 이는 std::swap에 대해 템플릿 특수화를 수행해주어 Widget이 들어가는 경우에 대해서는 특별하게 Widget의 멤버 함수 swap을 수행하도록 하고 Widget 클래스에서는 다음과 같이 따로 swap 함수를 구현해주면 됩니다.

class Widget {
public:
    void swap(Widget& other)
    {
        using std::swap;
        
        swap(pImpl, other.pImpl);
    }
};

namespace std {
    template<>
    void swap<Widget>l (Widget& a, Widget& b)
    {
        a.swap(b);
}

Widget이 클래스 템플릿으로 이루어진 경우에는 또 다른 문제를 발생시킵니다. 템플릿 특수화도 안될 뿐더러 std namespace 영역에서의 overloading도 불가능합니다. 이를 위해서는 비멤버 템플릿 함수로 swap 함수를 구현하고 이를 템플릿 특수화시킨 swap에서 인자 기반 탐색을 통해 찾도록 만듭니다. 이는 다음과 같이 구현됩니다.

template<typename T>
class WidgetImpl {...};

template<typename T>
class Widget {...};

template<typename T>
void swap(Widget<T> &a, Widget<T> &b)
{
    a.swap(b);
};

 이때까지는 swap을 구현하는데에 중점을 두었지만, 함수 템플릿 중에 swap을 쓰고 싶은 경우, 타입 T 전용 버전이 존재한다면 그것이 호출되도록 하고, T 타입 전용 버전이 없으면 std의 일반형 버전이 호출되도록 만들고 싶은 경우에는 다음과 같이 std::swap을 함수 안에서 쓴다는 것을 명시하고 사용하도록 합니다.

template<typename T>
void doSomething(T& obj1, T& obj2)
{
    using std::swap;
    ...
    swap(obj1, obj2);
    ...
};

이렇게 한다면 탐색 순서는

  1. 전역 유효범위 혹은 타입 T와 동일한 namespace 안에 T 전용의 swap 존재 여부를 검색
  2. T 전용 swap이 존재하지 않는다면 using std::swap 호출을 통한 std의 swap 호출 

이것만은 잊지 말자!

  • std::swap이 여러분의 타입에 대해 느리게 동작할 여지가 있다면 swap 멤버 함수를 제공합시다. 이 멤버 swap은 예외를 던지지 않도록 만듭시다.
  • 멤버 swap을 제공했으면, 이 멤버를 호출하는 비멤버 swap도 제공합니다. 클래스에 대해서는 std::swap도 특수화해 둡시다.
  • 사용자 입장에서 swap을 호출할 때는, std::swap에 대한 using 선언을 넣어 준 후에 namespace 한정 없이 swap을 호출합시다.
  • 사용자 정의 타입에 대한 std 템플릿을 완전 특수화하는 것은 가능합니다. 그러나 std에 어떤 것이라도 새로 추가하려고 들지는 마십시오.

+ Recent posts