Why did Julia choose nominal typing over structural typing/traits?

serhii
Mon, 11 Nov 2024 22:30:41 +0000
Internals & Design

I’ve been thinking about Julia’s type system design, particularly how it uses nominal typing rather than structural typing or a trait-based system. I’m curious about the historical and technical reasons behind this choice, and what challenges might arise if Julia had gone with a more trait-based approach.

To illustrate my question, here’s a classic example in current Julia using nominal typing:

abstract type Pet end

struct Dog <: Pet
    name :: String
end

struct Cat <: Pet
    name :: String
end

function encounter(a::Pet, b::Pet)
    verb = meets(a,b)
    println("$(a.name) meets $(b.name) and $verb")
end

meets(a::Dog, b::Dog) = "sniffs"
meets(a::Dog, b::Cat) = "chases"
meets(a::Cat, b::Dog) = "hisses"
meets(a::Cat, b::Cat) = "slinks"

fido     = Dog("Fido")
rex      = Dog("Rex")
whiskers = Cat("Whiskers")
spots    = Cat("Spots")

encounter(fido, rex)
encounter(fido, whiskers)
encounter(whiskers, rex)
encounter(whiskers, spots)

I’m wondering how this might look in a hypothetical trait-based version of Julia. Here’s what I envision:

trait Pet begin
    name(a::Self)::String
end
# Self refers to the type implementing the trait

trait Meetable{T} begin
    meets(a::Self, b::T)::String
end

# trait for pets that can meet each other, <: means that MeetablePet requires all the functions defined in Meetable and Pet traits
trait MeetablePet{T} <: Meetable{T} + Pet where T<:Pet

struct Dog
    name::String
end
name(d::Dog) = d.name

struct Cat
    name::String
end
name(c::Cat) = c.name

meets(a::Dog, b::Dog) = "sniffs"
meets(a::Dog, b::Cat) = "chases"
meets(a::Cat, b::Dog) = "hisses"
meets(a::Cat, b::Cat) = "slinks"

# this type constraints guarantee that a and b are pets that can meet each other
function encounter(a::MeetablePet{T}, b::T) where T <: Pet
    verb = meets(a, b)
    println("$(name(a)) meets $(name(b)) and $verb")
end

fido     = Dog("Fido")
rex      = Dog("Rex")
whiskers = Cat("Whiskers")
spots    = Cat("Spots")

encounter(fido, rex)
encounter(fido, whiskers)
encounter(whiskers, rex)
encounter(whiskers, spots)

This trait-based approach is very expressive and replaces the need for abstract types entirely. Here’s a more complex example showing how it might work with mathematical concepts:

trait Field begin
    +(a::Self, b::Self)::Self
    *(a::Self, b::Self)::Self
    -(a::Self)::Self
    /(a::Self, b::Self)::Self
    inv(a::Self)::Self
    zero(::Type{Self})::Self
    one(::Type{Self})::Self
end

trait VectorSpace{F <: Field} begin
    +(a::Self, b::Self)::Self
    *(f::F, v::Self)::Self
    -(v::Self)::Self
    zero(::Type{Self})::Self
end

trait InnerProductSpace{F} <: VectorSpace{F} where F <: Field begin
    dot(a::Self, b::Self)::F
end

# Example implementation for Float32
primitive type Float32 32 end

+(a::Float32, b::Float32) = Base.add_float(a, b)
-(a::Float32) = Base.neg_float(a)
*(a::Float32, b::Float32) = Base.mul_float(a, b)
inv(a::Float32) = Base.inv_float(a)
zero(::Type{Float32}) = Float32(0.0)
one(::Type{Float32}) = Float32(1.0)
# Float32 is a field, also it is a vector space over itself, if we define dot it will be an inner product space as well
dot(a::Float32, b::Float32) = a * b

# Example implementation for Vec2
struct Vec2{F} where F <: Field
    x::F
    y::F
end

+(a::Vec2{F}, b::Vec2{F}) where F <: Field = Vec2(a.x + b.x, a.y + b.y)
*(f::F, v::Vec2{F}) where F <: Field = Vec2(f*v.x, f*v.y)
*(v::Vec2{F}, f::F) where F <: Field = f*v
-(v::Vec2{F}) where F <: Field = Vec2(-v.x, -v.y)
zero(::Type{Vec2{F}}) where F <: Field = Vec2(zero(F), zero(F))
# Vec2 is a vector space over F, if we define dot it will be an inner product space as well
dot(a::Vec2{F}, b::Vec2{F}) where F <: Field = a.x*b.x + a.y*b.y

This would allow for very generic code as in the current version of julia but it is much more clear what the concrete type has to do to be acceptable by the function. For example, in the current version of julia it is impossible to understand what is expected of the nominal abstract type Number.

function do_computation(v1::V1, v2::V2)::F where {V1 <: InnerProductSpace{F}, V2 <: InnerProductSpace{F}, F <: Field}
    return v2 * (dot(v1, v1) / dot(v2, v2))
end

# Works with different types implementing the same traits
do_computation(Float32(2.43), Float32(1.24))
do_computation(
    Vec2(Float32(2.43), Float32(1.24)),
    Vec2(Float32(1.0), Float32(2.0))
)

Questions:

1. What were the main technical challenges that led Julia to choose nominal typing over structural typing/traits?

2. Would a trait-based system like the one shown above introduce performance penalties compared to the current system?

3. Would multiple dispatch work differently with traits vs. the current system?

30 posts - 6 participants