What's a method to do?

How to maximize cohesion while avoiding explosion

In last month's Design Techniques column, I told half of the method design story: minimizing method coupling. In this month's installment, I'll reveal the other half of the story: maximizing method cohesion.

As with last month's column, "Designing fields and methods," the principles discussed may be familiar to many readers, as they apply to just about any programming language. But given the vast quantity of code I have encountered in my career that didn't benefit from these basic principles, I feel it is an important public service to address the basics in the early installments of this column. In addition, I have attempted in this article to show how the basic principles apply in particular to the Java programming language.

Cohesion

Methods do things. On a low level, they do things such as accept data as input, operate on that data, and deliver data as output. On a higher level, they do things such as "clone this object," "print this string to the standard output," "add this element to the end of this vector," and "add this much coffee to this cup object."

Minimizing coupling (the topic of last month's article) requires you to look at methods on a low level. Coupling looks at how the inputs and outputs of a method connect it to other parts of the program. By contrast, maximizing cohesion requires that you look at methods on a high level. Cohesion looks at the degree to which a method accomplishes one conceptual task. The more a method is focused on accomplishing a single conceptual task, the more cohesive that method is.

Why maximize cohesion?

The more cohesive you make your methods, the more flexible (easy to understand and change) your code will be. Cohesive methods help make your code more flexible in two ways:

  1. If your method is focused on a single conceptual task, you can more easily choose a method name that clearly indicates what your method does. For example, a method named int convertOzToMl(int ounces), which converts ounces to milliliters, is easier to comprehend at first glance than a method named int convert(int fromUnits, int toUnits, int fromAmount). At first glance, you could guess that the convert() method may be able to convert ounces to milliliters, but even if that were so, you would need to do more digging to find out what fromUnits value represents ounces and what toUnits value represents milliliters. The convertOzToMl() method is more cohesive than the convert() method because it does just one thing, and its name indicates what that thing is.

  2. Cohesive methods help make your code more flexible because changes are easier to make when you can draw upon a set of methods, each of which performs a single conceptual task. Cohesive methods increase the odds that when you need to change a class's behavior at some point in the future, you'll be able to do so by writing code that invokes existing methods in a new way. In addition, changes to an existing behavior are more isolated if that behavior is encased in its own method. If several behaviors are intermixed in a single (non-cohesive) method, changes to one behavior may inadvertently add bugs to other behaviors that share that same method.

Low cohesion

As an example of a method that is not very functionally cohesive, consider this alternate way of designing a class that models coffee cups:

// In Source Packet in file: 
//      cohesion/ex1/CoffeeCup.java
// THIS APPROACH WORKS, BUT MAKES THE CODE 
// HARD TO UNDERSTAND AND HARD TO CHANGE
public class CoffeeCup {
    public final static int ADD = 0;
    public final static int RELEASE_SIP = 1;
    public final static int SPILL = 2;
    private int innerCoffee;
    public int modify(int action, int amount) {
        int returnValue = 0;
        switch (action) {
        case ADD:
            // add amount of coffee
            innerCoffee += amount;
            // return zero, even though that is meaningless
            break;
        case RELEASE_SIP:
            // remove the amount of coffee passed as amount
            int sip = amount;
            if (innerCoffee < amount) {
                sip = innerCoffee;
            }
            innerCoffee -= sip;
            // return removed amount
            returnValue = sip;
            break;
        case SPILL:
            // set innerCoffee to 0
            // ignore parameter amount
            int all = innerCoffee;
            innerCoffee = 0;
            // return all coffee
            returnValue = all;
        default:
            // Here should throw an exception, because they
            // passed an invalid command down in action
            break;
        }
        return returnValue;
    }
}

CoffeeCup's modify() method is not very cohesive because it includes code to do tasks that, conceptually, are quite different. Yes, it is a useful method. It can add, sip, and spill, but it can also perplex, befuddle, and confuse. This method is difficult to understand partly because its name, modify(), isn't very specific. If you tried to make the name more specific, however, you would end up with something like addOrSipOrSpill(), which isn't much clearer.

Another reason modify() is hard to understand is that some of the data passed to it or returned from it is used only in certain cases. For example, if the action parameter is equal to CoffeeCup.ADD, the value returned by the method is meaningless. If action equals CoffeeCup.SPILL, the amount input parameter is not used by the method. If you look only at the method's signature and return type, it is not obvious how to use the method.

Figure 1: Passing control down to modify() .

See Figure 1 for a graphical depiction of this kind of method. In this figure, the circle for the action parameter is solid black. The blackened circle indicates that the parameter contains data that is used for control. You can differentiate data that is used for control from data that isn't by looking at how a method uses each piece of input data. Methods process input data and generate output data. When a method uses a piece of input data not for processing, but for deciding how to process, that input data is used for control.

To maximize cohesion, you should avoid passing control down into methods. Instead, try to divide the method's functionality among multiple methods that don't require passing down control. In the process, you'll likely end up with methods that have a higher degree of cohesion.

By the way, it is fine to pass data used for control back up from a method. (Throwing an exception is a good example of passing control up.) In general, up is the direction control should go: Data used for control should be passed from a method back to the method that invoked it.

Medium cohesion

To increase the method cohesion of the previous CoffeeCup class, you could divide the functionality performed by modify() into two methods, add() and remove():

// In Source Packet in file cohesion/ex2/CoffeeCup.java
// THIS APPROACH WORKS, BUT MAKES THE CODE HARD TO UNDERSTAND
// AND HARD TO CHANGE
public class CoffeeCup {
    private int innerCoffee;
    public void add(int amount) {
        innerCoffee += amount;
    }
    public int remove(boolean all, int amount) {
        int returnValue = 0;
        if (all) {
            // set innerCoffee to 0
            // ignore parameter amount
            int allCoffee = innerCoffee;
            innerCoffee = 0;
            returnValue = allCoffee; // return all coffee
        }
        else {
            // remove the amount of coffee passed as amount
            int sip = amount;
            if (innerCoffee < amount) {
                sip = innerCoffee;
            }
            innerCoffee -= sip;
            returnValue = sip; // return removed amount
        }
       return returnValue;
    }
}
Figure 2: Passing control down to remove() .

This is a better design, but it's not quite there yet. Although the add() method does not require you to pass down control, the remove() method still does. The boolean parameter all indicates to the remove method whether or not to remove all coffee (a spill) or to remove some coffee (a sip). In the case of a sip, the amount parameter indicates the amount of coffee to remove (the size of the sip). The graphical depiction of the remove() method, shown in Figure 2, shows a blackened circle heading down for the all parameter just as modify() had a blackened circle heading down for the action parameter. It also includes a parameter, amount, that is not always used, just as modify() is not always used. For remove(), if all is false, amount indicates the amount of coffee to remove. If all is true, amount is ignored.

High cohesion

A better design for the CoffeeCup class is to divide remove() into two more methods, neither of which accept control data as input or have parameters that are used only part of the time. Here remove() has been divided into releaseOneSip() and spillEntireContents():

// In Source Packet in file cohesion/ex3/CoffeeCup.java
class CoffeeCup {
    private int innerCoffee;
    public void add(int amount) {
        innerCoffee += amount;
    }
    public int releaseOneSip(int sipSize) {
        int sip = sipSize;
        if (innerCoffee < sipSize) {
            sip = innerCoffee;
        }
        innerCoffee -= sip;
        return sip;
    }
    public int spillEntireContents() {
        int all = innerCoffee;
        innerCoffee = 0;
        return all;
    }
}

As you can see, the process of removing input data used for control yields more methods, each with a more focused functionality. Instead of indicating your wishes to one comprehensive method by passing down a command as a parameter, you call a different method. For example, instead of saying:

// In Source Packet in file cohesion/ex1/Example1.java
class Example1 {
    public static void main(String[] args) {
        CoffeeCup cup = new CoffeeCup();
        // ignore bogus return value of modify() in ADD case
        cup.modify(CoffeeCup.ADD, 355);
        int mlCoffee = cup.modify(CoffeeCup.RELEASE_SIP, 25);
        // 2nd parameter is unused in SPILL case
        mlCoffee += cup.modify(CoffeeCup.SPILL, 0);
        System.out.println("Ml of coffee: " + mlCoffee);
    }
}

You say:

// In Source Packet in file cohesion/ex3/Example3.java
class Example3 {
    public static void main(String[] args) {
        CoffeeCup cup = new CoffeeCup();
        cup.add(355);
        int mlCoffee = cup.releaseOneSip(25);
        mlCoffee += cup.spillEntireContents();
        System.out.println("Ml of coffee: " + mlCoffee);
    }
}

As described earlier, this approach to method design yields code that is easier to understand because each method is responsible for performing one conceptual function, and the method's name can describe that one function. Such code is also easier to understand because the data passed in and out are always used and valid. In this example,

add(int),int releaseOneSip(int),

and

spillEntireContents()

are easier to understand at first glance than the

int modify(int, int)

from the low cohesion example.

In addition, this approach to method design yields code that is more flexible, because it is easier to change one functionality without affecting the others. For example, if you wanted to make some adjustments to the spilling behavior of the coffee cup class with modify(), you would have to edit the body of modify(). Because the code for spilling is intermingled in modify() with the code for sipping and adding, you might inadvertently introduce a bug in the adding behavior when you enhance the spilling behavior. In the CoffeeCup class with separate methods for adding, spilling, and sipping, your chances are better that you can enhance the spilling behavior without disturbing the adding and sipping behaviors.

Reducing assumptions

Functionally cohesive methods also increase code flexibility because they make fewer assumptions about the order in which particular actions are performed. Here is an example of a method that is not very functionally cohesive because it assumes too much:

// In Source Packet in file cohesion/ex4/CoffeeCup.java
class CoffeeCup {
    private int innerCoffee;
    private int innerCream;
    private int innerSugar;
    private static final int CREAM_FRACTION = 30;
    private static final int SUGAR_FRACTION = 30;
    public void add(int amountOfCoffee) {
        innerCoffee += amountOfCoffee;
        innerCream += amountOfCoffee/CREAM_FRACTION;
        innerSugar += amountOfCoffee/SUGAR_FRACTION;
    }
    //...
}

This CoffeeCup object keeps track not only of the amount of coffee it contains (innerCoffee), but also of the amount of cream (innerCream) and sugar (innerSugar). As you would expect, the add() method accepts an amount of coffee to add, then increments innerCoffee by that amount; however, add() doesn't stop there. It assumes that anyone wishing to add coffee to a cup also would want to add some cream and sugar, in fixed amounts relative to the amount of coffee added. So add() goes ahead and adds the cream and sugar as well.

The design of this method reduces code flexibility because later, if a programmer wanted to add coffee with cream, but no sugar, this method would be of no use. A more flexible design would be:

1 2 Page
Join the discussion
Be the first to comment on this article. Our Commenting Policies
See more