© Edward Sciore 2019
Edward ScioreJava Program Designhttps://doi.org/10.1007/978-1-4842-4143-1_1

1. Modular Software Design

Edward Sciore1 
(1)
Newton, MA, USA
 

When a beginning programmer writes a program, there is one goal: the program must work correctly. However, correctness is only a part of what makes a program good. Another, equally important part is that the program be maintainable.

Perhaps you have experienced the frustration of installing a new version of some software, only to discover that its performance has degraded and one of the features you depend on no longer works. Such situations occur when a new feature changes the existing software in ways that other features did not expect.

Good software is intentionally designed so that these unexpected interactions cannot occur. This chapter discusses the characteristics of well-designed software and introduces several rules that facilitate its development.

Designing for Change

Software development typically follows an iterative approach. You create a version, let users try it, and receive change requests to be addressed in the next version. These change requests may include bug fixes, revisions of misunderstandings of how the software should work, and feature enhancements.

There are two common development methodologies. In the waterfall methodology, you begin by creating a design for the program, iteratively revising the design until users are happy. Then you write the entire program, hoping that the first version will be satisfactory. It rarely is. Even if you manage to implement the design perfectly, users will undoubtedly discover new features that they hadn’t realized they wanted.

In the agile methodology, program design and implementation occur in tandem. You start by implementing a bare-bones version of the program. Each subsequent version implements a small number of additional features. The idea is that each version contains “just enough” code to make the chosen subset of features work.

Both methodologies have their own benefits. But regardless of which methodology is used, a program will go through several versions during its development. Waterfall development typically has fewer iterations, but the scope of each version change is unpredictable. Agile development plans for frequent iterations with small, predictable changes.

The bottom line is that programs always change. If a program doesn’t work the way users expect then it will need to be fixed. If a program does work the way users expect then they will want it to be enhanced. It is therefore important to design your programs so that requested changes can be made easily, with minimal modification to the existing code.

Suppose that you need to modify a line of code in a program. You will also need to modify the other lines of code that are impacted by this modification, then the lines that are impacted by those modifications, and so on. As this proliferation increases, the modification becomes more difficult, time-consuming, and error prone. Therefore, your goal should be to design the program such that a change to any part of it will affect only a small portion of the overall code.

This idea can be expressed in the following design principle. Because this principle is the driving force behind nearly all the design techniques in this book, I call it the fundamental design principle .

The Fundamental Principle of Software Design

A program should be designed so that any change to it will affect only a small, predictable portion of the code.

For a simple illustration of the fundamental design principle, consider the concept of variable scope. The scope of a variable is the region of the program where that variable can be legally referenced. In Java, a variable’s scope is determined by where it is declared. If the variable is declared outside of a class then it can be referenced from any of the class’s methods. It is said to have global scope. If the variable is declared within a method then it can be referenced only from within the code block where it is declared, and is said to have local scope.

Consider the class ScopeDemo in Listing 1-1. There are four variables: x, z, and two versions of y. These variables have different scopes. Variable x has the largest scope; it can be referenced from anywhere in the class. Variable y in method f can only be accessed from within that method, and similarly for variable y in g. Variable z can only be accessed from within f’s for-loop.
public class ScopeDemo {
   private int x = 1;
   public void f() {
      int y = 2;
      for (int z=3; z<10; z++) {
         System.out.println(x+y+z);
      }
      ...
   }
   public void g() {
      int y = 7;
      ...
   }
}
Listing 1-1

The ScopeDemo Class

Why should a programmer care about variable scoping? Why not just define all variables globally? The answer comes from the fundamental design principle. Any change to the definition or intended use of a variable could potentially impact each line of code within its scope. Suppose that I decide to modify ScopeDemo so that the variable y in method f has a different name. Because of y’s scope, I know that I only need to look at method f, even though a variable named y is also mentioned in method g. On the other hand, if I decide to rename variable x then I am forced to look at the entire class.

In general, the smaller the scope of a variable, the fewer the lines of code that can be affected by a change. Consequently, the fundamental design principle implies that each variable should have the smallest possible scope.

Object-Oriented Basics

Objects are the fundamental building blocks of Java programs. Each object belongs to a class, which defines the object’s capabilities in terms of its public variables and methods. This section introduces some object-oriented concepts and terminology necessary for the rest of the chapter.

APIs and Dependencies

The public variables and methods of a class are called its Application Program Interface (or API). The designer of a class is expected to document the meaning of each item in its API. Java has the Javadoc tool specifically for this purpose. The Java 9 class library has an extensive collection of Javadoc pages, available at the URL https://docs.oracle.com/javase/9/docs/api . If you want to learn how a class from the Java library works then this is the first place to look.

Suppose the code for a class X holds an object of class Y and uses it to call one of Y’s methods. Then X is called a client of Y. Listing 1-2 shows a simple example, in which StringClient is a client of String.
public class StringClient {
   public static void main(String[] args) {
      String s = "abc";
      System.out.println(s.length());
   }
}
Listing 1-2

The StringClient Class

A class’s API is a contract between the class and its clients. The code for StringClient implies that the class String must have a method length that satisfies its documented behavior. However, the StringClient code has no idea of or control over how String computes that length. This is a good thing, because it allows the Java library to change the implementation of the length method as long as the method continues to satisfy the contract.

If X is a client of Y then Y is said to be a dependency of X. The idea is that X depends on Y to not change the behavior of its methods. If the API for class Y does change then the code for X may need to be changed as well.

Modularity

Treating an API as a contract simplifies the way that large programs get written. A large program is organized into multiple classes. Each class is implemented independently of the other classes, under the assumption that each method it calls will eventually be implemented and do what it is expected to do. When all classes are written and debugged, they can be combined to create the final program.

This design strategy has several benefits. Each class will have a limited scope and thus will be easier to program and debug. Moreover, the classes can be written simultaneously by multiple people, resulting in the program getting completed more quickly.

We say that such programs are modular . Modularity is a necessity; good programs are always modular. However, modularity is not enough. There are also important issues related to the design of each class and the connections between the classes. The design rules later in this chapter will address these issues.

Class Diagrams

A class diagram depicts the functionality of each class in a program and the dependencies between these classes. A class diagram has a rectangle for each class. The rectangles have three sections: the top section contains the name of the class, the middle section contains variable declarations, and the bottom section contains method declarations. If class Y is a dependency of class X then the rectangle for X will have an arrow to the rectangle for Y. The arrow can be read “uses,” as in “StringClient uses String.” Figure 1-1 shows a class diagram for the code of Listing 1-2.
../images/470600_1_En_1_Chapter/470600_1_En_1_Fig1_HTML.jpg
Figure 1-1

A class diagram for Listing 1-2

Class diagrams belong to a standard notational system known as UML (for Universal Modeling Language). UML class diagrams can have many more features than described here. Each variable and method can specify its visibility (such as public or private) and variables can have default values. In addition, the UML notion of dependency is broader and more nuanced. The definition of dependency given here is actually a special kind of UML dependency called an association . Although these additional modeling features enable UML class diagrams to more accurately specify a design, they add a complexity that will not be needed in this book and will be ignored.

Class diagrams have different uses during the different phases of a program’s development. During the implementation phase, a class diagram documents the variables and methods used in the implementation of each class. It is most useful when it is as detailed as possible, showing all the public and private variables and methods of each class.

During the design phase, a class diagram is a communication tool. Designers use class diagrams to quickly convey the functionality of each class and its role in the overall architecture of the program. Irrelevant classes, variables, methods and arrows may be omitted in order to highlight a critical design decision. Typically, only public variables and methods are placed in these class diagrams. Figure 1-1 is an example of a design-level class diagram: the private variable of type StringClient is omitted, as are the unreferenced methods in String. Given that this book is about design, it uses design-level class diagrams exclusively. Most of the classes we model will have no public variables, which means that the middle section of each class rectangle will usually be empty.

Static vs. Nonstatic

A static variable is a variable that “belongs” to a class. It is shared among all objects of the class. If one object changes the value of a static variable then all objects see that change. On the other hand, a nonstatic variable “belongs” to an object of the class. Each object has its own instance of the variable, whose value is assigned independently of the other instances.

For example, consider the class StaticTest in Listing 1-3. A StaticTest object has two variables: a static variable x and a nonstatic variable y. Each time a new StaticTest object is created, it will create a new instance of y and overwrite the previous value of x.
public class StaticTest {
   private static int x;
   private int y;
   public StaticTest(int val) {
      x = val;
      y = val;
   }
   public void print() {
      System.out.println(x + " " + y);
   }
   public static int getX() {
      return x;
   }
   public static void main(String[] args) {
      StaticTest s1 = new StaticTest(1);
      s1.print();  //prints "1 1"
      StaticTest s2 = new StaticTest(2);
      s2.print();  //prints "2 2"
      s1.print();  //prints "2 1"
   }
}
Listing 1-3

The StaticTest Class

Methods can also be static or nonstatic. A static method (such as getX in StaticTest) is not associated with an object. A client can call a static method by using the class name as a prefix. Alternatively, it can call a static method the conventional way, prefixed by a variable of that class.

For example, the two calls to getX in the following code are equivalent. To my mind, the first call to getX is to be preferred because it clearly indicates to the reader that the method is static.
   StaticTest s1 = new StaticTest(1);
   int y = StaticTest.getX();
   int z = s1.getX();

Because a static method has no associated object, it is not allowed to reference nonstatic variables. For example, the print method in StaticTest would not make sense as a static method because there is no unique variable y that it would be able to reference.

A Banking Demo

Listing 1-4 gives the code for a simple program to manage a fictional bank. This program will be used as a running example throughout the book. The code in Listing 1-4 consists of a single class, named BankProgram, and is version 1 of the demo.

The class BankProgram holds a map that stores the balances of several accounts held by a bank. Each element in the map is a key-value pair. The key is an integer that denotes the account number and its value is the balance of that account, in cents.
public class BankProgram {
   private HashMap<Integer,Integer> accounts
                                     = new HashMap<>();
   private double rate  = 0.01;
   private int nextacct = 0;
   private int current  = -1;
   private Scanner scanner;
   private boolean done = false;
   public static void main(String[] args) {
      BankProgram program = new BankProgram();
      program.run();
   }
   public void run() {
      scanner = new Scanner(System.in);
      while (!done) {
         System.out.print("Enter command (0=quit, 1=new,
                             2=select, 3=deposit, 4=loan,
                             5=show, 6=interest): ");
         int cmd = scanner.nextInt();
         processCommand(cmd);
      }
      scanner.close();
   }
   private void processCommand(int cmd) {
      if      (cmd == 0) quit();
      else if (cmd == 1) newAccount();
      else if (cmd == 2) select();
      else if (cmd == 3) deposit();
      else if (cmd == 4) authorizeLoan();
      else if (cmd == 5) showAll();
      else if (cmd == 6) addInterest();
      else
         System.out.println("illegal command");
   }
   ... //code for the seven command methods appears here
}
Listing 1-4

Version 1 of the Banking Demo

The program’s run method performs a loop that repeatedly reads commands from the console and executes them. There are seven commands, each of which has a corresponding method.

The quit method sets the global variable done to true, which causes the loop to terminate.
   private void quit() {
      done = true;
      System.out.println("Goodbye!");
   }
The global variable current keeps track of the current account. The newAccount method allocates a new account number, makes it current, and assigns it to the map with an initial balance of 0.
   private void newAccount() {
      current = nextacct++;
      accounts.put(current, 0);
      System.out.println("Your new account number is "
                        + current);
   }
The select method makes an existing account current. It also prints the account balance.
   private void select() {
      System.out.print("Enter account#: ");
      current = scanner.nextInt();
      int balance = accounts.get(current);
      System.out.println("The balance of account " + current
                       + " is " + balance);
   }
The deposit method increases the balance of the current account by a specified number of cents.
   private void deposit() {
      System.out.print("Enter deposit amount: ");
      int amt = scanner.nextInt();
      int balance = accounts.get(current);
      accounts.put(current, balance+amt);
   }
The method authorizeLoan determines whether the current account has enough money to be used as collateral for a loan. The criterion is that the account must contain at least half of the loan amount.
   private void authorizeLoan() {
      System.out.print("Enter loan amount: ");
      int loanamt = scanner.nextInt();
      int balance = accounts.get(current);
      if (balance >= loanamt / 2)
         System.out.println("Your loan is approved");
      else
         System.out.println("Your loan is denied");
   }
The showAll method prints the balance of every account.
   private void showAll() {
      Set<Integer> accts = accounts.keySet();
      System.out.println("The bank has " + accts.size()
                       + " accounts.");
      for (int i : accts)
         System.out.println(" Bank account " + i
                     + ": balance=" + accounts.get(i));
   }
Finally, the addInterest method increases the balance of each account by a fixed interest rate.
   private void addInterest() {
      Set<Integer> accts = accounts.keySet();
      for (int i : accts) {
         int balance = accounts.get(i);
         int newbalance = (int) (balance * (1 + rate));
         accounts.put(i, newbalance);
      }
   }

The Single Responsibility Rule

The BankProgram code is correct. But is it any good? Note that the program has multiple areas of responsibility—for example, one responsibility is to handle I/O processing and another responsibility is to manage account information—and both responsibilities are handled by a single class.

Multipurpose classes violate the fundamental design principle. The issue is that each area of responsibility will have different reasons for changing. If these responsibilities are implemented by a single class then the entire class will have to be modified whenever a change occurs to one aspect of it. On the other hand, if each responsibility is assigned to a different class then fewer parts of the program need be modified when a change occurs.

This observation leads to a design rule known as the Single Responsibility rule.

The Single Responsibility Rule

A class should have a single purpose, and all its methods should be related to that purpose.

A program that satisfies the Single Responsibility rule will be organized into classes, with each class having its own unique responsibility.

Version 2 of the banking demo is an example of such a design. It contains three classes: The class Bank is responsible for the banking information; the class BankClient is responsible for I/O processing; and the class BankProgram is responsible for putting everything together. The class diagram for this design appears in Figure 1-2.
../images/470600_1_En_1_Chapter/470600_1_En_1_Fig2_HTML.jpg
Figure 1-2

Version 2 of the banking demo

The code for Bank appears in Listing 1-5. It contains the three variables of version 1 that are relevant to the bank, namely the map of accounts, the interest rate, and the value of the next account number. The six methods in its API correspond to the command methods of version 1 (except for quit). Their code consists of the code of those methods, with the input/output code stripped out. For example, the code for the newAccount method adds a new account to the map but does not print its information to the console. Instead, it returns the account number to BankClient, which is responsible for printing the information.
public class Bank {
   private HashMap<Integer,Integer> accounts
                                     = new HashMap<>();
   private double rate = 0.01;
   private int nextacct = 0;
   public int newAccount() {
      int acctnum = nextacct++;
      accounts.put(acctnum, 0);
      return acctnum;
   }
   public int getBalance(int acctnum) {
      return accounts.get(acctnum);
   }
   public void deposit(int acctnum, int amt) {
      int balance = accounts.get(acctnum);
      accounts.put(acctnum, balance+amt);
   }
   public boolean authorizeLoan(int acctnum, int loanamt) {
      int balance = accounts.get(acctnum);
      return balance >= loanamt / 2;
   }
   public String toString() {
      Set<Integer> accts = accounts.keySet();
      String result = "The bank has " + accts.size()
                    + " accounts.";
      for (int i : accts)
         result += " Bank account " + i
                + ": balance=" + accounts.get(i);
      return result;
   }
   public void addInterest() {
      Set<Integer> accts = accounts.keySet();
      for (int i : accts) {
         int balance = accounts.get(i);
         int newbalance = (int) (balance * (1 + rate));
         accounts.put(i, newbalance);
      }
   }
}
Listing 1-5

The Version 2 Bank Class

Similarly, the deposit method is not responsible for asking the user for the deposit amount. Instead, it expects the caller of the method (i.e., BankClient) to pass the amount as an argument.

The authorizeLoan method eliminates both input and output code from the corresponding version 1 method. It expects the loan amount to be passed in as an argument and it returns the decision as a boolean.

The getBalance method corresponds to the select method of version 1. That method is primarily concerned with choosing a current account, which is the responsibility of BankClient. Its only bank-specific code involves obtaining the balance of the selected account. The Bank class therefore has a getBalance method for select to call.

The showAll method in version 1 prints the information of each account. The bank-specific portion of this method is to collect this information into a string, which is the responsibility of Bank’s toString method.

The addInterest method in version 1 has no input/output component whatsoever. Consequently, it is identical to the corresponding method in Bank.

The code for BankClient appears in Listing 1-6. It contains the three global variables from version 1 that are related to input/output, namely the current account, the scanner, and the am-I-done flag; it also has an additional variable that holds a reference to the Bank object. BankClient has the public method run and the private method processCommand; these methods are the same as in version 1. The code for the individual command methods is similar; the difference is that all bank-specific code is replaced by a call to the appropriate method of Bank. These statements are written in bold in the listing.
public class BankClient {
   private int current = -1;
   private Scanner scanner = new Scanner(System.in);
   private boolean done = false;
   private Bank bank = new Bank();
   public void run() {
      ... // unchanged from version 1
   }
   private void processCommand(int cmd) {
      ... // unchanged from version 1
   }
   private void quit() {
      ... // unchanged from version 1
   }
   private void newAccount() {
      current = bank.newAccount();
      System.out.println("Your new account number is "
                        + current);
   }
   private void select() {
      System.out.print("Enter acct#: ");
      current = scanner.nextInt();
      int balance = bank.getBalance(current);
      System.out.println("The balance of account "
                        + current + " is " + balance);
   }
   private void deposit() {
      System.out.print("Enter deposit amt: ");
      int amt = scanner.nextInt();
      bank.deposit(current, amt);
   }
   private void authorizeLoan() {
      System.out.print("Enter loan amt: ");
      int loanamt = scanner.nextInt();
      if (bank.authorizeLoan(current, loanamt))
         System.out.println("Your loan is approved");
      else
         System.out.println("Your loan is denied");
   }
   private void showAll() {
      System.out.println(bank.toString());
   }
   private void addInterest() {
      bank.addInterest();
   }
}
Listing 1-6

The Version 2 BankClient Class

The class BankProgram contains the main method , which parallels the main method of version 1. Its code appears in Listing 1-7.
public class BankProgram {
   public static void main(String[] args) {
      BankClient client = new BankClient();
      client.run();
   }
}
Listing 1-7

The Version 2 BankProgram Class

Note that version 2 of the banking demo is more easily modifiable than version 1. It is now possible to change the implementation of Bank without worrying about breaking the code for BankClient. Similarly, it is also possible to change the way that BankClient does its input/output, without affecting Bank or BankProgram.

Refactoring

One interesting feature of the version 2 demo is that it contains nearly the same code as in version 1. In fact, when I wrote version 2 I began by redistributing the existing code between its three classes. This is an example of what is called refactoring .

In general, to refactor a program means to make syntactic changes to it without changing the way it works. Examples of refactoring include: renaming a class, method, or variable; changing the implementation of a variable from one data type to another; and splitting a class into two. If you use the Eclipse IDE then you will notice that it has a Refactor menu, which can automatically perform some of the simpler forms of refactoring for you.

Unit Testing

Earlier in this chapter I stated that one of the advantages to a modular program is that each class can be implemented and tested separately. This begs the question: How can you test a class separate from the rest of the program?

The answer is to write a driver program for each class. The driver program calls the various methods of the class, passing them sample input and checking that the return values are correct. The idea is that the driver should test all possible ways that the methods can be used. Each way is called a use case .

As an example, consider the class BankTest, which appears in Listing 1-8. This class calls some of the Bank methods and tests whether they return expected values. This code only tests a couple of use cases and is far less comprehensive than it ought to be, but the point should be clear.
public class BankTest {
   private static Bank bank = new Bank();
   private static int acct = bank.newAccount();
   public static void main(String[] args) {
      verifyBalance("initial amount", 0);
      bank.deposit(acct, 10);
      verifyBalance("after deposit", 10);
      verifyLoan("authorize bad loan", 22, false);
      verifyLoan("authorize good loan", 20, true);
   }
   private static void verifyBalance(String msg,
                                     int expectedVal) {
      int bal = bank.getBalance(acct);
      boolean ok = (bal == expectedVal);
      String result = ok ? "Good! " : "Bad! ";
      System.out.println(msg + ": " + result);
   }
   private static void verifyLoan(String msg,
                    int loanAmt, boolean expectedVal) {
      boolean answer = bank.authorizeLoan(acct, loanAmt);
      boolean ok = (answer == expectedVal);
      String result = ok ? "Good! " : "Bad! ";
      System.out.println(msg + ": " + result);
   }
}
Listing 1-8

The BankTest Class

Testing the BankClient class is more difficult, for two reasons. The first is that the class calls a method from another class (namely, Bank). The second is that the class reads input from the console. Let’s address each issue in turn.

How can you test a class that calls methods from another class? If that other class is also in development then the driver program will not able to make use of it. In general, a driver program should not use another class unless that class is known to be completely correct; otherwise if the test fails, you don’t know which class caused the problem.

The standard approach is to write a trivial implementation of the referenced class, called a mock class. Typically, the methods of the mock class print useful diagnostics and return default values. For example, Listing 1-9 shows part of a mock class for Bank.
public class Bank {
   public int newAccount() {
      System.out.println("newAccount called, returning 10");
      return 10;
   }
   public int getBalance(int acctnum) {
      System.out.println("getBalance(" + acctnum
                       + ") called, returning 50");
      return 50;
   }
   public void deposit(int acctnum, int amt) {
      System.out.println("deposit(" + acctnum + ", "
                       + amt + ") called");
   }
   public boolean authorizeLoan(int acctnum,
                                int loanamt) {
      System.out.println("authorizeLoan(" + acctnum
                       + ", " + loanamt
                       + ") called, returning true");
      return true;
   }
   ...
}
Listing 1-9

A Mock Implementation of Bank

The best way to test a class that takes input from the console is to redirect its input to come from a file. By placing a comprehensive set of input values into a file, you can easily rerun the driver program with the assurance that the input will be the same each time. You can specify this redirection in several ways, depending on how you execute the program. From Eclipse, for example, you specify redirection in the program’s Run Configurations menu.

The class BankProgram makes a pretty good driver program for BankClient. You simply need to create an input file that tests the various commands sufficiently.

Class Design

A program that satisfies the Single Responsibility rule will have a class for each identified responsibility. But how do you know if you have identified all the responsibilities?

The short answer is that you don’t. Sometimes, what seems to be a single responsibility can be broken down further. The need for a separate class may become apparent only when additional requirements are added to the program.

For example, consider version 2 of the banking demo. The class Bank stores its account information in a map, where the map’s key holds the account numbers and its value holds the associated balances. Suppose now that the bank also wants to store additional information for each account. In particular, assume that the bank wants to know whether the owner of each account is foreign or domestic. How should the program change?

After some quiet reflection, you will realize that the program needs an explicit concept of a bank account. This concept can be implemented as a class; call it BankAccount. The bank’s map can then associate a BankAccount object with each account number. These changes form version 3 of the banking demo. Its class diagram appears in Figure 1-3, with new methods in bold.
../images/470600_1_En_1_Chapter/470600_1_En_1_Fig3_HTML.jpg
Figure 1-3

Version 3 of the banking demo

Listing 1-10 gives the code for the new BankAccount class. It has three global variables, which hold the account number, the balance, and a flag indicating whether the account is foreign. It has methods to retrieve the values of the three variables and to set the value of the balance and isforeign variables.
public class BankAccount {
   private int acctnum;
   private int balance = 0;
   private boolean isforeign = false;
   public BankAccount(int a) {
      acctnum = a;
   }
   public int getAcctNum() {
      return acctnum;
   }
   public int getBalance() {
      return balance;
   }
   public void setBalance(int amt) {
      balance = amt;
   }
   public boolean isForeign() {
      return isforeign;
   }
   public void setForeign(boolean b) {
      isforeign = b;
   }
}
Listing 1-10

The Version 3 BankAccount Class

Listing 1-11 gives the revised code for Bank. Changes are in bold. The class now holds a map of BankAccount objects instead of a map of integers and has code for the new method setForeign.
public class Bank {
   private HashMap<Integer,BankAccount> accounts
                                     = new HashMap<>();
   private double rate = 0.01;
   private int nextacct = 0;
   public int newAccount(boolean isforeign) {
      int acctnum = nextacct++;
      BankAccount ba = new BankAccount(acctnum);
      ba.setForeign(isforeign);
      accounts.put(acctnum, ba);
      return acctnum;
   }
   public int getBalance(int acctnum) {
      BankAccount ba = accounts.get(acctnum);
      return ba.getBalance();
   }
   public void deposit(int acctnum, int amt) {
      BankAccount ba = accounts.get(acctnum);
      int balance = ba.getBalance();
      ba.setBalance(balance+amt);
   }
   public void setForeign(int acctnum,
                          boolean isforeign) {
      BankAccount ba = accounts.get(acctnum);
      ba.setForeign(isforeign);
   }
   public boolean authorizeLoan(int acctnum, int loanamt) {
      BankAccount ba = accounts.get(acctnum);
      int balance = ba.getBalance();
      return balance >= loanamt / 2;
   }
   public String toString() {
      String result = "The bank has " + accounts.size()
                    + " accounts.";
      for (BankAccount ba : accounts.values())
         result += " Bank account "
             + ba.getAcctNum() + ": balance="
             + ba.getBalance() + ", is "
             + (ba.isForeign() ? "foreign" : "domestic");
      return result;
   }
   public void addInterest() {
      for (BankAccount ba : accounts.values()) {
         int balance = ba.getBalance();
         balance += (int) (balance * rate);
         ba.setBalance(balance);
      }
   }
}
Listing 1-11

The Version 3 Bank Class

As a result of these changes, obtaining information from an account has become a two-step process: A method first retrieves a BankAccount object from the map; it then calls the desired method on that object. Another difference is that the methods toString and addInterest no longer get each account value individually from the map keys. They instead use the map’s values method to retrieve the accounts into a list, which can then be examined.

The BankClient class must be modified to take advantage of Bank’s additional functionality. In particular, it now has a new command (command 7) to allow the user to specify whether the account is foreign or domestic, and it modifies the newAccount method to ask for the account’s ownership status. The relevant code appears in Listing 1-12.
public class BankClient {
   ...
   public void run() {
      while (!done) {
         System.out.print("Enter command (0=quit, 1=new,
                  2=select, 3=deposit, 4=loan,
                  5=show, 6=interest, 7=setforeign): ");
         int cmd = scanner.nextInt();
         processCommand(cmd);
      }
   }
   private void processCommand(int cmd) {
      if      (cmd == 0) quit();
      else if (cmd == 1) newAccount();
      else if (cmd == 2) select();
      else if (cmd == 3) deposit();
      else if (cmd == 4) authorizeLoan();
      else if (cmd == 5) showAll();
      else if (cmd == 6) addInterest();
      else if (cmd == 7) setForeign();
      else
         System.out.println("illegal command");
   }
   private void newAccount() {
      boolean isforeign = requestForeign();
      current = bank.newAccount(isforeign);
      System.out.println("Your new account number is "
                         + current);
   }
   ...
   private void setForeign() {
      bank.setForeign(current, requestForeign());
   }
   private boolean requestForeign() {
      System.out.print("Enter 1 for foreign,
                              2 for domestic: ");
      int val = scanner.nextInt();
      return (val == 1);
   }
}
Listing 1-12

The Version 3 BankClient Class

This relatively minor change to BankClient points out the advantage of modularity. Even though the Bank class changed how it implemented its methods, its contract with BankClient did not change. The only change resulted from the added functionality.

Encapsulation

Let’s look more closely at the code for BankAccount in Listing 1-10. Its methods consist of accessors and mutators (otherwise known as “getters” and “setters”). Why use methods? Why not just use public variables, as shown in Listing 1-13? With this class, a client could simply access BankAccount’s variables directly instead of having to call its methods.
public class BankAccount {
   public int acctnum;
   public int balance = 0;
   public boolean isforeign = false;
   public BankAccount(int a) {
      acctnum = a;
   }
}
Listing 1-13

An Alternative BankAccount Class

Although this alternative BankAccount class is far more compact, its design is far less desirable. Here are three reasons to prefer methods over public variables.

The first reason is that methods are able to limit the power of clients. A public variable is equivalent to both an accessor and a mutator method, and having both methods can often be inappropriate. For example, clients of the alternative BankAccount class would have the power to change the account number, which is not a good idea.

The second reason is that methods provide more flexibility than variables. Suppose that at some point after deploying the program, the bank detects the following problem: The interest it adds to the accounts each month is calculated to a fraction of a penny, but that fractional amount winds up getting deleted from the accounts because the balance is stored in an integer variable.

The bank decides to rectify this mistake by changing the balance variable to be a float instead of an integer. If the alternative BankAccount class were used then that change would be a change to the API, which means that all clients that referenced the variable would also need to be modified. On the other hand, if the version 3 BankAccount class is used, the change to the variable is private, and the bank can simply change the implementation of the method getBalance as follows:
   public int getBalance() {
      return (int) balance;
   }

Note that getBalance no longer returns the actual balance of the account. Instead, it returns the amount of money that can be withdrawn from the account, which is consistent with the earlier API. Since the API of BankAccount has not changed, the clients of the class are not aware of the change to the implementation.

The third reason to prefer methods over public variables is that methods can perform additional actions. For example, perhaps the bank wants to log each change to an account’s balance. If BankAccount is implemented using methods, then its setBalance method can be modified so that it writes to a log file. If the balance can be accessed via a public variable then no logging is possible.

The desirability of using public methods instead of public variables is an example of a design rule known as the rule of Encapsulation.

The Rule of Encapsulation

A class’s implementation details should be hidden from its clients as much as possible.

In other words, the less that clients are aware of the implementation of a class, the more easily that class can change without affecting its clients.

Redistributing Responsibility

The classes of the version 3 banking demo are modular and encapsulated. Nevertheless, there is something unsatisfactory about the design of their methods. In particular, the BankAccount methods don’t do anything interesting. All the work occurs in Bank.

For example, consider the action of depositing money in an account. The bank’s deposit method controls the processing. The BankAccount object manages the getting and setting of the bank balance, but it does so under the strict supervision of the Bank object.

This lack of balance between the two classes hints at a violation of the Single Responsibility rule. The intention of the version 3 banking demo was for the Bank class to manage the map of accounts and for the BankAccount class to manage each individual account. However, that didn’t occur—the Bank class is also performing activities related to bank accounts. Consider what it would mean for the BankAccount object to have responsibility for deposits. It would have its own deposit method:
   public void deposit(int amt) {
      balance += amt;
   }
And the Bank’s deposit method would be modified so that it called the deposit method of BankAccount :
   public void deposit(int acctnum, int amt) {
      BankAccount ba = accounts.get(acctnum);
      ba.deposit(amt);
   }

In this version, Bank no longer knows how to do deposits. Instead, it delegates the work to the appropriate BankAccount object.

Which version is a better design? The BankAccount object is a more natural place to handle deposits because it holds the account balance. Instead of having the Bank object tell the BankAccount object what to do, it is better to just let the BankAccount object do the work itself. We express this idea as the following design rule, called the Most Qualified Class rule.

The Most Qualified Class Rule

Work should be assigned to the class that knows best how to do it.

Version 4 of the banking demo revises the classes Bank and BankAccount to satisfy the Most Qualified Class rule. Of these classes, only the API for BankAccount needs to change. Figure 1-4 shows the revised class diagram for this class (with changes from version 3 in bold).
../images/470600_1_En_1_Chapter/470600_1_En_1_Fig4_HTML.jpg
Figure 1-4

The version 4 BankAccount class

The BankAccount class now has methods that correspond to the deposit, toString, and addInterest methods of Bank. The class also has the method hasEnoughCollateral, which (as we shall see) corresponds to Bank’s authorizeLoan method . In addition, the class no longer needs the setBalance method.

The code for the classes BankAccount and Bank need to change. The relevant revised code for Bank appears in Listing 1-14, with changes in bold.
public class Bank {
   ...
   public void deposit(int acctnum, int amt) {
      BankAccount ba = accounts.get(acctnum);
      ba.deposit(amt);
   }
   public boolean authorizeLoan(int acctnum,
                                int loanamt) {
      BankAccount ba = accounts.get(acctnum);
      return ba.hasEnoughCollateral(loanamt);
   }
   public String toString() {
      String result = "The bank has " + accounts.size()
                    + " accounts.";
      for (BankAccount ba : accounts.values())
         result += " " + ba.toString();
      return result;
   }
   public void addInterest() {
      for (BankAccount ba : accounts.values())
         ba.addInterest();
   }
}
Listing 1-14

The Version 4 Bank Class

As previously discussed, the bank’s deposit method is no longer responsible for updating the account balance. Instead, the method calls the corresponding method in BankAccount to perform the update.

The bank’s toString method is responsible for creating a string representation of all bank accounts. However, it is no longer responsible for formatting each individual account; instead, it calls the toString method of each account when needed. The bank’s addInterest method is similar. The method calls the addInterest method of each account, allowing each account to update its own balance.

The bank’s authorizeLoan method is implemented slightly differently from the others. It calls the bank account’s hasEnoughCollateral method, passing in the loan amount. The idea is that the decision to authorize a loan should be shared between the Bank and BankAccount classes. The bank account is responsible for comparing the loan amount against its balance. The bank then uses that information as one of its criteria for deciding whether to authorize the loan. In the version 4 code, the collateral information is the only criterion, but in real life the bank would also use criteria such as credit score, employment history, and so on, all of which reside outside of BankAccount. The BankAccount class is responsible only for the “has enough collateral” criterion because that is what it is most qualified to assess.

The four methods added to the BankAccount class appear in Listing 1-15.
public class BankAccount {
   private double rate = 0.01;
   ...
   public void deposit(int amt) {
      balance += amt;
   }
   public boolean hasEnoughCollateral(int amt) {
      return balance >= amt / 2;
   }
   public String toString() {
      return "Bank account " + acctnum + ": balance="
                  + balance + ", is "
                  + (isforeign ? "foreign" : "domestic");
   }
   public void addInterest() {
      balance += (int) (balance * rate);
   }
}
Listing 1-15

The Version 4 BankAccount Class

Dependency Injection

The Most Qualified Class rule can also be applied to the question of how to initialize the dependencies of a class. For example consider the BankClient class, which has dependencies on Scanner and Bank. The relevant code (taken from Listing 1-6) looks like this:
   public class BankClient {
      private Scanner scanner = new Scanner(System.in);
      private Bank bank = new Bank();
      ...
   }

When the class creates its Scanner object it uses System.in as the source, indicating that input should come from the console. But why choose System.in? There are other options. The class could read its input from a file instead of the console or it could get its input from somewhere over the Internet. Given that the rest of the BankClient code does not care what input its scanner is connected to, restricting its use to System.in is unnecessary and reduces the flexibility of the class.

A similar argument could be made for the bank variable. Suppose that the program gets modified so that it can access multiple banks. The BankClient code does not care which bank it accesses, so how does it decide which bank to use?

The point is that BankClient is not especially qualified to make these decisions and therefore should not be responsible for them. Instead, some other, more qualified class should make the decisions and pass the resulting object references to BankClient. This technique is called dependency injection .

Typically, the class that creates an object is most qualified to initialize its dependencies. In such cases an object receives its dependency values via its constructor. This form of dependency injection is called constructor injection . Listing 1-16 gives the relevant modifications to BankClient.
public class BankClient {
   private int current = -1;
   private Scanner scanner;
   private boolean done = false;
   private Bank bank;
   public BankClient(Scanner scanner, Bank bank) {
      this.scanner = scanner;
      this.bank = bank;
   }
   ...
}
Listing 1-16

The Version 4 BankClient Class

The class Bank can be improved similarly. It has one dependency, to its account map, and it also decides the initial value for its nextacct variable. The relevant code (taken from Listing 1-11) looks like this:
   public class Bank {
      private HashMap<Integer,BankAccount> accounts
                                        = new HashMap<>();
      private int nextacct = 0;
      ...
   }
The Bank object creates an empty account map, which is unrealistic. In a real program the account map would be constructed by reading a file or accessing a database. As with BankClient , the rest of the Bank code does not care where the account map comes from, and so Bank is not the most qualified class to make that decision. A better design is to use dependency injection to pass the map and the initial value of nextacct to Bank, via its constructor. Listing 1-17 gives the relevant code.
public class Bank {
   private HashMap<Integer,BankAccount> accounts;
   private int nextacct;
   public Bank(HashMap<Integer,BankAccount> accounts,
               int n) {
      this.accounts = accounts;
      nextacct = n;
   }
   ...
}
Listing 1-17

The Version 4 Bank Class

The version 4 BankProgram class is responsible for creating the Bank and BankClient classes , and thus is also responsible for initializing their dependencies. Its code appears in Listing 1-18.
public class BankProgram {
   public static void main(String[] args) {
      HashMap<Integer,BankAccount> accounts = new HashMap<>();
      Bank bank = new Bank(accounts, 0);
      Scanner scanner = new Scanner(System.in);
      BankClient client = new BankClient(scanner, bank);
      client.run();
   }
}
Listing 1-18

The Version 4 BankProgram Class

It is interesting to compare versions 3 and 4 of the demo in terms of when objects get created. In version 3 the BankClient object gets created first, followed by its Scanner and Bank objects. The Bank object then creates the account map. In version 4 the objects are created in the reverse order: first the map, then the bank, the scanner, and finally the client. This phenomenon is known as dependency inversion— each object is created before the object that depends on it.

Note how BankProgram makes all the decisions about the initial state of the program. Such a class is known as a configuration class . A configuration class enables users to reconfigure the behavior of the program by simply modifying the code for that class.

The idea of placing all dependency decisions within a single class is powerful and convenient. In fact, many large programs take this idea one step further. They place all configuration details (i.e., information about the input stream, the name of the stored data file, etc.) into a configuration file. The configuration class reads that file and uses it to create the appropriate objects.

The advantage to using a configuration file is that the configuration code never needs to change. Only the configuration file changes. This feature is especially important when the program is being configured by end users who may not know how to program. They modify the configuration file and the program performs the appropriate configurations.

Mediation

The BankClient class in the version 4 banking demo does not know about BankAccount objects . It interacts with accounts solely through methods of the Bank class. The Bank class is called a mediator .

Mediation can enhance the modularity of a program. If the Bank class is the only class that can access BankAccount objects then BankAccount is essentially private to Bank. This feature was important when the version 3 BankAccount class was modified to produce version 4; it ensured that the only other class that needed to be modified was Bank. This desirability leads to the following rule, called the rule of Low Coupling.

The Rule of Low Coupling

Try to minimize the number of class dependencies.

This rule is often expressed less formally as “Don’t talk to strangers.” The idea is that if a concept is strange to a client, or difficult to understand, it is better to mediate access to it.

Another advantage to mediation is that the mediator can keep track of activity on the mediated objects. In the banking demo, Bank must of course mediate the creation of BankAccount objects or its map of accounts will become inaccurate. The Bank class can also use mediation to track the activity of specific accounts. For example, the bank could track deposits into foreign accounts by changing its deposit method to something like this:
   public void deposit(int acctnum, int amt) {
      BankAccount ba = accounts.get(acctnum);
      if (ba.isForeign())
         writeToLog(acctnum, amt, new Date());
      ba.deposit(amt);
   }

Design Tradeoffs

The Low Coupling and Single Responsibility rules often conflict with each another. Mediation is a common way to provide low coupling. But a mediator class tends to accumulate methods that are not central to its purpose, which can violate the Single Responsibility rule.

The banking demo provides an example of this conflict. The Bank class has methods getBalance, deposit, and setForeign, even though those methods are the responsibility of BankAccount. But Bank needs to have those methods because it is mediating between BankClient and BankAccount.

Another design possibility is to forget about mediation and let BankClient access BankAccount objects directly. A class diagram of the resulting architecture appears in Figure 1-5. In this design, the variable current in BankClient would be a BankAccount reference instead of an account number. The code for its getBalance, deposit, and setForeign commands can therefore call the corresponding methods of BankAccount directly. Consequently, Bank does not need these methods and has a simpler API. Moreover, the client can pass the reference of the desired bank account to the bank’s authorizeLoan method instead of an account number, which improves efficiency.
../images/470600_1_En_1_Chapter/470600_1_En_1_Fig5_HTML.jpg
Figure 1-5

Bank is no longer a mediator

Would this new design be an improvement over the version 4 banking demo? Neither design is obviously better than the other. Each involves different tradeoffs: Version 4 has lower coupling, whereas the new design has simpler APIs that satisfy the Single Responsibility rule better. For the purposes of this book, I chose to go with version 4 because I felt that it was important for Bank to be able to mediate access to the accounts.

The point is that design rules are only guidelines. Tradeoffs are almost always necessary in any significant program. The best design will probably violate at least one rule somehow. The role of a designer is to recognize the possible designs for a given program and accurately analyze their tradeoffs.

The Design of Java Maps

As a real-life example of some design tradeoffs, consider the Map classes in the Java library. The typical way to implement a map is to store each key-value pair as a node. The nodes are then inserted into a hash table (for a HashMap object) or a search tree (for a TreeMap object). In Java, these nodes have the type Map.Entry.

Clients of a map typically do not interact with Map.Entry objects . Instead, clients call the Map methods get and put. Given a key, the get method locates the entry having that key and returns its associated value; the put method locates the entry having that key and changes its value. If a client wants to examine all entries in the map then it can call the method keySet to get all keys, and then repeatedly call get to find their associated values. Listing 1-19 gives some example code. The first portion of the code puts the entries ["a",1] and ["b",4] into the map and then retrieves the value associated with the key "a". The second portion prints each entry in the map.
HashMap<String,Integer> m = new HashMap<>();
m.put("a", 1);
m.put("b", 4);
int x = m.get("a");
Set<String> keys = m.keySet();
for(String s: keys) {
   int y = m.get(s);
   System.out.println(s + " " + y);
}
Listing 1-19

Typical Uses of a HashMap

This design of HashMap corresponds to the class diagram of Figure 1-6. Note that each HashMap object is a mediator for its underlying Map.Entry objects.
../images/470600_1_En_1_Chapter/470600_1_En_1_Fig6_HTML.jpg
Figure 1-6

HashMap as a mediator of Map.Entry

Unfortunately, this mediation can lead to inefficient code. The loop in Listing 1-19 is such an example. The keySet method traverses the entire data structure to acquire all the keys. The get method then has to repeatedly access the data structure again to get the value of each key.

The code would be more efficient if the client code could access the map entries directly. Then it could simply traverse the data structure once, getting each entry and printing its contents. In fact, such a method does exist in HashMap, and is called entrySet. The code in Listing 1-20 is equivalent to Listing 1-19 but is more efficient.
HashMap<String,Integer> m = new HashMap<>();
m.put("a", 1);
m.put("b", 4);
int x = m.get("a");
Set<Map.Entry<String,Integer>> entries = m.entrySet();
for (Map.Entry<String,Integer> e : entries) {
   String s = e.getKey();
   int y = e.getValue();
   System.out.println(s + " " + y);
}
Listing 1-20

Accessing Map Entries Directly

The existence of the method entrySet changes the class diagram of Figure 1-6. The class HashMap is no longer a mediator of Map.Entry because Map.Entry is now visible to the client. The new class diagram appears in Figure 1-7.
../images/470600_1_En_1_Chapter/470600_1_En_1_Fig7_HTML.jpg
Figure 1-7

HashMap is no longer a mediator of Map.Entry

Making the Map.Entry nodes visible to clients increases the complexity of programs that use maps. Clients need to know about two classes instead of just one. Moreover, the API of Map.Entry now cannot be changed without impacting the clients of HashMap. On the other hand, the complexity also makes it possible to write more efficient code.

The designers of HashMap had to take these conflicting needs into consideration. Their solution was to keep the complexity for people who need it but to make it possible to ignore the complex methods if desired.

Summary

Software development must be guided by a concern for program modifiability. The fundamental design principle is that a program should be designed so that any change to it will affect only a small, predictable portion of the code. There are several rules that can help a designer satisfy the fundamental principle.
  • The Single Responsibility rule states that a class should have a single purpose, and its methods should all be related to that purpose.

  • The Encapsulation rule states that a class’s implementation details should be hidden from its clients as much as possible.

  • The Most Qualified Class rule states that work should be assigned to the class that knows best how to do it.

  • The Low Coupling rule states that the number of class dependencies should be minimized.

These rules are guidelines only. They suggest reasonable design decisions for most situations. As you design your programs, you must always understand the tradeoffs involved with following (or not following) a particular rule.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.226.163.229