I am a relatively newbie and I am wondering what is the most appropriate Julian way to reuse the code.
I don’t want to trigger discussions on topics already discussed a million times. This post simply aims to lay down a clear list of rules to be followed to write good Julia code, and pose a couple of questions.
Let’s start with an example: I have a type Person
mutable struct Person <: AbstractPerson
name::String
age::Int
end
and a lot (hundreds) of methods dealing with it. Now I want to create a new type, say Citizen
, which extend or specialize Person
. Julia do not supports inheritance by design, hence we pursue code reuse by means of composition.
There are three viable approaches:
1. Encapsulate a Person
struct within a Citizen
one.
mutable struct Citizen
person::Person
nationality::String
end
Although straightforward, this approach implies that I have to redefine all the (undreds) methods working on Person
, e.g.: display(p::Citizen) = display(p.person)
.
2. Very similar to 1, but we assume Citizen <: Abstractperson
mutable struct Citizen <: AbstractPerson
person::Person
nationality::String
end
This implies that all methods accepting a AbstractPerson
may receive Citizen
as input and may not work appropriately since the underlying data structure is completely different. As in approach 1, this means that all the Person
methods should be overloaded;
3. Extend Person
by composition:
mutable struct Citizen <: AbstractPerson
name::String
age::Int
nationality::String
end
Although this involves a duplication of the struct field names and types, the methods accepting a AbstractPerson
object will work seamlessly.
QUESTION 1:
In order to reuse the code in the Person
methods as much as possible, approach 3 seems the best way. Is this correct?
In the following I will outline a complete implementation of both the Person
and Citizen
objects, in order to settle down all the minor details. The only “flaw” of the code is that it may requires the data structure to be copied back and forth (see the comment on the super
method).
#---------------------------------------------------------------------
# BASE TYPE : Person
# Use abstract type for the interface name, by convention prepend
# `Abstract` to the type name.
abstract type AbstractPerson end
# TYPE MEMBERS
mutable struct Person <: AbstractPerson
name::String
age::Int
end
# CONSTRUCTOR
function Person(name)
return Person(name, 0)
end
# TYPE METHODS: always use `AbstractPerson` as input type...
import Base.display
function display(p::AbstractPerson)
println("Person: ", p.name, " (age: ", p.age, ")")
end
function happybirthday(p::AbstractPerson)
p.age += 1
println(p.name, " is now ", p.age, " year(s) old")
end
function call(p::AbstractPerson)
print_with_color(:red, uppercase(p.name), "!")
end
# ...except in cases where the return type is a `Person` object: here
# we must use `Person` as input.
function son(p::Person)
return Person(p.name * " junior")
end
# EXAMPLES
p = Person("Giorgio")
happybirthday(p)
call(p)
call(son(p))
#---------------------------------------------------------------------
# DERIVED TYPE : Citizen
# Use abstract type for the interface name, by convention prepend
# `Abstract` to the type name.
abstract type AbstractCitizen <: AbstractPerson end # NOTE: this inherits from AbstractPerson
# TYPE MEMBERS (composition of `Person` fields and new ones)
mutable struct Citizen <: AbstractCitizen
name::String # this must be the same as Person
age::Int # this must be the same as Person
nationality::String # new field (not present in Person)
end
# Julia do not supports inheritance, hence `Person` and `Citizen`
# objects are completely unrelated entities and we need a function to
# obtain the `Person` object encapsulated in the `Citizen` one. Note
# that this function introduces an overhead due to a copy.
super(p::Citizen) = Person(p.name, p.age)
# CONSTRUCTOR
function Citizen(name, nationality)
p = Person(name) # Create parent object
return Citizen(p.name, p.age, nationality)
end
# TYPE METHODS: always use `AbstractCitizen` as input type...
# Note that we are not overloading `display` and `happybirthday`: they
# work well for both `Person` and `Citizen` objects.
# Overload of the `call` method. Although optional this new method
# allows us to specialize the behaviour on nationality.
function call(p::AbstractCitizen)
if p.nationality == "Italian"
print_with_color(:red, uppercase(p.name), " dove sei ?")
elseif p.nationality == "UK"
print_with_color(:red, uppercase(p.name), " where are you ?")
else
call(super(p))
end
end
# Method returning a `Citizen` object must always be overloaded and
# input type (if any) must be a `Citizen`, not an `AbstractCitizen`.
function son(p::Citizen)
return Citizen(p.name * " junior", p.nationality)
end
# EXAMPLES
p = Citizen("Giorgio", "Italian")
happybirthday(p)
call(p)
call(son(p))
p = Citizen("Kim", "UK")
happybirthday(p)
call(p)
call(son(p))
p = Citizen("Jean", "French")
happybirthday(p)
call(p)
call(son(p))
QUESTION 2:
In order to reuse the code in the Person
methods (and possibly in the Citizen
methods) as much as possible, the rules outlined above are the best solution. Is this correct?