모든 내용은 [조슈아 글레이저, 산제이 마드하브 저, "멀티플레이어 게임 프로그래밍", 길벗출판사] 를 기반으로 제 나름대로 이해하여 정리한 것입니다. 다소 부정확한 내용이 있을수도 있으니 이를 유념하고 봐주세요!
# C++11
1. auto 키워드
- 형을 선언하는 자리에 대체해서 넣는 것
- 컴파일러가 컴파일할 때 형을 추론하게 지시하는 문법
- 컴파일시 추론이 이루어지므로, auto를 쓴다고 런타임에 비용이 발생하지는 않음
e.g.
// int의 벡터를 선언
std::vector<int> myVect;
// auto를 사용해서 begin을 참조하는 반복자를 선언
auto iter=myVect.begin();
- 그러나 int, float를 대체하거나 하는 등, 남용하여 사용하지는 말자
- 단 auto는 기본적으로 참조자나 const로는 잡히지 않기 때문에, 이럴 때는 따로 표기 해주어야 함
e.g. auto&, const auto, const auto&
- 그런데 포인터형으로는 잡힘
2. nullptr
- 매크로 NULL(#define NULL 0)이나, 0은 "숫자 0으로 취급"됨
- nullptr은 숫자 0이 아니라, "포인터형"이다.
# 레퍼런스(참조자)
- 포인터와 달리 값을 읽고 쓰는 것이 더 간단
(반면에, 포인터는 포인터 역참조 연산자를 명시적으로 써야함)
- 포인터와 달리 null 값을 넘길 수 없으므로, 비교적 안전
1. 상수(const) 레퍼런스
- 복합 자료형의 경우, 값을 넘기는 것보다 레퍼런스를 넘기는 것이 거의 항상 효율적
그러나 기본 자료형인 경우, 값을 넘기는 것이 좋다.
- 값을 변경하려는 의도가 있다면 일반 레퍼런스를 넘기고, 변경하기 싫다면 상수 레퍼런스를 넘기자.
2. 상수 멤버함수
- Getter() 함수(내부 캡슐화된 데이터를 조회하는 함수)는
대부분 상수 레퍼런스로 내부 자료형을 꺼내주어야 함
그래야 호출자가 리턴된 레퍼런스를 가지고 임의로 내부 데이터를 변조하는 것을 막을 수 있음
- 클래스의 내부 데이터를 고치지 않는 멤버함수라면 항상 "상수 멤버함수"로 지정해야 함
- "상수 멤버함수" : 실행되면서 클래스 내부의 데이터를 고치지 않아야 하는 멤버함수
- 어떤 객체에 대한 상수 레퍼런스를 가지고 있는 경우, 이 객체의 상수 멤버함수만 호출할 수 있음
- 멤버함수 지정 방법 : 함수 인자를 나열한 괄호를 닫은 직후 const 키워드를 붙임
- 참고로, const 유무는 함수 오버로딩 조건에 포함됨
(컴파일러는 const가 붙지 않은 함수와 완전히 별개의 함수로 취급함)
class Student
{
private:
std::string mName;
int mAge;
public:
Student(const std::string& name, int age)
: mName(name), mAge(age)
{}
const std::string& getName() const {return mName;}
void setName(const std::string& name) {mName = name;}
int getAge() const {return mAge;}
void setAge(int age) {mAge=age;}
}
* 캡슐화된 내부 데이터(멤버변수)의 정보를 찾아서 반환(e.g. Get() 함수)할 때,
데이터의 변경 방지를 위해서는
1) 상수 레퍼런스(const 참조자) 반환형을 택하거나,
2) 그냥 값 자체를 반환하자.
=> 내부 데이터의 레퍼런스나 포인터를 반환하는 경우,
이로 인해 의도치 않게 내부 데이터가 변화할 수 있기 때문
# 템플릿
- 함수나 클래스가 어떤 자료형이든 일반화하여 처리할 수 있게 선언하는 수단
e.g.
template <typename T>
T max(const T& a, const T& b)
{
return ( (a>b)?a:b ) ;
}
- 컴파일러가 max()를 호출하는 부분을 컴파일하게 되면, 해당 자료형으로 치환한 버전(함수)를 하나 생성
- 클래스나 구조체도 마찬가지로 템플릿으로 선언할 수 있고, STL도 템플릿으로 주로 구현되어 있음
1. 템플릿 특수화
tempalte <typename T>
void copyToBuffer(char* buffer, const T& value)
{
std:memcpy(buffer, &value, sizeof(T)); // sizeof(T) 바이트만큼 바이너리 복사
}
/*
buffer는 기록할 버퍼,
value는 템플릿 변수로서 기록할 변수
*/
- 위 코드는 기본 자료형에 대해서는 잘 돌아가겠지만,
std::string과 같은 복합 자료형은 제대로 기록하지 못한다.
코드가 깊은 복사가 아니라 얕은 복사를 수행하기 때문이다.
이를 해결하려면 깊은 복사를 수행하게 특수화 버전 함수를 만들어야 한다.
template<>
void copyToBuffer<std::string>(char* buffer, const std::string &value)
{
std::memcpy(buffer,value.c_str(),value.length());
}
- 템플릿 매개변수가 여럿인 경우에도 템플릿 특수화를 적용할 수 있음
2. 정적 단언문과 자료형 특성 정보
- 런타임 단언문
1) 값이 유효한지 검사하는데 아주 쓸만함
2) 게임에선 예외(exception)보다 단언문을 많이 쓰는 편인데,
오버헤드도 작을뿐더러 릴리즈 빌드시 최적화되어 사라지기 때문
- 정적 단언문
1) 컴파일 시점에 검사되는 단언문 형식
2) 표현식은 bool형이어야 하며, 컴파일 도중 검사가 가능해야하므로
런타임에서만 검사할 수 있는 내용은 들어가선 안됨
e.g. static_assert(bool,"bool 값이 false시 출력할 문자열");
3) 시렞로 활용하기 위해서는 C++11의 <type_traits> 헤더파일을 include하여,
템플릿 함수에 주어진 자료형의 특성정보(type traits)를 사용해 정적 단언문을 만드는 것이 보통임
template <typename T>
void copyToBuffer(char* buffer, const T& value)
{
static_assert(std::is_fundamental<T>::value,
"copyToBuffer requires specialization for non-basic types.");
/*
is_fundamental() 구조체의 value 멤버값은 T가
기본 자료형일 때만 true 임을 활용
만약 해당 위치에 false 값이 오면
컴파일이 중단되고, 해당 문자열이 출력된다.
*/
std::memcpy(buffer,&value,sizeof(T));
}
* memcpy(바이너리 복사에 사용하자.)와 strcpy(문자열 복사에 사용하자.)의 차이점
- strcpy는 널문자를 만나면 거기까지만 복사한다.
- memcpy는 입력한 바이트 수만큼 전부 복사한다.
- 일반적으로 memcpy가 더 빠르나, 둘은 상황에 따라 적절히 사용하자.
# 스마트 포인터
1. 포인터
- 메모리 주소를 담는 변수
- 잘못 사용하면 문제가 발생
1) 메무리 누수 : e.g. 생성자에서 동적할당한 후, 소멸자로 메모리 해제를 하지 않는 경우 메모리 누수가 발생
2) 동적으로 할당된 변수를 여러 객체가 공유해 포인터로 참조할 때(다수의 포인터가 하나를 가리키는 상황),
어떠한 이유로 가리키던 데이터가 삭제되거나 유효하지 않게되면,
그 데이터를 가리키던 나머지 다수의 포인터들은 더이상 유효하지 않게 됨
이 유효하지 않은 포인터로 작업시 문제가 발생
- 위 두가지 문제를 해결하는 수단 : "스마트 포인터(smart pointer)"
- C++11에 이르러 표준 라이브러리로 채택되어 <memory> 헤더 파일에 추가됨
2. shared_ptr
- 공유 포인터(shared pointer)는 스마트 포인터의 일종
- 여러 포인터가 동적으로 할당된 변수를 공유하여 참조할 수 있는 자료형
- 스마트 포인터는 가리키고 있는 밑단 변수에 대한 참조 횟수를 추적하는데,
이를 레퍼런스 카운팅(reference counting)이라 한다.
- 밑단 변수는 레퍼런스 카운팅이 0이 되기 전까지 삭제되지 않는다.
- 공유 포인터를 만들 땐 std::make_shared() 템플릿 함수를 사용할 것을 추천
* shared_ptr의 생성자에 메모리 주소를 직접 넘겨 만드는 방법도 있지만,
객체를 삭제하는 방법을 커스터마이즈해야 하거나 하는, 꼭 필요한 경우가 아니라면
make_shared()를 쓰자. 이것이 효율적이고 오류의 여지가 적다.
{
// int에 대한 공유 포인터를 하나 생성
// 밑단 변수의 값은 50으로 초기화
// 이때 참조 횟수는 1이 됨
std::shared_ptr<int> p1=std::make_shared<int>(50);
{
// 공유 포인터를 하나 더 만들어 같은 변수를 가리키도록 함.
// 이제 참조 횟수는 2가 됨
std::shared_ptr<int> p2=p1;
// shared_ptr의 값에 접근하려면 일반 포인터처럼
// 역참조(dereference) 연산자 사용
*p2=100;
std::cout<<*p2<<std::endl;
} // 스코프가 닫히면서 p2가 소멸됨. 참조 횟수는 이제 1
} // p1도 소멸됨. 참조 횟수가 0이 되면서 밑단 변수도 삭제됨
- 공유 포인터를 함수의 인자로 넘길 때는 레퍼런스가 아니라 항상 "값"으로 넘겨야 함.
그래야만 참조횟수가 올바르게 계산됨
- 어떤 클래스가 내부적으로 자기 자신에 대한 shared_ptr을 만들 필요가 있을 땐,
this 포인터를 가지고 만들어선 안된다. 그러면 외부의 shared_ptr와는 별도의
참조 횟수 체계가 생겨 버리기 때문이다.
그대신 std::enable_shared_from_this라는 템플릿 클래스를 상속 받아야 한다.
e.g.
class Texture: public std::enable_shared_from_this<Texture>
{
...
...
};
/*
이렇게 한 다음, 텍스처의 멤버 함수 중 자신의 공유 포인터가 필요한 곳에서
shared_from_this() 멤버함수를 호출해서
돌려받은 shared_ptr를 꼭 사용해야
참조 횟수 체계가 올바르게 유지됨
*/
- 계층 구조상 다른 클래스로 캐스팅할 필요가 있을 땐
static_cast나 dynamic_cast 키워드 대신
std::static_pointer_cast() 및 std::dynamic_pointer_cast() 템플릿 함수를 사용해야 함
3. unique_ptr
- 고유 포인터(unique pointer)는 일견 공유 포인터와 유사하나,
밑단 변수를 가리키는 유효한 포인터가
"단 하나"만 존재하게 보장해 준다는 점에서 다름
- 고유 포인터를 다른 고유 포인터에 대입하려고 하면 에러 발생
- 참조 횟수를 추적하지 않고, 소멸될 때 밑단 변수를 그냥 삭제함
- 고유 포인터를 만들 땐 unique_ptr 클래스와 make_unique() 함수를 사용함
- 레퍼런스 카운터를 하지 않는다는 것만 제외하면 shared_ptr과 매우 유사한 코드가 나옴
* make_unique() 함수는 C++11에는 빠져있으며 C++14에 새로 추가됨
4. weak_ptr
- shared_ptr은 사실 두 종류의 참조 횟수를 추적
1) 강한 참조(strong reference) 횟수 : 0이 되면 밑단 객체를 삭제
2) 약한 참조(weak reference) 횟수 : 밑단 객체의 삭제와는 상관이 없고,
약한 포인터(weak pointer)라 하여 공유 포인터에 대한 약한 참조를 관리하는데 사용됨
- 약한 포인터가 필요한 이유는 객체의 소유권을 공유하지는 않고,
대신 해당 객체가 살아 있는지 여부만 안전하게 섬사한 뒤 사용하고 싶을 때가 있기 때문
(객체의 소유권을 공유하면 (강한) 참조 횟수가 증가하여 삭제되지 못하도록 만드는 효과가 있다.)
- C++11은 weak_ptr로 약한 포인터를 제공함
std::weak_ptr<int> wp = sp; // sp는 shared_ptr<int>
- expired() 함수를 사용하면 이 약한 포인터가 가리키는 객체가 아직 살아있는지 검사 가능
- 살아있다면 lock() 함수로 shared_ptr을 도로 얻을 수 있는데, 그러면 강한 참조 횟수가 1 증가함
if(!wp.expired())
{
// 공유 포인터로 받으면서 강한 참조 횟수가 증가함
std::shared_ptr<int> sp2=wp.lock();
// 이렇게 얻은 shared_ptr를 sp2로 사용한다.
// ...
}
- lock() 함수를 호출할 때 expired() 검사도 같이 수행하고,
삭제된 경우 nullptr을 반환하므로 다음과 같이 간결하게 작성도 가능하다.
멀티쓰레드 환경에서는 이것이 더 안전하다. lock() 함수는 원자적(atomic)으로 구현되어 있기 때문이다.
if(auto sp2=wp.lock()) // lock() 함수를 호출할 때 강한 참조 횟수가 증가함
{
...
}
- 약한 포인터는 순환 참조(circular reference)를 피하기 위한 수단으로 이용된다.
e.g. 객체 A와 B가 서로를 shared_ptr로 가리켜
객체 A나 B 둘다 삭제할 방법이 없게 되는 경우,
둘 중 하나를 weak_ptr로 만들면 순환 참조를 피할 수 있음
* 스마트 포인터 주의사항
- 동적으로 할당된 배열에는 제대로 사용하기 어려움
따라서, 배열에 스마트 포인터를 사용하고 싶을 때는
std::array 컨테이너에 대해 스마트 포인터를 사용하자.
- 일반 포인터 대비 메모리 오버헤드 및 성능상 비용이 미세하게 발생함을 유의
1) 극한의 성능을 추구 => 일반 포인터
2) 안전하고 편한 사용을 추구 => 스마트 포인터
# STL 컨테이너
- C++ 표준 템플릿 라이브러리(standard template library),
이하 STL에는 많은 종류의 컨테이너 자료구조가 있다.
- 각 컨테이너는 해당 이름의 헤더 파일에 선언되어 있다.
1. array
- 고정길이 배열의 rapper 클래스
- 크기가 고정이므로 push_back() 등의 멤버함수가 제공되지 않는다.
- [] 연산자 제공 => 따라서, 원소 조회 O(1)
- C 스타일의 일반 배열과 같은 기능을 제공하지만, 반복자와 잘 어울린다는 장점이 있다.
(C 스타일의 일반 배열에는 begin(), end() 같은 멤버함수가 존재하지 않는다.)
- [] 대신 at() 멤버함수를 쓰면 범위 검사도 수행하지만, []보다는 느리다.
2. vector
- 가변길이 배열
- push_back(), pop_back() 멤버함수 제공 : O(1)
- 임의위치 삽입, 삭제 or 크기변경 : O(n)
- push_back()은 일반적으로 O(1), 가득찼을 때는 재할당 및 전체 원소에 대한 복제에 의해 O(n)
- 범위 검사 수행 멤버함수 제공 : at()
- reserve() 멤버 함수로 미리 용량을 확보 가능하다.
=> 원소 추가로 인한 확장 및 복제를 피할 수 있다.
- 기본 자료형이 아닌 복합 자료형 원소를 vector에 추가하고자 할 때는,
push_back() 대신 emplace_back() 사용을 습관화하면 좋다.
1) push_back() : 인자를 통해 미리 임시객체를 생성해서, 그 임시객체를 vector 내부에서 또다시 복사해야 함
2) emplace_back() : 완벽전달(인자를 생성자에 완벽히 있는 그대로 효율적으로 전달)이 되므로,
임시객체가 vector 내부에서 생성됨. 따라서 임시객체를 생성하지 않고 인자만을 그대로 전달하면 됨.
1)은 2)에 비해 임시객체의 생성과 소멸과정이 추가적으로 필요하다.
따라서, 2)가 상대적으로 유리하다.
- "array를 제외"한 다른 STL 컨테이너들은 emplace_back()를 지원한다.
3. list
- 양방향 연결리스트
- 임의 접근을 지원하지 않는다.
- 맨 앞과 뒤에 삽입, 삭제시 O(1)
=> 맨 앞과 뒤가 아닌 경우에도 삽입, 삭제 자체는 O(1)이나
임의 접근을 지원하지 않으므로 삽입, 삭제 위치를 찾는데에 O(n)의 시간이 걸린다.
결과적으로 O(n)
- 재할당의 필요성이 없다.
- 각 원소들이 메모리상 인접해 있지 않으므로 배열형 자료구조 대비 캐시 친화적이지 않다.
따라서, 상대적으로 원소의 크기가 작은(64바이트 이하) 경우, vector의 성능이 list를 압도한다.
- forward list(단방향)
1) list 대비 노드당 메모리 오버헤드가 약간 작다.
2) 맨 앞에서 추가/삭제할 때만 O(1) 보장
- 일반적 성능 : list < deque / forward list < vector
그러나, splice 연산으로 일부 원소를 떼내는 작업이 필요할 때는 연결형 컨테이너가 유리하다.
4. map
- 순서가 정렬된 컨테이너
- key, value의 pair 저장
- 정렬의 기준은 key, 각 키는 중복되지 않아야 하며, strict weak ordering 조건을 충족해야 함
- 커스텀 자료형을 key로 쓰고자 한다면 operator< 오버로딩을 해야함
- 이진탐색트리로 구현되어 있기에 조회할 때의 시간복잡도 : 평균 log2(n)
- 항상 순서가 정렬되어 있는 상태로 유지되며, 반복자 순회도 오름차순.
- set은 map과 거의 동일하나, key와 value를 쌍으로 갖지 않고 key만 갖는다는 차이가 있음
5.unordered_map
- {key, value} pair를 해시테이블에 저장
- 해시테이블에 저장하므로 조회시 시간복잡도 : 상각된 O(1)
- 순서가 보장되지 않으므로, 반복자도 순서가 없음 => 반복자로 순회할 때 그 순서가 무의미함
- unordered_set도 존재
1) 내장 자료형에 대해 해시함수는 기본제공
2) 커스텀 자료형에 대해서는 std::hash() 템플릿 함수의 특수화 버전 작성이 필요
# 반복자(iterator)
- 컨테이너의 각 원소를 순회하기 위한 용도의 객체
- STL 컨테이너는 모두 반복자를 지원
- begin() : STL 컨테이너의 첫번째 원소를 가리키는 반복자
- end() : STL 컨테이너의 마지막 원소 바로 다음을 가리키는 반복자
- 반복자가 다음 원소로 전진하게 증가시킬 때, 접두(prefix) 연산자를 쓰는 것이 성능이 좋음
(접미연산자를 쓰면 반복자의 임시 사본 객체가 무수히 만들어졌다 사라지는 과정이 반복됨)
- 반복자로 원소의 밑단 값에 접근하려면 포인터와 마찬가지로 역참조 연산자를 사용
원소가 포인터형이라면 역참조연산자를 반복자에 한번, 그리고 포인터에 한번, 총 두번을 사용해야 함
- 모든 컨테이너는 두 종류의 반복자를 제공 : iterator / const_iterator
1) iterator는 수정을 허용 / const iterator는 원소에 대한 변조를 금지
2) STL 컨테이너를 상수 레퍼런스로 갖고 있으면 const_iterator만 얻을 수 있으며,
원소의 자료형이 클래스나 구조체인 경우 const 멤버함수만 호출 가능
1. 범위 기반 for 구문
- 컨테이너 원소 전체를 순회하고 싶을 때 사용하면 편함
for(auto((const) &) i : myVec)
{
std::cout << i << std::endl;
}
e.g.
그냥 auto i : myVec 형태로만 했다면, i는 "값"
그러나, 위의 예처럼 auto const &i : myVec 형태로 했다면, i는 "const 참조값"
- 컨테이너의 각 원소를 얻어 임시변수 i에 대입
- 모든 원소를 순회하고 나면 루프가 끝남
- 각 원소는 값이나 레퍼런스로 얻을 수 있고,
기본 자료형이 아니라면 항상 레퍼런스나 상수 레퍼런스를 사용하자.
(그렇지 않으면 매 원소마다 복제가 일어나 매우 비효율적
그러나 앞에서 언급했듯, shared_ptr에 대해서는 "값"을 사용해야 함)
- STL 스타일의 반복자 체계를 지원하는 컨테이너라면 어떤 것이든 호환됨
* STL 스타일 반복자
1) begin() 및 end() 멤버가 있고
2) 반복자가 증가 및 감소 연산자를 지원하는 등의 조건을 갖춘
3) 클래스 또는 구조체
2. 반복자 활용하기
- <algorithm> 헤더에 반복자를 여러가지 형태로 사용하는 수많은 함수들이 있음
- 반복자를 가장 많이 쓰는 곳은 아마도 map, set, unordered_map 등의 find() 멤버함수일 것임
e.g.
find() : 컨테이너 내의 전체 원소에서 지정된 키를 찾아,
해당 원소를 가리키는 반복자 리턴, 못찾으면 end()와 등가의 반복자 리턴
[출처] : 조슈아 글레이저, 산제이 마드하브 저, "멀티플레이어 게임 프로그래밍", 길벗출판사