[Effective C++] Chapter 1. C++에 왔으면 C++의 법을 따릅시다
Table of Contents
이펙티브 C++ 책을 읽고 공부한 노트입니다.
Item 1: C++를 언어들의 연합체로 바라보는 안목은 필수 #
- C++은 다중 패러다임 프로그래밍 언어(multiparadigm programming language)라고 불린다.
- C++을 단일 언어로 바라보는 눈을 넓혀서, 상관 관계가 있는 여러 언어들의 연합체(federation)로 보아야 한다.
특징 | 설명 |
---|---|
C | C++은 절차적(procedual) 프로그래밍 언어인 C를 기본으로 하고 있다. |
객체 지향(object-oriented) 개념의 C++ | 클래스를 쓰는 C라고 볼 수 있다. 클래스, 캡슐화, 상속, 다형성, 가상 함수 등이 포함된다. |
템플릿 C++ | 일반화(generic) 프로그래밍 부분이다. |
STL |
C++의 어떤 부분을 사용하느냐에 따라 효과적인 프로그래밍 규칙이 달라진다.
Item 2: #define
을 쓰려거든 const
, enum
, inline
을 떠올리자 #
상수를 쓰는 경우 #
#define ASPECT_RATIO 1.653
#define
은 컴파일 이전에 처리된다.- 따라서 이미 숫자 상수로 대치된 코드에서 컴파일 에러라도 발생하면, 꽤나 헷갈릴 것이다.
- 해결법: 상수를 쓴다.
const double AspectRatio = 1.653;
- 하지만 두 가지는 주의하자.
- (1) 상수 포인터의 경우
const
를 두 번 사용하게 되겠다.const char * const authorName = "Scott Meyers";
- (2) 클래스 멤버로 상수를 정의하는 경우.
- 사본 개수를 하나만 하고 싶다면
static
멤버로 만든다. - 구식 컴파일러는 클래스 내에서 정적 클래스 상수를 초기화하는 것을 금지하기 때문에 구현 파일에서 정의를 해주어야 하겠다.
- 배열 크기 등, 컴파일 하는 도중에 클래스 상수의 값이 필요한데 구식 컴파일러라서 초기화가 안 된다면,
enum
을 사용해보자. 이것을 나열자 둔갑술(enum hack)이라고 한다. - 만약 우리가 선언한 정수 상수의 주소를 얻는다던지, 참조를 하는 게 싫으면, 나열자 둔갑술이 좋은 선택이 될 수 있겠다.
- 사본 개수를 하나만 하고 싶다면
- (1) 상수 포인터의 경우
// 헤더 파일
class CostEstimate
{
private:
// static const 멤버의 선언과 초기화
static const double FudgeFactor = 1.35;
};
// 구현 파일
// 구식 컴파일러의 경우 정의에서 초기값을 준다.
const double CostEstimate::FudgeFactor = 1.35;
class GamePlayer
{
private:
enum { NumTurns = 5; } // 나열자 둔갑술
int scores[NumTurns]; // (O)
};
매크로 함수의 경우 #
#define CALL_WITH_MAX(a, b) f((a) > (b) ? (a) : (b))
- 매크로 함수는 단점이 한 두개가 아니다.
- 괄호를 인자마다 쓰는 것은 물론이며, 다음과 같은 괴현상이 발생한다.
int a = 5, b = 0;
CALL_WITH_MAX(++a, b); // a가 두 번 증가힌다.
CALL_WITH_MAX(++a, b + 10); // a가 한 번 증가힌다.
- 해결법: 인라인 함수에 대한 템플릿을 만든다.
- 기존 매크로의 효율은 그대로 유지하면서 함수의 동작 방식 및 타입 안정성까지 완벽히 취한다.
template<typename T>
inline void callWithMax(const T& a, const T& b)
{
f(a > b ? a : b);
}
단순한 상수를 쓸 때는,
#define
보다const
객체 혹은enum
을 우선 생각하자.
함수처럼 쓰이는 매크로를 만들려면,
#define
보다inline
함수를 우선 생각하자.
Item 3: 낌새만 보이면 const를 들이대 보자! #
const
를 붙여서 선언하면 컴파일러가 사용상의 에러를 잡아내는 데 도움을 준다.
포인터의 경우 #
char greeting[] = "Hello";
char *p = greeting; // 비상수
const char *p = greeting; // 상수 데이터
char const *p = greeting; // (이렇게 쓸 수도 있다)
char * const p = greeting; // 상수 포인터
const char * const p = greeting; // 상수 포인터, 상수 데이터
STL 반복자의 경우 #
std::vector<int> vec;
// T * const와 같다. (상수 포인터)
const std::vector<int>::iterator iter = vec.begin();
*iter = 10;
// const T * 와 같다. (상수 데이터)
std::vector<int>::const_iterator iter = vec.begin();
++iter;
함수 선언의 경우 #
class Rational {};
const Rational operator*(const Rational& lhs, const Rational& rhs);
// 맨앞에 const는
Rational a, b, c;
(a * b) = c; // (X)
// 이런 말도 안되는 상황을 막는다.
멤버 함수의 경우 #
- 멤버 함수에
const
를 쓰는 이유- (1) 인터페이스
- 이 함수에서 객체를 변경할 수 없다는 것을 알린다.
- (2) 상수 객체를 사용할 수 있게 한다.
- 보통 상수 객체가 생기는 경우는 포인터 혹은 참조자로 객체가 전달되는 경우이다.
- (1) 인터페이스
class TextBlock
{
private:
char* text;
public:
// 비상수 객체에 대한 operator[]
char& operator[](std::size_t position)
{
return text[position];
}
// 상수 객체에 대한 operator[]
// 맨뒤의 const로 상수 객체에서 이 함수를 호출할 수 있게된다.
// 또한 이 함수 내에서 객체를 변경할 수 없다.
// 맨앞의 const로 대입이 불가능하게 된다.
// ctb[0] = 'x'; 가 안 된다.
const char& operator[](std::size_t position) const
{
return text[position];
}
};
void print(const TextBlock& ctb)
{
cout << ctb[0]; // 상수 객체이므로, const 버전으로 호출한다.
}
- 멤버 함수가
const
라는 의미- (1) 비트 수준 상수성(bitwise constness), 물리적 상수성(physical constness)
- 그 객체를 구성하는 비트들 중 어떤 것도 바꾸면 안 된다는 것이다.
- (2) 논리적 상수성(logical constness)
- 일부 몇 비트 정도는 바꿀 수 있되, 그것을 사용자측에서 알아채지 못하게만 하면 상수 멤버 자격이 있다는 것이다.
- 하지만, 비트 수준 상수성을 갖추지 않으면 컴파일 되지 않는다.
- 따라서
const
함수에서도 데이터 멤버를 수정하기 위해mutable
이라는 키워드가 등장했다.
- (1) 비트 수준 상수성(bitwise constness), 물리적 상수성(physical constness)
class TextBlock
{
private:
char* text;
mutable std::size_t textLength;
mutable bool lengthIsValid;
public:
std::size_t length() const // const 멤버 함수지만
{
if (!lengthIsValid)
{
// mutable로 선언된 데이터 멤버들은 수정할 수 있다.
textLength = std::strlen(text);
lengthIsValid = true;
}
return textLength;
}
}
상수 멤버와 비상수 멤버에서 코드 중복 피하기 #
- 비상수 버전이 상수 버전을 호출하도록 만든다.
const
를 붙이는 것은 안전한 타입 변환이므로static_cast<>
로 하고,const
를 떼어내는 것은const_cast<>
밖에 없으므로 선택의 여지는 없다.
class TextBlock
{
private:
char* text;
public:
char& operator[](std::size_t position)
{
// const 버전을 호출한다.
return const_cast<char&>( static_cast<const TextBlock&>(*this)[position] );
}
const char& operator[](std::size_t position) const
{
// 여러가지 잡다한 작업들
// ...
return text[position];
}
};
const
를 사용하면 컴파일러가 사용상의 에러를 잡아내는 데 도움을 준다.
컴파일러는 비트수준 상수성을 지켜야 하지만, 우리는 논리적인 상수성을 사용해서 프로그래밍해야한다.
상수 멤버와 비상수 멤버가 기능적으로 서로 똑같게 구현되어 있을 경우에는, 비상수 버전이 상수 버전을 호출하도록 한다.
Item 4: 객체를 사용하기 전에 반드시 그 객체를 초기화하자 #
- C++은 어떤 규칙들에 의해서 초기화가 되고 안 되고가 달라진다.
- 이런 규칙을 모두 새겨두기 보다는, 모든 객체를 사용하기 전에 항상 초기화하자.
- 대입과 초기화를 헷갈리지는 말자.
생성자에서 멤버 초기화 #
- 생성자 안에서 대입을 하는 것은
- 기본 생성자를 호출해서 초기화를 한 후, 또 대입을 하는 것이다.
- 대신 멤버 초기화 리스트를 사용하면
- 초기화 리스트에 들어가는 인자를 사용해서 복사 생성자를 한번 호출한다.
- C++의 규칙에 의하면 데이터 멤버는 생성자의 본문이 실행되기 전에 초기화되어야 한다고 되어 있다.
- 멤버 초기화 리스트를 사용하면 객체 생성 전에 초기화되는 것이다.
- 따라서 대부분의 경우 멤버 초기화 리스트가 효율적이다.
- 물론, 기본 제공 타입의 객체는 그 차이가 없지만, 초기화를 빠트리는 실수를 방지하기 위해서라도 멤버 초기화 리스트를 사용하자.
- 상수이거나 참조자인 데이터 멤버의 경우에는 반드시 초기화해야 하겠다.
- 순서는 데이터 멤버가 클래스 내부에서 선언된 순서이다.
class PhoneNumber {};
class ABEntry
{
private:
// 여기에 선언된 순서로 초기화된다.
std::string theName;
std::string theAddress;
std::list<PhoneNumber> thePhones;
int numTimesConsulted;
public:
ABEntry(const std::string& name, const std::string address,
const std::list<PhoneNumber>& phones);
};
// 이것은 대입이다. 초기화가 아니다.
ABEntry::ABEntry(const std::string& name, const std::string address,
const std::list<PhoneNumber>& phones)
{
theName = name;
theAddress = address;
thePhones = phones;
numTimesConsulted = 0;
}
// 멤버 초기화 리스트를 사용해서 초기화한다.
ABEntry::ABEntry(const std::string& name, const std::string address,
const std::list<PhoneNumber>& phones)
: theName(name), theAddress(address), thePhones(phones), numTimesConsulted(0)
{ }
비지역 정적 객체의 초기화 #
- 정적 객체(static object)의 종류
분류 | 포함되는 사항 |
---|---|
지역 정적 객체 (local) |
함수 안에서 static 으로 선언된 객체 |
비지역 정적 객체 (non-local) |
전역 객체 네임스페이스 유효범위에서 정의된 객체 클래스 안에서 static 으로 선언된 객체 파일 유효범위에서 static 으로 선언된 객체 |
- 번역 단위(translation unit)란?
- 컴파일을 통해 하나의 목적 파일을 만드는 바탕이 되는 소스 코드이다.
- 기본적으로 소스 파일 하나가 되는데,
#include
하는 파일들까지 합쳐서 하나의 번역 단위가 된다.
- 별개의 번역 단위에서 정의된 비지역 정적 객체들의 초기화 순서는 정해져 있지 않다.
- 이 사실 때문에 여러 번역 단위에 비지역 정적 객체들이 있을 때 초기화 문제가 생긴다.
class FileSystem
{
public:
std::size_t numDisks() const;
};
// 사용자가 쓰게 될 객체이다.
extern FileSystem tfs;
class Directory
{
public:
Directory();
};
Directory::Directory()
{
// tfs 객체를 사용한다.
std::size_t disks = tfs.numDisks();
}
Directory tempDir();
// 만약 tfs가 먼저 초기화되지 않는다면 큰 문제가 발생할 것이다!
- 해결 방법
- 비지역 정적 객체를 지역 정적 객체로 바꾼다.
- 즉, 정적 객체 자체를 직접 사용하지 않고, 그 객체에 대한 참조를 반환하는 함수를 사용하는 것이다.
- 싱글톤 패턴(Singleton pattern)의 전형적인 구현 양식이다.
class FileSystem
{
public:
std::size_t numDisks() const;
};
FileSystem& tfs() // 클래스 안에 정적 멤버로 들어가도 된다.
{
// 지역 정적 객체를 정의한다.
static FileSystem fs;
return fs;
}
class Directory
{
public:
Directory();
};
Directory::Directory()
{
// tfs가 tfs()로 바뀌었다.
std::size_t disks = tfs().numDisks();
}
Directory& tempDir()
{
static Directory td;
return td;
}
- 다중 스레드 환경에서는…
- 비상수 정적 객체는 지역 객체이든 비지역 객체이든 온갖 골치덩이가 된다.
- 따라서 경쟁 생태(race condition)를 없애주는 방법들을 사용해야 한다. (여기서는 다루지 않는다)
기본 제공 타입의 객체는 직접 손으로 초기화한다.
생성자에서는 멤버 초기화 리스트를 즐겨 사용하자. 그리고 멤버가 선언된 순서와 똑같이 나열하자.
여러 번역 단위에 비지역 정적 객체들이 있을 때는, 지역 정적 객체로 바꾸어 주자.