Classes and objects II |
Objects are the basis of object-oriented programming. In Scala, every piece of data is an object. The type of the object determines what you can do with the object. Classes can contain data (the state of the object) and methods (what you can do with the object). You can think about a class as a blueprint for objects. Once you define a class, you can create objects from the blueprint using the keyword new.
Consider an object as an atomic unit. Clients (that's the term for program code that uses the object) do not care about the implementation of the object, they only use the exposed methods and fields. "Exposed" means here that they are made available by the class for use by clients.
A date consists of three attributes: year, month, and day, all of them integers. We could thus store a date as a triple (Int, Int, Int), or using a simple case class with three attributes.
Both methods, however, do not guarantee that our date objects will be consistent. Consistency means that the attributes take on only legal, meaningful values, and that the values of each attribute are consistent with each other. For instance, a day value of 31 is consistent with a month value of 3, but not with a month value of 4. A day value of 29 and a month value of 2 are consistent only if the year value indicates a leap year.
Here is a Date class that ensures that its state is always consistent (days1.scala):
val monthLength = Array(31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31) class Date(val year: Int, val month: Int, val day: Int) { require(1901 <= year && year <= 2099) require(1 <= month && month <= 12) require(1 <= day && day <= monthLength(month - 1)) require(month != 2 || day <= 28 || (year % 4) == 0) }The four require statements inside the class body are executed every time an object of type Date is constructed. If the values are okay, nothing happens. Otherwise require throws an exception, and we know immediately that something is wrong. Since the Date object is immutable, the consistent state that is guaranteed when the object is constructed can never be broken and made inconsistent.
It is helpful if objects guarantee that their state is consistent. It makes it possible for functions working with the objects to proceed without error checking, and simplifies debugging since we will notice quickly when something went wrong.
Here are some examples for using Date objects:
scala> var d1 = new Date(2012, 4, 16) d1: Date = Date@1f6c439 scala> println(d1.year, d1.month, d1.day) (2012,4,16) scala> var d2 = new Date(2012, 2, 30) IllegalArgumentException: requirement failed
In the past, we had functions that took arguments of a particular type, such as a function date2num that converted a date, represented as year, month, and day, into a day index starting on January 1, 1901.
In object-oriented programming, we prefer to define functions that work on a specific type as a method of that type. One advantage is that it clearly documents the available functions for a given type (for instance, to find out what you can do with a String, you would look at the list of methods of the String class). The main advantage, however, will be the possibility of hiding or protecting information inside the object, as we will see later.
For the moment, let us add methods to convert a Date object into a day index, and to return the day of the week of a Date (days3.scala):
val monthLength = Array(31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31) val weekday = Array("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday") class Date(val year: Int, val month: Int, val day: Int) { require(1901 <= year && year <= 2099) require(1 <= month && month <= 12) require(1 <= day && day <= monthLength(month - 1)) require(month != 2 || day <= 28 || (year % 4) == 0) // returns the number of days since 1901/01/01 (day 0) def dayIndex: Int = { val fourY = (365 + 365 + 365 + 366) val yearn = year - 1901 var total = 0 total += fourY * (yearn / 4) total += 365 * (yearn % 4) for (m <- 0 until month - 1) total += monthLength(m) total += day - 1 if (year%4 != 0 && month > 2) total -= 1 total } def dayOfWeek: String = weekday((dayIndex + 1) % 7) }A method looks like a function definition, but is placed inside the body of the class. The body of the method can refer to the names of the object's fields: Note the use of the fields year, month, and day in the method dayIndex.
A method can only be called if we have a reference to an object of that type available, and so whenever a method is executed, there is always a "current" object. The field names refer to the fields of this "current" object:
scala> var d1 = new Date(2012, 4, 16) d1: Date = Date@1c97c3e scala> var d2 = new Date(2000, 1, 1) d2: Date = Date@165de14 scala> d1.year res0: Int = 2012 scala> d2.month res1: Int = 1 scala> d1.dayIndex res2: Int = 40648 scala> d2.dayIndex res3: Int = 36159 scala> d1.dayOfWeek res4: String = Monday scala> d2.dayOfWeek res5: String = Saturday
The two methods we wrote here do not take any arguments, not even empty parentheses, and so calling them looks exactly like accessing a field of the object. Scala allows this "uniform access" to fields and methods—this is impossible in Java or C++. This can be a big advantage when you are designing a new class: You can switch between implementing an attribute as a field or as a method without changing the client code.
Every object in Scala (or Java) can be converted to a string, and that is what happens when you look at an object in the interactive mode, or using println: the object is first converted to a string using its toString method:
scala> d1 res5: Date = Date@14096e6 scala> val s = d1.toString s: String = Date@14096e6By default, the result of this conversion is not pretty: It contains the name of the class and a hexadecimal number (a number in base 16) that identifies the object on the heap.
We can change this by overriding the default definition of the toString method, like this (days4.scala):
val monthLength = Array(31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31) val weekday = Array("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday") val monthname = Array("January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December") class Date(val year: Int, val month: Int, val day: Int) { require(1901 <= year && year <= 2099) require(1 <= month && month <= 12) require(1 <= day && day <= monthLength(month - 1)) require(month != 2 || day <= 28 || (year % 4) == 0) // returns the number of days since 1901/01/01 (day 0) def dayIndex: Int = { val fourY = (365 + 365 + 365 + 366) val yearn = year - 1901 var total = 0 total += fourY * (yearn / 4) total += 365 * (yearn % 4) for (m <- 0 until month - 1) total += monthLength(m) total += day - 1 if (year%4 != 0 && month > 2) total -= 1 total } def dayOfWeek: String = weekday((dayIndex + 1) % 7) override def toString: String = "%s, %s %d, %d".format(dayOfWeek, monthname(month-1), day, year) }Note the keyword override. It is necessary because every Scala object already has a toString method. So we are not adding a new method, we are overriding the previous definition.
With this method, our object looks much prettier:
scala> var d1 = new Date(2012, 4, 16) d1: Date = Monday, April 16, 2012 scala> val s = d1.toString s: String = Monday, April 16, 2012 scala> println(d1) Monday, April 16, 2012
Let's add a method to compute the number of days between two dates. Since we already have dayIndex, this is very easy to implement (days5.scala):
def diff(rhs: Date) = dayIndex - rhs.dayIndex
And it works well:
scala> var d1 = new Date(1993, 7, 9) d1: Date = Friday, July 9, 1993 scala> var d2 = new Date(2012, 4, 9) d2: Date = Monday, April 9, 2012 scala> d2.diff(d1) res0: Int = 6849
But wouldn't it be nice if we could write d2 - d1 for the difference between two dates instead of the not-so-pretty d2.diff(d1)?
In Scala, unlike in Java, this is possible. In fact, in Scala, there is no difference at all between operators like + and methods like take—they are all implemented as methods.
Any method that has exactly one argument can be called using operator syntax. For instance, the Array[Int] class has a method take(n) to take the first n elements, and a method :+(el) to add an element at the end. (Both methods return a new array, of course, since arrays cannot change their length.)
Here are examples that show that we can call methods either in method syntax or in operator syntax:
scala> val A = Array(1, 2, 3, 4, 5) A: Array[Int] = Array(1, 2, 3, 4, 5) scala> A.take(3) res0: Array[Int] = Array(1, 2, 3) scala> A take 3 res1: Array[Int] = Array(1, 2, 3) scala> A :+ 9 res2: Array[Int] = Array(1, 2, 3, 4, 5, 9) scala> A.:+(9) res3: Array[Int] = Array(1, 2, 3, 4, 5, 9)
So we can simply rename our Date method diff to -, and we have difference operator that returns the difference between two dates as a number of days (days6.scala):
class Date(...) { // ... def -(rhs: Date) = dayIndex - rhs.dayIndex }We can now calculate with dates:
scala> val birth = new Date(1993, 7, 9) birth: Date = Friday, July 9, 1993 scala> val today = new Date(2012, 4, 16) today: Date = Monday, April 16, 2012 scala> today - birth res0: Int = 6856
It can be fun to define your own operators, but don't go overboard—it is only useful when it makes the code more readable!
Scala, like Java and C++ but unlike C, allows the overloading of method names: It is allowed to have different methods that have the same name, and only differ in the type of arguments they accept. Here is a simple example:
def f(n: Int) { println("Int " + n) } def f(s: String) { println("String " + s) } f(17) f("CS109")The compiler correctly determines that f(17) is a call to the first function, while f("CS109") is a call to the second function. (This example will not work if you type it into the interactive mode—you have to run it as a script!)
We can make use of overloading to add more operators to our Date class. We will allow adding or subtracting a number of days to a date to obtain a new date (days7.scala):
val monthLength = Array(31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31) val weekday = Array("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday") val monthname = Array("January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December") class Date(val year: Int, val month: Int, val day: Int) { require(1901 <= year && year <= 2099) require(1 <= month && month <= 12) require(1 <= day && day <= monthLength(month - 1)) require(month != 2 || day <= 28 || (year % 4) == 0) // returns the number of days since 1901/01/01 (day 0) def dayIndex: Int = { val fourY = (365 + 365 + 365 + 366) val yearn = year - 1901 var total = 0 total += fourY * (yearn / 4) total += 365 * (yearn % 4) for (m <- 0 until month - 1) total += monthLength(m) total += day - 1 if (year % 4 != 0 && month > 2) total -= 1 total } def dayOfWeek: String = weekday((dayIndex + 1) % 7) override def toString: String = "%s, %s %d, %d".format(dayOfWeek, monthname(month-1), day, year) def num2date(n: Int): Date = { val fourY = (365 + 365 + 365 + 366) var year = 1901 + (n / fourY) * 4 var day = n % fourY if (day >= 365 + 365 + 365 + 59) { year += 3 day -= 365 + 365 + 365 } else { year += (day / 365) day = day % 365 if (day >= 59) day += 1 } var month = 1 while (day >= monthLength(month-1)) { day -= monthLength(month-1) month += 1 } new Date(year, month, day+1) } def -(rhs: Date) = dayIndex - rhs.dayIndex def +(n: Int): Date = num2date(dayIndex + n) def -(n: Int): Date = num2date(dayIndex - n) }Note that there are two - operators defined for the Date class. The compiler correctly selects the one we need depending on the type of the right-hand side:
scala> val birth = new Date(1992, 8, 21) birth: Date = Friday, August 21, 1992 scala> val baekil = birth + 100 baekil: Date = Sunday, November 29, 1992 scala> val today = new Date(2012, 4, 16) today: Date = Monday, April 16, 2012 scala> today - birth res0: Int = 7178 scala> today - 7178 res1: Date = Friday, August 21, 1992 scala> birth + 7178 res2: Date = Monday, April 16, 2012
In a previous chapter we have learnt about case classes. In this chapter we have started to talk about normal classes. So what's the difference?
The short answer is: Case classes are for small objects that have no hidden state (see the next chapter for some examples of big objects with hidden state).
The long answer is that case classes are different from normal classes in the following ways:
case class Point(x: Int, y: Int) defined class Point scala> val p1 = Point(7, 3) p1: Point = Point(7,3) scala> val p2 = new Point(9, 5) p2: Point = Point(9,5)As you can see, it is allowed to use new, but it is not necessary.
scala> println(p1) Point(7,3)If we want a pretty toString method for normal classes, we need to override the toString method and define it ourselves.
scala> class Test(val x: Int) defined class Test scala> val x1 = new Test(7) x1: Test = Test@36edcdeb scala> val x2 = new Test(7) x2: Test = Test@190728ba scala> x1 == x2 res6: Boolean = false scala> case class CTest(val x: Int) defined class CTest scala> val c1 = CTest(7) c1: CTest = CTest(7) scala> val c2 = CTest(7) c2: CTest = CTest(7) scala> c1 == c2 res7: Boolean = trueIf you want to redefine the meaning of == and != for normal classes, you have to define an equals method.
Classes and objects II |