I have been tinkering with Julia and I am now trying to design a moderate-sized project. However, I have been struggling to come up with a type hierarchy and I realized that, in Julia, I don’t know why you should subtype at all. I’m hoping someone can provide me design advice on when to make subtypes, or at least prompt a discussion over the use of subtypes in Julia code.
As far as I can tell, the subtype relationship is a poor fit for both standard interpretations of inheritance. The Gang of Four book distinguishes between two kinds of inheritance: class inheritance and interface inheritance. Class inheritance is when a subclass shares “the definitions of all the data and operations that the parent class defines.” Since Julia supertypes are abstract, there is no data to share, so Julia subtyping is not class inheritance. I have seen repeatedly in the Julia forums the advice to use composition in place of class inheritance, and there are several good reasons to do so even in languages with class inheritance.
The other kind of inheritance defined by the Gang of 4 book is interface inheritance. An interface of a type is everything that type can do, which in Julia means all the methods defined for a type. By this definition, interface inheritance would be when a subtype can run all the same methods, which Julia lets you do. But from my understanding of Julia, you should not use subtyping for interface inheritance for many reasons:
- All modern languages that I know of support multiple inheritance of interfaces, including C++, Python, Java, Haskell, and Rust. I realize the single inheritance is important for dispatch, which enables many other important features of Julia, but this is a large problem for interface inheritance.
- Julia is a dynamic language, so predefined interfaces are less important. A large part of the “magic” of Julia is that the code of two unrelated packages can usually work together, like the famous plotting of solutions to differential equations with uncertainties example. To achieve this code composability, Julia encourages us to define functions with as few defined types as possible, effectively making the function duck-typed based on what methods you call with that argument (see the first point in the style guide). This is how the iterator interface in Julia works, for example. As long as you implement the specified methods, it doesn’t matter what type something actually is.
- If you need a stronger interface contract than “implements a method,” such as bundling methods together or certain properties for performance, Julia has traits that suffer none of these problems and are more extensible - types can have multiple traits, traits can be implemented after the type declaration, and it still works well with multiple dispatch.
So if I can achieve interface inheritance through traits and duck typing and use composition over inheritance for code reuse, why should I bother to put any types in a hierarchy at all? Well, there is one other interpretation of subtyping in Julia: an enumeration of values. If you think of all subtypes of a common parent as values in an enum from a language like Java or C and method dispatch as a switch statement (or equivalently pattern matching in functional languages), then you can see that the behavior is nearly the same. However, there is one key difference: Julia’s setup is open to extension. Another programmer in another package can add a new value to the enumeration, which would be impossible in C because all enum values must be defined together. Likewise, adding new methods is the same as adding new case statements to a switch block in a different file, an impossible feat in other languages that manages to keep the program open to composition and extension by other programs.
This “subtype as enumeration” model is how Julia traits types work, and it’s quite a powerful system. But outside of traits, I don’t see many places where this kind of setup is useful. Furthermore, there is something strange here: in most cases, enumerations aren’t types at all. If all the “subtypes” of an enumeration are singleton types (that is, structs with no fields, which is what I have seen for all trait examples I have looked at), then they are values, not types. Just like a C enum, these values can be replaced with an integer assigned by the compiler corresponding to which value it is (in Julia, this is probably a pointer to the singleton instance of the type, but I am not certain on the internals). And if these enumerations are values, then the type of these values is the supertype. Something seems fundamentally strange about using the subtype operation to instantiate different values for a type, but that is exactly what this pattern does.
So in conclusion, I have found that Julia subtyping is not class inheritance, could be interpreted as interface inheritance (but there are better alternatives), and is great for extensible enumerations (where they are not really subtypes at all). So why do you use subtypes in Julia? And what advice to you have for designing a type hierarchy for a project? My Julia experience is limited so I welcome corrections on my points here.