In Julia you have the ability to write code that can operate on different types. This is called “generic programming.”
In this chapter I will discuss the use of type declarations in Julia, and I will introduce methods that offer ways to implement different behavior for a function depending on the types of their arguments. This is called “multiple dispatch.”
The ::
operator attaches type annotations to expressions and variables, indicating what types they should have:
julia>
(
1
+
2
)
::
Float64
ERROR: TypeError: in typeassert, expected Float64, got Int64
julia>
(
1
+
2
)
::
Int64
3
This helps to confirm that your program works the way you expect.
The ::
operator can also be appended to the lefthand side of an assignment, or included as part of a declaration:
julia>
function
returnfloat
()
x
::
Float64
=
100
x
end
returnfloat (generic function with 1 method)
julia>
x
=
returnfloat
()
100.0
julia>
typeof
(
x
)
Float64
The variable x
is always of type Float64
and the value is converted to a floating point if needed.
A type annotation can also be attached to the header of a function definition:
function
sinc
(
x
)
::
Float64
if
x
==
0
return
1
end
sin
(
x
)
/
(
x
)
end
The return value of sinc
is always converted to type Float64
.
The default behavior in Julia when types are omitted is to allow values to be of any type (Any
).
In “Time”, we defined a struct named MyTime
and you wrote a function named printtime
:
using
Printf
struct
MyTime
hour
::
Int64
minute
::
Int64
second
::
Int64
end
function
printtime
(
time
)
@printf
(
"
%02d
:
%02d
:
%02d
"
,
time
.
hour
,
time
.
minute
,
time
.
second
)
end
As you can see, type declarations can (and should, for performance reasons) be added to the fields in a struct definition.
To call this function, we have to pass a MyTime
object as an argument:
julia>
start
=
MyTime
(
9
,
45
,
0
)
MyTime(9, 45, 0)
julia>
printtime
(
start
)
09:45:00
To add a method to the function printtime
that only accepts a MyTime
object as an argument, all we have to do is append ::
followed by MyTime
to the argument time
in the function definition:
function
printtime
(
time
::
MyTime
)
@printf
(
"
%02d
:
%02d
:
%02d
"
,
time
.
hour
,
time
.
minute
,
time
.
second
)
end
A method is a function definition with a specific signature: printtime
has one argument of type MyTime
.
Calling the function printtime
with a MyTime
object yields the same result as before:
julia>
printtime
(
start
)
09:45:00
We can now redefine the first method without the ::
type annotation, allowing an argument of any type:
function
printtime
(
time
)
println
(
"I don't know how to print the argument time."
)
end
If you call the function printtime
with an object that isn’t a MyTime
object, you now get:
julia>
printtime
(
150
)
I don't know how to print the argument time.
Rewrite timetoint
and inttotime
(from “Prototyping Versus Planning”) to specify their arguments.
Here’s a version of increment
(from “Modifiers”) rewritten to specify its arguments:
function
increment
(
time
::
MyTime
,
seconds
::
Int64
)
seconds
+=
timetoint
(
time
)
inttotime
(
seconds
)
end
Note that now it is a pure function, not a modifier.
Here’s how you would invoke increment
:
julia>
start
=
MyTime
(
9
,
45
,
0
)
MyTime(9, 45, 0)
julia>
increment
(
start
,
1337
)
MyTime(10, 7, 17)
If you put the arguments in the wrong order, you get an error:
julia>
increment
(
1337
,
start
)
ERROR: MethodError: no method matching increment(::Int64, ::MyTime)
The signature of the method is increment(time::MyTime, seconds::Int64)
, not increment(seconds::Int64, time::MyTime)
.
Rewriting isafter
to act only on MyTime
objects is as easy:
function
isafter
(
t1
::
MyTime
,
t2
::
MyTime
)
(
t1
.
hour
,
t1
.
minute
,
t1
.
second
)
>
(
t2
.
hour
,
t2
.
minute
,
t2
.
second
)
end
By the way, optional arguments are implemented as syntax for multiple method definitions. For example, this definition:
function
f
(
a
=
1
,
b
=
2
)
a
+
2
b
end
translates to the following three methods:
f
(
a
,
b
)
=
a
+
2
b
f
(
a
)
=
f
(
a
,
2
)
f
()
=
f
(
1
,
2
)
These expressions are valid Julia method definitions. This is shorthand notation for defining functions/methods.
A constructor is a special function that is called to create an object. The default constructor methods of MyTime
, which take all fields as parameters, have the following signatures:
MyTime
(
hour
,
minute
,
second
)
MyTime
(
hour
::
Int64
,
minute
::
Int64
,
second
::
Int64
)
We can also add our own outer constructor methods:
function
MyTime
(
time
::
MyTime
)
MyTime
(
time
.
hour
,
time
.
minute
,
time
.
second
)
end
This method is called a copy constructor because the new MyTime
object is a copy of its argument.
To enforce invariants, we need inner constructor methods:
struct
MyTime
hour
::
Int64
minute
::
Int64
second
::
Int64
function
MyTime
(
hour
::
Int64
=
0
,
minute
::
Int64
=
0
,
second
::
Int64
=
0
)
@assert
(
0
≤
minute
<
60
,
"Minute is not between 0 and 60."
)
@assert
(
0
≤
second
<
60
,
"Second is not between 0 and 60."
)
new
(
hour
,
minute
,
second
)
end
end
The struct MyTime
now has four inner constructor methods:
MyTime
()
MyTime
(
hour
::
Int64
)
MyTime
(
hour
::
Int64
,
minute
::
Int64
)
MyTime
(
hour
::
Int64
,
minute
::
Int64
,
second
::
Int64
)
An inner constructor method is always defined inside the block of a type declaration, and it has access to a special function called new
that creates objects of the newly declared type.
The default constructor is not available if any inner constructor is defined. You have to write explicitly all the inner constructors you need.
A second method without arguments of the local function new
exists:
struct
MyTime
hour
::
Int
minute
::
Int
second
::
Int
function
MyTime
(
hour
::
Int64
=
0
,
minute
::
Int64
=
0
,
second
::
Int64
=
0
)
@assert
(
0
≤
minute
<
60
,
"Minute is between 0 and 60."
)
@assert
(
0
≤
second
<
60
,
"Second is between 0 and 60."
)
time
=
new
()
time
.
hour
=
hour
time
.
minute
=
minute
time
.
second
=
second
time
end
end
This allows us to construct recursive data structures—i.e., structs where one of the fields is the struct itself. In this case the struct has to be mutable because its fields are modified after instantiation.
show
is a special function that returns a string representation of an object. For example, here is a show
method for MyTime
objects:
using
Printf
function
Base
.
show
(
io
::
IO
,
time
::
MyTime
)
@printf
(
io
,
"
%02d
:
%02d
:
%02d
"
,
time
.
hour
,
time
.
minute
,
time
.
second
)
end
The prefix Base
is needed because we want to add a new method to the Base.show
function.
When you print an object, Julia invokes the show
function:
julia>
time
=
MyTime
(
9
,
45
)
09:45:00
When I write a new composite type, I almost always start by writing an outer constructor, which makes it easier to instantiate objects, and a show
method, which is useful for debugging.
Write an outer constructor method for the Point
class that takes x
and y
as optional parameters and assigns them to the corresponding fields.
By defining operator 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:
import
Base
.+
function
+
(
t1
::
MyTime
,
t2
::
MyTime
)
seconds
=
timetoint
(
t1
)
+
timetoint
(
t2
)
inttotime
(
seconds
)
end
The import
statement adds the +
operator to the local scope so that methods can be added.
And here is how you could use it:
julia>
start
=
MyTime
(
9
,
45
)
09:45:00
julia>
duration
=
MyTime
(
1
,
35
,
0
)
01:35:00
julia>
start
+
duration
11:20:00
When you apply the +
operator to MyTime
objects, Julia invokes the newly added method. When the REPL shows the result, Julia invokes show
. So, there is a lot happening behind the scenes!
Adding to the behavior of an operator so that it works with programmer-defined types is called operator overloading.
In the previous section we added two MyTime
objects, but you also might want to add an integer to a MyTime
object:
function
+
(
time
::
MyTime
,
seconds
::
Int64
)
increment
(
time
,
seconds
)
end
Here is an example that uses the +
operator with a MyTime
object and an integer:
julia>
start
=
MyTime
(
9
,
45
)
09:45:00
julia>
start
+
1337
10:07:17
Addition is a commutative operator, so we have to add another method:
function
+
(
seconds
::
Int64
,
time
::
MyTime
)
time
+
seconds
end
And we get the same result:
julia>
1337
+
start
10:07:17
The dispatch mechanism determines which method to execute when a function is called. 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. Using all of a function’s arguments to choose which method should be invoked is known as multiple dispatch.
Write +
methods for Point
objects:
If both operands are Point
objects, the method should return a new Point
object whose x
coordinate is the sum of the x
coordinates of the operands, and likewise for the y
coordinates.
If the first or the second operand is a tuple, the method should add the first element of the tuple to the x
coordinate and the second element to the y
coordinate, and return a new Point
object with the result.
Multiple 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. This is known as generic programming.
Many of the functions we wrote for strings also work for other sequence types. For example, in “Dictionaries as Collections of Counters” we used histogram
to count the number of times each letter appears in a word:
function
histogram
(
s
)
d
=
Dict
()
for
c
in
s
if
c
∉
keys
(
d
)
d
[
c
]
=
1
else
d
[
c
]
+=
1
end
end
d
end
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
:
julia>
t
=
(
"spam"
,
"egg"
,
"spam"
,
"spam"
,
"bacon"
,
"spam"
)
("spam", "egg", "spam", "spam", "bacon", "spam")
julia>
histogram
(
t
)
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 a +
method is provided for MyTime
objects, they work with sum
:
julia>
t1
=
MyTime
(
1
,
7
,
2
)
01:07:02
julia>
t2
=
MyTime
(
1
,
5
,
8
)
01:05:08
julia>
t3
=
MyTime
(
1
,
5
,
0
)
01:05:00
julia>
sum
((
t1
,
t2
,
t3
))
03:17:10
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.
One of the goals of multiple dispatch 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. This means that the methods having an argument annotated with a type should not depend on how the fields of that type are represented.
For example, in this chapter we developed a struct that represents a time of day. Methods having an argument annotated with this type include timetoint
, isafter
, and +
.
We could implement those methods in several ways. The details of the implementation depend on how we represent MyTime
. In this chapter, the fields of a MyTime
object are hour
, minute
, and second
.
As an alternative, we could replace these fields with a single integer representing the number of seconds since midnight. This implementation would make some functions, like isafter
, easier to write, but other functions 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.
Calling a function with the correct arguments can be difficult when more than one method for the function is specified. To help with this, Julia allows us to introspect the signatures of the methods of a function.
To know what methods are available for a given function, you can use the function methods
:
julia>
methods
(
printtime
)
# 2 methods for generic function "printtime":
[1] printtime(time::MyTime) in Main at REPL[3]:2
[2] printtime(time) in Main at REPL[4]:2
In this example, the function printtime
has two methods: one with a MyTime
argument and one with an Any
argument.
The operator ::
followed by a type, indicating that an expression or a variable is of that type.
The number and type of the arguments of a method, allowing the dispatch to select the most specific method of a function during the function call.
An inner constructor that is available when no programmer-defined inner constructors are provided.
A constructor defined outside the type definition to define convenience methods for creating an object.
An outer constructor method of a type with as its only argument an object of the type. It creates a new object that is a copy of the argument.
A constructor defined inside the type definition to enforce invariants or to construct self-referential objects.
Adding to the behavior of an operator like +
so it works with a programmer-defined type.
The choice of which method to execute when a function is executed.
Change the fields of MyTime
to be a single integer representing seconds since midnight. Then modify the methods defined in this chapter to work with the new implementation.
Write a definition for a type named Kangaroo
with a field named pouchcontents
of type Array
and the following methods:
A constructor that initializes pouchcontents
to an empty array
A method named putinpouch
that takes a Kangaroo
object and an object of any type and adds it to pouchcontents
A show
method that returns a string representation of the Kangaroo
object and the contents of the pouch
Test your code by creating two Kangaroo
objects, assigning them to variables named kanga
and roo
, and then adding roo
to the contents of kanga
’s pouch.