Composition vs. OO: what's the best way to do this in Julia

Say I have 2 related types (previously seen in a 2016 thread with a similar topic):

struct Student
    age::Int
    name::AbstractString
    grade::Real
    function Student(a, n, g)
        0 < a < 120 || error("age must be in the range 0-120")
        0 < g < 5 || error("grade must be in the range 0-5")
        new(a, n, g)
    end
end

struct Employee
    age::Int
    name::AbstractString
    salary::Int
    function Employee(a, n, s)
        0 < a < 120 || error("age must be in the range 0-120")
        s > 20000 || error("salary is below minimum")
        new(a, n, s)
    end
end

(yes, this example is contrived)

These types share members and some functionality. Obviously, I would want to reuse both.

Is this the best way to re-write this code?

struct PersonData
    age::Int
    name::AbstractString
    function PersonData(a, n)
        0 < a < 120 || error("age must be in the range 0-120")
        new(a,n)
    end
end 

age(pd::PersonData) = pd.age
#...

struct Student
    pd::PersonData
    grade::Real
    function Student(a, n, g)
        0 < g < 5 || error("grade must be in the range 0-5")
        new(PersonData(a,n),g)
    end
end
age(s::Student) = age(s.pd)
#...

struct Employee
    pd::PersonData
    salary::Int
    function Employee(a, n, s)
        s > 20000 || error("salary is below minimum")
        new(PersonData(a, n), s)
    end
end
age(e::Employee) = age(e.pd)
#...
3 Likes

Looks good to me
One thing, don’t have fields with abstract types because that is quite bad for performance. You could either just use concrete types like instead of Real use Float64. Or use parametric types like described here Types · The Julia Language .

1 Like

I think @wsshin’s suggestion from the abstract types with fields issue is a nice alternative: effectively, it inverts the embedding order of the shared fields.

That’s a nice trick.
So, I can rewrite my code to:

abstract type PersonSpecs end

struct PersonData{S<:PersonSpecs}
    s::PersonSpecs
    age::Int
    name::String
end

struct StudentSpecs <: PersonSpecs
    grade::Real
end

struct EmployeeSpecs <: PersonSpecs
    salary::Int
end

const Student = PersonData{StudentSpecs}
const Employee = PersonData{EmployeeSpecs}

However, I’m missing the constructors.
I can try:

struct PersonData{S<:PersonSpecs}
    s::S
    age::Int
    name::String
    function PersonData(s, a, n)
        0 < a < 120 || error("age must be in the range 0-120")
        new{typeof(s)}(s, a, n)
    end
end

struct StudentSpecs <: PersonSpecs
    grade::Real
    function StudentSpecs( g)
        0 < g < 5 || error("grade must be in the range 0-5")
        new(g)
    end
end

struct EmployeeSpecs <: PersonSpecs
    salary::Int

    function Employee(a, n, s)
        s > 20000 || error("salary is below minimum")
        new(s)
    end
end

However if I now try:

s = Student(StudentSpecs(4.5), 19, "John")

I get:

MethodError: no method matching Student(::StudentSpecs, ::Int64, ::String)

(also, this is quite ugly, but I guess I can wrap this in a helper function, e.g. new_student)

What am I missing?

I think the standard way of defining the parametric constructor would look like:

    function PersonData{S}(s::S, a, n) where {S}
        0 < a < 120 || error("age must be in the range 0-120")
        new(s, a, n)
    end

With such a definition, the “ugly” construction works:

julia> Student(StudentSpecs(4.5), 19, "John")
Student(StudentSpecs(4.5), 19, "John")

And you can wrap this in another, prettier, constructor such as

# Needs to be specific enough to avoid method ambiguities
# (but this probably wouldn't be a problem if there were several fields in `StudentSpecs`)
Student(grade::Real, args...) = Student(StudentSpecs(grade), args...)

which yields:

julia> Student(4.5, 19, "John")
Student(StudentSpecs(4.5), 19, "John")
complete working example

abstract type PersonSpecs end

struct PersonData{S<:PersonSpecs}
s::S
age::Int
name::String
function PersonData{S}(s::S, a, n) where {S}
0 < a < 120 || error(“age must be in the range 0-120”)
new(s, a, n)
end
end

struct StudentSpecs <: PersonSpecs
grade::Real
function StudentSpecs(g)
0 < g < 5 || error(“grade must be in the range 0-5”)
new(g)
end
end

struct EmployeeSpecs <: PersonSpecs
salary::Int

function EmployeeSpecs(s)
    s > 20000 || error("salary is below minimum")
    new(s)
end

end

const Student = PersonData{StudentSpecs}
Student(grade::Real, args…) = Student(StudentSpecs(grade), args…)

const Employee = PersonData{EmployeeSpecs}
Employee(salary::Int, args…) = Employee(EmployeeSpecs(salary), args…)

Student(4.5, 19, “John”)
Employee(21_000, 19, “John”)
Employee(19_000, 19, “John”)

3 Likes