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.
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.
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]:
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]:
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)
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]:
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]:
In [8]:
function increment(time::MyTime, seconds::Int)
int_to_time(time_to_int(time) + seconds)
end
Out[8]:
In [10]:
methods(increment)
Out[10]:
Here’s how you would invoke increment:
In [14]:
end_time = increment(start_time, 1337)
print_time(end_time)
In [12]:
function MyTime()
MyTime(0, 0, 0)
end
MyTime()
Out[12]:
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)
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.
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)
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.
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]:
And here is how you could use it:
In [14]:
duration = MyTime(1, 35, 0)
end_time = start_time + duration
Out[14]:
Changing the behavior of an operator so that it works with programmer-defined types is called operator overloading.
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]:
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)
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]:
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]:
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]:
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.
In [23]:
methods(print_time)
Out[23]:
To know which method is called, we can use the @which
macro:
In [22]:
@which show(IOBuffer(), MyTime(1,1,1))
Out[22]:
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.