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

I feel this conversation is already a bit too specific for me to contribute, and I don’t want to be the “uhm akshually :nerd_face:” guy, but I’d still like to point out that structural typing and traits are not the same thing. A structural type system is one where types themselves don’t have names. This is almost never what you want for a language with traits. In structurally typed languages interfaces are usually done at the module level, not the type level.

AFAIK the idea that tacit interfaces are structural typing comes from Go, but that’s still nominal typing because you’re defining functions on top of named types, same as the examples in this thread (and, well… Go isn’t exactly known for its academic rigor).

4 Likes

I understand your sentiment, but I disagree with the statement. A structural type system based on traits does not inherently disallow anything by default - at least, no more so than a nominal type system. In both cases, the type system needs to be thoughtfully designed, and a structural type system can be just as flexible as the current type system in Julia, to the point where you can “always add an Int to a Float.” But at the same time it is more flexible, because you can prohibit locally to add Int to a Float.

1 Like

I am not very proficient in type theory, but I couldn’t find a better way to describe it than structural typing with traits. I guess it is somewhat similar to type classes, which I believe are considered nominal. However, not having an explicit declaration to implement the trait makes it structural. So, I’m not entirely sure how to name it properly.

In my defence Philip Wadler called it structural type system as well https://youtu.be/Dq0WFigax_c?t=1224

Thanks for the link. I was actually thinking about the paper Wadler mentions, since that’s also mentioned in the section on structural typing in TAPL.

I think the important point then (and my confusion with this thread) is about the other item: closed vs open supertypes. In that case, then yeah. Searching for “interfaces” rather than traits here on discourse will return plenty of discussions on the topic. Tho I think most discussions don’t get very far precisely because we don’t have the proper vocabulary or share the same context to talk about these systems.

1 Like

I think for this you’d need to convert the whole promote machinery to traits as well. I am not sure how that would look like but I think you would kindof weaken the Field trait to allow addition if one of the arguments can be promoted to the same field type. I actually think that this would work okayish without too much ambiguities because promotion is hierarchical. The promotion rules would then be wrapped into a new trait, but I am not quite sure how that would look like. Maybe @serhii could think about it and sketch the solution to resolve that concern.
EDIT: actually one doesn’t need to touch the promotion machinery and could just make a default method like
(+)(a,b) = +(promote(a,b)...)

I would like to raise a totally different concern though: In your traits you always specified function signatures including a fixed return type. That seems to restrictive to me. Sometimes the return type of a function cannot be determined statically or may not be unique. Yes type-instabilities are bad :tm: but I see it as a huge strength of Julia that it allows “suboptimal” code if performance is not critical. So imo you should leave out the return type in the interface definition if you want broad applicability and it might even be impossible in general to use the return type annotations.

(Sorry for residual typos - I am on mobile)

Apart from that it is a fun thought experiment. Maybe one could think about the implementation of some cases from linear algebra to check for advantages. E.g. the interplay of Matrix, SparseMatrixCSC and I might be enough as a reality check. The next level would be inclusion of some wrappers like Transpose or Symmetric (which actually is the point where the current system reached its limits sometimes - until a missing overload was added)

2 Likes

But my point is that this is a bad thing to do. It’s bad enough for + where it could cause you a 50% loss in performance, but it becomes even worse when you consider stuff like multiplication where making something like a sparse matrix dense would be catastrophic for a matmul.

So, a few things here

  1. I only did the return type checking because that was what the OP described in his hypothetical trait system (i.e. something is a Pet if it has a method for name that returns String) so I just implemented what he wanted. Not necessarily endorsing such a design.
  2. If you use a return tume annotation, you can still get a fully inferrable return type even if the body of the function is completely type unstable. For example:
julia> function name(x::Dog)::String
           @eval "something" * "uninferrable"
       end;

julia> code_warntype(name, (Dog,))
MethodInstance for name(::Dog)
  from name(x::Dog) @ Main REPL[35]:1
Arguments
  #self#::Core.Const(Main.name)
  x::Dog
Locals
  @_3::Any
Body::String
1 ─ %1  = Main.String::Core.Const(String)
│   %2  = $(Expr(:copyast, :($(QuoteNode(:("something" * "uninferrable"))))))::Expr
│   %3  = Core.eval(Main, %2)::Any
│         (@_3 = %3)
│   %5  = @_3::Any
│   %6  = (%5 isa %1)::Bool
└──       goto #3 if not %6
2 ─       goto #4
3 ─ %9  = @_3::Any
│   %10 = Base.convert(%1, %9)::String
└──       (@_3 = Core.typeassert(%10, %1))
4 ┄ %12 = @_3::String
└──       return %12

Notice the intermediate Anys but that the it knows the final return must be a String due to the assertion. But yes, as you said, it’s generally not good design to base APIs off asking the compiler for the return type.

  1. You can always just overload is_pet or whatever to bypass the hasmethod and Core.Compiler.return_type checks, e.g.
is_pet(d::Dog) = true
1 Like

I think for most of the concepts in Base including the abstract type Number, one could devise a neat structure such that computation remains efficient (e.g., there would be no need to promote an identity matrix to a dense matrix). For instance, if you want to add Float32 to Float64, there is a corresponding subfield concept between the two. Essentially, you can add the two numbers whenever needed, but you have to indicate your intent via the trait that declares one concrete type as a subfield of another.

trait Field begin
    +(a::Self, b::Self)::Self
    ...
    one(::Type{Self})::Self
end

trait SubField{F} <: Field where F <: Field begin
    +(a::F, b::Self)::F
    +(a::Self, b::F)::F
    -(a::F, b::Self)::F
    -(a::Self, b::F)::F
    *(a::F, b::Self)::F
    *(a::Self, b::F)::F
    /(a::F, b::Self)::F
    /(a::Self, b::F)::F
end

funcion f(a::Field, b::SubField{F}) where F <: Field
    (a + b) * (a - b)
end

f(Float32(1.0), Float32(2.0)) # this will work because Float32 is a field and a subfield of itself
f(Float64(1.0), Float32(2)) # this will work because Float32 is a subfield of Float64
f(Float32(1.0), Float64(2)) # this will not work because Float64 is not a subfield of Float32 even though there are methods that can be used to perfrom operations on Float32 and Float64

I think in that case it was more about the actual definition of the field; it has to return elements from the same set (concrete type). I am uncertain about returning completely unrelated concrete types from functions. If the return type depends on the actual argument values I believe one still should be able to capture the return type with another trait, something like this:

trait MyTrait(F) where F
    do_something(a::F)::<:SomeOtherTrait
end

Indeed, it would be an interesting thought experiment. When I have more time, I will investigate it.

2 Likes

FWIW, DuckDispatch.jl is my stab at structural typing and dispatch. Figuring out if one set of methods is a subset of another is actually not trivial because you might have something like

trait T1
    getindex(::T1, ::Any)
end

trait T2
    getindex(::T2, ::Int)
end

To decide if T2 is a subset of T1, we have to look at all the arguments of all the methods and check for subtyping. It’s pretty easy in this case, but gets very complicated if you add parameterized types and vararg and such. Additionally, I don’t even support marking a method signature with other traits. If you did that, things will get extremely complicated because now you’re recursively checking trait subsets :sweat_smile:

1 Like

It is an awesome package. In fact, experimenting with it sparked my entire interest into whether Julia-like language might even need a nominal type system at all.

1 Like

This is a pretty interesting discussion! One thing I want to mention is that while seasoned programmers may like a purely trait based approach, my feeling is that scientists will not. Most scientists I work with use something like Python because of its perceived simplicity. I’ve had issues explaining some of Julia’s more CS concepts, e.g. types and type stability to my colleagues for years. I’ve also spoken to some people who have expressed to me that Julia seems to be only for people who like programming and its concepts.

Most scientists I work with don’t want to think about CS ideas like traits, static guarantees, compilation etc. They want to implement their ideas quickly get their results and move on. My guess is that forcing the entire language to adopt a traits based system would not have improved Julia’s useability for one of its major target audiences.

That being said, a trait system that you can opt into makes a ton of sense to me. There are definitely times where I want to enforce some strict implementation. Having some support for this would be fantastic! I believe there are currently several approaches in the works and I’m excited to see where Julia lands on this in the near future.

10 Likes