Lecture 18. Methods


Type Declarations

The :: operator can be used to attach type annotations to expressions and variables in programs. There are two primary reasons to do this:

  • As an assertion to help confirm that your program works the way you expect,

  • To provide extra type information to the compiler, which can then improve performance in some cases

When appended to an expression computing a value, the :: operator is read as "is an instance of". It can be used anywhere to assert that the value of the expression on the left is an instance of the type on the right.

Multiple dispatch

A function is an object that maps a tuple of arguments to a return value, or throws an exception if no appropriate value can be returned. It is common for the same conceptual function or operation to be implemented quite differently for different types of arguments: adding two integers is very different from adding two floating-point numbers, both of which are distinct from adding an integer to a floating-point number. Despite their implementation differences, these operations all fall under the general concept of "addition". Accordingly, in Julia, these behaviors all belong to a single object: the + function.

To facilitate using many different implementations of the same concept smoothly, functions need not be defined all at once, but can rather be defined piecewise by providing specific behaviors for certain combinations of argument types and counts. A definition of one possible behavior for a function is called a method. Thus far, we have presented only examples of functions defined with a single method, applicable to all types of arguments. However, the signatures of method definitions can be annotated to indicate the types of arguments in addition to their number, and more than a single method definition may be provided. When a function is applied to a particular tuple of arguments, the most specific method applicable to those arguments is applied.

The choice of which method to execute when a function is applied is called dispatch. Julia allows the dispatch process to choose which of a function's methods to call based on the number of arguments given, and on the types of all of the function's arguments. Julia uses all of a function's arguments to choose which method should be invoked, this is known as multiple dispatch.

Printing objects

In previous lecture, we defined a composite type named MyTime and we wrote a function named print_time:


In [1]:
mutable struct MyTime
    hour :: Int
    minute :: Int
    second :: Int
end
function print_time(time)
    @printf("%02d:%02d:%02d", time.hour, time.minute, time.second)
end


Out[1]:
print_time (generic function with 1 method)

To make print_time a method, all we have to do is annotate the argument time:


In [2]:
function print_time(time::MyTime)
    @printf("%02d:%02d:%02d", time.hour, time.minute, time.second)
end


Out[2]:
print_time (generic function with 2 methods)

Now the function print_time has 2 methods:

  • print_time(time)

  • print_time(time::MyTime)

To call the last method, you have to pass a MyTime object as an argument:


In [5]:
start_time = MyTime(9, 45, 0)
print_time(start_time)


09:45:00

Write time_to_int and int_to_time as methods:


In [12]:
function time_to_int(time::MyTime)
    minutes = time.hour * 60 + time.minute
    seconds = minutes * 60 + time.second
end


Out[12]:
time_to_int (generic function with 1 method)

In [13]:
function int_to_time(seconds::Int)
    minutes, second = divrem(seconds, 60)
    hour, minute = divrem(minutes, 60)
    MyTime(hour, minute, second)
end


Out[13]:
int_to_time (generic function with 1 method)

Another example

Here’s a version of increment rewritten as a method:


In [8]:
function increment(time::MyTime, seconds::Int)
    int_to_time(time_to_int(time) + seconds)
end


Out[8]:
increment (generic function with 1 method)

In [10]:
methods(increment)


Out[10]:
2 methods for generic function print_time:
  • print_time(time::MyTime) at In[2]:2
  • print_time(time) at In[1]:7

Here’s how you would invoke increment:


In [14]:
end_time = increment(start_time, 1337)
print_time(end_time)


10:07:17

Constructors

A constructor method is a special method that is called to create an object:


In [12]:
function MyTime()
    MyTime(0, 0, 0)
end
MyTime()


Out[12]:
MyTime(0, 0, 0)

In [15]:
function MyTime(time::MyTime)
    MyTime(time.hour, time.minute, time.second)
end
new_time = MyTime(start_time)
println(new_time  start_time)
println(new_time)


false
MyTime(9, 45, 0)

We have defined two outer constructors, one when no arguments are given, and the copy constructor having as argument a MyTime object. Both use the default inner constructor having as arguments hour, minute and second, the fields of MyTime.

While outer constructor methods succeed in addressing the problem of providing additional convenience methods for constructing objects, they fail to address the enforcing of invariants, and the construction of self-referential objects. For these problems, we needs inner constructor methods:


In [2]:
mutable struct MyTime
    hour :: Int
    minute :: Int
    second :: Int
    function MyTime(hour::Int, minute::Int, second::Int)
        @assert(0  minute < 60, "Minute is between 0 and 60.")
        @assert(0  second < 60, "Second is between 0 and 60.")
        new(hour, minute, second)
    end
end

An inner constructor method is much like an outer constructor method, with two differences:

  • It is declared inside the block of a type declaration.

  • It has access to a special locally existent function called new that creates objects of the block's type.

If any inner constructor method is defined, no default constructor method is provided: it is presumed that you have supplied yourself with all the inner constructors you need. The default constructor is equivalent to writing your own inner constructor method that takes all of the object's fields as parameters (constrained to be of the correct type, if the corresponding field has a type), and passes them to new, returning the resulting object:

mutable struct MyTime
    hour :: Int
    minute :: Int
    second :: Int
    function MyTime(hour::Int, minute::Int, second::Int)
        new(hour, minute, second)
    end
end

A second method of the local function new exist:

mutable struct MyTime
    hour :: Int
    minute :: Int
    second :: Int
    function MyTime(hour::Int, minute::Int, second::Int)
        time = new()
        time.hour = hour
        time.minute = minute
        time.second = second
        time
    end
end

This allows to construct incompletely initialized objects and self-referential objects, or more generally, recursive data structures.

The Base.show method

Base.show is a special function that is supposed to return a string representation of an object. For example, here is a Base.show method for Time objects:


In [3]:
function Base.show(io::IO, time::MyTime)
    @printf(io, "%02d:%02d:%02d", time.hour, time.minute, time.second)
end

When you print an object, Julia invokes the Base.show function:


In [9]:
start_time = MyTime(11, 59, 1)
println(start_time)


11:59:01

When I write a new composite type, I almost always start by writing an inner constructor, which makes it easier to instantiate objects, and Base.show, which is useful for debugging.

Operator overloading

By defining other special methods, you can specify the behavior of operators on programmer-defined types. For example, if you define a method named + with two MyTime arguments, you can use the + operator on MyTime objects.

Here is what the definition might look like:


In [10]:
import Base.+

function +(t1::MyTime, t2::MyTime)
    seconds = time_to_int(t1) + time_to_int(t2)
    int_to_time(seconds)
end


Out[10]:
+ (generic function with 181 methods)

And here is how you could use it:


In [14]:
duration = MyTime(1, 35, 0)
end_time = start_time + duration


Out[14]:
13:34:01

Changing the behavior of an operator so that it works with programmer-defined types is called operator overloading.

Type-based dispatch

In the previous section we added two MyTime objects, but you also might want to add an integer to a MyTime object:


In [17]:
function +(t::MyTime, seconds::Int)
    int_to_time(time_to_int(t) + seconds)
end
function +(seconds::Int, t::MyTime)
    t + seconds
end


Out[17]:
+ (generic function with 183 methods)

Here are examples that use the + operator with different types:


In [18]:
seconds = time_to_int(duration)
println(start_time + seconds)
println(seconds + start_time)


11:20:00
11:20:00

Polymorphism

Type-based dispatch is useful when it is necessary, but (fortunately) it is not always necessary. Often you can avoid it by writing functions that work correctly for arguments with different types.

Many of the functions we wrote for strings also work for other sequence types. For example, we used histogram to count the number of times each letter appears in a word.


In [15]:
function histogram(s)
    d = Dict()
    for c in s
        if c  keys(d)
            d[c] = 1
        else
            d[c] += 1
        end
    end
    d
end


Out[15]:
histogram (generic function with 1 method)

This function also works for lists, tuples, and even dictionaries, as long as the elements of s are hashable, so they can be used as keys in d:


In [16]:
t = ("spam", "egg", "spam", "spam", "bacon", "spam")
histogram(t)


Out[16]:
Dict{Any,Any} with 3 entries:
  "bacon" => 1
  "spam"  => 4
  "egg"   => 1

Functions that work with several types are called polymorphic. Polymorphism can facilitate code reuse.

For example, the built-in function sum, which adds the elements of a sequence, works as long as the elements of the sequence support addition.

Since MyTime objects provide a + method, they work with sum:


In [17]:
t1 = MyTime(1, 7, 2)
t2 = MyTime(1, 50, 0)
t3 = MyTime(1, 40, 0)
total = sum((t1, t2, t3))


Out[17]:
04:37:02

In general, if all of the operations inside a function work with a given type, the function works with that type.

The best kind of polymorphism is the unintentional kind, where you discover that a function you already wrote can be applied to a type you never planned for.

Debugging

To know what methods are available, we can use the function methods:


In [23]:
methods(print_time)


Out[23]:
2 methods for generic function print_time:
  • print_time(time::MyTime) at In[2]:2
  • print_time(time) at In[1]:7

To know which method is called, we can use the @which macro:


In [22]:
@which show(IOBuffer(), MyTime(1,1,1))


Out[22]:
show(io::IO, time::MyTime) at In[3]:2

Interface and implementation

One of the goals of good software design is to make software more maintainable, which means that you can keep the program working when other parts of the system change, and modify the program to meet new requirements. A design principle that helps achieve that goal is to keep interfaces separate from implementations. For objects, that means that the methods a class provides should not depend on how the attributes are represented.

For example, in this chapter we developed a struct that represents a time of day. Methods provided for this type include time_to_int, is_after, and add_time.

We could implement those methods in several ways. The details of the implementation depend on how we represent time. In this chapter, the attributes of a MyTime object are hour, minute, and second.

As an alternative, we could replace these attributes with a single integer representing the number of seconds since midnight. This implementation would make some methods, like is_after, easier to write, but it makes other methods harder.

After you deploy a new type, you might discover a better implementation. If other parts of the program are using your type, it might be time-consuming and error-prone to change the interface.

But if you designed the interface carefully, you can change the implementation without changing the interface, which means that other parts of the program don’t have to change.