Conditional logic is essential to building any application, yet too much can make an application incomprehensible. Many of the applications I build require that an object exist in many different states, with behavior differing from state to state. A straightforward implementation involves lots of if statements and complex conditionals, producing overly convoluted solutions in short order. As a remedy, I use the state design pattern to keep my code from getting out of hand.
Holdings in a library provide a good example. A holding is a copy of a book (see Listing 1). (In my implementation, the book is simply the ISBN classification information. Thus, each holding object references a copy number and a book object.) Holdings can be checked out, checked in, they can be moved from branch to branch, they can be held by a patron, they can be warehoused, and so on. Each of these events puts the holding into a state where different rules apply. For example, a book that's checked out obviously can't be warehoused.
Listing 1: The Book class.
// BookTest.java
import static org.junit.Assert.*;
import org.junit.*;
public class BookTest {
public static final Book CATCH22 = new Book("0-671-12805-1",
"Catch-22", "Heller, Joseph", "1961");
@Test
public void create() {
assertEquals("0-671-12805-1", CATCH22.getIsbn());
assertEquals("Catch-22", CATCH22.getTitle());
assertEquals("Heller, Joseph", CATCH22.getAuthor());
assertEquals("1961", CATCH22.getYear());
}
}
// Book.java
public class Book {
private final String isbn;
private final String title;
private final String author;
private final String year;
public Book(String isbn, String title, String author,
String year) {
this.isbn = isbn;
this.title = title;
this.author = author;
this.year = year;
}
public String getIsbn() {
return isbn;
}
public String getTitle() {
return title;
}
public String getAuthor() {
return author;
}
public String getYear() {
return year;
}
}
Listing 2 shows a starter implementation for Holding. (Note that I'm not yet concerned with the relevancy of the patron ID.)
Listing 2: An initial Holding implementation.
import static org.junit.Assert.*;
import java.util.Date;
import org.junit.*;
public class HoldingTest {
private Holding holding;
private static final Date NOW = new Date();
private static final String PATRON_ID = "12345";
@Before
public void initialize() {
Book book = BookTest.CATCH22;
int copyNumber = 1;
holding = new Holding(book, copyNumber);
}
@Test
public void create() {
assertSame(BookTest.CATCH22, holding.getBook());
assertEquals(1, holding.getCopyNumber());
assertFalse(holding.isOnLoan());
}
@Test
public void checkout() {
holding.checkout(NOW, PATRON_ID);
assertTrue(holding.isOnLoan());
assertEquals(NOW, holding.getLoanDate());
}
@Test
public void checkin() {
Date later = new Date(NOW.getTime() + 1);
holding.checkout(NOW, PATRON_ID);
holding.checkin(later);
assertFalse(holding.isOnLoan());
}
}
// Holding.java
import java.util.Date;
public class Holding {
private final Book book;
private final int copyNumber;
private Date checkoutDate;
public Holding(Book book, int copyNumber) {
this.book = book;
this.copyNumber = copyNumber;
}
public Book getBook() {
return book;
}
public int getCopyNumber() {
return copyNumber;
}
public boolean isOnLoan() {
return checkoutDate != null;
}
public Date getLoanDate() {
return checkoutDate;
}
public void checkout(Date date, String patronId) {
checkoutDate = date;
}
public void checkin(Date date) {
checkoutDate = null;
}
}
The next story I
want to tackle is to allow patrons to place a hold on a book. If
checked in, the librarian places a held book behind the desk. Attempts
to check it out by a different patron are rejected. If checked out, the
system marks the book on hold for when it's returned. Holds are only
valid for three days; a daily update routine hits all holdings, giving
each an opportunity to clear any expired holds. The three-day timer
starts only once a book is returned, if it's checked out. A hold cannot
be placed on a book already on hold.
Based on these requirements, the code to manage patron holds (see
Listing 3) really isn't that bad, but I'm starting to wonder where it's
headed. I must ensure I have if statements in the right
place, for example, when doing an update: If I add code to check to see
whether any hold needs to be cleared, but neglect to guard against the
case where the holding isn't already on hold, my code throws a
NullPointerException. As I consider similar features, such as transfers
and placing books on reserve, I'm thinking that the code easily could
start getting unwieldy and confusing.Tracking State
A holding can be in a number of states: checked out, checked in, on
hold or not, in transit between branches, and so on. For the existing
code, a holding can be in one of four possible states:
- checked out, on hold
- checked out, not on hold
- checked in, on hold
- checked out, not on hold
Defining the states allows me to draw a state diagram (see Figure
1). The state diagram captures each separate state as a separate box. A
holding can transition between states when specific events occur, such
as the holding being checked in. Events usually trigger actions as part
of transitioning to another state.
Click here for a larger image.
Figure 1: State transitions.
The state pattern says that I can take each of these states and
represent them as a separate class. The current state is tracked within
Holding as a reference to a state object. I define the possible events
as methods on all states. Each state handles the set of events however
appropriately by invoking an action on the Holding object. The event
handler also is responsible for updating the state reference on the
Holding object if a state transition is required.
Listing 3: Holding, revised.// HoldingTest.java
import static org.junit.Assert.*;
import java.util.*;
import org.junit.*;
public class HoldingTest {
private Holding holding;
private static final Date NOW = new Date();
private static final Date LATER = new Date(NOW.getTime() + 1);
private static final String PATRON_ID1 = "12345";
private static final String PATRON_ID2 = "22345";
@Before
public void initialize() {
Book book = BookTest.CATCH22;
int copyNumber = 1;
holding = new Holding(book, copyNumber);
}
@Test
public void create() {
assertSame(BookTest.CATCH22, holding.getBook());
assertEquals(1, holding.getCopyNumber());
assertFalse(holding.isOnLoan());
}
@Test
public void checkout() {
holding.checkout(NOW, PATRON_ID1);
assertTrue(holding.isOnLoan());
assertEquals(NOW, holding.getLoanDate());
}
@Test
public void checkin() {
holding.checkout(NOW, PATRON_ID1);
holding.checkin(LATER);
assertFalse(holding.isOnLoan());
}
@Test
public void placeHoldOnCheckedInHolding() {
assertFalse(holding.isOnHold());
holding.placeHold(NOW, PATRON_ID1);
assertTrue(holding.isOnHold());
}
@Test
public void rejectsDuplicateHolds() {
holding.placeHold(NOW, PATRON_ID1);
try {
holding.placeHold(NOW, PATRON_ID2);
fail();
} catch (HoldException expected) {
assertTrue(holding.isOnHold());
}
}
@Test(expected = HoldException.class)
public void holdOnCheckedOutBookRetainedOnCheckin() {
holding.checkout(NOW, PATRON_ID2);
holding.placeHold(NOW, PATRON_ID1);
holding.checkin(LATER);
assertTrue(holding.isOnHold());
holding.checkout(NOW, PATRON_ID2);
}
@Test
public void releaseHold() {
holding.placeHold(NOW, PATRON_ID1);
holding.releaseAnyHold();
assertFalse(holding.isOnHold());
}
@Test
public void holdReleasedAfter3Days() {
holding.placeHold(NOW, PATRON_ID1);
holding.update(DateUtil.addDays(NOW, 1));
assertTrue(holding.isOnHold());
holding.update(DateUtil.addDays(NOW, 2));
assertTrue(holding.isOnHold());
holding.update(DateUtil.addDays(NOW, 3));
assertFalse(holding.isOnHold());
}
@Test
public void holdReleasedAfter3DaysAfterCheckin() {
holding.checkout(NOW, PATRON_ID1);
holding.placeHold(LATER, PATRON_ID1);
Date checkinDate = DateUtil.addDays(NOW, 3);
holding.update(checkinDate);
assertTrue(holding.isOnHold());
holding.checkin(checkinDate);
assertTrue(holding.isOnHold());
holding.update(DateUtil.addDays(checkinDate, 3));
assertFalse(holding.isOnHold());
}
@Test
public void releaseHoldHarmlessIfNoHolds() {
holding.releaseAnyHold();
assertFalse(holding.isOnHold());
}
@Test
public void checkoutReleasesHold() {
holding.placeHold(NOW, PATRON_ID1);
holding.checkout(LATER, PATRON_ID1);
assertFalse(holding.isOnHold());
}
@Test
public void rejectCheckoutForHoldByDifferentPatron() {
holding.placeHold(NOW, PATRON_ID1);
try {
holding.checkout(NOW, PATRON_ID2);
fail();
} catch (HoldException expected) {
}
}
}
// Holding.java
import java.util.*;
public class Holding {
private final Book book;
private final int copyNumber;
private Date checkoutDate;
private String holdPatron;
private Date holdDate;
public Holding(Book book, int copyNumber) {
this.book = book;
this.copyNumber = copyNumber;
}
public Book getBook() {
return book;
}
public int getCopyNumber() {
return copyNumber;
}
public boolean isOnLoan() {
return checkoutDate != null;
}
public Date getLoanDate() {
return checkoutDate;
}
public void checkout(Date date, String patronId) {
if (isOnHold() && patronId != holdPatron)
throw new HoldException();
releaseAnyHold();
checkoutDate = date;
}
public void checkin(Date date) {
checkoutDate = null;
}
public void placeHold(Date date, String patronId) {
if (isOnHold())
throw new HoldException();
this.holdPatron = patronId;
holdDate = new Date();
}
public boolean isOnHold() {
return holdPatron != null;
}
public void releaseAnyHold() {
holdPatron = null;
}
public void update(Date date) {
if (isOnHold() && !isOnLoan() &&
DateUtil.daysBetween(holdDate, date) >= 3)
releaseAnyHold();
}
}
I refactor my code
slowly, running tests continually. The first class I create is the
HoldingState class (see Listing 4), an abstract class intended to
provide null behavior for each of the possible events.
Listing 4: HoldingState.// HoldingState
import java.util.*;
abstract class HoldingState {
protected final Holding holding;
HoldingState(Holding holding) {
this.holding = holding;
}
void checkout(Date date, String patronId) {
}
void checkin(Date date) {
}
void placeHold(Date date, String patronId) {
}
void update(Date date) {
}
}I then create state derivatives, one for each of the three states.
After I create the derivatives, I begin to move code over from the
Holding class. For each event (checkout, checkin, placeHold, update),
the job of the Holding object is simply to delegate the event to the
current HoldingState object. For example, the checkout method in
Holding ends up reading:public void checkout(Date date, String patronId) {
state.checkout(date, patronId);
}Because these are simple delegations, I could consider using the
Java proxy mechanism. Right now, I'm not concerned about a rapidly
growing public interface on Holding, so I'll defer that enhancement.States that need to do something with the checkout event
call an action method and/or transition to another state. Other states
may choose to ignore that event. One interesting result of implementing
the state pattern, though, is that it may help point out a potential
problem in the system by making ignored events apparent. For example,
my CheckedOut state object provides no behavior for the checkout event. That unhandled event triggers me to think that I must determine what should happen if someone does try to check out a book twice.The benefit from moving all the code into the state objects is that
the conditionals begin to disappear. When in the CheckedInHeld state, I
no longer have to check whether or not a book is held before I proceed:class CheckedInHeld extends HoldingState {
// ...
@Override
public void checkout(Date date, String patronId) {
if (patronId != holding.holdPatron)
throw new HoldException();
holding.doCheckout(date, patronId);
holding.state = new CheckedOut(holding);
}
// ...
}Previously, the code in checkout in Holding had to ask
whether or not the book was already held. Because this code is
executing when the Holding references the CheckedInHeld state, that
question no longer needs to be asked.The state classes are shown in Listing 5. Each of the state classes is small, simple, and easily could be tested directly.
Listing 5: State derivatives.
// CheckedOut.java
import java.util.*;
class CheckedOut extends HoldingState {
CheckedOut(Holding holding) {
super(holding);
}
@Override
public void checkin(Date date) {
holding.doCheckin(date);
holding.state = new CheckedIn(holding);
}
@Override
public void placeHold(Date date, String patronId) {
holding.doHold(date, patronId);
holding.state = new CheckedOutHeld(holding);
}
}
// CheckedIn.java
import java.util.*;
class CheckedIn extends HoldingState {
CheckedIn(Holding holding) {
super(holding);
}
@Override
public void checkout(Date date, String patronId) {
holding.doCheckout(date, patronId);
holding.state = new CheckedOut(holding);
}
@Override
public void placeHold(Date date, String patronId) {
holding.doHold(date, patronId);
holding.state = new CheckedInHeld(holding);
}
}
// CheckedInHeld.java
import java.util.*;
public class CheckedInHeld extends CheckedIn {
CheckedInHeld(Holding holding) {
super(holding);
}
@Override
public void checkout(Date date, String patronId) {
if (patronId != holding.holdPatron)
throw new HoldException();
holding.doCheckout(date, patronId);
holding.state = new CheckedOut(holding);
}
@Override
public void placeHold(Date date, String patronId) {
throw new HoldException();
}
@Override
public void update(Date date) {
holding.doReleaseOldHold(date);
}
}
// CheckedOutHeld.java
import java.util.*;
public class CheckedOutHeld extends HoldingState {
CheckedOutHeld(Holding holding) {
super(holding);
}
@Override
public void checkin(Date date) {
holding.doCheckin(date);
holding.state = new CheckedInHeld(holding);
}
@Override
public void placeHold(Date date, String patronId) {
throw new HoldException();
}
}
The Holding class that remains (see Listing 6) is now devoid of most conditional logic! I can now test it, too, with excruciating ease.
The state pattern is one of the few design patterns that requires tight coupling between its classes: The Holding class is dependent upon the initial state, and each of the states is in turn dependent upon Holding. Although there are some clever ways to break this, it's probably not necessary, because the classes represent a self-enclosed subsystem that can remain isolated from the rest of the system. For the implementation of the state classes, I took advantage of Java's package-level access, directly having the states access Holding variables as needed, but not exposing these fields to external clients of Holding. It's one of the rare cases where I won't insist that a field remain private.
Listing 6: Holding, using the state design pattern.
import java.util.*; public class Holding { private final Book book; private final int copyNumber; Date checkoutDate; String holdPatron; Date holdDate; HoldingState state = new CheckedIn(this); Date checkinDate; public Holding(Book book, int copyNumber) { this.book = book; this.copyNumber = copyNumber; } public Book getBook() { return book; } public int getCopyNumber() { return copyNumber; } public boolean isOnLoan() { return checkoutDate != null; } public Date getLoanDate() { return checkoutDate; } public void checkout(Date date, String patronId) { state.checkout(date, patronId); } public void checkin(Date date) { state.checkin(date); } public void placeHold(Date date, String patronId) { state.placeHold(date, patronId); } public void update(Date date) { state.update(date); } public boolean isOnHold() { return holdPatron != null; } public void releaseAnyHold() { holdPatron = null; } // callback actions void doHold(Date date, String patronId) { holdPatron = patronId; holdDate = date; } void doCheckout(Date date, String patronId) { checkoutDate = date; releaseAnyHold(); } public void doCheckin(Date date) { checkoutDate = null; } public void doReleaseOldHold(Date updatedAt) { if (DateUtil.daysBetween(holdDate, updatedAt) >= 3) releaseAnyHold(); } }
Adding new functionality into the state diagram usually is simple and straightforward. The separate state classes help keep conditional logic to a minimum by avoiding confusion about the conditions under which something can occur. Often, modifications will necessitate changes to transitions between states, requiring updates to multiple state derivatives. Drawing a state diagram can be a good idea, but there's another possible solution to help manage the transitions.
State diagrams can be represented as simple tables. If I were to refactor to a more disciplined implementation of the state pattern, I could dramatically simplify my work. The table would define event methods and callback action method names; these would be used to automatically generate code for the state derivatives. This automation can be critical in a highly dynamic state system. The Object Mentor web site contains a download for just such a Java code generator, SMC (State Machine Compiler). There also are state pattern variants that can simplify state system changes in a more dynamic subsystem.

Click here for a larger image.
Figure 2: The State pattern.
REF: http://www.developer.com/design/article.php/10925_3753906_1
--
Thanks & Regards,
B.Chandra Shekhar.
Software Engineer,
Jiva Infotech Pvt Ltd.

No comments:
Post a Comment