Object oriented principles

From Juneday education
Jump to: navigation, search

Description

Design patterns are the collected wisdom of many developers for how to solve a set of common problems when designing an object oriented system. But what should an object oriented design look like? What should we strive for?

There are quite a few OO design principles described in literature and elsewhere. We'll run through some of them in this lecture and give some examples of what the principles try to achieve and what problems they try to solve.

Abstraction

This is a recurring principle which is part of many other principles, but using abstractions is about focusing on the big picture and avoid focusing on details. The reason for doing this is that it allows for the details to change but leave parts of our code unaffected if our code use abstractions which do not include details.

Typical examples include encapsulation, interfaces and abstract base classes - all of which is about describing something in general terms, leaving out the details, so that we can program against a more general abstraction.

Encapsulation

Encapsulation is the practice of grouping data and behavior together in one class, which allows us to create objects which know stuff (hold data) and offers behavior (which can depend on the data) to the users. Objects are often good as abstractions. We can have an object of type Character (it might for instance be an instance of the class Troll in some game). The character knows its name and its health, but how this is done is an implementation detail. We can ask the character for its health, or ask the character to fight with some other character (behavior). Even the use of the weapon for fighting can be encapsulated in a Weapon object, which knows how to apply the attack behavior and also knows the amount of damage it may inflict.

Encapsulating both data and behavior in one object (which can be composed of many other objects) is a good form of abstraction.

Prefer composition to inheritance

Composition is often much more flexible than plain old inheritance. Composition is when you describe a class as having objects which can represent a value or even a behavior. An example used in this book is that the Character base class in a text-based adventure game declares that every Character has a Weapon (sometimes we call it WeaponBehavior to stress the fact that it is actually an object which encapsulates a behavior the Character has).

The benefit of having a weapon which has behavior (such as useWeapon()) rather than just a method directly in the class Character, is that we can write a method which replaces the current weapon with a new one, so a character can change weapon when the game is running. If we had settled with a method for doing the fighting behavior, we wouldn't be able to change this method dynamically.

Using composition this way, to compose an object with an object representing behavior, lets us be very abstract in the way we describe a Character in the base class. We only know that every Character (some instance of a subclass to Character, such as Knight) simply has a WeaponBehavior. When we write the code in the Character class, we don't know what type of WeaponBehavior it is, and we don't care. We focus on the big picture, quite satisfied with simply knowing that every Character has a WeaponBehavior. When we implement the fight() method in some subclass, we just use our WeaponBehavior as a WeaponBehavior in general. We don't have to know the details of how the weapon works or looks. It is enough to treat it as any weapon. The usage from our abstract view point is the same. Simply apply the behavior useWeapon() on the weapon and figure out what the damage was on the opponent.

There are other cases where inheritance is not the best strategy to use. We take an example in the lecture video about a design for a company employee record system. The first design was using inheritance with the base class Employee which has two subclasses Manager and Engineer.

The Manager class has one subclass, SeniorManager. The Engineer class has two subclasses, ComputerEngineer and NetworkEngineer. This looks like a reasonable hierarchy for our company. But problems start when a ComputerEngineer gets a promotion and becomes also a Manager. What should we do now? The object representing this engineer manager can't have both the type Engineer and Manager (because they are siblings).

Using composition, we could solve this for example by having just one class for Employee and compose Employee with a list of Role references. Role could be a class describing a title or position at the company (such as Manager and ComputerEngineer). Giving the Employee class a method addRole(Role) would solve the problem described earlier. The engineer would start with only one role, "Computer Engineer". When the promotion comes, the role of "Manager" is added to the Engineer's list of Role references.

Program to a supertype (interface)

Another principle which is good to follow is to settle for programming against a very general and abstract type. The goal is to insulate our classes from details which will likely change at some point. The more general and abstract we are in our code, the less affected it becomes when stuff changes elsewhere in the system.

Here too, some examples are given. As a simple example, we could take some code requesting a list of references of some type. Now, List is an interface in java.util. Writing the provider method to return a reference to a List forces the receiver to be equally unspecific about what kind of list we are dealing with:

List<SomeType> objects = SomeClass.makeList();

What's gained from this? Well, the method makeList() returns a reference to some specific kind of list, like ArrayList (a class also in the java.util package). But this might change in the future! For technical reasons, there might come a decision to switch to some other kind of list, say, LinkedList. But the code shown above will never need to change because of implementation details like this. It always expects and receives a reference to a List (of some concrete type not specified).

It the method instead, would have returned a reference to an ArrayList and declared that the return type was exactly this, then the writers of the code above would be tempted to make use of this fact. It could declare objects as of type ArrayList instead and then we'd be tempted to call method specific to ArrayList (and not stick with the more general methods declared in the List interface). When the switch to LinkedList comes, this would risk to break the code. Not only the declaration of objects, but also the calls on objects for methods existing in ArrayList but not in LinkedList!

So the principle we should follow here is to program to the supertype List (in this case) rather than to any specific implementation class.

Avoid strong coupling between classes

This principle as quite coupled with the principle of programming to a supertype. What is meant by coupling is the introduction of dependencies between classes which makes one class having to change because something changes in the other class. This is most of the time a result of classes knowing to much about each other. To avoid this situation, we should use abstractions, encapsulation and supertypes, in order to hide information about stuff which could change.

The less a class knows about the implementation (code) in a class it is using, the less depending on that second class it becomes. This is quite obvious if we think about it. The more one class relies on details about the other class, the less can change in the other class without breaking the first class.

Don't repeat yourself

Avoid having the same logic encoded in more than one place, so that when the logic changes, you will only have to change it in one place. Put differently, don't write the same code in many places (if the code represents the same rules or logic). An example is given in the lecture with overloaded constructors. The first version of two constructors looks as follows:

public Character(String name){
  this.name = name;
}


public Character(String name, Weapon weapon){
  this.name = name;
  this.weapon = weapon;
}

Then a rule is introduced, that the name parameter is not allowed to be null, in which case a NullPointerException should be thrown (as a punishment to the caller who didn't follow the rule). Now, a check for null must be inserted in both constructors, right? But if we forget to insert the check in one of them, it is possible to violate the rule by calling the constructor without the check. So as long as we put the check in both constructors, there is no problem then?

public Character(String name){
  if(name==null){
    throw new NullPointerException("Name cannot be null");
  }
  this.name = name;
}


public Character(String name, Weapon weapon){
  if(name==null){
    throw new NullPointerException("Name cannot be null");
  }
  this.name = name;
  this.weapon = weapon;
}

No! We have fixed the problem but we still are depending on the repetition of the same code in two places. What if we decide to throw some other exception in place of NullPointerException? Now we must remember to change both places again. Not good. This is the better solution:

public Character(String name){
  if(name==null){
    throw new NullPointerException("Name cannot be null");
  }
  this.name = name;
}


public Character(String name, Weapon weapon){
  this(name);
  this.weapon = weapon;
}

The second constructor calls the first constructor, and only the first constructor owns the responsibility for doing the name check.

Single responsibility

We should aim to give our classes one single reason to change. Put differently, we don't want to give to much tasks to one single class, because if one of the tasks needs to change, then the whole class needs to be changed. The more tasks, the more likely it is that something forces our class to have to be re-written or changed.

A classic example is a class which both calculates a value (fuel need for some vehicle going some distance for instance) and also offers a method to output the results somewhere in some format.

Even if the fuel calculations are not very likely to change, the output will probably change because some one might want to output to a new place or in a new format. The solution to this simplified example is to create one class FuelNeedCalcualtor and another class FuelNeedOutput for the task of outputting the results.

On a side note, we recommend that you keep the amount of output statements (particularly System.out.println) to a minimum in your classes. It is better to centralize output to one module (which may change or be extended) and let the classes and objects do the math so to speak. Using System.out.println locks your classes to a console based application, which makes it less suitable for fitting into a web application or a GUI-based application (or an app in a phone etc).

We apologize for using println statements in our examples. There are there more to achieve some visual effect to prove a point.

Think about backwards compatibility

Aim to make your modules and classes closed for changes (once you publish an API it should never change) but leave opportunities for adding stuff later on (for instance by extending them). Changing an API (or a supertype) will of course break all the code using the previous version of your API (or module). That will not be popular.

Protect high-level components from changes in low-level components

If you have layers in your application (a high level layer could for instance be a user interface, and a low level layer could be closer to the hardware), you should make sure that no high level layers depend on low level layers. Another way to think about levels such as "high level class" and "low level class" is to think about what abstractions are used. If a component in a system is called Character and one of Character's behaviors is defined in another component, Weapon (or WeaponBehavior), then Character is a higher level component than Weapon. Perhaps there is a component called GameEngine which keeps tracks of Characters and other objects in the game, then the GameEngine is a high-level component, because its behavior is defined by for instance Character objects (which are low-level components compared to GameEngine).

A great way to insulate the high-level layers/components from the low-level stuff is to introduce an abstraction layer between them. A change in a low level thing should never impact code in the high levels.

Example of code which is not shielded from details from a low level implementation:

try{
  List<Character> players = Storage.getCharacters();
}catch(SQLException sqle){
  alertUser("Old game could not be loaded");
}

Why should the caller of the Storage class have to import and handle SQLException problems? It shouldn't know about the detail decision of using a database. If this low level decision changes (databases are not hip any more and the Storage class switch to cloud storage), then the caller must change its import statements and try-catch statement. This is not good. It should be the other way around, one could argue. The top level (highest levels) should set the abstractions, and the low level modules should adapt to that. An abstraction level between them often solves this problem.

One example application of this principle (sometimes called Dependency Inversion) is, if we take the Storage example again, to make the Storage an abstraction by making it an Interface. Once this interface is defined, the developers of a concrete kind of storage, say, database storage, have to follow the definitions of the Storage interface. This, way, the low-level components (like the DatabaseStorage implementaiton) depend on the high-level abstraction of the Storage interface.

If the user of the storage instead would program directly to the concrete class DatabaseStorage, then it would be depending on a low-level component, which is what this principle tries to avoid.

So the solution was to make a rather high-level abstraction of a Storage (as an interface). Then let the various concrete implementations (perhaps there will be DatabaseStorage and FileStorage and CloudStorage as concrete implementations of the Storage interface) depend on this high-level abstraction. The Storage interface will be an abstraction which protects the client code from the low-level components, so that it can focus on a Storage in general (and care less for the concrete implementation that happens to be in use at the time).

We'll see some patterns to help support this principle further on in the book.

Chapter videos

English videos

Exercises

Storage

Think about this small program for reading a file and creating a list:

// Main.java
import java.util.ArrayList;
public class Main{
  public static void main(String[] args){
    ArrayList<Person> persons = Storage.fetchPersons();

    // Make the capacity no bigger than the number of elements
    persons.trimToSize(); 

    System.out.println(persons);
  }
}

// Person.java
import java.io.Serializable;
public class Person implements Serializable{
  private static final long serialVersionUID = 3487495895819393L;
  private String name;
  private String email;

  public Person(String name, String email){
    this.name = name;
    this.email= email;
  }
  @Override
  public String toString(){
    return String.format("%s - %s", name, email);
  }
}

// Storage.java
import java.io.*;
import java.util.*;

public class Storage{
  private final static String FILE = "persons.bin";
  @SuppressWarnings("unchecked")
  public static ArrayList<Person> fetchPersons(){
    File f = new File(FILE);
    ArrayList<Person> list = null;
    try{
      if (!f.exists()){
        System.out.println("INFO: Can't find " + FILE);
        return list;
      }
      ObjectInputStream in =
	new ObjectInputStream(new FileInputStream
                              (FILE));
      list = (ArrayList<Person>)in.readObject();
      in.close();
    }catch(Exception e){
      System.err.println("Could not load address book from " + FILE);
    }
    return list;
  }

  public static void save(ArrayList<Person> list){
    try{
      ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream(FILE));
      out.writeObject(list);
      out.close();
    }catch(Exception e){
      e.printStackTrace();
    }
  }
}
  • What happens if the designers of the Storage class decide to change list implementation to LinkedList?
    • Think about the method calls in main - does trimToSize() exist in LinkedList?
    • Should Main know about what concrete type of List Storage provides?
  • Change the Storage class so that it returns a List<Person> reference and accepts a List<Person> reference for its methods
    • Change the Storage class to internally use LinkedList<Person>
    • Change Main so that it doesn't know what type of list it is dealing with (remove the trimToSize() as a consequence)

NOTE: The Storage class returns null in the fetchPersons() method if there is a missing file or IOException. It would be better to throw an exception of some type than to return null. Returning null puts the burden on the caller to always check for null. If it is important to be aware of the problem and it can be fixed by the caller, then make the method throw a checked exception. If it is important to signal that there was a problem, but we can't expect the caller to fix it anyway, throw an unchecked exception.

Replacing inheritance

Consider these classes:

public abstract class Employee{
  private String name;
  private String department;
  public Employee(String name, String department){
    this.name = name;
    this.department = department;
  }
  public String name(){ return name; }
  public String department(){ return department; }
  @Override
  public String toString(){
    return String.format("%s at %s", name, department);
  }
}
public class Engineer extends Employee{
  public Engineer(String name, String department){
    super(name, department);
  }
  @Override
  public String toString(){
    return String.format("Engineer %s", super.toString());
  }
}

public class Manager extends Employee{
  public Manager(String name, String department){
    super(name, department);
  }
  @Override
  public String toString(){
    return String.format("Manager %s", super.toString());
  }
}
  • Make a UML diagram showing the inheritance tree (you don't need to be specific)
  • Is it possible for an Engineer to become also Manager?
  • Re-design the classes so that there is only one (concrete) class called Employee.
    • Create a new Class Role which has only a role name as the instance variable and a method for getting the role name and a toString() method (see below)
    • Make an instance variable roles for the Employee as a List<Role>
    • Make a constructor which takes name, department and initialRole for the Employee class
    • (add the initial role to the list in the constructor)
    • make a method for adding a role
    • Make the toString() implementaion so that it creates a String with name, department and [roles]
    • Write a test program which confirms that you can create some employees with initial roles and that adding new roles work
  • If you want a removeRole(Role) method, how do you have to change the Role class?
    • hint: to remove an object from a list, the list must be able to find the object, perhaps using equals
+------------------+
|     Role         |
+------------------+
| -name : String   |
+---------------  -+
| +Role(String)    |
+------------------+
| +name() : String |
| +toString() :    |
|        String    |
+------------------+

Don't repeat yourself (repeat after me: Don't repeat yourself)

Add a check which if name is null throws a NullPointerException in the following constructors:

public class Person{
  private String name;
  private String phone;
  private String email;
  public Person(String name){
    this.name = name;
  }
  public Person(String name, String email){
    this.name = name;
    this.email = email;
  }
  public Person(String name, String email, String phone){
    this.name = name;
    this.email = email;
    this.phone = phone;
  }
  public String name() { return name;  }
  public String email(){ return email; }
  public String phone(){ return phone; }
  @Override
  public String toString(){
    return String.format("%s  %s  %s", name, (email==null?"":email), (phone==null?"":phone));
  }
}

Change the constructors so that there is no code duplication. Hint: do the null check in the simplest constructor

Suggested solutions

Expand using link to the right to see the full content.

Proposed solution to Storage

If the designers of Storage switched to LinkedList, then the code in Main would break. There is no trimToSize() method in LinkedList, and the import statements must be changed, and the variable types must be changed.

It is better to leave Main ignorant of what particular type of list the Storage is using. Use the interface List as the type instead.

Code for the suggested re-design:

// Program to populate the list - not part of the actual task
import java.util.List;
import java.util.LinkedList;
public class PopulateList{
  public static void main(String[] args){
    List<Person> persons = new LinkedList<>();
    
    persons.add(new Person("Bob", "bob@email.com"));
    persons.add(new Person("Ben", "ben@email.com"));
    persons.add(new Person("Pam", "pam@email.com"));
    persons.add(new Person("Eve", "eve@email.com"));
    persons.add(new Person("Guy", "guy@email.com"));
    persons.add(new Person("Lis", "lis@email.com"));
    persons.add(new Person("Ann", "ann@email.com"));

    Storage.save(persons);
    System.out.println("Saved");
  }
}

// Main.java:
import java.util.List;
public class Main{
  public static void main(String[] args){
    List<Person> persons = Storage.fetchPersons();
    System.out.println(persons);
  }
}

//Storage.java:
import java.io.*;
import java.util.*;

public class Storage{
  private final static String FILE = "persons.bin";
  @SuppressWarnings("unchecked")
  public static List<Person> fetchPersons(){    
    File f = new File(FILE);
    LinkedList<Person> list = null;
    try{
      if (!f.exists()){
        System.out.println("INFO: Can't find " + FILE);
        return list;
      }
      ObjectInputStream in = 
        new ObjectInputStream(new FileInputStream
                              (FILE));
      list = (LinkedList<Person>)in.readObject();
      in.close(); 
    }catch(Exception e){
      System.err.println("Could not load address book from " + FILE);
    }
    return list;
  }

  public static void save(List<Person> list){
    try{
      ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream(FILE));
      out.writeObject(list);
      out.close();
    }catch(Exception e){
      e.printStackTrace();
    }
  }
}

// Person.java (not changed):
import java.io.Serializable;
public class Person implements Serializable{
  private static final long serialVersionUID = 3487495895819393L;
  private String name;
  private String email;

  public Person(String name, String email){
    this.name = name;
    this.email= email;
  }
  @Override
  public String toString(){
    return String.format("%s - %s", name, email);
  }
}

You can get the source from github.

Proposed solution to replacing inheritance

      Employee
       ^     ^
      /       \
   Manager   Engineer

It is not possible for an Engineer to become also a Manager, because those classes are siblings.

Suggested re-design:

// Employee.java
import java.util.List;
import java.util.ArrayList;
public class Employee{
  private String name;
  private String department;
  private List<Role> roles;
  public Employee(String name, String department, Role initialRole){
    this.name = name;
    this.department = department;
    roles = new ArrayList<Role>();
  }
  public String name(){ return name; }
  public String department(){ return department; }
  public void addRole(Role role){
    roles.add(role);
  }
  public void removeRole(Role role){
    roles.remove(role);
  }
  @Override
  public String toString(){
    return String.format("%s at %s employed as %s", name, department, roles.toString());
  }
}

// Main.java - tests if we can create employees, add roles, remove roles
public class Main{
  public static void main(String[] args){
    Employee bob = new Employee("Bob", "IT Department", new Role("Engineer"));
    bob.addRole(new Role("Group manager"));
    bob.addRole(new Role("Union representative"));
    System.out.println(bob);
    bob.removeRole(new Role("Group manager"));
    System.out.println(bob);
  }
}

// Role.java
public class Role{
  private String name;
  public Role(String roleName){
    this.name = roleName;
  }

  public String name(){ return name; }
  @Override
  public String toString(){
    return name;
  }

  @Override
  public boolean equals(Object o){
    if(! (o instanceof Role)){
      return false;
    }
    return ((Role)o).name.equals(this.name);
  }

  // If we override equals, we should override hashCode() too
  @Override
  public int hashCode(){
    return 17 + 31*name.hashCode();
  }
}

If you want to try that the hashCode() in Role works, use a HashSet:

    java.util.HashSet<Role> set = new java.util.HashSet<Role>();
    set.add(new Role("This is a test"));
    System.out.println("Does hashCode work with contains()? " +
                       set.contains(new Role("This is a test")));
    System.out.println(set);
    // Set's should have unique members                                                                                                          
    set.add(new Role("This is a test"));
    set.add(new Role("This is a test"));
    set.add(new Role("This is a test"));
    System.out.println("After multiple adds of the same: " + set);
    set.add(new Role("This is another test"));
    System.out.println("Should contain two elements: " + set);
    set.remove(new Role("This is another test"));
    System.out.println("Should contain only the first: " + set);

You can get the source from github.

Proposed solution to don't repeat yourself

// Main.java:
public class Main{
  public static void main(String[] args){
    Person p1 = new Person("Ben Afflec");
    Person p2 = new Person("Ben Stiller", "ben@hollywood.com");
    Person p3 = new Person("Ben Kingsley", "dentist@email.com", "555-123-124");
    System.out.println(p1);
    System.out.println(p2);
    System.out.println(p3);
    //new Person(null);                                                                                                                          
    //new Person(null, "asdf");                                                                                                                  
    //new Person(null, "lkj", "sdf");                                                                                                            
  }
}

// Person.java:
public class Person{
  private String name;
  private String phone;
  private String email;
  public Person(String name){
    if(name==null){
      throw new NullPointerException("Name can't be null");
    }
    this.name = name;
  }
  public Person(String name, String email){
    this(name);
    this.email = email;
  }
  public Person(String name, String email, String phone){
    this(name,email);
    this.phone = phone;
  }
  public String name() { return name;  }
  public String email(){ return email; }
  public String phone(){ return phone; }
  @Override
  public String toString(){
    return String.format("%s  %s  %s", name, (email==null?"":email), (phone==null?"":phone));
  }
}

You can get the source from github

Chapter links

Vimeo channel

Source code

  • Source code for exercises and proposed solutions on github

Books this chapter is a part of

Further reading

Book TOC | previous chapter | next chapter