관리 메뉴

KoreanFoodie's Study

Effective C++ 정리 5-2 : 구현 본문

Tutorials/C++ : Advanced

Effective C++ 정리 5-2 : 구현

머니덕 2022. 9. 16. 22:45

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


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

핵심 :

1. 예외 안전성을 갖춘 함수는 실행 중 예외가 발생되더라도 자원을 누출시키지 않으며 자료구조를 더럽힌 채로 내버려 두지 않는다. 이런 함수들이 제공할 수 있는 예외 안전성 보장은 기본적인 보장, 강력한 보장, 예외 금지 보장이 있다.
2. 강력한 예외 안전성 보장은 '복사-후-맞바꾸기' 방법을 써서 구현할 수 있지만, 모든 함수에 대해 강력한 보장이 실용적인 것은 아니다.
3. 어떤 함수가 제공하는 예외 안전성 보장의 강도는, 그 함수가 내부적으로 호출하는 함수들이 제공하는 가장 약한 보장을 넘지 않는다.

 

예외 안전성이라는 측면에서 다음 코드를 살펴보자.

class PrettyMenu
{
  public:
    ...
    // 배경 그림을 바꾸는 멤버 함수
    void changeBackground(std::istream* imgSrc);
    ...
  
  private:
    Mutex mutex;
    Image *bgImage; // 현재의 배경 그림
    int imgaeChanges; // 배경 그림이 바뀐 횟수
};

void PrettyMenu::changeBackground(std::istream& imgSrc)
{
  lock(&mutex);
  
  delete bgImage;
  ++imgaeChanges;
  bgImage = new Image(imgSrc);
  
  unlock(&mutex);
}

위 코드는 예외 안전성 원칙을 전혀 지키지 않는 위험천만한 함수이다. 예외 안전성을 가진 함수라면 예외가 발생할 때 이렇게 동작해야 한다.

자원이 새도록 만들지 않는다 : 위의 코드는 자원이 샌다. 왜냐하면 "new Imgae(imgSrc)" 에서 예외를 던지면 unlock 함수가 실행되지 않아 뮤텍스가 계속 잡힌 상태로 남기 때문이다.

자료구조가 더럽혀지는 것을 허용하지 않는다 : "new Imgae(imgSrc)" 에서 예외를 던지면 그림은 이미 삭제된 상태고, imageChanges 변수는 이미 증가해 버린 채로 남는다.

위의 코드에서 자원 누출 문제는 항목 13항목 14를 참고하면 다음과 같이 고칠 수 있다.

void PrettyMenu::changeBackground(std::istream& imgSrc)
{
  // 뮤텍스를 대신 획득하고 필요없어지면 바로 해제
  Lock ml(&mutex);
  
  delete bgImage;
  ++imgaeChanges;
  bgImage = new Image(imgSrc);
}

 

이제 자료구조 오염 문제를 해결해 보자. 예외 안전성을 갖춘 함수는 아래의 세 가지 보장(guarantee) 중 하나를 제공한다.

  • 기본적인 보장(basic guarantee) : 함수 동작 중에 예외가 발생하면, 실행 중인 프로그램에 관련된 모든 것들을 유효한 상태로 유지하겠다는 보장이다. 위의 예시에서는, 예외가 발생했을 때 이전 그림을 유지할 수도, 기본 배경그림을 사용할 수도 있을 것이다(프로그래머에게 달림).
  • 강력한 보장(strong guarantee) : 함수 동작 중에 예외가 발생하면, 프로그램의 상태를 절대로 변경하지 않겠다는 보장이다. 이런 함수를 호출하는 것은 원자적인(atomic) 동작이라고 할 수 있다. 즉, 함수가 성공적으로 실행을 마친 후의 상태와 함수가 호출될 때의 상태만이 존재할 수 있다.
  • 예외불가 보장(nothrow guarantee) : 예외를 절대로 던지지 않겠다는 보장이다. 약속한 동작은 언제나 끝까지 완수하는 함수라는 뜻이다. 기본제공 타입(int, 포인터 등) 에 대한 모든 연산은 예외를 던지지 않게 되어 있다.

어떠 예외도 던지지 않게끔 예외 지정이 된 함수는 예외불가 보장을 제공한다고 생각한다면, 잘못 생각한 것이다. 아래 예시를 보자.

// 비어 있는 예외 지정
int doSomething() throw();

위의 함수 선언이 전하는 메시지는 doSomething 이 절대로 예외를 던지지 않겠다는 말이 아니다. 만약 doSomething 에서 예외가 발생되면 매우 심각한 에러가 생긴 것으로 판단되므로, 지정되지 않은 예외가 발생했을 경우에 실행되는 처리자인 unexpected 함수가 호출된다.

앞서 언급했듯, 예외 안전성을 갖춘 함수는 위의 세 가지 보장 중 하나를 반드시 제공해야 한다. 일반적으로는 기본적인 보장과 강력한 보장 중 하나를 고르게 된다.

강력한 보장을 거의 적용한 예시를 보자.

class PrettyMenu
{
  ...
  std::shared_ptr<Image> bgImage;
  ...
};

void PrettyMenu::changeBackground(std::istream& imgSrc)
{
  Lock m1(&mutex);
  // bgImage 의 내부 포인터를 "new Image" 의 표현식의 실행 결과로 바꿔치기
  bgImage.reset(new Image(imgSrc));
  
  ++imageChanges;
}

delete 연산자는 reset 함 수 안에 들어가 있으므로, reset 이 불리지 않는 한 delete 도 쓰일 일이 없다. 물론, new Image 가 예외를 일으킬 때, 입력 스트림의 읽기 표시자가 이동한 채로 남아 있을 가능성이 있다. 따라서 엄밀히 말하면 위의 코드는 기본적인 보장을 제공한다고 볼 수도 있다.

 

추가적으로, '복사-후-맞바꾸기(copy-and-swap)' 전략을 사용하면 강력한 예외 안전성 보장을 제공하도록 만들 수 있다. 'pimpl 관용구' 를 사용하는 다음 예시를 보자.

struct PMImpl
{
  std::shared_ptr<Image> bgImage;
  int imageChanges;
};

class PrettyMenu
{
  ...
  private:
    Mutex mutex;
    std::shared_ptr<PMImpl> pImpl;
};

void PrettyMenu::changeBackground(std::istream& imgSrc)
{
  // 항목 25 참고
  using std::swap;
  
  // 뮤텍스를 잡는다
  Lock m1(&mutex);

  // 객체의 데이터 부분을 복사한다
  std::shared_ptr<PMImpl> pNew(new PMImpl(*pImpl));
  
  // 사본을 수정한다
  pNew->bgImage.reset(new Image(imgSrc));
  ++pNew->imgaeChanges;
  
  // 배경 그림을 바꿔치기한다
  swap(pImpl, pNew);

  // 뮤텍스를 놓는다
}

PMImpl 은 구조체가 아니라 클래스로 만들어도 무방하지만, PrettyMenu 클래스에서 private 으로 선언했으니 캡슐화에 있어 큰 문제는 없다.

'복사-후-맞바꾸기' 전략은 객체의 상태를 '전부 바꾸거나 혹은 안 바꾸거나(all-or-nothing)' 방식으로 유지하려는 경우에 효과적이다. 하지만 함수 전체가 강력한 예외 안전성을 갖도록 보장하지는 않는다. 다음과 같은 예시를 보자.

void someFunc()
{
  // 이 함수의 현재 상태에 대해 사본 생성
  ...
  
  f1();
  f2();
  
  // 변경된 상태를 바꾸어 넣기
  ...
}

f1 혹은 f2 에서 보장하는 예외 안전성이 '강력'하지 못하면, someFunc 함수 역시 강력한 예외 안전성을 보장하기 힘들어진다. 즉, "어떤 함수가 제공하는 예외 안전성 보장의 강도는, 그 함수가 내부적으로 호출하는 함수들이 제공하는 가장 약한 보장을 넘지 않는다" 는 핵심 3번의 내용과도 궤를 함께한다.

 

 

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

핵심 :

1. 함수 인라인은 작고, 자주 호출되는 함수에 대해서만 하자. 이렇게 하면 디버깅 및 라이브러리의 바이너리 업그레이드가 용이해지고, 자칫 생길 수 있는 코드 부풀림 현상이 최소화되며, 프로그램의 속력이 더 빨라질 수 있는 여지가 최고로 많아진다.
2. 함수 템플릿이 대개 헤더 파일에 들어간다는 일반적인 부분만 생각해서 이들을 inline 으로 선언하면 안 된다.

 

인라인 함수를 사용하면 컴파일러가 함수 본문에 대해 문맥별(context-specific) 최적화를 걸기가 용이해진다. 목적 코드의 크기도 작아지며 명령어 캐시 적중률도 높아진다.

inline 은 컴파일러에 대해 '요청'을 하는 것이지, '명령' 이 아니다. 즉, inline 을 붙이지 않아도 그냥 되는 경우도 있고 명시적으로 할 수도 있다는 뜻이다. 암시적인 inline 의 예시를 보자.

class Person
{
  public:
    ...
    // age 는 클래스 정의 내부에서 정의됨
    int age() const { return theAge; }
  private:
    int theAge;
};

대부분의 경우, inline 함수는 컴파일 도중에 수행하므로 헤더 파일에 넣어주는 것이 좋다. 하지만 복잡한 함수나 가상함수는 인라인해 주지 않는다. 다음 예시들을 보면서, 인라인이 될 수 있는 경우와 아닌 경우를 살펴보자.

// f 의 호출은 컴파일러가 인라인해 준다고 가정
inline void f() { ... }

// pf 는 f 를 가리키는 함수 포인터
void (*pf)() = f;
...
// 이 함수는 인라인화 될 것임
f();

// 이 함수는 인라인화되지 않음
// 함수 포인터를 통해 호출되고 있으므로!
pf();

 

생성자와 소멸자도 인라인화하기에 그리 좋지 않은 함수이다. 다음의 예를 보자.

class Base
{
private:
  std::string bm1, bm2;
};

class Derived: public Base
{
public:
  // Derived 생성자가 비어 있다
  // 정말 비어 있을까?
  Derived() {}
private:
  std::string dm1, dm2, dm3;
};

위의 Derived 생성자는 비어 있는 것처럼 보이지만, 실제로는 아래와 같이 동작할 것이다(실제 코드가 저렇게 생성된다는 뜻은 아니다).

class Derived: public Base
{
public:
  // Derived 생성자가 비어 있다
  // 정말 비어 있을까?
  Derived() 
  {
    Base::Base();

    // dm1 생성을 시도
    try { dm1.std::string::string(); }
    // dm1 생성 도중 예외를 던지면?
    // 기본 클래스 부분 소멸 후 예외 전파
    catch (...)
    {
      Base::~Base();
      throw;
    }
    // dm2, dm3 에 대해서도 위와 동일
  }
private:
  std::string dm1, dm2, dm3;
};

 

그렇다면 어떤 함수를 인라인으로 선언해야 하고 어떤 것을 선언하지 말아야 할까?

우선, 아무것도 인라인하지 말자. 아니면 정말 단순한 함수만 인라인화를 하자. 만약 f 라는 특정 함수가 인라인화 되어 있고, 해당 함수를 이용한 응용 프로그램을 만들었다고 가정하자. 만약 나중에 이 함수를 바꾸면, 이 함수를 이용한 모든 프로그램을 다시 컴파일 해야 한다. 하지만 인라인이 아니라면, 링크만 해주면 되므로 비용이 훨씬 덜 들게 될 것이다!

 

 

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

핵심 :

1. 컴파일 의존성을 최소화하는 작업의 배경이 되는 가장 기본적인 아이디어는 '정의' 대신에 '선언' 에 의존하게 만들자는 것이다. 이 아이디어에 기반한 두 가지 접근 방법은 핸들 클래스와 인터페이스 클래스이다.
2. 라이브러리 헤더는 그 자체로 모든 것을 갖추어야 하며 선언부만 갖고 있는 형태여야 한다. 이 규칙은 템플릿이 쓰이거나 쓰이지 않거나 동일하게 적용하자.

 

C++ 는 인터페이스와 구현을 깔끔하게 분리하는 일에 일가견이 없다. 예시를 보자.

class Person
{
public:
  Person(const std::string& name, const Date& birthday,
    const Address& addr)
  {
    std::string name() const;
    std::string birthDate() const;
    std::string address() const;
  }
private:
  std::string theName;
  Date theBirthDate;
  Address theAddress;
};

위와 같은 코드를 컴파일 하기 위해선, string, Date, Address 클래스의 구현 세부사항을 알고 있어야 한다. 즉, 수많은 #include 가 필요하다는 뜻이다. 이는 헤더 파일들 사이에 컴파일 의존성(compilation dependency)란 것을 엮어 버린다. 파일 하나만 바뀌어도 해당 파일을 사용하는 모든 파일을 몽땅 다시 컴파일해야 하는 것이다.

전방선언으로 위의 문제를 회피한다면 어떨까? 아래 예시를 보자.

class Person;

int main()
{
  int x;
  Person p( params );
}

int 는 4바이트 공간을 할댕해야 한다는 것을 알고 있지만, Person 의 경우는 어떨까? 자바라면 포인터 크기 만큼만 공간을 할당하겠지만, C++ 는 그렇지 않다. 이 문제는 pImpl 관용구 기법으로 해결해볼 수는 있다.

class PersonImpl;
class Date;
class Address;

class Person
{
public:
  Person(const std::string& name, const Date& birthday,
    const Address& addr)
    {}
  std::string name() const;
  std::string birthDate() const;
  std::string address() const;
private:
  std::shared_ptr<PersonImpl> pImpl;
};

위 코드에서 실제 데이터 멤버는 포인터(shared_ptr) 하나 이다. 이제 Person 의 사용자는 Person 클래스의 구현부가 변경되더라도 다시 컴파일할 필요가 없어진다. 이렇게 인터페이스와 구현을 둘로 나누는 열쇠는 '정의부에 대한 의존성(dependencies on definitions)'을 '선언부에 대한 의존성(dependencies on declarations)' 로 바꾸어 놓는 데 있다. 이제 내용을 정리해 보자.

 

객체 참조자 및 포인터로 충분한 경우에는 객체를 직접 쓰지 않는다

반면, 어떤 타입의 객체를 정의할 때는 그 타입의 정의가 준비되어 있어야 한다.

 

할 수 있으면 클래스 정의 대신 클래스 선언에 최대한 의존하도록 만든다

예시를 보자.

// 클래스 선언
class Date;
// 클래스의 정의를 가져오지 않아도 된다
Date today();
void ClearAppointments(Date d);

 

선언부와 정의부에 대해 별도의 헤더 파일을 제공한다

"클래스를 둘로 쪼개자" 라는 지침을 제대로 쓰려면 헤더 파일이 짝으로 있어야 한다. 하나는 선언부를 위한 헤더 파일이고, 하나는 정의부를 위한 헤더 파일이다. 즉, 라이브러리 사용자 쪽에서는 전방 선언 대신 선언부 헤더 파일을 #include 하면 된다. 

// Date 클래스를 선언하는(정의는 아님) 헤더 파일
#include "datefwd.h"
// 클래스의 정의부는 가져오지 않아도 된다
Date today();
void ClearAppointments(Date d);

실제로 C++ 은 <iosfwd> 라는 iostream 관련 선언부를 제공하고, 정의부는 <sstream>, <streambuf>, <fstream>, <iostream> 등으로 나누어 제공하고 있다.

이는 템플릿에 대해서도 마찬가지이다. C++ 에서는 템플릿 선언과 정의를 분리할 수 있는 기능을 export 라는 키워드로 지원하고 있다.

 

위의 pimpl 관용구를 사용하는 Person 같은 클래스를 핸들 클래스(handle class) 라고 한다. 활용 예시를 한 번 보자.

// Person 의 클래스 정의 #include
#include "Person.h"
// PersonImpl 의 멤버 함수는 Person의 멤버 함수와 
// 일대응으로 대응 (인터페이스가 같음)
#include "PersonImpl.h"

Person::Person(const std::string& name, const Date& birthday,
    const Address& addr)
: pImpl(new PersonImpl(name, birthday, addr))
{}

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

Person 은 핸들 클래스이지만 그렇다고 Person 의 동작이 바뀐 것은 아니다. Person 의 동작을 수행하는 방법이 바뀐 것 뿐이다!

 

혹은 핸들 클래스 방법 대신 인터페이스 클래스(Interface class)를 만드는 방법도 있다.

class Person
{
public:
  virtual ~Person();
  virtual std::string name() const = 0;
  virtual std::string birthDate() const = 0;
  virtual std::string address() const = 0;
  ...
};

이제 Person 클래스의 파생 클래스를 만들어 사용하면 된다. 위 방식을 활용할 때는, 팩토리 함수 혹은 가상 생성자(virtual constructor) 라고 부르는 함수를 만드는 경우가 많다. 이 함수는 인터페이스 클래스의 인터페이스를 지원하는 객체를 동적으로 할당한 후, 그 객체의 포인터를 반환하는 것이다. 예시를 보자.

// 팩토리 함수 선언
class Person
{
public:
  ...
  // 아래 같은 함수는 정적으로 선언하는 경우가 많음
  static std::shared_ptr<Person>
  create(const std::string& name,
         const Date& birthday,
          const Address& addr);
  ...
};

////////////////////////////
// 사용자 코드 /////////////
///////////////////////////
std::string name;
Date dateOfBirth;
Address address;

// Person 인터페이스를 지원하는 객체 한 개를 생성
std::shared_ptr<Person> pp(Person::create(name, dateOfBirth, address));
...
std::cout << pp->name()
          << " was born on "
          << pp->birthDate()
          << " and now lives at "
          << pp->address();

실제로 위의 create 함수는 다음처럼 구현될 것이다.

class RealPerson: public Person { ... };

std::shared_ptr<Person> Person::create(
  const std::string& name, 
  const Date& birthday, 
  const Address& addr)
{
  return std::shared_ptr<Person>(new RealPerson(name, birthday, addr));
}

즉, 인터페이스의 명세를 물려 받고, 파생 클래스에서 이를 구현(가상 함수를 구현) 하면 된다. 두 번째 방법은 다중 상속이 있는데, 이는 항목 40을 참고하자.

물론 핸들 클래스나 인터페이스 클래스는 추가적인 메모리 할당이나 가상 함수 호출에 대한 비용적인 문제가 있다. 그러나 컴파일 의존성을 줄이면서, 클래스 사이의 결합도를 낮출 수 있으므로 개발 도중에는 위의 두 가지 방식을 적극적으로 활용하는 것이 좋다. 최적화 문제는 결합도를 높여도 된다는 결론이 나온 후, 통짜 구체 클래스로 바꾸어도 늦지 않다는 뜻이다!

0 Comments
댓글쓰기 폼