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

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

Type Object

  • 단일 클래스를 각 인스턴스가 다른 타입 객체형으로 표현할 수 있게 만든다.
    • 이를 통해 새로운 ‘classes’을 유연하게 생성할 수 있음.

동기

  • 다양한 몬스터 구현
    • 체력 + 공격 + 그래픽 + 사운드 등 속성
    • attack string 속성
    • 종족(breed) 속성 (체력, attack string 결정한다고 가정)

전형적인 OOP방식

OOP 몬스터 예제: 구현할 몬스터가 많아지면..
  • 여러 종족들은 모두 몬스터이다.
  • 객체지향 방식 => Monster라는 상위 클래스를 만드는게 자연스러움
    • 즉, is-a관계(상속말고도 방법이 있음)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Monster
{
public:
  virtual ~Monster() {}
  virtual const char* getAttack() = 0;

protected:
  Monster(int startingHealth)
  : health_(startingHealth)
  {}

private:
  int health_; // Current health.
};
  • getAttack(): 공격할 때 보여줄 문구를 반환한다.
    • 하위클래스가 오버라이드(override)해서 다른 공격 문구를 보여줌
  • 생성자: protected, 체력을 받음. 하위클래스인 종족에서 최대 체력을 전달

  • 아래는 종족을 표현한 하위클래스
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Dragon : public Monster
{
public:
  Dragon() : Monster(230) {}

  virtual const char* getAttack()
  {
    return "The dragon breathes fire!";
  }
};

class Troll : public Monster
{
public:
  Troll() : Monster(48) {}

  virtual const char* getAttack()
  {
    return "The troll clubs you!";
  }
};

  • 이렇게 클래스들을 구현하면, 하위 클래스가 많아진다.

문제점

  • 몬스터 수백 종을 만드면, 작업이 느려지게 됨.
    • 몇 줄 안되는 Monster 하위 클래스를 작성 후 컴파일 해야함.
  • 작업 플로우: 기획자의 수정 요청 => 헤더파일 체크아웃 후 수정 => 컴파일 => 변경사항 체크인 => 기획자에게 답장
    • 이를 반복함.
  • 프로그래머들은 생각없이 데이터만 입력하게됨
    • 기획자들은 숫자 몇개만 바꾸는 데에도 하루 종일 걸리는 것에 좌절
    • 종족 상태 값은 게임 코드를 빌드하지 않고도 변경가능해야한다.
    • 기획자가 스스로 새로운 종족을 만들고 값 또한 수정할 수 있어야한다.

클래스를 위한 클래스

  • 해결하는 간단한 방법: 게임에 몬스터가 여러 종 => 몇몇 속성은 여러 몬스터가 공유하게

  • 몬스터마다 종족에 대한 정보를 두는 것.

    • 종족마다 Monster 클래스를 상속받게 하지 않고, Monster 클래스 하나와 종족(Breed) 클래스 하나만 만든다.
    • 그리고 Monster에서 Breed를 참조하게(몬스터와 종족을 결합)

  • 이제 클래스 두 개만으로 해결 가능하다.
    • 모든 몬스터를 Monster 클래스의 인스턴스로 표현할 수 있다.
    • Breed 클래스에는 공유하는 정보가 들어있다.
    • Breed 클래스 == 몬스터의 ‘타입’을 정의 (타입 객체 패턴이라는 이름의 이유)

타입 객체 패턴

  • 코드 수정 없이 새로운 타입을 정의할 수 있다는 게 장점.

    • 런타임에 정의할 수 있는 데이터로 옮긴것.
  • Breed 인스턴스를 만들어 다른 값 입력 == 또 다른 종족 생성

    • 설정 파일에서 읽은 데이터로 종족 객체를 생성 == 데이터만으로 전혀 다른 몬스터 정의 가능

The pattern

  • type object 클래스와 typed object 클래스를 정의해야함. (타입 객체와 타입 사용 객체)

  • 타입 객체: 논리적으로 다른 타입 의미
    • 개념적으로 같은 타입끼리 공유하는 데이터나 동작 저장
  • 타입 사용 객체: 자신의 타입을 나타내는 타입 객체를 참조

    • 인스턴스별로 다른 데이터는 타입 사용 객체 인스턴스에 저장
  • 같은 타입 객체를 참조 == 같은 타입인 것처럼 동작
    • 상속 처리를 하드 코딩하지 않고 마치 상속받는 것처럼 비슷한 객체끼리 데이터나 동작을 공유.

언제 사용?

  • 타입 객체 패턴: 다양한 종류를 정의해야하는데, 개발 언어의 타입 시스템이 유연하지 않아 코드로 표현하기 어려울 경우 적합.

  • 나중에 어떤 타입이 필요할지 알 수 없음.(새로운 몬스터)

  • 컴파일이나 코드 변경 없이 새로운 타입을 추가하거나 타입을 변경하고 싶을 경우.

주의 사항

  • 타입 객체 패턴의 핵심
    • 표현력은 감소, 유연성은 증가, 데이터로 ‘타입’표현

타입 객체 수동 추적

  • C++의 타입 시스템

    • 컴파일러가 클래스를 위한 일들을 알아서 해줌.

      C++ 가상함수: 내부적으로 vtable로 구현

    vtable은 단순한 구조체에 클래스의 가상 함수들을 함수 포인터를 저장해놓은 것.

    가상함수 호출할 경우, 먼저 객체로부터 vtable을 찾은 후 vtable에 저장된 함수 포인터를 찾아 호출한다.

    vtable == 종족 객체, vtable에 대한 포인터 == 몬스터에 있는 종족 객체 레퍼런스

    C++클래스 == 컴파일러가 C언어 내부적으로 타입 객체 패턴을 적용한 것

    • 각각의 클래스를 정의하는 데이터: 컴파일될 때 자동으로 실행 파일의 정적 메모리 영역에 들어가 동작.
  • 타입 객체 패턴에서는, 몬스터 인스턴스와 타입 객체를 직접 관리해야한다.
    • 타입 객체를 필요로 하는 객체가 있다면, 메모리에 계속 유지해야함.
    • 몬스터 생성시 알맞은 레퍼런스 초기화
  • 컴파일러가 해주던 일을 직접 구현해야함.

타입별로 동작을 표현하기 어려움

  • 상속 방식 => 메서드 오버라이드 => 코드로 값계산, 다른 코드 호출

  • 타입 객체 패턴 => 종족 객체 변수에 값을 저장하는 방식

    • 데이터‘는 정의하기 쉽지만 ‘동작‘을 정의하기는 어려움

    • ex. 다른 AI알고리즘 적용

  • 간단한 해결방법:
    • 미리 동작 코드 여러개 정의
    • 타입 객체가 적당한 함수 포인터를 저장하게 하면 타입 객체를 AI알고리즘과 연계 가능.(타입 객체에서 vtable 구현)
    • 데이터만으로 동작을 정의할 수도 있다.
  • 바이트코드 패턴 or 인터프리터 패턴 => 데이터로 동작 표현
    • 파일에서 데이터 읽고, 이들 패턴으로 자료구조 생성, 동작 정의

      게임은 점점 데이터 주도 방식으로 변화, 하드웨어 최적화보다 콘텐츠를 얼마나 만들 수 있는가에 따른 제약을 받음.

요즘 게임은 게임플레이를 가득 채우는게 일

스크립트 언어 같은 고수준 방식으로 게임 동작을 정의 == 실행 성능은 하락, 생산성은 높임.

예제 코드

타입 객체 패턴의 핵심
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Breed
{
public:
  Breed(int health, const char* attack)
  : health_(health),
    attack_(attack)
  {}

  int getHealth() { return health_; }
  const char* getAttack() { return attack_; }

private:
  int health_; // Starting health.
  const char* attack_;
};
  • Breed 클래스에는 최대 체력과, 공격 문구 필드 두 개만 있다.
  • Monster 클래스에서는 이 클래스를 아래와 같이 사용.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Monster
{
public:
  Monster(Breed& breed)
  : health_(breed.getHealth()),
    breed_(breed)
  {}

  const char* getAttack()
  {
    return breed_.getAttack();
  }

private:
  int    health_; // Current health.
  Breed& breed_;
};
  • Monster 클래스 생성자는 Breed 객체를 레퍼런스로 받는다. - 상속 없이 종족 정의 - 최대 체력 == breed로부터얻음

생성자 함수를 통해 타입 객체를 좀 더 타입 같이

  • 위에선 몬스터 생성자에서 종족을 전달했음

    • 메모리를 먼저 할당한 후 그 메모리에 클래스를 할당하는 것과 같음.
    • 대부분 OOP언어에서는 이런 식으로 객체를 만들지 않음.
    • 대신 클래스의 생성자 함수를 호출해 클래스가 알아서 새로운 인스턴스를 생성하게 한다.(팩토리 함수같은것)

    • 타입 객체에도 이 패턴을 적용한다.(팩토리 메서드 패턴)
1
2
3
4
5
6
7
class Breed
{
public:
  Monster* newMonster() { return new Monster(*this); }

  // Previous Breed code...
};
  • 그리고 다음과 같이 몬스터의 생성자를 private로 설정, 종족을 friend로 설정한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Monster
{
  friend class Breed;

public:
  const char* getAttack() { return breed_.getAttack(); }

private:
  Monster(Breed& breed)
  : health_(breed.getHealth()),
    breed_(breed)
  {}

  int health_; // Current health.
  Breed& breed_;
};
  • Breed 클래스의 newMonster가 팩토리 메서드 패턴의 생성자가 된다.

루비, 스몰토크, 오브젝트 C == 클래스가 객체인 언어 == 새로운 인스턴스를 생성하기 위해 클래스 객체의 메서드를 호출

  • 이제 몬스터는 다음과 같이 생성한다.
1
Monster* monster = someBreed.newMonster();
  • 이러면, 객체는 메모리 할당과 초기화 2단계로 생성됨.
  • Monster 클래스 생성자 함수에서는 필요한 모든 초기화 작업을 다 할 수있다.

    • 그래픽 로딩, 몬스터 AI 설정 등 다른 초기화 작업을 한번에 할 수 있음.
  • 이런 초기화 작업 == 메모리 할당 후에 진행
    • 아직 제대로 초기화되지 않은 몬스터가 메모리에 먼저 올라와있음.
    • 객체 생성 과정을 제어하고 싶은 경우: 오브젝트 풀 패턴을 이용, 커스텀 할당자 이용 => 객체가 메모리 어디에 생성될지를 제어

이점

  • 생성자를 다른곳에 두는 이점
    • Monster 클래스에 초기화 제어권 넘겨주기 전에 메모리 풀이나 커스텀 힙에서 메모리를 가져올 수 있다.
    • 모든 몬스터가 정해놓은 메모리 관리 루티능ㄹ 따라 생성되도록 강제 가능.

상속으로 데이터 공유하기

  • 종족이 수백 개, 속성이 많아질 경우

    • 여러 종족이 속성 값을 공유하면 좋음.
  • OOP방식처럼 상속을 통할 수 있음.

    • 프로그래밍 언어의 상속기능이 아닌 타입 객체끼리 상속할 수 있는 시스템을 구현할것임.

단일 상속

  • 클래스가 상위 클래스를 갖는 것처럼 종족 객체도 상위 종족 객체를 가질 수 있게 만든다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Breed
{
public:
  Breed(Breed* parent, int health, const char* attack)
  : parent_(parent),
    health_(health),
    attack_(attack)
  {}

  int         getHealth();
  const char* getAttack();

private:
  Breed*      parent_;
  int         health_; // Starting health.
  const char* attack_;
};
  • breed 객체생성시 상속받을 종족 객체를 넘겨줌.
    • 상위 종족이 없는 최상위 종족 == NULL 값
  • 하위 객체는 상위 객체로부터 받을지, 자기 값으로 오버라이드할지 제어 가능해야함.
    • 예제: 최대체력이 0이 아닐 때, 공격 문구가 NULL이 아닐 때 자기 값 사용하도록, 그 외는 상위 객체 값.
  • 두가지 방식이 있음.
속성을 요청받을 때마다 동적으로 위임
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int Breed::getHealth()
{
  // Override.
  if (health_ != 0 || parent_ == NULL) return health_;

  // Inherit.
  return parent_->getHealth();
}

const char* Breed::getAttack()
{
  // Override.
  if (attack_ != NULL || parent_ == NULL) return attack_;

  // Inherit.
  return parent_->getAttack();
}
  • 런타임에 변경해야할 경우 좋음
    • 특정 속성 값을 더 이상 오버라이드하지 않을 때
    • 더이상 상속 받지 않도록 변경
  • 단점 - 포인터 유지 - 메모리 차지 - 속성 조회시 상속된것들 전부 확인 => 느림
  • 종족 속성 값이 바뀌지 않으면, 생성 시점에 바로 상속적용 가능.
    • '카피다운(copy-down)' 위임

      이라고 함

    • 객체 생성시 상속받는 속성 값을 하위 타입으로 복사해서 넣기 때문.
카피다운 위임
1
2
3
4
5
6
7
8
9
10
11
Breed(Breed* parent, int health, const char* attack)
: health_(health),
  attack_(attack)
{
  // Inherit non-overridden attributes.
  if (parent != NULL)
  {
    if (health == 0) health_ = parent->getHealth();
    if (attack == NULL) attack_ = parent->getAttack();
  }
}
  • 상위 종족 객체를 포인터로 유지할 필요가 없음.
  • 깔끔하고 빠르다.
1
2
int         getHealth() { return health_; }
const char* getAttack() { return attack_; }

Json

  • json 파일로 종족을 정의해보자

    프로토타입 패턴에서 다룬것처럼

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
  "Troll": {
    "health": 25,
    "attack": "The troll hits you!"
  },
  "Troll Archer": {
    "parent": "Troll",
    "health": 0,
    "attack": "The troll archer fires an arrow!"
  },
  "Troll Wizard": {
    "parent": "Troll",
    "health": 0,
    "attack": "The troll wizard casts a spell on you!"
  }
}
  • 체력이 0이므로 상위 종족으로부터 값을 상속받음.
  • 종족과 종족별 속성 개수가 늘어날 수록 상속으로 시간을 많이 아낄 수 있다.
  • 기획자가 자유롭게 제어할 수 있음.

디자인 결정

  • 설계의 폭이 넓고 여러 시도를 할 수 있는 패턴
  • 하지만 가능성이 제한됨
    • 시스템 복잡 => 개발기간 증가, 유지보수 어려움
    • 사용자는 프로그래머가 아닌 경우가 많음(이해하기 쉽게 만들어야함) => 간단하게

타입 객체를 숨김? 노출?

  • Monster 설계시 종족을 반환하도록 변경할 경우
    • 모든 몬스터에 종족 객체가 있다는 사실이 공개 API에 포함되어야함.
    • Monster 클래스 설계가 바뀜
1
2
3
4
5
6
7
class Monster
{
public:
  Breed& getBreed() { return breed_; }

  // Existing code...
};

타입 객체를 캡슐화(If the type object is encapsulated)

  • 타입 객체 패턴의 복잡성이 나머지 다른 코드에는 드러나지 않음
    • 구현 디테일 == 타입 사용 객체에서만 고민.
  • 타입 사용 객체는 타입 객체로부터 동작을 선택적으로 오버라이드할 수 있다.

    • 외부 코드에서 Breed 객체의 getAttack()을 사용하면, 아래와 같이 코드를 추가 못함.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    const char\* Monster::getAttack()
    {
    if (health\_ < LOW_HEALTH)
    {
    return "The monster flails weakly.";
    }
    
    return breed\_.getAttack();
    }
    
  • 타입 객체 메서드를 전부 포워딩해야 한다.
    • 타입 사용 객체 클래스에서 외부에 공개하고 싶은 메서드들을 포워딩해야함.

타입 객체를 노출

  • 타입 사용 클래스 인스턴스를 통하지 않고도 외부에서 타입 객체에 접근할 수 있다.

    • 타입 객체인 Breed의 메서드를 호출해 새로운 몬스터 생성(팩토리)
  • 타입 객체가 공개 API의 일부가 됨.

    • 인터페이스를 적게 노출 => 복잡성 줄어들고, 유지보수 편함
    • 타입 객체 노출 => 타입 객체가 제공하는 모든 것 == 객체 API에 포함

타입 사용 객체를 어떻게 생성?

  • 객체는 쌍으로 존재 (타입, 타입 사용)

객체 생성한 후 타입 객체를 넘겨주는 경우

  • 외부 코드에서 메모리 할당 제어 가능
    • 두 객체 모두 외부에서 생성
    • 커스텀 메모리 할당자, 스택 등에 둘 수 있음.

타입 객체의 생성자 함수에서 생성하는 경우

  • 타입 객체에서 메모리 할당을 제어
    • 외부에서 타입 객체 메모리 생성 선택권을 주고 싶지 않을 경우
    • 타입 객체 팩토리 메서드를 통해 객체를 생성하여 메모리 제어 가능.
    • 모든 객체를 특정 오브젝트 풀 or 다른 메모리 할당자에서만 생성하도록 제한하고 싶을 경우

타입을 변경할 수 있는가?

  • 변경하게 가능.
  • ex. 죽으면 종족을 좀비로

타입을 바꿀 수 없다면

  • 코드 구현 쉬움, 이해 쉬움.
  • 디버깅 쉬움.

타입을 바꿀 수 있다면

  • 객체 생성횟수 줄어든다.
    • 타입 불변: 기존 몬스터 삭제 => 새로운 몬스터 생성
    • 타입 변: 포인터 교체
  • 강한 커플링
    • 종족 타입을 바꾼다면, 기존 코드들이 새로운 타입의 요구사항에 맞아야함. => 검증 코드 필요할 수 있음.

상속을 어떻게 지원?

상속 없음

  • 단순
  • 중복 작업이 생길 가능성

단일 상속

  • 그나마 단순한 편
    • 구현 쉽고, 이해 쉬움.
    • 많은 프로그래밍 언어가 단일 상속만 지원함.
  • 속성 값을 얻는 데 오래 걸림
    • 값을 얻으려면, 적절한 타입을 찾을 때 까지 상속구조 타고 올라감.
    • 런타임 낭비

다중 상속

  • 거의 모든 데이터 중복 피함
  • 복잡함
    • 이해하기 어려움.
    • 상속 그래프 흐름 이해해야함.
    • 상속 구조를 잘 설계해야함
    • C++ 표준에서는 다중 상속을 금지함.(구글 코딩 컨벤션에서는 interface에서는 허용. 다른 규약들도 비슷)

관련 자료

  • 고수준에서 이 패턴이 해결하려고 하는 문제 == 여러 객체끼리 데이터와 동작을 공유하려는 것.
    • 프로토타입 패턴 또한 이 문제를 해결하고자 함.
  • 타입 객체 패턴은 경량 패턴이랑 비슷.
    • 여러 인스턴스가 데이터 공유할 수 있게 함.
    • 경량 패턴 목적: 메모리 절약
    • 타입 객체 패턴 목적: 조직화와 유연성
  • 상태 패턴과 유사함.
    • 자기를 정의하는 부분 일부를 위임함.
    • 타입 객체: 해당 객체를 나타내는 불변 데이터를 주로 위임
    • 상태 패턴: 객체의 현재 상태가 어떤지를 나타내는 임시 데이터를 주로 위임
    • 타입 객체 교체 == 상태 패턴 역할도 가능.

출처

Type Object

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

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

[게임 프로그래밍 패턴] Decoupling Patterns: Component