본문 바로가기
프로그래밍언어/C++

[Effective C++] 항목 29~31

by 목가 2018. 4. 10.
반응형

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


void PrettyMenu::changeBackground(std::istream& imgSrc)
{
lock(&mutex);                       // mutex 를 획득

delete bgImage;                   // 이전의 배경 그림 없앰
++imageChanges;                 // 그림 변경 횟수를 갱신
bgImage = new Image(imgSrc);  // 새 배경그림을 생성

unlock(&mutex);                   // mutex 해제
}

예외 안전성을 가진 함수라면 예외가 발생할 때 아래처럼 동작해야 한다.
1. 자원이 새도록 만들지 않는다 
 - 위의 코드는 자원이 샌다. new Image(imgSrc) 에서 예외를 던지면 unlock 함수가 실행되지 않아 뮤텍스가 계속 잡힘
2. 자료구조가 더럽혀지는 것을 허용하지 않는다
 - new Image(imgSrc) 에서 예외를 던지면 bgImage 객체가 이미 삭제된 상태인데 imageChanges 가 증가됨

Lock 등의 자원관리 전담클래스를 써서 해결 가능(코드 길이도 짧아져서 보기 좋음)

void PrettyMenu::changeBackground(std::istream& imgSrc)
{
Lock m1(&mutex);                       // 필요없어질 때 알아서 해제되는 객체 

delete bgImage;                   // 이전의 배경 그림 없앰
++imageChanges;                 // 그림 변경 횟수를 갱신
bgImage = new Image(imgSrc);  // 새 배경그림을 생성
}

예외 안전성을 갖춘 함수는 아래의 세가지 보장 중 하나를 제공한다.
1. 기본적인 보장 : 함수 동작 중에 예외가 발생하면, 실행 중인 프로그램에 관련된 모든 것들을 유효한 상태로 유지하겠다는 보장. 함수를 만든 사람에 달려있는 보장
2. 강력한 보장 : 함수 동작 중에 예외가 발생하면, 프로그램의 상태를 절대로 변경하지 않겠다는 보장. 호출이 마무리까지 성공하면 성공이고, 호출이 실패하면 함수 호출이 없었던 것처럼 프로그램의 상태가 되돌아간다. 예측할 수 있는 상황이 두가지 뿐이라 기본적인 보장보다 쓰기 쉽다. 
3. 예외불가 보장 : 예외를 절대로 던지지 않겠다는 보장. 약속한 동작은 언제나 끝까지 완수하라는 함수라는 뜻.(기본제공 타입 int, 포인터 등에 대한 모든 연산은 예외를 던지지 않음)
int doSomething() throw();     // 비어 있는 예외 지정
throw()이 함수는 예외를 throw하지 않습니다. 그러나 throw()로 표시된 함수에서 예외가 throw되는 경우 Visual C++ 컴파일러는 unexpected를 호출하지 않습니다. 자세한 내용은 unexpected 및 unexpected를 참조하세요. 함수에 throw()가 표시된 경우 Visual C++ 컴파일러는 함수가 C++ 예외를 throw하지 않고 그에 따라 코드를 생성한다고 가정합니다. 함수가 예외를 throw하는 경우 C++ 컴파일러에서 수행할 수 있는 코드 최적화로 인해(함수가 C++ 예외를 throw하지 않는다는 가정을 전제) 프로그램이 제대로 실행하지 못할 수 있습니다.
강력한 보장을 제공하기 위해 위 예제를 변경
class PrettyMenu{
...
std::tr1::shared_ptr<Image> bgImage;
...
};

void PrettyMenu::changeBackground(std::istream& imgSrc)  //std::istream 대신 파일명을 전달하는 식으로 하면 예외보장
{
Lock m1(&mutex);
bgImage.reset(new Image(imgSrc));   //reset 함수를 이용해서 내부 포인터를 바꿔치기함
++imageChanges;
}

또 다른방법 복사후 맞바꾸기(copy-and-swap)는 어떤 객체를 수정하고 싶으면 그 객체의 사본을 하나 만들어 놓고 그 사본을 수정하는 것이다. 
pimpl 방법을 이용해서 구현
struct PMimpl{
std::tr1::shared_ptr<Image> bgImage;
int imageChanges;
};

class PrettyMenu{
...
private:
Mutex mutex;
std::tr1::shared_ptr<PMImpl> pImpl;
};
void PrettyMenu::changeBackground(std::istream& imgSrc)
{
using std::swap;
Lock m1(&mutex);
std::tr1::shared_ptr<PMImpl> pNew(new PMImpl(*pImpl));
pNew->bgImage.reset(new Image(imgSrc));
++pNew->imageChanges;
swap(pImpl, pNew);
}

PMImple 이 클래스가 아니라 구조체로 만들어진 이유는 pImpl 이 private 멤버로 되어 있어서 구현 객체의 데이터가 바로 캡슐화 되기 때문이다. 

void someFunc()
{
...
f1();
f2();
...
}
위 예제 같은 상황에서 Side Effect(부수효과) 가 발생한다. 

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

항목 30: 인라인 함수는 미주알고주알 따져서 이해해 두자
인라인 함수 : 코드 최적화할 때 용이, 함수 호출 비용 감소

헤더에서 클래스 정의 안에 함수를 바로 정의해 넣으면 컴파일러는 그 함수를 인라인 함수 후보로 점찍음
class Person{
public:
...
int age() const {return theAge;}
...
private:
int theAge;
};

명시적인 사용법은 함수 정의 앞에 inline 을 붙여서 사용하는 것
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();    // 인라인 되지 않음. 함수 포인터를 통해 호출 되므로

생성자와 소멸자는 인라인하기 좋지 않은 함수이다. (책의 예제를 보도록...)
보이기에는 텅비어 보이지만 멤버함수들을 생성하고 소멸하는 등의 코드가 숨어있다. 

라이브러리를 제공하는 경우에 inline 으로 선언할지에 대해 많은 고민을 해야한다. 
인라인 함수로 제공하는 경우 해당 함수가 수정되면 사용자가 컴파일을 다시 해야한다. 반대로 인라인 함수가 아니면
사용자는 링크만 다시 걸어주면 된다. (동적링크로 하면 사용자가 더욱 신경쓸 것이 없다.)

디버거 입장에서는 인라인 함수가 비호감 1순위이다. 

우선, 아무것도 인라인 하지말고, 꼭 인라인 해야되는 함수들에 대해서만 하나씩 인라인 함수로 선언하자.
정말 필요한 위치에 인라인 함수를 놓도록 수동 최적화를 하는 것이 나중에 좋다. (80-20법칙)

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

항목 31: 파일 사이의 컴파일 의존성을 최대로 줄이자
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;

};


위 코드만 가지고 Person 클래스가 컴파일 될까?? string, Date, Address 가 어떻게 정의되어있는지를 모르면 컴파일이 불가능하다. 
이들이 정의된 정보를 #include 지시자를 사용해야 컴파일 할 수 있다. 
#include <stding>
#include "date.h"
#include "address.h"

근데 여기서 #inlcue 문은 Person을 정의한 파일과 위의 헤더파일들 사이에 컴파일 의존성이란 것이 생긴다. 
이들과 엮이 헤더파일들이 하나만 수정되도 다른파일들까지 모두 컴파일이 다시 되어야한다. 

컴파일 의존성을 없애기 위해서 전방선언을 하는 방법의 문제 1
namespace std{
class string;
}
class Date;
class Address;

string 은 클래스가 아니라 typedef 로 정의한 타입동의어이다.(basic_string<char>) 제대로 전방선언을 하려면 템플릿을 가져와야 한다. 

전방선언 문제 2
int main()
{
int x;
Person p(params);
...
}
x 를 만나면 int 형 크기의 공간을 스택에 할당한다. Person 을 만나면 객체하나의 크기가 얼만인지를 컴파일러가 알 수 없다. 
스몰토크나 자바에서는 객체가 정의될 때 포인터를 담을 공간만 할당하므로 걱정할 필요가 없다. C++ 에서도 그런식으로 '포인터뒤에 실제 객체 구현부 숨기기' 방법을 사용할 수 있다.
int main()
{
int x;
Person *p;
...
}

pimpl(pointer to implementation) 패턴을 이용해서 구현하는 방법
#include <string>
#include <memory>
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::tr1::shared_ptr<PersonImpl> pImpl;
}

Person 클래스에 대한 구현 클래스 부분을 고쳐도 Person 의 사용자 쪽에서는 컴파일을 다시 할 필요가 없다. 

*객체 참조자 및 포인터로 충분한 경우에는 객체를 직접 쓰지 않는다. 
 - 어떤 타입에 대한 참조자 및 포인터를 정의할 때는 그 타입의 선언부만 필요하다. 반면, 어떤 타입의 객체를 정의할 때는 그 타입의 정의가 준비되어 있어야 한다.
* 할 수 있으면 클래스 정의 대신 클래스를 선언에 최대한 의존하도록 만든다.
 - 어떤 클래스를 사용하는 함수를 선언할 때는 그 클래스의 정의를 가져오지 않아도 된다. 심지어 클래스 객체를 값으로 전달하거나 반환하더라도 클래스 정의가 필요 없다. 
class Date;
Date today();
void clearAppointments(Date d);
이렇게 쓰는 이유는 모두가 이 함수를 호출하지 않을 수 있기 때문이다. 실제 함수를 호출하는 쪽에서 정의해서 쓰도록 한다. 

*선언부와 정의부에 대해 별도의 헤더 파일을 제공한다. 
 - 선언부를 위한 헤더파일과 정의부를 위한 헤더파일을 따로 관리한다. 사용자 측에서 전방선언 대신에 선언부 헤더파일을 include 해서 사용한다. 라이브러리 제작측은 항상 두가지 헤더파일을 제공한다. (NVR HAL 구조 참고)

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


반응형

'프로그래밍언어 > C++' 카테고리의 다른 글

[Effective C++] 항목 38~40  (0) 2018.04.10
[Effective C++] 항목 35~37  (0) 2018.04.10
[Effective C++] 항목 26~28  (0) 2018.04.10
[Effective C++] 항목 22 ~ 25  (0) 2018.01.23
[Effective C++] 항목 18 ~ 21  (0) 2018.01.23

댓글