Lecture 20: Arrays, Variables, and For-loops 2
Overview
Creating arrays from scratch
More patterns of iteration and looping
11.1 Creating Arrays with Initializers
Unlike inheritance, methods, classes, and recursive data, arrays really don’t have much to do with object-oriented programming. However, they’re so pervasive in programming that it’s worth studying them.
So far, we’ve only processed arrays that were passed in as command-line arguments by Java. This is a common use case for arrays, but not the only one. It’s important and useful to see other uses of arrays, since they’re commonly used to store data in programs.
To get started, we need to first know how to create them in the first place. There are several ways to create arrays. The first and easiest is to use an array initialization statement. This is written as a sequence of comma-separated values in between curly braces. Here’s a simple one:
String[] args = {"one", "two", "three"};
We can use these to declare either variables or fields. To easily try things out, we could use these to create fields in an examples class, for example:
class ExamplesArrays { String[] args = {"one", "two", "three"}; String one = args[0]; String two = args[1]; String three = args[2]; }
If we run this, we see some new output that shows the array shape:
new ExamplesArrays:1( |
this.args = |
new Object[3](){ |
[0] "one", |
[1] "two", |
[2] "three"} |
this.one = "one" |
this.two = "two" |
this.three = "three") |
The funny new Object[3](){ printout is how the tester library prints array values – 3 indicates the length. The values are printed in order with the corresponding index next to each.
Arrays can contain values other than strings. For example, they can contain int or double values:
class ExamplesArrays { ... int[] someNumbers = {40, 50, 60}; double[] someMoreNumbers = {100.5, 0.3, 9.5, 4.4}; int num40 = someNumbers[0]; double num9p5 = someMoreNumbers[2]; }
Arrays that contain non-object values (remember – Strings are objects and have methods) print oddly with the current version of the tester library:
this.someNumbers = new [I:3() |
this.someMoreNumbers = new [D:4()) |
this.num40 = 40 |
this.num9p5 = 9.5) |
I’ve contacted the author of the tester library. This section will be updated if the library is fixed to have different behavior.
11.2 Arrays as Arguments
Just as we can use references to objects as arguments to methods, we can use references to arrays. So, for example, we could write a sum method over arrays of doubles, and use it on one of the examples we just created:
class ExamplesArrays { /* @param numbers Elements to add together @return The sum of the numbers */ double sum(double[] numbers) { double total = 0; for(double num: numbers) { total = total + num; } return total; } ... double[] someMoreNumbers = {100.5, 0.3, 9.5, 4.4}; double[] noNumbersHere = {}; double sum1 = this.sum(this.someMoreNumbers); double sum2 = this.sum(this.noNumbersHere); }
There are no new Java constructs involved here. The for-each loop is the same as in the last section, where the type of the element variable is double, the name of the element variable is num, and the array is an array of doubles. Just as we wrote String[] for the parameter type of main, we write double[] for the parameter type of sum. In addition, we made sure that this method would work for empty arrays by adding an example for that case.
Exercise
Write a method mean that takes an array of double and returns the mean.
Exercise
Write a method max that takes an array of int and returns the largest integer in the array.
Exercise
Write a method longest that takes an array of String and returns the longest string in the array.
11.3 More Iteration Patterns
There are many other patterns for iterating over arrays, especially using counted for loops rather than for-each loops. For example, we could sum every other element in an array:
double sumEveryOther(double[] numbers) { double total = 0; for(int i = 0; i < numbers.length; i = i + 2) { total = total + numbers[i]; } return total; }
Do Now!
Read the code above carefully – what makes it not visit every index, but only every other?
The key part above is i = i + 2, which makes the variable i increase by 2 rather than 1 on each iteration.
Do Now!
Fill in the tabular form for the above loop given the input array {1.1, 2.2, 3.3}:
Iteration
Value of i
total before
total after
We can also write methods that skip just the last element of an array. A common case for such a method is one that puts commas in betwee each element in a list of strings, but not after the last one. Let’s build that up:
/* @param strs The strings to join together @param separator The string to insert between the provided strs @return A single string containing the provided strings joined with the separator */ String intersperse(String[] strs, String separator)
When we get into processing arrays in trickier ways, there are lots of opportunities to make mistakes with indices. For example, it’s easy to be off by one index. To avoid these issues, it is more important than ever with array methods to think through examples first, so that we have tests to check our work later, after things get complicated. So let’s pick a few tests for this method. In general, we should always make sure a method that processes arrays makes sense with the empty array, arrays with single elements, and several cases of longer arrays:
boolean testIntersperse(Tester t) { String[] strs1 = {"a", "b", "c"}; t.checkExpect(this.intersperse(strs1, " and "), "a and b and c"); t.checkExpect(this.intersperse(strs1, ";"), "a;b;c"); String[] empty = {}; t.checkExpect(this.intersperse(empty, ","), ""); t.checkExpect(this.intersperse(empty, "; "), ""); String[] one = {"onestring"}; t.checkExpect(this.intersperse(one, ","), "onestring"); t.checkExpect(this.intersperse(one, "; "), "onestring"); String[] two = {"two", "strings"}; t.checkExpect(this.intersperse(two, "|"), "two|strings"); t.checkExpect(this.intersperse(two, "; "), "two; strings"); }
One way to implement this is to write a loop that appends each string with the separator after it for all the elements except the last one, and then add the last string in the array:
i += 1 is an abbreviation for i = i + 1
String intersperse(String[] strs, String separator) { String result = ""; for(int i = 0; i < strs.length - 1; i += 1) { result += strs[i] + separator; } result += strs[strs.length - 1]; return result; }
If we run this, we get an exception!
⤇ ./run ExamplesArrays |
Tester Prima v.2.3 |
----------------------------------- |
Tests defined in the class: ExamplesArrays: |
--------------------------- |
Threw exception during test 4 |
java.lang.ArrayIndexOutOfBoundsException: -1 |
Do Now!
What’s the problem? The exception is happening on the line just before the return statement in the intersperse method, during the test on empty.
When the array is empty, the line
result += strs[strs.length - 1];
runs with strs.length is equal to 0. That produces the index -1 for the array lookup, which produces the error. The body of the method doesn’t work for empty arrays! We could try to re-work the logic around this case. We could also just add a single check:
String intersperse(String[] strs, String separator) { if(strs.length == 0) { return ""; } String result = ""; for(int i = 0; i < strs.length - 1; i += 1) { result += strs[i] + separator; } result += strs[strs.length - 1]; return result; }
That is, if the array is empty, we know the empty string "" should be returned, so just do that directly. Sometimes the empty array (or even the case with a single element in the array) is special, and deserves this kind of special-case treatment.
There are actually several ways we could have written this method. For example, we could have visited every element with the loop, and not appended the separator if the index was equal to the highest index in the array. Or we could have started result with the first element in the array, and then added the separator + strs[i] in a loop that started at index 1. Often, we have some choice in our particular layout of a loop, and there’s not a specific “right” way to write it.
Exercise
Try writing one of the versions described in English above, and test it to make sure it works.
Exercise
Write a method that computes the sum of the first half of the elements in the array. What part of the counted for loop needs to change to accomplish this?
11.4 Creating Arrays, Round 2
We saw how we can create arrays with array initializers. This is a somewhat limited approach, since it can only create arrays with a size that’s known when we write the program. It’s also useful to be able to create arrays that have a size determined by the program itself. This works differently than using initializers.
A useful method is one that takes two numbers and produces an array of numbers from the first up to the second. This could be useful for creating a numbered list, for example. This is different than the methods we’ve written so far, because it doesn’t take a reference to an array as input. Instead, it produces one as output. Let’s think about the header first:
/* @param start The number to start from @param end The number to end before @return a reference to a new array containing integers from start (inclusive) to end (exclusive) */ int[] range(int start, int end)
A few examples are warranted:
boolean testRange(Tester t) { int[] check1 = {0, 1, 2}; t.checkExpect(this.range(0, 3), check1); int[] check2 = {}; t.checkExpect(this.range(0, 0), check2); int[] check3 = {5}; t.checkexpect(this.range(5, 6), check3); int[] check4 = {5, 6, 7, 8}; t.checkexpect(this.range(5, 9), check4); }
We need a two new Java features to write this method body, because there is no array initializer we can write that will create a different array depending on the argument value. That is, how to fill in the ??? below with an array that always has the right size based on start and end?
int[] range(int start, int end) { int[] result = ??? ... }
Here’s the first of the new features we need: we can create an array of a specified size with the following syntax:
new Type[size]
Where size is some expression that calculates an int. For example, before going back to the range method, we can think about using this as example data:
class ExamplesArrays { int[] nums = new int[3]; }
In memory diagram form, here’s what this creates:
Note that the value of all the elements of the newly created array are 0! When we create an array using the new operator, all the elements get a specific default value. For int and double arrays, that value is 0. There are 3 elements in this array because we specified the size 3 in new int[3].
This leaves us with a question. If the arrays get created full of default values, how can we create an array that contains the values we want? The answer is the second new feature we need, which is array assignment. This assigns values into the indices in an array, and the syntax is:
someArray[index] = newVal
We can see this in action with a simple method that creates a fixed size array and sets its contents:
int[] simpleArrayCreator() { int[] nums = new int[3]; nums[0] = 88; nums[1] = 95; nums[2] = 1000; return nums; }
As this method runs, here’s what happens:
We can apply these two new features to fill in the body of range:
int[] range(int start, int end) { int[] result = new int[end - start]; // creates a new array of int of size (end - start) for(int i = start; i < end; i += 1) { result[i - start] = i; // assigns into the appropriate start field } return result; }
We can see the execution of this in tabular form. Let’s take the input 5, 9.
Iteration |
| Value of i |
| Value of start |
| Value of end |
| result before |
| result after |
1 |
| 5 |
| 5 |
| 9 |
| {0, 0, 0, 0} |
| {5, 0, 0, 0} |
2 |
| 6 |
| 5 |
| 9 |
| {5, 0, 0, 0} |
| {5, 6, 0, 0} |
3 |
| 7 |
| 5 |
| 9 |
| {5, 6, 0, 0} |
| {5, 6, 7, 0} |
4 |
| 8 |
| 5 |
| 9 |
| {5, 6, 7, 0} |
| {5, 6, 7, 8} |
There’s a few interesting things going on here. First, we create an array of the correct size right at the beginning of the method. This is a common pattern in array-processing methods, which is to compute the correct size and create the resulting array first. Next we loop the index i from start to end, so i will take on the values 5 through 8. When we perform the array assignment, we are careful to use i - start to make sure we don’t use indices that are too large for the array, and always start at index 0. The tabular description helps us see the values within the array at each step, and how they are incrementally filled in – in each step, we can see that the index of i - start is filled in in the rightmost column.
Do Now!
Can you think of a way to write this that makes i loop from 0 to end - start? How would the loop body need to change?
11.5 More Array Manipulation
Armed with the ability to create arrays of any length, and to change their contents after creation, we now have many new programs we can write.
For example, we can write a method that takes in an array and an element, and returns a new array that has all the same contents, but with the new element appended at the end. This lets us “get around” the restriction that arrays’ size is fixed at creation time, because we can use this method to create new, larger arrays from existing ones. Note that we could do the same thing with the linked lists we wrote, so this isn’t quite fundamentally new. However, doing it with arrays keeps the benefits of access-by-index, which can be important in many applications.
Let’s try to write addAtEnd:
The method body here is an example of a stub I filled in to avoid putting the whole implementation in place.
/* @param String[] base The base array to add to @param String toAdd The string to add at the end @return A new array containing the elements of base followed by toAdd */ String[] addAtEnd(String[] base, String toAdd) { String[] result = {}; return result; } boolean testAddAtEnd(Tester t) { String[] base1 = {"kiwi", "apple", "banana"}; String[] check1 = {"kiwi", "apple", "banana", "orange"}; t.checkExpect(this.addAtEnd(base1, "orange"), check1); String[] base2 = {}; String[] check2 = {"bear"}; String[] check3 = {"bear", "lion"}; t.checkExpect(this.addAtEnd(base2, "bear"), check2); t.checkExpect(this.addAtEnd(check2, "lion"), check3); return true; }
We can note a few things here. First, the returned array always has a length one greater than the input array. That helps us determine the size of the new array that should be created. Second, since the contents are the same for the indices that overlap, we know that we’ll be copying some number of elements from the original array to the returned one. That helps us figure out the structure of the method:
String[] addAtEnd(String[] base, String toAdd) { String[] result = new String[base.length + 1]; // There will be one more element in what we return for(int i = 0; i < base.length; i += 1) { result[i] = base[i]; } result[base.length] = toAdd; return result; }
It’s important to note that in running addAtEnd, the original array is completely unchanged. There is simply a new array created that happens to have much of the same contents, with an additional element.
Exercise
Write a method addAtBeginning that’s like addAtEnd but adds the element at the beginning of the returned array.
11.6 Summary
Arrays aren’t just for command line arguments.
We can create arrays both with array initializers and with new.
We can use array assignment to update values within an array.
When we iterate over indices, we can use the for loop construct to control which indices we visit.