Design Pattern 101 — State Pattern
State Pattern เป็นหนึ่งในกลุ่มของ Behavioral ที่จัดกลุ่มโดย GoF หลายท่านอาจจะเคยได้ใช้ Pattern นี้จัดการสถานะของระบบกันบ้างแล้ว ซึ่งในบทความนี้จะมาทบทวนและทดลองใช้ State Pattern ว่าสามารถแก้ปัญหาอะไรได้บ้าง
State Pattern
Problem
ถ้าเราต้องพัฒนาซอฟต์แวร์ควบคุมโหลหมุนไข่หยอดเหรียญ (Gumball Machine) สำหรับบริษัท Mighty Gumball โดยที state diagram การทำงานของเครื่องดังนี้
โดยเริ่มจากหยอดเหรียนเข้าไปในโหล (Insert quarter) สถานะโหลจะเป็น Has Quarter
(แต่ถ้าต้องการเอาเหรียญออกมาจากโหล (Ejects quarter)) แล้วก็หมุนไข่ (Turns crank) โหลจะเปลี่ยนสถานะเป็น Gumball Sold
แล้วไข่ก็จะออกมาจากโหล (Dispense gumball) และเปลี่ยนสถานะเป็น No Quarter
แล้วก็วนไปเรื่อยๆ จนกระทั้งไข่หมดโหลแล้ว สถานะจะเปลี่ยนไปเป็น Out of Gumballs
หรือไข่หมดโหลแล้วจ้าาาาาา
จาก diagram จะเห็นได้ว่าโหลหมุนไข่มีอยู่ 4 สถานะ (State) คือ
- No Quarter — ไม่มีเหรียญ
- Has Quarter — มีเหรียญ
- Gumball Sold — ขายไข่
- Out of Gumball — ไข่หมด
นำเอาสถานะของเครื่องมาเป็น constance ของ GumballMachine
final static int SOLD_OUT = 0;
final static int NO_QUARTER = 1;
final static int HAS_QUARTER = 2;
final static int SOLD = 3;
และ action ที่กระทำกับโหลหมุนไข่และทำให้สถานะเปลี่ยนไปคือ
- Insert Quarter — หยอดเหรียญ
- Eject Quarter — เอาเหรียญออกมา
- Turns crank — หมุนไข่
- Dispense — ไข่ออกมาจากโหล
นำเอา action ของเครื่องมาเป็น method ของ GumballMachine
public void insertQuarter() {
// implementation
}
public void ejectQuarter() {
// implementation
}
public void turnCrank() {
// implementation
}
public void dispense() {
// implementation
}
โดยที่ตรวจสอบสถานะของ GumballMachine
ในแต่ละ action
public void insertQuarter() {
if (state == HAS_QUARTER) {
System.out.println("You can't insert another quarter");
} else if (state == NO_QUARTER) {
state = HAS_QUARTER;
System.out.println("You inserted a quarter");
} else if (state == SOLD_OUT) {
System.out.println("You can't insert a quarter, the machine is sold out");
} else if (state == SOLD) {
System.out.println("Please wait, we're already giving you a gumball");
}
}
public void ejectQuarter() {
if (state == HAS_QUARTER) {
System.out.println("Quarter returned");
state = NO_QUARTER;
} else if (state == NO_QUARTER) {
System.out.println("You haven't inserted a quarter");
} else if (state == SOLD) {
System.out.println("Sorry, you already turned the crank");
} else if (state == SOLD_OUT) {
System.out.println("You can't eject, you haven't inserted a quarter yet");
}
}
public void turnCrank() {
if (state == SOLD) {
System.out.println("Turning twice doesn't get you another gumball!");
} else if (state == NO_QUARTER) {
System.out.println("You turned but there's no quarter");
} else if (state == SOLD_OUT) {
System.out.println("You turned but there are no gumballs");
} else if (state == HAS_QUARTER) {
System.out.println("You turned ...");
state = SOLD;
dispense();
}
}
public void dispense() {
if (state == SOLD) {
System.out.println("A gumball comes rolling out the slot");
count = count - 1;
if (count == 0) {
System.out.println("Oops, out of gumballs!");
state = SOLD_OUT;
} else {
state = NO_QUARTER;
}
} else if (state == NO_QUARTER) {
System.out.println("You need to pay first");
} else if (state == SOLD_OUT) {
System.out.println("No gumball dispensed");
}
}
กำหนดให้ค่าเริ่มต้นของ GumballMachine
เป็น NO_QUARTER
public GumballMachine(int numberOfGumball) {
this.count = numberOfGumball;
if (numberOfGumball > 0) {
state = NO_QUARTER;
}
}
และเมื่อนำ GumballMachine
มาใช้งาน โดยที่เรียก method ของแต่ละ action ของโหลหมุนไข่ และพิมพ์สถานะของเครื่องออกมาดูเรื่อยๆ
GumballMachine gumballMachine = new GumballMachine(5);
System.out.println(gumballMachine);
gumballMachine.insertQuarter();
gumballMachine.turnCrank();
gumballMachine.insertQuarter();
gumballMachine.turnCrank();
gumballMachine.ejectQuarter();
System.out.println(gumballMachine);
gumballMachine.insertQuarter();
gumballMachine.insertQuarter();
gumballMachine.turnCrank();
gumballMachine.insertQuarter();
gumballMachine.turnCrank();
gumballMachine.insertQuarter();
gumballMachine.turnCrank();
System.out.println(gumballMachine);
จะเห็นได้ว่าเริ่มต้นมีไข่ 5 ลูก เมื่อหยอดเหรียนแล้วหมุนไข่ไป 2 ครั้ง และตรวจสอบว่าเครื่องมีสถานะอย่างไร ปรากฎว่า เหลือไข่อีก 3 ลูก
และก็หยอดเหรียนแล้วหมุนไข่อีก 3 ครั้ง ไข่ก็จะหมดจากเครื่องเรียบร้อย
Mighty Gumball, Inc
Java-enabled Standing Gumball Model #2004
Inventory: 5 gumballs
Machine is waiting for quarterYou 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 quarterMighty Gumball, Inc
Java-enabled Standing Gumball Model #2004
Inventory: 3 gumballs
Machine is waiting for quarterYou 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
You inserted a quarter
You turned ...
A gumball comes rolling out the slot
Oops, out of gumballs!Mighty Gumball, Inc
Java-enabled Standing Gumball Model #2004
Inventory: 0 gumballs
Machine is waiting for quarter
ซอฟต์แวร์ของเราได้ควบคุมโหลหมุนไข่ได้อย่าง ตรงตาม requirement
….แต่อยู่มาวันหนึ่ง requirement เพิ่มเป็นว่าจะให้ 10% ของคนที่หมุนไข่ได้ ไข่ 2 ลูก (ทุกๆ หมุนไข่ 10 ครั้งจะได้ ไข่เพิ่ม 1 ลูกเป็น 2 ลูก)
ซึ่งเราต้องเพิ่มสถานะใหม่ของโหลหมุนไข่ในซอฟต์แวร์ของเรา และแก้ไข method ของ action ทุก method เพื่อพิจารณาสถานะใหม่ด้วย
ถ้าในอนาคตมี requirement ที่ต้องเพิ่มสถานะใหม่อีกล่ะ ก็ต้องแก้โค้ดเก่าทุกครั้งใช่ไหม ?????
Solution
ถึงเวลาแล้วที่เราต้องรื้อซอฟต์แวร์ของเราทั้งหมดเพื่อรองรับการแก้ไขในอานาคตที่มีโอกาสเกิดขึ้นสูง ด้วย State Pattern โดยที่สร้าง interface State
ที่มี method ของ action ทั้งหมด และให้สถานะต่างๆ implement interface นี้ ซึ่งมีการทำงานเฉพาะของแต่ละสถานะ (State)
public interface State {
void insertQuarter();
void ejectQuarter();
void turnCrank();
void dispense();
}
สถานะที่เป็นตัว implement ของ State
นั้น จะเก็บ instance ของ GumbalMachine
ไว้เพื่อที่จะเรียกใช้งาน โดย pass เข้ามาผ่าน constructor
public class NoQuarterState implements State {
GumballMachine gumballMachine;
State nextState;
public NoQuarterState(GumballMachine gumballMachine) {
this.gumballMachine = gumballMachine;
this.nextState = gumballMachine.getHasQuarterState();
}
@Override
public void insertQuarter() {
System.out.println("You inserted a quarter");
gumballMachine.setState(nextState);
}
@Override
public void ejectQuarter() {
System.out.println("You haven't inserted a quarter");
}
@Override
public void turnCrank() {
System.out.println("You turned but there's no quarter");
}
@Override
public void dispense() {
System.out.println("You need to pay first");
}
}
จากเดิมที่ประกาศสถานะด้วย constance ก็เปลี่ยนมาประกาศเป็น type State
private State noQuarterState;
private State hasQuarterState;
private State soldState;
private State soldOutState;
ใน construtor ก็สร้าง instance ของแต่ละชนิดของสถานะ และกำหนดให้สถานะแรกเป็น NoQuarterState
public GumballMachine(int numberOfGumball) {
noQuarterState = new NoQuarterState(this);
hasQuarterState = new HasQuarterState(this);
soldState =new SoldState(this);
soldOutState = new SoldOutState(this);
count = numberOfGumball;
if(numberOfGumball > 0) {
state = noQuarterState;
}
}
และ implement ของสถานะอื่นๆ
public class HasQuarterState implements State {
GumballMachine gumballMachine;
State nextState;
State soldState;
public HasQuarterState(GumballMachine gumballMachine) {
this.gumballMachine = gumballMachine;
this.nextState = gumballMachine.getNoQuarterState();
this.soldState = gumballMachine.getSoldState();
}
@Override
public void insertQuarter() {
System.out.println("You can't insert another quarter");
}
@Override
public void ejectQuarter() {
System.out.println("Quarter returned");
gumballMachine.setState(gumballMachine.getNoQuarterState());
}
@Override
public void turnCrank() {
System.out.println("You turned ...");
gumballMachine.setState(gumballMachine.getSoldState());
}
@Override
public void dispense() {
System.out.println("No gumball dispensed");
}
}public class SoldState implements State {
GumballMachine gumballMachine;
State nextState;
State soldOutState;
public SoldState(GumballMachine gumballMachine) {
this.gumballMachine = gumballMachine;
this.nextState = gumballMachine.getNoQuarterState();
this.soldOutState = gumballMachine.getSoldOutState();
}
@Override
public void insertQuarter() {
System.out.println("Please wait, we're already giving you a gumball");
}
@Override
public void ejectQuarter() {
System.out.println("You can't eject, you haven't inserted a quarter yet");
}
@Override
public void turnCrank() {
System.out.println("You turned but there are no gumballs");
}
@Override
public void dispense() {
gumballMachine.releaseBall();
if(gumballMachine.getCount() > 0) {
gumballMachine.setState(nextState);
} else {
System.out.println("Oops, out of gumballs!");
gumballMachine.setState(soldOutState);
}
}
}public class SoldOutState implements State {
GumballMachine gumballMachine;
public SoldOutState(GumballMachine gumballMachine) {
this.gumballMachine = gumballMachine;
}
@Override
public void insertQuarter() {
System.out.println("You can't insert a quarter, the machine is sold out");
}
@Override
public void ejectQuarter() {
System.out.println("You can't eject, you haven't inserted a quarter yet");
}
@Override
public void turnCrank() {
System.out.println("You turned but there are no gumballs");
}
@Override
public void dispense() {
System.out.println("No gumball dispensed");
}
}
จะเห็นได้ว่าแต่ละสถานะจะจัดการเฉพาะสถานะของตัวเองโดยที่ไม่ได้ยุ้งเกี่ยวกับสถานะอื่นเลย เพียงแต่รู้ว่าสถานะต่อไปของตัวเองคือสถานะอะไรเท่านั้น
ลองเรียกใช้อีกครั้งจะได้ output เหมือนเดิม
และเมื่อเราเพิ่มสถานะใหม่ตาม requirement ใหม่นั้นก็เพียงสร้าง class ที่ implement State
โดยสร้าง class WinnerState
สำหรับสถานะ Winner
public class WinnerState implements State {
GumballMachine gumballMachine;
State nextState;
State soldOutState;
public WinnerState(GumballMachine gumballMachine) {
this.gumballMachine = gumballMachine;
this.nextState = gumballMachine.getNoQuarterState();
this.soldOutState = gumballMachine.getSoldOutState();
}
@Override
public void insertQuarter() {
System.out.println("You can't insert a quarter, the machine is sold out");
}
@Override
public void ejectQuarter() {
System.out.println("You can't eject, you haven't inserted a quarter yet");
}
@Override
public void turnCrank() {
System.out.println("You turned but there are no gumballs");
}
@Override
public void dispense() {
System.out.println("YOU'RE A WINNER! You got two gumballs for your quarter");
gumballMachine.releaseBall();
if(gumballMachine.getCount() == 0) {
gumballMachine.setState(soldOutState);
} else {
gumballMachine.releaseBall();
if(gumballMachine.getCount() > 0) {
gumballMachine.setState(nextState);
} else {
System.out.println("Oops, out of gumball!");
gumballMachine.setState(soldOutState);
}
}
}
}
แก้ไขสถานะมีเหรียญอยู่แล้วในเครื่อง HasQuarterState
โดยเพิ่มการสุ่ม Winner ใน action หมุนไข่ turnCrank()
public class HasQuarterState implements State {
GumballMachine gumballMachine;
Random randomWinner = new Random(System.currentTimeMillis());
public HasQuarterState(GumballMachine gumballMachine) {
this.gumballMachine = gumballMachine;
}
@Override
public void insertQuarter() {
// not change
}
@Override
public void ejectQuarter() {
// not change
}
@Override
public void turnCrank() {
System.out.println("You turned ...");
int winner = randomWinner.nextInt(10);
if(winner == 0 && gumballMachine.getCount() > 1) {
gumballMachine.setState(gumballMachine.getWinnerState());
} else {
gumballMachine.setState(gumballMachine.getSoldState());
}
}
@Override
public void dispense() {
// not change
}
}
และเมื่อใช้งาน GumballMachine
เหมือนตอนแรกจะได้ผลลัพย์ที่เหมือนกันเลย
State Pattern จะอนุญาติให้ object เปลี่ยนแปลงพฤติกรรม เมื่อสถานะภายในเปลี่ยนแปลง ซึ่ง object จะแสดงให้เห็นถึงการเปลี่ยนแปลงของมันเอง
Pattern จะซ้อน (encapsulate) สถานะไปเป็น class ที่แยกจากัน และเป็นตัวแทนของ สถานะปัจจุบันของ object ซึ่งเรารู้ว่าพฤติกรรมเปลี่ยนแปลงด้วยสถานะภายใน และ
สรุป
จากที่ได้ทบทวนและใช้งาน State Pattern นั้น จะเห็นได้ว่า pattern นี้เหมาะมากสำหรับซอฟต์แวร์ที่จัดการสถานะที่หลากหลาย และแต่ละสถานะก็มีลักษนะเฉพาะของตัวเอง โดยที่ลดการแก้ไขโค้ดเดิมและสามารถเพิมสถานะอีกได้เรื่อยๆ ในบทความนี้ใช้ตัวอย่างของ Head First Design Pattern อีกเช่นเคย 😅