Javascript. State pattern

Паттерн Состояние (State pattern) управляет изменением поведения объекта при изменении его внутреннего состояния. Внешне это выглядит так, словно объект меняет свой класс. Паттерн инкапсулирует состояние в отдельных классах и делегирует операции объекту, представляющему текущее состояние, поэтому поведение объекта изменяется вместе с внутренним состоянием. Данный паттерн относится к поведенческим шаблонам проектирования. Ниже представлена диаграмма классов данного паттерна.



Данный паттерн по диаграмме эквивалентен паттерну Стратегия, но отличие заключается в цели: вместо алгоритмов в нашем случае варианты поведения инкапсулируются в объектах состояния. Поэтому данный паттерн может рассматриваться как замена многочисленных условных конструкций в коде контекста: если поведение инкапсулировано в объектах состояния, для изменения поведения контекста достаточно выбрать другой объект состояния.

Рассмотрим сначала пример реализации работы автомата, выдающего шарики за монетку без использования паттерна Состояние. Итак, наш автомат может находиться в определенных состояниях, например - ожидание, пуст и прочее, что зависит от ряда условий. Между состояниями существуют переходы, например - выдыча шарика или прием монетки. И вот здесь нужно использовать различные условные конструкции. Для начала рассмотрим модель нашего автомата на следующем рисунке.



Как видно, кружки - состояния автомата, а стрелки - переходы между состояниями. Вот реализация без паттерна.

/* GumballMachine */

var GumballMachine = function (count) {
var SOLD_OUT = 0,
NO_QUARTER = 1,
HAS_QUARTER = 2,
SOLD = 3;
var state = SOLD_OUT,
count = count || 0;
  
if (count > 0) {
state = NO_QUARTER;
}
  
this.insertQuarter = function () {
if (state == HAS_QUARTER) {
console.log("You can't insert another quarter");
} else if (state == NO_QUARTER) {
state = HAS_QUARTER;
console.log("You inserted a quarter");
} else if (state == SOLD_OUT) {
console.log("You can't insert a quarter, the machine is sold out");
} else if (state == SOLD) {
        console.log("Please wait, we're already giving you a gumball");
}
};

this.ejectQuarter = function () {
if (state == HAS_QUARTER) {
console.log("Quarter returned");
state = NO_QUARTER;
} else if (state == NO_QUARTER) {
console.log("You haven't inserted a quarter");
} else if (state == SOLD) {
console.log("Sorry, you already turned the crank");
} else if (state == SOLD_OUT) {
        console.log("You can't eject, you haven't inserted a quarter yet");
}
};
this.turnCrank = function () {
if (state == SOLD) {
console.log("Turning twice doesn't get you another gumball!");
} else if (state == NO_QUARTER) {
console.log("You turned but there's no quarter");
} else if (state == SOLD_OUT) {
console.log("You turned, but there are no gumballs");
} else if (state == HAS_QUARTER) {
console.log("You turned...");
state = SOLD;
this.dispense();
}
};
this.dispense = function () {
if (state == SOLD) {
console.log("A gumball comes rolling out the slot");
count = count - 1;
if (count == 0) {
console.log("Oops, out of gumballs!");
state = SOLD_OUT;
} else {
state = NO_QUARTER;
}
} else if (state == NO_QUARTER) {
console.log("You need to pay first");
} else if (state == SOLD_OUT) {
console.log("No gumball dispensed");
} else if (state == HAS_QUARTER) {
console.log("No gumball dispensed");
}
};
this.refill = function (numGumBalls) {
count = numGumBalls;
state = NO_QUARTER;
};

this.toString = function () {
var result = "";
result += "\nMighty Gumball, Inc.";
result += "\nJavascript-enabled Standing Gumball Model #2004\n";
result += "Inventory: " + count + " gumball";
if (count != 1) {
result += "s";
}
result += "\nMachine is ";
if (state == SOLD_OUT) {
result += "sold out";
} else if (state == NO_QUARTER) {
result += "waiting for quarter";
} else if (state == HAS_QUARTER) {
result += "waiting for turn of crank";
} else if (state == SOLD) {
result += "delivering a gumball";
}
result += "\n";
return result;
};
};

/* testing... */

var Application = function () {
this.run = function () {
var gumballMachine = new GumballMachine(5);

console.log(gumballMachine.toString());

gumballMachine.insertQuarter();
gumballMachine.turnCrank();

console.log(gumballMachine.toString());

gumballMachine.insertQuarter();
gumballMachine.ejectQuarter();
gumballMachine.turnCrank();

console.log(gumballMachine.toString());

gumballMachine.insertQuarter();
gumballMachine.turnCrank();
gumballMachine.insertQuarter();
gumballMachine.turnCrank();
gumballMachine.ejectQuarter();

console.log(gumballMachine.toString());

gumballMachine.insertQuarter();
gumballMachine.insertQuarter();
gumballMachine.turnCrank();
gumballMachine.insertQuarter();
gumballMachine.turnCrank();
gumballMachine.insertQuarter();
gumballMachine.turnCrank();

console.log(gumballMachine.toString());
};
}; 

var application = new Application();
application.run();

На выходе получаем:

Mighty Gumball, Inc.
Javascript-enabled Standing Gumball Model #2004
Inventory: 5 gumballs
Machine is waiting for quarter

You inserted a quarter
You turned...
A gumball comes rolling out the slot

Mighty Gumball, Inc.
Javascript-enabled Standing Gumball Model #2004
Inventory: 4 gumballs
Machine is waiting for quarter

You inserted a quarter
Quarter returned
You turned but there's no quarter

Mighty Gumball, Inc.
Javascript-enabled Standing Gumball Model #2004
Inventory: 4 gumballs
Machine is waiting for quarter

You inserted a quarter
You turned...
A gumball comes rolling out the slot
You inserted a quarter
You turned...
A gumball comes rolling out the slot
You haven't inserted a quarter

Mighty Gumball, Inc.
Javascript-enabled Standing Gumball Model #2004
Inventory: 2 gumballs
Machine is waiting for quarter

You inserted a quarter
You can't insert another quarter
You turned...
A gumball comes rolling out the slot
You inserted a quarter
You turned...
A gumball comes rolling out the slot
Oops, out of gumballs!
You can't insert a quarter, the machine is sold out
You turned, but there are no gumballs

Mighty Gumball, Inc.
Javascript-enabled Standing Gumball Model #2004
Inventory: 0 gumballs
Machine is sold out

А теперь реализуем данный автомат с помощью паттерна Состояние. 

/* interface State */

var State = function () {
  this.insertQuarter = function () {};
this.ejectQuarter = function () {};
this.turnCrank = function () {};
this.dispense = function () {};
};

/* ConcreteState - HasQuarterState */

var HasQuarterState = function (gumballMachine) {
var gumballMachine = gumballMachine;
  
this.insertQuarter = function () {
console.log("You can't insert another quarter");
};
this.ejectQuarter = function () {
console.log("Quarter returned");
gumballMachine.setState(gumballMachine.getNoQuarterState());
};
this.turnCrank = function () {
console.log("You turned...");
gumballMachine.setState(gumballMachine.getSoldState());
};

    this.dispense = function () {
        console.log("No gumball dispensed");
    };
this.toString = function () {
return "waiting for turn of crank";
};
};

HasQuarterState.prototype = new State();
HasQuarterState.prototype.constructor = HasQuarterState;

/* ConcreteState - NoQuarterState */

var NoQuarterState = function (gumballMachine) {
    var gumballMachine = gumballMachine;
this.insertQuarter = function () {
console.log("You inserted a quarter");
gumballMachine.setState(gumballMachine.getHasQuarterState());
};
this.ejectQuarter = function () {
console.log("You haven't inserted a quarter");
};
this.turnCrank = function () {
console.log("You turned, but there's no quarter");
};
this.dispense = function () {
console.log("You need to pay first");
}; 
this.toString = function () {
return "waiting for quarter";
};
}

NoQuarterState.prototype = new State();
NoQuarterState.prototype.constructor = NoQuarterState;

/* ConcreteState - SoldOutState */

var SoldOutState = function (gumballMachine) {
    
var gumballMachine = gumballMachine;
this.insertQuarter = function () {
console.log("You can't insert a quarter, the machine is sold out");
};
this.ejectQuarter = function () {
console.log("You can't eject, you haven't inserted a quarter yet");
};
this.turnCrank = function () {
console.log("You turned, but there are no gumballs");
};
this.dispense = function () {
console.log("No gumball dispensed");
};
this.toString = function () {
return "sold out";
};
};

SoldOutState.prototype = new State();
SoldOutState.prototype.constructor = SoldOutState;

/* ConcreteState - SoldState */

var SoldState = function (gumballMachine) {
var gumballMachine = gumballMachine;
       
this.insertQuarter = function () {
console.log("Please wait, we're already giving you a gumball");
};
this.ejectQuarter = function () {
console.log("Sorry, you already turned the crank");
};
this.turnCrank = function () {
console.log("Turning twice doesn't get you another gumball!");
};
this.dispense = function () {
gumballMachine.releaseBall();
if (gumballMachine.getCount() > 0) {
gumballMachine.setState(gumballMachine.getNoQuarterState());
} else {
console.log("Oops, out of gumballs!");
gumballMachine.setState(gumballMachine.getSoldOutState());
}
};
this.toString = function () {
return "dispensing a gumball";
};
};

SoldState.prototype = new State();
SoldState.prototype.constructor = SoldState;

/* Context */

var GumballMachine = function (numberGumballs) {
var soldOutState = new SoldOutState(this),
noQuarterState = new NoQuarterState(this),
hasQuarterState = new HasQuarterState(this), 
soldState = new SoldState(this);
var state = soldOutState,
count = numberGumballs;
if (numberGumballs > 0) {
state = noQuarterState;
}
this.insertQuarter = function () {
state.insertQuarter();
};
this.ejectQuarter = function () {
state.ejectQuarter();
};
this.turnCrank = function () {
state.turnCrank();
state.dispense();
};

this.setState = function (_state) {
state = _state;
};
this.releaseBall = function () {
console.log("A gumball comes rolling out the slot...");
if (count != 0) {
count = count - 1;
}
};
this.getCount = function () {
return count;
};
this.refill = function (_count) {
count = _count;
state = noQuarterState;
};

    this.getState = function () {
        return state;
    };

    this.getSoldOutState = function () {
        return soldOutState;
    };

    this.getNoQuarterState = function () {
        return noQuarterState;
    };

    this.getHasQuarterState = function () {
        return hasQuarterState;
    };

    this.getSoldState = function () {
        return soldState;
    };
this.toString = function () {
var result = "";
result += "\nMighty Gumball, Inc.";
result += "\nJavascript-enabled Standing Gumball Model #2004";
result += "\nInventory: " + count + " gumball";
if (count != 1) {
result += "s";
}
result += "\n";
result += "Machine is " + state + "\n";
return result;
};
};

/* testing... */

var GumballMachineTestDrive = function () {
this.run = function () {
var gumballMachine = new GumballMachine(5);

console.log(gumballMachine.toString());

gumballMachine.insertQuarter();
gumballMachine.turnCrank();

console.log(gumballMachine.toString());

gumballMachine.insertQuarter();
gumballMachine.turnCrank();
gumballMachine.insertQuarter();
gumballMachine.turnCrank();

console.log(gumballMachine.toString());
};
};

var machine = new GumballMachineTestDrive();
machine.run();

На выходе получаем следующий результат:

Mighty Gumball, Inc.
Javascript-enabled Standing Gumball Model #2004
Inventory: 5 gumballs
Machine is waiting for quarter

You inserted a quarter
You turned...
A gumball comes rolling out the slot...

Mighty Gumball, Inc.
Javascript-enabled Standing Gumball Model #2004
Inventory: 4 gumballs
Machine is waiting for quarter

You inserted a quarter
You turned...
A gumball comes rolling out the slot...
You inserted a quarter
You turned...
A gumball comes rolling out the slot...

Mighty Gumball, Inc.
Javascript-enabled Standing Gumball Model #2004
Inventory: 2 gumballs
Machine is waiting for quarter

Всем удачи в бою :)