항목 9 : 데이터를 삭제할 때에도 조심스럽게 선택할 것이 많다

container<int> c;

어떤 표준 컨테이너 c가 있다고 하자. 여기서 어떤 값을 지우고 싶을 때, 컨테이너마다 방법이 다르다.

 

 

◾ 특정 값을 모두 삭제하는 경우

c.erase(std::remove(c.begin(), c.end(), 1963), c.end()); // vector, string, deque
c.remove(1963); // list

연속 메모리(배열 형식) 컨테이너에는 erase-remove가 효과적이다. 단, list는 그냥 멤버 함수로 있는 remove만 해주는것이 더 효율적이다.

 

c.erase(1963);

표준 연관 컨테이너라면 erase밖에 없다. 애초에 멤버 함수로 remove가 없을 뿐더러 remove 알고리즘 사용 시 컨테이너가 변형될 수 있다.

 

 

◾ 특정 조건에 부합하는 값을 삭제하는 경우

bool badValue(int x);
c.erase(std::remove_if(c.begin(), c.end(), badValue), c.end()); // vector, string, deque
c.remove_if(badValue); // list

연속 메모리 컨테이너의 경우 특정 값 대신 bool을 반환하는 함수를 넘겨주면 된다.

 

표준 연관 컨테이너의 경우 두 가지 방법이 있다.

 

AssocContainer<int> c; // 삭제되지 말아야 할 값을 담아 둘 임시 컨테이너
AssocContainer<int> goodValues; // c에서 goodValues로 옮긴다

std::remove_copy_if(c.begin(), c.end(), std::inserter(goodValues, goodValues.end()), badValue);
c.swap(goodValue);

첫 번째는 삭제하지 않을 값을 임시 연관 컨테이너에 보관해 두었다가 원래 컨테이너와 맞바꾸는 비효율적인 방법이다. 복사 비용이 꽤 비싸다.

 

for (auto i = c.begin(); i != c.end();) {
    if (badValue(*i)) c.erase(i++);
    else ++i;
}

두 번째는 반복자 순회로 요소를 하나씩 지우는 것이다.

erase가 이뤄질 때 후위형 증가로 인해 반복자가 무효화되지 않는다.

 

정리

◾ 컨테이너에서 특정한 값을 가진 객체를 모두 없애려면

연속 메모리 컨테이너 : erase-remove

list : list::remove

표준 연관 컨테이너 : container::erase

 

◾ 컨테이너에서 특정 조건을 만족하는 객체를 모두 없애려면

연속 메모리 컨테이너 : erase-remove_if

list : list::remove_if

표준 연관 컨테이너 : remove_copy_if-swap 또는 루프를 돌며 객체를 하나씩 erase

 

◾ 루프 안에서 무엇인가를 하려면(객체 삭제 포함)

표준 시퀀스 컨테이너 : erase의 반환값으로 반복자 업데이트

표준 연관 컨테이너 : erase 할때 같이 하면 됨

 

 

항목 10 : 할당자(allocator)의 일반적인 사항과 제약 사항에 대해 잘 알아두자

C++ 표준안에 의하면 디폴트 할당자와 사용자 정의 할당자는 pointer(T*)와 reference(T&) 타입을 제공해야 한다.

 

같은 타입의 모든 할당자 객체는 동등하며 항상 상등 비교를 수행하는것을 가정해야한다는 제약이 있다.

그 이유는 아래와 같다.

 

template<typename T>
class SpecialAllocator { ... };
typedef SpecialAllocator<Widget> SAW;

std::list<Widget,SAW> L1;
std::list<Widget,SAW> L2;
...
L1.splice(L1.begin(), L2);

위와 같은 코드가 있다고 해보자. L1.splice에 의해 L2의 모든 노드들이 L1으로 복사된다.

이후 L1이 소멸될 때, L1의 할당자는 L2의 할당자에 의해 생성된 노드들을 해제시켜야 한다. (L1에 복사된 L2의 노드들은 L2의 할당자에 의해 생성된것이기 때문)

하지만 둘의 할당자가 서로 동등하지 않다면 문제가 발생하게 된다.

그렇기 때문에 어떤 할당자 객체에 의해 할당된 메모리(L2)는 다른 할당자 객체(L1)로도 안전하게 해제할 수 있어야 하므로 할당자의 타입이 동등해야 한다는 제약이 반드시 필요하다.

 

타입이 동등하다는 제약이 성립되려면 이식이 가능한 할당자 객체는 상태를 가지지 않아야 한다. (=비정적 데이터 멤버를 가지지 않는다.)

상태를 가지고 있는 할당자라고 해서 컴파일이 안되는것은 아니지만 실행 시 의도적인 결과가 나오지 않을 수 있다.

 

 

할당자는 저수준 메모리 할당을 수행한다는 점에서 operator new와 같지만 인터페이스는 전혀 다르게 생겼다.

 

void* operator new(size_t bytes); // 바이트
pointer allocator<T>::allocate(size_type numObjects); // 메모리에 맞는 객체의 수

둘이 무슨 차이인가 하면 sizeof(int)가 4바이트라고 했을 때, new는 4를 넘겨야하지만 allocate는 1을 넘겨야 한다.

반환되는 타입도 다르다. operator new는 메모리 할당은 되었지만 초기화되지 않은 메모리의 포인터(void*)를 반환하는데 반해서 allocator는 반환되는 포인터(T*)가 메모리에 생성조차 안된 상태이다.

 

대부분의 표준 컨테이너는 자신이 생성될 때 같이 붙어온 할당자를 한 번도 호출하지 않는다.

예를 들어 list는 별다른 할당자를 추가로 지정하지 않으면 list<T, allocator<T>>로 정의된다.

 

std::list<int> L; /* std::list<int, allocator<int>> L */

template<typename T, typename Allocator = allocator<T>>
class list {
private:
    Allocator alloc;
    struct ListNode {
        T data;
        ListNode* prev;
        ListNode* next;
    };
...
};

list는 대략 이런식으로 구성되어 있을 것이다. 그런데 노드가 추가될 때, 노드의 타입은 ListNode이지 T가 아니다.

그래서 할당자로 allocator<T>가 넘어가도 쓸모가 없어서 사용하지 않는다.

ListNode에 대한 할당자는 other라는 이름으로 재정의 되어있는데 allocator<T> 안에 rebind라는 구조체 안에 들어있다.

 

template<typename T>
class allocator {
public:
    template<typename U>
    struct rebind {
        typedef allocator<U> other; /* std::allocator<T>::rebind<U>::other */
    };
...
};

 

마지막으로 커스텀 할당자를 작성할 일이 생겼을 때 기억해야 할 것들을 정리한다.

 

◾ 할당자를 템플릿으로 만든다. 템플릿 매개변수는 메모리를 할당하고자 하는 객체 타입 T를 사용한다.

◾ pointer와 reference라는 타입 재정의를 제공하되 항상 pointer는 T*, reference는 T&이도록 한다.

◾ 할당자에는 객체별 상태를 절대로 주지 않는다.

◾ 할당자의 allocate 멤버 함수에는 필요한 바이트 수가 아닌 객체의 개수를 매개 변수로 넘긴다. T* 포인터를 반환하지만 T 객체가 생성되지는 않은 상태임을 명심해야한다.

◾ 표준 컨테이너에서 필요로 하는 rebind 중첩 템플릿을 꼭 제공한다.

 

 

항목 11 : 커스텀 할당자를 제대로 사용하는 방법을 이해하자

◾ 사용 예1)

void* mallocShared(size_t bytesNeeded);
void freeShared(void *ptr);

template<typename T>
class SharedMemoryAllocator {
public:
    ...
    pointer allocate(size_type numObjects, const void* localityHint = 0) {
        return static_cast<pointer>(mallocShared(numObjects * sizeof(T)));
    }
    void deallocate (pointer ptrToMemory, size_type numObjects) {
        freeShared(ptrToMemory);
    }
};

공유 메모리 힙을 관리할 목적으로 malloc과 free함수를 본떠서 만들고 STL 컨테이너에 넣어서 공유 메모리 매커니즘을 쓴다고 가정한다.

 

typedef std::vector<double, SharedMemoryAllocator<double>> SharedDoubleVec;
...
{
    SharedDoubleVec v;
    ...
}

공유 메모리를 위해 커스텀 할당자를 만들고 템플릿 매개변수로 넘겨줬음에도 v는 공유 메모리 안에 위치하지 않는다.

 

void* pVectorMemory = mallocShared(sizeof(SharedDoubleVec)); // 충분한 공유 메모리 할당
SharedDoubleVec* pv = new (pVectorMemory) SharedDoubleVec; // 전용 new를 써서 객체를 메모리에 생성
...
pv->~SharedDoubleVec(); // 객체 소멸
freeShared(pVectorMemory); // 메모리 해제

 

진짜로 공유 메모리에 두려면 수동으로 할당-생성-소멸-해제를 직접 해주어야 한다.

 

◾ 사용 예2)

두 개의 힙에서 메모리를 관리한다.

 

class Heap1 {
public:
    ...
    static void* alloc(size_t numBytes, const void* memoryBlockToBeNear);
    static void dealloc(void* ptr);
    ...
};

class Heap2 { /* Heap1과 동일한 인터페이스 */ };

 

template<typename T, typename Heap>
SpecialHeapAllocator {
public:
    ...
    pointer allocate(size_t numObjects, const void* localityHint = 0) {
        return static_cast<pointer>(Heap::alloc(numObjects * sizeof(T), localityHint));
    }
    void deallocate(pointer ptrToMemory, size_tpe numObjects) {
        Heap::dealloc(ptrToMemory);
    }
    ...
};

STL 컨테이너 몇 개를 종류에 맞춰서 각기 다른 힙에 모아두고 싶다면 Heap1, Heap2를 사용하도록 설계된 할당자를 만든다.

 

std::vector<int, SpecialHeapAllocator<int, Heap1>> v;
std::set<int, SpecialHeapAllocator<int, Heap1>> s;

std::list<Widget, SpecialHeapAllocator<Widget, Heap2>> l;
std::map<int, string, SpecialHeapAllocator<std::pair<int, string>, Heap2>> m;

(항목 11은 이해가 잘 되지 않아서 추가로 더 찾아봐야 할 것 같다.)

 

 

항목 12 : STL 컨테이너의 쓰레드 안전성에 대한 기대는 현실에 맞추어 가지자

std::vector<int> v;

auto first5(std::fint(v.begin(), v.end(), 5));
if (first5 != v.end())
    *first5 = 0;

이 코드는 다중 스레드 환경에서 문제가 될 수 있다. 값을 찾아서 반복자를 반환 받았는데 그사이 v의 값이 변하면 이후 조건식은 쓸모가 없어진다.

이 코드가 스레드 안전성을 가지려면 아래 세줄이 모두 락이 걸린채로 실행되어야 한다.

위와 같은 상황 말고도 많은 상황들이 존재할텐데 모든것을 예측해서 스레드에 안전하게 STL을 구현하는것은 매우 어려운일이다.

그렇기때문에 STL 컨테이너에게 스레드 문제 해결을 전적으로 믿고 맡길 수 없고 직접 스레드 동기화를 제어해주는것이 낫다.

 

STL은 여러 스레드가 하나의 컨테이너를 읽거나 쓰는 일에 대해서는 스레드 안전성을 기대해도 된다. 단, 읽고 쓰기가 동시에 이뤄져서는 안된다.

하지만 동시성 제어의 문제까지 없애주지는 않는다.

 

'도서 > Effective STL' 카테고리의 다른 글

반복자(Iterators)  (0) 2022.12.20
STL 연관 컨테이너 (2)  (0) 2022.12.20
STL 연관 컨테이너 (1)  (0) 2022.12.20
vector와 string  (0) 2022.12.18
효과적인 컨테이너 요리법 (1)  (0) 2022.12.16

항목 1 : 적재적소에 알맞은 컨테이너를 사용하자

◾ 표준 STL 시퀀스 컨테이너 : vector, string, deque, list

◾ 표준 STL 연관 컨테이너 : set, multiset, map, multimap

 

vector는 일반적, list는 시퀀스의 중간에 삽입/삭제가 빈번하게 일어날 때, deque는 시퀀스의 끝에서 삽입/삭제가 빈번하게 일어날 때 사용된다.

 

◾ 연속 메모리(배열 기반) 컨테이너 : vector, string, deque

요소가 삽입되거나 삭제될 때 발생하는 밀어내기로 인해 수행 성능이 떨어지거나 예외 안전성에 영향을 미친다.

 

◾ 노드 기반 컨테이너 : list 및 표준 연관 컨테이너

요소의 삽입이나 삭제가 이루어져도 밀어내기가 발생하지 않는다.

 

 

항목 2 : "컨테이너에 독립적인 코드"라는 환상을 조심하자

각 컨테이너를 모두 지원하는 코드 작성은 무의미한 행동이다. STL의 컨테이너는 서로 바꾸어서 쓸 수 있도록 설계되어 있지 않기 때문이다.

 

 

항목 3 : 복사는 컨테이너 안의 객체에 맞게 비용은 최소화하며, 동작은 정확하게 하자

컨테이너에 객체를 담을때는 원본이 아닌 복사본이 들어가고 객체를 꺼낼때는 참조자를 얻어서 복사되어 꺼내진다.

복사되어 들어가고, 복사되어 나오는 것이 STL의 방식이다.

배열 기반 컨테이너에 insert, erase, next_permutation 등의 함수를 호출하면 내부적으로 복사를 통해 위치가 바뀐다.

이 때 객체들의 복사는 해당 클래스의 복사 멤버 함수를 사용하게 된다.

상속된 객체의 경우 복사중에 데이터가 슬라이스 되는 경우도 발생한다.

 

객체 대신 포인터를 컨테이너에 저장하면 슬라이스도 발생하지 않고 성능 이점이 생긴다.

 

STL은 복사를 많이 하긴 하지만 불필요한 복사는 피하도록 설계되어있다.

 

Widget w[maxNumWidgets]; // maxNumWidgets만큼 기본 생성자 호출

vector<Widget> vw;
vw.reserve(maxNumWidgets); // maxNumWidgets만큼의 크기만 미리 할당

 

 

항목 4 : size()의 결과를 0과 비교할 생각이라면 차라리 empty를 호출하자

if (c.size() == 0)
if (c.empty())

두 코드는 본질적으로 같다.

하지만 empty는 모든 컨테이너에서 상수 시간에 수행되는 반면에 size는 선형 시간에 수행되는 컨테이너가 존재한다.

예를 들어 list의 size()와 splice()는 둘 중 하나가 반드시 선형 시간이어야 한다. 그리고 보통 splice가 상수 시간으로 구현되어있다.

 

 

항목 5 : 단일 요소를 단위로 동작하는 멤버 함수보다 요소의 범위를 단위로 동작하는 멤버 함수가 더 낫다

v1.assign(v2.begin() + v2.size() / 2, v2.end()); // v2의 뒤쪽 절반 복사

iterator를 사용한 범위 단위 동작이 아니라 단일 요소 단위로 같은 동작을 하려면 필연적으로 반복문을 사용할 수 밖에 없다.

범위 단위 멤버 함수가 코드도 더 간결해지고 가독성도 높아진다.

 

/* data: 배열, numValues: 배열크기 */
std::copy(data, data + numValues, std::inserter(v, v.begin()));

배열의 복사를 위와 같이 구현하는 경우 시퀀스 컨테이너의 성능에 있어서 발목을 붙잡는 요소들이 몇 가지 발생한다.

첫 번째, inserter는 배열 요소 개수만큼 호출된다.

두 번째, inserter가 호출될때마다 기존에 삽입된 v의 요소가 한칸씩 뒤로 계속 밀린다. (복사가 빈번하게 일어남) 범위 단위 멤버 함수인 insert라면 한 번에 마지막 위치까지 옮긴다.

세 번째, vector의 경우 복사되는 요소의 갯수가 많다면 크기 확장이 빈번하게 일어나서 메모리 할당의 횟수가 많아진다.

 

string, deque, list의 경우에는 조금씩 다르지만 어쨌든 공통적으로 범위 단위 insert가 성능면에서 우수하다.

 

연관 컨테이너에서는 단일 요소 버전과 범위 멤버 함수의 효율성을 따지기 어렵다.

다만 범위 멤버 함수는 간결한 코드와 가독성에 있어서 확실하게 이점이 있다.

 

/* 범위 생성 */
container::container(iterator begin, iterator end);

/* 범위 삽입 */
void container::insert(iterator position, iterator begin, iterator end);

모든 표준 컨테이너는 begin, end를 인자로 갖는 생성자와 동일한 형태의 insert 멤버 함수를 지원한다.

(추가: 책에서는 시퀀스 컨테이너와 연관 컨테이너의 erase 반환값이 달랐지만 모던 C++은 동일하다.)

 

void container::assign(iterator begin, iterator end);

모든 표준 시퀀스 컨테이너는 동일한 범위 버전의 assign 멤버 함수를 지원한다.

 

최종적으로 정리하자면 범위 멤버 함수는 작성이 쉽고, 가독성이 좋고, 성능도 월등히 좋다.

 

 

항목 6 : C++ 컴파일러의 어이없는 분석 결과를 조심하자

std::ifstream dataFile("ints.dat");
std::list<int> data(std::istream_iterator<int>(dataFile), std::istream_iterator<int>());

위의 코드는 컴파일은 되지만 런타임에 아무 일도 하지 않는다. 그 이유는 리스트가 아닌 함수의 선언으로 분석하기 때문이다. 반환타입이 std::list<int>이고 매개변수가 두개인 함수 data의 선언이다.

해결 방법은 익명 객체가 아니라 각 반복자 객체를 정의해서 인자로 넘겨주는 것이다.

 

std::ifstream dataFile("ints.dat");
std::istream_iterator<int> dataBegin(dataFile);
std::istream_iterator<int> dataEnd;
std::list<int> data(dataBegin, dataEnd);

익명 객체를 사용하지 않는 것은 프로그래밍 스타일에 적합하다고 볼 수 없지만 혼란을 주지 않기 위해서는 어쩔 수 없다.

 

 

항목 7 : new로 생성한 포인터의 컨테이너를 사용할 때에는 컨테이너가 소멸되기 전에 포인터를 delete하는 일을 잊지 말자

컨테이너는 자신이 소멸할 때 각 요소 자체를 없애주기는 하지만 요소가 포인터인 경우 포인터에 대해 delete를 수행하지 않기 때문에 소멸자가 호출되지 않는다.

 

struct DeleteObject {
    template<typename T>
    void operator()(const T* ptr) const { delete ptr; }
};

std::for_each(dssp.begin(), dssp.end(), DeleteObject()); // 자동으로 타입이 추론된다

이런 방식은 타입 안전성은 있지만 예외 안전성이 없기 때문에 스마트 포인터를 사용해 주어야 한다.

 

void doSomeThing() {
    typedef std::shared_ptr<Widget> SPW;
    std::vector<SPW> vwp;
    for (int i = 0; i < SOME_MAGIC_NUMBER; ++i)
        vwp.push_back(SPW(new Widget));
}

스마트 포인터는 유효 범위를 벗어날 때 참조 횟수가 줄어들고 0이 되면 소멸자를 호출하기 때문에 예외 안전성이 보장된다.

 

(추가: auto_ptr의 컨테이너는 사용하지 말라고 한다. 하지만 요즘은 auto_ptr이 삭제되었다.)

 

 

항목 8 : auto_ptr의 컨테이너는 절대로 만들지 말자

(추가: 우선 언급하자면 auto_ptr은 C++11부터 사용 자제 권고가 계속 내려졌고 C++17부터 아예 제거되었다.)

 

auto_ptr이 담긴 컨테이너는 컴파일 조차 되어서는 안된다. auto_ptr은 복사가 허용되지 않기 때문이다.

예를 들어 auto_ptr이 담긴 컨테이너를 정렬한다고 했을 때, sort함수 내부적으로 요소를 임시 지역 변수로 '복사' 하는 과정이 있다. 이 때 소유권이 이동되기 때문에 컨테이너의 값이 소실된다.

'도서 > Effective STL' 카테고리의 다른 글

반복자(Iterators)  (0) 2022.12.20
STL 연관 컨테이너 (2)  (0) 2022.12.20
STL 연관 컨테이너 (1)  (0) 2022.12.20
vector와 string  (0) 2022.12.18
효과적인 컨테이너 요리법 (2)  (0) 2022.12.16

상태 (State)

 

객체의 내부 상태에 따라 스스로 행동을 변경할 수 있게 허가하는 패턴으로, 이렇게 하면 객체는 마치 자신의 클래스를 바꾸는 것처럼 보입니다.

 

예제

플랫포머 게임에서 점프를 구현한다고 해보자.

 

void Heroine::handlerInput(Input input)
{
    if (input == PRESS_B) {
        yVelocity_ = JUMP_VELOCITY;
        setGraphics(IMAGE_JUMP);
    }
}

 

구현: B버튼을 누르면 점프를 한다.

문제점: 공중점프를 무한하게 할 수 있다.

 

void Heroine::handlerInput(Input input)
{
    if (input == PRESS_B) {
        if (!isJumping_)
            isJumping_ = true;
            yVelocity_ = JUMP_VELOCITY;
            setGraphics(IMAGE_JUMP);
        }
    }
}

 

개선: 플래그 변수를 추가해서 점프 상태를 판별하여 공중점프를 막는다.

 

이번에는 캐릭터가 땅에 있을 때 아래 버튼을 누르면 엎드리고 버튼을 떼면 일어나는것을 구현한다고 해보자.

 

void Heroine::handlerInput(Input input)
{
    if (input == PRESS_B) {
        if (!isJumping_) {
            isJumping_ = true;
            yVelocity_ = JUMP_VELOCITY;
            setGraphics(IMAGE_JUMP);
        }
    }
    else if (input == PRESS_DOWN) {
        if (!isJumping_) {
            setGraphics(IMAGE_DUCK);
        }
    }
    else if (input == RELEASE_DOWN) {
        setGraphics(IMAGE_STAND);
    }
}

 

구현: 점프 상태가 아닐 때, 아래키를 누르면 엎드리고 키를 떼면 일어선다.

문제점: 엎드린 상태에서 점프한 뒤, 공중에서 아래 버튼을 떼면 땅에 서있는 모습으로 보인다.

 

void Heroine::handlerInput(Input input)
{
    if (input == PRESS_B) {
        if (!isJumping_ && !isDucking_) {
            isJumping_ = true;
            yVelocity_ = JUMP_VELOCITY;
            setGraphics(IMAGE_JUMP);
        }
    }
    else if (input == PRESS_DOWN) {
        if (!isJumping_) {
            isDucking_ = true;
            setGraphics(IMAGE_DUCK);
        }
    }
    else if (input == RELEASE_DOWN) {
        isDucking_ = false;
        setGraphics(IMAGE_STAND);
    }
}

 

개선: 플래그 변수를 또 추가해서 상태를 판별하여 막는다.

 

코드가 하나씩 추가될때마다 구조가 크게 망가진다. 이런식으로 구현하다가는 끝이 없다.

 

 

유한 상태 기계(Finite State Machine)

캐릭터가 할 수 있는 동작과 조건을 플로 차트로 그리면 위와 같다. 이것이 유한 상태 기계이다.

유한 상태 기계의 요점은 다음과 같다.

 

◾ 가질 수 있는 '상태'가 한정된다.

◾ 한 번에 '한 가지' 상태만 될 수 있다.

◾ '입력'이나 '이벤트'가 기계에 전달된다.

◾ 각 상태에는 입력에 따라 다음 상태로 바뀌는 '전이'가 있다.

 

 

FSM의 상태는 주로 열거형으로 정의되고 다중 조건(switch-case)문으로 조건을 검사하여 상태를 변경한다.

 

enum State {
    STATE_STANDING,
    STATE_JUMPING,
    STATE_DUCKING,
    STATE_DIVING
};

void Heroine::handleInput(Input input)
{
    switch (state_) {
    case STATE_STANDING:
        if (input == PRESS_B) {
            state_ = STATE_JUMPING;
            yVelocity_ = JUMP_VELOCITY;
            setGraphics(IMAGE_JUMP);
        }
        else if (input == PRESS_DOWN) {
            state_ = STATE_DUCKING;
            setGraphics(IMAGE_DUCK);
        }
        ...
    }
};

 

계속 추가되는 플래그 변수 대신 상태를 나타내는 열거형 하나로 줄어들어서 코드 가독성이 조금 더 좋아진다.

 

만약 엎드린 상태라면 기를 모으고 특수공격을 할 수 있다고 해보자.

 

void Heroine::update()
{
    if (state_ == STATE_DUCKING) {
        chargeTime_++;
        if (chargeTime_ > MAX_CHARGE) {
            superBomb();
        }
    }
}

 

이 때는 열거형 뿐만 아니라 다른 변수를 추가로 사용하면 된다.

 

 

상태 패턴

◾ 상태 인터페이스

class HeroineState {
public:
    virtual ~heroineState() {}
    virtual void handleInput(Heroine& heroine, Input input) {}
    virtual void update(Heroine& heroine) {}
};

 

상태에 의존하는 모든 동작을 인터페이스의 가상 메서드로 만든다.

 

◾ 상태별 클래스 만들기

class DuckingState : public HeroineState {
public:
    DuckingState() : chargeTime_(0) {}
    virtual void handleInput(Heroint& heroint, Input input) {
        if (input == RELEASE_DOWN) {
            ...
            heroine.setGraphics(IMAGE_STAND);
        }
    }
    
    virtual void update(Heroine& heroine) {
        chargeTime++;
        if (chargeTime_ > MAX_CHARGE) heroine.superBomb();
    }
private:
    int chargeTime_;
};

 

switch-case문에서 case별로 존재하던 상태를 별도의 클래스로 만든다.

 

◾ 동작을 상태에 위임하기

class Heroine {
public:
    virtual void handleInput(Input input) { state_->handleInput(*this, input); }
    virtual void update() { state_->update(*this); }
    ...
private:
    HeroineState* state_;
};

 

선택문을 모두 제거하고 자신의 현재 상태는 HeroineState 객체 포인터에게 위임한다.

 

 

상태 객체는 어디에 둬야 할까?

열거형과 다르게 상태 패턴은 클래스를 사용하기 때문에 포인터에 담을 실제 인스턴스가 필요하다.

 

◾ 정적 객체

상태 객체에 chargeTime_ 같은 필드가 따로 없다면 인스턴스가 모두 동일하기 때문에 정적 인스턴스 하나만 만들어도 충분하다.(=경량 패턴)

만약 필드도 없고 가상 메서드도 하나밖에 없으면 상태 클래스 대신 정적 함수로 바꿔도 무관하다.

 

◾ 상태 객체 만들기

상태 객체에 필드가 존재한다면 각 캐릭터마다 필드의 값을 다르게 유지해야 하기 때문에 정적 객체만으로는 해결할 수 없다.

이때는 상태를 전이할때마다 새로운 상태 객체를 만들고 기존의 상태 객체는 해제한다.

 

void Heroine::handleInput(Input input)
{
    HeroineState* state = state_->handleInput(*this, input);
    if (state != nullptr) {
        delete state_;
        state_ = state;
    }
}

HeroineState* StandingState::handleInput(Heroine& heroine, Input input)
{
    if (input == PRESS_DOWN) {
        ...
        return new DuckingState();
    }
    ...
    return nullptr;
}

 

상태 전이가 일어날 때마다 할당과 해제가 이루어지므로 가능하면 정적 상태를 사용하는것이 좋을 것이다.

메모리 단편화가 걱정된다면 차후에 다룰 메모리 풀을 고려해본다.

 

 

상태 전이: 입장과 퇴장

HeroineState* DuckingState::handleInput(Heroint& heroint, Input input) {
    if (input == RELEASE_DOWN) {
        heroine.setGraphics(IMAGE_STAND);
        return new StandingState();
    }
    ...
}

 

상태가 변화할 때, 스프라이트는 새로운 상태가 아닌 이전 상태에서 변경되기 때문에 시기가 올바르지 않는다.

때문에 상태에서 스프라이트까지 제어하는 것이 더 바람직한 흐름 및 구조를 가지게 될 것이다.

 

class StandingState : public HeroineState {
public:
    virtual void enter(Heroine& heroine) { heroine.setGraphics(IMAGE_STAND); }
    virtual Heroine* handleInput(Heroine& heroine, Input input) { ... }
    ...
};


void Heroine::handleInput(Input input)
{
    HeroineState* state = state_->handleInput(*this, input);
    if (state != nullptr) {
        delete state_;
        state_ = state;
        state_->enter(*this); // 새로운 상태로 전이할 때 enter 호출
    }
}

 

상태가 전이할 때 전이되는 상태 객체에서 스프라이트를 변경시키기 때문에 이전 상태와 상관없는 코드로 작성이 되었다.

만약 상태가 전이되기 전에 퇴장 함수를 호출해서 변경해야 할 사항들이 있다면 객체를 삭제하기 전에 호출하면 된다.

 

 

단점

엄격하게 제한된 구조를 강제한다.

미리 정의해둔 여러 개의 상태와 현재 상태 하나, 그리고 하드코딩되어있는 전이만이 존재한다.

하지만 이 구조가 단점이면서 장점이 될 수도 있다.

 

 

상태 기계의 단점 극복: 병행 상태 기계

FSM은 오로지 하나의 상태만 가져야 하기 때문에 두 가지 상태가 병행해야 하는 경우라면 두 가지 상태를 병행하는 한가지 새로운 상태를 정의해야 한다.

그런데 이렇게 구현하는 경우에는 행위에 대한 상태가 N개, 소유에 대한 상태가 M개 있다고 했을 때, N*M개의 상태를 정의해야 한다.

상태의 종류가 무수히 많아지는것 뿐만 아니라 중복 코드도 무수히 많아지는 문제가 발생한다.

이럴때는 상태 기계를 둘로 나누면 된다.

 

class Heroine {
public:
    void handleInput(Input input) {
        state_->handleInput(*this, input);
        equipment_->handleInput(*this, input);
    }
private:
    HeroineState* state_;
    HeroineState* equipment_;
};

 

개념이 심플한 만큼 사용하는것도 심플하다.

대신 각각의 상태기계가 독립적이고 연관성이 없다는 보장이 있어야만 문제가 생기지 않을것이다.

점프 상태에서는 칼을 휘두를수 있지만, 엎드린 상태에서는 칼을 휘두를수 없다고 한다면 서로의 상태를 검사해야 하는 지저분한 코드가 작성될 여지가 있다.

 

 

상태 기계의 단점 극복: 계층형 상태 기계

서기, 걷기, 달리기, 미끄러지기 이런 행동에서는 공통적으로 점프나 엎드리기 같은 상태로 전이하는 공통된 코드가 작성되기 마련이다.

공통되는 코드를 묶어서 상위 상태로 만들고, 하위 상태는 상속받아서 고유 동작을 추가해서 상위 상태를 호출하면 된다.

 

class OnGroundState : public HeroineState {
public:
    virtual void handleInputHGeroine& heroine, Input input) {
        if (input == PRESS_B) { /* 점프 */ }
        else if (input == PRESS_DOWN) { /* 엎드리기 */ }
    }
};

class DuckingState : public OnGroundState {
public:
    virtual void handleInputHGeroine& heroine, Input input) {
        if (input == RELEASE_DOWN) { /* 서기 */ }
        else {
            OnGroundState::handleInput(heroine, input); // 상위 상태 호출
            // 필요시 추가 동작 구현
        }
    }
};

 

만약 상속을 통한 계층 구조 설계가 불가능하다면 주 클래스에 단일 상태 대신 상태 스택을 만들어서 연쇄적으로 모델링 할 수 있다.

 

 

상태 기계의 단점 극복: 푸시다운 오토마타

계층형 상태 기계에서 마지막에 언급한 상태 스택과는 다른 방식으로 상태 스택을 활용하여 FSM을 확장시킨다.

FSM은 현재 상태는 알 수 있지만, 직전 상태가 무엇인지 저장하지 않기 때문에 이력 개념이 없다.

이게 문제가 되는 경우를 예를 들어보자면, 캐릭터가 총을 발사하면 발사 애니메이션 재생과 총알 및 이펙트를 생성하는 상태를 새로 만들어서 전이하게 된다.

이 때, 총을 발사할 수 있는 상태가 한 개라면 문제가 되지 않지만 두 개 이상이면 이력이 저장되어 있지 않아서 사격이 끝나고 어떤 상태로 돌아가는지 알 수 없는 문제가 발생한다.

 

일반적인 FSM에서는 각각의 새로운 상태를 만들고 사격이 끝났을 때 되돌아갈 상태를 하드코딩 해야한다.

하지만 푸시다운 오토마타는 상태를 한 개의 포인터로 관리하는 것이 아니라 스택으로 관리하기 때문에 사격이 끝나면 현재 상태를 스택에서 제거하고 바로 다음 상태로 전이하면 된다.

 

계층형 상태 기계에서의 상태 스택은 상태 관련 동작을 최상위 스택부터 전달해서 처리될때까지 하위 단계로 내려보내는 방식이고 푸시다운 오토마타에서의 상태 스택은 이력 저장을 위해 사용한다는 차이가 있다.

 

 

상태 기계의 유용성

FSM을 개선한다고 해도 한계가 존재하기 때문에 요즘 AI는 행동 트리 또는 계획 시스템을 더 많이 사용한다.

그렇다고 상태 기계가 쓸모없다는 것은 아니고 간단한 상태에서는 유용하게 사용할 수 있다.

 

◾ 내부 상태에 따라 객체 동작이 바뀔 때

◾ 이런 상태가 그다지 많지 않은 선택지로 분명하게 구분될 수 있을 때

◾ 객체가 입력이나 이벤트에 따라 반응할 때

 

위와 같은 경우에 사용하면 좋다.

싱글턴 (Singleton)

 

오직 한 개의 클래스 인스턴스만을 갖도록 보장하고, 이에 대한 전역적인 접근점을 제공합니다.

 

어떤 식으로 써야하는지를 알아야 하는 다른 패턴과는 반대로 어떤 식으로 안써야하는지를 아는것이 더 중요한 패턴이다.

 

 

◾ 오직 한 개의 클래스 인스턴스만 갖도록 보장

아무데서나 클래스 인스턴스 여러개를 만들 수 없어야 한다.

 

◾ 전역 접근점을 제공

하나의 인스턴스만 생성하는 것에 더해서 전역에서 접근할 수 있는 메서드를 제공한다.

 

class FileSyetem {
public:
    static FileSystem& instance() {
        if (instance_ == nullptr) { // 게으른 초기화
            instance_ = new FileSystem();
        }
        return *instance_;
    }
private:
    FileSystem() {}
    static FileSystem* instance_;
};

 

class FileSystem {
public:
    static FileSystem& instance() {
        static FileSystem* instance = new FileSystem();
        return *instance;
    }
private:
    FileSystem() {}
};

 

요즘은 이렇게도 만든다.

 

 

왜 사용하는가?

◾ 한 번도 사용하지 않는다면 아예 인스턴스를 생성하지 않는다

 

◾ 런타임에 초기화된다

정적 클래스의 경우 정적 멤버 변수는 사용하지 않더라도 자동으로 초기화가 이루어진다. 또한 초기화 순서도 보장되지 않기 때문에 다른 정적 변수에 안전하게 의존할 수 없다.

하지만 싱글턴은 게으른 초기화 덕분에 순환 의존이 존재하지 않으면 괜찮다.

 

◾ 싱글턴을 상속할 수 있다

만약 파일 시스템 래퍼가 크로스 플랫폼을 지원해야 하는 경우라면 추상 인터페이스를 만들고 플랫폼 별로 구체 클래스를 만들면 된다.

 

class FileSystem {
public:
    static FileSystem& instance() {
    #if PLATFORM == PLAYSTATION3
        static FileSystem* instance = new PS3FileSystem();
    #elif PLATFORM == WII
        static FileSystem* instance = new WiiFileSystem();
        
        return *instance;        
    }
    virtual ~FileSystem() {}
    virtual char* readFile(char* path) = 0;
    virtual void writeFile(char* path, char* contents) = 0;
protected:
    FileSystem() {}
};

class PS3FileSystem : public FileSystem {
public:
    virtual char* readFile(char* path) { /* PS3 파일 시스템 사용 */ }
    virtual void writeFile(char* path, char* contents) { /* PS3 파일 시스템 사용 */ }
};

class WiiFileSystem : public FileSystem {
public:
    virtual char* readFile(char* path) { /* Wii 파일 시스템 사용 */ }
    virtual void writeFile(char* path, char* contents) { /* Wii 파일 시스템 사용 */ }
};

 

 

왜 문제인가?

◾ 전역 변수는 코드를 이해하기 어렵게 한다

순수 함수는 해당 함수의 코드와 매개변수만 확인하면 된다. 하지만 전역 변수나 함수에 접근한다면 해당 전역 변수와 함수에 접근하는 모든 곳을 다 살펴봐야만 상황을 파악할 수 있다.

 

◾ 전역 변수는 커플링을 조장한다

 

◾전역 변수는 멀티스레딩 같은 동시성 프로그래밍에 알맞지 않다

모든 스레드가 볼 수 있기 때문에 일종의 공유자원이 되므로 교착상태 등의 스레드 동기화 버그가 발생할 수 있다.

 

◾ 싱글턴은 문제가 하나뿐일 때도 두 가지 문제를 풀려 든다

아래와 같은 두 가지 문제가 있다고 하자.

  1. 인스턴스를 한개로 강제할 뿐, 전역 접근을 허용하지 않는다.
  2. 인스턴스는 여러개일 수 있지만, 전역 접근이 허용된다.

 

보통 2번의 경우에 싱글턴 패턴을 선택한다. 예를 들어 로그를 기록하는 클래스가 있다.

처음에는 별 문제가 되지 않다가 프로젝트의 규모가 커지면서 각자 필요한 정보를 로그로 남기다 보면 로그 파일이 뒤죽박죽 섞이게 된다.

이 시기쯤 되어서 로거를 나누려고 해도 싱글턴이기 때문에 인스턴스를 하나밖에 만들지 못하는 설계 제약이 발목을 잡는다.

 

◾ 게으른 초기화는 제어할 수 없다

지연 기법은 대체적으로 좋은 선택이지만 게임에서는 예외사항이 있다.

예를 들어 오디오 시스템의 초기화 시점이 최초로 소리가 재생될때라면 전투 도중에 초기화가 이루어지는 바람에 순간적으로 프레임이 떨어질 수 있다.

또한 오디오 시스템이 상당한 양의 메모리가 할당된다면 힙 어디에 메모리를 할당할지 제어할 수 있어야 하기 때문에 무조건 게으른 초기화가 이루어져선 안되고 적절한 초기화 시점을 찾아야 한다.

 

 

대안

싱글턴 클래스가 꼭 필요한지를 고려해봐야 한다.

애매하게 다른 객체 관리용으로만 존재하는 Manager, System, Engine 등의 관리자 클래스 인스턴스가 존재한다면 안에 존재하는 기능들을 원래 클래스에 구현해 두는것이 더 좋을 수도 있다.

객체가 스스로를 챙기는것이 OOP이기 때문이다.

 

 

인스턴스가 한개만 존재하길 바라면서 전역 접근을 허용하고 싶지 않은 경우에는 싱글턴 대신 생성자에 단언문을 넣어서 제어할 수 있다.

 

class FileSystem {
public:
    FileSystem() {
        assert(!instantiated_); // 단언문
        instantiated_ = true;
    }
    ~FileSystem() {
        instantiated_ = false;
    }
private:
    static bool instantiated_;
};

bool FileSystem::instantiated_ = false;

 

인스턴스가 최초 생성될때는 문제가 없지만 두 개째 생성부터는 단언문에 의해 코드 실행이 중지된다.

단일 인스턴스는 보장하면서 클래스를 어떻게 사용할지에 대해서는 제약이 없다.

다만 기존 싱글턴이 컴파일 타임에 단일 인스턴스를 보장하는 반면, 위의 방식은 런타임에 인스턴스 개수를 확인한다.

 

 

객체를 전역이 아니어도 접근할 수 있는 방법에 대해서도 고민을 해보아야 한다.

 

◾ 넘겨주기

전역으로 접근하는 것 보다 함수의 매개변수로 받아서 접근하는게 더 쉽고 최선인 경우가 있을수도 있다.

매개변수로 받는 객체가 동일한 인터페이스를 제공하는 경우가 이에 해당한다.

 

◾ 상위 클래스로부터 얻기

class GameObject {
protected:
    Log& getLog() { return log_; }
private:
    static Log& log_;
};

class Enemy : public GameObject {
public:
    void doSomething() {
        getLog().write("I can log!");
    }
};

 

파생 객체들이 공통된 단일 인스턴스를 사용해야 하는 경우라면 기본 클래스에 정적 데이터를 정의함으로써 인스턴스를 얻을 수 있다.

 

◾ 이미 전역인 객체로부터 얻기

전역 상태를 모두 제거한다는 것은 사실상 매우 어렵다. 결국 전체 게임 상태를 관리하는 Game이나 World같은 전역 객체와 커플링 될수밖에 없기 때문이다.

그대신 어쩔수 없이 존재하는 전역 객체에 빌붙어서 전역 클래스 숫자를 줄일 수 있다.

 

class Game {
public:
    static Game& instance() { return instance_; }
    
    Log& getLog() { return *log_; }
    FileSystem& getFileSystem() { return *fileSystem_; }
    AudioPlayer& getAudioPlayer() { return *audioPlayer_; }
    ...
private:
    static Game instance_;
    Log *log_;
    FileSystem* fileSystem;
    AudioPlayer* audioPlayer;
};

 

기존에는 FileSystem, AudioPlayer 등의 클래스가 전역 객체로 개별적으로 존재해야 했지만, 기존에 존재하는 전역 클래스인 Game의 멤버로 들어감으로써 전역 클래스 개수를 줄인다.

그만큼 더 많은 코드가 Game 클래스와 커플링 된다는 단점은 존재한다.

 

 

추후 싱글턴 패턴을 대체할 수 있는 샌드박스 패턴, 서비스 중개자 패턴을 다룬다.

프로토타입 (Prototype)

 

원형이 되는 인스턴스를 사용하여 생성할 객체의 종류를 명시하고, 이렇게 만든 견본을 복사해서 새로운 객체를 생성합니다.

 

다른말로 원형이라고도 부른다.

 

 

예제

핵앤슬래시 게임을 만들다고 해보자. 몬스터들은 스포너를 통해 게임에 스폰되고 몬스터별로 스포너가 존재한다.

 

class Monster {};
class Ghost : public Monster {};
...

class Spawner {
public:
    virtual ~Spawner() {}
    virtual Monster* spawnMonster() = 0;
};

class GhostSpawner : public Spawner {
public:
    virtual Monster* spawnMonster() { return new Ghost(); }
};
...

 

만약 이런식으로 구현하게 된다면 몬스터의 종류가 늘어나면 스포너도 그에 맞춰서 늘어나기 때문에 코드의 중복도 발생하므로 비효율적이다.

하지만 프로토타입 패턴을 적용하면 자신과 비슷한 객체를 스폰(복제)할 수 있기 때문에 개별적인 스포너를 추가하지 않아도 된다.

 

class Monster {
public:
    virtual ~Monster() {}
    virtual Monster* clone() = 0; // 복제를 위한 순수가상함수 추가
};

class Ghost : public Monster {
public:
    Ghost(int health, int speed) : health_(health), speed_(speed) {}
    virtual Monster* clone() { return new Ghost(health_, speed_); }
private:
    int health_;
    int speed_;
};

class Spawner {
public:
    Spawner(Monster* prototype) : prototype_(prototype) {}
    Monster* spawnMonster() { return prototype->clone(); }
private:
    Monster* prototype_;
};

 

몬스터별로 스포너를 만드는 대신 몬스터의 기본 클래스에 복제를 위한 인터페이스를 추가한다.

몬스터들은 동일한 인터페이스를 제공하기 때문에 스포너는 스폰(복제)할 원본 객체를 보관했다가 필요할 때 인터페이스를 통해 스폰하면 된다.

스포너에 있던 인터페이스가 몬스터로 옮겨갔다고 보면 될것같다.

 

Monster* ghostPrototype = new Ghost(15, 3); // 원형 객체 생성
Spawner* ghostSpawner = new Spawner(ghostPrototype); // 앞으로 health 15, speed 3의 객체 복제

 

프로토타입 패턴의 장점은 클래스뿐만 아니라 상태도 같이 복제한다는 점이다. 원형 객체를 생성할 때 설정된 상태 그대로 복제한다.

 

그래도 결과적으로는 코드 양이 크게 줄어들지는 않는다.

또한 요즘은 몬스터 종류별로 클래스를 만들기보다는 컴포넌트나 타입객체로 모델링 하는것이 더 선호된다.

 

다른 구현 방법으로는 함수 포인터, 템플릿, 일급 자료형을 사용하는 방법이 있다.

 

 

데이터 모델링을 위한 프로토타입

게임에서는 데이터 모델을 정의할 때, 보통 JSON을 많이 사용한다.

 

{
    "이름": "고블린 보병",
    "기본체력": 20,
    "최대체력": 30,
    "내성": ["추위", "독"],
    "약점": ["불", "빛"]
}

{
    "이름": "고블린 마법사",
    "기본체력": 20,
    "최대체력": 30,
    "내성": ["추위", "독"],
    "약점": ["불", "빛"]
    "마법": ["화염구", "번개 화살"]
}

{
    "이름": "고블린 궁수",
    "기본체력": 20,
    "최대체력": 30,
    "내성": ["추위", "독"],
    "약점": ["불", "빛"]
    "공격방법": ["단궁"]
 }

 

키/값 구조로 위와 같이 정의되어 있을 것이다. 하지만 중복되는 부분이 매우 많다.

 

{
    "이름": "고블린 보병",
    "기본체력": 20,
    "최대체력": 30,
    "내성": ["추위", "독"],
    "약점": ["불", "빛"]
}

{
    "이름": "고블린 마법사",
    "프로토타입": "고블린 보병",
    "마법": ["화염구", "번개 화살"]
}

{
    "이름": "고블린 궁수",
    "프로토타입": "고블린 보병",
    "공격방법": ["단궁"]
 }

 

추상 프로토타입을 만드는 대신 기존의 객체를 위임함으로써 코드 중복을 대폭 줄일 수 있다.

 

{
    "이름": "참수의 마법검",
    "프로토타입": "롱소드",
    "보너스대미지": "20"
}

 

기존 아이템에 추가옵션이 붙는 아이템의 경우에도 동일하게 적용할 수 있다.

 

 

프로토타입은 결과적으로 기존 객체를 복제하거나, 추상 클래스를 만드는 대신 위임을 통해 코드의 중복을 회피하는 기법이라고 볼 수 있을것 같다.

+ Recent posts