3.1 C++ 개념과 올바른 사용법
프로그래머라면 최소한 2개의 고급high-level 언어를 학습하고, 일정 수준의 어셈블리 언어 프로그래밍을 익혀 놓는 것이 좋다.
객체지향 프로그래밍 개념 전반에 대해 알아보고 특히 C++에 주의를 집중하자.
3.1.1 객체지향 프로그래밍에 대한 간략한 개념
3.1.1.1 클래스와 객체
- 클래스class란 여러 개의 값과 그에 대한 행위들의 모음인 포괄적 개념이다.
- eg) 스누피는 개 클래스의 한 인스턴스
3.1.1.2 캡슐화(encapsulation)
- 객체가 정해진 인터페이스만 공개하고 객체 내부의 상태와 상세한 구현 사항을 숨기는 것
- 인터페이스만 이해하면 클래스 내부 구현을 신경쓰지 않아도 됨
3.1.1.3 상속
- 어떤 클래스가 이미 존재하는 클래스를 확장하는 것
- 상속은 트리 형태의 계층을 이루게 된다.
- is-a 관계 (eg: 원은 도형이다)
다중 상속
- 일부 프로그래밍 언어가 지원
- 한 클래스가 부모 클래스를 여러 개 가질 수 있는 것
- 코드를 혼란스럽게, 구현을 힘들게 하기 쉬움 (트리 구조가 아닌 그래프 구조까지 확장될 수 있기때문)
- '다이아몬드 문제' 참고
- 많은 C++ 프로그래머가 다중 상속을 아예 사용하지 않거나 제한적인 경우에만 사용 ('mix-in 클래스' 참고)
3.1.1.4 다형성
- 프로그래밍 언어에서 서로 다른 타입의 객체들을 하나의 공통 인터페이스로 다룰 수 있는 기능
- C++에서는 주로 가상virtual함수를 이용
3.1.1.5 합성과 집합
- 합성composition이란 서로 영향을 주고받는 여러 객체를 이용해 복잡한 일을 해결하는 것
- 'has-a'관계나 'uses-a'관계를 형성 (엄밀하게 'has-a'는 합성, 'uses-a'는 집합aggreation 이라고 한다)
- eg) the space ship has an engine, the engine has a fuel tank
- eg) GUI 인터페이스를 설계할 때
- 상속사용: Window is-a Rectangle - (X)
- 합성/집합사용: Window has-a Rectangle, Window uses-a Rectangle - (O)더 유연하고 캡슐화에 유리하다
3.1.1.6 디자인 패턴
같은 문제가 반복 발생 -> 문제를 해결하는 데 많은 프로그래머가 유사한 방법 사용 -> '디자인 패턴'
예시:
- 싱글턴singleton : 어떤 클래스의 객체가 오직 하나(싱글턴 인스턴스)만 있게 보장하고 그에 대한 포인터를 제공
- 반복자iterator : 어떤 집합의 세부적인 구현을 몰라도 집합의 각 요소에 접근하는 효율적인 방법 제공
- 추상화 팩토리abstractor factory : 서로 연관되거나 의존적인 클래스들의 계열을 만들 때 구현 클래스를 저장하지 않아도 되는 인터페이스를 제공
게임 업계에서도 렌더링에서 충돌, 애니메이션, 오디오 등 다양한 부분에 대한 해법이 있고, 이 책이 3D게임 엔진 디자인에 대한 고차원적 디자인 패턴을 설명한다고 할 수 있다.
관리인과 RAII
- 리소스 획득과 초기화 동시에 하기RAII, Resource Acquisition is Initialization
- 매우 유용하다
- 리소스(파일, 동적 할당 메모리, 뮤텍스 락 등)의 획득acquisition과 반환release이 각각 클래스의 생성자, 파괴자의 묶이는 것.
- 클래스의 로컬 인스턴스를 만들면 자원 획득, 범위를 벗어나면 자연스럽게 반환.
- 너티독에서는 이런 클래스를 관리인janinor라고 불렀다
class AllocJanitor
{
public:
explicit AllocJanitor(mem::Context context)
{
mem::PushAllocator(context);
}
~AllocJanitor()
{
mem::PopAllocator();
}
};
void f()
{
{
AllocJanitor janitor(mem::Context::kSingleFrame);
U8* pByteBuffer = new U8[SIZE];
float* pFloatBuffer = new float[SIZE];
}
}
참고: https://en.cppreference.com/w/cpp/language/raii
3.1.2 C++ 표준화
표준화 역사에 대해서는 자세한 설명을 생략한다.
3.1.2.1 더 읽을거리
3.1.2.2 언어의 어떤 기능을 쓸 것인가?
C++에 추가된 모든 새로운 기능을 엔진이나 게임에 써서는 안된다.
완전한 기능 지원 부족
- 최신 기능들이 컴파일러에서 제대로 지원되지 않을 수도 있다.
표준 간 전환 비용
- 코드베이스를 한 표준에서 다른 표준으로 전환하려면 비용이 든다.
- 사용할 표준 정한 후 어느 기간동안 유지해야 함.
언차티드 4, 언차티드: 잃어버린 유산은 C++98표준으로 짰다.
위험 그리고 게인
모든 기능이 좋은 것은 아니다.
- 좋은 경우:
nullptr 는 이견이 없을 정도로 좋다
- 장단이 있음:
auto 키워드
- 코드가 난잡해지고 타입을 알기 어려워 질 수 있음
- 너티독의 경우, 반복자를 선언할 경우, 다른 대안이 없는 경우, 코드 가독성이 좋아지는 경우에만 사용.
- 게임에선 쓰지마: 템플릿 메타프로그래밍metaprogramming
- 가독성 떨어짐
- 이식성이 떨어지거나 없음
- 프로그래머가 이해하기 어려워짐 (많은 노력이 듦)
- eg) Andrei Alexandrescu의 Loki 라이브러리
- 너티독에서는 런타임 엔진 코드에 템플릿 메타프로그래밍을 금지함
3.1.3 코딩 규칙: 필요한 이유와 적용 정도
코딩 스탠다드에 관해서는 논쟁이 많다.
다만, 적어도 최소한의 코딩 규칙이라도 적용하면 좋다는 것은 맞다.
필요한 이유
- 특정 규칙을 준수하면 코드를 읽고 이해하기 쉬워지며 유지 비용도 적게 든다.
- 프로그래머가 터무니없는 실수를 하지 않게 도와준다.
코딩 규칙을 정할 때 가장 중요한 것들
- 인터페이스를 제일 중시할 것
- 인터페이스(
.h 파일)은 간결하고 단순하며 최소한의 것만 포함해야 함. 이해하기 쉬워야되고 주석을 잘 달아야 한다.
- 이름을 잘 지을 것
- 클래스나 함수, 변수의 목적에 맞는 가장 직관적인 이름을 지어라.
- 전역 네임스페이스를 깔끔하게 유지할 것
- 네임 스페이스와 이름에 붙이는 접두사 등을 사용해 다른 라이브러리의 이름과 충돌하지 않게
#define문을 이용할 때 주의. (전처리기는 네임스페이스 무시하기 때문)
- 널리 알려진 C++ 사용법을 따를 것
- 이펙티브 C++ 시리즈 스캇 마이어스
- 이펙티브 STL
- 등 서적 참고
- 코딩 규칙은 일관돼야 할 것
- 규칙은 일관되어야 한다.
- 이미 만들어진 코드를 고친다면 그 코드의 규칙을 철저히 따라야 한다.
- 오류를 스스로 드러내는 코드를 작성할 것
3.2 에러 감지와 처리
3.2.1 에러의 종류
- 사용자 에러: 사용자가 뭔가 잘못해서 발생
- 프로그래머 에러: 프로그램의 잘못된 버그 때문에 발생
사용자들은 프로그램이 사용자의 오류를 문제없이 처리할 수 있기를 바란다.
다른 프로그래머가 사용자 입장이 될 수도 있다.
사용자 에러와 프로그래머 에러의 구분은 상황에 따라 다르다는 뜻.
3.2.2 에러 처리
3.2.2.1 플레이어 에러 처리
- 게임 플레이어가 사용자일 경우
- 에러는 게임 내용에 맞게 처리되는 게 자연스럽다.
- eg) 총알 없는데 무기 재장전 요구하면 게임이 튕기는게 아니라 음향, 애니메이션을 보여줘서 상황 알려줌
3.2.2.2 개발자 에러 처리
- 아티스트, 애니메이터, 게임 디자이너 등, 게임을 만드는 다른 사람이 사용자일 경우
- 두 가지 의견이 대립
- 잘못된 게임 자원을 가능하면 빨리 발견해 고치는 것이 중요
- 단 한가지라도 잘못된 게임 자원 발견하면 게임 실행 안되게
- 처음부터 완벽하기는 힘들다.
- 엔진이 어떤 문제에도 동작해야한다.
- 게임 엔진이 비대해짐, 최종 출시시에도 잘못된채로 출시될 가능성 높음
- 적절한 균형을 찾는것이 중요
3.2.2.3 프로그래머 에러 처리
- Assertion 시스템: 에러 감지 코드를 직접 소스코드 곳곳에 넣고 여기 걸릴 경우 프로그램 멈춰버리게 하는것 (가장 좋은 방법)
- 모든 에러를 다루는데 assertion 사용할 수는 없다.
- Assertion과 자연스런 에러 처리를 각각 어디에 사용할지 감 익혀야 한다.
3.2.3 에러 감지와 에러 처리 구현
3.2.3.1 에러 리턴 코드
- 맨 처음 에러를 감지한 함수에서 특정한 에러 코드를 리너테하게 하는 방법
- 참/거짓(bool) 값으로 성공과 실패를 나타낼 수도, 불가능한 값을 리턴하는 경우도 있음
- 더 좋은 방법은 enumerated value 중 하나를 리턴해서 성공, 실패를 나타내게 하는 설계.
- 해당 함수 호출한 함수에서 리턴되는 에러 코드 보고 적절히 대응
- 처리 또는 우회해서 계속 실행
- 자기 호출한 함수에 에러코드 전달
- 문제점: 에러를 처음 감지한 함수가 그 에러를 처리할 수 있는 함수와 전혀 연관 없을 수도 있음
3.2.3.2 에러 처리
- 예외exception 던지기.
- 예외 처리exception handling는 C++가 지원하는 강력한 기능
- 어떤 함수가 처리할지 전혀 신경 쓰지 않고도 에러 전달할 수 있음
- 예외가 던져지면 예외 객체라는 자료구조에 연관있는 정보들을 넣는다.
- 콜 스택을 자동으로 펼쳐 try-catch 블록에 도달할 때 까지 계속
- try-catch 블록을 찾으면 예외 객체를 처리할 수 있는 catch 블록을 찾는다.
- 맞는 블록을 찾으면 그 catch 블록 안의 코드가 실행된다.
- 스택이 펼쳐지면서 변수들의 파괴자가 자동으로 호출됨.
에러 처리의 문제점
- 성능 저해
- try-catch 블록 포함하는 모든 함수의 스택 프레임에 stack unwinding을 위한 별도 정보 추가 필요
- 단 한 부분에서만 예외 처리 사용해도 프로그램 전체에 예외 처리를 사용하게 설정해야 함
- 예외 처리 사용하는 라이브러리를 사용하면서 게임 코드는 예외처리를 사용하지 않으려면 이것들을 격리해야함
- API 호출을 새로운 함수로 감싸고, 이 함수들이 구현된 번역 단위에는 예외 처리를 활성화
- 이 함수들은 발생할 수 있는 모든 예외에 대해 try/catch블록으로 감싸 처리하고 그 결과를 에러 코드로 리턴
- 이렇게 구현된 라이브러리를 게임 코드와 링크하면 게임 코드는 예외 처리를 꺼도 됨.
- 성능문제보다 더 중요한 건, 예외exception가 어떻게 보면 goto 문과 다를게 없다는 것
- 조엘 스폴스키는 예외가 goto보다 더 나쁘다고 함. (코드 식별이 어렵기 때문에)
- 예외를 던지지도 않고, 처리하지도 않는 함수라도 콜 스택에서 이런 함수들 사이에 끼면 스택 펼치기 과정에 관여할 수 있음.
- 스택 펼치기 과정 또한 완벽하지 않음
- 안정적인 소프트웨어를 짜는 게 어려워 짐
- 비용 문제
- 이론상 현대적 예외 처리 프레임워크는 에러가 발생하지 않는 한 런타임 부하가 없어야 하지만 사실 꼭 그렇지는 않음
- 게임 엔진 전체적으로 예외 처리를 끄는 많은 게임 개발사
- 너티독
- EA
- Midway
- InSomniac Games
예외와 RAII
- RAII 패턴과 예외 처리를 함께 사용하는 경우가 자주 있다.
- 생성자에서 원하는 자원 획득 시도, 실패할 경우 예외 던짐.
- 생성자가 예외 던지지 않았다면 자원 획득 성공했다는 의미.
- RAII 패턴을 예외 처리 없이도 쓸 수 있다.
- 자원 객체를 새로 만들고 상태를 검사하도록 코딩 스탠다드 강화.
- RAII의 모든 이점은 그대로 챙길 수 있다.
- 예외를 Assertion으로 대체해서 자원 획득 실패를 알릴 수도 있음.
3.2.3.3 어서션assertion
- 어서션assertion이란 어떤 표현을 검사하는 코드
- 표현이 참이면 아무일도 일어나지 않음
- 거짓인 경우 프로그램 중단, 메시지 출력, 디버거 연결가능하면 디버거 실행
- 어서션의 비용 때문에 디버그 버전이 아닌경우 제거할 수 있게 구현
- C에서는 표준 라이브러리 헤더 파일 <assert.h> 를 통해 assert() 매크로 제공
- assert() 정의는 디버그 빌드에서는 유효, 아닌경우 제거됨.
어서션 구현
- 구조
- if/else 조건 구문
- 조건이 거짓인 경우 호출될 함수
- 프로그램 실행을 중단하고 디버거가 연결돼 있다면 디버거를 불러낼 어셈블리 코드
- 통상적 구현
#if ASSERTIONS_ENABLED
#define debugBreak() asm { int 3 }
#define ASSERT(expr) \
if (expr) { } \
else \
{ \
reportAssertionFailure{#expr, \
__FILE__, \__LINE__); \
debugBreak(); \
}
#else
#define ASSERT(expr)
#endif
-
맨 바깥 #if/#else/#endif 는 코드에서 assertion을 제거할 수 있게 한다.
-
debugBreak()는 프로그램 실행을 멈추고 디버거가 연결된 경우 디버거에 실행을 넘기는 어셈블리 코드를 부른다. (CPU마다 다르지만 보통 어셈블리 명령어 1개로 구현)
-
ASSERT() 매크로는 else까지 딸린 if 문으로 구현. (다른 if/else 문 안에서도 제약 없이 사용될 수 있게)
-
잘못된 예)
#define ASSERT(expr) if (!(expr)) debugBreak()
void f()
{
if (a < 5)
ASSERT(a >= 0);
else
doSomething(a);
}
void f()
{
if (a < 5)
if (!(a >= 0))
debugBreak();
else
doSomething(a);
}
-
ASSERT() 매크로의 else 구문이 하는 일
- 프로그래머에게 뭐가 잘못됐는지 알려주는 메시지 출력 (
#expr 부분에서 #이 expr표현을 문자열로 바꿔 메시지 출력)
- 그 후 디버거로 실행 넘김
-
FILE과 LINE 매크로의 사용
- .cpp 파일의 이름과 해당 매크로가 있는 줄 위치를 담는다.
- 메시지를 뿌려주는 함수에 이 값을 넘김으로써 문제가 발생한 정확한 위치를 출력할 수 있음.
- 두 가지 어서션 사용하는 방법 (어서션이 성능에 영향을 미치기 때문에)
- ASSERT(): 모든 빌드에 들어감, 디버그 빌드 아니라도 에러 쉽게 찾아내는 데 씀.
- SLOW_ASSERT(): 디버그 빌드에서만 활성화, 릴리스 빌드에서는 성능에 영향가는 곳에 사용
- 중요: 프로그램 안의 버그를 찾는 데만 써야하고 절대 사용자 에러를 찾는 데 써서는 안된다.
컴파일 시의 어서션
- 어서션의 취약점은 런타임에만 조건 점검이 이루어진다는 것.
- 컴파일 타임에 조건을 알 수 있는경우가 있음
- 예시:
-
반드시 128바이트여야 하는 구조체 정의. 프로그래머가 구조체의 크기를 변경할 경우 컴파일러가 에러 메시지 출력하는 경우
struct NeedsToBe128Bytes
{
U32 m_a;
F32 m-b;
};
static_assert(sizeof(NeedsToBe128Bytes) == 128, "wrong size");
-
C++11 을 사용하지 않는다면 직접 구현하면 된다. (1개의 enumerator를 갖는 이름 없는 열거형 타입 정의해서)
#define _ASSERT_GLUE(a, b) a ## b
#define ASSERT_GLUE(a, b) _ASSERT_GLUE(a, b)
#define STATIC_ASSERT(expr) \
enum \
{ \
ASSERT GLUE(g_assert_fail_, __LINE__) \
= 1 / (int)(!!(expr)) \
|
STATIC_ASSERT(sizeof(int) == 4);
STATIC_ASSERT(sizeof(float) == 1);
- 접두사 g_assert_fail과 고유 접미사(이 경우 매크로가 호출되는 라인 넘버)를 접합(glue)해 번역 단위 내에서 고유하게 정의
- 열거자의 값은 1 / (!!(expr)) 이 된다. 2개의 부정 연산자(!!)를 통해 expr이 Boolean값이 되도록 보장.
- 이 값을 int로 형변환하게 되면 값이 true냐 flase냐에 따라 각각 1, 0 이 된다.
- 조건이 참이면 열거자는 1/1, 즉 항상 1이 된다.
- 조건이 거짓인 경우 열거자에 1/0이 들어가는데, 이것은 컴파일 에러를 낸다.
- 이러면 에러메시지가 '식이 상수로 계산되지 않았습니다' 등으로 표시된다.
-
템플릿 특수화를 통해 STATIC_ASSERT() 정의 하는 방법
-
C++11 이상 사용하는지 먼저 점검
-
만약 그렇다면 이식성을 위해 표준 라이브러리의 static_assert() 사용
-
그렇지 않다면 직접 구현한 코드 사용.
#ifdef __cplusplus
#if __cplusplus >= 201103L
#define STATIC_ASSERT(expr) \
static_assert(expr, \
"static assert failed:" \
#expr)
#else
template<bool> class TStaticAssert;
template<> class TStaticAssert<true> {};
#define STATIC_ASSERT(expr) \
enum \
{ \
ASSERT_GLUE(g_assert_fail_, __LINE__) \
= sizeof(TStaticAssert<!!(expr)>) \
}
#endif
#endif
-
이 경우가 0으로 나누는 코드보다는 에러 메시지가 조금 더 보기 좋다.
-
컴파일 타임 어서션 구현은 http://www.pixelbeat.org/programming/gcc/static_assert.html 을 참고하면 도움이 될 것이다.