[Effective C++] Chapter 5. 구현
Table of Contents
이펙티브 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 |
암시적 변환을 강제로 진행한다. (예를 들면, 비상수 객체를 상수 객체로 바꾸거나, int 를 double 로 바꾸거나, 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)
- 인라인 함수의 도움을 제대로 끌어내기 힘들다.
- 하지만 이런 단점에도 불구하고, 구현부가 바뀌었을 때 사용자에게 미칠 파급효과를 최소화하기 위해서는 사용되어야 좋겠다.
- 핸들 클래스와 인터페이스 클래스로 인해서 실행 속력이나 파일 크기에서 손해를 많이 봐서 클래스 사이에 결합도를 높이는 방법밖에 없다면 그때 사용하지 않으면 된다.