[C++/도서] Effective C++ - 챕터1: C++에 왔으면 C++의 법을 따릅시다

Donghee·2024년 9월 4일
0

Effective C++

목록 보기
1/2
post-thumbnail

챕터1: C++에 왔으면 C++의 법을 따릅시다

항목1: C++를 언어들의 연합체로 바라보는 안목은 필수

오늘날의 C++는 다중패러다임 프로그래밍 언어라고 불린다. 절차적 프로그래밍, 객체 지향, 함수식, 일반화, 메타프로그래밍 개념까지 지원한다.

C++를 단일 언어로 바라보는 눈을 넓혀, 상관 관계가 있는 여러 언어들의 연합체로 보자. C++를 제대로 따라잡으려면, 이 언어가 네 가지의 하위 언어를 제공한다는 점을 새기고 있어야 한다.

  • C: C++는 여전히 C를 기본으로 하고 있다.
  • 객체 지향 개념의 C++: ‘클래스를 쓰는 C’에 관한 것이 모두 해당된다. 클래스, 캡슐화, 상속, 다형성, 가상 함수 등이다.
  • 템플릿 C++: C++의 일반화 프로그래밍 부분이다.
  • STL: 이름에서 알 수 있듯이 템플릿 라이브러리다. STL은 나름대로 독특한 사용규칙이 있어서, 이를 써서 프로그래밍하려면 그 규약을 따르면 된다.

한 하위 언어에서 다른 하위 언어로 옮겨 가면서 대응 전략을 바꿔야 하는 상황이 오더라도 당황하지 말아야 한다.

예를 들어, C 스타일로만 쓰고 있으면 기본 제공 타입에 대해서는 “값 전달이 참조 전달보다 대개 효율이 더 좋다”라는 규칙이 통한다. 하지만 객체 지향 C++로 옮겨 가면 사용자 정의 생성자/소멸자 개념이 생기면서 상수 객체 참조아에 의한 전달이 더 좋은 효율을 보인다. 템플릿 C++에서도 객체의 타입을 알 수 없기 때문에, 이 점이 두드러진다. 하지만 STL 쪽으로 넘어오면, 반복자와 함수 객체가 C의 포인터를 본떠 만든 것이기 때문에 값 전달에 대한 규칙이 다시 힘을 발휘한다.

C++를 사용한 효과적인 프로그래밍 규칙은, C++의 어떤 부분을 사용하느냐에 따라 달라진다.

항목2: #define을 쓰려거든 const, enum, inline을 떠올리자

다음 줄과 비슷한 코드를 썼다고 가정해 보자.

#define ASPECT_RATIO 1.653

컴파일러에겐 ASPECT_RATIO가 전혀 보이지 않는다. 소스 코드가 컴파일러에게 넘어가기 전에 선행 처리자가 이를 밀어버리고 숫자 상수로 바꾸어 버리기 때문이다. 때문에 컴파일 에러라도 나게 되면, ASPECT_RATIO 대신 1.653이 에러 메세지에 뜨는 등 혼란이 발생한다.

이 문제에 해결법은 매크로 대신 상수를 쓰는 것이다.

const double AspectRatio = 1.653;

이는 언어 차원에서 지원하는 상수 탕비의 데이터이기 때문에, 컴파일러의 눈에 보인다. 또한 상수가 부동소수점 실수 타입일 경우에는 위의 코드가 #define을 썼을 때보다 컴파일 최종 코드 크기가 더 작게 나올 수 있다. 매크로를 쓰면 ASPECT_RATIO가 모두 1.653으로 바뀌며 1.653의 사본이 등장 횟수만큼 들어가지만, 상수 타입은 아무리 여러 번 쓰이더라도 사본은 한 개만 생기기 때문이다.

#define을 상수로 교체할 때는 두 가지 경우만 조심하자.

첫째는 상수 포인터를 정의하는 경우다. 포인터는 꼭 const로 선언해 주어야 하고, 포인터가 가리키는 대상까지 const로 선언하는 것이 보통이다.

const char * const authorName = "Scott Meyers";

char* 기반의 문자열 상수를 정의한다면 위와 같이 const를 두 번 써야 한다.

두 번째 경우는 클래스 멤버로 상수를 정의하는 경우이다.

class GamePlayer{
private:
	static const int NumTurns = 5;
	int scores[NumTurns];
	...	
};

위의 NumTurns는 선언된 것이다. 정의가 아니다. C++에서는 정의가 마련되어 있어야 하는 게 보통이지만, 정적 멤버로 만들어지는 정수류 타입의 클래스 내부 상수는 예외다. 이들에 대해 주소를 취하지 않는 한, 정의 없이 선언만 해도 아무 문제가 없다. 단, 주소를 구하거나, 컴파일러의 문제로 정의를 제공해야 할 경우가 있다.

const int GamePlayer::NumTurns;

클래스 상수의 정의는 헤더 파일이 아닌 구현 파일에 둔다.

정의에는 상수의 초기값이 있으면 안된다. 클래스 상수의 초기값은 해당 상수가 선언된 시점에서 바로 주어지기 때문이다. 즉, 선언되면서 바로 초기화된다는 것이다.

그러나 클래스 내부 초기화를 허용하는 경우는 정수 타입의 상수에 대해서만 국한되어 있다. 이럴 때엔 초기값을 상수 정의 시점에 주자.

class CostEstimate{
private:
	static const double FudgeFactor; // 헤더 파일에서의 선언
	...
};
const double CostEstimate::FudgeFactor = 1.35; // 구현 파일에서의 정의

예외가 있다면 클래스를 컴파일 하는 중에 해당 클래스의 클래스 상수가 필요할 때이다. 정수 타입의 정적 클래스 상수에 대한 클래스 내 초기화를 금지하는 구식 컴파일러라면, 나열자 둔갑술(enum hack)이라는 기법을 생각할 수 있다. 이는 나열자(enumerator) 타입의 값은 int가 놓일 곳에도 쓰일 수 있다는 것을 활용한 것이다.

class GamePlayer{
private:
	enum { NumTurns = 5 };
	int scores[NumTurns];
	...	
};

이는 동작 방식이 const보다는 #define에 가깝다. enum의 주소를 얻는 것은 불가능하고, 컴파일러가 enum에 대한 메모리 할당을 저지르지 않는다. 또한 많은 코드에서 이 기법이 쓰이고 있으므로 눈을 단련시켜 둬야하는 의미도 있다.

#define 지시자의 또 다른 오용 사례는 매크로 함수다. 다음 코드를 보자. 매크로 인자들 중 큰 것을 사용해 어떤 함수 f를 호출하는 매크로다.

#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 키워드에 대해서 추가

https://en.cppreference.com/w/cpp/language/inline
추가로 inline 키워드가 C++17 이후에는 inline 함수/변수를 헤더 파일에 정의되도록 지정하는 키워드가 되었다고 한다. static 변수 같은 경우엔 여러 소스코드에서 사용될 때마다 추가로 정의가 되어서 이를 공유하고자하면 extern 키워드를 써서 매번 선언해줘야 하지만, inline 키워드를 통해 한번에 선언과 정의를 끝낼 수 있다는 점도 알아두자.

항목3: 낌새만 보이면 const를 들이대 보자!

const는 어떤 값이 불변이어야 한다는 제작자의 의도를 컴파일러 및 다른 프로그래머와 나눌 수 있는 수단이다.

포인터에 대한 상수는 기본적으로는 포인터 자체를 상수로, 혹은 포인터가 가리키는 데이터를 상수로 지정할 수 있는데, 둘 다 지정할 수도 있고 아무것도 지정하지 않을 수도 있다.

char *p = "Hello"; // 비상수 포인터, 비상수 데이터
const char *p = "Hello"; // 비상수 포인터, 상수 데이터
char * const p = "Hello"; // 상수 포인터, 비상수 데이터
const char * const p = "Hello"; // 상수 포인터, 상수 데이터

const 키워드가 표의 왼쪽에 있으면 포인터가 가리키는 대상이 상수, const가 표의 오른쪽에 있는 경우엔 포인터 자체가 상수다. 포인터가 가리키는 대상을 상수로 만들 때엔 타입 앞에 const를 붙이기도 하고, 뒤에 붙이기도 한다. 스타일의 차이이다.

// 둘의 의미는 같다.
void f1(const Widget *pw);
void f2(Widget const *pw);

STL 반복자는 포인터를 본뜬 것이기 때문에, 기본적인 동작 원리가 T 포인터와 흡사하다. 어떤 반복자를 const로 선언하는 것은 포인터 자체를 상수로 선언하는 것(T const 포인터)과 같다. 이 경우에 반복자가 가리키는 대상의 값 변경하는 것은 가능하지만, 반복자의 대상을 변경하는 것은 불가능하다. 만약 상수 객체를 가리키는 반복자가 필요하다면 const_iterator를 쓰자.

가장 강력한 const의 용도는 함수 선언에 쓰는 경우다. const는 함수 반환 값, 각각의 매개변수, 멤버 함수 앞에 붙을 수 있다. 함수 반환 값을 상수로 정해주면, 안정성을 포기하지 않고도 사용자측의 에러 돌발 상황을 줄이는 효과를 자주 볼 수 있다. 눈 딱 감고 여섯 글자만 눌러보자.

상수 멤버 함수

멤버 함수에 붙는 const 키워드의 역할은 ”해당 멤버 함수가 상수 객체에 대해 호출될 함수이다”라는 사실을 알려주는 것이다. 이런 함수가 왜 중요할까?

첫째는 클래스의 인터페이스를 이해하기 좋게 하기 위해서다. 그 클래스로 만들어진 객체를 변경할 수 있는 함수는 무엇이고, 변경할 수 없는 함수는 무엇인지 사용자 쪽에서 알기 쉽게 한다.

둘째는 이 키워드를 통해 상수 객체를 사용할 수 있게 하자는 의미도 있다. C++ 프로그램의 퍼포먼스를 높이는 핵심 기법 중 하나가 객체 전달을 상수 객체에 대한 참조자로 진행하는 것이기 때문이다. 이 기법이 제대로 적용되려면 상수 상태로 전달된 객체를 조작할 수 있는 const 멤버 함수가 준비되어 있어야 한다.

또한 const 키워드가 있고 없고의 차이만 있는 멤버 함수들은 오버로딩이 가능하다. 실제 프로그램에서 상수 객체가 생기는 경우는 상수 객체에 대한 포인터 혹은 상수 객체에 대한 참조자로 객체가 전달될 때이다.

어떤 멤버 함수가 상수 멤버라는 것이 대체 어떤 의미일까? 여기에는 비트수준 상수성, 논리적 상수성이라는 개념이 존재한다.

비트 수준 상수성은 어떤 멤버 함수가 그 객체의 어떤 데이터 멤버도 건드리지 않아야 그 멤버 함수가 ‘const’임을 인정하는 개념이다. 즉, 그 객체를 구성하는 비트들 중 어떤 것도 바꾸면 안 된다. 사실 C++에서 정의하고 있는 상수성이 비트수준 상수성이다. 비트수준 상수성을 지키는 다음 예제를 살펴보자.

class CTextBlock{
public:
	...
	char& operator[](std::size_t position) const { return pText[position]; }
	
private:
	char *pText;
};

const CTextBlock cctb("Hello");
char *pc = &cctb[0]; // 내부 데이터의 포인터를 얻는다.
*pc = 'J'; // 이제 cctb는 "Jello"라는 값을 갖는다.

분명 비트수준 상수성을 지키는 코드이지만, 상수 멤버 함수를 호출했더니 상수 객체의 값이 바뀌어버린 상황이다. 논리적 상수성은 이런 상황을 보완하는 개념이다. 상수 멤버 함수라고 해서 객체의 한 비트라도 수정할 수 없는 것이 아니라, 몇 비트 정도는 바꿀 수 있되, 그것을 사용자측에서 알아채지 못하게만 하면 상수 멤버 자격이 있다는 것이다. 다음 예제를 보자.

class CTextBlock{
public:
	...
	std::size_t length() const;
	
private:
	char *pText;
	std::size_t textlength;
	bool lengthIsValid;
};

std::size_t CTextBlock::length() const
{
	if (!lengthIsValid) {
		textLength = std::strlen(pText); // 상수 멤버 함수 안에서는 대입 불가
		lengthIsValid = true; // 상수 멤버 함수 안에서는 대입 불가
	}
	
	return textLength;
}

다음은 논리적 상수성을 지키지만, C++의 비트수준 상수성을 지키지는 못한다. textLength와 lengthIsValid가 비트수준에서 이미 변하기 때문이다. 하지만 mutable 키워드를 사용하면 이를 해결할 수 있다. mutable는 비정적 데이터 멤버를 비트수준 상수성의 족쇄에서 풀어 주는 키워드이다.

class CTextBlock{
public:
	...
	std::size_t length() const;
	
private:
	char *pText;
	mutable std::size_t textlength;
	mutable bool lengthIsValid;
};

std::size_t CTextBlock::length() const
{
	if (!lengthIsValid) {
		textLength = std::strlen(pText); // mutable 멤버들은 수정 가능
		lengthIsValid = true; // mutable 멤버들은 수정 가능
	}
	
	return textLength;
}

다음과 같이 수정하면 C++의 상수성을 벗어나지 않으면서, 논리적 상수성을 만족하는 상수 멤버 함수를 만들 수 있다.

상수 멤버 및 비상수 멤버 함수에서 코드 중복 현상을 피하는 방법

mutable은 위의 문제를 해결하는 데엔 괜찮은 방법이지만, 이것으로 const에 관한 골칫거리를 모두 해결하진 못한다. 앞서 있었던 TextBlock 클래스의 operator []를 상수/비상수 버전으로 나눈다고 가정해보자.

class TextBlock{
public:
	...
	const char& operator[](std::size_t position) const 
	{
		...
		return pText[position];
	}
	char& operator[](std::size_t position)
	{
		...
		return pText[position];
	}
	
private:
	char *pText;
};

대강 보아도 코드 중복이 심하다. 이 코드를 볼 때, operator[]의 양 버전 중 하나만 제대로 만들고 다른 버전은 이것을 호출하는 식으로 만들고 싶지 않은가? const 껍데기를 캐스팅으로 날려버리는 식으로 해결해보자.

class TextBlock{
public:
	...
	const char& operator[](std::size_t position) const 
	{
		...
		return pText[position];
	}
	char& operator[](std::size_t position)
	{
		...
		return const_cast<char&>(
			static_cast<const TextBlock&>(*this)[position]
		);
	}
	
private:
	char *pText;
};

보면 캐스팅이 한 번이 아니라 두 번이 호출된다. 곰곰히 생각해보면, 비상수 operator[] 속에서 그냥 operator[]를 사용하면 자신이 무한 재귀 호출될 것이다. 따라서 ”상수 operator[]를 호출하고 싶다.”를 표현해주어야 하는데, 이것을 표현하는 방법이 this의 타입 캐스팅인 것이다. 정리하면, 두 캐스팅 중 첫 번째 것은 this에 const를 붙이는 것이고, 두 번째 것은 상수 operator[]의 반환 값에서 const를 떼어내는 캐스팅이다.

const를 붙이는 캐스팅은 안전한 타입 변환을 강제로 진행하는 것뿐이기 때문에 static_cast만 써도 맞는다. 반면 const를 제거하는 캐스팅은 const_cast밖에 없으므로 선택의 여지가 없다.

그렇다면 상수 버전이 비상수 버전을 호출하는 방법은 안될까? 이는 우리가 원하는 바가 아니다. 어쩌다가 상수 멤버에서 비상수 멤버를 호출하게 되면, 그 객체는 변경될 위험에 빠질 수 있다. 또한 이 방법은 const_cast를 이용해 *this에 붙은 const를 떼어내어야 하는데, 이게 온갖 재앙의 씨앗이다.

const는 어떤 유효범위에 있는 객체에도 붙을 수 있으며, 함수 매개변수 및 반환 타입에도 붙을 수 있으며, 멤버 함수에도 붙을 수 있다.
컴파일러 쪽에서 보면 비트수준 상수성을 지켜야 하지만, 우리는 논리적 상수성을 사용해 프로그래밍해야 한다.
상수/비상수 멤버 함수가 똑같게 구현되어 있을 때는 코드 중복을 피해야 하자. 이때는 비상수 버전이 상수 버전을 호출하도록 하자.

항목4: 객체를 사용하기 전에 반드시 그 객체를 초기화하자

int x;

int형 변수 하나를 선언해보자. 어떤 경우에는 x의 값이 0으로 확실히 초기화되지만, 또 어떤 경우에서는 그것이 보장되지 않는다.

C++의 객체/변수 초기화가 중구난방인 것은 아니다. 분명 규칙이 명확히 있다. C++의 C 부분만을 쓴다면 값이 초기화된다는 보장이 없다. 그러나 STL의 vector는 초기화된다는 보장을 갖게 된다. 이런 불균형을 해결하는 좋은 방법은 모든 객체를 사용하기 전 항상 초기화하는 것이다.

여기서 초기화와 대입을 헷갈리지 말자. 다음의 코드를 보자. 모두 대입을 하고 있다.

class ABEntry{
public:
	ABEntry(const std::string& name, const std::string& address, const std::list<PhoneNumber>& phones);
	
private:
	std::string theName;
	std::string theAddress;
	std::list<PhoneNumber> thePhones;
	int numTimesConsulted;
};

ABEntry::ABEntry(const std::string& name, const std::string& address, const std::list<PhoneNumber>& phones)
{
	theName = name;
	theAddress = address;
	thePhones = phones;
	numTimesConsulted = 0;
	// 모두 초기화가 아니라 대입을 하고 있다.
}

C++의 규칙에 의하면 어떤 객체이든 그 객체의 데이터 멤버는 생성자의 본문이 실행되기 전, 초기화되어야 한다고 명기되어 있다. 여기서 ABEntry의 데이터 멤버들은 초기화되고 있는 것이 아니라 대입되고 있는 것이다. 정확히 말하자면, 생성자에 진입하기도 전에 numTimesConsulted를 제외한 세 데이터 멤버가 기본 생성자에 의해 초기화되었다. numTimesConsulted는 기본제공 타입이기 때문에 초기화되었다는 보장이 없다. 올바르게 초기화할 수는 없을까?

ABEntry::ABEntry(const std::string& name, const std::string& address, const std::list<PhoneNumber>& phones)
: theName(name),
	theAddress(address),
	thePhones(phones),
	numTimesConsulted(0)
{}

멤버 초기화 리스트를 사용하면 된다. 이는 기본 생성자로 초기화 후 다시 대입하는 아까의 방식과 다르게 바로 인자로 초기화가 된다. 대부분 이렇게 복사 생성자를 한 번 호출해 초기화하는 쪽이 더 효율적이다.

또한 데이터 멤버를 기본 생성자로 초기화하고 싶을 때도 멤버 초기화 리스트를 사용하는 습관을 들이자. 이렇게 해야 어쩌다가 리스트에서 특정 멤버를 빼먹었을 때 ‘그 멤버가 초기화되지 않았을 수 있다’는 생각의 부담이 사라진다.

데이터 멤버가 상수거나, 참조자로 되어있다면 반드시 초기화되어야 한다. 이들은 대입 자체가 불가능하기 때문이다. 따라서 이 경우엔 초기화 리스트가 선택이 아니라 의무가 된다. 이렇게 여러 경우를 고려하며 초기화 리스트를 썼다가 안 썼다가 하기엔 너무 헷갈리니, 그냥 모두 쓰는 편이 낫다.

물론 생성자마다 초기화 리스트가 주렁주렁 달려있다면 다소 예쁘지 않게 보인다. 이런 경우엔 대입으로도 초기화가 가능한 데이터 멤버를 별도의 함수로 옮기는 것도 방법이 된다. 하지만 일반적인 경우만 따지면 초기화 리스트를 통한 진짜 멤버 초기화가 아무래도 좋다.

C++에서 클래스 데이터 멤버는 선언된 순서로 초기화된다. 아무리 멤버 초기화 리스트에서 데이터 멤버의 순서를 바꾸더라도, 초기화 순서는 그대로다. 순서가 바뀌면 혼동이 일어날 수 있으니, 초기화 리스트에 넣는 순서도 클래스에 선언된 순서와 동일하게 맞춰주자.

또한, 비지역 정적 객체의 초기화 순서는 개별 번역 단위에서 정해진다. 이게 무슨 말일까? 비지역 정적 객체개별 번역 단위가 뭔지 알아보자.

우선 정적 객체는 자신이 생성된 시점부터 프로그램이 끝날 때까지 살아 있는 객체를 일컫는다.

  • 전역 객체
  • 네임스페이스 유효범위에서 정의된 객체
  • 클래스 안에서 static으로 선언된 객체
  • 함수 안에서 static으로 선언된 객체
  • 파일 유효범위에서 static으로 정의된 객체

이 중 함수 안에 있는 정적 객체는 지역 정적 객체라고 하고, 나머지는 비지역 정적 객체라고 한다.

번역 단위는 프로그램을 컴파일할 때 독립적으로 번역될 수 있는 코드의 최소 단위를 의미한다. 보통 하나의 소스 파일이나 헤더 파일이 번역 단위가 된다. 여기서 그 파일이 #include하는 파일들까지 합쳐서 하나의 번역 단위가 된다.

여기서 생기는 문제는 이렇다. 별도의 번역 단위에 있는, 즉 별도로 컴파일된 소스 파일이 두 개 이상 있을 때 각자의 비지역 정적 객체들의 초기화 순서는 어떻게 되는가? 만약 한 쪽의 정적 객체의 초기화가 다른 정적 객체를 사용한다면?

위에도 말했듯이 별개의 번역 단위에 있는 비지역 정적 객체들의 초기화 순서는 정해져 있지 않다. 이는 설계의 약간 변화를 줘서 해결할 수 있다. 바로 싱글톤 패턴이다. 비지역 정적 객체를 하나씩 맡는 함수를 준비하고, 이 안에 각 객체를 넣는 것이다. 이제 함수에서 정적 객체를 선언해주고, 그에 대한 참조자를 반환하게 하자. 이제 비지역 정적 객체가 아닌 지역 정적 객체가 되었다. 지역 정적 객체는 함수 호출 중에 객체의 정의에 최초로 닿았을 때 초기화된다. 따라서 이 설계가 위의 문제를 해결할 수 있다.

이 참조자 반환 함수는 내부적으로 정적 객체를 쓰기 떄문에, 다중스레드 시스템에서는 동작에 장애가 생길 수도 있다. 초기화를 할 때 경합 조건(race condition)에 들어가면 골칫거리가 되기 때문이다. 이 해결책으로는 다중스레드를 본격적으로 들어가기 전, 참조자 반환 함수를 손수 한번씩 호출시켜줘 초기화하는 방법이 있다.

기본제공 타입의 객체는 직접 손으로 초기화하자.
여러 번역 단위에 있는 비지역 정적 객체들의 초기화 순서 문제는 피해서 설계하자. 참조자 반환 함수를 통해 비지역을 지역 정적 객체로 바꾸자.
생성자에서는 멤버 초기화 리스트를 이용해 초기화하자. 리스트의 멤버 나열 순서는 선언된 순서와 같게 하자.

클래스/구조체 정적 멤버

https://en.cppreference.com/w/cpp/language/static
위 문서에서는 static 키워드는 static member의 선언에만 사용되고, 정의에는 아예 사용이 되지 않는다고 한다. 정확한 이유까지는 써있지 않아 유추해보자면, 아마 클래스/구조체가 선언되는 과정과 연관이 있을 것 같다.

클래스/구조체를 선언하면, 컴파일러는 이들의 정보(타입, 이름)를 알아두지만 이를 실질적으로 메모리 공간에 할당하지는 않는다. 이들의 static member도 같은 식으로 처리가 되어 선언은 되어있지만, 정의는 안되는 상황이 일어나는 듯 보인다.

profile
마포고개발짱

0개의 댓글