일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | ||||||
2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | 24 | 25 | 26 | 27 | 28 |
- FrameResource
- Deferred Rendering
- 장치 초기화
- Direct3D12
- 게임 클래스
- effective C++
- 조명 처리
- light
- 디퍼드 렌더링
- Frustum Culling
- C++
- TCP/IP
- 네트워크
- 게임 프로그래밍
- InputManager
- Render Target
- Dynamic Indexing
- 노멀 맵핑
- DirectX12
- 네트워크 게임 프로그래밍
- direct3d
- 직교 투영
- gitscm
- 게임 디자인 패턴
- 동적 색인화
- 절두체 컬링
- 큐브 매핑
- gitlab
- DirectX
- 입방체 매핑
- Today
- Total
코승호딩의 메모장
C++에 왔으면 C++의 법을 따르자 본문
이번 포스팅에서는 스콧 마이어스의 Effective C++ 책을 읽고 공부한 내용을 정리하고자 합니다. 저자에 따르면 이 책은 C++ 행동강령이나 유일한 참된 덕목이 아닌 프로그램을 지금보다 더 낫게 만들기 위한 지침을 제공하는 것이라고 합니다. 따라서 이 책에서 습득할 수 있는 내용은 프로그램의 근본원리를 이해하는 것이고 자신에게 어떤 용도에 어떻게 써먹을 수 있는지에 초점을 두어야 합니다.
Effective C++ : 네이버 도서
네이버 도서 상세정보를 제공합니다.
search.shopping.naver.com
01 C++를 언어들의 연합체로 바라보는 안목은 필수
오늘날의 C++은 다중패러다임 프로그래밍 언어라고 불린다. 절차적 프로그래밍을 기본으로 객체 지향, 함수식, 일반화, 메타프로그래밍 개념까지 지원하고 있기 때문이다. 이렇게 지원하는 프로그래밍 개념마다 적절한 사용 규칙이 있다 하여도 예외가 없는 경우는 없을 것이다. 그렇다면 과연 어떻게 C++을 이해해야 할까? 정답은 바로 C++을 단일 언어로 바라보는 눈을 넓혀, 상관관계가 있는 여러 언어들의 연합체라고 보는 것이다. 즉, C++을 제대로 이해하기 위해서는 이 언어가 하위 언어(C, 객체 지향 개념의 C++, 템플릿 C++, STL)를 제공한다는 점을 새겨야 한다.
이렇게 네 가지 하위 언어들이 C++를 이루고 있으며 효과적인 개발을 위해 한 하위 언어에서 다른 하위 언어로 옮겨 가면서 대응 전략을 바꿔야 하는 상황이 오더라도 당황하지 말아야 한다. 예를 들어 C만 사용하고 있는 상황에서는 값 전달이 참조 전달보다 대개 효율이 좋다는 규칙이 통하지만 객체 지향 C++에서는 상수 객체 참조자에 의한 전달이 더 좋은 효율을 보인다. 템플릿 C++에서는 더욱이 객체의 타입조차 알 수 없다. 그러나 또 STL에서는 C 스타일의 포인터를 본떠 만든 반복자와 함수 객체가 있고 여기서는 값 전달에 대한 규칙이 다시 효율이 좋아진다.
즉, C++은 한 가지 프로그래밍 규칙 아래 똘똘 뭉친 통합 언어가 아니라 네 가지 하위 언어들의 연합체이다. 각각의 하위 언어가 자신만의 규칙을 가지고 있다. C++의 어떤 부분을 사용할 것인가에 따라 효과적인 프로그래밍 규칙이 달라지는 것이다.
02 #define을 쓰려거든 const, enum, inline을 떠올리자
#define ASPECT_RATIO 1.653
다음과 같이 #define 문법을 썼다고 하였을 때, 컴파일러에겐 ASPECT_RATIO라는 기호식 이름이 보이지 않는다. 소스 코드가 컴파일러에게 넘어가기 전, 선행 처리자가 모두 숫자 상수로 밀어버리기 때문이다. 만약 숫자 상수로 대체된 코드에서 컴파일 에러가 발생하면 꽤나 헷갈릴 수 있다. 소스 코드엔 ASPECT_RATIO가 있었는데 에러 메시지엔 1.653이라고 뜨기 때문이다.
const double AspectRatio = 1.653;
위 문제는 매크로 대신 상수를 사용하여 해결할 수 있다. 상수 타입의 데이터이기 때문에 기호 테이블에 들어가며 컴파일러에게도 보이게 된다. 게다가 상수가 부동소수점 실수 타입일 경우 컴파일을 거친 최종 코드 크기가 #define보다 작게 나올 수 있는데, 매크로를 쓰면 선행 처리자에 의해 ASPECT_RATIO가 등장한 횟수만큼 1.653의 사본이 코드 안에 들어가게 되지만 상수 타입의 AspectRatio는 아무리 여러 번 쓰더라도 사본은 딱 한개만 생기기 때문이다.
// GamePlayer.h
class GamePlayer {
private:
static const int NumTurns = 5;
int scores[NumTurns];
};
위와 같이 NumTurns은 정의된 것이 아닌 선언된 것이다. 보통 사용하고자 하는 것에 대한 정의가 마련되어 있어야 하지만 정적 멤버로 만들어지는 정수류 타입의 클래스 내부 상수는 예외이다. 이들에 주소를 취하지 않는 한, 정의 없이 선언만 해도 아무 문제 없다. 단, 클래스 상수의 주소를 구한다든지, 컴파일러 문제가 뜬다면 별도의 정의도 제공해야 한다.
// GamePlayer.cpp
const int GamePlayer::NumTurns;
다음과 같이 정의를 제공할 수 있는데 값이 주어지지 않는 이유는 클래스 상수의 초기값은 해당 상수가 선언된 시점에서 바로 주어지기 때문이다. 즉, NumTurns가 선언될 당시에 바로 초기화 된다. 만약 오래된 컴파일러로 인해 위 문법이 막힌다면 초기값을 선언 시점에 주지 않고 정의 시점에 주면 된다. 그러나 해당 클래스를 컴파일하는 도중 클래스 상수의 값이 필요할 경우 예외가 있다. 위 예에서 scores의 배열의 크기에 NumTurns를 넣어줘야 하는데 만약 선언 시점에 값을 주지 않고 정의 시점에 주었을 때이다. 이 경우 나열자 둔갑술(enum hack)을 사용할 수 있을 것이다.
// GamePlayer.h
class GamePlayer {
private:
enum { NumTurns = 5 };
int scores[NumTurns];
};
나열자 타입의 값은 int가 놓일 곳에도 쓸 수 있다. 이 동작 방식은 const보다는 #define에 더 가깝다. 즉, enum의 주소를 취하는 것은 불법이고, #define의 주소를 얻는 것 역시 맞지 않기 때문이다. 만약 선언한 정수 상수에 다른 사람이 주소를 얻거나 참조자를 쓰는 것이 싫다면 enum이 아주 좋은 자물쇠가 될 수 있다. 또한 enum은 어떤 형태의 쓸데없는 메모리 할당도 저지르지 않는다.
#define CALL_WITH_MAX(a, b) f((a) > (b) ? (a) : (b))
#define 지시자의 또 다른 오용 사례는 매크로 함수이다. 위 코드는 매크로 인자 중 큰 것을 사용하여 f를 호출하는 매크로이다. 이런 매크로를 작성할 때는 매크로 인자마다 괄호를 씌워 줘야 한다.
int a = 5, b = 0;
CALL_WITH_MAX(++a, b);
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매크로가 아닌 인라인 함수를 먼저 생각하도록 하자.
03 낌새만 보이면 const를 들이대 보자
STL의 반복자는 포인터를 본뜬 것이기 때문에, 기본적인 동작 원리가 포인터와 매우 흡사하다. 어떤 반복자를 const로 선언하는 것은 포인터를 상수로 선언하는 것과 같다.
std::vector<int> vec;
const std::vector<int>::iterator iter = vec.begin();
*iter = 10; // (o)
++iter; // (x)
const std::vector<int>::const_iterator cIter = vec.begin();
*cIter = 10; // (x)
++cIter; // (o)
다음과 같이 반복자에 const를 붙인 것은 T* const와 동일하게 동작하며 const_iterator는 const T*와 동일하게 동작한다.
const의 가장 강력한 용도는 함수 선언에 쓸 경우이다. const는 함수 반환 값, 매개 변수, 멤버 함수 앞에 붙을 수 있으며 함수 전체에 대해 const의 성질을 붙일 수 있다. 함수 반환 값을 상수로 정해 주면, 안정성, 효율을 갖은 채로 사용자 측 에러를 줄일 수 있는 효과를 볼 수 있다. 따라서 매개 변수 혹은 지역 객체를 수정할 수 없게 하는 것이 목적이라면 const로 선언하자.
멤버 함수에 붙는 const는 해당 멤버 함수가 상수 객체에 대해 호출될 함수이다 라는 사실을 알려주는 것이다. 이것이 중요한 이유는 첫째로 클래스의 인터페이스를 이해하기 좋게 하기 위함이다. 해당 클래스로 만들어진 객체를 변경할 수 있는 함수는 무엇이고, 또 변경할 수 없는 함수는 무엇인지를 사용자 측에서 알고 있어야 하기 때문이다. 둘째는 const를 통해서 상수 객체를 사용할 수 있게 하는 것이다. 상수 상태로 전달된 객체를 조작할 수 있기 위해서는 const 멤버 함수가 준비되어 있어야 하기 때문이다.
class TextBlock {
public:
const char& operator[] (std::size_t position) const
{ return text[position]; }
char& operator[] (std::size_t position)
{ return text[position]; }
private:
std::string text;
}
const가 있고 없고의 차이만 있는 멤버 함수들은 오버로딩이 가능하다. 실제 프로그램에서 상수 객체가 생기는 경우는 첫째로 상수 객체에 대한 포인터 둘째는 상수 객체에 대한 참조자로 객체가 전달될 때이다.
TextBlock tb("Hello");
const TextBlock ctb("World");
std::cout << ctb[0]; // (o)
ctb[0] = 'x'; // (x)
std::cout << tb[0]; // (o)
tb[0] = 'x'; // (o)
위 예제에서 operator[ ]를 오버로드하여 각 버전마다 반환 타입을 다르게 가져갔기 때문에 쓰임새가 달라진다.
어떤 멤버 함수가 상수 멤버라는 것에 대한 의미는 크게 비트수준 상수성과 논리적 상수성이라는 개념이 자리잡고 있다.
- 비트수준 상수성 : 어떤 멤버 함수가 그 객체의 어떤 데이터 멤버도 건드리지 않아야(정적 멤버 제외) 그 멤버 함수가 const임을 인정한다는 개념으로 즉, 그 객체를 구성하는 비트들 중 어떠한 것도 바뀌면 안된다.
- 논리적 상수성 : 상수 멤버 함수라고 객체의 한 비트도 수정할 수 없는 것이 아닌 일부 몇 비트 정도는 바꿀 수 있되, 그것을 사용자측에서 알아채지 못하게만 하면 상수 멤버 자격이 있다는 개념이다.
그러나 제대로 const가 동작하지 않는데도 비트수준 상수성 검사가 통과하는 멤버 함수들이 적지 않다. 어떠한 포인터가 가리키는 대상을 수정하는 멤버 함수들이 이 경우에 속한다. 하지만 그 포인터가 객체의 멤버로 들어있는 한, 이 함수는 비트수준 상수성을 갖는 것으로 판별되고 컴파일러도 오류를 내지 않는다.
class CTextBlock {
public:
char& operator[] (std::size_t position) const
{ return pText[position]; }
private:
char* pText;
}
위 코드와 같이 operator[ ] 함수가 상수 멤버 함수로 선언이 되어 있다. 그럼에도 불구하고 해당 객체의 내부 데이터에 대한 참조자를 반환한다. 이 함수의 내부 코드에서는 pText를 건드리지 않기 때문에 비트수준 상수성이 만족하기 때문이다.
cosnt 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;
}
위 코드는 문장 구역의 길이를 사용자들이 요구할 때마다 이 정보를 캐시해 두는 상황이다. 위 Length 함수는 비트 수준 상수성과 매우 멀리 떨어져 있다. 그렇다면 이런 상황에서 컴파일러의 검열을 통과하기 위해서는 어떻게 해야 할까? const에 맞서는 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);
lengthIsValid = true;
}
return textLength;
}
mutable 키워드가 붙은 멤버 함수들은 어떤 순간에도 수정이 가능하며 멤버 함수 안에서도 수정이 가능하다.
class TextBlock {
public:
const char& operator[] (std::size_t position) const
{
//... 경계 검사
//... 접근 데이터 로깅
//... 자료 무결성 검증
return text[position];
}
char& operator[] (std::size_t position)
{
//... 경계 검사
//... 접근 데이터 로깅
//... 자료 무결성 검증
return text[position];
}
private:
std::string text;
}
다만, 위 코드와 같이 상수/비상수 버전에 모든 코드를 넣어 버리면 중복 코드가 덕지덕지 붙게 될 것이다. 과연 operator[ ]의 핵심 기능을 한 번만 구현해 두고 이것을 두 번 사용하고 싶다면 어떻게 해야 할까? 바로 캐스팅을 써서 반환 타입의 const 키워드를 없애는 것이다. 즉, 비상수 operator[ ]가 상수 버전을 호출하도록 구현하는 것이다.
class TextBlock {
public:
const char& operator[] (std::size_t position) const
{
//... 경계 검사
//... 접근 데이터 로깅
//... 자료 무결성 검증
return text[position];
}
char& operator[] (std::size_t position)
{
return const_cast<char&>(static_cast<const TextBlock&>(*this)[position]);
}
private:
std::string text;
}
위 코드를 해석해보자면, 지금 해야할 일은 비상수 operator[ ]가 상수 버전을 호출하게 해야 한다는 것이다. 그런데 비상수 operator[ ]속에서 그냥 operator[ ]라고 적으면 그 자신이 재귀호출 되어 무한 재귀호출이 될 것이다. 따라서 이를 피하기 위해서는 상수 operator[ ]를 호출하고 싶다는 것을 코드로 표현해야 한다. 이 방식이 바로 *this의 타입 캐스팅이다. TextBlock&에서 const TextBlock&으로 바꾸는 것이다. 따라서 정리하자면, 첫 번째 캐스팅은 *this에 const를 붙이기 위함이고 두 번째 캐스팅은 상수 operator[ ]의 반환 값에서 const를 떼어내는 캐스팅이다.
04 객체를 사용하기 전에 반드시 그 객체를 초기화하자
초기화되지 않은 값을 읽도록 내버려 두면 정의되지 않은 동작이 그대로 흘러나오게 된다. 대체적인 경우 적당히 무작위 비트 값을 읽고 객체의 내부가 이상한 값을 갖게 된다. 따라서 모든 객체를 사용하기 전에 항상 초기화해야 한다.
class PhoneNumber;
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의 경우 기본제공 타입의 데이터 멤버이고, 기본제공 타입의 데이터 멤버는 대입되기 전 초기화되리라는 보장이 없다. 이를 위해서는 멤버 초기화 리스트를 사용해야 한다.
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()
:theName(),
theAddress(),
thePhones(),
numTimesConsulted(0)
{
}
만약 데이터 멤버를 기본 생성자로 초기화하고 싶을 때도 멤버 초기화 리스트를 쓰며 인자로 아무것도 주지 않으면 된다.
요약하자면, 기본제공 타입의 객체는 직접 손으로 초기화 해야 한다. 경우에 따라 저절로 되기도 하고 안되기도 하기 때문이다. 그리고 생성자에서는, 데이터 멤버에 대한 대입문을 생성자 본문 내부에 넣는 방법으로 멤버를 초기화하지 말고 멤버 초기화 리스트를 즐겨 사용해야 한다. 그리고 초기화 리스트에 데이터 멤버를 나열할 때는 클래스에 데이터 멤버를 선언한 순서와 똑같이 나열하자.
'C++ Study > Effective C++' 카테고리의 다른 글
자원 관리 (1) | 2024.01.11 |
---|---|
생성자, 소멸자 및 대입 연산자 (1) | 2024.01.11 |