Observer
- Model-View-Controller 아키텍처의 기반이 되는것이 Observer 패턴
MVC는 Smalltalkers에 의해 발명되었다.
Achievement Unlocked
- 게임에 업적 시스템을 추가한다고 가정.
- 게임의 업적은 게임 플레이의 다양한 측면에 의해 유발된다.
- 업적 코드를 모두에 연결하지 않고 작동: 옵저버 패턴이 필요한 이유
- 객체와 객체간의 관심사를 분리해야한다.
- 다리에 딸어지는 업적코드는 다음과 같이 구현할 수 있다.
- 물리 엔진이 알림을 주게하는것.
- 보낼 알람을 결정해야하므로 완전한 분리가 아님.
- 이 알림을 받아, 떨어지는 객체가 무엇인지, 이전의 장소가 어디였는지를 확인할 수 있다.
- 물리 엔진이 알림을 주게하는것.
1
2
3
4
5
6
7
8
9
10
void Physics::updateEntity(Entity& entity)
{
bool wasOnSurface = entity.isOnSurface();
entity.accelerate(GRAVITY);
entity.update();
if (wasOnSurface && !entity.isOnSurface())
{
notify(entity, EVENT_START_FALL);
}
}
- 이 알림은 누가 받든지 상관하지 않는다.
작동원리
The Observer
- 다른 객체가 흥미로운 작업을 수행할 때를 알려는 클래스가 필요하며, 인터페이스는 다음과 같다.
1
2
3
4
5
6
class Observer
{
public:
virtual ~Observer() {}
virtual void onNotify(const Entity& entity, Event event) = 0;
};
일반적인 매개변수는 알림을 보낸 객체와 다른 세부정보인 데이터이다.
- 업적 시스템은 다음과 같다.(엔티티에 무슨일이 일어났는지 열거형을 사용하여 하드 코딩함)
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
class Achievements : public Observer
{
public:
virtual void onNotify(const Entity& entity, Event event)
{
switch (event)
{
case EVENT_ENTITY_FELL:
if (entity.isHero() && heroIsOnBridge_)
{
unlock(ACHIEVEMENT_FELL_OFF_BRIDGE);
}
break;
// Handle other events, and update heroIsOnBridge_...
}
}
private:
void unlock(Achievement achievement)
{
// Unlock if not already unlocked...
}
bool heroIsOnBridge_;
};
The subject
- 대상:
- 알림 메서드는 관찰중인 객체에 의해 호출된다.
- observers의 리스트를 가지고 있어야한다.
- 알림을 보내야한다.
1
2
3
4
5
6
class Subject
{
private:
Observer* observers_[MAX_OBSERVERS]; // 실제 코드에선 동적 컬렉션을 사용하는것이 좋다.
int numObservers_;
};
- 대상은 해당 리스트를 수정하기 위해 API를 노출해야한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Subject
{
public:
void addObserver(Observer* observer)
{
// Add to array...
}
void removeObserver(Observer* observer)
{
// Remove from array...
}
// Other stuff...
};
이를 통해 알림 받는 객체를 제어할 수 있다.
- 대상은 observer와 상호작용하지만 커플링되어있지 않음.
리스트를 가지고 있는것은 중요하다.
- observer들이 서로 암묵적으로 연결되지 않도록한다.
- 하나의 observer: 하나를 추가하면 다른 하나는 비활성화 == 간섭이 일어남
- observer들이 서로 암묵적으로 연결되지 않도록한다.
알림은 다음과 같이 보낸다.
1
2
3
4
5
6
7
8
9
10
11
12
13
class Subject
{
protected:
void notify(const Entity& entity, Event event)
{
for (int i = 0; i < numObservers_; i++)
{
observers_[i]->onNotify(entity, event);
}
}
// Other stuff...
};
Observable physics
- 위에 작성한것을 물리 엔진에 붙일 수 있다.
1
2
3
4
5
class Physics : public Subject
{
public:
void updateEntity(Entity& entity);
};
실제 코드에서는 상속을 사용하지 않는다. 대상은 물리엔진이 아니라, “떨어지는 이벤트” 객체가 된다. observer는 다음과 같이 자신을 등록할 수 있다.
physics.entityFell().addObserver(this);
, 이것이 바로 “observer” 시스템과 “event” 시스템의 차이점 (전자는 흥미로운 일을 관찰, 후자는 흥미로운 일을 나타내는 대상을 관찰)
- 일부 인터페이스의 인스턴스에 대한 포인터 목록을 유지관리하는 하나의 클래스가 subject
성능
너무 느리다?
옵저버 패턴은 “events”, “messages”, “data binding” 라고 평가받는다.(부정적)
- 이러한 시스템 중 일부는 느릴 수 있다.(각 알림에 대해 대기열을 지정하는 등 동적 할당을 수행 하는 작업이 포함)
- 하지만 이 패턴은, 알림을 보내는 작업은 단순히 리스트를 보고 몇몇 가상 메서드를 호출하는 것이다.
- 정적으로 디스패치된 호출보다는 약간 느리지만, 성능이 가장 중요한 코드를 제외한 모든 코드에서 이 비용은 무시할 수 있다.
이 패턴은 hot code paths에 가장 적합하므로, 일반적으로 동적 디스패치를 감당할 수 있다.
- 그 외의 오버헤드는 없다.
- message를 위한 객체 할당이 없음
- 대기열이 없음
- 동기 메서드 호출에 대한 간접 참조일 뿐.
너무 빠르다?
- 동기식이기 때문에 주의해야한다.
- 대상은 옵저버를 직접 호출한다.
- 즉, 모든 옵저버의 알림 메서드를 호출할 때 까지 작업을 하지 않는다.
- 느린 옵저버는 대상을 멈추게 한다.
- 너무 느려질 경우 다른 스레드나 작업 대기열로 푸시해야한다.
- 하지만 스레딩 및 명시적 잠금을 혼합하는것은 주의해야한다.
- 교착상태(deadlock)가 일어날 수 있기 때문.
- 스레드가 많은 엔진에서는 Event Queue를 사용하는 비동기식 통신이 더 나을 수 있다.
너무 많은 동적할당?
- GC가 있는 managed language라도 메모리 할당은 중요하다.
많은 게임 개발자는 할당에 대해 덜 걱정하고, 조각화(fragmentation)에 대해 더 걱정한다. 게임이 지속적으로 실행되어야하는 경우 점점 파편화되가는 힙이 문제가 될 수 있다. Object Pool을 사용하면 이를 피할 수 있다.
- 실제 구현에서 옵저버 리스트는 동적으로 할당된 컬렉션으로 구현한다.
- 연결될 때만 메모리를 할당한다.
- 알림을 보내는데 메모리 할당은 일어나지 않는다.(단순한 메서드 호출)
- 게임 시작시 옵저버를 연결하는 것이 좋다.
Linked observers
- 동적할당자(vector) 없이 옵저버 추가 및 제거
- Interface는 구체적인 상태 저장 클래스보다 선호되므로 일반적으로 좋은것.
- 그러나 우리가 약간의 상태를 넣을 의향이 있다면,
- 할당문제를 해결할 수 있다.
- 별도의 리스트 대신, 링크드 리스트의 노드를 사용.
- 배열을 제거하고 헤드에 대한 포인터를 추가
1
2
3
4
5
6
7
8
9
10
class Subject
{
Subject()
: head_(NULL)
{}
// Methods...
private:
Observer* head_;
};
- Observer에 다음 Observer에 대한 포인터를 가지도록 확장한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
class Observer
{
friend class Subject;
public:
Observer()
: next_(NULL)
{}
// Other stuff...
private:
Observer* next_;
};
- 대상은 옵저버를 추가 및 제거하기 위한 API를 소유하지만, 관리할 리스트는 이제 Observer 클래스에 있다.
- 그 리스트를 건드릴 수 있게 friend로 subject를 삼는다.
- 새 옵저버를 등록하는 것은 리스트에 연결하는것이며, 쉬운 방법은 앞쪽에 삽입하는것이다.
- 뒤에 추가하는것보다 간단.
- 이 경우, 끝을 찾기위해 리스트를 탐색하거나 꼬리 포인터를 추가해야한다.
- 앞에 추가하는 것의 부작용: 가장 최근에 등록한 것이 먼저 알림을 받는다. (동일한 대상을 관찰하는 두 옵저버가 서로에 대한 순서 종속성이 없는것이 좋긴하다.)
- 뒤에 추가하는것보다 간단.
1
2
3
4
5
void Subject::addObserver(Observer* observer)
{
observer->next_ = head_;
head_ = observer;
}
- 제거는 다음과 같다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void Subject::removeObserver(Observer* observer)
{
if (head_ == observer)
{
head_ = observer->next_;
observer->next_ = NULL;
return;
}
Observer* current = head_;
while (current != NULL)
{
if (current->next_ == observer)
{
current->next_ = observer->next_;
observer->next_ = NULL;
return;
}
current = current->next_;
}
}
더블 포인터를 사용하면 더 깔끔하다.
- 알림은 다음과 같이 보낸다.
1
2
3
4
5
6
7
8
9
void Subject::notify(const Entity& entity, Event event)
{
Observer* observer = head_;
while (observer != NULL)
{
observer->onNotify(entity, event);
observer = observer->next_;
}
}
전체 목록을 살펴보고, 그 안의 모든 옵저버에게 알림. 이렇게 하면, 모든 옵저버가 동일한 우선순위를 가지며, 서로 독립적이다.
옵저버가 알림을 받으면 계속 탐색하는지 멈춰야하는지를 나타내는 플래그를 반환할 수 있도록 조정할 수 있다 => 책임사슬 패턴에 근접
- 장점:
- 대상이 동적메모리를 가지지 않고
- 원하는 만큼 옵저버를 가질 수 있고,
- 등록 및 등록 취소가 배열만큼 빠르다.
- 단점:
- 옵저버는 한 번에 하나의 대상만 관찰할 수 있다.
- 각 대상이 배열을 가지면, 옵저버가 동시에 둘 이상의 리스트에 있을 수 있기 때문.
- 하지만, 대상이 여러 옵저버를 갖는것이 더 일반적이다.
A pool of list nodes
- 노드가 옵저버 자체가 아닌것.
- 옵저버에 대한 포인터를 가진 리스트 노드 객체
여러 노드가 모두 같은 옵저버를 가리킬 수 있기 때문에
- 옵저버는 동시에 둘 이상의 대상 리스트에 포함되어 있을 수 있다.
앞에서 본 링크드 리스트와는 다르게 이제 대상을 동시에 관찰할 수 있다.
링크드 리스트는 두가지 형태이다. 데이터가 포함된 노드 객체(앞서 본 예제)형태 그리고, “intrusive” 링크드 리스트로 침입 리스트를 덜 유연하게 하지만 효율적 (리스트에서 객체를 사용하는 것이 해당 객체 자체의 정의를 침해, Linux 커널과 같은 곳에서 인기있음.)
- 동적 할당을 피하는 방법은 간단하다.
- 모든 노드의 크기와 타입이 동일하기 때문에, 해당 노드의 오브젝트 풀을 미리 할당하면, 필요에 의해 재사용할 수 있다.
Remaining Problems
옵저버 패턴은 정확하고 효율적으로 구현하더라도 올바른 솔루션이 아닐 수 있다.
두 문제가 남아 있다.
- 기술적인것
- 유지관리
기술적인것: Destroying subjects and observers
- 대상이나 옵저버를 삭제할 경우
- 포인터… 할당 해제된 메모리에 대한 댕글링 포인터 문제
- 대부분 구현에서 옵저버가 대상에 대한 참조를 가지고 있지 않기 때문에, 대상을 파괴하는 것은 더 쉽다.
- 하지만 그래도 문제가 발생할 수 있다.
- 남아 있는 옵저버들은 알림을 대기하며, 알림이 일어나지 않을 것이라는 사실을 모른다.
- 해결방법
- 어떤 대상에서든 옵저버를 등록해제 하는 작업은 옵저버가
- 옵저버는 관찰중인 대상을 알고 있으므로 일반적으로 소멸자에 대한 호출을 추가하면 된다.
- 대상이 파괴되는 직전에 파괴된다는 알림을 보내도록하고,이를 받는 옵저버에서 적절히 처리하면된다.
- 더 안전한 방법은 옵저버가 파괴될 때 모든 대상에서 자동으로 옵저버를 등록취소하도록 하는것.
- 약간의 복잡성이 생긴다.
- 각 옵저버가 관찰대상의 리스트가 필요하다는것을 의미한다.
- 어떤 대상에서든 옵저버를 등록해제 하는 작업은 옵저버가
Don’t worry, I’ve got a GC
- GC가 있더라도, 참조가 제거되지 않으면 가비지가 누적된다.
- lapsed listener problem 이라 부르는 것.
- 등록 취소의 필요성
What’s going on?
의도한 목적의 직접적인 결과
- 두 코드 조각 간의 결합을 느슨하게 한다.
- 간접적으로 통신.
- 만약 옵저버 체인에 버그가 있을 경우, 런타임에 옵저버를 확인해야한다.
- 정적 추론 대신에, 명령적이고 동적인 행동에 대해 추론해야한다.
- 이를 대처하는 방법: 프로그램의 일부를 이해하기 위해 양쪽에 대해 자주 생각해야하는 경우 옵저버 패턴을 사용하지 않으면 된다.
- 다른 하나에 대한 지식이 적어도 다른 하나에 대해 작업을 할 수 있을 경우에만 옵저버 사용.
- 관찰자 패턴은 대부분 관련이 없는 덩어리들을 하나의 큰 덩어리로 병합하지 않고 서로 대화할 수 있도록 하는 좋은 방법이다.
- 하나의 기능에 전념하는 단일 코드 덩어리 안에서는 덜 유용
Observers Today
대부분 이제 주로 함수형 프로그래밍에 익숙해졌음.
- 알림을 받기 위해 전체 인터페이스를 구현해야 하는 것은 오늘날에 맞지 않음
- 서로 다른 대상에 대해 서로 다른 알림 방법을 사용하는 단일 클래스를 가질 수 없다.
- 여러 대상을 관찰할 경우, 어느쪽이 호출했는지 알 수 있어야한다.
- 현대적인 접근방식: 옵저버가 한 메서드나 함수에 대한 참조
- 일급함수가 있는 언어, 클로저가 있는 언어에서는 이것이 일반적인 방법이다.
- 클래스 기반이 아닌 함수 기반으로 만드는것이 좋다.
- C++에서도 옵저버를 멤버 함수 포인터를 등록할 수 있는 시스템이 더 좋다.
- 구현예시
Observers Tomorrow
이벤트 시스템, 옵저버 패턴과 유사한 패턴은 오늘날 매우 일반적이다.
- 옵저버의 많은 코드는 동일하게 보일 수 있다.
- 일부 상태가 변경되었다는 알림
- 새 상태를 반영하도록 일부 UI 청크를 불가피하게 수정
최근의 많은 애플리케이션 프레임워크는 “데이터 바인딩”을 사용한다.
급진적인 모델과 달리 데이터 바인딩은 명령형 코드를 완전히 제거하려고 하지 않으며, 거대한 선언적 데이터 플로우 그래프를 중심으로 전체 애플리케이션을 설계하지 않는다.
- UI요소 또는 계산된 속성을 조정하여 일부 값에 대한 변경 사항을 반영하는 바쁜 작업을 자동화하는것.
- 다른 선언적 시스템과 마찬가지로 데이터 바인딩은 게임 엔진 코어에 맞추기에는 너무 느리고 복잡하다.
- UI와 같은 성능에 덜 민감한 분야에서는 사용할 수 있다.