Item 29 : Strive for exception-safe code
아래의 코드를 살펴보자.
void PrettyMenu::ChangeBackground(std::istream& imgSrc)
{
lock(&mutex);
delete dbImage;
++ImageChanges;
bgImage = new Image(imgSrc);
unlock(&mutex);
}
예외 안전성의 측면에서, 이 함수는 나쁘다. 예외가 발생한 경우, exception safe 함수는 다음과 같은 사항을 만족해야 한다.
1. Leak no resources
위 함수는 new Image(imgSrc)에서 예외가 발생하면, unclock함수가 절대 호출되지 않는다.
따라서 nutex를 영원히 가지게 된다.
2. Don't allow data structures to become corrupted
new Image(imgSrc)에서 예외가 발생하면, bgImage는 삭제된 객체를 가리키는 포인터가 된다.
메모리 누수 문제에 대한 해결은 쉽다.
Item13에서 설명했듯이, 리소스를 관리하는 클래스 Lock을 선언하여 사용하면 된다.
데이터 손상에 대한 문제를 논의하기 전에 exception-safe function의 조건을 살펴보자.
Exception-safe functions은 아래 3가지 중 한가지를 제공해야 한다.
아래의 특성들은 함수의 선언이 아닌, 구현부에 의해 결정된다.
1. the basic guarantee
만약, exception이 throw되면, 프로그램의 모든 것은 유효한 상태로 남아있어야 한다.
어떠한 객체나 데이터 구조도 손상되면 안되며 모든 객체는 내부적으로 안정된 상태를 유지해야 한다.
그러나, 프로그램의 정확한 상태를 예측할 수 없다.
2. the strong guarantee
만약, exception이 throw되면, 프로그램의 상태는 변하지 않아야 한다.
이는 basic guarantee를 제공하는 것보다 더 쉽다.
3. the nothrow guarantee
절대로 exception을 throw하지 않는다.
모든 built-in-type들은 nothrow이다.
일반적으로 basic, strong guarantee를 많이 사용한다.
changeBackground 함수의 경우, strong guarantee를 제공하는 것은 어렵지 않다.
첫째로, PrettyMenu의 bgImage 데이터 멤버의 타입을 smart resource-managing 포인터 Image*로 바꾼다.
스마트 포인터를 사용함으로써 메모리 누수를 예방할 수 있다.
두번째로, image가 변화할 때까지 imageChanges를 증가시키는 순서로 statement의 순서를 조정한다.
일반적으로 어떤 일이 실제로 일어날때까지 어떤 일이 일어났음을 나타내기 위해 객체의 상태를 변경하지 않는 것이 좋다.
따라서 코드를 아래와 같이 수정할 수 있다.
class PrettyMenu{
...
std::tr1::shared_ptr<Image> bgImage;
...
};
void PrettyMenu::changeBackground(std::istream& imgSrc)
{
Lock ml(&mutex);
bgImage.reset(new Imatge(imgSrc));
++imageChanges;
}
그러나 이 코드는 오직 basic exception safety guarantee만을 제공한다.
만약 Image 생성자에서 예외가 발생하면, input stream의 read marker가 움직이고, 프로그램의 나머지에게 보이는 상태가 된다.
이 함수를 strong guarantee를 제공하도록 하기위해 istream 파라미터 타입을 image data를 포함하는 데이터로 바꿀 수 있다.
일반적으로 stong guarantee를 제공하는 방법에는 'copy and swap'이 있다.
수정하기를 원하는 객체를 복사한다음, 모든 필요한 수정을 복사본에 수행한다.
만약 modifying operation이 예외를 throw해도, 원본 객체는 수정되지 않은 채로 유지된다.
모든 수정이 성공적으로 이루어진 뒤, 수정된 객체와 원본 객체를 swap한다.
이것은 보통 모든 객체의 데이터를 실제 객체에서 분리된 구현 객체로 넣고, 실제 객체에 구현 객체에 대한 포인터를 제공함으로써 구현된다.
struct PMImpl{
std::tr1::shared_ptr<Image> bgImage;
int imageChanges;
};
class PrettyMenu{
Mutex mutex;
std::tr1::shared_ptr<Image> bgImage;
...
};
void PrettyMenu::changeBackground(std::istream& imgSrc)
{
using std::swap;
Lock ml(&mutex);
std::tr1::shared_ptr<PMImpl>pNew(new PMImpl(*pImpl)); //copy obj.data
pNew->bgImage.reset(new Image(imgSrc)); // modify the copy
++pNew->imageChanges;
swap(pImpl, pNew); // swap the new data into place
}
copy-and-swap 전략은 객체의 상태를 모든 것이 변경되거나 변경되지 않도록 만드는 완벽한 전약이다.
그러나 일반적으로 모든 함수를 strong exception-safe라고 보장하는 것은 아니다.
copy-and-swap의 사용은 다른 함수의 호출을 야기한다.
void someFunc()
{
...
f1();
f2();
...
}
만약, f1과 f2가 strong exception safe일지라도, 실제로 더 좋지 않다.
어쨌든 f1이 실행된 후에 프로그램의 상태는 임의적인 방법으로 변하고 만약 f2가 exception을 throw하면, 프로그램의 상태는 f2가 아무것도 변경하지 않았더라도 someFunc이 호출되었을 때와 같지 않다.
따라서, 효율성이나 복잡성의 비용으로 인해 strong exception safe guarantee를 유지하기는 어렵다.
소프트웨어 시스템은 exception-safe이거나 아니다.
부분적으로 exception-safe한 시스템은 존재하지 않는다.
만약 시스템이 not exception-safe한 하나의 함수가 존재한다면, 전체 시스템은 not exception-safe가 된다.
그 함수에 대한 호출이 메모리 누수나 데이터 구조 손상을 야기할 수 있기 때문이다.
많은 C++ 코드는 exception safety를 고려하지 않고 작성되었기 때문에, 오늘날의 많은 시스템들은 not exception safe이다.
그러나, 이 상태를 지속할 이유가 없다.
세 코드를 작성하거나 기존 코드를 수정할 때 예외로부터 안전하게 만드는 방법에 대해 신중히 생각해야 한다.
세가지 예외 안전 보장 중 어느 것이 작성하는 각 기능에 대해 실제로 제공할 수 있는 가장 강력한 보장인지 결정하여 legacy코드에 대한 호출로 인해 선택의 여지가 없는 경우에만 안전 보장이 없음으로 설정한다.
클라이언트와 미래의 유지 관리자를 위해 결정을 문서화 해야 한다.
함수의 예외 안전 보장은 인터페이스의 가시적인 부분이므로 함수 인터페이스의 다른 모든 측면을 선택하는 것처럼 신중하게 선택해야 한다.
정리하자면, Exception-safe 함수는 예외가 발생하는 경우에도, 메모리 누수가 없고 어떤 데이터 구조도 손상되지 않도록 한다.
이러한 함수들은 basic, strong, or nothrow guarantee를 제공한다.
strong guarantee는 copy-and-swap을 통해 구현될 수 있지만, strong guarantee는 모든 함수에 실용적이지 않다.
함수는 보통 호출하는 함수의 가장 약한 보증보다 더 강력하지 않은 보증을 제공할 수 있다.
Item 30 : Understand the ins and outs of inlining
Inline function은 macros보다 좋다.
함수 호출의 오버헤드없이 함수를 호출할 수 있다.
컴파일러 최적화는 일반적으로 함수 호출이 없는 확장된 코드를 위해 설계되었으므로 함수를 인라인할 때 컴파일러가 함수 body에서 컨텍스트별 최적화를 수행하도록 할 수 있다.
대부분의 컴파일러는 outlined 함수 호출에 대해 최적화를 수행하지 않는다.
inline 함수에 대한 아이디어는 모든 함수에 대한 호출을 그것의 code body로 대체하는 것이다.
이것은 object code의 사이즈를 증가시킨다.
한정된 메모리를 가진 기계에서는 inlining은 가능한 공간에 비해 너무 큰 프로그램을 생성할 수 있다.
만약 inline 함수 body의 크기가 매우 짧다면, function body에 대해 생성된 코드는 함수 호출을 통해 생성된 코드보다 짧을 수 있다.
이 경우에 inlining the function은 실제로 더 작은 object code를 만든다.
inline은 컴파일러에게 요청하는 것이다.
요청은 implicitly(암시적)이거나 explicitly(명시적)으로 주어진다.
암시적인 방법은 클래스 정의 안에 함수를 정의하는 것이다.
class Person{
public:
...
int age() const { return theAge; }
...
private:
int theAge;
};
이 함수들은 멤버 함수다.
friend 함수도 클래스 안에 정의될 수 있으므로, 그러한 경우 암시적으로 inline으로 선언된다.
명시적인 방법은 inline function을 함수 정의 앞에 inline 키워드로 선언하는 것이다.
예를들어, standard max template은 종종 다음과 같이 구현된다.
template<typename T>
inline const T& std::max(const T& a, const T& b){ return a < b & b:a; }
max는 template이고, inline function과 template 둘다 전형적으로 헤더파일에 선언된다.
이것 때문에 function template이 inline이어야 한다고 결론지을 수 있지만, 유효하지 않다.
컴파일하는 동안, 인라인이 이루어지기 때문에, Inline function은 전형적으로 헤더 파일에 위치한다.
function call을 호출된 함수의 body로 대체하기 위해, 컴파일러는 함수가 어떻게 생겼는지 알아야 한다.
템플릿은 전형적으로 헤더파일에 위치한다.
컴파일러는 템플릿을 사용할 때 인스턴스화 하기위해 템플릿이 어떻게 생겼는지 알아야 한다.
템플릿 인스턴스화는 인라인과 독립적이다.
만약 템플릿을 작성하고 함수 인스턴스화가 인라인되도록 하기 위해서는 템플릿을 인라인으로 선언해야 한다.
하지만, 인라인에는 비용이 있기 때문에, 인라인될 필요가 없는 템플릿의 경우, 템플릿을 인라인으로 선언하는 것을 피해야 한다.
인라인은 컴파일러가 무시할지도 모르는 요청이다.
대부분의 컴파일러는 너무 복잡한 경우 인라인 함수를 거부한다.
그리고 모든 virtual function 호출에 대한 시도는 인라인을 무시한다.
virtual은 어떤 함수가 호출될지 런타임 시간에 찾고, inline은 실행 전에 호출을 호출된 함수로 대체하는 것을 의미한다.
컴파일러가 어떤 함수를 호출할지 알지 못한다면, 인라인을 거부한다.
때때로 함수를 인라인할 의향이 있을 때조차 컴파일러는 inline 함수에 대해 function body를 생성한다.
예를 들어, inline 함수의 주소를 가지는 경우, 컴파일러는 전형적으로 outlined 함수를 생성한다.
생성자, 소멸자는 인라인을 하기에는 적절하지 못한 장소다.
정리하자면, 대부분 인라인을 작고 자주 호출되는 함수로 제한한다.
이것은 디버깅 및 바이너리 업그레이드 가능성을 용이하게 하고 잠재적인 코드 팽창을 최소화하며 더 프로그램이 빠르게 작동할 기회를 최대화한다.
템플릿이 헤더파일에 위치한다는 것만 고려해서, 함수 템플릿을 인라인으로 선언하면 안된다.
Item 31 : Minimize compilation dependencies between files
파일 사이의 컴파일 의존성을 최대로 줄이자.
함수의 구현을 변경하면 프로그램을 다시 빌드해야 한다.
이때, 모든 코드가 다시 컴파일되고 링크된다.
이는 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;
};
위 코드의 경우, 클래스 Person은 구현에 사용한 클래스의 정의에 대한 접근 없이 컴파일할 수 없다.
즉, std::string과 Date, Address에 대한 정보가 필요하다.
이러한 정의는 #include를 통해 제공된다.
Person을 정의하는 파일과 다른 헤더파일 사이의 컴파일 의존성이 있다.
이 헤더파일 중 하나가 변경되면, 그것에 의존하는 헤더파일 또한 변경되고 클래스 Person을 포함하는 파일 또한 다시 컴파일된다.
구현 세부사항을 따로 떼어 전방에 선언 해주자.
namespace std{
class string;
}
class Date;
class Addres;
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;
...
};
이 방법에는 두가지 문제점이 있다.
1. string은 클래스가 아닌 typedef이다.
결과적으로, string에 대한 전방선언은 틀렸으며, 이에 대한 적절한 전방 선언은 더 복잡하다.
따라서 그냥 #include를 하면 되며 일반적으로 이는 문제가 되지 않는다.
왜냐하면 standard library는 일반적으로 컴파일 bottleneck이 되지 않기 때문이다.
표준라이브러리에서 문제가 발생한다면, 인터페이스 디자인을 바꾸어야 한다.
2. 컴파일하는 동안 컴파일러는 모든 객체의 사이즈를 알아야 할 필요가 있다.
구현부가 없고, 선언만 있는 경우, 컴파일러는 객체에 대해 얼마나 메모리를 할당해야 하는지 알 수 없다.
컴파일러는 오직 객체에 대한 포인터에 대해서만 충분한 공간을 할당한다.
포인터 뒤에 객체 구현을 숨기는 방법을 사용하여 이 문제를 해결할 수 있다.
#include<string>
#include<memory>
class PersonImpl;
class Date;
class Addres;
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:
stsd::tr1::shared_ptr<PersonImpl> pImpl;
};
여기서, 클래스는 포인터를 제외한 어떤 데이터 멤버도 클래스의 구현부에서 가지지 않는다.
이러한 디자인을 종종 pimpl idiom(pointer to implementation)을 사용한다고 말할 수 있다.
Person 클래스의 클라이언트는 date, address, person에 대한 세부사항과 이별하게 된다.
이 클래스들에 대한 구현이 수정되더라도, Person 클라이언트는 다시 컴파일될 필요가 없다.
게다가, Person의 구현부를 볼 수 없기 때문에, 클라이언트는 이 디테일들에 의존하는 코드를 작성하지 않을 것이다.
이것은 인터페이스와 구현부를 분리한다.
이렇게 인터페이스와 구현을 나누는 열쇠는 정의부에 대한 의존성(dependencies on definition)을 선언부에 대한 의존성(dependencies on declarations)으로 바꾸어 놓는 데 있다.
즉, 헤더파일을 만들 때는 실용적으로 의미를 갖는 한 self-sufficient한 형태로 만들고, 안되면 다른 파일에 대해 의존성을 가지도록 하며 정의부가 아닌 선언부에 대한 의존성을 가지도록 만든다.
1. 객체 레퍼런스와 포인터로 충분한 경우에는 객체를 직접 사용하지 않는다.
어떤 타입에 대한 참조, 포인터를 정의할 때는 선언부만 필요하다.
2. 클래스 정의보다는 클래스 선언부에 의존하도록 만든다.
어떤 클래스를 사용하는 함수를 선언할 때 함수의 정의를 필요로 하지 않는다.
심지어 그 클래스 타입의 값으로 전달하거나 반환하더라도 필요 없다.
클래스에 대한 정의에 대한 부담을 함수를 호출하는 사용자에게 전가하여 의존성을 줄여준다.
3. 선언과 정의에 대한 분리된 헤더파일을 제공한다.
선언부와 구현부를 각각의 헤더파일로 만들때 반드시 짝으로 이루어져야 한다.
만약, 선언이 변경되면, 구현부에서도 변경되어야 한다.
결과적으로 클라이언트는 항상 선언부가 포함된 파일을 #include 해주어야 한다.
Handle class
pimpl idiom을 적용한 클래스들을 Handle classes라고 한다.
이 핸들 클래스에서 어떤 함수를 호출할 때, 핸들 클래스에 대응되는 구현 클래스 쪽으로 그 호출을 전달하여 구현 클래스가 실제 작업을 수행하도록 해야 한다.
#include "Person.h"
#include "PersonImpl.h"
Person::Person(const std::string& name, const Date& birthday, const Adress& addr)
:pImpl(new PersonImpl(name, birthday, addr)){}
std::string Person::name() const
{
return pImpl->name();
}
위 코드에서 Person 생성자는 PersonImpl 생성자를 호출하고, Person::name은 pImpl->name을 호출한다.
핸들 클래스가 직접 함수를 호출해도 실제 함수호출은 pImp에서 호출하는 것이 된다.
Interface Class
핸들 클래스의 대안으로 사용할 수 있는 방법이다.
Person클래스를 Interface class라 불리는 특별한 종류의 abstract base class로 만든다.
class Person{
public:
virtual ~Person();
virtual std::string name() const = 0;
virtual std::string birthDate() const = 0;
virtual std::string address() const = 0;
};
위 클래스는 derived class를 위한 interface를 특정하는 것이 목적이기 때문에, data members, constructor는 포함하지 않고, 오직 virtual destructor와 pure virtual function만 포함된다.
non-virtual function의 구현은 계통 내의 모든 클래스에서 일치해야 하기 때문에, 이러한 함수들은 Interface class의 일부로 구현해두는 것이 타당하다.
Interface class의 클라이언트는 Interface class의 interface가 변경되지 않는한 다시 컴파일되지 않는다.
Interface class의 클라이언트가 새로운 객체를 생성할 수 있는 방법이 필요하다.
실제로 인스턴스화될 수 있는 derived class의 생성자 역할을 하는 함수를 호출하고, 이 함수가 Interface class의 interface를 지원하는 동적으로 할당된 객체에 대한 포인터를 반환하도록 한다.
이를 factory function, virtual constsructors라고 한다.
이 함수들은 Interface lcass내부에 static으로 선언되기도 한다.
class Person{
public:
static std::tr1::shared_ptr<Person> create(const std::string name, const Date& birthday, const Address& addr);
};
실제로 이 기능이 작동하기 위해서 추상 클래스인 Person을 상속받는 derived class가 있어야 한다.
create함수는 Person 클래스의 derived class의 객체를 가리키는 포인터를 반환해야 한다.
'C++ > Effective C++' 카테고리의 다른 글
[Effective C++] Chapter 6: Inheritance and Object-Oriented Design(2) (0) | 2022.07.08 |
---|---|
[Effective C++] Chapter 6: Inheritance and Object-Oriented Design(1) (0) | 2022.07.06 |
[Effective C++] Chapter 5: Implementations(1) (0) | 2022.07.05 |
[Effective C++] Chapter 4: Design and Declarations(2) (0) | 2022.07.05 |
[Effective C++] Chapter 4: Design and Declarations(1) (0) | 2022.07.02 |