Skip to main content

[Effective C++] Chapter 4. 설계 및 선언

이펙티브 C++ 책을 읽고 공부한 노트입니다.




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

  • 사용자가 실수할 만한 것들을 생각해보고 인터페이스를 설계하자
class Date
{
public:
  Date(int month, int day, int year); 
};

int main()
{
  Date d(1, 30, 2023); // 저런, month와 day를 잘못 넣었다!
}
  • Wrapper 타입을 만들어본다.
struct Day
{
  int val
  explicit Day(int v) : val(v)
};
// ... Month, Year ...

class Date
{
public:
  Date(const Month& m, const Day& d, const Year& y); 
};

int main()
{
  Date d(1, 30, 2023); // 컴파일 에러 
  Date d(Month(1), Day(30), Year(2023); // 컴파일 에러 
  Date d(Day(30), Month(1), Year(2023); // Okay
  Date d(Day(1), Month(30), Year(2023); // 악 안돼!
}
  • 적절한 값들을 미리 준비해놓고 그것만 쓰도록 한다.
    • enum 은 타입 안정성이 좋지 못하므로 (int로도 쓰일 수 있다) 유효한 Month의 집합을 미리 정의해 두면 좋겠다.
class Month
{
public:
  // 객체가 아닌, 함수를 사용하였다. 
  // 객체가 초기화 되기 전에 사용되면 위험하기 때문이다 (Item 4)
  static Month Jan() { return Month(1); }
  static Month Feb() { return Month(1); }
  //...

private:
  // Month 값이 새로 생성되지 않도록
  // 명시호출 생성자가 private 멤버이다. 
  explicit Month(int m); 
};

int main()
{
  Date d(Month::Jan(), Day(30), Year(2023)); // Good
}

  • 사용자가 사용법을 제대로 외워야 사용할 수 있는 인터페이스는 잘못 사용하기 쉽다.
Investment * createInvestment();
// 사용자가 자원을 만들어 놓고 삭제하는 걸 까먹으면 어떻하지?
// Investment를 스마트 포인터로 관리해야겠다. 
// 스마트 포인터 사용을 까먹으면 어떻하지?
// 차라리 스마트 포인터를 리턴하는 것이 좋겠다. 

std::shared_ptr<Investment> createInvestment();
// 이렇게
  • 만약에 스마트 포인터의 삭제자가 따로 있어서 미리 지정해 주어야 한다면?
    • 그리고 스마트 포인터를 생성하는 시점에 실제 데이터를 알 수 없어서, 일단 널 포인터를 가리키는 스마트 포인터를 생성하고 싶다면?
// 삭제자
void getRidOfInvestment(Investment * inv); 

std::shared_ptr<Investment> createInvestment()
{
  // 삭제가가 묶인 스마트 포인터를 리턴하므로
  // 이제 삭제 걱정도 안 해도 된다.
  
  std::shared_ptr<Investment> retVal(0, getRidOfInvestment);
  // (X) 이것은 널을 가리키는 스마트 포인터가 아니다. 
  // 0은 int이지 Investment* 타입이 아니다. 
  
  std::shared_ptr<Investment> retVal(static_cast<Investment *>(0), getRidOfInvestment);
  // 캐스트를 활용해서(Item 27 참고) 널 포인트를 가진 스마트 포인터를 생성한다.


  // 그리고 나중에 실제 데이터를 가리키도록 한다.
  // retVal = ...
  // 만약에 스마트 포인터를 생성하는 시점에 실제 데이터를 알 수 있다면, 생성자에 바로 넘겨주는게 좋겠다.

  return retVal;
}

  • 교차 DLL 문제(cross-DLL problem)
    • 객체 생성할 때 사용한 동적 링크 라이브러리(dynamically linked library: DLL)의 new와, 그 객체를 삭제할 때 사용한 delete의 DLL이 다른 경우에 발생한다.
  • shared_ptr는 가 교차 DLL 문제가 발생하지 않아서 좋다.
class Investment {};
class Stock : public Investment {};

std::shared_ptr<Investment> createInvestment()
{
  return std::shared_ptr<Investment>(new Stock());
  // Investment형 shared_ptr이어도, 
  // Stock으로 생성한 걸 잊지 않고 그 DLL의 delete를 호출해준다. 
}



Item 19: 클래스 설계는 타입 설계와 똑같이 취급하자 #

  • 클래스를 설계한다는 것은, 타입을 설계하는 것과 같다.
    • 따라서 마치 언어 설계자가 그 언어의 기본제공 타입을 설계하면서 쏟는 정성과 보살핌이 필요하다.

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



Item 20: ‘값에 의한 전달’보다는 ‘상수객체 참조자에 의한 전달’ 방식을 택하는 편이 대개 낫다 #

  • 값에 의한 전달(pass-by-value)은 복사 생성자에 의해 사본을 만든다.
    • 그래서 고비용 연산이 되기도 한다.
class Person {};
class Student : public Person {};

bool validateStudent(Student s);

int main()
{
  Student plato;
  
  validateStudent(plato);
  // Student의 복사 생성자가 호출된다. 
  // Person 객체가 먼저 만들어진다. 
  
  // 만약에 멤버로 string 객체가 있다면?
  // 그에 따른 생성자가 또 불릴 것이다. 
  
  // 삭제할 때는 생성한 만큼 더 불린다. 
  
  // 값에 의한 전달은 어마어마한 비용이 들게 되었다. 
}
  • 따라서 참조자로 전달하면 좋겠다.
bool validateStudent(const Student & s);
// const를 사용하므로써 변경을 방지한다. 

  • 참조자는 복사손실 문제(slicing problem) 를 막을 수도 있다.
class Window
{
public:
  virtual void display() const;
};

class WindowWithScrollBars : public Window
{
public:
  virtual void display() const;
};

void displayWindow(Window w)
// WindowWithScrollBars 객체가 Window 객체로 복사되면서
// WindowWithScrollBars의 부분이 싹둑 잘려나간다. 
{
  w.display();
  // 이것은 Window::display()만 부를 것이다. 
  // 해결하려면 참조로 전달하면 된다. 
}

int main()
{
  WindowWithScrollBars wwsb;
  displayWindow(wwsb);
}



Item 21: 함수에서 객체를 반환해야 할 경우에 참조자를 반환하려고 들지 말자 #

  • 그렇다고 모든 코드를 참조에 의한 전달로 하는 것은 옳지 않다.
    • 함수에서 객체를 반환해야 할 때를 살펴보자.
class Rational 
{
private:
  int n, d;
  
  // 두 유리수를 곱한다.
  friend const Rational operator*(const Rational& lhs, const Rational& rhs);
  // 값으로 반환하게 되어 있다. 그럼 객체의 생성과 소멸에 대한 비용이 들겠다.
  // 이것을 참조로 반환하면 비용이 덜 들지 않을까?

public: 
  Rational(int numerator = 0, int denominator = 1);
};

  • 객체에 대한 참조자를 반환할 수 있으려면, 그 전에 객체를 생성해야 하는 건 마찬가지이다.
    • 객체는 스택이나 혹은 힙에 만들 수 있다.
    • 그럼 스택에 만들어보자.
const Rational& operator*(const Rational& lhs, const Rational& rhs)
{
  // 이것은 지역객체이다. 
  Rational result(lhs.n * rhs.n, lhs.d * rhs.d);
  
  return result;
} // 블록이 끝나면 사라져버린다. 
// 따라서 반환된 참조는 필요없는 메모리 뭉치가 된다. 

  • 그럼 힙에 만들어보자.
const Rational& operator*(const Rational& lhs, const Rational& rhs)
{
  Rational * result = new Rational(lhs.n * rhs.n, lhs.d * rhs.d);
  
  return *result;
  // 근데 이 객체는 누가 delete 해주지?
} 

int main()
{
  Rational w, x, y, z;
  w = x * y * z;
  // operator* 호출이 두 번 일어나므로 new에 맞춰서 delete도 두 번 해줘야 한다. 
  // 하지만 반환되는 참조자에 대한 포인터를 알 수 있는 방법이 없다. 
}

  • 정적 객체로 만들어 놓고 그것의 참조자를 반환하면 어떨까?
const Rational& operator*(const Rational& lhs, const Rational& rhs)
{
  static Rational result; // 정적 객체를 반환한다. 
  
  // result = ...;
  
  return result;
}

bool operator==(const Rational& lhs, const Rational& rhs);

int main()
{
  Rational a, b, c, d;
  
  if ((a * b) == (c * d)) // 이것은 늘 true가 되버린다.
  // ...
}

  • 따라서 함수에서 객체를 반환할 때는, 새로운 객체를 만들어서 값으로 반환해야하겠다.
const Rational operator*(const Rational& lhs, const Rational& rhs)
{
  return Rational(lhs.n * rhs.n, lhs.d * rhs.d);
}



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

  • 데이터 멤버는 private로 하자. 그 이유는…
    • 일관성 있는 인터페이스를 제공할 수 있다.
    • 필요에 따라 접근 제어가 세밀하게 가능하다.
    • 캡슐화(encapsulation) 를 함으로써, 클래스의 불변속성을 강화할 수 있고, 내부 구현을 융통성 있게 바꿀 수 있다.

  • protected는 어떨까?
    • 어떤 데이터 멤버를 protected로 했다가 제거한다고 치자..
    • 그러면 그 멤버를 사용하던 파생 클래스에 해당하는 코드를 전부 고쳐야 할 것이다.
    • protectedpublic보다 더 많이 보호 받고 있다는 것이 절대 아니다.



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

class WebBrowser
{
public:
  void clearCache();
  void clearHistory();
  void removeCookies();
  
  // 위의 세가지를 한 번에 하는 함수를 만들고 싶다. 
  
  // 이것을 멤버 함수로 제공할 것인가?
  void clearEverything();
};

// 아니면 비멤버 비프렌드 함수로 제공할 것인가?
void clearBrowser(WebBrowser& wb)
{
  wb.clearCache();
  wb.clearHistory();
  wb.removeCookies();
}

  • 멤버 함수 말고 비멤버 비프렌드 함수를 쓰도록 하자.
    • 비멤버 비프렌드 함수는 private 멤버에 접근할 수 없다. 따라서 캡슐화 정도가 더 높다고 볼 수 있다.

  • 컴파일 의존성을 줄일 수 있다.
    • 멤버 함수는 반드시 하나의 클래스가 통으로 정의되어야 한다.
    • 하지만 비멤버 비프렌드 함수들은 같은 네임스페이스 안에 둔 다음, 쓰임새에 따라 각기 다른 헤더파일에 몰어서 선언할 수 있다.
    • 그러면 사용자는 실제로 사용하는 구성요소에 대해서만 컴파일 의존성을 고려할 수 있게 된다.
  • 또한 확장도 쉬워진다.
    • 새로운 함수를 만들 때는 헤더 파일을 하나 더 만들어서 정의를 추가하면 된다.
// "webbrowser.h" 
namespace WebBrowserStuff // 같은 네임스페이스 안에 둔다. 
{
  // 핵심 관련 기능들...
  
  class WebBrowser {};
  
  void clearBrowser(WebBrowser& wb);
}

// "webbrowserbookmarks.h" 
namespace WebBrowserStuff 
{
  // 즐겨 찾기 관련 편의 함수들...
}

// "webbroswercookies.h"
namespace WebBrowserStuff 
{
  // 쿠기 관련 평의 함수들...
}



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

  • 유리수 클래스를 만들었는데, int 형 등과 함께 혼합형(mixed-mode) 수치 연산이 가능하게 하고 싶다.
class Rational
{
public:
  // 생성자를 explicit(명시 호출)로 선언하지 않았다. 
  // 따라서 암시적 타입 변환이 가능해진다. 
  Rational(int numerator = 0, int denominator = 1);
  
  int numerator();
  int denominator();

  // operator*를 멤버 함수로 두었다. 
  const Rational operator*(const Rational& rhs) const;
};

int main()
{
  Rational oneEighth(1, 8);
  Rational oneHalf(1, 2);
    
  Rational result = oneHalf * oneEighth;
    
  // 암시적 타입 변환으로 2가 Rational 객체가 되었기에 가능하다 
  result = oneHalf * 2; // oneHalf.operator*(2)
  
  // 컴파일 에러!
  result = 2 * oneHalf; // 2.operator*(oneHalf) 
}

  • 컴파일러는 암시적 타입 변환(implicit type conversion)을 했다.
    • Rational 생성자에 int를 넘겨서 intRational 클래스로 둔갑시켰다.
    • 이것은 생성자에 explicit 선언이 되지 않았기에 가능하다.
    • 컴파일 에러 부분을 해결하기 위해 operator*비멤버 함수로 만들면 어떨까?
class Rational {};

// 비멤버 함수로 두었다. 
const Rational operator*(const Rational& lhs, const Rational& rhs)
{
  return Rational(lhs.numerator() * rhs.numerator(), lhs.denominator() * rhs.denominator());
}

int main()
{
  Rational oneHalf(1, 2);
  Rational result;

  result = oneHalf * 2; // OK
  result = 2 * oneHalf; // OK!
}

  • 멤버 함수의 반대는 비멤버 함수이다. friend 함수가 아니다.
    • operator*Rationalpublic 인터페이스만 써서 구현할 수 있으므로, friend일 필요가 없다.



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

  • 표준에서 제공하는 swap()은 복사만 제대로 지원하는(복사 생성자, 복사 대입 연산자를 통해) 타입이기만 하면 어떤 타입의 객체이든 맞바꾸기 동작을 수행해 준다.
namespace std
{
  template<typename T>
  void swap(T& a, T& b)
  {
    T temp(a);
    a = b;
    b = temp;
  }
}



클래스를 위한 std::swap()의 특수화 #

  • 포인터가 주성분인 객체는 굳이 데이터까지 복사하지 않고 포인터만 맞바꾸는게 비용이 적게 들 것이다.
    • 이 때, std::swap특수화(specialize) 하면 되겠다.
class WidgetImpl
{
private:
  int a, b, c;
  std::vector<double> v; // 복사 비용이 높겠다.
};

class Widget
{
private:
  // 포인터가 주성분이다. 
  WidgetImpl * pImpl;
    
public:
  // 복사 생성자와 복사 대입 연산자를 제공한다. 
  Widget(const Widget& rhs);
  Widget& operator=(const Widget& rhs)
  {
    *pImpl = *(rhs.pImpl);
    // ...
  }

  void swap(Widget& other)
  {
    using std::swap;
    
    // 객제가 아니라 포인터를 맞바꾼다. 
    swap(pImpl, other.pImpl);
  }
};

namespace std
{
  // 명시적 특수화 : 
  // 다른 swap 템플릿을 사용하지 말고, 주어진 형에 맞게 특별히 명시적으로 정의된 이 함수 정의를 사용해라.
  template<>
  void swap<Widget>(Widget& a, Widget& b)
  {
    // Widget에서 swap()을 제공한다.  
    // 굳이 freind로 해서 pImpl에 직접 접근할 필요가 없다. 
    a.swap(b);
  }
}



클래스 템플릿을 위한 비멤버 swap() #

  • 만약 WidgetWidgetImpl이 클래스 템플릿으로 되어있다면?
    • std::swap부분적인 특수화(partial specialization) 해야 하겠다.
  • 하지만 C++은 클래스 템플릿에 대해서는 부분적 특수화를 허용하지만, 함수 템플릿(std::swap)에 대해서는 허용하지 않는다.
// 클래스 템플릿 -> std::swap의 부분 특수화가 필요해졌다. 
template<typename T>
class WidgetImpl {};

template<typename T>
class Widget {};

namespace std
{
  // (X) C++은 함수 템플릿의 부분 특수화는 허용하지 않는다. 
  template<typename T>
  void swap<Widget<T>>(Widget<T>& a, Widget<T>& b)
  {
    a.swap(b);
  }
}

  • 함수 템플릿을 부분 특수화하고 싶을 때는 보통 오버로드 버전을 하나 추가한다.
    • 하지만 std 네임스페이스의 경우 조금 특별해서, 이 안에서 완전 특수화는 OK지만, 새로운 템플릿을 추가하는 건 NOT OK이다.
  • 따라서 비멤버 swap을 선언하되, std::swap의 특수화 버전이나 오버로딩 버전으로 선언하지 않으면 된다.
namespace WidgetStuff // std가 아니므로 오버로딩 버전이 아니다. 
{
  template<typename T>
  class Widget {};
  
  // 비멤버 swap
  template<typename T>
  void swap(Widget<T>& a, Widget<T>& b)
  // swap 뒤에 <...>가 없으므로 특수화 버전이 아니다. 
  {
    a.swap(b);
  }
}



사용자 입장에서 swap 사용하는 법 #

  • 위에서 본 것처럼 swap을 만들 었다면, 사용자는 어떻게 사용해야 좋을까?
template<typename T>
void doSomething(T& obj1, T& obj2)
{
  swap(obj1, obj2);
  // 어떤 버전의 swap을 호출해야 할까?
  // (1) T 타입 전용 버전
  // (2) std::swap을 특수화한 버전
  // (3) std::swap
}

  • 순서대로 살펴보기를 원한다면 함수 내 using 선언을 포함시켜서 해당 함수가 std::swap을 볼 수 있게 한다.
template<typename T>
void doSomething(T& obj1, T& obj2)
{
  using std::swap; // std::swap을 이 함수 안으로 끌어온다. 
  swap(obj1, obj2);
}

  • 그러면 C++의 이름 탐색 규칙(인자 기반 탐색: argument-dependent lookup 혹은 쾨니그 탐색: Koenig lookup) 에 따라서,
    • (1) 전역 유효범위 or 타입 T와 동일함 네임스페이스 안에, T 전용의 swap이 있는지 찾는다.
    • (2) 없다면 std::swap의 특수화 버전을 찾는다.
    • (3) 없다면 std::swap을 사용한다.