More ArrayIntList; pre/post conditions; exceptions
Table of contents
Review
We have learned a lot this week about object-oriented programming! Some of the highlights include:
- How to define our own classes, and how to manage state (fields) and define behaviors (methods) for those classes.
- How to create instances of the classes and work with objects.
- How to create abstractions that separate the client from knowing how our class works internally.
- How to enforce access to a class’s internal fields by encapsulating state with the
private
modifier.
These are all concepts that can be tricky to wrap your head around the first time you see them since they require a different way to think about the programs than what you are used to. It can also be hard to see why we care about abstractions and encapsulation until we work on larger programs.
One motivation is that, unlike other engineering disciplines, software is effectively unconstrained by the laws of physics. Chemical engineers, for example, have to worry about temperature. Civil engineers have to worry about the properties of materials. For software engineers, the greatest limitation we face is understanding exactly what we’re building.
Other instances
What if we wanted to write methods for ArrayIntList
that took other ArrayIntList
instances as parameters? This sounds kind of weird at first but can be really helpful! For example, the ArrayList
class has a method addAll
that takes another ArrayList
and adds all the values in that list to this one.
Let’s implement the addAll
method for ArrayIntList
.
Client
ArrayIntList list1 = new ArrayIntList();
list1.add(1);
list1.add(2);
list1.add(3);
ArrayIntList list2 = new ArrayIntList();
list2.add(4);
list2.add(5);
list1.addAll(list2);
System.out.println(list1);
// [1, 2, 3, 4, 5]
We can start by writing a method with the correct header
Implementer
public class ArrayIntList {
private int[] elementData;
private int size;
...
// post: adds all the values from other to the end of this list
// the values will appear at the end in the same order they appear in other
public void addAll(ArrayIntList other) {
// TODO
}
}
In the addAll
method, we’re now working with two different instances of the ArrayIntList
class.
- The secret variable
this
refers tolist1
. - The parameter
other
refers tolist2
.
We want to add each element of list2
to list1
. To make this happen, in the addAll
method, our code needs to call this.add
on each element of other
.
Implementer
public void addAll(ArrayIntList other) {
for (int i = 0; i < other.size(); i++) {
this.add(other.get(i));
}
}
Private access
Here is the same method implemented by accessing the fields of other
directly.
public void addAll(ArrayIntList other) {
for (int i = 0; i < other.size; i++) {
this.add(other.elementData[i]);
}
}
Will this code compile?
Yes! Even though the elementData
and size
fields are declared private, Java’s definition of private
has some technicalities to it.
In Java, private
doesn’t mean private to things outside this instance, it means private to things outside of this class. While that looks like a tiny wording difference, what it implies is that any instance of ArrayIntList
has the ability to access fields of any other ArrayIntList
.
Both this example of addAll
and the earlier example are good style. Neither approach is significantly faster than the other. But when we implement linked lists in a couple weeks, we’ll see how some of these assumptions break down due to The Law of Leaky Abstractions.
Pre/post conditions
When we implemented get
and set
, there were some situations that our ArrayIntList
couldn’t handle.
Give an example of a problematic input to get.
A simple example would be an ArrayIndexOutOfBoundsException
by calling get(-8)
or get(100000)
. A more subtle example is any index greater than or equal to the current list size, since get
would return the “garbage” value in a vacant space.
- Preconditions
- Conditions that the client needs to follow in order for the method to behave as intended.
- Postconditions
- The intended behavior of a method assuming preconditions hold.
Implementer
// pre : 0 <= index < size()
// post: returns the element at the given index
public int get(int index) {
return elementData[index];
}
While these comments are helpful for the client, what’s even better is to add checks in the code to mitigate or prevent unintended behavior from occurring when the preconditions are not met.
- We can return a specific dummy value or print a message to the console in order to notify the client. For example, some implementations of
indexOf
will return -1 if the element is not in the array. Since the client knows that the only valid values are in the range0 <= index < size()
, -1 signifies a false result. This behavior should be documented! - We can stop the program entirely if there’s no acceptable way of handling the problem. We’ll learn how to do this using Java exceptions.
Why is it a bad idea for get to return -1 when given an invalid index?
The client might have actually added the value -1 to the list earlier, so they wouldn’t be able to know if the -1 returned from get
represented the dummy value or the value that they added to the list.
Exceptions
We can check the preconditions by adding an if
statement. In order to stop the program within the if
statement, we use the Java throw
syntax.
Implementer
// pre : 0 <= index < size() (throws IllegalArgumentException if not)
// post: returns the element at the given index
public int get(int index) {
if (...) {
throw new IllegalArgumentException();
}
return elementData[index];
}
In this example, we chose to use a new IllegalArgumentException
. There are hundreds of different types of exceptions, each of which informs the client of a different kind of problem. We don’t need to worry too much about the particulars of which type of exception to use.
Notice that we also documented this exception by adding it to our method comment.
What condition should we use within the parentheses of the if statement?
We want to enter the if
branch when the preconditions are not met, or when the index < 0
or index >= size()
.
Class constants
Programs are meant to be read by humans, not by computers. We want our code to say what we mean as clearly as possible. Let’s look back at our version of ArrayIntList
.
Implementer
public class ArrayIntList {
private int[] elementData;
private int size;
public ArrayIntList() {
elementData = new int[10];
size = 0;
}
}
If someone else were to read our code, they might ask, “Why is the number 10 there? Is there any reason it’s not 9?”
In this program, the number 10 is what’s called a magic value. As the implementers, we wanted 10 to be the default capacity of elementData
. However, to someone new to this program, it’s not necessarily immediately clear that the number 10 means “default capacity”. (They might assume that the number 10 might have important meaning elsewhere in the class, so they might end up having unnecessarily to read the rest of the class to answer this qusetion.)
One solution is to just write a comment indicating the meaning of the value 10. That’s not a bad idea. However, if we use the number 10 in many in places in the class, it would be better to have the flexibility of changing it without having to change every single place that it appears. A better solution is to store magic values in a class constant with a descriptive name that we use in our code instead of that magic value.
Implementer
public class ArrayIntList {
public static final int DEFAULT_CAPACITY = 10;
private int[] elementData;
private int size;
public ArrayIntList() {
elementData = new int[DEFAULT_CAPACITY];
size = 0;
}
}
- Class constants are usually declared
public static final
. There may be some cases where we want those to be different, but every class constant in CSE 143 should have all of these modifiers. - By convention, class constants follow an
UPPER_CASE
naming format (affectionately known asYELLING_CASE
). - Everywhere we need the default size we will now instead use the constant
DEFAULT_CAPACITY
.
Class constants are even more helpful when they replace magic values in multiple places. Now, if we want to change the default capacity, we only need to change it once at the top of the class instead of having to manually modify every instance. This way, we avoid forgetting to change some of them and also avoid bugs that arise from accidentally changing other magic values that happen to be set to 10.
Multiple constructors
As implementers, we can provide different ways for clients to construct a new instance of a class. For example, what if we would like the client to be able to construct a new ArrayIntList
with a particular capacity? We would want to define a new constructor with an int capacity
parameter.
Implementer
public class ArrayIntList {
public static final int DEFAULT_CAPACITY = 10;
private int[] elementData;
private int size;
public ArrayIntList() {
elementData = new int[DEFAULT_CAPACITY];
size = 0;
}
public ArrayIntList(int capacity) {
elementData = new int[capacity];
size = 0;
}
}
Notice that there’s a bit of redundancy in this code. The two lines in each of the constructors are nearly identical. Use this(...)
to call another constructor from within a constructor.
Implementer
public class ArrayIntList {
public static final int DEFAULT_CAPACITY = 10;
private int[] elementData;
private int size;
public ArrayIntList() {
this(DEFAULT_CAPACITY);
}
public ArrayIntList(int capacity) {
elementData = new int[capacity];
size = 0;
}
}
What would happen if we replaced this(...) with new ArrayIntList(...) instead?
Whenever we use the new
keyword, a new object is created. We can even create new objects as we are creating an object. Calling new ArrayIntList(DEFAULT_CAPACITY)
creates a second ArrayIntList
inside of the constructor!
It might help to visualize the following code that assigns the result to a variable.
Implementer
public ArrayIntList() {
// this(DEFAULT_CAPACITY);
ArrayIntList list = new ArrayIntList(DEFAULT_CAPACITY);
}