목차
C – C++은 C를 기본으로 한다.
객체 지향 개념의 C++ - 클래스, 캡슐화, 상속, 다형성, 가상 함수 등
템플릿 C++ - C++의 일반화 프로그래밍 부분
STL –템플릿 라이브러리. 컨테이너, 반복자, 알고리즘과 함수 객체.
이것만은 잊지 말자!
* C++를 사용한 효과적인 프로그래밍 규칙은 경우에 따라 달라집니다. 그 경우란, 바로 C++의 어떤 부분을 사용하느냐입니다.
‘가급적 선행 처리자보다 컴파일러를 더 가까이 하자.’
#define ASPECT_RATIO 1.653
const double AspectRatio = 1.653
매크로를 쓰면 코드에 ASPECT_RATIO가 등장하기만 하면 선행 처리자에 의해1.653으로 모두 바뀌면서 결국 목적 코드 안에1.653의 사본이 등장 횟수만큼 들어가게 되지만, 상수 타입의AspectRatio는 아무리 여러 번 쓰이더라도 사본은 딱 한 개만 생긴다.
#define을 상수로 교체하는 경우 주의점
상수 포인터(constant pointer)를 정의하는 경우 - const를 두 번 써야 한다.
const char* const authorName = “Scott Meyers”;
클래스 멤버로 상수를 정의하는 경우 - 정적( static ) 멤버로 만들어야 한다.
Class GamePlayer {
private:
static const int NumTurns = 5;
int scores[NumTurns];
…
};
나열자 둔갑술
동작 방식이 const보다는 #define에 더 가깝다.
- enum은 #define처럼 어떤 형태의 쓸데없는 메모리 할당도 절대 저지르지 않는다.
- 기존 매크로의 효율을 그대로 유지함은 물론 정규 함수의 모든 동작방식 및 타입 안전성까지 완벽히 취할 수 있는 방법
template<typename T>
inline void callWithMax( const T& a, const T& b)
{ F( a > b ? a : b ); }
이것만은 잊지 말자!
* 단순한 상수를 쓸 때는, #define보다 const 객체 혹은 enum을 우선 생각합시다.
* 함수처럼 쓰이는 매크로를 만들려면, #define 매크로보다 인라인 함수를 우선 생각합시다.
const 키워드가 *표의 왼쪽에 있으면 포인터가 가리키는 대상이 상수인 반면, const가 *표의 오른쪽에 있는 경우엔 포인터 자체가 상수입니다.
매개 변수 혹은 지역 객체를 수정할 수 없게 하는 것이 목적이라면 const로 선언하는 것을 잊지 말자.
멤버 함수에 붙는 const 키워드의 역할은 “해당 멤버 함수가 상수 객체에 대해 호출될 함수이다”라는 사실을 알려 준다. 이런 함수가 중요한 이유는? 첫째, 클래스의 인터페이스를 이해하기 좋게 하기 위해서. 둘째, 이 키워드를 통해 상수 객체를 사용할 수 있게 하자.
const 키워드가 있고 없고의 차이만 있는 멤버 함수들은 오버로딩이 가능하다.
mutable 은 비정적 데이터 멤버를 비트수준 상수성의 족쇄에서 풀어 주는 키워드이다.
const를 붙여 선언하면 컴파일러가 사용상의 에러를 잡아내는 데 도움을 준다. const는 어떤 유효범위에 있는 객체에도 붙을 수 있으며, 함수 매개변수 및 반환 타입, 멤버 함수에도 붙을 수 있다.
상수 멤버 및 비상수 멤버 함수가 기능적으로 서로 똑같게 구현되어 있을 경우에는 코드 중복을 피하기 위해 비상수 버전이 상수 버전을 호출하도록 만든다.
이것만은 잊지 말자!
* const를 붙여 선언하면 컴파일러가 사용상의 에러를 잡아내는 데 도움을 줍니다. const는 어떤 유효범위에 있는 객체에도 붙을 수 있으며, 함수 매개변수 및 반환 타입에도 붙을 수 있으며, 멤버 함수에도 붙을 수 있습니다.
* 컴파일러 쪽에서 보면 비트수준 상수성을 지켜야 하지만, 여러분은 개념적인(논리적인) 상수성을 사용해서 프로그래밍해야 합니다.
* 상수 멤버 및 비상수 멤버 함수가 기능적으로 서로 똑같게 구현되어 있을 경우에는 코드 중복을 피하는 것이 좋은데, 이때 비상수 버전이 상수 버전을 호출하도록 만드세요.
생성자에서 그 객체의 모든 것을 초기화하라. C++ 규칙에 의하면 어떤 객체이든 그 객체의 데이터 멤버는 생성자의 본문이 실행되기 전에 초기화되어야 한다. 상수이거나 참조자로 되어 있는 데이터 멤버의 경우엔 반드시 초기화되어야 한다.
객체를 구성하는 데이터의 초기화 순서
1. 기본 클래스는 파생 클래스보다 먼저 초기화된다.
2. 클래스 데이터 멤버는 그들이 선언된 순서대로 초기화된다.
3. 비지역 정적 객체의 초기화 순서는 개별 번역 단위에서 정해진다.
4. 지역 정적 객체는 함수 호출 중에 그 객체의 정의에 최초로 닿았을 때 초기화되도록 만들어져 있다.
이것만은 잊지 말자!
* 기본제공 타입의 객체는 직접 손으로 초기화합니다. 경우에 따라 저절로 되기도 하고 안되기도 하기 때문입니다.
* 생성자에서는, 데이터 멤버에 대한 대입문을 생성자 본문 내부에 넣는 방법으로 멤버를 초기화하지 말고 초기화 리스트를 즐겨 사용합시다. 그리고 초기화 리스트에 데이터 멤버를 나열할 때는 클래스에 각 데이터 멤버가 선언된 순서와 똑같이 나열합시다.
* 여러 번역 단위에 있는 비지역 정적 객체들의 초기화 순서 문제는 피해서 설계해야 합니다. 비지역 정적 객체를 지역 정적 객체로 바꾸면 됩니다.
C++의 어떤 멤버 함수는 여러분이 클래스 안에 직접 선언해 넣지 않으면 컴파일러가 저절로 선언해 주도록 되어 있습니다. 바로 복사 생성자, 복사 대입 연산자, 그리고 소멸자 인데, 좀더 자세히 말하면 이때 컴파일러가 만드는 함수의 형태는 모두 기본형입니다. 게다가, 생성자조차도 선언되어 있지 않으면 역시 컴파일러가 여러분 대신에 기본 생성자를 선언해 놓습니다. 이들은 모두 public 멤버이며, inline함수입니다.
참조자를 데이터 멤버로 갖고 있는 클래스에 대입 연산을 지원하려면 여러분이 직접 복사 대입 연산자를 정의해 주어야 합니다. 복사 대입 연산자를 private로 선언한 기본 클래스로부터 파생된 클래스의 경우, 이 클래스는 암시적 복사 대입 연산자를 가질 수 없습니다. 컴파일러가 거부해 버리니까요.
이것만은 잊지 말자!
* 컴파일러는 경우에 따라 클래스에 대해 기본 생성자, 복사 생성자, 복사 대입 연산자, 소멸자를 암시적으로 만들어 놓을 수 있습니다.
복사를 막고 싶은 경우.
컴파일러가 생성하는 함수는 모두 공개된다는, 즉 public 멤버가 된다. 복사 생성자와 복사 대입 연산자가 저절로 만들어지는 것을 막기 위해 여러분이 직접 선언해야 한다는 점은 맞지만, 이것들을 public 멤버로 선언해야 한다고 요구하는 곳은 아무 데도 없다는 점을 기억하셔야 하겠습니다. 그러니까 public 멤버로 두지 말고, 복사 생성자 및 복사 대입 연산자를 private 멤버로 선언하도록 합시다. 일단 클래스 멤버 함수가 명시적으로 선언되기 때문에, 컴파일러는 자신의 기본 버전을 만들수 없게 되지요. 게다가 이 함수들이 비공개(private)의 접근성을 가지므로, 외부로부터의 호출을 차단할 수 있습니다.
여기까지 90점. private 멤버 함수는 그 클래스의 멤버 함수 및 프렌드(friend) 함수가 호출할 수 있다는 점이 여전히 허점입니다. 이것까지 막으려면, 그러니까 '정의(define)'를 안 해 버리는 기지를 발휘해 보면 어떨까요? 정의되지 않은 함수를 누군가가 어쩌다 실수로 호출하려 했다면 분명히 링크 시점에 에러를 보게 될 테니 괜찮습니다. 실제로 이 꼼수[멤버 함수를 private 멤버로 선언하고 일부러 정의(구현)하지 않는 방법]는 꽤 널리 퍼지면서 하나의 '기법'으로 굳어지기까지 했습니다.
이 꼼수를 사용해보면,
class HomeForSale {
public :
...
private :
...
HomeForSale( const HomeForSale&); // 선언만 달랑 있다.
HomeForSale& operator=(const HomeForSale&);
};
한 가지 더 덧붙이면, 링크 시점 에러를 컴파일 시점 에러로 옮길 수도 있습니다. 복사 생성자와 복사 대입 연산자를 private로 선언하되, 이것을 HomeForSale 자체에 넣지 말고 별도의 기본 클래스에 넣고 이것으로부터 HomeForSale을 파생시키는 것입니다. 그리고 그 별도의 기본 클래스는 복사 방지만 맡는다는 특별한 의미를 부여합니다. 소개가 거창했지만 이 기본 클래스는 사실 단순 그 자체입니다.
class Uncopyable {
protected: // 파생된 객체에 대해서
Uncopyable() {} // 생성과 소멸을
~Uncopyable() {} // 허용한다.
private:
Uncopyable(const Uncopyable&); // 하지만 복사는 방지한다.
Uncopyable& operator=(const Uncopyable&);
};
복사를 막고 싶은 HomeForSale 객체는 이제 이렇게 바꿔 봅시다. Uncopyable로부터 상속받게 하고 그냥 내버려 두는 것으로 끝입니다.
class HomeForSale : private Uncopyable { // 복사 생성자도,
... // 복사 대입 연산자도
}; // 이제는 선언되지 않는다.
HomeForSale 객체의 복사를 외부(멤버 함수나 프렌드 함수까지도)에서 시도하려고 할 때 컴파일러는 HomeForSale 클래스만의 복사 생성자와 복사 대입 연산자를 만들려고 할 것입니다. 항목 12에서 보겠지만, 컴파일러가 생성한 복사 함수는 기본 클래스의 대응 버전을 호출하게 되어 있습니다. 그런데 이런 호출은 지금 통하지 않게 됩니다. 아시다시피 복사 함수들이 기본 클래스에서 공개되어 있지 않기 때문입니다.
이것만은 잊지 말자!
* 컴파일러에 자동으로 제공하는 기능을 허용치 않으려면, 대응되는 멤버 함수를 private로 선언한 후에 구현은 하지 않은 채로 두십시오. Uncopyable과 비슷한 기본 클래스를 쓰는 것도 한 방법입니다.
class TimeKeeper {
public:
TimeKeeper();
~TimeKeeper();
...
};
class AtomicClock: public TimeKeeper { ... };
TimeKeeper* ptk = getTimeKeeper(); // TimeKeeper 클래스 계통으로부터 동적으로 할당된 객체를 얻는다.
... // 이 객체를 사용한다.
delete ptk; // 자원 누출을 막기 위해 해제(삭제)한다.
C++의 규정에 의하면, 기본 클래스 포인터를 통해 파생 클래스 객체가 삭제될 때 그 기본 클래스에 비가상 소멸자가 있으면 프로그램 동작은 미정의 사항이라고 되어 있습니다. 대개 그 객체의 파생 클래스 부분이 소멸되지 않게 되지요. 이 문제를 없애는 방법은 기본 클래스의 소멸자 앞에 virtual 하나 붙여주면 파생 클래스 부분까지 객체 전부가 소멸됩니다.
class TimeKeeper {
public:
TimeKeeper();
virtual ~TimeKeeper();
...
};
TimeKeeper *ptk = getTimeKeeper();
...
delete ptk; // 이제 제대로 동작한다.
가상 함수를 하나라도 가진 클래스는 가상 소멸자를 가져야 하는 게 대부분 맞습니다.
가상 함수를 C++에서 구현하려면 클래스에 별도의 자료구조가 하나 들어가야 합니다. 이 자료구조는 프로그램 실행 중에 주어진 객체에 대해 어떤 가상 함수를 호출해야 하는지를 결정하는 데 쓰이는 정보인데, 실제로는 포인터의 형태를 취하는 것이 대부분이고, 대개 vptr['가상 함수 테이블 포인터(virtual table pointer)'란 뜻]이라는 이름으로 불립니다. vptr은 가상 함수의 주소, 즉 포인터들의 배열을 가리키고 있으며 가상 함수 테이블 포인터의 배열은 vtbl['가상 함수 테이블(virtual table)']이라고 불립니다. 가상 함수를 하나라도 갖고 있는 클래스는 반드시 그와 관련된 vtbl을 갖고 있습니다. 어떤 객체에 대해 어떤 가상 함수가 호출되려고 하면, 호출되는 실제 함수는 그 객체의 vptr이 가리키는 vtbl에 따라 결정됩니다.
가상 소멸자를 선언하는 것은 그 클래스에 가상 함수가 하나라도 들어 있는 경우에만 한정하세요.
소멸자가 동작하는 순서는 이렇습니다. 상속 계통 구조에서 가장 말단에 있는 파생 있는 파생 클래스의 소멸자가 먼저 호출되는 것을 시작으로, 기본 클래스 쪽으로 거쳐 올라가면서 각 기본 클래스의 소멸자가 하나씩 호출됩니다.
기본 클래스의 손에 가상 소멸자를 쥐어 주자는 규칙은 다형성을 가진 기본 클래스, 그러니까 기본 클래스 인터페이스를 통해 파생 클래스 타입의 조작을 허용하도록 설계된 기본 클래스에만 적용된다는 사실을 알려드리고자 합니다.
이것만은 잊지 말자!
* 다형성을 가진 기본 클래스에는 반드시 가상 소멸자를 선언해야 합니다. 즉, 어떤 클래스가 가상 함수를 하나라도 갖고 있으면, 이 클래스의 소멸자도 가상 소멸자이어야 합니다.
* 기본 클래스로 설계되지 않았거나 다형성을 갖도록 설계되지 않은 클래스에는 가상 소멸자를 선언하지 말아야 합니다.
데이터베이스 연결을 나타내는 클래스를 쓰고 있다고 가정하고 이야기 하자.
class DBConnection {
public:
...
static DBConnection create(); // DBConnection 객체를 반환하는 함수.
void close(); // 연결을 닫는다. 이때 연결이 실패하면 예외를 던진다.
};
-----------------------------------------------------------------------------------
class DBConn { // DBConnection 객체를 관리하는 클래스
public:
...
~DBConn()
{ db.close(); } // 데이터베이스 연결이 항상 닫히도록 확실히 챙겨주는 함수
private:
DBConnection db;
};
------------------------------------------------------------------------------------
{ // 블록 시작
DBConn dbc(DBConnection::create() ); // DBConnection 객체를 생성하고 이것을 DBConn
// 객체로 넘겨서 관리를 맡긴다.
... // DBConn 인터페이스를 통해
// 그 DBConnection 객체를 사용한다.
} // 블록 끝. DBConn 객체가 여기서 소멸된다.
// 따라서 DBConnection 객체에 대한 close 함수의
// 호출이 자동으로 이루어진다.
close를 호출했는데 여기서 예외가 발생했다고 가정하면 어떻게 될까요? DBConn의 소멸자는 분명히 이 예외를 전파할 것입니다. 쉽게 말해 그 소멸자에서 예외가 나가도록 내버려 둔다는 거죠. 바로 이것이 문제입니다. 예외를 던지는 소멸자는 곧 '걱정거리'를 의미하기 때문입니다.
걱정거리를 피하는 방법은 두 가지입니다. DBConn의 소멸자는 이 둘 중 하나를 선택할 수 있고요.
close에서 예외가 발생하면 프로그램을 바로 끝냅니다. 대개 abort를 호출합니다.
DBConn::~DBConn()
{
try { db.close(); }
catch (...) {
close 호출이 실패했다는 로그를 작성한다;
std::abort();
}
}
객체 소멸이 진행되다가 에러가 발생한 후에 프로그램 실행을 계속할 수 없는 상황이라면 꽤 괜찮은 선택입니다. 간단히 말해, abort를 호출해서 못 볼꼴을 미리 안 보여 주겠다는 의도죠.
close를 호출한 곳에서 일어난 예외를 삼켜 버립니다.
DBConn::~DBConn()
{
try { db.close(); }
catch (...) {
close 호출이 실패했다는 로그를 작성한다;
}
}
대부분의 경우에서 예외 삼키기는 그리 좋은 발상이 아닙니다. 중요한 정보가 묻혀 버리기 때문입니다. 무엇이 잘못됐는지를 알려 주는 정보 말입니다.
더 좋은 전략을 고민해 보도록 하죠. DBConn 인터페이스를 잘 설계해서, 발생할 소지가 있는 문제에 대처할 기회를 사용자가 가질 수 있도록 하면 어떨까요? 이를테면, DBConn에서 close 함수를 직접 제공하게 하면 이 함수의 실행 중에 발생하는 예외를 사용자가 직접 처리할 수 있을 것입니다. DBConnection이 닫혔는지의 여부를 유지했다가, 닫히지 않았으면 DBConn의 소멸자에서 닫을 수도 있을 것이고요. 이렇게 하면 데이터베이스 연결이 누출되지 않습니다. 하지만 소멸자에서 호출하는 close마저 실패한다면( 그래서 예외가 일어나면), '끝내거나 혹은 삼켜 버리거나' 모드로 다시 돌아올 수밖에 없지요.
class DBConn {
public:
...
void close()
{
db.close();
closed = true;
}
~DBConn()
{
if(!closed)
try {
db.close();
}
catch (...) {
close 호출이 실패했다는 로그를 작성합니다;
...
}
}
private:
DBConnection db;
bool closed;
};
close 호출의 책임을 DBConn의 소멸자에서 DBConn의 사용자로 떠넘기는 이런 아이디어는 어떤 동작이 예외를 일으키면서 실패할 가능성이 있고 또 그 예외를 처리해야 할 필요가 있다면, 그 예외는 소멸자가 아닌 다른 함수에서 비롯된 것이어야 한다는 것이 포인트입니다. 이유는 ? 예외를 일으키는 소멸자는 시한폭탄이나 마찬가지라서 프로그램의 불완전 종료 혹은 미정의 동작의 위험을 내포하고 있기 때문입니다. 위의 예제는 사용자가 호출할 수 있는 close 함수를 두긴 했지만 부담을 떠넘기는 모양새가 아닙니다. 사용자에게 에러를 처리할 수 있는 기회를 주는 것이죠. 이것마저 없다면 사용자는 예외를 대처할 기회를 못 잡게 됩니다.
이것만은 잊지 말자!
* 소멸자에서는 예외가 빠져나가면 안 됩니다. 만약 소멸자 안에서 호출된 함수가 예외를 던질 가능성이 있다면, 어떤 예외이든지 소멸자에서 모두 받아낸 후에 삼켜 버리든지 프로그램을 끝내든지 해야 합니다.
* 어떤 클래스의 연산이 진행되다가 던진 예외에 대해 사용자가 반응해야 할 필요가 있다면, 해당 연산을 제공하는 함수는 반드시 보통의 함수(즉, 소멸자가 아닌 함수)이어야 합니다.
// 주식 거래를 본떠 만든 클래스 계통 구조가 있다고 가정합시다. 이를테면 매도 주문, 매수 주문등등이 있겠죠. 이러한 거래를 모델링하는 데 있어서 중요한 포인트라면 감사 기능이 있어야 한다는 점입니다. 그렇기 때문에 주식 거래 객체가 생성될 때마다 감사 로그에 적절한 거래 내역이 만들어지도록 해야 합니다. 결국, 다음과 같은 클래스 정도가 나와 주는게 맞을 것 같습니다.
class Transaction { // 기본 클래스
public :
Transaction();
virtual void logTransaction() const = 0; // 타입에 따라 달라지는 로그 기록
...
};
Transaction::Transaction() // 기본 클래스 생성자의 구현
{
...
logTransaction(); // 마지막 동작으로, 이 거래를 로깅 합니다.
}
class BuyTransaction : public Transaction { // 파생 클래스
public :
virtual void logTransaction() const; // 타입에 따른 거래내역 로깅 구현
...
}
class SellTransaction : public Transaction { // 파생 클래스
public :
virtual void logTransaction() const; // 타입에 따른 거래내역 로깅 구현
...
};
BuyTransaction b;
BuyTransaction 생성자가 호출되는 것은 어쨌든 맞습니다. 그러나 우선은 Transaction 생성자가 호출되어야 합니다. 파생 클래스 객체가 생성될 때 그 객체의 기본 클래스 부분이 파생 클래스 부분보다 먼저 호출되는 것이 정석입니다. Transaction 생성자의 마지막 줄을 보면 가상 함수인 logTransaction을 호출하는 문장이 보이는데, 여기서 호출되는 logTransaction함수는 BuyTransaction의 것이 아니라 Transaction의 것이란 사실! 기본 클래스의 생성자가 호출될 동안에는, 가상 함수는 절대로 파생 클래스 쪽으로 내려가지 않습니다. 그 대신, 객체 자신이 기본 클래스 타입인 것처럼 동작합니다. 기본 클래스 생성 과정에서는 가상 함수가 먹히지 않습니다.
기본 클래스 생성자는 파생 클래스 생성자보다 앞서서 실행되기 때문에, 기본 클래스 생성자가 돌아가고 있을 시점에 파생 클래스 데이터 멤버는 아직 초기화된 상태가 아니라는 것이 핵심입니다. 어떤 객체의 초기화되지 않은 영역을 건드린다는 것은 치명적인 위험을 내포하기 때문에, C++는 여러분이 이런 실수조차 하지 못하도록 막은 것입니다.
파생 클래스 객체의 기본 클래스 부분이 생성되는 동안은, 그 객체의 타입은 바로 기본 클래스입니다. 호출되는 가상 함수는 모두 기본 클래스의 것으로 결정될 뿐만 아니라, 런타임 타입 정보를 사용하는 언어 요소[ 이를테면 dynamic_cast라든지 typeid같은 것 ]를 사용한다고 해도 이 순간엔 모두 기본 클래스 타입의 객체로 취급합니다.
이제는 이 문제의 대처 방법에 대해 말씀드리겠습니다. logTransaction을 Transaction 클래스의 비가상 멤버 함수로 바꾸는 것입니다. 그러고 나서 파생 클래스의 생성자들로 하여금 필요한 로그 정보를 Transaction의 생성자로 넘겨야 한다는 규칙을 만듭니다. logTransaction이 비가상 함수이기 때문에 Transaction의 생성자는 이 함수를 안전하게 호출할 수 있습니다. 다음과 같이 말이죠.
class Transaction {
public :
explicit Transaction( const std::string& logInfo );
void logTransaction( const std::string& logInfo ) const; // 이제는 비가상 함수
...
};
Transaction::Transaction( const std::string& logInfo )
{
...
logTransaction( logInfo ); // 비가상 함수를 호출
}
class BuyTransaction: public Transaction {
public :
BuyTransaction( parameters ) : Transaction( createLogString( parameters) )
{ ... } // 로그 정보를 기본 클래스 생성자로 넘긴다.
...
private:
static std::string createLogstring( parameters );
};
기본 클래스 생성될 때는 가상 함수를 호출한다 해도 기본 클래스의 울타리를 넘어 내려갈 수 없기 때문에, 필요한 초기화 정보를 파생 클래스 쪽에서 기본 클래스 생성자로 '올려'주도록 만듦으로써 부족한 부분을 역으로 채울 수 있다는 것입니다. createLogString 함수는 기본 클래스 생성자 쪽으로 넘길 값을 생성하는 용도로 쓰이는 도우미 함수인데, 정적 멤버로 되어 있기 때문에, 생성이 채 끝나지 않은 BuyTransaction 객체의 미초기화된 데이터 멤버를 자칫 실수로 건드릴 위험도 없습니다.
이것만은 잊지 말자!
* 생성자 혹은 소멸자 안에서 가상 함수를 호출하지 마세요. 가상 함수라고 해도, 지금 실행중인 생성자나 소멸자에 해당되는 클래스의 파생 클래스 쪽으로는 내려가지 않으니까요.
x = y = z = 15; // 대입이 사슬처럼 이어집니다.
대입 연산이 가진 특성은 바로 우측 연관 연산이라는 점입니다. 즉, 위의 대입 연산 사슬은 다음과 같이 분석됩니다.
x = ( y = ( z = 15 ) );
이렇게 대입 연산이 사슬처럼 엮이려면 대입 연산자가 좌변 인자에 대한 참조자를 반환하도록 구현되어 있을 것입니다. 이런 구현은 일종의 관례인데, 여러분이 나름대로 만드는 클래스에 대입 연산자가 혹시 들어간다면 여러분도 이 관례를 지키는 것이 좋습니다.
class Widget {
public :
...
Widget& operator=( const Widget& rhs ) // 반환 타입은 현재의 클래스에 대한
{ // 참조자입니다.
...
return *this; // 좌변 객체(의 참조자)를 반환
}
...
};
"좌면 객체의 참조자를 반환하게 만들자"라는 규약은 위에서 보신 단순 대입형 연산자 말고도 모든 형태의 대입 연산자에서 지켜져야 합니다. 다시 아래의 코드를 보세요.
class Widget {
public :
...
Widget& operator+=( const Widget& rhs )
{ // +=, -=, *= 등에도
... // 동일한 규약이 적용
return *this;
}
Widget& operator=(int rhs)
{ // 대입 연산자의 매개변수 타입이
... // 일반적이지 않은 경우에도
return *this; // 동일한 규약 적용
}
...
};
이것만은 잊지 말자!
* 대입 연산자는 *this의 참조자를 반환하도록 만드세요
자기대입이란, 어떤 객체가 자기 자신에 대해 대입 연산자를 적용하는 것을 말합니다.
class Widget { ... };
Widget w;
...
w = w; // 자기에 대한 대입
a[i] = a[j]; // i 및 j가 같은 값을 갖게 되면 자기대입문이 된다.
*px = *py; // px 및 py가 가리키는 대상이 같으면 자기대입이 된다.
이러한 자기 대입이 생기는 이유는 여러 곳에서 하나의 객체를 참조하는 상태, 다시 말해 중복참조라고 불리는 것 때문입니다. 같은 타입으로 만들어진 객체 여러 개를 참조자 혹은 포인터로 물어 놓고 동작하는 코드를 생성할 때는 같은 객체가 사용될 가능성을 고려하는 것이 일반적으로 바람직한 자세가 되겠습니다.
자기 참조의 가능성이 있는 위험천만한 operator=의 구현 코드
class Bitmap {...};
class Widget {
....
private:
Bitmap *pb;
};
Widget&
Widget::operator=(const Widget& rhs)
{
delete pb;
pb = new Bitmap(*rhs.pb);
return *this;
}
여기서 찾을 수 있는 자기 참조 문제는 operator= 내부에 *this(대입되는 대상)와 rhs가 같은 객체일 가능성이 있다는 것입니다. 이 둘이 같은 객체이면, delete 연산자가 *this 객체의 비트맵에만 적용되는 것이 아니라 rhs의 객체까지 적용되어 버립니다. 그러니까 이 함수가 끝나는 시점이 되면 해당 Widget 객체는 자신의 포인터 멤버를 통해 물고 있던 객체가 삭제된 상태가 되는 불상사를 당하게 됩니다.
많은 경우에 문장 순서를 세심하게 바꾸는 것만으로 예외에 안전한(동시에 자기대입에 안전한) 코드가 만들어진다.
Widget& Widget::operator=(const Widget& rhs)
{
Bitmap *pOrig = pb; // 원래의 pb를 어딘가에 기억해 둡니다.
pb = new Bitmap(*rhs.pb); // 다음, pb가 *pb의 사본을 가리키게 만듭니다.
delete pOrig; // 원래의 pb를 삭제합니다.
return *this;
}
이 코드는 이제 예외에 안전합니다. 'new Bitmap' 부분에서 예외가 발생하더라도 pb는 변경되지 않은 상태가 유지되기 때문이죠. 게다가 일치성 검사 같은 것이 없음에도 불구하고 이 코드는 자기대입 현상을 완벽히 처리하고 있습니다.
이것만은 잊지 말자!
* operator=을 구현할 때, 어떤 객체가 그 자신에 대입되는 경우를 제대로 처리하도록 만듭시다. 원본 객체와 복사대상 객체의 주소를 비교해도 되고, 문장의 순서를 적절히 조정할 수도 있으며, 복사 후 맞바꾸기 기법을 써도 됩니다.
* 두 개 이상의 객체에 대해 동작하는 함수가 있다면, 이 함수에 넘겨지는 객체들이 사실 같은 객체인 경우에 정확하게 동작하는지 확인해 보세요.
클래스에 데이터 멤버를 추가했으면, 추가한 데이터 멤버를 처리하도록 복사 함수를 다시 작성할 수 밖에 없습니다. 그뿐 아니라 생성자도 전부 갱신해야 하고 비표준형 operator= 함수도 전부 바꿔줘야 합니다.
파생 클래스에 대한 복사 함수를 여러분 스스로 만든다고 결심했다면 기본 클래스 부분을 복사에서 빠뜨리지 않도록 각별히 주의하셔야 하겠습니다. 물론 기본 클래스 부분은 private 멤버일 가능성이 아주 높기 때문에 이들을 직접 건드리긴 어렵습니다. 그 대신, 파생 클래스의 복사 함수 안에서 기본 클래스의 복사 함수를 호출하도록 만들면 됩니다.
객체의 복사 함수를 작성할 때는 다음의 두 가지를 꼭 확인해야 합니다. 첫째, 해당 클래스의 데이터 멤버를 모두 복사하고, 둘째는 이 클래스가 상속한 기본 클래스의 복사 함수도 꼬박꼬박 호출해 주도록 합시다.
이것만은 잊지 말자!
* 객체 복사 함수는 주어진 객체의 모든 데이터 멤버 및 모든 기본 클래스 부분을 빠뜨리지 말고 복사해야 합니다.
* 클래스의 복사 함수 두 개를 구현할 때, 한쪽을 이용해서 다른 쪽을 구현하려는 시도는 절대로 하지 마세요. 그 대신, 공통된 동작을 제3의 함수에다 분리해 놓고 양쪽에서 이것을 호출하게 만들어서 해결합시다.
프로그래밍 분야에서 자원(resource)이란, 사용을 일단 마치고 난 후엔 시스템에 돌려주어야 하는 모든 것을 일컫습니다.
void f()
{
Investment *pInv = createInvestment(); // 팩토리 함수를 호출
... // pInv를 사용
delete pInv; // 객체 해제
}
createInvestment() 는 클래스의 객체를 동적 할당하고 그 포인터를 반환합니다. 이 객체의 해제는 호출자 쪽에서 직접 해야 합니다. 위 소스는 멀쩡해 보이지만, createInvestment 함수로부터 얻은 투자 객체의 삭제에 실패할 수 있는 경우가 세상에 한두 가지가 아닙니다. 첫 번째는 '...' 부분 어딘가에서 '도중하차' return 문이 들어 있을 가능성입니다. 이 문장이 실행되면 프로그램의 제어가 delete문까지 도달하지 않게 됩니다. 또 '...' 안의 어떤 문장에서 예외를 던질 수도 있다는 점도 고려해야 합니다. delete 문을 건너뛰는 경우는 여러 가지이지만, 결과는 똑같습니다. 우선 투자 객체를 담고 있는 메모리가 누출되고, 그와 동시에 그 객체가 갖고 있던 자원까지 모두 샙니다.
createInvestment 함수로 얻어낸 자원이 항상 해제되도록 만들 방법은, 자원을 객체에 넣고 그 자원 해제를 소멸자가 맡도록 하며, 그 소멸자는 실행 제어가 f를 떠날 때 호출되도록 만드는 것입니다. 자원을 객체에 넣음으로써, C++가 자동으로 호출해 주는 소멸자에 의해 해당 자원을 저절로 해제할 수 있습니다.
소프트웨어 개발에 쓰이는 상당수의 자원이 힙에서 동적으로 할당되고, 하나의 블록(block) 혹은 함수 안에서만 쓰이는 경우가 잦기 때문에 그 블록 혹은 함수로부터 실행 제어가 빠져 나올 때 자원이 해제되는 게 맞습니다. 표준 라이브러리를 보면 auto_ptr 이란 것이 있는데, 바로 이런 용도에 쓰라고 마련된 클래스입니다. auto_ptr은 포인터와 비슷하게 동작하는 객체 스마트 포인터로서, 가리키고 있는 대상에 대해 소멸자가 자동으로 delete를 불러주도록 설계되어 있습니다. 그럼 f에서 생길 수 있는 자원 누출을 막기 위해 auto_ptr을 사용하는 방법을 보시겠습니다.
void f()
{
std::auto_ptr<Investment>pInv(createInvestment()); // 팩토리 함수를 호출
... // pInv 사용
} // auto_ptr의 소멸자를 통해 pInv 삭제
자원 관리 객체를 사용하는 방법의 중요한 두 가지 특징
첫째, 자원을 획득한 후에 자원 관리 객체에게 넘깁니다. 자원 획득 즉 초기화(Resource Acquisition Is Initialization: RAII) : 자원 획득과 자원 관리 객체의 초기화가 바로 한 문장에서 이루어진다.
둘째, 자원 관리 객체는 자신의 소멸자를 이용해서 자원이 확실히 해제되도록 합니다. 소멸자는 어떤 객체가 소멸될 때(유효범위를 벗어난 경우가 한 예) 자동적으로 호출되기 때문에, 실행 제어가 어떤 경위로 블록을 떠나는가에 상관없이 자원 해제가 제대로 이루어지게 되는 것입니다.
auto_ptr은 자신이 소멸될 때 자신이 가리키고 있는 대상에 대해 자동적으로 delete를 먹이기 때문에, 어떤 객체를 가리키는 auto_ptr의 개수가 둘 이상이면 절대로 안된다. auto_ptr 객체를 복사하면 원본 객체는 null로 만든다. 복사하는 객체만이 그 자원의 유일한 소유권을 갖는다고 가정한다. auto_ptr을 쓸 수 없는 상황이라면 그 대안으로 참조 카운트 방식 스마트 포인터(RCSP)가 아주 괜찮다. RCSP는 특정한 어떤 자원을 가리키는 외부 객체의 개수를 유지하고 있다가 그 개수가 0이 되면 해당 자원을 자동으로 삭제하는 스마트 포인터 입니다.
이것만은 잊지 말자!
* 자원 누출을 막기 위해, 생성자 안에서 자원을 획득하고 소멸자에서 그것을 해제하는 RAII객체를 사용합시다.
* 일반적으로 널리 쓰이는 RAII 클래스는 rt1::shared_ptr 그리고 auto_ptr입니다. 이 둘 가운데 tr1::shared_ptr이 복사시의 동작이 직관적이기 때문에 대개 더 좋습니다. 반면, auto_ptr은 복사되는 객체(원본 객체)를 null로 만들어 버립니다.
RAII 객체가 복사될 때 어떤 동작이 이루어져야 할까요? 다음의 선택지 중 하나를 골라 잡고 싶을 겁니다.
복사를 금지합니다. 실제로, RAII 객체가 복사되도록 놔두는 것 자체가 말이 안되는 경우가 꽤 많습니다. 복사를 막는 방법은 복사 연산(함수)을 private 멤버로 만드는 것입니다.
관리하고 있는 자원에 대해 참조 카운팅을 수행합니다. 자원을 사용하고 있는 마지막 객체가 소멸될 때까지 그 자원을 저 세상으로 안 보내는 게 바람직할 경우도 종종 있습니다. 현재 tr1::shared_ptr이 사용하고 있습니다. tr1::shared_ptr은 '삭제자(deleter)' 지정을 허용합니다. 여기서 삭제자란, tr1::shared_ptr이 유지하는 참조 카운트가 0 이 되었을 때 호출되는 함수 혹은 함수 객체를 일컫습니다. 삭제자는 tr1::shared_ptr 생성자의 두 번째 매개변수로 선택적으로 넣어 줄 수 있습니다.
관리하고 있는 자원을 진짜로 복사합니다. 때에 따라서는 자원을 원하는 대로 복사할 수도 있습니다. 이때는 '자원을 다 썼을 때 각각의 사본을 확실히 해제하는 것'이 자원 관리 클래스가 필요한 유일한 명분이 되는 것이죠.
관리하고 있는 자원의 소유권을 옮깁니다. 어떤 특정한 자원에 대해서 그 자원을 실제로 참조하는 RAII 객체는 딱 하나만 존재하도록 하고 싶은 경우, 그 RAII 객체가 복사될 때 그 자원의 소유권을 사본 쪽으로 아예 옮겨야 할 경우도 살 다 보면 생깁니다. 바로 auto_ptr의 '복사'동작이죠.
이것만은 잊지 말자!
* RAII 객체의 복사는 그 객체가 관리하는 자원의 복사 문제를 안고 가기 때문에, 그 자원을 어떻게 복사하느냐에 따라 RAII 객체의 복사 동작이 결정됩니다.
* RAII 클래스에 구현하는 일반적인 복사 동작은 복사를 금지하거나 참조 카운팅을 해 주는 선으로 마무리하는 것입니다. 하지만 이 외의 방법들도 가능하니 참고해 둡시다.
RAII 클래스(ex:shared_ptr)의 객체를 그 객체가 감싸고 있는 실제 자원으로 변환할 방법이 필요할때가 있습니다. 이런 목적에 일반적인 방법을 쓴다면 두 가지가 있는데, 하나는 명시적 변환이고 또 다른 하나는 암시적 변환입니다. tr1::shared_ptr 및 auto_ptr은 명시적 변환을 수행하는 get이라는 멤버 함수를 제공합니다. 다시 말해 이 함수를 사용하면 각 타입으로 만든 스마트 포인터 객체에 들어 있는 실제 포인터(의 사본)를 얻어 낼 수 있습니다.
제대로 만들어진 스마트 포인터 클래스라면 거의 모두가 그렇듯, tr1::shared_ptr과 auto_ptr은 포인터 역참조 연산자(operator-> 및 operator*)도 오버로딩하고 있습니다. 따라서 자신이 관리하는 실제 포인터에 대한 암시적 변환도 쉽게 할 수 있습니다.
이것만은 잊지 말자!
* 실제 자원을 직접 접근해야 하는 기존 API들도 많기 때문에, RAII 클래스를 만들 때는 그 클래스가 관리하는 자원을 얻을 수 있는 방법을 열어 주어야 합니다.
* 자원 접근은 명시적 변환 혹은 암시적 변환을 통해 가능합니다. 안전성만 따지면 명시적 변환이 대체적으로 더 낫지만, 고객 편의성을 놓고 보면 암시적 변환이 괜찮습니다.
std::string *stringPtr1 = new std::string;
std::string *stringPtr2 = new std::string[100];
...
delete stringPtr1; // 객체 한 개를 삭제
delete [] stringPtr2; // 객체의 배열을 삭제
어떤 포인터에 대해 delete를 적용할 때, delete 연산자로 하여금 '배열 크기 정보가 있다'라는 것을 알려 주는 방법은 대괄호 쌍([])을 delete 뒤에 붙여 주는 것입니다. 그제야 delete가 '포인터가 배열을 가리키고 있구나'라고 가정하게 됩니다. 그렇지 않으면 그냥 단일 객체라고 간주하고 맙니다.
이것만은 잊지 말자!
* new 표현식에 []를 썼으면, 대응되는 delete 표현식에도 []를 써야 합니다. 마찬가지로 new 표현식에 []를 안 썼으면, 대응되는 delete 표현식에도 []를 쓰지 말아야 합니다.
처리 우선 순위를 알려 주는 함수가 하나 있고, 동적으로 할당한 Widget 객체에 대해 어떤 우선순위에 따라 처리를 적용하는 함수가 하나 있다고 가정합시다.
int priority();
void processWidget(std::rt1::shared_ptr<Widget> pw, int priority);
-----------------------------------------------------------------------
컴파일러는 processWidget 호출 코드를 만들기 전에 우선 이 함수의 매개변수로 넘겨지는 인자를 평가(evaluate)하는 순서를 밟습니다. 여기서 두 번째 인자는 priority 함수의 호출문밖에 없지만, 첫 번째 인자는 두 부분으로 나누어져 있습니다.
"new Widget" 표현식을 실행하는 부분, tr1::shared_ptr 생성자를 호출하는 부분
사정이 이렇기 때문에, processWidget 함수 호출이 이루어지기 전에 컴파일러는 다음의 세 가지 연산을 위한 코드를 만들어야 합니다.
priority를 호출, "new Widget"을 실행, tr1::shared_ptr 생성자를 호출.
그런데, 여기서 각각의 연산이 실행되는 순서는 컴파일러 제작사마다 다르다는 게 문제입니다. 만약 어떤 컴파일러의 순서가 다음과 같다고 해봅시다.
"new Widget"을 실행
priority를 호출
tr1::shared_ptr 생성자 호출
하지만 priority 호출 부분에서 예외가 발생하면 "new Widget"으로 만들어졌던 포인터가 유실됩니다. 자원 누출을 막아 줄 줄 알고 준비한 tr1::shared_ptr에 저장되기도 전에 예외가 발생했기 때문입니다. 자원이 생성되는 시점("new Widget"을 통한)과 그 자원이 자원 관리 객체로 넘어가는 시점 사이에 예외가 끼어들 수 있기 때문에 자원이 누출될 가능성이 있습니다.
이런 문제를 피해 가는 방법은 간단합니다. Widget을 생성해서 스마트 포인터에 저장하는 코드를 별도의 문장 하나로 만들고, 그 스마트 포인터를 processWidget에 넘기는 것입니다.
std::tr1::shared_ptr<Widget> pw(new Widget); // new로 생성한 객체를 스마트 포인터에 담는 코드
processWidget( pw, priority() ); // 이제는 자원 누출 걱정이 없다.
한 문장 안에 있는 연산들보다 문장과 문장 사이에 있는 연산들이 컴파일러의 재조정을 받을 여지가 적기 때문에 위의 코드는 자원 누출 가능성이 없습니다.
이것만은 잊지 말자!
* new로 생성한 객체를 스마트 포인터로 넣는 코드는 별도의 한 문장으로 만듭시다. 이것이 안 되어 있으면, 예외가 발생될 때 디버깅하깅 힘든 자원 누출이 초래될 수 있습니다.
'제대로 쓰기에 쉽고 엉터리로 쓰기에 어려운' 인터페이스를 개발하려면 우선 사용자가 저지를 만한 실수의 종류를 머리에 넣어두고 있어야 합니다. 예를 들어, 날짜를 나타내는 어떤 클래스에 넣을 생성자를 설계하고 있다고 가정합시다.
여기에는 사용자가 쉽게 저지를 수 있는 오류 구멍이 적어도 두 개나 나 있습니다. 우선 매개변수의 전달 순서가 잘못될 여지가 열려 있다는 것이 첫째죠. 두 번째는 월과 일에 해당하는 숫자가 어이없는 숫자일 수 있다는 점입니다.
새로운 타입을 들여와 인터페이스를 강화하면 상당수의 사용자 실수를 막을 수 있습니다. 지금의 경우, 일, 월, 연을 구분하는 간단한 래퍼 타입을 각각 만들고 이 타입을 Date 생성자 안에 둘 수 있을 것입니다.
타입을 적절히 새로 준비해 두기만 해도 인터페이스 사용 에러를 막는 데는 약발이 통한다는 점을 보여줍니다.
'제대로 쓰기엔 쉽고 엉터리로 쓰기에 어려운 타입 만들기'를 위한 또 하나의 일반적인 지침은 '그렇게 하지 않을 번듯한 이유가 없다면 사용자 정의 타입은 기본제공 타입처럼 동작하게 만들지어다'이다. int 등의 타입 정도는 사용자들이 그 성질을 이미 다 알고 있기 때문에, 여러분이 사용자를 위해 만드는 타입도 웬만하면 이들과 똑같이 동작하게 만드는 센스를 갖추어라 이겁니다. 기본제공 타입과 쓸데없이 어긋나는 동작을 피하는 실질적인 이유는 일관성 있는 인터페이스를 제공하기 위해서입니다. 제대로 쓰기에 괜찮은 인터페이스를 만들어 주는 요인 중에 일관성만큼 똑 부러지는 것이 별로 없습니다. 사용자 쪽에서 뭔가를 외워야 제대로 쓸 수 있는 인터페이스는 잘못 쓰기 쉽습니다.
이것만은 잊지 말자!
* 좋은 인터페이스는 제대로 쓰기에 쉬우며 엉터리로 쓰기에 어렵습니다. 인터페이스를 만들 때는 이 특성을 지닐 수 있도록 고민하고 또 고민합시다.
* 인터페이스의 올바른 사용을 이끄는 방법으로는 인터페이스 사이의 일관성 잡아주기, 그리고 기본제공 타입과의 동작 호환성 유지하기가 있습니다.
* 사용자의 실수를 방지하는 방법으로는 새로운 타입 만들기, 타입에 대한 연산을 제한하기, 객체의 값에 대해 제약 걸기, 자원 관리 작업을 사용자 책임으로 놓지 않기가 있습니다.
tr1::shared_ptr은 사용자 정의 삭제자를 지원합니다. 이 특징 때문에 tr1::shared_ptr은 교차 DLL 문제를 막아 주며, 뮤텍스 등을 자동으로 잠금 해제하는 데 쓸 수 있습니다.
클래스 설계시 고려사항
새로 정의한 타입의 객체 생성 및 소멸은 어떻게 이루어져야 하는가? ( 클래스 생성자 및 소멸자의 설계가 바뀝니다. )
객체 초기화는 객체 대입과 어떻게 달라야 하는가? ( 초기화와 대입을 헷갈리지 않는 것이 가장 중요 )
새로운 타입으로 만든 객체가 값에 의해 전달되는 경우에 어떤 의미를 줄 것인가? ( 어떤 타입에 대해 '값에 의한 전달'을 구현하는 쪽은 복사 생성자입니다. )
새로운 타입이 가질 수 있는 적법한 값에 대한 제약은 무엇으로 잡을 것인가? ( 클래스의 데이터 멤버의 몇 가지 조합 값만은 반드시 유효해야 합니다. 이런 조합을 가리켜 클래스의 불변속성이라고 하며, 클래스 차원에서 지켜주어야 하는 부분입니다. 이 불변속성에 따라 클래스 멤버 함수 안에서 해 주어야 할 에러 점검 루틴이 좌우됩니다. )
기존의 클래스 상속 계통망에 맞출 것인가? ( 이미 갖고 있는 클래스로부터 상속을 시킨다고 하면, 당연히 여러분의 설계는 이들 클래스에 의해 제약을 받게 됩니다. 특히 멤버 함수가 가상인가 비가상인가의 여부가 가장 큰 요인입니다. )
어떤 종류의 타입 변환을 허용할 것인가? ( 만든 타입은 결국 기존의 수많은 타입들과 어울려야 하는 운명을 짊어집니다. )
어떤 연산자와 함수를 두어야 의미가 있을까? ( 어떤 것들은 멤버 함수로 적당할 것이고, 또 몇몇은 그렇지 않을 것입니다. )
표준 함수들 중 어떤 것을 허용하지 말 것인가? ( private로 선언해야 하는 함수가 여기에 해당 됩니다. )
새로운 타입의 멤버에 대한 접근권한을 어느 쪽에 줄 것인가? ( 어떤 클래스 멤버를 public, protected, private 영역에 둘 것인가를 결정합니다. )
'선언되지 않은 인터페이스'로 무엇을 둘 것인가? ( 여러분이 만들 타입이 제공할 보장이 어떤 종류일가에 대한 질문으로서, 보장할 수 있는 부분은 수행 성능 및 예외 안전성 그리고 자원 사용입니다. )
새로 만드는 타입이 얼마나 일반적인가? ( 여러분이 정의하는 것이 동일 계열의 타입군 전체일지도 모릅니다. 그렇다면 여러분은 '새로운'클래스 템플릿을 정의해야 할 것입니다. )
정말로 꼭 필요한 타입인가? ( 기존의 클래스에 대해 기능 몇 개가 아쉬워서 파생 클래스를 새로 뽑고 있다면, 차라리 간단하게 비멤버 함수라든지 템플릿을 몇 개 더 정의하는 것이 낫습니다. )
이것만은 잊지 말자!
* 클래스 설계는 타입 설계입니다. 새로운 타입을 정의하기 전에, 이번 항목에 나온 모든 고려사항을 빠짐없이 점검해 보십시오.
기본적으로 C++는 함수로부터 객체를 전달받거나 함수에 객체를 전달할 때 '값에 의한 전달'방식을 사용합니다. 특별히 다른 방식을 지정하지 않는 한, 함수 매개변수는 실제 인자의 '사본'을 통해 초기화되며, 어떤 함수를 호출한 쪽은 그 함수가 반환한 값의 '사본'을 돌려받습니다. 이들 사본을 만들어내는 원천이 바로 복사 생성자인데요, 이 점 때문에 '값에 의한 전달'이 고비용의 연산이 되기도 합니다.
Person(); // 매개변수는 간결함을 위해 생략합니다.
virtual ~Person();
이 함수가 호출될 때 어떤 일이 일어날까요?
plato로부터 매개변수 s를 초기화시키기 위해 Student의 복사 생성자가 호출될 것입니다. 게다가 s는 validateStudent가 복귀할 때 소멸될 것이고요. 정리하면, 이 함수의 매개변수 전달 비용은 Student의 복사 생성자 호출 한 번, 그리고 Student의 소멸자 한 번입니다.
하지만 여기서 끝이 아닙니다. Student 객체에는 string 객체 두 개가 멤버로 들어 있기 때문에, Student객체가 생성될 때마다 이들 string 형제도 덩달아 생성되어야 합니다. 게다가 Student 객체는 Person 객체로부터 파생되었기 때문에, Student 객체가 생성되면 Person 객체도 (먼저) 생성되어야 합니다. Person 객체 안에는 또 string 객체 두 개가 들어 있기 때문에, Person 객체가 매번 생성될 때 string 생성자가 두 번 더 불리게 되겠지요. Student 객체의 사본이 소멸될 때도 앞에서 호출된 생성자들 각각이 소멸자 호출과 대응됩니다. Student 객체를 값으로 전달하는 데 날아간 비용을 계산해 보니 생성자 여섯 번에 소멸자 여섯 번입니다.
생성자 소멸자 호출을 몇 번씩 거치지 않고 넘어갈 수 있는 방법은 상수객체에 대한 참조자로 전달하게 만드는 것입니다.
이렇게 하면 순식간에 훨씬 효율적인 코드로 바뀝니다. 새로 만들어지는 객체 같은 것이 없기 때문에, 생성자와 소멸자가 전혀 호출되지 않습니다.
참조에 의한 전달 방식으로 매개변수를 넘기면 복사손실 문제가 없어지는 장점도 있습니다. 파생 클래스 객체가 기본 클래스 객체로서 전달되는 경우, 이 객체가 값으로 전달되면 기본 클래스의 복사 생성자가 호출되고, 파생 클래스 객체로 동작하게 해주는 특징들이 '싹둑 잘려' 떨어지고 맙니다.
일반작으로 '값에 의한 전달'이 저비용이라고 가정해도 괜찮은 유일한 타입은 기본제공 타입, STL 반복자, 함수 객체 타입, 이렇게 세 가지뿐입니다.
이것만은 잊지 말자!
* '값에 의한 전달'보다는 '상수 객체 참조자에 의한 전달'을 선호합시다. 대체적으로 효율적일 뿐만 아니라 복사손실 문제까지 막아 줍니다.
* 이번 항목에서 다룬 법칙은 기본제공 타입 및 STL 반복자, 그리고 함수 객체 타입에는 맞지 않습니다. 이들에 대해서는 '값에 의한 전달'이 더 적절합니다.
유리수를 나타내는 클래스가 하나 있다고 가정합시다. 이 클래스에는 두 유리수를 곱하는 멤버 함수가 선언되어 있습니다.
함수 수준에서 새로운 객체를 만드는 방법은 딱 두 가지뿐입니다. 하나는 스택에 만드는 것이고, 또 하는 힙에 만드는 것입니다. 우선 전자의 방법부터 보죠. 스택에 객체를 만들려면 지역 변수를 정의하면 됩니다. 그럼 이 방법을 써서 operator*를 한 번 작성해 볼까요?
const Rational& operator*( const Rational& lhs, const Rational& rhs )
{
Rational result(lhs.n * rhs.n, lhs.d * rhs.d );
return result; // 경고! 어이없는 코드
}
이 연산자 함수는 result에 대한 참조자를 반환하는데, result는 지역 객체입니다. 다시 말해 함수가 끝날 때 덩달아 소멸되는 객체죠. 그러니까, 이 operator*는 현재 온전한 Rational 객체에 대한 참조자를 반환하지 않습니다. 지역 객체에 대한 참조자를 반환하는 함수는 어떤 함수든지 프로그램의 핵폭탄이 됩니다.(지역 객체의 포인터를 반환하는 함수도 마찬가지이고요.)
자, 다음은 후자의 방법을 살펴볼 순서입니다. 함수가 반환할 객체를 힙에 생성해 뒀다가 그 녀석의 참조자를 반환하는 것은 어떨가요?
여기서 new로 저질러 버린 객체를 대체 누가 delete로 뒤처리해 주길 바란다는 말입니까?
새로운 객체를 반환해야 하는 함수를 작성하는 방법에는 정도가 있습니다. 바로 '새로운 객체를 반환하게 만드는 것'이죠. 그러니까 Rational의 operator*는 아래처럼 혹은 아래와 비슷하게 작성해야 합니다.
모든 프로그래밍 언어가 그러하듯, C++에서도 다 컴파일러 구현자들이 가시적인 동작 변경을 가하지 않고도 기존 코드의 수행성능을 높이는 최적화를 적용할 수 있도록 배려해 두었습니다. 그 결과, 몇몇 조건하에서는 이 최적화 매커니즘에 의해 operator*의 반환 값에 대한 생성과 소멸 동작이 안전하게 제거될 수 있습니다.
이것만은 잊지 말자!
* 지역 스택 객체에 대한 포인터나 참조자를 반환하는 일, 혹은 힙에 할당된 객체에 대한 참조자를 반환하는 일, 또는 지역 정적 개체에 대한 포인터나 참조자를 반환하는 일은 그런 객체가 두 개 이상 필요해질 가능성이 있다면 절대로 하지 마세요.(항목 4를 보시면 지역 정적 객체에 대한 참조자를 반환하도록 설계된 올바른 코드 예제를 찾을 수 있습니다. 최소한 단일 스레드 환경에서는 통합니다.)
public 데이터 멤버가 안되는 이유
우선 문법적 일관성이 첫 번째 이유가 되겠습니다. 데이터 멤버가 public이 아니라면, 사용자 쪽에서 어떤 객체를 접근할 수 있는 유일한 수단은 멤버 함수일 것입니다.
함수를 사용하면 데이터 멤버의 접근성에 대해 훨씬 정교한 제어를 할 수 있습니다. 데이터 멤버에 대해 접근 불가, 일기 전용, 읽기 쓰기 접근을 여러분이 직접 구현할 수 있습니다.
public 데이터 멤버가 안되는 이유 세번째는 캡슐화입니다. 함수를 통해서만 데이터 멤버에 접근할 수 있도록 구현해 두면, 데이터 멤버를 나중에 계산식으로 대체할 수도 있을 것이고, 구현상의 융통성을 전부 누릴 수 있습니다. 예를 들면 이런 것들이 간편해집니다. 데이터 멤버를 읽거나 쓸 때 다른 객체에 알림 메시지를 보낸다거나, 클래스의 불변속성 및 사전조건, 사후조건을 검증한다든지, 스레딩 환경에서 동기화를 건다든지 하는 일이죠.
protected 데이터 멤버나 public 멤버나 오십 보 백보입니다. 데이터 멤버가 바뀌면 이 멤버에 의존하는 다른 코드들이 헤아릴 수 없으리만치 망가지는 것은 마찬가지이기 때문입니다.
이것만은 잊지 말자!
* 데이터 멤버는 private 멤버로 선언합시다. 이를 통해 클래스 제작자는 문법적으로 일관성 있는 접근 통로를 제공할 수 있고, 필요에 따라서는 세밀한 접근 제어도 가능하며, 클래스의 불변속성을 강화할 수 있을 뿐 아니라, 내부 구현의 융통성도 발휘할 수 있습니다.
* protected는 public보다 더 많이 '보호'받고 있는 것이 절대로 아닙니다.
웹브라우저를 나타내는 클래스가 하나 있다고 가정합시다.
사용자 중에서 이 세 동작을 한 번에 하고 싶은 분들도 상당수 있기 때문에, 세 함수를 모아서 불러주는 함수도 준비해 둘 수 있을 것입니다.
물론 이 기능은 비멤버 함수로 제공해도 됩니다. 웹브라우저 객체의 멤버 함수를 순서대로 불러주기만 하면 되는 거죠.
어느 쪽이 더 괜찮을까요? 객체 지향 법칙은 할 수 있는 만큼 데이터를 캡슐화하라고 주장합니다. 그러나 멤버 버전인 clearEverything는 비멤버 버전인 clearBrowser보다 캡슐화 정도에서 오히려 형편없습니다. 비멤버 비프렌드 함수는 어떤 클래스의 private 멤버 부분을 접근할 수 있는 함수의 개수를 늘리지 않으니까요.
여기서 주의해야 할 부분 두 가지를 짚어드릴 필요가 있을 것 같습니다. 첫째, 이 이야기는 비멤버 비프렌드 함수에만 적용된다는 것입니다. 프렌드 함수는 private 멤버에 대한 접근권한이 해당 클래스의 멤버 함수가 가진 접근권한과 똑같기 때문에, 캡슐화에 대한 영향 역시 같습니다.
주의해야 할 두 번째는, 캡슐화에 대한 이러저런 이야기 때문에 "함수는 어떤 클래스의 비멤버가 되어야 한다"라는 주장이 "그 함수는 다른 클래스의 멤버가 될 수 없다"라는 의미가 아니라는 것입니다. 이를테면, clearBrowser 함수를 다른 유틸리티 클래스 같은 데의 정적 멤버 함수로 만들어도 된다는 이야기입니다. 어쨌든 이 함수가 WebBrowser 클래스의 멤버가 아니기만 하면 됩니다.
C++로는 더 자연스런 방법을 구사할 수 있습니다. clearBrowser를 비멤버 함수로 두되, WebBrowserStuff와 같은 네임스페이스 안에 두는 것입니다.
네임스페이스는 클래스와 달리 여러 개의 소스 파일에 나뉘어 흩어질 수 있습니다. clearBrowser같은 편의상 준비한 함수들을 나누어 놓는 쉽고 깔끔한 방법은, 관련된 편의 함수를 각 헤더 파일에 몰아서 선언하는 것입니다. 이렇게 하면, 사용자가 실제로 사용하는 구성요소에 대해서만 컴파일 의존성을 고려할 수 있게 되는 거죠. 편의 함수 전체를 여러 개의 헤더 파일에 나누어 놓으면 편의 함수 집합의 확장도 손쉬워집니다. 해당 네임스페이스에 비멤버 비프렌드 함수를 원하는 만큼 추가해 주기만 하면 그게 확장입니다.
이것만은 잊지 말자!
* 멤버 함수 보다는 비멤버 비프렌드 함수를 자주 쓰도록 합시다. 캡슐화 정도가 높아지고, 패키징 유연성도 커지며, 기능적인 확장성도 늘어납니다.
클래스에서 암시적 타입 변환을 지원하는 것은 일반적으로 못된 생각이지만 예외가 있습니다. 가장 흔한 예외 중 하나가 숫자 타입을 만들 때입니다. 예를 들어, 유리수를 나타내는 클래스를 만들고 있다면, 정수에 유리수로의 암시적 변환은 허용하자고 합시다.
유리수를 나타내는 클래스이니만큼 덧셈이나 곱셈 등의 수치 연산은 기본으로 지원하고 싶겠죠. operator*를 Rational 클래스 안에 구현해 넣는 게 자연스러울 것 같습니다.
이렇게 설계해 두면 유리수 곱셈을 아주 쉽게 할 수 있게 됩니다.
이 문제의 원인은 위의 두 예제를 함수 형태로 바꾸어 써 보면 바로 드러납니다.
첫 번째 줄에서 oneHalf 객체는 operator* 함수를 멤버로 갖고 있는 클래스의 인스턴스이므로, 컴파일러는 이 함수를 호출합니다. 하지만 두 번째 줄에서 정수 2에는 클래스 같은 것이 연관되어 있지 않기 때문에, operator* 멤버 함수도 있을 리가 없습니다. 컴파일러는 비멤버 버전의 operator*(네임스페이스 혹은 전역 유효범위에 있는 operator*)도 찾아봅니다. 비멤버 버전의 operator*가 없으면 탐색은 실패하고 컴파일 에러가 나게 됩니다.
Rational::operator*의 선언문을 보면 인자로 Rational 객체를 받도록 되어 있습니다. 컴파일러는 이 함수에 int를 넘기면 함수 쪽에선 Rational을 요구한다는 사실을 알고 있으니, 이 int를 Rational 클래스의 생성자에 주어 호출하면 Rational로 둔갑시킬 수 있다는 사실도 알고 있습니다. 그래서 컴파일러는 자기가 알고 있는 대로 합니다. 다시 말해, 마치 아래와 같이 작성된 코드인 것처럼 처리한 거죠.
물론 컴파일러가 이렇게 동작한 것은 명시호출로 선언되지 않은 생성자가 있기 때문입니다.
혼합형 수치 연산을 지원하고 싶다면 operator*를 비멤버 함수로 만들어서, 컴파일러 쪽에서 모든 인자에 대해 암시적 타입 변환을 수행하도록 내버려 둡니다.
멤버 함수의 반대는 프렌드 함수가 아니라 비멤버 함수입니다. 프렌드 함수는 피할 수 있으면 피하도록 하십시오.
이것만은 잊지 말자!
* 어떤 함수에 들어가는 모든 매개변수(this 포인터가 가리키는 객체도 포함해서)에 대해 타입 변환을 해 줄 필요가 있다면, 그 함수는 비멤버이어야 합니다.
두 객체의 값을 '맞바꾸기(swap)'한다는 것은 각자의 값을 상대방에게 주는 동작입니다. 기본적으로는 이 맞바꾸기 동작을 위해 표준 라이브러리에서 제공하는 swap 알고리즘을 쓰는데, 이 알고리즘이 구현된 모습을 보면 여러분이 알고있는 그 'swap'과 하나도 다르지 않다는 것을 알 수 있습니다.
복사만 제대로 지원하는(복사 생성자 및 복사 대입 연산자를 통해) 타입이기만 하면 어떤 타입의 객체이든 맞바꾸기 동작을 수행해 줍니다. swap을 위해 특별히 추가 코드를 마련하거나 할 필요가 없습니다. 하지만 이 방벙은 한 번 호출에 복사가 세 번이 일어납니다. a에서 temp로, b에서 a로, temp에서 b로 말이죠. 타입에 따라서는 이런 사본이 정말 필요가 없는 경우도 있습니다.
복사하면 손해를 보는 타입들 중 으뜸을 꼽는다면 아마도 다른 타입의 실제 데이터를 가리키는 포인터가 주성분(!)인 타입일 것입니다. 이러한 개념을 설계의 미학으로 끌어올려 많이들 쓰고 있는 기법이 바로 'pimpl 관용구'( pointer to implimentation':항목31)이지요. 이해를 돕는 의미에서, pimpl 설계를 차용하여 Widget 클래스를 만든 예를 보여 드리죠.
이렇게 만들어진 Widget 객체를 우리가 직접 맞바꾼다면, pImpl 포인터만 살짝 바꾸는 것 말고는 실제로 할 일이 없습니다. 하지만 이런 사정을 표준 swap 알고리즘이 알 턱이 없죠. 언제나처럼 Widget 객체 세 개를 복사하고, 그것도 모자라 WidgetImpl 객체 세개도 복사할 것입니다. 아주 심하게 비효율적입니다.
그래서 조금 손을 보고 싶습니다. std::swap에다가 뭔가를 알려 주는 거죠. Widget 객체를 맞바꿀 때는 일반적인 방법을 쓰지 말고 내부의 pImpl 포인터만 맞바꾸라고 말입니다. C++ 로는 방금 이야기한 그대로 할 수 있는 방법이 있습니다. std::swap을 Widget에 대해 특수화하는 것인데, 일단 기본 아이디어만 간단히 코드로 보여드리겠습니다. 아직 컴파일은 안 되니 주의하세요.
우선 함수 시작부분에 있는 'template<>'을 봐 주세요. 이 함수가 std::swap의 완전 템플릿 특수화 함수라는 것을 컴파일러에게 알려 주는 부분입니다. 그리고 함수 이름 뒤에 있는 '<Widget>'은 T가 Widget일 경우에 대한 특수화라는 사실을 알려 주는 부분이고요. 다시 말해, 타입에 무관한 swap 템플릿이 Widget에 적용될 때는 위의 함수 구현을 사용해야 한다는 뜻입니다.
그렇지만 이 함수는 컴파일되지 않습니다. 문법이 틀려서 그런 것은 아니고, a와 b에 들어 있는 pImpl 포인터에 접근하려고 하는데 이들 포인터가 private 멤버이기 때문입니다. 특수화 함수를 프렌드로 선언할 수도 있었지만, 이렇게 하면 표준 템플릿들에 쓰인 규칙과 어긋나므로 좋은 모양은 아닙니다. 그래서 Widget 안에 swap이라는 public 멤버 함수를 선언하고 그 함수가 실제 맞바꾸기를 수행하도록 만든 후에, std::swap의 특수화 함수에게 그 멤버 함수를 호출하는 일을 맡깁니다.
using std::swap; // 이 선언문이 필요한 이유는 이후의
// 설명에서 확인하실 수 있습니다.
컴파일될 뿐만 아니라, 기존의 STL 컨테이너와 일관성도 유지되는 착한 코드가 되었습니다. public 멤버 함수 이전의 swap과 이 멤버 함수를 호출하는 std::swap의 특수화 함수 모두 지원하고 있고요.
그런데 이제 이런 가정을 하나 더 해 봅시다. Widget과 WidgetImpl이 클래스가 아니라 클래스 템플릿으로 만들어져 있어서, WidgetImpl에 저장된 데이터의 타입을 매개변수로 바꿀 수 있다면 어떻게 될까요?
swap 멤버 함수를 Widget에 (필요하면 WidgetImpl에도) 넣는 정도는 먼젓번 경우처럼 별로 어렵지 않지만, std::swap을 특수화하는 데서 좌절모드로 들어가게 됩니다. 사실 우리가 작성하려고 했던 코드는 이런 것이었단 말이죠.
C++는 클래스 템플릿에 대해서는 부분 특수화를 허용하지만 함수 템플릿에 대해서는 허용하지 않도록 정해져 있습니다.
함수 템플릿을 '부분적으로 특수화'하고 싶을 때 흔히 취하는 방법은 그냥 오버로드 버전을 하나 추가하는 것입니다. 즉, 이렇게 하라는 거죠.
일반적으로 함수 템플릿의 오버로딩은 해도 별 문제가 없지만, std는 조금 특별한 네임스페이스이기 때문에 이 네임스페이스에 대한 규칙도 다소 특별합니다. 요컨대, std 내의 템플릿에 대한 완전 특수화는 OK이지만, std에 새로운 템플릿을 추가하는 것은 OK가 아닙니다(혹은 클래스이든 함수이든 어떤 것도 안 됩니다). std의 영역을 침범하더라도 일단 컴파일까지는 거의 다 되고 실행도 됩니다. 그런데 실행되는 결과가 미정의 사항이라는 것입니다. std에 절대 아무것도 추가하지 마세요.
그럼 어쩌라고요? 방법은 간단합니다. 멤버 swap을 호출하는 비멤버 swap을 선언해 놓되, 이 비멤버 함수를 std::swap의 특수화 버전이나 오버로딩 버전으로 선언하지만 않으면 됩니다. 예를 들어, 이번 항목에 나온 Widget 관련 기능이 전부 WidgetStuff 네임스페이스에 들어 있다고 가정하면 다음과 같이 만들라는 이야기입니다.
이제는 어떤 코드가 두 Widget 객체에 대해 swap을 호출하더라도, 컴파일러는 C++의 이름 탐색 규칙[이 규칙은 인자 기반 탐색 혹은 퀴니그 탐색이란 이름으로 알려져 있습니다( 어떤 함수에 어떤 타입의 인자가 있으면, 그 함수의 이름을 찾기 위해 해당 타입의 인자가 위치한 네임스페이스 내부의 이름을 탐색해 들어간다는 간단한 규칙이다)]에 의해 WidgetStuff 네임스페이스 안에서 Widget 특수화 버전을 찾아냅니다.
여러분이 만든 '클래스 타입 전용의 swap'이 되도록 많은 곳에서 호출되도록 만들고 싶으시면(그리고 그런 swap을 갖고 있다면), 그 클래스와 동일한 네임스페이스 안에 비멤버 버전의 swap을 만들어 넣고, 그와 동시에 std::swap의 특수화 버전도 준비해 두어야 하겠습니다.
그런데 말이죠, 위의 모든 사항들은 여러분이 네임스페이스를 안 쓰고 있어도 여전히 유효합니다(멤버 swap을 호출하는 비멤버 swap이 필요하다는 말씀입니다).
지금까지 함께 살펴본 내용은 전부 swap을 구현하는 사람들 쪽에 무게가 있었지만, 이제는 고객의 눈으로 어떤 상황 하나를 놓고 이야기해 보도록 하지요.
이 부분에서 과연 어떤 swap을 호출해야 할까요? 가능성은 세 가지 입니다. 먼저 std에 있는 일반형 버전 : 이것은 확실히 있습니다. 둘째, std의 일반형을 특수화한 버전 : 있을 수도, 없을 수도 있습니다. 셋째, T 타입 전용의 버전 : 있거나 없거나 할 수 있으며, 어떤 네임스페이스안에 있거나 없거나 할 수도 있습니다(하지만 확실히 std 안에는 없어야 하겠지요). 여러분은 타입 T 전용 버전이 있으면 그것이 호출되도록 하고, T 타입 전용 버전이 없으면 std의 일반형 버전이 호출되도록 만들고 싶습니다. 어떻게 이렇게 이끌어낼 수 있을까요? 아래의 코드가 정답입니다.
컴파일러가 위의 swap 호출문을 만났을 때 하는 일은 현재의 상황에 딱 맞는 swap을 찾는 것입니다. C++의 이름 탐색 규칙을 따라, 우선 전역 유효범위 혹은 타입 T와 동일한 네임스페이스 안에 T 전용의 swap이 있는지를 찾습니다[ 예를 들어 T가 WidgetStuff 네임스페이스 내의 Widget 이라면, 컴파일러는 인자 의존 규칙을 적용하여 WiddgetStuff의 swap을 찾아낼 것입니다]. T 전용 swap이 없으면 컴파일러는 그 다음 순서를 밟는데, 이 함수가 std::swap을 볼 수 있게 해주는 using 선언이 함수 앞부분에 떡 하니 있기 때문에 std의 swap을 쓰게끔 결정할 수도 있습니다. 하지만 이런 상황이 되더라도 컴파일러는 std::swap의 T전용 버전을 일반형 템플릿보다 더 우선적으로 선택하도록 정해져 있기 때문에, T에 대한 std::swap의 특수화 버전이 이미 준비되어 있으면 결국 그 특수화 버전이 쓰이게 됩니다.
멤버 버전의 swap은 절대로 예외를 던져선 안 됩니다. 그 이유는 swap을 진짜 쓸모 있게 응용하는 방법들 중에 클래스(및 클래스 템플릿)가 강력한 예외 안정성 보장을 제공하도록 도움을 주는 방법이 있기 때문입니다. 그런데 이 기법은 멤버 버전 swap이 예외를 던지지 않아야 한다는 가정을 깔고 있습니다. 하필 멤버 버전만 이렇습니다. 따라서 swap을 직접 만들어 보실 분은 두 값을 빠르게 바꾸는 방법만 구현하고 끝내면 안 되겠지요? 예외를 던지지 않는 방법도 함께 준비하는 센스가 필요합니다.
이것만은 잊지 말자!
* std::swap이 여러분의 타입에 대해 느리게 동작할 여지가 있다면 swap 멤버 함수를 제공합시다. 이 멤버 swap은 예외를 던지지 않도록 만듭시다.
* 멤버 swap을 제공했으면, 이 멤버를 호출하는 비멤버 swap도 제공합시다. 클래스(템플릿이 아닌)에 대해서는, std::swap도 특수화해 둡시다.
* 사용자 입장에서 swap을 호출할 때는, std::swap에 대한 using 선언을 넣어 준 후에 네임스페이스 한정 없이 swap을 호출합시다.
* 사용자 정의 타입에 대한 std 템플릿을 완전 특수화하는 것은 가능합닏. 그러나 std에 어떤 것이라도 새로 '추가'하려고 들지는 마십시오.
생성자 혹은 소멸자를 끌고 다니는 타입으로 변수를 정의하면 반드시 물게 되는 비용이 두 개 있다. 하나는 프로그램 제어 흐름이 변수의 정의에 닿을 때 생성자가 호출되는 비용이고, 또 하나는 그 변수가 유효범위를 벗어날 때 소멸자가 호출되는 비용이다.
어떤 변수를 사용해야 할 때가 오기 전까지 그 변수의 정의를 늦추는 것은 기본이고, 초기화 인자를 손에 넣기 전까지 정의를 늦출 수 있는지도 둘러봐야 한다는 것이다. 이렇게 해야 쓰지도 않을 객체가 만들어졌다 없어지는 일이 생기지 않으며, 불필요한 기본 생성자 호출도 일어나지 않는다. 덤으로, 누가 보아도 그 변수의 의미가 명확한 상황에서 초기화가 이루어지기 때문에, 변수의 쓰임새를 문서화하는 데도 큰 도움이 된다.
두 방법에 걸리는 비용
A 방법 : 생성자 1번 + 소멸자 1번 + 대입 n번
B 방법 : 생성자 n번 + 소멸자 n번
클래스 중에는 대입에 들어가는 비용이 생성자-소멸자 쌍보다 적게 나오는 경우가 있는데, Widget 클래스가 이런 종류에 속한다면 A 방법이 일반적으로 훨씬 효율이 좋다. 이 차이는 n이 커질 때 특히 더 커진다. 반면, 그렇지 않은 경우엔 B 방법이 아마 더 좋을 것이다. A 방법 쓰면 w라는 이름을 볼 수 있는 유효범위가 B 방법을 쓸 때보다 넓어지기 때문에, 프로그램의 이해도와 유지보수성이 역으로 안 좋아질 수도 있다. 그러니까 이렇게 해야한다. '대입이 생성자-소멸자 쌍보다 비용이 덜 들고, 전체 코드에서 수행성능에 민감한 부분을 건드리는 중'이라고 생각하지 않는다면, 앞뒤 볼 것 없이 B 방법이 더 좋다.
이것만은 잊지 말자!
* 변수 정의는 늦출 수 있을 때까지 늦춥시다. 프로그램이 더 깔끔해지며 효율도 좋아집니다.
C++는 네 가지로 이루어진 새로운 형태의 캐스트 연산자를 독자적으로 제공한다.
const_cast 객체의 상수성을 없애는 용도로 사용한다.
dynamic_cast '안전한 다운캐스팅'을 할 때 사용하는 연산자. 즉, 주어진 객체가 어떤 클래스 상속 계통에 속한 특정 타입인지 아닌지를 결정하는 작업에 쓰인다. 런타임 비용이 높은 캐스트 연산자이다.
reinterpret_cast 포인터를 int로 바꾸는 등의 하부 수준 캐스팅을 위해 만들어진 연산자로서, 이것의 적용 결과는 구현환경에 의존적이다( 이식성이 없다는 뜻).
static_cast 암시적 변환[int를 double로 바꾸는 등의 변환]을 강제로 진행할 때 사용한다.
C++ 스타일의 캐스트는 우선, 코드를 읽을 때 알아보기 쉽기 때문에, 소스 코드의 어디에서 C++의 타입 시스템이 망가졌는지를 찾아보는 작업이 편해진다. 둘째, 캐스트를 사용한 목적을 더 좁혀서 지정하기 때문에 컴파일러 쪽에서 사용 에러를 진단할 수 있다.
캐스팅은 최대한 격리 시켜야 한다. 캐스팅을 해야 하는 코드를 내부 함수 속에 몰아 놓고, 그 안에서 일어나는 '천한' 일들은 이 함수를 호출하는 외부에서 알 수 없도록 인터페이스로 막아두는 식으로 해결하라.
이것만은 잊지 말자!
* 다른 방법이 가능하다면 캐스팅은 피하십시오. 특히 수행 성능에 민감한 코드에서 dynamic_cast는 몇 번이고 다시 생각하십시오. 설계 중에 캐스팅이 필요해졌다면, 캐스팅을 쓰지 않는 다른 방법을 시도해 보십시오.
* 캐스팅이 어쩔 수 없이 필요하다면, 함수 안에 숨길 수 있도록 해 보십시오. 이렇게 하면 최소한 사용자는 자신의 코드에 캐스팅을 넣지 않고 이 함수를 호출할 수 있게 됩니다.
* 구형 스타일의 캐스트를 쓰려거든 C++ 스타일의 캐스트를 선호하십시오. 발견하기도 쉽고, 설계자가 어떤 역할을 의도했는지가 더 자세하게 드러납니다.
클래스 데이터 멤버는 아무리 숨겨봤자 그 멤버의 참조자를 반환하는 함수들의 최대 접근도에 따라 캡슐화 정도가 정해진다.
어떤 객체에서 호출한 상수 멤버 함수의 참조자 반환 값의 실제 데이터가 그 객체의 바깥에 저장되어 있다면, 이 함수의 호출부에서 그 데이터의 수정이 가능하다.
참조자를 반환하도록 되어 있었다 해도 마찬가지 이유롤 인해 마찬가지 문제가 생긴다. 참조자, 포인터 및 반복자는 어쨌든 모두 핸들(다른 객체에 손을 댈 수 있게 하는 매개자)이고, 어떤 객체의 내부요소에 대한 핸들을 반환하게 만들면 언제든지 그 객체의 캡슐화를 무너뜨리는 위험을 무릅쓸 수 밖에 없다.
어떤 객체의 '내부요소'라고 하면 흔히들 데이터 멤버만 생각하는 분들이 많은데, 일반적인 수단으로 접근이 불가능한(쉽게말해 protected 혹은 private로 선언된) 멤버 함수도 객체의 내부요소에 들어간다. 이들에 대한 핸들도 반환하지 말아야 한다. 즉, 외부 공개가 차단된 멤버 함수에 대해, 이들의 포인터를 반환하는 멤버 함수를 만드는 일이 절대로 없어야 한다.
이것만은 잊지 말자!
* 어떤 객체의 내부요소에 대한 핸들(참조자, 포인터, 반복자)을 반환하는 것은 되도록 피하세요. 캡슐화 정도를 높이고, 상수 멤버 함수가 객체의 상수성을 유지한 채로 동작할 수 있도록 하며, 무효참조 핸들이 생기는 경우를 최소화 할 수 있습니다.
기본적인 보장 함수 동작 중에 예외가 발생하면, 실행 중인 프로그램에 관련된 모든 것들을 유효한 상태로 유지하겠다는 보장.
강력한 보장 함수 동작 중에 예외가 발생하면, 프로그램의 상태를 절대로 변경하지 않겠다는 보장. 이런 함수를 호출하는 것은 원자적인 동작이라고 할 수 있다.
예외불가 보장 예외를 절대로 던지지 않겠다는 보장. 약속한 동작은 언제나 끝까지 오나수하는 함수라는 뜻이다.
이것만은 잊지 말자!
* 예외 안전성을 갖춘 함수는 실행 중 예외가 발생되더라도 자원을 누출시키지 않으며 자료구조를 더럽힌 채로 내버려 두지 않습니다. 이런 함수들이 제공할 수 있는 예외 안전성 보장은 기본적인 보장, 강력한 보장, 예외 금지 보장이 있습니다.
* 강력한 예외 안정성 보장은 '복사-후-맞바꾸기' 방법을 써서 구현할 수 있지만, 모든 함수에 대해 강력한 보장이 실용적인 것은 아닙니다.
* 어떤 함수가 제공하는 예외 안전성 보장의 강도는, 그 함수가 내부적으로 호출하는 함수들이 제공하는 가장 약한 보장을 넘지 않습니다.
인라인 함수는 호출 비용이 면제되는 것은 눈에 보이는 이점이고, 대체적으로 컴파일러 최적화는 함수 호출이 없는 코드가 연속적으로 이어지는 구간에 적용되도록 설계되었기 때문에, 인라인 함수를 사용하면 컴파일러가 함수 본문에 대해 문맥별 최적화를 걸기가 용이해진다. 실제로 대부분의 컴파일러는 '아웃라인' 함수 호출에 대해 이런 최적화를 적용하지 않는다.
인라인 함수의 아이디어는 함수 호출문을 그 함수의 본문으로 바꿔치기하자는 것이어서, 목적 코드의 크기가 커진다. 메모리가 제한된 컴퓨터에서 아무 생각 없이 인라인을 남발했다가는 프로그램의 크기가 그 기계에서 쓸 수 있는 공간을 넘어버릴 수도 있다. 가상 메모리를 쓰는 환경일지라도 인라인 함수로 인해 부풀려진 코드는 성능의 걸림돌이 된다. 페이징 횟수가 늘어나고, 명령어 캐시 적중률이 떨어질 가능성도 높아진다.
inline은 컴파일러에 대해 '요청'을 하는 것이지, '명령'이 아니다.
인라인 함수는 대체적으로 헤더 파일에 들어 있어야 하는 게 맞다. 왜냐하면 대부분의 빌드 환경에서 인라인을 컴파일 도중에 수행하기 때문이다.
템플릿 역시, 대체적으로 헤더 파일에 들어 있어야 맞다. 템플릿이 사용되는 부분에서 해당 템플릿을 인스턴스로 만들려면 그것이 어떻게 생겼는지를 컴파일러가 알아야 하기 때문이다.
이것만은 잊지 말자!
* 함수 인라인은 작고, 자주 호출되는 함수에 대해서만 하는 것으로 묶어둡시다. 이렇게 하면 디버깅 및 라이브러리의 바이너리 업그레이드가 용이해지고, 자칫 생길 수 있는 코드 부풀림 현상이 최소화되며, 프로그램의 속력이 더 빨라질 수 있는 여지가 최고로 많아집니다.
* 함수 템플릿이 대개 헤더 파일에 들어간다는 일반적인 부분만 생각해서 이들을 inline으로 선언하면 안 됩니다.
C++의 클래스 정의는 클래스 인터페이스만 지정하는 것이 아니라 구현 세부사항까지 상당히 많이 지정하고 있다.
위의 코드가 컴파일 되기 위해서는 string, Date, Address가 어떻게 정의됐는지를 컴파일러가 알아야 한다. 결국 이들이 정의된 정보를 가져 와야 하고, 이때 쓰는 것이 #include 지시자이다. 따라서 Person 클래스를 정의하는 파일을 보면 대개 아래와 비슷한 코드를 발견하게 된다.
#include <string>
#include "date.h"
#include "address.h"
위의 #include 문은 Person을 정의한 파일과 위의 헤더 파일들 사이에 컴파일 의존성이란 것을 엮어 버린다. 그러면 위의 파일 셋 중 하나라도 바뀌는 것은 물론이고 이들과 또 엮여 있는 헤더 파일들이 바뀌기만 해도, Person 클래스를 정의한 파일은 코 꿰이듯 컴파일러에게 끌려가야 한다.
int x; int x;
Person p( params ); // 컴파일러가 Person 객체 Person *p; // Person 하나에 대한 포인터
Person 클래스에 대해 위의 방법을 적용해보면, 주어진 클래스를 두 클래스로 쪼개야 한다. 한쪽은 인터페이스만 제공하고, 또 한쪽은 그 인터페이스의 구현을 맡도록 만드는 것이다.
위의 코드를 보면 주 클래스(Person)에 들어 있는 데이터 멤버라고는 구현 클래스(PersonImpl)에 대한 포인터(tr1::shared_ptr)뿐이다. 이런 설계를 pimpl 관용구 라고 부른다.
어쨌든 이렇게 설계해 두면, Person의 사용자는 생일, 주소, 이름 등의 자질구레한 세부사항과 완전히 갈라서게 된다. Person 클래스에 대한 구현 클래스 부분은 생각만 있으면 마음대로 고칠 수 있지만, 그래도 Person의 사용자 쪽에서는 컴파일을 다시 할 필요가 없다. 게다가 Person이 어떻게 구현되어 있는지를 들여다 볼 수 없기 때문에, 구현 세부 사항에 어떻게든 직접 발을 걸치는 코드를 작성할 여지가 사라진다. 그야말로 인터페이스와 구현이 분리되어 떨어진 것이다.
이렇게 인터페이스와 구현을 둘로 나누는 열쇠는 '정의부에 대한 의존성'을 '선언부에 대한 의존성'으로 바꾸어 놓는 데 있다. 이게 바로 컴파일 의존성을 최소화하는 핵심 원리이다. 즉, 헤더 파일을 만들 때는 실용적으로 의미를 갖는 한 자체조달 형태로 만들고, 정 안 되면 다른 파일에 대해 의존성을 갖도록 하되 정의부가 아닌 선언부에 대해 의존성을 갖도록 만드는 것이다.
정리를 하면,
객체 참조자 및 포인터로 충분한 경우에는 객체를 직접 쓰지 않는다.
- 어떤 타입에 대한 참조자 및 포인터를 정의할 때는 그 타입의 선언부만 필요하다. 반면, 어떤 타입의 객체를 정의할 때는 그 타입의 정의가 준비되어 있어야 한다.
할 수 있으면 클래스 정의 대신 클래스 선언에 최대한 의존하도록 만든다.
- 어떤 클래스를 사용하는 함수를 선언할 때는 그 클래스의 정의를 가져오지 않아도 된다. 심지어 그 클래스 객체를 값으로 전달하거나 반환하더라도 클래스 정의가 필요 없다.
선언부와 정의부에 대해 별도의 헤더 파일을 제공한다.
- "클래스를 둘로 쪼개자"라는 지침을 제대로 쓸 수 있도록 하려면 헤더 파일이 짝으로 있어야 한다. 하나는 선언부를 위한 헤더 파일이고, 또 하나는 정의부를 위한 헤더 파일이다. 당연한 이야기이겠지만 이 두 파일은 관리도 짝 단위로 해야 한다. 한쪽에서 어떤 선언이 바뀌면 다른 쪽도 바꾸어야 한다. 그렇기 때문에, 라이브러리 사용자 쪽에서는 전방 선언 대신에 선언부 헤더 파일을 항상 #include해야 할 것이고, 라이브러리 제작자 쪽에서는 헤더 파일 두 개를 짝징 제공하는 일을 잊으면 안 된다.
pimpl 관용구를 사용하는 Person 같은 클래스를 가리켜 핸들 클래스라고 한다. 핸들 클래스에서 어떤 함수를 호출하게 되어 있다면, 핸들 클래스에 대응되는 구현 클래스 쪽으로 그 함수 호출을 전달해서 구현 클래스가 실제 작업을 수행하게 만들어라. 밑에 예제를 구현해 보겠다.
Person 생성자가 어떻게 PersonImpl 생성자를 호출하는지를 잘 봐야 한다(new 를 사용하고 있다). 그리고 Person::name이 PersonImpl::name을 호출하고 있다. Person은 핸들 클래스이지만 그렇다고 Person의 동작이 바뀐 것은 아니다. Person의 동작을 수행하는 방법이 바뀌었을 뿐이다.
핸들 클래스 방법 대신에 다른 방법을 쓰고 싶다면 Person을 특수 형태의 추상 기본 클래스, 이른 바 인터페이스 클래스로 만드는 방법도 생각해 볼 수 있다. 어떤 기능을 나타내는 인터페이스를 추상 기본 클래스를 통해 마련해 놓고, 이 클래스로부터 파생 클래스를 만들 수 있게 하자는 것이다. 파생이 목적이기 때문에 이런 클래스에는 데이터 멤버도 없고, 생성자도 없으며, 하나의 가상 소멸자와 인터페이스를 구성하는 순수 가상 함수만 들어 있다.
핸들 클래스의 단점은 첫째, 핸들 클래스의 멤버 함수를 호출하면 알맹이(구현부) 객체의 데이터까지 가기 위해 포인터(구현부 포인터)를 타야 한다. 그러니까 한 번 접근할 때마다 요구되는 간접화 연산이 한 단계 더 증가하는 셈이다. 둘째, 객체 하나씩을 저장하는데 필요한 메모리 크기에 구현부 포인터 크기가 더해지는 것도 필수이다. 마지막으로, 구현부 포인터가 동적 할당된 구현부 객체를 가리키도록 어디선가 그 구현부 포인터의 초기화가 일어나야 한다(핸들 클래스의 생성자 안에서). 결국 동적 메모리 할당(그리고 필수적인 해제)에 따른 연산 오버헤드는 물론이고, bad_alloc(메모리가 고갈) 예외와 맞부딪힐 가능성까지 있다.
인터페이스 클래스의 경우에는 호출되는 함수가 전부 가상 함수라는 것이 약점이다. 따라서 함수 호출이 일어날 때마다 가상 테이블 점프에 따르는 비용이 소모된다. 게다가, 인터페이스 클래스에서 파생된 객체는 죄다 가상 테이블 포인터를 지니고 있어야 한다.
마지막으로, 핸들 클래스와 인터페이스 클래스가 똑같이 갖고 있는 약점은 인라인 함수의 도움을 제대로 끌어내기 힘들다는 점이다.
개발 도중에는 핸들 클래스 혹은 인터페이스 클래스를 사용하라. 구현부가 바뀌었을 때 사용자에게 미칠 파급 효과를 최소로 만드는 것이 좋다.
이것만은 잊지 말자!
* 컴파일 의존성을 최소화하는 작업의 배경이 되는 가장 기본적인 아이디어는 '정의' 대신에 '선언'에 의존하게 만들자는 것입니다. 이 아이디어에 기반한 두 가지 접근 방법은 핸들 클래스와 인터페이스 클래스입니다.
* 라이브러리 헤더는 그 자체로 모든 것을 갖추어야 하며 선언부만 갖고 있는 형태여야 합니다. 이 규칙은 템플릿이 쓰이거나 쓰이지 않거나 동일하게 적용합시다.
이것만은 잊지 말자!
* public 상속의 의미는 "is-a"입니다. 기본 클래스에 적용되는 모든 것들이 파생 클래스에 그대로 적용되어야 합니다. 왜냐하면 모든 파생 클래스 객체는 기본 클래스 객체의 일종이기 때문입니다.
이것만은 잊지 말자!
* 파생 클래스의 이름은 기본 클래스의 이름을 가립니다. public 상속에서는 이런 이름가림 현상은 바람직하지 않습니다.
* 가려진 이름을 다시 볼 수 있게 하는 방법으로, using 선언 혹은 전달 함수를 쓸 수 있습니다.
이것만은 잊지 말자!
* 인터페이스 상속은 구현 상속과 다릅니다. public 상속에서, 파생 클래스는 항상 기본 클래스의 인터페이스를 모두 물려받습니다.
* 순수 가상 함수는 인터페이스 상속만을 허용합니다.
* 단순(비순수) 가상 함수는 인터페이스 상속과 더불어 기본 구현의 상속도 가능하도록 지정합니다.
* 비가상 함수는 인터페이스 상속과 더불어 필수 구현의 상속도 가하도록 지정합니다.
이것만은 잊지 말자!
* 가상 함수 대신에 쓸 수 있는 다른 방법으로 NVI 관용구 및 전략 패턴을 들 수 있습니다. 이 중 NVI 관용구는 그 자체가 템플릿 메서드 패턴의 한 예입니다.
* 객체에 필요한 기능을 멤버 함수로부터 클래스 외부의 비멤버 함수로 옮기면, 그 비멤버 함수는 그 클래스의 public 멤버가 아닌 것들을 접근할 수 없다는 단점이 생깁니다.
* tr1::function 객체는 일반화된 함수 포인터처럼 동작합니다. 이 객체는 주어진 대상 시그너처와 호환되는 모든 함수호출성 객체를 지원합니다.
이것만은 잊지 말자!
* 상속받은 비가상 함수를 재정의하는 일은 절대로 하지 맙시다.
이것만은 잊지 말자!
* 상속받은 기본 매개변수 값은 저대로 재정의해서는 안 됩니다. 왜냐하면 기본 매개변수 값은 정적으로 바인딩되는 반면, 가상 함수(여러분이 오버라이드할 수 있는 유일한 함수이죠)는 동적으로 바인딩되기 때문입니다.
이것만은 잊지 말자!
* 객체 합성의 의미는 public 상속이 가진 의미와 완전히 다릅니다.
* 응용 영역에서 객체 합성의 의미는 has-a입니다. 구현 영역에서는 is-implemented-in-terms-of(..는...를 써서 구현됨)의 의미를 갖습니다.
이것만은 잊지 말자!
* private 상속의 의미는 is-implemented-in-terms-of입니다. 대개 객체 합성과 비교해서 쓰이는 분야가 많지는 않지만, 파생 클래스 쪽에서 기본 클래스의 protected 멤버에 접근해야 할 경우 혹은 상속받은 가상 함수를 재정의해야 할 경우에는 private 상속이 나름대로 의미가 있습니다.
* 객체 합성과 달리, private 상속은 공백 기본 클래스 최적화(EBO)를 활성화시킬 수 있습니다. 이 점은 객체 크기를 가지고 고민하는 라이브러리 개발자에게 꽤 매력적인 특징이 되기도 합니다.
이것만은 잊지 말자!
* 다중 상속은 단일 상속보다 확실히 복잡합니다. 새로운 모호성 문제를 일으킬 뿐만 아니라 가상 상속이 필요해질 수도 있습니다.
* 가상 상속을 쓰면 크기 비용, 속도 비용이 늘어나며, 초기화 및 대입 연산의 복잡도가 커집니다. 따라서 가상 기본 클래스에는 데이터를 두지 않는 것이 현실적으로 가장 실용적입니다.
* 다중 상속을 적법하게 쓸 수 있는 경우가 있습니다. 여러 시나리오 중 하나는, 인터페이스 클래스로부터 public 상속을 시킴과 동시에 구현을 돕는 클래스로부터 private 상속을 시키는 것입니다.
이것만은 잊지 말자!
* 클래스 및 템플릿은 모두 인터페이스와 다형성을 지원합니다.
* 클래스의 경우, 인터페이스는 명시적이며 함수의 시그니처를 중심으로 구성되어 있습니다. 다형성은 프로그램 실행 중에 가상 함수를 통해 나타납니다.
* 템플릿 매개변수의 경우, 인터페이스는 암시적이며 유효 표현식에 기반을 두어 구성됩니다. 다형성은 컴파일 중에 템플릿 인스턴스화와 함수 오버로딩 모호성 해결을 통해 나타납니다.
이것만은 잊지 말자!
* 템플릿 매개변수를 선언할 때, class 및 typename은 서로 바꾸어 써도 무방합니다.
* 중첩 의존 타입 이름을 식별하는 용도에는 반드시 typename을 사용합니다. 단, 중첩 의존 이름이 기본 클래스 리스트에 있거나 멤버 초기화 리스트 내의 기본 클래스 식별자로 있는 경우에는 예외입니다.
이것만은 잊지 말자!
* 파생 클래스 템플릿에서 기본 클래스 템플릿의 이름을 참조할 때는, "this->"를 접두사로 붙이거나 기본 클래스 한정문을 명시적으로 써 주는 것으로 해결합시다.
이것만은 잊지 말자!
* 템플릿을 사용하면 비슷비슷한 클래스와 함수가 여러 벌 만들어집니다. 따라서 템플릿 매개변수에 종속되지 않은 템플릿 코드는 비대화의 원인이 됩니다.
* 비타입 템플릿 매개변수로 생기는 코드 비대화의 경우, 템플릿 매개변수를 함수 매개변수 혹은 클래스 데이터 멤버로 대체함으로써 비대화를 종종 없앨 수 있습니다.
* 타입 매개변수로 생기는 코드 비대화의 경우, 동일한 이진 표현구조를 가지고 인스턴스화되는 타입들이 한 가지 함수 구현을 공유하게 만듦으로써 비대화를 감소시킬 수 있습니다.
이것만은 잊지 말자!
* 호환되는 모든 타입을 받아들이는 멤버 함수를 만들려면 멤버 함수 템플릿을 사용합시다.
* 일반화된 복사 생성 연산과 일반화된 대입 연산을 위해 멤버 템플릿을 선언했다 하더라도, 보통의 복사 생성자와 복사 대입 연산자는 여전히 직접 선언해야 합니다.
이것만은 잊지 말자!
* 모든 매개변수에 대해 암시적 타입 변환을 지원하는 템플릿과 관게가 있는 함수를 제공하는 클래스 템플릿을 만들려고 한다면, 이런 함수는 클래스 템플릿 안에 프렌드 함수로서 정의합시다.
이것만은 잊지 말자!
* 특성정보 클래스는 컴파일 도중에 사용할 수 있는 타입 관련 정보를 만들어냅니다. 또한 특성정보 클래스는 템플릿 및 템플릿 특수 버전을 사용하여 구현합니다.
* 함수 오버로딩 기법과 결합하여 특성정보 클래스를 사용하면, 컴파일 타임에 결정되는 타입별 if...else 점검문을 구사할 수 있습니다.
이것만은 잊지 말자!
* 템플릿 메타프로그래밍은 기존 작업을 런타임에서 컴파일 타임으로 전환하는 효과를 냅니다. 따라서 TMP를 쓰면 선행 에러 탐지와 높은 런타임 효율을 손에 거머쥘 수 있습니다.
* TMP는 정책 선택의 조합에 기반하여 사용자 정의 코드를 생성하는 데 쓸 수 있으며, 또한 특정 타입에 대해 부적절한 코드가 만들어지는 것을 막는 데도 쓸 수 있습니다.
이것만은 잊지 말자!
* set_new_handler 함수를 쓰면 메모리 할당 요청이 만족되지 못했을 때 호출되는 함수를 지정할 수 있습니다.
* 예외불가(notthrow) new는 영향력이 제한되어 있습니다. 메모리 할당 자체에만 적용되기 때문입니다. 이후에 호출되는 생성자에서는 얼마든지 예외를 던질 수 있습니다.
이것만은 잊지 말자!
* 개발자가 스스로 사용자 정의 new 및 delete를 작성하는 데는 여러 가지 나름대로 타당한 이유가 있습니다. 여기에는 수행 성능을 향상시키려는 목적, 힙 사용 시의 에러를 디버깅하려는 목적, 힙 사용 정보를 수집하려는 목적 등이 포함됩니다.
이것만은 잊지 말자!
* 관례적으로, operator new 함수는 메모리 할당을 반복해서 시도하는 무한 루프를 가져야 하고, 메모리 할당 요구를 만족시킬 수 없을 때 new 처리자를 호출해야 하며, 0바이트에 대한 대책도 있어야 합니다. 클래스 전용 버전은 자신이 할당하기로 예정된 크기보다 더 큰(틀린) 메모리 블록에 대한 요구도 처리해야 합니다.
* operator delete 함수는 널 포인터가 들어왔을 때 아무 일도 하지 않아야 합니다. 클래스 전용 버전의 경우에는 예정 크기보다 더 큰 블록을 처리해야 합니다.
이것만은 잊지 말자!
* operator new 함수의 위치지정(placement) 버전을 만들 때는, 이 함수와 짝을 이루는 위치지정 버전의 operator delete 함수도 꼭 만들어 주세요. 이 일을 빼먹었다가는, 찾아내기도 힘들며 또 생겼다가 안 생겼다 하는 메모리 누출 현상을 경험하게 됩니다.
* new 및 delete의 위치지정 버전을 선언할 때는, 의도한 바도 아닌데 이들의 표준 버전이 가려지는 일이 생기지 않도록 주의해 주세요.
이것만은 잊지 말자!
* 컴파일러 경고를 쉽게 지나치지 맙시다. 여러분의 컴파일러에서 지원하는 최고 경고 수준에도 경고 메시지를 내지 않고 컴파일되는 코드를 만드는 쪽에 전력을 다 하십시오.
* 컴파일러 경고에 너무 기대는 인생을 지양하십시오. 컴파일마다 트집을 잡고 경고를 내는 부분들이 천차만별이기 때문입니다. 지금 코드를 다른 컴파일러로 이식하면서 여러분이 익숙해져 있는 경고 메시지가 온 데 간 데 없이 사라질 수도 있습니다.
이것만은 잊지 말자!
* 최초에 상정된 표준 C++ 라이브러리의 주요 구성요소는 STL, iostream, 로케일 등입니다. 여기에는 C89의 표준 라이브리도 포함되어 있습니다.
* TR1이 도입되면서 추가된 것은 스마트 포인터(tr1::shared_ptr 등), 일반화 함수 포인터(tr1::function), 해시 기반 컨테이너, 정규 표현식 그리고 그 외의 10개 구성요소입니다.
* TR1 자체는 단순히 명세서일 뿐입니다. TR1의 기능을 사용하기 위해서는 명세를 구현한 코드를 구해야 합니다. TR1 구현을 구할 수 있는 자료처 중 한 군데가 바로 부스트입니다.
이것만은 잊지 말자!
* 부스트는 동료 심사를 거쳐 등록되고 무료로 배포되는 오픈 소스 C++ 라이브러리를 개발하는 모임이자 웹사이트입니다. 또한 C++ 표준화에 있어서 영향력 있는 역할을 맡고 있습니다.
* 부스트에서 배포되는 라이브러리들 중엔 TR1 구성요소에 들어간 것도 있지만, 그 외에 다른 라이브러리들도 아주 많습니다.