[게임 프로그래밍 패턴] Chapter12 하위 클래스 샌드박스

Jangmanbo·2023년 11월 20일
0

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


슈퍼히어로 게임에는 수많은 초능력이 필요하다.

Superpower라는 상위클래스를 상속받는 수많은 초능력 클래스를 구현하고,
이 초능력 클래스가 사운드, 이펙트, AI 상호작용, 물리 작용 등의 일을 수행하게 한다.

단점
1. 중복 코드: 냉동 광선, 열 광선은 만들고 보면 거의 유사하다.
2. 거의 모든 게임 코드가 초능력 클래스와 커플링: 초능력 클래스와 직접 엮일 의도가 없던 하부 시스템을 바로 호출하도록 코드를 짜게 된다.
3. 외부 시스템 변경 시 초능력 클래스가 깨질 가능성: 초능력 외 게임 코드의 변경 시 초능력 클래스에 영향을 미친다.
4. 모든 초능력 클래스가 지켜야할 불변식 정의가 어려움: 예를 들어 초능력 클래스가 재생하는 모든 사운드를 항상 큐를 통해 우선순위를 맞추고 싶다. 이때 수백 개의 초능력 클래스가 사운드 엔진에 직접 접근한다면 이를 맞추기 쉽지 않다.

해결법
원시명령 집합을 제공한다. (ex. playSound, spawnParticles, ...)
->이제 더 이상 초능력 클래스는 이런저런 헤더를 include하지 않아도 된다.

원시명령은 하위 클래스용이므로 Superpower의 비가상 protected 메서드로 만들어 하위 클래스들이 접근 가능하도록 한다.
그리고 하위 클래스가 구현해야 하는 샌드박스 메서드를 protected 순수 가상 메서드로 만든다.

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

SuperPower 클래스는 여러 게임 시스템과 커플링되지만, 수많은 하위 클래스는 상위클래스하고만 커플링된다.
따라서 SuperPower 클래스만 수정하면 하위 클래스 모두에게 적용할 수 있다.



하위 클래스 샌드박스 패턴

  1. 상위 클래스는 추상 샌드박스 메서드와 여러가지 제공 기능(provided operation)을 정의
  2. 제공 기능은 protected 메서드로 만들어 하위 클래스용이라는 것을 명시
  3. 각 하위 클래스는 제공 기능을 이용해 샌드박스 메서드 구현

언제 쓸 것인가?

  • 클래스 하나에 하위 클래스가 많이 있음
  • 상위 클래스는 하위 클래스가 필요로 하는 기능을 전부 제공
  • 하위 클래스끼리 겹치는 행동이 많아, 하위 클래스끼리 쉽게 공유
  • 하위 클래스 사이의 커플링 및 하위 클래스와 나머지 코드와의 커플링 최소화

주의사항

상속을 하면 상위 클래스에 코드가 계속 쌓이게 되며, 특히 하위 클래스 샌드박스 패턴은 더욱 그럴 수 있다.
이럴 땐 컴포넌트 패턴을 사용하자.

깨지기 쉬운 상위 클래스(fragile base class)문제에 빠질 수 있다. 즉, 상위 클래스를 조금만 바꿔도 깨지기 쉽다.

장점

커플링이 상위 클래스에 몰려있기 때문에, 하위 클래스를 나머지 코드와 분리할 수 있다.
따라서 동작 대부분은 하위 클래스에 있어 유지보수가 쉽다.



하위 클래스 샌드박스 패턴 예제

class Superpower {
public:
    virtual ~Superpower(){}

protected:
    virtual void activate() = 0;
    void move(double x, double y, double z) {
        // ...
    }
    void playSound(soundId sound, double volume) {
        // ...
    }
    void spawnParticles(ParticleType type, int count) {
        // ...
    }
};

activate: 샌드박스 메서드. 순수 가상함수이기 때문에 하위클래스가 반드시 오버라이드 해야한다.
move, playSound, spawnParticles: 제공기능. 하위 클래스에서 activate 메서드를 구현할 때 호출한다.

class SkyLaunch : public Superpower {
protected: 
    virtual void activate() {
        // 하늘로 뛰어오르기
        playSound(SOUND_SPROING, 1.0f);
        spawnParticles(PARTICLE_DUST, 10);
        move(0, 0, 20);
    }
};

Superpower의 제공기능으로 샌드박스 메서드를 구현했다.
(사실 모든 초능력 클래스가 이렇게 move, playSound, spawnParticles의 조합으로만 되어있다면 하위 클래스 샌드박스 패턴을 사용할 필요가 없다.)

class Superpower {
protected:
    double getHeroX() { /* ... */ }
    double getHeroY() { /* ... */ }
    double getHeroZ() { /* ... */ }
    // ...
};

class SkyLaunch : public Superpower {
protected:
    virtual void activate() {
        if(getHeroZ() == 0) {
            // 땅이라면 공중으로 점프
            playSound(SOUND_SPROING, 1.0f);
            spawnParticles(PARTICLE_DUST, 10);
            move(0, 0, 20);
        }
        else if(getHeroZ() < 10.0f) {
            // 거의 땅에 도착했다면 이중 점프
            playSound(SOUND_SWOOP, 1.0f);
            move(0, 0, getHeroZ() - 20);
        }
        else {
            // 공중에 높이 떠 있다면 내려찍기 공격
            playSound(SOUND_DIVE, 0.7f);
            spawnParticles(PARTICLE_SPARKLES, 1);
            move(0, 0, -getHeroZ());
        }
    }
};

히어로 위치를 얻는 메서드를 추가해서 SkyLaunch 클래스를 구현했다.
이렇게 상태에 대해 접근할 수 있게 되면서, 샌드박스 메서드로 실제적인 제어 흐름을 만들 수 있게 되었다.



디자인 결정

어떤 기능을 제공해야 할까?

아마 일부 기능은 상위 클래스로부터 제공받고, 나머지 기능은 하위 클래스가 직접 접근하는 외부 시스템에서 제공받을 것이다.
제공 기능이 많을수록 하위 클래스는 외부 시스템과는 적게, 상위클래스와는 더 많이 커플링된다.

  • 몇몇 하위 클래스만 사용하는 제공 기능은, 혜택을 받는 하위 클래스는 적고 상위 클래스의 복잡도만 증가하여 좋지 않다.
  • 다른 시스템의 함수를 호출해도 그 함수가 상태를 변경하지 않는다면 안전한 커플링이다. 반대로 외부 시스템의 상태를 변경하는 함수를 호출한다는 것은 해당 시스템과 더 강하게 커플링됐다는 것. 즉, 상위 클래스의 제공 기능으로 만드는 것이 좋다.
  • 단순히 외부 시스템 함수를 호출하는 일은 하위 클래스에서 직접 하는 게 더 깔끔할 수 있다. 그러나 이런 단순한 포워딩 메서드도 하위 클래스에 특정 상태를 숨길 수 있다는 장점이 있다.
void playSound(SoundId sound, double volume) {
	soundEngine_.play(sound, volume);	// soundEngine_을 하위 클래스로부터 캡슐화
}

메서드를 직접 제공할 것인가? 이를 담고 있는 객체를 통해서 제공할 것인가?

1. 직접 제공

class Superpower {
protected:
    void playSound(SoundId sound, double volume) { /* ... */ }
    void stopSound(SoundId sound) { /* ... */ }
    void setVolume(SoundId sound, double volume) { /* ... */ }
    // ...
};

2. 객체를 통해서 제공

class SoundPlayer {
    void playSound(SoundId sound, double volume) { /* ... */ }
    void stopSound(SoundId sound) { /* ... */ }
    void setVolume(SoundId sound, double volume) { /* ... */ }
};

class Superpower {
protected:
    SoundPlayer& getSoundPlayer() {
        return soundPlayer_;
    }
    // ...

private:
    SoundPlayer soundPlayer_;
};

객체를 통해서 제공했을 때의 장점

  • 상위 클래스의 메서드 개수 감소
  • 보조 클래스에 있는 코드가 유지보수하기 더 쉬움
  • 상위 클래스와 다른 시스템과의 커플링을 낮춤

(이게 컴포넌트 패턴 아닌가..?)

상위 클래스는 필요한 객체를 어떻게 얻는가?

상위 클래스 멤버변수 중 하위 클래스로부터 캡슐화하고 싶은 데이터가 있을 수 있다. (ex. ParticleSystem 객체)

상위 클래스의 생성자로 받기

class Superpower {
public:
    Superpower(ParticleSystem* particles) : particles_(particles) {}
    // 샌드박스 메서드 등...

private:
    ParticleSystem* particles_;
};

class SkyLaunch : public Superpower {
public:
    SkyLaunch(ParticleSystem* particles) : Superpower(particles) {}
};

모든 하위 클래스 생성자는 ParticleSystem을 인수로 받아 상위 클래스 생성자에 전달해야 한다.
즉, 모든 하위 클래스에 노출된다.

또한 상위 클래스에 다른 상태를 추가하려면, 모든 하위 클래스 생성자도 수정해야 하기 때문에 유지보수에도 좋지 않다.

2단계 초기화

Superpower* power = new SkyLaunch();
power->init(particles);

SkyLaunch 클래스 생성자에는 인수가 없기 때문에, particles와 전혀 커플링되지 않는다.

Superpower* createSkyLaunch(ParticleSystem* particles) {
    Superpower* power = new SkyLaunch(); // 1단계 초기화
    power->init(particles);	// 2단계 초기화
    return power;
}

init을 반드시 호출해야 하기 때문에, 객체 생성 과정 전체를 하나의 함수로 캡슐화하자.
(SkyLaunch 생성자를 private으로 두고, createSkyLaunch에서만 SkyLaunch 객체를 생성할 수 있도록 friend class를 활용하면, 절대 init을 놓칠 일이 없다.)

정적 객체로 만들기

모든 초능력 인스턴스가 별도의 파티클 객체를 필요로 하는 게 아니라면(=싱글턴이라면), private 정적 멤버 변수로 만들자.
초기화는 초능력 클래스에서 한 번만 하면 된다.

class Superpower {
public:
	// 정적
    static void init(ParticleSystem* particles) {
        particles_ = particles;
    }
    // 샌드박스 메서드 등...

private:
	// 정적
    static ParticleSystem* particles_;
};

미리 Superpower::init() 한 번만 호출하면, 모든 초능력 인스턴스가 같은 파티클 시스템에 접근 가능하다.
또한 파티클 시스템 인스턴스가 하나만 존재하므로 메모리 사용량도 줄일 수 있다.

서비스 중개자를 이용하기

상위 클래스가 필요로 하는 객체를 잊지 말고 넣어주는 작업(초기화)을 외부 코드에 넘기고 있다.
서비스 중개자 패턴을 이용하면 상위 클래스가 원하는 객체를 직접 가져와 스스로 초기화할 수 있다.

class Superpower {
protected:
    void spawnParticles(ParticleType type, int count) {
        ParticleSystem& particles = Locator::getParticles();
        particles.spawn(type, count);
    }
    // 샌드박스 메서드 등...
};

spawnParticles: 파티클 시스템 객체를 외부 코드에서 전달받지 않고 직접 서비스 중개자 (Locator 클래스)에서 가져온다.

0개의 댓글