Wzorzec stan (state)

Dziś prezentuję kolejny wzorzec projektowy, który pozwala uniknąć drabinek if-else if. Mowa tutaj o wzorcu state, który idealnie nada się, jeśli posiadasz różne stany w swoim systemie oraz chcesz mieć możliwość płynnego przechodzenia pomiędzy nimi.

Wzorzec stan

Czynnościowy (behawioralny) wzorzec projektowy stan, pozwala na przechowywanie reprezentacji i zachowań konkretnego stanu systemu w pojedynczym obiekcie. Zamiast deklaracji wielu zmiennych, funkcji i klas, które umownie grupujemy, jako zestaw pewnych zachowań w obrębie logiki, możemy uspójnić nasz kod poprzez zastosowanie wzorca state

Stosowanie tego wzorca ma najwięcej sensu, kiedy funkcje klas pełne są instrukcji warunkowych zmieniających ich zachowania (stan), w zależności od zestawu właściwości czy wartości pól w obrębie klasy. Inaczej mówiąc, jeżeli Twój kod zmienia swoją reprezentację, kiedy zmieniają się jego właściwości, to znak, że powinieneś/aś rozważyć zastosowanie tego pattern-u.

Problem

Wyobraźmy sobie, że projektujemy grę. Gracz może znajdować się w trzech stanach: żyje, umarł, jest sparaliżowany. W zależności od stanu w jakim się znajduje zmienia się jego zachowanie. Przykładowo, zmarli nie mogą się poruszać, a w przypadku paraliżu mowa jest niezrozumiała przez innych.

enum PlayerStatus {
  alive = 'alive',
  paralyzed = 'paralyzed',
  dead = 'dead',
}

class Player {
  constructor(
    private readonly id: PlayerId,
    private status: PlayerStatus,
    private readonly name: string,
    private speed: number,
    private position: Position,
  ) {}

  move(direction: MoveDirection) {
    if (this.status === PlayerStatus.dead) return;

    let movementSpeed = this.speed;
    if (this.status === PlayerStatus.paralyzed) {
      movementSpeed = this.speed / 10;
    }

    this.position = this.position.change(
      direction,
      speed,
    );
  }

  say(speech: string): string {
    if (this.status === PlayerStatus.dead) return '';

    if (this.status === PlayerStatus.paralyzed) {
      return `${this.name}: ipposadobvmannmnvc ljsdasalndnsa nbdsad`;
    }

    return `${this.name}: ${speech}`;
  }
}

Teraz wyobraźmy sobie, że dochodzi kolejny stan postaci: boosted. W tym stanie prędkość poruszania się jest dwukrotnie wyższa. Musimy wtedy w obydwu metodach say() i move() dopisać kolejne warunki. Z drugiej strony, co w przypadku, jeżeli dojdzie nowa metoda, np. attack()

Przykład wzorca stan

Zamiast powielać if-ologię, spróbujmy zamknąć zachowania dedykowane poszczególnych stanom w jeden obiekt. Następnie zapiszmy ten obiekt w samym Playerze. Teraz, w zależności od stanu w którym znajduje się gracz będziemy dynamicznie wywoływać odpowiednią logikę.

interface PlayerState {
  move(direction: Direction);
  say(speech: string): string;
}

class DeadPlayer implements PlayerState {
  constructor(
    private readonly player: Player
  ) {}

  move(direction: MoveDirection) {}

  say(speech: string): string {
    return '';
  }
}

class ParalyzedPlayer implements PlayerState {
  constructor(
    private readonly player: Player
  ) {}

  move(direction: MoveDirection) {
    this.player.changePosition(
      direction,
      this.player.getSpeed() / 10,
    );
  }

  say(speech: string): string {
    return `${this.player.getName()}: ipposadobvmannmnvc ljsdasalndnsa nbdsad`;
  }
}

W ten sposób zdefiniowane stany mogą być zmieniane w obrębie obiektu Player. Musimy w tym celu udostępnić odpowiednie metody, które będą modyfikowały stan gracza.

class Player {
  constructor(
    private readonly id: PlayerId,
    private readonly name: string,
    private speed: number,
    private position: Position,
    private state = new AlivePlayer(this)
  ) {}

  kill() {
    this.state = new DeadPlayer(this);
  }

  paralyze() {
    this.state = new ParalyzedPlayer(this);
  }

  reBorn() {
    this.state = new AlivePlayer(this);
  }

  move(direction: MoveDirection) {
    this.state.move(direction);
  }

  say(speech: string): string {
    return this.state.say(speech);
  }
}

State vs. Strategy

Stan może do złudzenia przypominać wzorzec strategii, o którym już wspominałem. Poszczególne stany mogą posiadać odwołania pomiędzy sobą. Możemy także zmieniać stan obiektu w aktualnym stanie. Przykładowo, jeżeli gracz wypije miksturę uleczenia w stanie paraliżu, jego status powinien automatycznie zostać zamieniony na żywy. W przypadku strategy pattern, konkretne strategie nie powinny nic o sobie wiedzieć. Strategie są od siebie odizolowane i niezależne.

Podsumowanie

  • Stan jest czynnościowym wzorcem projektowym pozwalającym na ograniczenie ifologii w kodzie.
  • Jeżeli Twoje klasy pełne są instrukcji warunkowych, które sprawdzają pola klasy w celu wybrania konkretnego zachowania, to ten wzorzec jest dla Ciebie.
  • Stan przypomina wzorzec strategii, jednak różnią się one od siebie i rozwiązują inne problemy.
Autor wpisu

blog@orbisbit.com

Komentarze

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *

Sprawdź również
  • Wzorzec strategia (strategy pattern)

    Jeżeli masz dość if-ologii w swoim kodzie, to konieczne sprawdź czym jest czynnościowy wzorzec projektowy strategia. Pozwala on mądrze obsługiwać różne scenariusze w procesie i jednocześnie być fancy pod względem zasad SOLID.

    Zobacz wpis

  • SOLID z przykładami w TypeScript

    SOLID (Single responsibility principle, Open/closed principle, Liskov substitution principle, Interface segregation principle, Dependency inversion principle), czyli pięć zasad programowania obiektowego, które każdy powinien przestrzegać.

    Zobacz wpis

  • Prawo Demeter (The Law of Demeter)

    Omawiam dzisiaj mało znaną zasadę, której stosowanie skutkuje posiadaniem łatwo utrzymywalnego i testowalnego kodu. Mowa o prawie demeter, które w najprostszym ujęciu zakłada, że obiekty powinny operować jedynie na najbliższym im otoczeniu.

    Zobacz wpis