Guidelines for Class Design
Good design of individual classes is crucial to good overall system
design. A well-designed class is more re-usable in different contexts,
and more modifiable for future versions of software. Here, we'll look at
some general class design guidelines, as well as some tips for specific
languages, like C++ or Java.
Reminder: Important C++/Java differences
Remember, there are some differences in the implementations of different
object-oriented languages. The primary ones we've seen are C++ and Java.
- In Java, all class-type "variables" are just references, which can be
attached to objects, which are dynamically allocated.
- In C++, pointers can attach to dynamic objects or arrays, but regular
class-type variables can represent statically allocated objects
- This is a big difference that must be understood when designing
classes in these two languages
General goals for building a good class
- Having a good usable interface
- Implementation objectives, like efficient algorithms and
convenient/simple coding
- Separation of implementation from interface!!
- Improves modifiability and maintainability for future versions
- Decreases coupling between classes, i.e. the amount of
dependency between classes (will changing one class require changes
to another?)
- Can re-work a class inside, without changing its interface, for
outside interctions
- Consider how much the automobile has advanced technologically since
its invention. Yet the basic interface remains the same -- steering
wheel, gas pedal, brake pedal, etc.
Designing a good class interface
By interface, we are talking about what the class
user (a programmer who uses a specific class in their own coding) sees.
This is the public section of the class. When desiging the interface of a
class, here are some things to strive for:
- Cohesion (or coherence)
- A class is cohesive if all of its methods are related to a single
abstraction
- Completeness
- A class should support all important operations that are a part of
the abstraction represented by the class
- Convenience
- Basically, this means ease of use for the intended user
- Make sure you understand who your likely user is!
- Could be a general application programmer (as with Java API
packages)
- Could be another subsystem of the system being designed
- Perhaps all tasks are possible, but are they all easy? Or does the
user have to jump through hoops to accomplish certain tasks?
- Clarity
- The interface of a class should be clear to programmers, without
allowing confusion or ambiguous interpretations
- Consistency
- Operations in a class will be most clear if they are consistent with
each other
- This applies in naming conventions, behavior, how similar methods are
set up with parameters and returns, etc
Sometimes these goals can conflict with each other. Designer must use
best judgement to balance any conflicts and decide which aspects are most
important.
Some counterexamples
Cohesion
Consider this class
pulic class Roster
{
public addStudent(Student s) { ... }
public void deleteStudent(Student s) { ... }
public Student getStudent(int index) { ... }
public void processCommand(String cmd) { ... }
}
- What's the purpose of this class (i.e. this abstraction)?
- Appears to be to store and manage students on a class roster
- processCommand() appears to be out of place
- Doesn't really relate to the roster, that stores and manages a list
of students
- This method is out of place -- violates cohesion
- Better to have a different class deal with "commands"
Completeness
Consider the first version of class Fraction I use when
introducing classes:
Clearly we could add more functions to these -- they were just preliminary
examples. In what ways are these class examples incomplete? What should
be added as standard features?
- Simplify?
- Arithmetic operations between two fractions
- Comparisons between two fractions (incluing equalities and
inequalities, for sorting)
- Other numeric related operations?
- Conversions to other compatible types?
If arithmetic operations were left out of the Fraction class, would this
be a fatal flaw?
Convenience
- The java.util.Scanner class is a great convenience
- Consider the task of reading formatted input from the console
(standard input) in Java 1.4.2 or earlier (i.e. before the
Scanner class)
- MyInput.java --
Here's a class that illustrates how one might write a few functions to
parse a few types of keyboard input
- It works (for strings, integers, and doubles). But it's not
convenient!
- Luckily, the Scanner class now simplifies these things. Just
build a Scanner object around System.in and use methods like
nextInt()
Clarity
Consider the
ListIterator interface in Java.
- Try this example code
- Note that it builds a small linked list, loads it with "ABCD", and
builds a ListIterator, which can be used to walk through the list
elements
- What happens when we try remove()? Why?
- Now,
look at the API documentation for remove
- Confusing? Certainly not intuitive. Notice that it cannot be
directly called after add(). You must call
next or previous before a remove.
- See this revised code
example
- Is the complexity really necessary? Perhaps it should be simpler.
Consistency
Look at the Java
String class
- For checking equality between strings s and t, you can call:
s.equals(t); // for exact equality
s.equalsIgnoreCase(t); // to disregard uppercase/lowercase differences
- Similarly, we have:
s.compareTo(t)
s.compareToIgnoreCase(t)
- Now, look at the two regionMatches method prototypes:
boolean regionMatches(int toffset, String other, int ooffset, int len)
boolean regionMatches(boolean ignoreCase, int toffset, String other,
int ooffset, int len)
See anything inconsistent here? Why not a
regionMatchesIgnoreCase method, like the others? Instead, this
one uses a boolean parameter to set the "ignoreCase" version.
- Not a fatal flaw, and the class is still usable. But this could be
irritating to users, and at the very least, it's not using consistent
name/parameter setups here
Encapsulation -- separating interface and implementation
General guidelines
- Make data private
- Create an interface of public methods for accessing/manipulating the
data
- Any "utility" functions should be made private
- These would be functions not specifically part of the user's
interface
- Called upon by other methods -- usually for division of duties or
commonly performed internal tasks
- Provide accessors and/or mutators where needed
- Ensure the object is always in a valid state (i.e. valid for the
chosen semantic meaning of the object)
- e.g. a zero denominator would be invalid for a Fraction, a negative
radius makes no sense for a Circle
- All constructors should intialize the object to a valid state
- Make sure the class has at least one programmer-defined constructor
(an automatically built constructor in C++ does nothing)
- All mutator methods should leave the object in a valid state
- Avoid returning direct access (reference or pointer) to internal
private data
The Law of Demeter
- This refers to a general "principle of least knowledge" guideline
for developing software
- Wikipedia link
with definition
- Fundamental notion is that an object should assume as little as
possible about other objects or components
- When applied to member functions, it says that a method M of a class
should only invoke methods of:
- the same class type (itself)
- objects that are instance fields of the class
- objects passed as M's parameters
- objects created inside M
- Note that the Law of Demeter implies that a class should not return a
reference or pointer to an object that is part of its own private data,
especially for the purpose of allowing it to be changed by the caller
- Note that there are occasionally exceptions to this part in common
classes:
- Example: A C++ array-based class that overloads the bracket []
operator, returning internal data by reference, to be used as an
L-value with common array notation.
- Java objects that are immutable -- safe to return by reference,
without fear of change
- Note: A method that follows the Law of Demeter does not operate on
global objects or on objects that are a part of another object
- It's good to avoid object promiscuity
Accessors and Mutators
- A strict accessor method is a function that only returns
something -- a copy of instance data, or a computed value, for example
-- but does not change the state of the object
- A mutator method is one that modifies the state of the object
- In a more general sense, any function that modifies an object could
be considered a mutator
- Often, the term "mutator" brings to mind set() functions,
whose purpose is simply to reset some or all of the object's state to
new values
- Accessors should not return direct access to instance fields (see Law
of Demeter).
- Should there be a set mutator method for every instance
variable? Not necessarily! Don't assume it.
- Consider a Date class that has instance variables
month, day, year
- What if we had setMonth(), setDay(),
setYear()?
- Consider this code:
Date deadline = new Date(1, 31, 2008); // deadline is Jan 1, 2008
// Suppose we decide the deadline should be moved to Feb 28?
deadline.setMonth(2); // will this be allowed? after all, if successful
// the date would now be 2/31/2008. Illegal date!
deadline.setDay(28); // state invalid UNTIL we reach this call
If validation of some sort was added into these methods, the first
would not be allowed, because Feb 31 is not legal, and we haven't set
the day yet
- In this case, it seems better to have a single set() method,
with three parameters:
deadline.set(2,28,2008);
- In some cases, individual set functions make sense.
- Class java.awt.Graphics has methods setColor() and
setFont()
- It is common to set these individually, and they do not conflict
in any way
Separation of accessors and mutators
It's usually best to keep accessors and mutators separate -- an accessor
should not change the object's state, and mutator should.
There are some classes that do things otherwise. A decision must be
made to balance things like clarity against convenience, for example.
Consider the
java.util.StringTokenizer class.
- This class allows a tokenizer to be created from a larger string, and
it is broken up into pieces (tokens)
- A call to nextToken() returns the next token, or piece.
- So, it feels like an accessor (returns something internal)
- But, it also advances to the next token -- so it's a mutator, changing
the state of the internal object. Call it twice in a row, and you get
different tokens
- This code would iterate through all the tokens:
String str = ... // initialize to whatever string desired
String temp;
StringTokenizer tok = new StringTokenizer(str);
while(tok.hasMoreTokens())
temp = tok.nextToken();
- Is it possible to split this into separate accessor and mutator?
Sure -- consider writing these methods:
String getToken() // strict accessor, no change
void nextToken() // mutator -- advances to next token
- In this hypothetical situation, this loop would traverse the tokens:
for (tok = new StringTokenizer(str); tok.hasMoreTokens(); t.nextToken())
temp = t.getToken();
- Advantages? The first way requires fewer function calls, but the
second (hypothetical) way allows retrieving the current token multiple
times if needed, before advancing
- This could easily be remedied by just adding a getToken()
(strict accessor) method to the class, and leaving nextToken()
as-is:
- The nextToken() doesn't need to be void return
type, because the caller can always ignore the return.
- This way, either of the above iteration techniques could be
used, and both advantages realized!
- Generally speaking, this would amount to having a separate accessor
and a mutator -- where the mutator returns the value for the user's
convenience, but the user can still use separately if desired.
- Where could you do the same thing on a Queue class, for example?