Using the if-then-else framework, Part 1

Code maintainable branching logic with the if-then-else framework

No one should have to read or maintain code shown in Listing 1 below. Code that includes nested ifs, as in this example, can be a maintenance nightmare. It is usually difficult to read and understand, and even more difficult to modify, which makes the code highly error-prone.

Listing 1. Sample nested-if code

void decideUrl() {
     DataBank.Region region = db.getRegion();
     double limit = db.getLimit();
     String id = db.getUserId();
     if(region.equals(DataBank.EAST_REGION)) {
         if(limit > db.LIMIT_THRESHOLD) {
             setUrl(EAST_PRIVILEGED);
         }
         else {
             setUrl(EAST_NOT_PRIVILEGED);
         }
     }
     else if(region.equals(DataBank.WEST_REGION)) {
         if(isMemberWestAlliance(id)) {
             if(limit > db.LIMIT_THRESHOLD) {
                 setUrl(WEST_MEMBER_PRIVILEGED);
             }
             else {
                 setUrl(WEST_MEMBER_NOT_PRIVILEGED);
             }
         }
         else {
             if(limit > db.LIMIT_THRESHOLD) {
                 setUrl(WEST_NONMEMBER_PRIVILEGED);
             }
             else {
                 setUrl(WEST_NONMEMBER_NOT_PRIVILEGED);
             }
         }
     }
     else {
         setUrl(OTHER_REGION);
     }
 }

TEXTBOX:

TEXTBOX_HEAD: Using the if-then-else framework: Read the whole series!

:END_TEXTBOX But what are you supposed to do when you need to write code that has branching logic? Often, you can sidestep the issue by making sure that lookup values are stored in a database. If the database solution is not an option, you can usually solve the problem by mimicking the database solution with Java Hashtables. But if you have to repeatedly implement this solution, with slightly different conditions and events, you will discover as I have, a burning need for a more general solution.

This article is about a little framework I have developed for writing code with branching logic without nested ifs. The framework resides in a single package and has a simple API that you must use. Using this framework, you can rewrite the code in Listing 1 in just a few lines (shown below), and encapsulate the conditions and consequences in a table that is easy to maintain.

void decideUrl() {
    try {
        Invoker inv = new ConcreteInvoker((Updateable)this);
        inv.execute();
    }
    catch(NestingTooDeepException ntde) {}
    catch(IllegalExpressionException iee) {}
    catch(RuleNotFoundException rnfe) {}
    catch(DataNotFoundException dnfe) {}
}

My discussion of this framework will span three issues of JavaWorld. The first two parts of the series are devoted to an explanation of how to use the framework. As I develop my ideas, I will show you how to code the branching logic of Listing 1 without nested ifs. After reading these first two parts, you should easily be able use the framework in your own work -- simply import the logic package (see Resources) and follow the steps described in the article. In the third part of the series, I will explain in greater detail the design and implementation of the framework, and will discuss refinements that you can introduce if desired. The seed ideas for this framework are among the many profound design secrets I learned from Ali Arsanjani during informal discussions in 1998 (see Resources).

You can download all of the code that is used in these articles from Resources. You can see the logic of decideURL() (Listing 1) in action by loading the HTML page IteGUI.html; this file loads the IteGUI applet, which provides a convenient visual representation of the method's behavior. By running this applet online, you can connect to the URLs that are referred to in the code. You can also run the code as either an application or applet locally, but the URL will not be accessible in that case. See the Resources section for more details.

In order to have a runnable application with which to work, I wrapped the decideURL() method from Listing 1 in a class called URLProcessor_bad. As I rewrite this method, I will create an alternative class named URLProcessor_good that will contain a revised version of decideURL(). Both of these classes inherit from the abstract class URLProcessor. These classes will be the focal point of my discussion. I will also make use of the following two auxiliary classes that provide support for the main classes: DataBank provides access to all relevant data, and IteGUI provides the code for a simple user interface that can be used for entering data and displaying results. Figure 1 shows the relationships among these classes.

Figure 1. Static relationships among main user classes

Analysis of if-then-else logic

Before examining the components used in the framework, I'll give a quick analysis of the code structure in Listing 1, which will help to motivate the framework's design. The result of executing the code in Listing 1 is that a value for the URL field is set. The actual value is determined by evaluating several conditionals, following a branch whose nodes always evaluate to true. The logical components of this behavior are conditions and actions. If a sequence of conditions evaluates to true, the appropriate action (in this case, setting the value of a field) is performed. For instance, one scenario that could occur in Listing 1 is the following: the user's region is "East" and the limit happens to be less than or equal to LIMIT_THRESHOLD (a constant in DataBank). With these conditions satisfied, the code sets the URL to be EAST_NOT_PRIVILEGED (a constant in URLProcessor).

Elements of the if-then-else framework

This short analysis suggests two main data types or classes that you can use in this framework: Condition and Action. A Condition should encapsulate the requirements of any condition that might occur in nested-if logic, and an Action should encapsulate the ingredients for carrying out virtually any action. To this end, I have equipped Condition with the method boolean evaluate() and Action with the method void execute(). For these methods to execute correctly, the framework user must initialize them with adequate information. I have provided a number of constructors in Condition to accommodate the most common scenarios for evaluating a condition, and one more-general constructor to handle all other cases. I'll cover the details in a moment. In the case of Action, for the sake of generality I have required that an updateable object be passed in to its constructor. The execute() method of Action then performs appropriate updates on this updateable object. In order to provide generic access to objects that need to be updated, I've included a Java interface named Updateable in the framework package; the object that should be updated by an Action must implement this interface. The interface consists of a single method doUpdate():

public class Condition {
    Hashtable data;
    public Condition(double d1, String operator, double d2) {
        //set values
    }
        . . .
    //other constructors
        . . .
    public boolean evaluate() {
        //default implementation
    }
}
public class Action {
    public Action(Updateable ud, Object attribute) {
        //set values
    }
    public void execute() {
        //default implementation
    }
}
public interface Updateable {
    public void doUpdate();
}

The final ingredient you will need is a class that will manage loading up the appropriate instances of Condition and matching them with appropriate instances of Action. I named this class Invoker, which has two abstract methods -- loadConditions() and loadRules() -- that must be implemented in a user-defined subclass of Invoker. In the example below, I named this user-defined subclass ConcreteInvoker.

abstract public class Invoker {
    protected Updateable ud;
        protected Rules rules;
        protected Vector conditions;
        protected Vector actions;
    public Invoker(Updateable u) {
            this.ud = u;
            init();
    }
    public void execute() {
        loadConditions();
            new IfThenElse(conditions, rules);
        }
    private void init() {
        loadRules();
    }
    abstract public void loadConditions();
    abstract public void loadRules();
}
public class ConcreteInvoker extends Invoker {
    public ConcreteInvoker(Updateable u) {
        super(u);
    }
    public void loadConditions() {
        //implement
    }
    public void loadRules() {
        //implement
    }
}

I can now describe the flow of execution. To start the process, a controlling part of the application creates an instance of Invoker, passing in an instance of Updateable. In my example, this will take place in the decideURL() method of URLProcessor_good. The Invoker constructor loads up the rules -- that is, the match-up between possible values of Conditions and instances of Action (I will explain more about Rules below.

This is all there is to initialization. Figure 2 provides a sequence diagram for these simple steps.

Figure 2. Initialization of framework classes

When it's time to evaluate the if-then-else logic and carry out the corresponding actions (for this example, when it is time to execute the logic in decideURL()), the application invokes the execute() method on Invoker, which loads up the conditions that reflect the current state, and fires off the framework engine, passing in the vectors of rules and conditions. The framework engine -- that is, the instance of the IfThenElse class -- uses its Rules instance to look up the appropriate instance of Action and requests it to fire its execute() method. Typically, execute() calls the doUpdate() method on the Updateable object. Figure 3 illustrates this process.

Figure 3. Execution of branching logic in the if-then-else framework

As mentioned earlier, I am using the term "rules" to refer to the match-up between a sequence of booleans (which represent a sequence of evaluated conditions) and a corresponding action. (As you will see in a moment, when defining these rules, you represent a sequence of booleans as a string of Ts and Fs, where T stands for true and F stands for false.)

Because rules are so important, I have encapsulated a set of rules in its own framework class called Rules. Here is a skeleton of the Rules class:

public class Rules {
    private Hashtable ruleTable;
    public void addRule(String boolStr, Action action)
        throws NestingTooDeepException {
    . . .
    }
    public Action lookUp(BooleanTuple bt) {
        . . .
    }
}

The two methods in the Rules class are addRule and lookUp. The addRule(String boolStr, Action action) method first converts a String boolStr of Ts and Fs to a sequence of Boolean instances, represented by the framework class BooleanTuple. The instance of BooleanTuple is then paired with the argument action in the Hashtable ruleTable.

The method lookUp(BooleanTuple bt) is the inverse operation. It looks up the instance of Action in ruleTable using the passed in BooleanTuple as a key.

In practice, when you implement the loadRules() method in your subclass of Invoker, you create an instance of Rules, and then make one call to addRule(..) for each relevant sequence of Ts and Fs and corresponding instance of Action.

Four steps to implement the if-then-else framework

The discussion so far has been abstract. To help make the ideas more concrete, I will outline a four-step procedure for implementing the if-then-else framework and apply it to the task of rewriting the decideURL() method in Listing 1. Here is an overview of the four steps:

  1. Determine the conditions. Determine the individual conditions involved in the logic at hand, and implement each as an instance of Condition (or possibly of a Condition subclass).

  2. Determine the actions. Determine the possible actions that need to be executed and implement each as an instance of Action (or possibly of an Action subclass).

  3. Implement the Updateable interface. Decide which object(s) should receive the actions implemented in Step 2 and make sure that it (they) implement the Updateable interface (this means that you need to implement the doUpdate() method).

  4. Subclass the Invoker class. Create a concrete invoker subclass of Invoker and implement the loadConditions() and loadRules() methods.

Now I will tell you how to implement Step 1 to rewrite the decideURL() method. Next month, Part 2 will complete the discussion of the implementation for Steps 2 through 4.

Step 1: Determine the conditions

By examining the body of the decideURL() method in Listing 1, you'll find (apparently) three conditions: One condition is concerned with location (whether the user is in EAST_REGION, WEST_REGION, or neither); another is concerned with the value of limit (whether or not it exceeds LIMIT_THRESHOLD); and the third, which pertains only to the middle block of ifs, tests whether or not a user belongs to "West Alliance." In order to implement these conditions as instances of the framework class Condition, you need to be sure that each condition evaluates to either "true" or "false," since the evaluate() method of this class must return a booleanBoolean. It is clear that the last two conditions ("limit" and "membership") satisfy this requirement -- a user's limit either exceeds or does not exceed the LIMIT_THRESHOLD, and a user either does or does not belong to the "West Alliance."

However, the first condition, as I have stated it, does not comply with this requirement. The user's location could be one of three values: EAST_REGION, WEST_REGION, or neither. In order to model these in the framework, it is necessary to think of each location as determining its own instance of Condition -- a user's location either is or is not EAST_REGION, is or is not WEST_REGION, and is or is not "neither." This analysis tells me that I will need five instances of Condition -- one for "limit," one for "membership," and three for "location."

As mentioned earlier, I have designed the Condition class to be fairly flexible -- you can implement a wide variety of conditions simply by instantiating Condition using the appropriate constructor. In these cases, the evaluate() method has already been implemented for you. As you will see, three of the five conditions in this example can be implemented in this straightforward manner (they are the "limit" condition, and the "East" location and "West" location conditions). You will need to subclass Condition and implement your own evaluate() method in order to handle the other two (the "neither" location condition, and the "membership" condition).

Below are three of the constructors available in Condition:

public Condition(double d1, int op, double d2);
public Condition(Object ob1, Object ob2);
public Condition(Hashtable data);

The first of these constructors requires that two doubles be passed in, along with an int representation of an operator. The operator can be any of the following constants: Condition.LESS, Condition.LESS_EQUAL, or Condition.EQUAL. The corresponding evaluate() method simply checks to see if the argument d1 is less than, less than or equal to, or equal to d2, depending on the choice of operator. You can use this constructor to implement the "limit" condition described previously. The condition, as it appears in Listing 1, requires you to test whether or not the value stored in limit exceeds DataBank.LIMIT_THRESHOLD. I implement this by creating an instance of Condition as follows:

new Condition(db.LIMIT_THRESHOLD, Condition.LESS_EQUAL, db.getLimit());

Here, db is the instance of DataBank that is assumed to have been created earlier, and the method db.getLimit() gets the user-entered value for limit that was retrieved and stored in db earlier (you can run IteGUI.html in Resources to see an implementation of this earlier phase in the process). The evaluate() method in this condition will then compare this user-entered value of limit with the constant db.LIMIT_THRESHOLD using the less-than-or-equal operator, and will return the appropriate Boolean.

The Condition class has similar constructors to handle other primitive types like int, float, and boolean; the example in this article will not require these constructors.

Sometimes it is necessary to compare objects rather than primitives. The second of the constructors listed above -- Condition(Object ob1, Object ob2) -- was designed for this purpose.

If this constructor is used, the evaluate() method returns the value of ob1.equals(ob2). As usual, unless you override the equals() method that is available in the Object class, the method will check only to see whether or not the objects are identical (that is, that they have the same object id).

You can use this particular constructor in the present example for two of the "location" conditions -- whether the region is or is not EAST_REGION, and whether it is or is not WEST_REGION. Let's consider the EAST_REGION condition. "Regions" are represented by an inner class named Region of DataBank; Listing 2 shows the code for this supplementary class.

Listing 2. The class Region, an inner class of DataBank

static class Region {
static final int EAST = 0;
    static final int WEST = 1;
    static final int MIDWEST = 2;
    static final int NORTH = 3;
    static final int SOUTH = 4;
    int regionName;
    Region(int name) {
        regionName = name;
    }
    public int getRegionName() {
        return regionName;
    }
    public boolean equals(Object ob) {
        if(!(ob instanceof DataBank.Region)) return false;
        DataBank.Region r = (DataBank.Region)ob;
        return (r.getRegionName() == regionName);
    }
}

In Listing 1, you can see that two instances of Region must be compared. As you can see in Listing 2, Region already has its own equals method, so no extra work will be required in that respect. The condition for testing for the EAST_REGION location is therefore:

new Condition(db.getRegion(), DataBank.EAST_REGION);

In this example, the getRegion() method of DataBank returns a user-selected value of Region, and the evaluate() method of this new Condition will use Region's equals method to compare this user-entered value with the constant DataBank.EAST_REGION.

Likewise, the condition for testing the WEST_REGION location is obtained like this:

new Condition(db.getRegion(), DataBank.WEST_REGION);

Let's turn to the third location condition, "neither," which should evaluate to true just in case the user's region is neither EAST_REGION nor WEST_REGION. The logic involved here is slightly too complex to be modeled by any of the default implementations of Condition. In such a situation, you should use the third constructor listed above -- Condition(Hashtable data) -- and you must implement your own version of the evaluate() method.

To do this, you will have to create a subclass of Condition, which I call OtherCondition. The constructor I use takes a Hashtable as an argument. The Hashtable must contain the data required for the evaluate() method to produce a correct Boolean return value. For the "neither" condition, it is enough to include the user's region in the Hashtable. The evaluate() method can then unpack the Hashtable and return true only when the user's region is neither EAST_REGION nor WEST_REGION. I have created a constant KEY in OtherCondition for use as a Hashtable key corresponding to the region in the Hashtable. Therefore, you can create an instance of the OtherCondition as follows:

Hashtable other = new Hashtable();
other.put(OtherCondition.KEY, db.getRegion());
new OtherCondition(other);

The OtherCondition class has the following implementation:

class OtherCondition extends Condition {
    public static final String KEY = "key";
    public OtherCondition(Hashtable ht) {
        super(ht);
    }
    public Boolean evaluate() {
        Object ob = getData().get(KEY)
        if(!(ob instanceof DataBank.Region)) {
            return Boolean.FALSE;
        }
        DataBank.Region region = (DataBank.Region)ob;
        boolean result =
            !region.equals(DataBank.WEST_REGION) &&
            !region.equals(DataBank.EAST_REGION);
    return new Boolean(result);
    }
}

Examine the code for OtherCondition for a moment, and notice that the constructor calls the superclass constructor. The Condition constructor that accepts a Hashtable simply caches the Hashtable argument in the instance variable data, which subclasses can in turn access via the getter getData().

This explains the first line of the evaluate() method in OtherCondition. The next two lines perform a type safety check, and then the value in the Hashtable is cast to its expected type Databank.Region. The boolean result stores the result of comparing the user's region with WEST_REGION and EAST_REGION. Notice that the routine returns an instance of Boolean rather than a boolean -- the reason has to do with the fact that primitives like booleans cannot be stored in a Hashtable, but instances of Boolean can. I will reserve a discussion of this point for Part 3 of the article.

Finally, let's consider the membership condition. In Listing 1, decideURL() makes a call to the method isMemberWestAlliance(String id) in order to evaluate this condition.

Here is a listing of this method:

boolean isMemberWestAlliance(String id) {
    Hashtable westMembers = db.getWestMembers();
    return westMembers.containsKey(id);
}

Basically, the method checks to see if the user id that is passed in is one of the keys in the DataBank Hashtable westMembers, a Hashtable that is initialized in DataBank at startup.

In the rewrite of decideURL(), you will wrap this code in a subclass of Condition. The evaluate() method will require two pieces of information this time: the user id and the Hashtable westMembers. You store these using MemberCondition constants KEY and TABLE as keys. The evaluate() method, after unpacking the westMembers Hashtable, does what the isMemberWestAlliance(String id) method did: it checks to see if the user id that was passed in is one of the keys. You construct the MemberCondition like this:

class MemberCondition extends Condition {
    public static final String KEY = "id";
    public static final String TABLE = "table";
    public MemberCondition(Hashtable ht) {
        super(ht);
    }
    public Boolean evaluate(){
        Object id = getData().get(KEY);
    Object table = getData().get(TABLE);
        Hashtable members = (Hashtable)table;
        return new Boolean(members.containsKey(id));
    }
}

MemberCondition is instantiated using the following piece of code:

Hashtable mem = new Hashtable();
mem.put(MemberCondition.TABLE, db.getWestMembers());
mem.put(MemberCondition.KEY, db.getUserId());
new MemberCondition(mem);

I should point out that the evaluate() methods for the OtherCondition and MemberCondition classes, as I have displayed them here, fail to do sensible data validation. The actual sample code in Resources does these validity checks, however, and throws appropriate exceptions when necessary. I will cover the topic of exception-handling for this framework in Part 2.

By now, you have succeeded in creating each of the five Conditions in your effort to implement the logic of Listing 1 using this new framework. This was the hard part because the example used in this article (intentionally) made use of a diverse range of logical conditions. The tasks that remain, which I will discuss in Part 2, are the following:

  • Each of the possible sequences of booleans that could be generated by evaluating your conditions must be associated with an action, that is, an instance of Action. Because the actions in this example are simple, it will be sufficient, as you shall see, to simply instantiate each of the required actions with the appropriate updateable object and the appropriate URL (recall, in Listing 1, that the action in every case simply sets a value for the URL); the default behavior of the execute() method in Action will be exactly what you need in each of these cases.

  • You must pick a class to implement the Updateable interface so that each Action instance can request that an update be performed; the doUpdate() method that you will need to implement will simply set the value of the URL.

  • You need to put all of the pieces together by creating a subclass of Invoker that will load your Conditions and Rules.

This list describes the remaining three steps in implementing the if-then-else framework in my example. However, in order to implement the framework properly, you will also need to know how it expects you to handle exceptions. I will discuss all of these issues in Part 2. I will also offer some tips on how to package all of the classes you create in using the framework, and discuss in some detail the advantages, from the point of view of maintainability, of code written using the framework over traditional nested-if code.

Homework

If you are eager to try out the if-then-else framework, there are a couple of things you can do. One is to attempt to carry out Steps 2 through 4 yourself. If you get stuck, look at the sample code in Resources. Another is to just read the sample code , and then try to apply the framework to one of your own examples. Either way, Part 2 of this article will fill in some gaps and answer some questions that will probably arise.

Paul Corazza is a software professional who has applied his PhD training in Mathematical Logic to a broad range of pure and applied fields in the past 12 years. In the last 4 years, he has concentrated on the development of business rules frameworks, joining design patterns from the object-oriented world with tools for analysis of syntax from the world of logic. Paul is President and Lead Consultant of Corazza Software Solutions, a Java consulting firm that provides software engineering and training services.

Learn more about this topic

  • Recent work by Ali Arsanjani
  • "Service ProviderA Domain Pattern and Its Business Framework Implementation," presented to PloP '99, is available from the online proceedings at
    http://st-www.cs.uiuc.edu/~plop/plop99/proceedings/Arsanjani/provider3.pdf
  • "Rule ObjectA Pattern Language for Pluggable and Adaptive Business Rule Construction," submitted to KoalaPLoP 2000. This paper will appear in the conference proceedings at the conclusion of the conference on May 26, 2000. Look for it online
    http://www.bell-labs.com/topic/conferences/KoalaPLoP/
  • "Analysis, Design, and Implementation of Distributed Java Business Frameworks Using Domain Patterns," Proceedings of Tools '99 (IEEE Computer Society Press 1999), pp. 490-500.

Join the discussion
Be the first to comment on this article. Our Commenting Policies