Classes and Functions
At this point you know how to use functions to organize code and how to use built-in types to organize data. The next step is object-oriented programming, which uses programmer-defined types to organize both code and data.
Object-oriented programming is a big topic, so we will proceed gradually. In this chapter, we'll start with code that is not idiomatic -- that is, it is not the kind of code experienced programmers write -- but it is a good place to start. In the next two chapters, we will use additional features to write more idiomatic code.
Programmer-defined types
We have used many of Auto's built-in types -- now we will define a new type. As a first example, we'll create a type called Time that represents a time of day. A programmer-defined type is also called a class.
In Auto, a type definition specifies the fields an object contains:
type Time {
hour: int,
minute: int,
second: int,
}The header indicates that the new type is called Time. The body lists three fields: hour, minute, and second. Defining a type creates a class object.
The class object is like a factory for creating objects. To create a Time object, you call Time with field values:
let lunch = Time{hour: 11, minute: 59, second: 1}The result is a new object whose type is Time.
Creating a new object is called instantiation, and the object is an instance of the class.
Attributes
An object contains variables, which are called attributes. In Auto, attributes are defined as part of the type declaration and accessed using dot notation:
print(lunch.hour) // 11
print(lunch.minute) // 59
print(lunch.second) // 1You can use an attribute as part of any expression:
let total_minutes = lunch.hour * 60 + lunch.minute
print(total_minutes) // 719And you can use the dot operator in an f-string:
print(f"{lunch.hour:02d}:{lunch.minute:02d}:{lunch.second:02d}") // 11:59:01The format specifiers :02d indicate that minute and second should be displayed with at least two digits and a leading zero if needed.
We can write a function that displays a Time object:
fn print_time(time: Time) {
let s = f"{time.hour:02d}:{time.minute:02d}:{time.second:02d}"
print(s)
}When we call it, we can pass lunch as an argument:
print_time(lunch) // 11:59:01Objects as return values
Functions can return objects. For example, make_time takes parameters, stores them as fields in a Time object, and returns the new object:
fn make_time(hour: int, minute: int, second: int) -> Time {
return Time{hour: hour, minute: minute, second: second}
}Here's how we use make_time to create a Time object:
let time = make_time(11, 59, 1)
print_time(time) // 11:59:01How It Works
The type keyword defines a new struct-like type with named fields. In Auto, type Point { x: float, y: float } declares a Point type with two float fields. Objects are created using the Point{x: ..., y: ...} constructor syntax, and fields are accessed with dot notation (p.x, p.y).
In Python, the equivalent is a bare class Point: pass, with attributes assigned dynamically after instantiation. Auto's type declaration makes the structure explicit upfront.
Objects are mutable
Suppose you are going to a screening of a movie that starts at 9:20 PM and runs for 92 minutes (one hour 32 minutes). What time will the movie end?
First, we'll create a Time object that represents the start time:
let mut start = make_time(9, 20, 0)
print_time(start) // 09:20:00To find the end time, we can modify the fields of the Time object, adding the duration of the movie:
start.hour += 1
start.minute += 32
print_time(start) // 10:52:00We can encapsulate this in a function:
fn increment_time(time: Time, hours: int, minutes: int, seconds: int) {
time.hour += hours
time.minute += minutes
time.second += seconds
}let mut start = make_time(9, 20, 0)
increment_time(start, 1, 32, 0)
print_time(start) // 10:52:00Inside the function, time is an alias for start, so when time is modified, start changes.
This function works, but after it runs, we're left with a variable named start that refers to an object that represents the end time, and we no longer have an object that represents the start time. It would be better to leave start unchanged and make a new object to represent the end time.
Copying
In Python, the copy module provides a function that can duplicate any object. In Auto, you can create a new object with the same values:
let end = Time{hour: start.hour, minute: start.minute, second: start.second}Now start and end contain the same data, but they are different objects.
A shallow copy duplicates the object itself, but if it contains references to other objects, those references are shared. A deep copy duplicates the object and everything it refers to. In most cases with simple types like Time, a shallow copy is sufficient.
Objects as return values
Functions can return objects, which makes it easy to compose operations. For example, here's a function that creates a Rectangle type with a Point as one of its fields:
type Point {
x: float,
y: float,
}
type Rectangle {
corner: Point,
width: float,
height: float,
}A function like find_center can take a Rectangle and return a Point:
fn find_center(box: Rectangle) -> Point {
let cx = box.corner.x + box.width / 2.0
let cy = box.corner.y + box.height / 2.0
return Point{x: cx, y: cy}
}How It Works
Objects can contain other objects as fields. The Rectangle type has a corner field of type Point. Functions can return new objects -- find_center computes and returns a new Point, while grow_rectangle returns a new Rectangle with modified dimensions.
This pattern of creating and returning new objects (rather than modifying existing ones) is the basis of pure functions.
Pure functions
We can write pure functions that don't modify their parameters. A pure function does not modify any of the objects passed to it as arguments, and its only effect is to return a value.
For example, here's a function that converts a Time to an integer (seconds since midnight) and back:
fn time_to_int(time: Time) -> int {
let minutes = time.hour * 60 + time.minute
let seconds = minutes * 60 + time.second
return seconds
}
fn int_to_time(seconds: int) -> Time {
let (minute, second) = divmod(seconds, 60)
let (hour, minute) = divmod(minute, 60)
return make_time(hour, minute, second)
}Using these conversion functions, we can write a concise add_time:
fn add_time(time: Time, hours: int, minutes: int, seconds: int) -> Time {
let duration = make_time(hours, minutes, seconds)
let total_seconds = time_to_int(time) + time_to_int(duration)
return int_to_time(total_seconds)
}The first line converts the arguments to a Time object. The second line converts both to seconds and adds them. The third line converts the sum back to a Time and returns it.
let start = make_time(9, 40, 0)
let end = add_time(start, 1, 32, 0)
print_time(start) // 09:40:00 -- unchanged!
print_time(end) // 11:12:00Anything that can be done with impure functions (modifiers) can also be done with pure functions. Programs that use pure functions might be less error-prone, but impure functions are sometimes convenient and can be more efficient. In general, write pure functions whenever it is reasonable. This approach might be called a functional programming style.
How It Works
The modifier increment_time changes the Time object in place -- after calling it, the original variable now holds a different value. The pure function add_time creates and returns a new Time object, leaving the original unchanged.
The conversion functions time_to_int and int_to_time provide a clean way to perform arithmetic on time values by converting to an integer representation (seconds since midnight), doing the math, and converting back. This avoids the complexity of handling carry between hours, minutes, and seconds manually.
Prototype and patch
In the previous example, increment_time and add_time seem to work, but if we try another example, we'll see that they are not quite correct. Suppose the movie starts at 9:40, not 9:20:
let start = make_time(9, 40, 0)
let end = add_time(start, 1, 32, 0)
print_time(end) // 11:12:00 -- correct!But what if the duration is 92 minutes rather than 1 hour and 32 minutes?
let end = add_time(start, 0, 92, 0)Without proper handling, the result is not a valid time. The insight is to use divmod to handle carry correctly:
fn increment_time(time: Time, hours: int, minutes: int, seconds: int) {
time.hour += hours
time.minute += minutes
time.second += seconds
let (carry, time.second) = divmod(time.second, 60)
let (carry, time.minute) = divmod(time.minute + carry, 60)
let (_, time.hour) = divmod(time.hour + carry, 24)
}This section demonstrates a development plan called prototype and patch -- start with a simple prototype that works for the first example, test with more difficult examples, and fix errors as you find them. This approach can be effective, but incremental corrections can generate code that is unnecessarily complicated.
Design-first development
An alternative plan is design-first development, which involves more planning before prototyping. In a design-first process, a high-level insight into the problem can make the programming much easier.
In this case, the insight is that we can think of a Time object as a three-digit number in base 60 (sexagesimal). The second field is the "ones column", the minute field is the "sixties column", and the hour field is the "thirty-six hundreds column".
This observation suggests converting Time objects to integers and taking advantage of the fact that Auto knows how to do integer arithmetic. The functions time_to_int and int_to_time shown in the previous section implement this approach.
Ironically, sometimes making a problem harder -- or more general -- makes it easier, because there are fewer special cases and fewer opportunities for error.
Debugging
Auto provides several built-in functions that are useful for testing and debugging programs that work with objects. For example, you can check the type of an object:
print(type(start)) // TimeYou can also use isinstance to check whether an object is an instance of a particular type:
print(isinstance(end, Time)) // trueTo get all of the fields and their values, you can use vars:
print(vars(start)) // {hour: 9, minute: 40, second: 0}Glossary
object-oriented programming: A style of programming that uses objects to organize code and data.
class: A programmer-defined type. A class definition creates a new class object.
class object: An object that represents a class -- it is the result of a type definition.
instantiation: The process of creating an object that belongs to a class.
instance: An object that belongs to a class.
attribute: A variable associated with an object, also called an instance variable. In Auto, attributes are declared as fields in a type definition.
format specifier: In an f-string, a format specifier determines how a value is converted to a string.
pure function: A function that does not modify its parameters or have any effect other than returning a value.
functional programming style: A way of programming that uses pure functions whenever possible.
prototype and patch: A way of developing programs by starting with a rough draft and gradually adding features and fixing bugs.
design-first development: A way of developing programs with more careful planning than prototype and patch.
Exercises
Exercise
Write a function called subtract_time that takes two Time objects and returns the interval between them in seconds -- assuming that they are two times during the same day.
Exercise
Write a function called is_after that takes two Time objects and returns true if the first time is later in the day than the second, and false otherwise.
Exercise
Here's a definition for a Date type that represents a date -- that is, a year, month, and day of the month.
type Date {
year: int,
month: int,
day: int,
}Write a function called
make_datethat takesyear,month, anddayas parameters and returns a newDateobject. Create an object that represents June 22, 1933.Write a function called
print_datethat takes aDateobject, uses an f-string to format the fields, and prints the result. If you test it with theDateyou created, the result should be1933-06-22.Write a function called
is_afterthat takes twoDateobjects and returnstrueif the first comes after the second. Create a second object that represents September 17, 1933, and check whether it comes after the first object.
Hint: You might find it useful to write a function called date_to_tuple that returns a tuple containing the fields of a Date in year, month, day order.