Skip to main content

[Effective C++] Chapter 5. 구현

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




Item 26: 변수 정의는 늦출 수 있는 데까지 늦추는 근성을 발휘하자 #

  • 변수의 정의는 꼭 필요해지기 전까지 늦춘다.
std::string encryptPassword(const std::string& password)
{
  using namespace std;
  
  // 이 변수는 
  string encrypted; 
  
  // 여기서 에러가 발생하면 전혀 쓰이지 않고, 괜히 생성자와 소멸자만 더 불린다. 
  if (password.length() < MinimumPasswordLength)
    throw logic_error("Password is too short");

  // 따라서 실제로 쓰일 때까지 늦춰서, 이곳에 선언하는 게 좋겠다. 
  // ... encrypted 변수를 설정한다 ...
    
  return encrypted;
}

void encrypt(std::string& s);

std::string encryptPassword(const std::string& password)
{
  // (BAD)
  std::string encrypted; // 쓸데없이 기본 생성자가 불린다. 
  encrypted = password;  // 그리고 대입한다. 
  
  // (GOOD) 정의를 최대한 늦춰서, 정의와 동시에 초기화한다. 
  std::string encrypted(password); 
  
  encrypt(encrypted);
    
  return encrypted;
}

  • 루프에서 변수 정의의 위치는 어디가 좋을까?
    • 대입이 생성자, 소멸자 쌍보다 비용이 덜 들고, 전체 코드에서 성능에 민감한 부분을 건드리지 않는다면 (1)이 좋겠다.
// (1) 루프 바깥쪽에 정의한다. 
// 생성자 1번 + 소멸자 1번 + 대입 n번

Widget w; // w의 접근 범위가 (2)보다 넓다. 
for (int i = 0; i < n; i++)
{
  w = i에 따라 달라지는 ;
}
// (2) 루프 안쪽에 정의한다. 
// 생성자 n번 + 소멸자 n번

for (int i = 0; i < n; i++)
{
  Widget w(i에 따라 달라지는 );
}



Item 27: 캐스팅은 절약, 또 절약! 잊지 말자 #

구형 스타일의 캐스트 보다는 C++ 스타일의 캐스트를 사용하자 #

  • C 스타일 캐스트 (구형 스타일의 캐스트)
표현 설명
(T) 표현식 표현식 부분을 T 타입으로 캐스팅한다.
T (표현식) 표현식 부분을 T 타입으로 캐스팅한다.

  • C++ 스타일 캐스트
    • 구형 스타일 보다는 가시성이 좋아서 나중에 찾기 편리하다.
    • 또한 캐스트의 목적을 좁혀서 지정하기 때문에 컴파일러쪽에서 사용 에러를 진단할 수 있다.
표현 설명
const_cast 객체의 상수성(constness)을 없앤다.
즉 상수 객체를 비상수 객체로 만든다.
dynamic_cast 안전한 다운캐스팅(safe downcasting) 을 한다.
즉 해당 객체가 어떤 클래스 상속 계통에 속한 특정 타입인지 아닌지를 결정한다.
런타임 비용이 매우 높다.
reinterpret_cast 하부 수준 캐스팅을 한다. (예를 들면, 포인터를 int로 바꾸는 등)
구현 환경에 의존적이어서 이식성이 없다.
static_cast 암시적 변환을 강제로 진행한다.
(예를 들면, 비상수 객체를 상수 객체로 바꾸거나, intdouble로 바꾸거나, void*를 일반 타입으로 바꾸거나, 기본 클래스의 포인터를 파생 클래스의 포인터로 바꾸는 등.
참고로 상수 객체를 비상수 객체로 만드는 건 const_cast 뿐이다.



캐스팅의 비용은 적지 않다. #

  • 캐스팅으로 명시적으로 바꾸든, 컴파일러가 암시적으로 바꾸든 간에 일단 타입 변환이 있으면 런타임에 실행되는 코드가 만들어진다.
int x, y;

double d = static_cast<double>(x) / y;
// 대부분의 컴퓨터 아키텍쳐에서 int와 double의 표현구조가 아예 다르기 때문에
// int 타입을 double 타입으로 캐스팅한 부분에서 코드가 만들어진다. 
class Base {};
class Derived {};

Derived d;

// Derived* -> Base* 의 암시적 변환이 이루어진다. 
Base * pb = &d;
// 두 포인터의 값이 같지 않을 때도 있는데, 이럴 때는
// 포인터의 변위(offset)를 Derived* 포인터에 적용하여 Base* 포인터 값을 구하게 된다. 
// 이런 과정이 런타임에 일어난다!

  • 파생 클래스에서 기본 클래스의 virtual 함수를 호출하는 법
    • 캐스팅을 쓰지 않는다.
class Window
{
public:
  virtual void onResize();
};

class SpecialWindow : public Window
{
public:
  virtual void onResize()
  {
    // 기본 클래스의 onResize를 먼저 호출해야 한다면?
  
    // 이 코드는 *this의 사본을 생성하고 그 사본의 함수를 호출하게 된다. 
    static_cast<Window>(*this).onResize();
    
    // 이렇게 해야 *this의 기본 클래스의 함수를 호출하는 것이다. 
    Window::onResize();
  }
}



dynamic_cast를 되도록이면 피하자. #

  • dynamic_cast는 정말 느리게 구현되어 있으므로, 왠만하면 피하는 것이 좋다.
  • 피하는 방법은…

  • (1) 파생 클래스 객체에 대한 포인터를 컨테이너에 담아둔다.
// 기본 클래스 객체에 대한 포인터를 가지고 있다. 
typedef std::vector<std::shared_ptr<Window>> VPW;

int main()
{
  VPW winPtrs;
  
  for (VPW::iterator iter = windPtrs.begin(); iter != winPTrs.end(); ++iter)
    if (SpecialWindow *psw = dynamic_cast<SpecialWindow*>(iter->get()))
    // 런타임에 dynamic_cast로 캐스팅해서 파생클래스의 함수를 호출한다.  
      psw->blink();
}
// 애초에 파생 클래스 객체에 대한 포인터를 가지고 있다면
// 런타임에 캐스팅이 필요없다. 
typedef std::vector<std::shared_ptr<SpecialWindow>> VPSW;

int main()
{
  VPSW winPtrs;
  
  for (VPSW::iterator iter = windPtrs.begin(); iter != winPTrs.end(); ++iter)
    (*iter)->blink();
}

  • (2) 아무것도 안 하는 virtual 함수를 기본 클래스에서 제공한다.
class Window
{
public:
  // 빈 가상 함수 제공 
  virtual void blick() {} 
}

class SpecialWindow
{
public:
  virtual void blick() 
  { 
    // ... 
  }
}

typedef std::vector<std::shared_ptr<Window>> VPW;

int main()
{
  VPW winPtrs;
  
  for (VPW::iterator iter = windPtrs.begin(); iter != winPTrs.end(); ++iter)
    (*iter)->blink();
}

  • 폭포식(cascading) dynamic_cast는 반드시 피하자.
// 이런 코드는 어떤 방식으로든지 바꿔놓아야 할 것이다. 

for (VPW::iterator iter = windPtrs.begin(); iter != winPTrs.end(); ++iter)
{
  if (SpecialWindow1 * psw1 = dynamic_cast<SpecialWindow1*>(iter->get()))
  {
    // ...  
  }
  else if (SpecialWindow2 * psw2 = dynamic_cast<SpecialWindow1*>(iter->get()))
  {
    // ...  
  }
  else if (SpecialWindow3 * psw3 = dynamic_cast<SpecialWindow1*>(iter->get()))
  {
    // ...  
  }
}



Item 28: 객체 내부에 대한 “핸들"을 반환하는 코드는 되도록 피하자 #

  • 객체 내부에 대한 핸들을 반환하면, 마음대로 내부 데이터를 수정할 수 있게 되어 위험하다.
class Point
{
public:
  Point(int x, int y);
  void setX(int x);
  void setY(int y);
};

struct RectData
{
  Point ulhc; // upper left-hand corner
  Point lrhc; // lower right-hand corner
};

class Rectangle
{
private:
  // 메모리 부담을 줄이려고 포인터로 사각형을 표현한다. 
  std::shared_ptr<RectData> pData;
  
public:
  // 객체 내부의 데이터에 대한 참조자를 리턴하고 있다. 
  Point& upperLeft() const { return pData->ulhc; }
  Point& lowerRight() const { return pData->lrhc; }
};

int main()
{
  Point coord1(0, 0);
  Point coord2(100, 100);
  
  // 상수 Rectangle 객체의
  const Rectangle rec(coord1, coord2);
  
  // 내부 데이터에 접근해서 값을 바꿀 수 있게 되어버렸다!
  rec.upperLeft().setX(50);
}

  • 반환 타입이 const라면 안전할까?
    • 핸들을 따라갔을 때 실제 객체의 데이터가 없는 무효참조 핸들(dangling handle) 이 될 수 있다는 문제가 있다.
class Rectangle
{
public:
  // const를 붙였으므로 위와 같이 값을 바꾸는 것은 허용되지 않는다. 
  const Point& upperLeft() const { return pData->ulhc; }
  const Point& lowerRight() const { return pData->lrhc; }
};

class GUIObject {};

// GUIObject를 Rectangle로 바꾼다. 
const Rectangle boundingBox(const GUIObject& obj);

int main()
{
  GUIObject * pgo;
  
  // ...
  
  // 하지만 무효참조 핸들이 될 문제가 있다. 
  const Point * pUpperLeft = &(boundingBox(pgo).upperLeft());
  // boundingBox를 호출해서 Rectangle 임시 객체가 만들어진다. 
  // 이것에 대해 upperLeft가 호출된다. 
  // 이 문장이 끝나면 boundingBox의 반환값이 소멸된다. 
  // 그 안에 있던 Point 객체들도 소멸된다. 
  // 따라서 pUpperLeft 포인터가 가리키는 객체는 이제 날아가고 없다. 
}



Item 29: 예외 안전성이 확보되는 그날을 위해 싸우고 또 싸우자! #

class PrettyMenu
{
private:
  Mutex mutex;
  Image * bgImage;
  int imageChanges;
  
public:
  void changeBackground(std::istream& imgSrc);
};

// 위험천만한 함수 
void PrettyMenu::changeBackground(std::istream& imgSrc)
{
  lock(&mutex);
  
  delete bgImage;
  ++imageChanges; // (2) 자료구조가 이상해진다. 
  bgImage = new Image(imgSrc); // 여기서 예외가 발생하면 
    
  unlock(&mutex); // (1) 자원이 새어나가고 
}

  • 예외 안정성을 갖춘 함수는 아래 보장들 중 하나를 제공한다.
명칭 설명
기본적인 보장
(basic guarantee)
예외가 발생하면 실행 중인 프로그램에 관련된 모든 것들을 유효한 상태로 유지하겠다는 것이다.
즉 일관성(불변속성)이 유지되는 것이다.
강력한 보장
(strong guarantee)
예외가 발생하면 프로그램의 상태를 절대로 변경하지 않겠다는 것이다.
즉 함수 호출이 없었던 것처럼 프로그램의 상태가 되돌아간다.
예외불가 보장
(nothrow guarantee)
예외를 절대로 발생시키지 않겠다는 것이다.

  • 위 함수가 강력한 보장을 제공하도록 해보자.
class PrettyMenu
{
private:
  // 자원관리 전담 포인터를 사용 -> 알아서 delete 되도록 한다 (Item 13)
  std::shared_ptr<Image> bgImage; 
};

void PrettyMenu::changeBackground(std::istream& imgSrc)
{
  // 자원관리 전담 포인터를 사용 -> 알아서 unlock 되도록 한다 (Item 14)
  Lock m1(&mutex);
  
  bgImage.reset(new Image(imgSrc));
  
  // 진짜 그림이 바뀌면 증가시키기로 한다. 
  ++imageChanges;
}



복사-후-맞바꾸기 전략 #

  • 복사-후-맞바꾸기(copy-and-swap) 전략이란, 어떤 객체를 수정하고 싶으면 그 객체의 사본을 하나 만들어 놓고 그 사본을 수정하는 것이다. 그리고 수정이 예외 없이 완료되었을 때 수정된 객체를 원본 객체와 맞바꾼다.
    • 이 전략은 ‘전부 바꾸거나 혹은 안 바꾸거나(all-or-nothing)’ 방식으로 유지하려는 경우에 좋다.
// 이 전략은 보통 데이터를 모두 별도의 구현 객체에 넣어두고 
struct PrettyMenuImpl
{
  std::shared_ptr<Image> bgImage;
  int imageChanges;
};

class PrettyMenu
{
private:
  // 그 구현 객체를 가리키는 포인터를 진짜 객체가 물고 있게 한다. (Item 31)
  // 이런 패턴을 pimpl 관용구라고 한다. 
  std::shared_ptr<PrettyMenuImpl> pImpl;
};

void PrettyMenu::changeBackground(std::istream& imgSrc)
{
  using std::swap;

  Lock m1(&mutex);
  
  // 사본을 생성한다. 
  std::shared_ptr<PrettyMenuImpl> pNew(new PrettyMenuImpl(*pImpl));
  
  // 사본을 수정한다. 
  pNew->bgImage.reset(new Image(imgSrc));
  ++pNew->imageChanges;
  
  // 사본과 진짜 객체를 맞바꾼다. 
  swap(pImpl, pNew);
}

  • 하지만 강력한 예외 안전성을 함수 전체가 보장하지는 않는다.
void someFunc()
{
  // ... 사본을 만든다. 
  
  f1(); 
  f2();
  // f1, f2에서 강력한 예외 안전성을 보장하지 않는다면?
  // someFunc 역시 강력한 예외 안전성을 보장할 수 없다. 
  // 특히 f1, f2에서 자신에만 국한되는 것들의 상태를 바꾸는게 아니라, 
  //   비지역 데이터(ex. 데이터베이스 변경)에 대해 부수효과(side effect)를 준다면,
  //   someFunc쪽에서는 어쩔 도리가 없다. 
  
  // ... 사본과 진짜 객체를 맞바꾼다. 
}

  • 일부만 안전성을 갖춘 시스템이라는 것은 존재하지 않는다. 일부가 예외에 취약하다면 전체가 위험한 것이다.
  • 앞으로는 함수를 만들 때 예외 안전성을 갖추기 위해 진지하게 고민하는 버릇을 들이자.



Item 30: 인라인 함수는 미주알고주알 따져서 이해해 두자 #

  • 인라인 함수란?

    • 호출될 때 일반적인 함수의 호출 과정을 거치지 않고, 함수의 본문을 그대로 호출된 자리에 끼워넣는 함수이다.
  • 장점

    • 함수 호출 비용이 면제 된다.
    • 컴파일러가 함수 본문에 대해 문맥별(context-specific) 최적화를 걸기가 용이해진다.

인라인 요청 방법 #

  • 암시적인 방법
class Person
{
private:
  int theAge;
  
public:
  // 클래스 정의 안에 함수를 바로 정의한다. 
  int age() const
  {
    return theAge;
  }
}

  • 명시적인 방법
// 함수 정의 앞에 inline 키워드를 붙인다. 
// (표준 라이브러리의 max 템플릿)
template<typename T>
inline const T& std::max(const T& a, const T& b)
{
  return a < b ? b : a;
}



헤더 파일에 들어간다는 것만 생각하고 인라인화하면 안 된다. #

  • 인라인 함수와 템플릿은 대개 헤더 파일 안에 정의한다. 그렇다고 템플릿이 모두 인라인 함수여야 한다는 건 아니다.
  • 템플릿 인스턴스화와 인라인은 완전히 별개의 개념으로 하등의 관련이 없다.
    • 인라인 함수의 경우, 본문으로 바꿔치려면 컴파일러가 그 함수가 어떤 형태인지 알고 있어야 한다. 이렇듯 컴파일 도중에 인라인을 처리하기 때문에 대체적으로 헤더 파일에 들어 있다.
    • 템플릿의 경우, 해당 템플릿을 사용하는 부분에서 인스턴스로 만들러면 컴파일러가 그것이 어떤 형태인지 알고 있어야 한다. 따라서 대체적으로 헤더 파일에 들어있다.



인라인 여부는 컴파일러가 판단한다. #

  • 인라인은 컴파일러 선에서 무시할 수 있는 요청이다.
    • 아무리 인라인 함수로 선언되어 있어도 자신이 보기에 복잡한 함수(루프, 재귀 등)는 절대로 인라인 확장의 대상에 넣지 않는다.
    • 혹은 가상 함수 호출 같은 것은 절대로 인라인해 주지 않는다.
    • 인라인 함수로 선언된 함수를 함수 포인터를 통해 호출하는 경우도 대개 인라인화 되지 않는다.
inline void f() {} // 인라인 요청

void (*pf)() = f; // 함수 포인터 

f();  // 인라인화 될 것이다. 

// 있지도 않은 함수에 대한 포인터를 가져올 수 없으므로..
pf(); // 인라인되지 않을 것이다. 

  • 생성자와 소멸자는 인라인하기 좋은 함수가 아니다.
    • 아무 코드도 들어 있지 않은 빈 생성자라 할지라도, 컴파일러 구현자에 따라 여러가지 코드가 포함될 수 있다. 그러면 코드가 엄청나게 비대해질 것이다.



그외 단점 #

  • 코드가 비대해진다. 이에 따라 페이징 횟수가 늘어나고 명령어 캐시 적중률이 떨어져서 성능에 영향을 미칠 수 있다.
  • 라이브러리의 바이너리 업그레이드 시 죄다 소스를 컴파일 해야한다. 반면 보통의 함수라면 사용자가 링크만 다시 해주면 되겠다.
  • 디버깅이 어렵다. 있지도 않은 함수에 중단점을 걸 수 없기 때문이다.

  • 우선, 아무것도 인라인하지 말아라.
  • 아니면 꼭 인라인해야 하는 함수(Item 46)나 정말 단순한 함수(Person::age)만 인라인 함수로 선언하자.



Item 31: 파일 사이의 컴파일 의존성을 최대로 줄이자 #

  • C++의 클래스 정의는 클래스 인터페이스만 지정하는 것이 아니라 구현 세부사항까지 상당히 많이 지정하고 있다.
    • 그래서 다른 헤더 파일을 포함시키면 컴파일 의존성(compilation dependency) 이 생긴다.
    • 헤더 파일들 중에 하나라도 바뀌거나 혹은 그들과 엮여 있는 헤더 파일들이 바뀌면 다시 컴파일 해야하는 번거로움이 생긴다.
    • 이런 꼬리에 꼬리를 무는 컴파일 의존성이 있으면 프로젝트가 고통스러워진다.
// Person의 구현 세부사항에 속하는 std::string, Date, Address가 
// 어떻게 정의됐는지 알아야 컴파일이 가능하므로
// 아래 헤더 파일들을 포함시켜야만 한다. 
#include <string>
#include "date.h"
#include "address.h"

class Person
{
private:
  std::string theName; // std::string 구현 세부사항을 알아야 한다. 
  Date theBirthDate;   // Date 구현 세부사항을 알아야 한다. 
  Address theAddress;  // Address 구현 세부사항을 알아야 한다. 
  
public:
  Person(const std::string& name, const Date& birthday, const Address& addr);
  std::string name() const;
  std::string birthDate() const;
  std::string address() const;
};

  • 해결 방법은?
    • 인터페이스(선언부)구현(정의부) 을 별로의 헤더파일로 나눈 후, 인터페이스(선언부)에만 의존하도록 만들면 되겠다.
    • 즉, 정의부에 대한 의존성(dependencies on definitions)을 선언부에 대한 의존성(dependencies on declarations)으로 바꾸어 놓는 것이다.



방법 (1) 핸들 클래스 #

  • 주 클래스에는 구현 클래스에 대한 포인터만 두는 것을 pimpl 관용구(pointer to implementation) 라고 한다.
  • 그리고 포인터만 가지는 주 클래스를 핸들 클래스(handle class) 라고 한다.
// Person.h

// 헤더 파일이 줄었다. 
#include <string> // 표준 라이브러리는 전방선언을 하면 안된다. 
#include <memory>
// 이렇게 되면 Person을 사용하는 사용자 입장에서는 
// Date, Address가 바뀌었다고 해서 컴파일을 다시할 필요가 없어진다. 

// 전방 선언 
class PersonImpl;
class Date;
class Address;

class Person
{
private:
  // 주 클래스에서는 구현 클래스인 PersonImpl에 대한 포인터만 가지고 있다. 
  // 그래서 세부 구현 사항을 몰라도 된다. 
  std::shared_ptr<PersonImpl> pImpl;
  
public:
  Person(const std::string& name, const Date& birthday, const Address& addr);
  std::string name() const;
  std::string birthDate() const;
  std::string address() const;
};
// Person.cpp

#include "Person.h"
#include "PersonImpl.h"

Person::Person(const std::string& name, const Date& birthday, const Address& addr)
  : pImpl(new PersonImpl(name, birthday, addr)
  // 핸들 클래스 Person에 대응되는 구현 클래스 PersonImpl 쪽으로 함수 호출을 전달한다. 
  // 실제 작업은 PersonImpl이 수행한다. 
{}

std::string Person::name() const
{
  return pImpl->name();
}

  • 어떤 클래스를 사용하는 함수를 선언할 때는 그 클래스의 정의를 가져오지 않아도 된다.
// 클래스 선언 
class Date; // 굳이 정의를 몰라도 된다. 

// 함수 선언 -> 가능 
Date today();
void clearAppointments(Date d);



방법 (2) 인터페이스 클래스 #

  • 추상 기본 클래스를 통해서 인터페이스 클래스를 만들어 놓고, 이 클래스로부터 파생 클래스를 만들 수 있게 하는 방식이다. (Item 34)
  • 순수 가상 함수를 포함한 클래스는 인스턴스를 만들 수 없다. 따라서 객체를 만들기 위해 팩토리 함수(가상 생성자) 를 사용할 수 있다.
  • 즉, 인터페이스 클래스의 객체를 동적으로 할당한 후에 그 포인터를 반환하면 된다.
// 추상 기본 클래스 
class Person
{
public:
  virtual ~Person();
  virtual std::string name() const = 0;
  virtual std::string birthDate() const = 0;
  virtual std::string address() const = 0;
  
  // 객체를 생성하는 팩토리 함수
  static std::shared_ptr<Person> create(const std::string& name, const Date& birthday, const Address& addr);
};
// 인터페이스 클래스 Person으로부터 인터페이스를 물려받은 다음,
// 가상 함수들을 구현한다. 
// (혹은 다중 상속을 사용할 수도 있다 - Item 40)

class RealPerson : public Person
{
private:
  std::string theName; 
  Date theBirthDate;  
  Address theAddress; 

public:
  RealPerson(const std::string& name, const Date& birthday, const Address& addr)
    : theName(name), theBirthDate(birthday), theAddress(addr)
  {}
  
  virtual ~RealPerson() {}
  std::string name() const;
  std::string birthDate() const;
  std::string address() const;
};

// 팩토리 함수는 이렇게 구현할 수 있겠다. 
std::shared_ptr<Person> Person::create(const std::string& name, const Date& birthday, const Address& addr)
{
  return shared_ptr<Person>(new RealPerson(name, birthday, addr));
}
// 사용자는 이렇게 사용하면 되겠다.

std::string name;
Date dateOfBirth;
Address address;

std::shared_ptr<Person> pp(Person::create(name, dateOfBirth, address));

std::cout << pp->name() << " " << pp->birthDate() << " " << pp->address() << endl;



단점 #

  • 핸들 클래스
    • 포인터를 타야 접근할 수 있으므로 간접화 연산이 한 단계 더 증가한다.
    • 객체 하나마다 구현부 포인터의 크기가 더해진다.
    • 핸들 클래스의 생성자 안에서 구현부 포인터의 초기화가 어디선가 일어나야 한다. 이에 따르는 동적 메모리 할당의 연산 오버헤드, bad_alloc 예외의 가능성이 더해진다.
    • 인라인 함수의 도움을 제대로 끌어내기 힘들다.

  • 인터페이스 클래스
    • 호출되는 함수가 전부 가상 함수이므로, 함수 호출 시마다 가상 테이블 점프에 따르는 비용이 소모된다. (Item 7)
    • 또한 파생된 객체는 모두 가상 테이블 포인터를 지니고 있어야 한다. (Item 7)
    • 인라인 함수의 도움을 제대로 끌어내기 힘들다.

  • 하지만 이런 단점에도 불구하고, 구현부가 바뀌었을 때 사용자에게 미칠 파급효과를 최소화하기 위해서는 사용되어야 좋겠다.
  • 핸들 클래스와 인터페이스 클래스로 인해서 실행 속력이나 파일 크기에서 손해를 많이 봐서 클래스 사이에 결합도를 높이는 방법밖에 없다면 그때 사용하지 않으면 된다.