Subclass Sandbox
- 상위 클래스가 제공하는 기능들을 통해서 하위 클래스에서 행동을 정의
동기
다양한 능력을 선택하는 게임
SuperPower라는 상위클래스 => 상속받는 클래스
하위클래스가 많아질 가능성이 높음.
바이트 코드 패턴 or 타입 객체 패턴을 사용하면 더 나음(데이터 기반)
하위 클래스인 초능력 클래스는 여러 코드를 건드리게됨
다수의 하위클래스: 단점
- 중복 코드가 많아짐.
- 거의 모든 게임 코드가 초능력 클래스와 커플링
- 엮일 의도가 전혀 없던 하부 시스템(subsystem)을 바로 호출하도록 코드를 짤 수 있음
- 외부 시스템이 변경되면 초능력 클래스가 깨질 가능성이 높다.
- 여러 초능력 클래스가 게임 내 다양한 코드와 커플링
- 모든 초능력 클래스가 지켜야 할 불변식(invariant)을 정의하기 어렵다.
원시 명령 집합 제공(a set of primitives)
- 사운드 => playSound
- 파티클 => spwanPaticles
필요한 모든 기능들을 제공하면 초능력 클래스가 이런저런 헤더를 include하거나 다른 코드를 찾아 헤매지 않아도 된다.
이러한 작업을 Superpower의
protected
메서드로 만들어 모든 하위 초능력 클래스에서 쉽게 접근하게 해야함- protected 또는 비-가상함수: 이들 함수가 하위 클래스 용이라는것을 알려주기 위해.
샌드 박스 메서드
사용할 공간을 제공하기 위해 하위 클래스가 구현해야하는 샌드박스 메서드 => 순수 가상 메서드로 만들어 protected에 둔다.
이제 초능력 클래스 구현은 다음과 같다.
- Superpower를 상속받는 새로운 클래스를 만든다.
- 샌드박스 메서드인 activate()를 오버라이드한다.
- Superpower 클래스가 제공하는 protected 메서드를 호출하여 activate()를 구현한다.
장점
중복코드 문제 해결
- 중복되는 코드가 있으면, 언제든지 Superpower클래스로 옮겨서 하위 클래스에서 재사용.
리펙토링: pull up 기법이라고 부름.
- 중복되는 코드가 있으면, 언제든지 Superpower클래스로 옮겨서 하위 클래스에서 재사용.
- 커플링 문제 해결
- 커플링을 한곳으로 모음.
- Superpower 클래스 == 여러 게임 시스템과 커플링
- 하지만, 수 많은 하위 클래스는 상위 클래스와만 커플링됨.
- 게임 시스템이 변경될 때 Superpower클래스를 고쳐야함.
- 상속 구조가 얇게 퍼진다.
- 많은 클래스가 같은 상위 클래스를 둠 => 전략적 요충지 확보 가능
- Superpower 클래스에 집중 투자
깊은 상속 구조보다는 얇고 넓은 상속 구조가 좋음.
The pattern
상위 클래스: 추상 샌드박스 메서드와 여러 제공 기능을 정의.
제공 기능: protected로 만들어서 하위 클래스용이라는 걸 분명히 한다.
When to Use It
굉장히 단순하고 일반적이라 게임이 아닌 곳에서도 사용.
클래스 protected인 비-가상 함수가 있다면, 이 패턴을 사용하고 있을 가능성이 높다.
- 클래스 하나에 하위 클래스가 많이 있을 경우 사용
- 상위 클래스는 하위 클래스가 필요한 기능을 전부 제공 가능.
- 하위 클래스 행동 중에 겹치는 게 많아, 이를 하위 클래스끼리 쉽게 공유하고 싶을 경우.
- 하위 클래스들 사이의 커플링 및 하위 클래스와 나머지 코드와의 커플링을 최소화하고 싶을 경우.
주의 사항
상속 == 코드가 계속 쌓이는 경향이 있음.
- 게임 엔진 아키텍처에서는 ‘버블업 효과’라고 부른다.
- 하위 클래스는 상위 클래스를 통해서 나머지 게임 코드에 접근함.
- 상위 클래스가 하위 클래스에서 접근해야 하는 모든 시스템과 커플링됨.
- 이런 커플링관계 => 상위 클래스를 조금만 바꿔도 어딘가가 깨지기 쉽다.
깨지기 쉬운 상위 클래스(fragile base class) 문제에 빠짐
- 상위 코드가 거대해지면, 제공 기능 일부를 별도 클래스로 뽑아내 책임을 나눠 갖게 할 수 있음.
- 이 때 컴포넌트 패턴 유용
장점
- 하위 클래스를 나머지 코드와 깔끔하게 분리
- 이상적이라면 동작 대부분이 하위 클래스에 있을것.
- 많은 코드가 격리되어 있으면 유지보수 하기 쉽다.
예제 코드
- 의도가 중요.
예제: 제어 흐름을 만드는데 유용
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Superpower
{
public:
virtual ~Superpower() {}
protected:
virtual void activate() = 0;
void move(double x, double y, double z)
{
// Code here...
}
void playSound(SoundId sound, double volume)
{
// Code here...
}
void spawnParticles(ParticleType type, int count)
{
// Code here...
}
};
activate: 샌드박스 메서드, 순수 가상 함수
- 초능력 클래스 구현 작업 위치
- 나머지 protected 메서드: 제공기능, activate메서드를 구현할 경우 사용
- Superpower 클래스에서만 다른 시스템에 접근함, Superpower 안에 모든 커플링을 캡슐화
- 거미에게 점프 기능 부여
1
2
3
4
5
6
7
8
9
10
11
class SkyLaunch : public Superpower
{
protected:
virtual void activate()
{
// Spring into the air.
playSound(SOUND_SPROING, 1.0f);
spawnParticles(PARTICLE_DUST, 10);
move(0, 0, 20);
}
};
- 점프 능력: 소리 + 흙먼지 + 높이 뛰어오름
- 모든 능력 클래스가 이런 조합만으로 되어 있다면, 이 패턴을 사용할 필요는 없음(동작은 같으면서 데이터만 다를 때)
- 정해진 동작만 하도록 activate()를 구현하고, 능력별로 다른 사운드 ID, 파티클, 움직임을 사용하게 만들면된다.
- 모든 능력 클래스가 이런 조합만으로 되어 있다면, 이 패턴을 사용할 필요는 없음(동작은 같으면서 데이터만 다를 때)
- 더 정교한 코드는 다음과 같다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Superpower
{
protected:
double getHeroX()
{
// Code here...
}
double getHeroY()
{
// Code here...
}
double getHeroZ()
{
// Code here...
}
// Existing stuff...
};
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
class SkyLaunch : public Superpower
{
protected:
virtual void activate()
{
if (getHeroZ() == 0)
{
// On the ground, so spring into the air.
playSound(SOUND_SPROING, 1.0f);
spawnParticles(PARTICLE_DUST, 10);
move(0, 0, 20);
}
else if (getHeroZ() < 10.0f)
{
// Near the ground, so do a double jump.
playSound(SOUND_SWOOP, 1.0f);
move(0, 0, getHeroZ() + 20);
}
else
{
// Way up in the air, so do a dive attack.
playSound(SOUND_DIVE, 0.7f);
spawnParticles(PARTICLE_SPARKLES, 1);
move(0, 0, -getHeroZ());
}
}
};
- 어떤 상태에 대해 접근할 수 있기 때문에, 제어 흐름을 만들 수 있게 됨.
디자인 결정
- 상당히 소프트한 패턴.
중요: 어떤 기능을 제공해야하나?
- 기능을 적게 제공하는 방향 맨 끝
- 상위 클래스에 제공 기능은 전혀 없음.(샌드박스 메서드 하나만있음)
- 하위 클래스에서는 상위 클래스가 아닌 외부 시스템을 직접 호출해야함.
- 모든 기능을 상위 클래스에서 제공
- 하위 클래스: 상위 클래스와만 커플링됨, 외부 시스템 접근 x(#include 상위 클래스 헤더 파일 딱 하나)
- 양 극단 사이
- 적절한 외부 시스템, 상위 클래스의 제공 기능들
- 제공 기능이 많음: 상위 클래스와 더 많이 커플링.
- 많은 하위 클래스가 일부 외부 시스템과 커플링되어 있다면,
- 커플링을 상위 클래스의 제공 기능으로 옮김으로써 커플링을 상위 클래스 한 곳에 모아둘 수 있다는 장점이 있다.
- 그럴수록 상위 클래스는 커지고 유지보수 힘들어짐
일반적인 원칙
제공기능을 몇 안되는 하위 클래스에서만 사용한다면 별 이득은 없다.
- 상위 클래스의 복잡도는 증가하는 반면, 혜택을 받는 클래스는 몇 안됨.
- 그냥 외부 시스템에 직접 접근하는 것이 더 간단 명료
- 다른 시스템의 함수를 호출할 때에도 그 함수가 상태를 변경하지 않는다면 크게 문제가 되지않음.
- 안전한 커플링
기술적으로는 데이터 읽기만으로 문제 발생 가능, 게임 상태가 엄격하게 결정적일 때도 까다로움(플레이어 상태 동기화해야하는 경우), 비결정적인 버그에 노출될 수 있다.
- 외부 시스템의 상태를 변경하는 함수 사용 ==> 그 시스템과 더 강하게 결합된다는 점을 좀 더 분명히 인지해야함. => 상위 클래스 제공기능으로 옮겨 눈에 잘 들어오게
- 안전한 커플링
- 제공 기능이 단순한 외부 시스템으로 호출을 넘겨주는 일밖에 하지 않는다면 있어봐야 좋을 게 없다.
- 하위 클래스에서 외부 메서드 직접 호출하는 게 더 깔끔
- 단순 포워딩만해도, 하위 클래스에 특정 상태를 숨길 수 있다는 장점이 있음.
1 2 3 4
void playSound(SoundId sound, double volume) { soundEngine_.play(sound, volume); }
- 위 함수는 포워딩만 하지만, 함부로 soundEngine_에 하위 클래스에서 접근할 수 없도록 캡슐화함.
메서드를 직접 제공? 객체를 통해서 제공?
- 하위 클래스 샌드박스 패턴의 골칫거리: 상위 클래스의 메서드 수가 끔찍하게 늘어난다는 점.
- 다른 클래스로 이전하면 완화 가능
예제: 다른 클래스 추가
- 예를 들어 초능력을 사용할 때 사운드를 내기 위해 Superpower 클래스에 메서드 직접 추가하는 방법이 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Superpower
{
protected:
void playSound(SoundId sound, double volume)
{
// Code here...
}
void stopSound(SoundId sound)
{
// Code here...
}
void setVolume(SoundId sound)
{
// Code here...
}
// Sandbox method and other operations...
};
- 여기서 사운드 기능을 모아 이를 제공하는 클래스를 만들 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class SoundPlayer
{
void playSound(SoundId sound, double volume)
{
// Code here...
}
void stopSound(SoundId sound)
{
// Code here...
}
void setVolume(SoundId sound)
{
// Code here...
}
};
- 이 객체에 접근하도록 해야함.
1
2
3
4
5
6
7
8
9
10
11
12
13
class Superpower
{
protected:
SoundPlayer& getSoundPlayer()
{
return soundPlayer_;
}
// Sandbox method and other operations...
private:
SoundPlayer soundPlayer_;
};
- 보조 클레스로 옮기는것의 장점
- 상위 클래스의 메서드 개수 줄임.
- 보조 클래스에 있는 코드를 유지보수하기 더 쉬움.
- 상위 클래스와 다른 시스템과의 커플링을 낮춤.
상위 클래스는 필요한 객체를 어떻게 얻는가?
- 상위 클래스 멤버 변수 중 캡슐화하고 하위 클래스로부터 숨기고 싶은 데이터를 얻는 방법?
상위 클래스의 생성자로 받기
- 상위 클래스의 생성자 인수로 받으면 가장 간단.
1
2
3
4
5
6
7
8
9
10
11
12
class Superpower
{
public:
Superpower(ParticleSystem* particles)
: particles_(particles)
{}
// Sandbox method and other operations...
private:
ParticleSystem* particles_;
};
- 생성될 때 파티클 시스템 객체를 참조하도록 강제.
- 하지만 하위 클래스에서 문제.
1
2
3
4
5
6
7
class SkyLaunch : public Superpower
{
public:
SkyLaunch(ParticleSystem* particles)
: Superpower(particles)
{}
};
모든 하위 클래스 생성자는 파티클 시스템을 인수로 받아 상위 클래스 생성자에 전달해야함.
- 원치않게 상위 클래스의 상태가 노출됨.
상위 클래스에 다른 상태를 추가하려면 하위 클래스 생성자도 해당 상태를 전달하도록 전부 바꿔야함.(유지보수 취약)
2단계 초기화
- 초기화를 2단계로 나누면 생성자로 모든 상태를 전달하는 번거로움을 피할 수 있다.
- 생성자는 매개변수를 받지 않고 그냥 객체를 생성한다.
- 그 후 상위 클래스 메서드를 따로 실행해 필요한 데이터를 제공한다.
1
2
Superpower* power = new SkyLaunch();
power->init(particles);
- private으로 숨겨놓은 멤버 변수와 전혀 커플링되지 않음.
- init()를 무조건 호출해야 한다는 문제가 발생
- 객체 생성 과정 전체를 한 함수로 캡슐화 하면 해결.
1
2
3
4
5
6
Superpower* createSkyLaunch(ParticleSystem* particles)
{
Superpower* power = new SkyLaunch();
power->init(particles);
return power;
}
?? 생성자를 private에 두고 friend 클래스를 잘 활용하면 createSkyLaunch()에서만 SkyLaunch 객체를 생성할 수 있도록 보장할 수 있다. ???
정적 객체로 만들기
- 상태를 상위 클래스의 private 정적 멤버 변수로 만들 수 있음.
- 한 번한 초기화 하면 됨.
- 어떤 상태가 많은 객체에 공유되기 때문에 싱글턴의 여러 단점이 따라옴.
- 같은 객체를 여러 인스턴스가 건드려서 코드가 복잡해짐
1
2
3
4
5
6
7
8
9
10
11
12
13
class Superpower
{
public:
static void init(ParticleSystem* particles)
{
particles_ = particles;
}
// Sandbox method and other operations...
private:
static ParticleSystem* particles_;
};
- 하위 클래스 생성자만 호출하면 Superpower인스턴스를 그냥 만들 수 있음.
- particles_가 정적 변수이기 때문에 메모리 사용량을 줄임.
서비스 중개자 이용
앞에서는 상위클래스가 필요로 하는 객체를 먼저 넣어주는 작업을 밖에서 잊지말고 해줘야 했음.
- 초기화 부담을 외부 코드에 넘기고 있었음.
만약 상위 클래스가 원하는 객체를 직접 가져올 수 있으면 스스로 초기화 가능.
서비스 중개자 패턴
1
2
3
4
5
6
7
8
9
10
11
class Superpower
{
protected:
void spawnParticles(ParticleType type, int count)
{
ParticleSystem& particles = Locator::getParticles();
particles.spawn(type, count);
}
// Sandbox method and other operations...
};
spawnParticles()
는 필요로 하는 파티클 시스템 객체를 외부 코드에서전달받지
않고 직접 서비스 중개자(Locator 클래스)에서 가져온다.
관련자료
- 업데이트 메서드 패턴: 업데이트 메서드는 흔히 샌드박스 메서드임.
이와 상반되는 패턴: 템플릿 메서드 패턴, 둘다 원시 명령들(a set of primitive operations)로 메서드를 구현,
- 샌드박스 메서드 == 하위 클래스에서 구현, 원시명령(primitive operations) == 상위 클래스에 있음
- 템플릿 메서드 == 상위 클래스에 있음, 원시명령(primitive operations) == 하위 클래스 구현
- GOF의 파사드(facade) 패턴의 일종
- 여러 다른 시스템을 하나의 단순화된 API 뒤로 숨길 수 있다.
- 하위 클래스 샌드박스 패턴에서 상위 클래스 == 전체 게임 코드를 하위 클래스로부터 솜겨주는 일종의 파사드처럼 동작