Branch Statement Optimization — State Pattern
Published: 2019-01-25
The State pattern is a behavioral pattern that can greatly simplify branching statements. When code contains many conditional statements related to an object's state, those conditionals reduce maintainability and flexibility and make it hard to add or remove states. Here, a state can be the approval flow status in an OA system, or various intermediate states in forward and reverse order processes; the scenarios are very common. We can define the State pattern as follows:
An object's behavior depends on its state (attributes), and it can exhibit different behaviors as its state changes; from the outside it appears as if the object itself changed.
Below are the branching statements we commonly see. For composite state condition checks, the cost doubles, which is highly unfavorable for maintenance and modification.
// Single-state conditional checks — each time a new state is added, a new check must be added
function Foo1 (status) {
if(status === 1) {
// do sth1
} else if(status === 2) {
// do sth2
}else {
// do sth3
}
}
// Composite states double the cost of conditional checks
function Foo2 (status1, status2) {
if (status1 === 1) {
// do sth1
} else if (status1 === 2) {
// do sth2
} else if (status1 === 1 && status2 === 2) {
// do sth1 and sth2
} else if (status1 === 2 && status2 == 3) {
// do sth2 and sth3
}
}Note that the behavior produced by the composite states above is the sum of the behaviors produced by individual states. Below we implement multi-branch logic using the State pattern.
Pattern example
// Create the State class
function Foo3 () {
// Safe constructor pattern
if (!(this instanceof Foo3)) return new Foo3()
// Internal private state variable
var _currentStates = {},
// State enumeration container; of course an object could be used here instead
states = new Map([
[1, () => {// do sth1}],
[2, () => {// do sth2}],
[3, () => {// do sth3}],
[4, () => {// do sth4}],
[5, () => {// do sth5}],
['default', () => {// do sth6}]
])
// Action controller
var Action = {
// Method to change state — passing multiple states decides execution of multiple actions
changeState: function (...args) {
// Reset internal state
_currentstate = args || [];
// Return the action controller
return this;
},
// Execute actions corresponding to states
goes: function () {
// Iterate over internally stored states
for (let i of _currentstate) {
// If the state exists, execute it
let action = states.get(i) || states.get('default')
action.call(this)
}
return this;
}
}
// Return interface methods change and goes
return {
change: Action.changeState,
goes: Action.goes
}
}Inside the State class there is a private state variable and a state-enumeration container used to define the behavior for each single state value. Here we use ES6 Map; you could of course use an object instead, but you'll see the great flexibility that Map's newer syntax provides.
Because the State class needs to preserve the current state internally and must be instantiated as different objects, a safe constructor pattern is included in its constructor so that even if the caller usesFoo3()to initialize, it is equivalent to using the new keywordnew Foo3().
Here we expose two interfaces, change and goes, used to change the instance's internal state and to execute all states on the instance. This transforms bloated, coupled branch-handling logic into a clearer, independent calling style.
Foo3().change(1, 2).goes().change(3).goes(); // do sth1, do sth2, do sth3Next let's increase the complexity of composite states so that the behaviors produced by multiple states are no longer a simple sum of single-state behaviors. What should we do then?
function Foo4 (status1, status2) {
if (status2 === 'A') {
if (status1 === 1) {
// do sth1
} else if (status1 === 2) {
// do sth2
}
// ...
} else if (status2 === 'B') {
if (status1 === 1) {
// do sth3
} else if (status1 === 2) {
// do sth4
}
// ...
}
}
function Foo5 () {
if (!(this instanceof Foo5)) return new Foo5()
var _currentStates = {},
states = new Map([
['A_1', () => {// do sth1}],
['A_2', () => {// do sth2}],
['B_1', () => {// do sth3}],
['B_2', () => {// do sth4}],
// ...
['default', () => {// do sth5}]
])
var Action = {
changeState: function (...args) {
_currentstate = args || [];
return this;
},
goes: function () {
let action = states.get(`${_currentstate[2]}_${_currentstate[1]}`) || states.get('default')
action.call(this)
return this;
}
}
return {
change: Action.changeState,
goes: Action.goes
}
}The main changes in Foo5 are the Map used for state enumeration and the goes interface output. Here we cleverly use concatenated multi-state values as the Map key, allowing calls like the following.
Foo4().change(1, ‘A’).goes(); // do sth1Map trick
Next we'll raise the complexity further to demonstrate the cleverness of using Map instead of an object. If under state A several composite states perform the same action while other cases do different things, and all composite states under A need to execute a shared piece of logic, according to the previous approach states would look like:
states = new Map([
['A_1', () => {// do sth1}],
['A_2', () => {// do sth1}],
['A_3', () => {// do sth1}],
['A_4', () => {// do sth2}],
// ...
['common', () => {// do common thing}]
])But if the number of states is large or the relationships among keys are more complex and variable, enumerating every case in the Map becomes as crude as usingif...elsebranching. First, let's understand the major differences between Map and object.
A
Mapkey can beany value, including functions, objects, primitive types,and regular expressions.
Map's key-value pairs are ordered, whereas keys added to an object are not. Therefore, when iterating, a Map returns entries in insertion order.
You can get the number of key-value pairs directly via the
sizeproperty, while anMapObjectrequires manual counting to determine its number of pairs.It can be iterated directly, whereas
Mapobject iteration requires obtaining its key array first and then iterating.requires manual counting to determine its number of pairs.Both have their own prototypes, and property names on the prototype chain can conflict with names you set on your object. Although since ES5 you can use
requires manual counting to determine its number of pairs.map = Object.create(null)to create an object without a prototype, this usage is uncommon.Map can offer some performance advantages in scenarios involving frequent addition and deletion of key-value pairs.
MapThe above is excerpted from MDN. The feature we use here is that Map keys can be
regular expressionsand regular expressions, whose flexibility can significantly simplify most complex and variable state relationships, as follows:
states = new Map([
[/^A_[1-3]$/, () => {// do sth1}],
[/^A_4$/, () => {// do sth1}],
// ...
[/^A_.*$/, () => {// do common thing}]
])
goes: function () {
// Note you cannot use Map.prototype.get(key) to retrieve a value whose key is a regular expression; you must filter by iteration
let states_post = [...states].filter(([key, value]) => key.test(`${_currentstate[2]}_${_currentstate[1]}`))
states_post.forEach([key, value] => value.call(this))
return this;
}I believe this example will inspire many ideas for handling complex branching problems in the future.
Summary
The State pattern can solve bloated branching conditional issues in code by turning each branch into an independent state, making each state easier to manage without having to traverse all branches on every execution. Which behavior the program produces depends on which state is selected, and the choice of state is determined at runtime. Ultimately, the purpose of the State pattern is to simplify the branching decision flow.
References
JavaScript Design Patterns — Zhang Rongming
Last updated