Skip to content

Commit 4bffe37

Browse files
authored
Merge pull request #25 from VirtualPlantLab/manuals
Add tutorial on multiple dispatch and composition in Julia.
2 parents bc879d2 + 80c575f commit 4bffe37

File tree

3 files changed

+361
-1
lines changed

3 files changed

+361
-1
lines changed

docs/make.jl

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ makedocs(;
2121
pages=[
2222
"Virtual Plant Laboratory" => "index.md",
2323
"Manual" => [
24-
"Julia basic concepts" => "manual/Julia.md",
24+
"Julia" => ["Julia basic concepts" => "manual/Julia/Julia.md",
25+
"Multiple dispatch and composition" => "manual/Julia/Objects.md"
26+
],
2527
"Dynamic graph creation and manipulation" => "manual/Graphs.md",
2628
"Geometry primitives" => "manual/Geometry/Primitives.md",
2729
"Turtle geometry and scenes" => "manual/Geometry/Turtle.md",
File renamed without changes.

docs/src/manual/Julia/Objects.md

Lines changed: 358 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,358 @@
1+
# [Multiple dispatch and composition](@id manual_objets)
2+
3+
In this document we will learn how types and methods relate in Julia.
4+
5+
## Types
6+
7+
A type in Julia is a structure that collects related data and can be assigned specific
8+
behavior (see below on *methods*). We create types using the `struct` keyword (with optional
9+
modifiers) and listing the data fields that the type will contain (and we recommend to
10+
include the type of data, for performance reasons).
11+
12+
For example, we can create types that represent leaves and fruits of a plant with some
13+
basic properties.
14+
15+
```julia
16+
struct Leaf
17+
length::Float64
18+
width::Float64
19+
weight::Float64
20+
color::String
21+
end
22+
23+
struct Fruit
24+
radius::Float64
25+
weight::Float64
26+
color::String
27+
end
28+
```
29+
30+
We can create instances of these types:
31+
32+
```julia
33+
L = Leaf(10.0, 5.0, 1.0, "green")
34+
F = Fruit(1.0, 0.5, 1.0, "red")
35+
```
36+
37+
All the data fields in a type are public by default, which means that we can access them
38+
directly.
39+
40+
```julia
41+
L.length
42+
F.color
43+
```
44+
45+
## Methods
46+
47+
Functions in Julia can be defined to operate on specific types of data if you annotate the
48+
type of arguments in the function definition. For example, we can define a function to
49+
calculate the surface area of a plant organ, but the formula to be used is different for
50+
leaves and fruits. We could define two different functions, one for each type (e.g.,
51+
`area_leaf` and `area_fruit`), but this would make the code more quite cumbersome and hard
52+
to manage. Instead, we can define a single function `area` that will
53+
behave differently depending on the type of the argument passed to it:
54+
55+
```julia
56+
# Area of a leaf assuming an ellipse
57+
function area(organ::Leaf)
58+
pi*organ.length*organ.width/4
59+
end
60+
61+
# Area of a fruit assuming a sphere
62+
function area(organ::Fruit)
63+
4*pi*organ.radius^2
64+
end
65+
```
66+
67+
We can now call the function `area` with either a leaf or a fruit and it will return the
68+
correct area:
69+
70+
```julia
71+
area(L)
72+
area(F)
73+
```
74+
75+
This is an example of *multiple dispatch*, which is a key feature of Julia. It allows us to
76+
define functions that can operate on different types of data and have different behavior
77+
depending on the type of the argument passed to it. The *dispatch* part means that the
78+
call to the function `area` will be dispatched to the correct method based on the type of the
79+
argument passed to it.
80+
81+
Dispatch will work on the combination of the types of all arguments, not just the
82+
first one. For example, let's define two types of pests that can affect a plant, a larva that
83+
can infest fruits and a caterpillar that can infest leaves. We want to test whether a particular
84+
pest can infest a particular organ of the plant. We can define the types and methods as
85+
follows:
86+
87+
```julia
88+
struct Larva
89+
end
90+
struct Caterpillar
91+
end
92+
# Method to test whether a larva can infest an organ
93+
infest(pest::Larva, organ::Fruit) = true
94+
infest(pest::Larva, organ::Leaf) = false
95+
# Method to test whether a caterpillar can infest an organ
96+
infest(pest::Caterpillar, organ::Fruit) = false
97+
infest(pest::Caterpillar, organ::Leaf) = true
98+
```
99+
100+
Note that we did not add any fields to the pest types, as for now we are only interested in
101+
whether they can infest a particular organ or not. Also, we are defining the methods using
102+
a simpler syntax (without the `function` and `end` keyword) as they are quite simple.
103+
104+
Note that you can also define methods where the arguments are not annotated with specific
105+
types. This will become a default method that will be called if the types of the arguments
106+
do not match any of the the other methods. For example, we can add a default method that
107+
returns `false` by default:
108+
109+
```julia
110+
infest(pest, organ) = false
111+
```
112+
113+
This means that if we call `infest` with a pest and an organ that are not
114+
`Larva` or `Caterpillar` and `Fruit` or `Leaf`, respectively, it will return `false`. Of
115+
course, we can add more specific methods later to handle other types of pests or organs.
116+
117+
Note that you can define methods for types that you did not create, not just for your
118+
own types, even if they are stored in packages you downloaded from the internet. This
119+
allows extending functionality of existing types and allows your own types to interact
120+
with types defined by someone else.
121+
122+
Also, you can sometimes define types and methods that are meant to use by some algorithm in
123+
a package, also extending the functionality of that package. For example, in VPL, you can
124+
define types that are meant to be used as nodes in graphs and this can be achieved by simply
125+
defining a couple of methods for specific functions defined in VPL (like the `feed!` method
126+
to generate geometry). The flexibility of multiple dispatch is one of the key features of
127+
Julia.
128+
129+
## Abstract types
130+
131+
Abstract types are an optional feature in Julia that allows implementing a reduced form of
132+
*inheritance* in Julia. The idea is that one can define a method for an abstract type and
133+
any type that inherits from that abstract type will match that method. Abstract types can
134+
also inherit from other abstract types, allowing to create a hierarchy of types.
135+
136+
For example, we could define an abstract type `Organ` from which all plants organs will
137+
inherit. Abstract types do not contain any data and we cannot create instances of them,
138+
they are really just tags for asigning methods. Inheritance is indicated with
139+
the symbol `<:` after the name of the type.
140+
141+
Let's create a new version of the `Leaf` and `Fruit` types that inherit from an `Organ`
142+
abstract type. Unfortunately, Julia does not allow redefining types (unless you put them
143+
in a module and import said module, we will do this in the VPL tutorials), so we will just
144+
call them `Leaf2` and `Fruit2` for the purpose of this example:
145+
146+
```julia
147+
abstract type Organ end
148+
149+
struct Leaf2 <: Organ
150+
length::Float64
151+
width::Float64
152+
weight::Float64
153+
color::String
154+
end
155+
156+
struct Fruit2 <: Organ
157+
radius::Float64
158+
weight::Float64
159+
color::String
160+
end
161+
```
162+
163+
We could now define a method that operates on any organ, regardless of its type,
164+
for example, to extract the color of the organ:
165+
166+
```julia
167+
get_color(organ::Organ) = organ.color
168+
```
169+
170+
And we can call it with any organ type that inherits from `Organ`:
171+
172+
```julia
173+
L2 = Leaf2(10.0, 5.0, 1.0, "green")
174+
F2 = Fruit2(1.0, 0.5, 1.0, "red")
175+
println("Leaf2 color: ", get_color(L2))
176+
println("Fruit2 color: ", get_color(F2))
177+
```
178+
179+
Note that if we now define a method of `get_color` for `Leaf2` or `Fruit2`, it will
180+
override the method for `Organ` and that one will be called instead. That is, the method
181+
defined for the abstract type will be called only if there is no more specific method
182+
defined for the concrete type. Abstract types and inheritance are not as important in Julia
183+
as in traditional object-oriented programming languages.
184+
185+
In the context of VPL, you will need to define some types to extend the functionality of the
186+
package and in those cases you will need to inherit from specific abstract types defined in
187+
VPL. For example, when defining your own type of data structures to be used as node in
188+
dynamic graphs (see tutorials for examples) those types will need to inherit from the
189+
`Node` abstract type defined in VPL. This allows the internal code for graph rewriting to
190+
handle objects defined by the user (which are obviously not known by the VPL developers
191+
ahead of time).
192+
193+
In addition, the user will have to define specific methods for their data structures that
194+
are expected by the VPL
195+
in order for their internal algorithms to work properly. This is known as an *interface*
196+
and it is a common practice in Julia programming. For example, in the most common version
197+
of FSP models built with VPL, the user will have to define data types that inherit from
198+
`Node` and define a method for the `feed!` function that generates the geometry associated
199+
to each type of node. If you omit the `feed!` method, you will still be able to use those
200+
types as nodes in the dynamic graphs but they will not generate any geometry (which in some
201+
cases it may be what you want).
202+
203+
## Composition
204+
205+
In the previous sections we have seen how to define types and methods in Julia, as well as
206+
how to use abstract types to create a hierarchy of types. These two approaches allow for
207+
reuse of methods for multiple types (that share the same abstract type) as well as extending
208+
code to work with new types. However, the methods that are being reused expected certain
209+
data to be expected in the type. For example, the `get_color` method above expects that the
210+
type has a `color` field, otherwise it will throw an error. There is therefore a need to
211+
reuse data as well, not just methods. This is where *composition* comes into play.
212+
213+
Composition is a design principle in which a complex object is composed of simpler objects.
214+
The idea is that the simpler objects implement a specific functionality with associated data
215+
such that we can add functionality to a type by composing it with other types. Let's define
216+
the functionality *growth* that will confer any organ the ability to grow. We will assume
217+
a logistic growth model, where current growth rate is proportional to the current weight. We
218+
thus need to keep track of the current weight, the maximum weight that the organ can attain
219+
and the relative growth rate. We can define a type that implements this functionality as follows:
220+
221+
```julia
222+
mutable struct Growth
223+
weight::Float64 # Current weight of the organ
224+
max_weight::Float64 # Maximum weight of the organ
225+
rgr::Float64 # Relative growth rate
226+
end
227+
```
228+
229+
Note that we added the `mutable` keyword to the type definition, which means that
230+
instances of this type can be modified after they are created. This is important because
231+
we will need to update the current weight of the organ as it grows. We can then define a
232+
method that will update the current weight of the organ based on the
233+
growth rate and the maximum weight:
234+
235+
```julia
236+
function grow!(growth::Growth)
237+
if growth.weight < growth.max_weight
238+
growth.weight += growth.rgr*growth.weight*(1 - growth.weight/growth.max_weight)
239+
end
240+
return nothing
241+
end
242+
```
243+
244+
Note that we modify the `weight` field of the `growth` instance in place, which is
245+
possible because we defined the type as `mutable`. The `grow!` function will not return
246+
anything, it will just update the `weight` field of the `growth` instance.
247+
248+
We can now define new types of leaves and fruits that will have the ability to grow by
249+
composing them with the `Growth` type. As explained before, we need to define new types
250+
because we did not put them in their own module and import it:
251+
252+
```julia
253+
struct Leaf3 <: Organ
254+
length::Float64
255+
width::Float64
256+
color::String
257+
growth::Growth # Composition with Growth type
258+
end
259+
struct Fruit3 <: Organ
260+
radius::Float64
261+
color::String
262+
growth::Growth # Composition with Growth type
263+
end
264+
```
265+
266+
Note how we have replaced the previous `weight` field with a `growth` field that
267+
contains an instance of the `Growth` type. We can now create instances of `Leaf3` and `Fruit3`
268+
and pass an instance of `Growth` to them:
269+
270+
```julia
271+
L3 = Leaf3(10.0, 5.0, "green", Growth(1.0, 0.1, 0.1))
272+
F3 = Fruit3(1.0, "red", Growth(1.0, 0.2, 0.1))
273+
```
274+
275+
Of course our types would need to be improved as their dimensions are now decoupled from the
276+
weight of the organ itself, but remember that this is just an example to illustrate features
277+
of the Julia language and in a real model we would later add changes to the types as needed
278+
(in fact, you are likely to develop models in this iterative, dynamic way, rather than
279+
figure everything out ahead of time). We can now call the `grow!` method on the
280+
`growth` field of the `Leaf3` and `Fruit3` instances to update their
281+
current weight:
282+
283+
```julia
284+
grow!(L3.growth)
285+
grow!(F3.growth)
286+
```
287+
288+
And the weight of the organs will be updated accordingly. We can access the current weight
289+
of the organs by accessing the `weight` field of the `growth` field:
290+
291+
```julia
292+
L3.growth.weight
293+
F3.growth.weight
294+
```
295+
296+
## Method forwarding
297+
298+
We have seen how to compose types in Julia to add functionality to them. However, this
299+
means that we need to access the methods of the composed type through the field name, which
300+
can be cumbersome. For example, we need to call `grow!(L3.growth)`
301+
to grow the leaf, which is not very intuitive. We can use *method forwarding* to
302+
make this more intuitive. Method forwarding is a technique that allows us to define methods
303+
that forward the call to the method of the composed type. For example, we can define a
304+
method for the `grow!` function that forwards the call to the `growth` field of the organ.
305+
We can define this method per organ type or, if we expect all organs to grow, we can define
306+
it for the abstract type `Organ` so that all organs will have the same behavior:
307+
308+
```julia
309+
function grow!(organ::Organ)
310+
grow!(organ.growth)
311+
end
312+
```
313+
314+
Now we can call `grow!(L3)` and `grow!(F3)` to grow the leaf and the fruit, respectively:
315+
316+
```julia
317+
grow!(L3)
318+
grow!(F3)
319+
```
320+
321+
Here we are using multiple dispatch and inheritance to use the `grow!` method on all organs
322+
while relying on type composition to implement the actual growth functionality. If you need
323+
to forward many methods you may want to use a macro to automate the process or use existing
324+
packages that already implement such macros (e.g., `MethodForwarding.jl` or `ForwardMethods.jl`).
325+
326+
At this point you may be wondering why we did not just define the `grow!` method
327+
directly in the `Leaf3` and `Fruit3` types. The reason is modularity. By separating the
328+
data structures and methods according to functionality, we can encapsulate the relevant code
329+
and make it easier to use, without necessarily having to know all the details. This approach
330+
is currently being used in the VPLverse package `Ecophys.jl`, where data structures for, for
331+
example, photosynthesis are defined with associated methods. Thus, adding the ability to
332+
photosynthesize to a plant organ is as simply as adding one of the relevant data types from
333+
`Ecophys.jl` and calling the relevant method.
334+
335+
## About object-oriented programming
336+
337+
If one searches online whether Julia implements object-oriented programming (OOP), the results
338+
will be mixed as it depends entirely on how does one define OOP.
339+
If the definition matches what is understood by OOP in languages
340+
such as C++, Java or Python (i.e. *classic* OOP), then the answer is simply no. The reason for this is that:
341+
342+
- Julia only allows inheritance from abstract types. This means that concrete types in
343+
Julia can inherit methods from their parent types but not data.
344+
- Data types in Julia encapsulate data but not methods (i.e., objects in Julia do not own
345+
methods).
346+
347+
If one defines OOP as a paradigm that requires encapsulation of data
348+
(but not necessarily methods) and inheritance of methods (but not necessarily data) then
349+
the approach used in Julia and described above would qualify as OOP.
350+
That is, the answer to whether Julia implements OOP depends entirely
351+
on how one defines the paradigm and there is simply no right or wrong way of defining concepts.
352+
353+
If you are transitioning form a language with *classic* OOP you will need
354+
to rethink how to organize your code if you want to stick to a *Julian* way of programming.
355+
Essentially you should replace inheritance of data with object composition (plus optionally
356+
method forwarding) and use multiple method dispatch for functionality (what in *classic* OOP
357+
would be *interfaces*). Searching online for *composition over inheritance* may help with
358+
the transition (e.g., https://en.wikipedia.org/wiki/Composition_over_inheritance).

0 commit comments

Comments
 (0)