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 patternu.

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) {
      // cannot move if you are dead
      return;
    }

    if (this.status === PlayerStatus.paralyzed) {
      this.position = this.position.change(
         direction,
         this.speed / 10, // slower moves 
      );
      return;
    }

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

  say(speech: string): string {
    if (this.status === PlayerStatus.dead) {
      // cannot speak if you are dead
      return '';
    }

    if (this.status === PlayerStatus.paralyzed) {
      // cannot understand your speech if you are 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ć ifologię, 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




Polecane wpisy:

KISS: keep it simple, stupid!

Zasada KISS: "Keep it simple, stupid", może zostać dosłownie przetłumaczona na: "rób to prosto, głupku". Mówi ona o tym, abyśmy tworzyli kod w jak najprostszy i najbardziej czytelny sposób. Już dziś sprawdź, czego się wystrzegać, aby spełniać KISS.

Sprawdź ten wpis

Wzorce projektowe

Wzorce projektowe zostały stworzone po to, aby nie wymyślać przysłowiowego koła na nowo. Znajomość wzorców projektowych i umiejętność ich stosowania pozwala na szybkie rozwiązywanie problemów. Wpis ten radzi, jakie wzorce zastosować u siebie.

Sprawdź ten wpis

Wzorzec singleton

Kolejny kreacyjny wzorzec projektowy omawiany na łamach tego bloga: singleton. Wzorzec dookoła którego narosło wiele mitów i legend. Dziś o tym dlaczego singleton jest antywzorcem, jakie problemy powoduje oraz kiedy warto po niego sięgnąć.

Sprawdź ten wpis

Wzorzec pełnomocnik (virtual proxy)

Wzorzec virtual proxy służy do tworzenia obiektów pośrednich, które nadzorują dostęp do obiektów dla których są pełnomocnikami. Dzięki temu, że pełnomocnik i klasa bazowa udostępniają jednakowy interfejs, możemy ukryć w pośredniku dodatkową logikę.

Sprawdź ten wpis

Wzorzec strategia (strategy pattern)

Jeżeli masz dość ifologii 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.

Sprawdź ten wpis

Autor wpisu:

Gabriel Ślawski

Fanatyk czystego i prostego kodu. Zwolennik podejść DDD oraz Modular Monolith. Na codzień pracuje jako programista i architekt. Po godzinach spełnia się w projektach open source, udziela się na blogach oraz czyta książki o kosmosie i astrofizyce.