Lecture 8: Class Invariants
In Lecture 7 we discussed the idea of bad freedoms—width
field of our
proposed Connect $N$ model implementation is declared with type
int
, which means that as far as Java is concerned, any
int
goes, even if it’s something like -8
, which makes no sense
as a width.
Some bad possibilities are ruled out by the language we’re programming in. In
Java, if we declare width
to be an int
then the language
guarantees that it will be.1What possibilities does your favorite
language let you rule out? However, Java (and most
but not all other languages) gives us
no way to say directly that width
must be positive. But it does give us
a way to control it, if we think and program carefully.
1 Example: the Even
class
Consider this class for representing even integers:
/**
* An even integer.
*/
final class Even {
/*
* Constructs an {@code Even} from an even {@code int}.
*
* @param value the {@code int} representation of the even number
* @throws IllegalArgumentException if {@code value} isn't even
*/
public Even(int value) {
if (value % 2 != 0) {
throw IllegalArgumentException("value must be even");
}
this.value = value;
}
/**
* Returns the even value as an {@code int}.
*
* @return the even {@code int}
*/
public int getValue() {
return value;
}
private final int value;
}
The Even
class has one field, an int
representing the
number. Given the intended meaning of an Even
class, it would
be wrong for field value
to contain an odd number (or from the client
perspective, that the result of getValue()
would be odd). Because the
Java programming language doesn’t understand, much less track, evenness,
it cannot directly enforce this restriction for us. Instead, the Even
class enlists other rules of the language to enforce its requirements.
How do we know that field value
can never be odd?
The constructor only initializes with even numbers and throws when given an odd number.
The value of a
final
field cannot change.
Together, these two facts are enough to establish that value
is always
even, because the constructor makes that so and nothing2Well, nothing
within Java, but take a look at
JNI.
Let’s consider what happens when the class is modified slightly. Like
Even
, EvenCounter
’s value should always be even, but EvenCounter
affords the client to increment the value
field:
final class EvenCounter {
public EvenCounter(int value) {
if (value % 2 != 0) {
throw IllegalArgumentException("value must be even");
}
this.value = value;
}
public int nextValue() {
return value += 2;
}
private int value;
}
Again we’d like ensure that value
is always even, but the situation is
complicated by mutation.
How do we know? Consider that
the constructor initializes
value
to an even number,the only way to change a private field is by calling a method, and
method
nextValue()
preserves evenness.
That last point is key. Does nextValue()
ensure that value
is
even when it returns? Not at all! However, it does ensure that if
value
is even when nextValue()
is called then value
continues to be
even upon the method’s return.
We can use similar reasoning to determine whether adding particular methods would allow objects of the class to violate its rules. Consider, for example, these methods:
public void reset() { value = 0; }
Regardless of the prior value of
value
, this leaves it in a correct (even) state. So addingreset
is no threat.public int scale(int factor) { return value *= factor; }
Here the situation is subtler, because
scale
does not in fact ensure that after it's called,value
is even. However, ifvalue
is even to begin with then multiplying by anotherint
will leave it even afterward.public int halve() { return value /= 2; }
Unlike
scale
, which can only make harmless changes, division can produce oddness from evenness. Thus,halve
can be called when the object is in a good state and leave it in a bad state, so we rejecthalve
.public int half() { return value / 2; }
Method
half
looks strikingly similar to methodhalve
, buthalf
is fine and won't break our evenness abstract. Can you see why?
2 What’s going on here?
In the preceding section, we employed a technique for reasoning about programs called a class invariant. A class invariant is a logical statement about the instantaneous state of an object that is ensured by the constructors and preserved by the methods. Let’s break that down:
A logical statement is a claim that is true or false.
The instantanous state of an object is the combination of values of all its fields at some point in time.
The invariant is ensured by constructors in the sense that whenever a public constructor returns, the logical statment holds.
Preserving the logical statement means that the method doesn’t introduce nonsense—
instead, we know that if given a object in a good state then it will leave the object in a good state as well.
Here are some comments that are not class invariants:
// INVARIANT: value is small
This doesn’t work as an invariant because it isn’t a logical statement, hinging as it does on the vague word “small.”
// INVARIANT: value never decreases
This is another statement that, true or not, it’s not of the right form to be a class invariant because it’s a temporal statement rather than a statement that applies at any single point in time.
// INVARIANT: value is non-negative
Here we have a logical statement about the instantaneous state of the object, but the statement isn’t true—
the constructor is fine being passed a negative number— so it isn’t an invariant. // INVARIANT: value an int
True, but vacuous because Java’s type system takes care of this invariant for us. It’s very rarely worth listing.
3 Back to Connect $N$
We can apply the class invariant technique to our implementation of the
Connect $N$ model to rule out the additional kinds of nonsense states
that were method earlier, such as dimensions being negative or the
columns list containing values that don’t correspond to players. We
guard against these possibilities by imposing class invariants and
checking that they’re respected. In the case of Connect $N$, we want
know that the dimensions are always sensible (positive), the turn stands
for a valid player, the length of the columns list equals width
of the
grid, the length of every column in the list doesn’t exceed height
, an
all the elements of the columns are non-null integers between 0 and
players - 1
.
In order to apply class invariant reasoning, we need to determine what invariants we have (or think we have), and then check the code to make sure that’s true.
4 The class invariant reasoning principle
Class invariants enable a form of reasoning called rely-guarantee. The idea is that components rely on some things being true when they’re invoked, and they guarantee some things being true upon return (provide their reliance is satisfied). In particular,
if the constructor ensures some property,
and every method (or means by which the client can mutate the object) preserves the property,
then every public method, on entry, can rely on the property.
In this way, class invariants allow you to rule out object representations that you don’t want.
5 Example: rational numbers
Sometimes we want to rule out representations because they don’t make sense in terms of the relevant domain, but another reason to restrict representations is to make other parts of our program simpler. For example, we might write rational number class using a fractional representation with a numerator field and a denominator field. Unfortunately, this representation has a wrinkle, because there are many ways to write each rational number as a fraction.
We now consider three iterations of an implementation of
a simple Rational
interface:
RationalImpl1
disallows constructing an object with zero denominator (common the next two iterations as well), but imposes no other restrictions on the values of thenum
andden
fields and deals with the possibility of different ways to represent the same number on an ad-hoc basis, converting where necessary.RationalImpl2
documents the invariant thatden != 0
. It then takes advantage of the new invariant by introducing a private fast path forRational
construction where the denominator is guaranteed to be zero. It does this by removing all validation logic from the constructor to a static factory method, and then making the constructor, which now trusts its parameters,private
.RationalImpl3
adds the invariant that the fractional representation is in least terms, or equivalently, that the greatest common divisor betweenden
andnum
is 1. Least-terms fractions define a canonical represention for each rational, which means that now we can compare rationals by comparing their components without any kind of conversion. And operations that are known to preserve least terms, such as negation, are allowed to skip validation altogther when constructing a the result.
6 Other invariants
The notion of an invariant should be familiar from last year. Recall from Fundies 2 our discussion of heaps, which were binary trees that obeyed two invariants simultaneously:
A structural invariant, the fullness property, that ensured the tree with $n$ items was always as short as it could possibly be, which in turn ensured $O(\log n)$ performance. It also came in handy when we looked at clever data representations of heaps using arrays.
A logical invariant, the heap-ordering property, that ensured the largest values were always near the root of the tree and that all subtrees were themselves heaps.
These invariants gave us similar rely-guarantee reasoning principles as the class invariants we discussed above, albeit based on totally different premises. Note that both of these invariants are instantaneous properties, like class invariants, but they are not properties of a single isolated object (though if you squint and treat the entire heap as a “single” entity, it’s pretty close). We then used these invariants to drive our algorithm design.
Class invariants are similar in spirit, but a bit different in scope. They focus more on making all the methods of a single class work properly in concert, so that other parts of the program can rely on the property being true. When a single class implements an entire data type, class invariants and logical invariants become largely the same thing, but it’s rare for a data type to be implemented by just one class!
1What possibilities does your favorite language let you rule out?
2Well, nothing within Java, but take a look at JNI.