|How to Think Like a Computer Scientist|
source ref: ebookit.html
Every time you write a class definition, you create a new Object type, with the same name as the class. Way back in Chapter 1.5, when we defined the class named Hello, we also created an object type named Hello. We didn't create any variables with type Hello, and we didn't use the new command to create any Hello objects, but we could have!
That example may not make any sense, since there is no reason to create a Hello object, and it is not clear what it would be good for if we did. In this chapter, we will look at some examples of class definitions that create useful new Object types.
Here are the most important ideas in this chapter:
Here are some syntax issues about class definitions:
With those issues out of the way, let's look at an example of a user-defined type, Time.
A common motivation for creating a new Object type is to take several related pieces of data and encapsulate them into an object that can be manipulated (passed as an argument, operated on) as a single unit. We have already seen two built-in types like this, Point and Rectangle.
Another example, which we will implement ourselves, is Time, which is used to record the time of day. The various pieces of information that form a time are the hour, minute and second. Because every Time object will contain these data, we need to create instance variables to hold them.
The first step is to decide what type each variable should be. It seems clear that hour and minute should be integers. Just to keep things interesting, let's make second a double, so we can record fractions of a section.
Instance variables are declared at the beginning of the class definition, outside of any method definition, like this:
By now you are probably getting sick of seeing the word public everywhere, with no explanation of what it means. Be patient; I will explain it soon. For now, you should use it in front of the definitions of instance variables, as well as class and method definitions.
All by itself, this code fragment is a legal class definition, but usually you want at least one constructor for each class.
The usual role of a constructor is to initialize the instance variables. The syntax for constructors is similar to that of other methods, with two exceptions:
Here is an example for the Time class:
Notice that where you would expect to see a return type, between public and Time, there is nothing. That's how we (and the compiler) can tell that this is a constructor.
This constructor does not take any arguments, as indicated by the empty parentheses (). Each line of the constructor initializes an instance variable to an arbitrary default value (in this case, midnight). The name this is a special keyword that is the name of the object we are creating. You can use this the same way you use the name of any other object. For example, you can read and write the instance variables of this, and you can pass this as an argument to other methods.
But you do not declare this and you do not use new to create it. In fact, you are not even allowed to make an assignment to it! this is created by the system; all you have to do is store values in its instance variables.
A common error when writing constructors is to put a return statement at the end. Resist the temptation.
Constructors can be overloaded, just like other methods, which means that you can provide multiple constructors with different parameters. Java knows which constructor to invoke by matching the arguments of the new command with the parameters of the constructors.
It is very common to have one constructor that takes no arguments (shown above), and one constructor that takes a parameter list that is identical to the list of instance variables. For example:
The names and types of the parameters are exactly the same as the names and types of the instance variables. All the constructor does is copy the information from the parameters to the instance variables.
If you go back and look at the documentation for Points and Rectangles, you will see that both classes provide constructors like this. Overloading constructors provides the flexibility to create an object first and then fill in the blanks, or to collect all the information before creating the object.
So far this might not seem very interesting, and in fact it is not. Writing constructors is a boring, mechanical process. Once you have written two, you will find that you can churn them out in your sleep, just by looking at the list of instance variables.
Although constructors look like methods, you never invoke them directly. Instead, when you use the new command, the system allocates space for the new object and then invokes your constructor to initialize the instance variables.
The following program demonstrates two ways to create and initialize Time objects:
As an exercise, figure out the flow of execution through this program.
In main, the first time we invoke the new command, we provide no arguments, so Java invokes the first constructor. The next few lines assign values to each of the instance variables.
The second time we invoke the new command, we provide arguments that match the parameters of the second constructor. This way of initializing the instance variables is more concise (and slightly more efficient), but it can be harder to read, since it is not as clear which values are assigned to which instance variables.
The output of this program is:
When Java prints the value of a user-defined object type, it prints the name of the type and a special hexadecimal (base 16) code that is unique for each object. This code is not meaningful in itself; in fact, it can vary from machine to machine and even from run to run. But it can be useful for debugging, in case you want to keep track of individual objects.
In order to print objects in a way that is more meaningful to users (as opposed to programmers), you usually want to write a method called something like printTime:
The output of this method, if we pass either t1 or t2 as an argument, is 11:8:3.14159. Although this is recognizable as a time, it is not quite in the standard format. For example, if the number of minutes or seconds is less than 10, we expect a leading 0 as a place-keeper. Also, we might want to drop the decimal part of the seconds. In other words, we want something like 11:08:03.
In most languages, there are simple ways to control the output format for numbers. In Java there are no simple ways.
Java provides very powerful tools for printing formatted things like times and dates, and also for interpreting formatted input. Unfortunately, these tools are not very easy to use, so I am going to leave them out of this book. If you want, though, you can take a look at the documentation for the Date class in the java.util package.
Even though we can't print times in an optimal format, we can still write methods that manipulate Time objects. In the next few sections, I will demonstrate several of the possible interfaces for methods that operate on objects. For some operations, you will have a choice of several possible interfaces, so you should consider the pros and cons of each of these:
A method is considered a pure function if the result depends only on the arguments, and it has no side effects like modifying an argument or printing something. The only result of invoking a pure function is the return value.
One example is after, which compares two Times and returns a boolean that indicates whether the second operand comes after the first:
What is the result of this method if the two times are equal? Does that seem like the appropriate result for this method? If you were writing the documentation for this method, would you mention that case specifically?
A second example is addTime, which calculates the sum of two times. For example, if it is 9:14:30, and your breadmaker takes 3 hours and 35 minutes, you could use addTime to figure out when the bread will be done.
Here is a rough draft of this method that is not quite right:
Although this method returns a Time object, it is not a constructor. You should go back and compare the syntax of a method like this with the syntax of a constructor, because it is easy to get confused.
Here is an example of how to use this method:
The output of this program is 12:49:30.0, which is correct. On the other hand, there are cases where the result is not correct. Can you think of one?
The problem is that this method does not deal with cases where the number of seconds or minutes adds up to more than 60. In that case, we have to "carry" the extra seconds into the minutes column, or extra minutes into the hours column.
Here's a second, corrected version of this method.
Although it's correct, it's starting to get big. Later, I will suggest an alternate approach to this problem that will be much shorter.
This code demonstrates two operators we have not seen before, += and -=. These operators provide a concise way to increment and decrement variables. They are similar to ++ and --, except (1) they work on doubles as well as ints, and (2) the amount of the increment does not have to be 1. The statement sum.second -= 60.0; is equivalent to sum.second = sum.second - 60;
As an example of a modifier, consider increment, which adds a given number of seconds to a Time object. Again, a rough draft of this method looks like:
The first line performs the basic operation; the remainder deals with the same cases we saw before.
Is this method correct? What happens if the argument secs is much greater than 60? In that case, it is not enough to subtract 60 once; we have to keep doing it until second is below 60. We can do that by simply replacing the if statements with while statements:
This solution is correct, but not very efficient. Can you think of a solution that does not require iteration?
Occasionally you will see methods like addTime written with a different interface (different arguments and return values). Instead of creating a new object every time addTime is invoked, we could require the caller to provide an "empty" object where addTime should store the result. Compare the following with the previous version:
One advantage of this approach is that the caller has the option of reusing the same object repeatedly to perform a series of additions. This can be slightly more efficient, although it can be confusing enough to cause subtle errors. For the vast majority of programming, it is worth a spending a little run time to avoid a lot of debugging time.
Anything that can be done with modifiers and fill-in methods can also be done with pure functions. In fact, there are programming languages, called functional programming languages, that only allow pure functions. Many programmers believe that programs that use pure functions are faster to develop and less error-prone than programs that use modifiers. Nevertheless, there are times when modifiers are convenient, and some cases where functional programs are less efficient.
In general, I recommend that you write pure functions whenever it is reasonable to do so, and resort to modifiers only if there is a compelling advantage. This approach might be called a functional programming style.
In this chapter I have demonstrated an approach to program development I refer to as rapid prototyping with iterative improvement. In each case, I wrote a rough draft (or prototype) that performed the basic calculation, and then tested it on a few cases, correcting flaws as I found them.
Although this approach can be effective, it can lead to code that is unnecessarily complicated--since it deals with many special cases--and unreliable--since it is hard to convince yourself that you have found all the errors.
An alternative is high-level planning, in which a little insight into the problem can make the programming much easier. In this case the insight is that a Time is really a three-digit number in base 60! The second is the "ones column," the minute is the "60's column", and the hour is the "3600's column."
When we wrote addTime and increment, we were effectively doing addition in base 60, which is why we had to "carry" from one column to the next.
Thus an alternate approach to the whole problem is to convert Times into base 10 and take advantage of the fact that the computer already knows how to do arithmetic in base 10. Here is a method that converts a Time into a double:
Now all we need is a way to convert from a double to a Time object. We could write a method to do it, but it might make more sense to write it as a third constructor:
This constructor is a little different from the others, since it involves some calculation along with assignments to the instance variables.
You might have to think a bit to convince yourself that the technique I am using to convert from one base to another is correct. Assuming you are convinced, we can use these methods to rewrite addTime:
This is much shorter than the original version, and it is much easier to demonstrate that it is correct (assuming, as usual, that the methods it invokes are correct).
As an exercise, rewrite increment the same way. You might find that it is not quite as elegant as addTime.
In some ways converting from base 60 to base 10 and back is harder than just dealing with times. Base conversion is more abstract; our intuition for dealing with times is better.
But if we have the insight to treat times as base 60 numbers, and make the investment of writing the conversion methods (convertToSeconds and the third constructor), we get a program that is shorter, easier to read and debug, and more reliable.
It is also easier to add more features later. For example, imagine subtracting two Times to find the duration between them. The naive approach would be to implement subtraction complete with "borrowing." Using the conversion methods would be much easier.
Ironically, sometimes making a problem harder (more general) makes is easier (fewer special cases, fewer opportunities for error).
When you write a general solution for a class of problems, as opposed to a specific solution to a single problem, you have written an algorithm. I mentioned this word in Chapter 1, but did not define it carefully. It is not easy to define, so I will try a couple of approaches.
First, consider some of the algorithms you know. For example, when you learned to multiply single-digit numbers, you probably memorized the multiplication table. In effect, you memorized 100 specific solutions, so that knowledge is not really algorithmic.
But if you were "lazy," you probably cheated by learning a few algorithms. For example, to find the product of n and 9, you can write n-1 as the first digit and 10-n as the second digit. That's an algorithm!
Similarly, the techniques you learned for addition with carrying, subtraction with borrowing, and long division are all algorithms. One of the characteristics of an algorithm is that it does not require any intelligence to carry out. They are mechanical processes in which each step follows from the last according to a simple set of rules.
In my opinion, it is embarassing that humans spend so much time in school learning to execute algorithms that, quite literally, require no intelligence.
On the other hand, the process of designing algorithms is interesting, intellectually challenging, and a central part of what we call programming.
Some of the things that people do naturally, without difficulty or conscious thought, are the most difficult to express algorithmically. Understanding natural language is a good example. We all do it, but so far no one has been able to explain how we do it, at least not in the form of an algorithm.
Later in this class, you will have the opportunity to design simple algorithms for a variety of problems. If you take the next class is the Computer Science sequence, Data Structures, you will see some of the most interesting, clever, and useful algorithms computer science has produced.