관리 메뉴

KoreanFoodie's Study

Effective C++ 정리 4-2 : 설계 및 선언 본문

Tutorials/C++ : Advanced

Effective C++ 정리 4-2 : 설계 및 선언

머니덕 2022. 9. 15. 16:00

C++ 프로그래머의 필독서이자 바이블인, 스콧 마이어스의 Effective C++ 를 읽고 기억할 내용을 요약하고 있습니다. 꼭 읽어보시길 추천드립니다!


항목 22 : 데이터 멤버가 선언될 곳은 private 영역임을 명심하자

핵심 :

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

 

먼저, 데이터 멤버에 public 대신 private 을 써야 하는 이유를 알아보자.

  1. 문법적 일관성 (콤마를 붙여야 하는지, 괄호를 붙여야 하는지 헷갈리지 않음)
  2. 데이터 멤버의 접근성을 세밀하게 제어할 수 있음
  3. 캡슐화(Encapsulation)

 

어떤 것이 바뀌면 깨질 가능성을 가진 코드가 늘어날 때 캡슐화의 정도는 그에 반베례해서 작아진다. 즉, 해당 데이터 멤버를 수정하거나 삭제했을 때, 깨질 수 있는 코드의 양에 반비례해서 그 데이터멤버는 캡슐화 정도가 감소한다는 것이다.

그리고 충격적이게도, protected 는 public 보다 캡슐화를 더 많이 한다고 보기 힘들다.

만약 public  데이터 멤버를 제거한다면, 얼마나 많은 코드가 깨질까? 파악하기 힘들만큼 많을 것이다. 그렇다면 protected 데이터 멤버를 제거하면..? public 의 경우만큼이나 많을 것이다. protected 의 경우, 파생 클래스까지 다 살펴보려면 public 과 오십보 백보라는 뜻이다.

따라서 캡슐화의 관점에서 쓸모 있는 접근 수준은 private(캡슐화 제공) 과 private 가 아닌 나머지(캡슐화 없음), 이렇게 둘 뿐이다!

 

 

항목 23 : 멤버 함수보다는 비멤버 비프렌드 함수와 더 가까워지자

핵심 :

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

 

웹 브라우저에서 캐시를 비우는 함수, URL 기록을 없애는 함수, 쿠키를 제거하는 함수가 있다고 하자. 세 동작을 동시에 하도록 만드는 함수를 정의할 때, 멤버 함수로 만드는 것이 좋을까, 비멤버 함수로 제공하는 것이 더 좋을까?

class WebBrowser
{
  public:
    void clearCache();
    void clearHistory();
    void removeCookies();
    
    // 1. 멤버함수 - 위 함수 3개 호출
    void clearEverything();
};

// 2. 비멤버함수 - 함수 3개 호출
void clearBrowser(WebBrowser& wb)
{
  wb.clearCache();
  wb.clearHistory();
  wb.removeCookies();
}

일반적으로 2번, 비멤버함수를 사용하는 것이 더 낫다!

 

일단, 캡슐화의 관점에서 보면 clearBrowser 를 비멤버함수로 두는 것이 더 낫다. 왜냐면 멤버함수가 아니기 때문에 수정 및 삭제 문제로부터 자유롭기 때문이다.주의해야 할 부분은, 위 내용은 '비프렌드' 함수에만 적용된다는 것이다. 프렌드 함수일 경우, 캡슐화에 영향을 끼칠 것이기 때문이다. 또한 해당 함수가 다른 클래스의 멤버가 될 수도 있고, 아닐 수도 있다는 것이다. 다음처럼 구현하면 매우 자연스러울 수 있다.

namespace WebBrowserStuff
{
  class WebBrowser { ... };
  void clearBrowser(WebBrowser& wb);
  ...
}

namespace 를 활용해서, 여러 파일에서 필요한 부분들로 함수를 쪼개 쓰면 된다! 예를 들어, 웹 브라우저 관련 기능을 추가 하고 싶으면 헤더 파일을 하나 만들고, WebBrowswerStuff 네임스페이스 를 만들어서 관련 함수를 작성하기만 하면 된다(확장성)!

 

 

항목 24 : 타입 변환이 모든 매개변수에 대해 적용되어야 한다면 비멤버 함수를 선언하자

핵심 :

어떤 함수에 들어가는 모든 매개변수(this 포인터가 가리키는 객체도 포함해서) 에 대해 타입 변환을 해 줄 필요가 있다면, 그 함수는 비멤버이어야 한다.

 

유리수를 나타내는 클래스가 있다고 하고, 곱셉 연산을 만든다고 가정하자.

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

int Rational::numerator() const
{
  return n;
}

int Rational::denominator() const
{
  return d;
}

Rational::Rational(int numerator, int denominator)
{
  n = numerator;
  d = denominator;
}

const Rational Rational::operator*(const Rational& rhs) const
{
  return Rational(this->n*rhs.numerator(), this->d*rhs.denominator());
}

void printRational(const Rational& r)
{
  cout << r.numerator() << " / " << r.denominator() << endl;
}


int main()
{
  Rational oneEight(1, 8);
  Rational oneHalf(1, 2);
  
  Rational result = oneHalf * oneEight;
  printRational(result);

  return 0;
}

위 클래스는 다음과 같은 케이스를 처리할 수 없다!

// 이건 잘 된다
result = oneHalf * 2;
// 다음과 같이 변형
result = oneHalf.operator*(2);
// 암시적 변환 : 2 를 const Rational temp(2) 처럼...
result = oneHalf * temp;

// 이건 안 된다
result = 2 * oneHalf;
// 다음과 같이 변형 (정수의 operator* 멤버 함수 따위 없음)
result = 2.operator*(oneHalf);
// 컴파일러의 탐색.. 혹시 이런 게 있나? -> 탐색 실패!
result = operator*(2, oneHalf);

위의 코드 블록에서처럼, 혼합형 연산(e.g. 정수 * 유리수 클래스 연산)을 지원할 수 있도록 만드려면 다음과 같이 비멤버 함수를 선언해주면 된다.

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

위의 함수는 지금의 예제에서는 프렌드 함수로 두면 안된다. 왜냐하면 Rational 클래스의 public 인터페이스만을 이용해서 구현할 수 있기 때문이다.

즉, "멤버 함수의 반대는 프렌드 함수가 아니라 비멤버 함수이다" 라는 결론을 기억하자! 프렌드 함수를 남용하지 않도록 주의하는 것은 덤이다.

 

 

항목 25 : 예외를 던지지 않는 swap 에 대한 지원도 생각해 보자

핵심 :

1. std::swap 이 사용자 정의 타입에 대해 느리게 동작할 여지가 있다면 swap 멤버 함수를 제공하자. 이 멤버 swap 은 예외를 던지지 않도록 만들자.
2. 멤버 swap 을 제공했으면, 이 멤버를 호출하는 비멤버 swap 도 제공하자. 클래스(템플릿이 아닌)에 대해서는, std::swap 도 특수화해 두자.
3. 사용자 입장에서 swap 을 호출할 때는, std::swap 에 대한 using 선언을 넣어 준 후에 네임스페이스 한정 없이 swap 을 호출하자.
4. 사용자 정의 타입에 대한 std 템플릿을 완전 특수화하는 것은 가능하다. 그러나 std 에 어떤 것이라도 새로 '추가' 하려고 들지는 말자.

 

먼저 표준 라이브러리에서 제공하는 기본 swap 알고리즘 구현을 보자.

namespace std
{
  template<typename T>
  void swap(T& a, T& b)
  {
    T temp(a);
    a = b;
    b = temp;
  }
}

위의 코드는 복사 생성자 및 복대 대입 연산자를 호출한다.

손해를 줄이기 위해, 포인터 복사를 응용한 pimpl 기법을 보자(pointer to implementation).

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

class Widget
{
  public:
    Widget(const Widget& rhs);
    // Widget 복사를 위해 포인터를 복사
    Widget& operator=(const Widget& rhs)
    {
      *pImpl = *(rhs.pImpl);
    }
  private:
    // Widget 의 실제 데이터를 가진 객체에 대한 포인터
    WidgetImpl *pImpl;
};

std::swap 을 특수화하면, 위의 WidgetImpl 을 이용한 효율적 swap 을 구현할 수 있다.

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

하지만 위 코드는 컴파일이 안된다. 왜냐하면 pImpl 이 private 이기 때문이다. Widget 클래스를 다음과 같이 조금 고쳐보자.

class Widget
{
  public:
    void swap(Widget& other)
    {
      // 해당 선언문은 왜 필요할까? (조금 이따가 공개...)
      using std::swap;
      swap(pImpl, ohter.pImpl);
    }
  
  ...  
    
  private:
    WidgetImpl *pImpl;
};

// swap 특수화 버전도 살짝 수정한다
namespace std
{
  template<>
  void swap<Widget>(Widget& a, Widget& b)
  {
    a.swap(b);
  }
}

이제 swap 이 정상적으로 작동한다!

 

Widget 과 WidgetImpl 가 클래스가 아니라 클래스 템플릿으로 되어 있어서, WidgetImpl 에 저장된 데이터의 타입을 매개변수로 바꿀 수 있다면 어떻게 될까?

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

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

namespace std
{
  // 에러! 적법하지 않은 코드!
  template<typename T>
  void swap<Widget<T>>(Widget<T>& a, Widget<T>& b)
  {
    a.swap(b);
  }
}

C++ 는 클래스 템플릿에 대해서는 부분 특수화(partial specialization) 을 허용하지만 함수 템플릿에 대해서는 허용하지 않도록 정해져 있다. 따라서 컴파일이 되지 않는다!

함수 템플릿을 '부분적으로 특수화' 하고 싶을 때 흔히 취하는 방식은 다음과 같이 오버로드 버전을 하나 추가하는 것이다.

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

일반적으로 함수 템플릿의 오버로딩은 별 문제가 없다. 하지만 std 는 조금 특별해서, std 내의 템플릿에 대한 완전 특수화는 OK 지만, 새로운 템플릿을 추가하는 것은 금지되어 있다. 컴파일은 되지만 미정의 동작이 발생하므로, 절대 추가하지 말자!

결론은, 멤버 swap 을 호출하는 비멤버 swap 을 선언해 놓되, 이 비멤버 함수를 std::swap 의 특수화 버전이나 오버로딩 버전으로 선언하지만 않으면 된다. 예를 한 번 보자.

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

이제는 어떤 코드가 두 Widget 객체에 대해 swap 을 호출하더라도, 컴파일러는 C++ 의 탐색 규칙(인자 기반 탐색 : argument-dependent lookup) 혹은 쾨니그 탐색(Koenig lookup) 에 의해 WidgetStuff 네임스페이스 안에서 Widget 특수화 버전을 찾아낸다.

 

그렇다면 다음 코드에서는 어떤 swap 이 호출될까?

template<typename T>
void doSomething(T& obj1, T& obj2)
{
  // std::swap 을 이 함수 안으로 끌어올 수 있도록 만드는 문장
  using std::swap;
  ...
  // T 타입 전용의 swap 을 호출한다
  swap(obj1, obj2);
}

 3 가지 가능성이 있다.

  1. std 에 있는 일반형 버전 : 이건 확실히 있다
  2. std 의 일반형을 특수화한 버전 : 이건 있을 수도, 없을 수도 있다
  3. T 타입 전용의 버전 : 있을 수도, 없을 수도 있고, 어떤 네임스페이스 안에 있거나 없거나 할 수도 있다.

위의 코드블락처럼, using std::swap 을 선언하면, 3->2->1 순으로 탐색을 하여 적절한 swap 함수를 호출한다. 이때, using std::swap 을 반드시 사용해야 하는데,  그 이유는 doSomething 내부의 swap 이 호출될 때std::swap 을 찾아볼 수 있도록 만들어 주어야 하기 때문이다!

다만, swap 함수를 호출할 때 한정자를 잘못 붙이면 안된다.

// swap 을 호출하는 잘못된 방법
// std 의 swap 이외의 것들은 찾아보지 않는다
// 다만 특수화된 std::swap 은 호출할 수도 있다
std::swap(obj1, obj2);

 

이제 항목 25의 내용을 정리해 보자.

첫째, 표준에서 제공하는 swap 이 충분히 합리적이라면 아무것도 하지 않아도 된다.

둘째, 표준 swap 의 효율이 별로라면, 다음과 같이 하면 된다.

  1. 두 객체의 값을 효율적으로 맞바꾸는 함수를 swap 이란 이름으로 만들고, public 멤버 함수로 두자(예외를 던지면 안됨).
  2. 클래스 혹은 템플릿이 들어 있는 네임스페이스와 같은 네임스페이스에 비멤버 swap 을 만들고, 1번에서 만든 swap 멤버 함수를 이 미멤버 함수가 호출하도록 만든다.
  3. 새로운 클래스를 만들고 있다면, 그 클래스에 대한 std:;swap 의 특수화 버전을 준비하자. 이 특수화 버전에서도 swap 멤버 함수를 호출하도록 만든다.

셋째이자 마지막으로, 사용자 입장에서 swap 을 호출할 때, swap 을 호출하는 함수가 std::swap 을 볼 수 있도록 using 선언을 반드시 포함시킨다! swap 을 호출할 때는 네임스페이스 한정자를 붙이지 않도록 주의하자.

 

위에서 멤버 버전의 swap 은 예외를 던지지 않도록 만들라고 했는데, 그 이유는 swap 을 진짜 쓸모 있게 응용하는 방법들 중 클래스(및 클래스 템플릿)가 강력한 예외 안전성 보장(strong exception-safety guarntee) 를 제공하도록 도움을 주는 방법이 있기 때문이다. 항목 29를 참고하도록 하자!

 

1 Comments
댓글쓰기 폼