Home [게임 프로그래밍 패턴] Behavioral Patterns: Subclass Sandbox
Post
Cancel

[게임 프로그래밍 패턴] Behavioral Patterns: Subclass Sandbox

Subclass Sandbox

  • 상위 클래스가 제공하는 기능들을 통해서 하위 클래스에서 행동을 정의

동기


다양한 능력을 선택하는 게임

SuperPower라는 상위클래스 => 상속받는 클래스

하위클래스가 많아질 가능성이 높음.

바이트 코드 패턴 or 타입 객체 패턴을 사용하면 더 나음(데이터 기반)

하위 클래스인 초능력 클래스는 여러 코드를 건드리게됨

다수의 하위클래스: 단점

  • 중복 코드가 많아짐.
  • 거의 모든 게임 코드가 초능력 클래스와 커플링
    • 엮일 의도가 전혀 없던 하부 시스템(subsystem)을 바로 호출하도록 코드를 짤 수 있음
  • 외부 시스템이 변경되면 초능력 클래스가 깨질 가능성이 높다.
    • 여러 초능력 클래스가 게임 내 다양한 코드와 커플링
  • 모든 초능력 클래스가 지켜야 할 불변식(invariant)을 정의하기 어렵다.

원시 명령 집합 제공(a set of primitives)

  • 사운드 => playSound
  • 파티클 => spwanPaticles
  • 필요한 모든 기능들을 제공하면 초능력 클래스가 이런저런 헤더를 include하거나 다른 코드를 찾아 헤매지 않아도 된다.

  • 이러한 작업을 Superpower의 protected 메서드로 만들어 모든 하위 초능력 클래스에서 쉽게 접근하게 해야함

    • protected 또는 비-가상함수: 이들 함수가 하위 클래스 용이라는것을 알려주기 위해.

샌드 박스 메서드

  • 사용할 공간을 제공하기 위해 하위 클래스가 구현해야하는 샌드박스 메서드 => 순수 가상 메서드로 만들어 protected에 둔다.

  • 이제 초능력 클래스 구현은 다음과 같다.

    1. Superpower를 상속받는 새로운 클래스를 만든다.
    2. 샌드박스 메서드인 activate()를 오버라이드한다.
    3. Superpower 클래스가 제공하는 protected 메서드를 호출하여 activate()를 구현한다.

장점

  • 중복코드 문제 해결

    • 중복되는 코드가 있으면, 언제든지 Superpower클래스로 옮겨서 하위 클래스에서 재사용.

      리펙토링: pull up 기법이라고 부름.

  • 커플링 문제 해결
    • 커플링을 한곳으로 모음.
    • 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 뒤로 숨길 수 있다.
    • 하위 클래스 샌드박스 패턴에서 상위 클래스 == 전체 게임 코드를 하위 클래스로부터 솜겨주는 일종의 파사드처럼 동작

출처

sub-sandbox

This post is licensed under CC BY 4.0 by the author.

[게임 프로그래밍 패턴] Behavioral Patterns: Bytecode

[게임 프로그래밍 패턴] Behavioral Patterns: Type Object