Type Hierarchies Explained

Introduction

Welcome back to Julia Types and Multiple Dispatch! You've successfully completed two foundational lessons and built a solid understanding of Julia's type system. In our previous lesson, we learned how to create custom types using the struct keyword, bundle related data together, and integrate our types seamlessly with Julia's type-checking tools. Now we're ready to explore one of Julia's most powerful features: Type Hierarchies.

Today's lesson focuses on organizing types in structured relationships, where some types can be more general than others. We'll discover how to create abstract types that serve as common ancestors, define concrete types that inherit from these abstractions, and leverage these relationships for flexible, reusable code. This hierarchical organization is what enables Julia's multiple dispatch system to be so elegant and powerful.

Building on the custom types we created in the previous lesson, we'll now learn how to establish parent-child relationships between types, creating organized families of related types that share common characteristics while maintaining their individual identities. This knowledge forms the crucial bridge toward understanding multiple dispatch, where functions can operate on entire families of related types rather than just individual concrete types.

Understanding Type Hierarchies

Type hierarchies organize types in parent-child relationships, creating structured families where more specific types inherit characteristics from more general ones. Think of this as a family tree for types: at the top level, we might have very general concepts like "Animal" or "Shape," and as we move down the hierarchy, we get more specific types like "Dog" and "Cat" under "Animal," or "Circle" and "Square" under "Shape."

In Julia, type hierarchies consist of two main kinds of types: abstract types and concrete types. Abstract types serve as organizational nodes in the hierarchy and define common interfaces, but they cannot be instantiated directly. Concrete types, which we learned to create with struct in the previous lesson, can be instantiated and represent actual data. The beauty of this system is that it allows us to write functions that work on entire families of related types, making our code both more general and more specific when needed.

This hierarchical organization becomes essential for multiple dispatch, where Julia can select the most appropriate method based not just on individual types, but on entire type relationships. Instead of writing separate functions for every possible type, we can write functions that operate on abstract types and automatically work with all their concrete subtypes.

Creating Abstract Types

Let's start building our first type hierarchy by creating an abstract type. Abstract types in Julia are defined using the abstract type keyword and serve as the foundation for organizing related concrete types:

# Creating an abstract base type
abstract type Animal end

The abstract type Animal end declaration creates a new abstract type called Animal. This type exists purely for organizational purposes and cannot be instantiated directly; you cannot create an instance of Animal itself. Instead, Animal serves as a common ancestor for more specific animal types that we'll define as concrete subtypes. Think of abstract types as category labels that help organize and relate more specific types in meaningful ways.

Creating Concrete Subtypes

Now we can create concrete types that inherit from our abstract Animal type using the subtype operator <:. This operator establishes the parent-child relationship in our type hierarchy:

# Creating concrete subtypes
struct Dog <: Animal
    name::String
    breed::String
end

struct Cat <: Animal
    name::String
    color::String
end

The syntax Dog <: Animal means "Dog is a subtype of Animal" or "Dog inherits from Animal." Both Dog and Cat are concrete types that can be instantiated, and each carries its own specific fields while sharing the common Animal ancestry. Notice that each subtype can have different fields: Dog has a breed field while Cat has a color field, reflecting their individual characteristics while maintaining their shared Animal nature.

Instantiating and Using Hierarchical Types

With our type hierarchy established, we can create instances of the concrete types just as we learned in the previous lesson. These instances automatically participate in the hierarchical relationship:

# Creating instances
println("=== Creating animals ===")
buddy = Dog("Buddy", "Golden Retriever")
whiskers = Cat("Whiskers", "Orange")

println("Buddy: ", buddy)
println("Whiskers: ", whiskers)

Creating instances of hierarchical types works identically to creating instances of simple types. The Dog and Cat constructors accept the appropriate arguments for their specific fields, and each instance knows about its place in the type hierarchy. When we print these instances, Julia displays them with their concrete type names, showing both the specific type and the data they contain.

=== Creating animals ===
Buddy: Dog("Buddy", "Golden Retriever")
Whiskers: Cat("Whiskers", "Orange")

Type Relationship Testing

One of the most powerful features of type hierarchies is the ability to test relationships between types using the subtype operator <:. This allows us to verify the hierarchical relationships we've established:

# Type relationships
println("\n=== Type relationships ===")
println("Is Dog a subtype of Animal? ", Dog <: Animal)
println("Is Cat a subtype of Animal? ", Cat <: Animal)
println("Is Animal a subtype of Dog? ", Animal <: Dog)

The <: operator tests whether the left type is a subtype of the right type. Since we defined both Dog and Cat as subtypes of Animal, the first two tests return true. However, the relationship is not symmetric: Animal is not a subtype of Dog because the parent type is more general than the child type. This directional relationship is crucial for understanding how hierarchies work and how multiple dispatch selects methods.

=== Type relationships ===
Is Dog a subtype of Animal? true
Is Cat a subtype of Animal? true
Is Animal a subtype of Dog? false

Type Checking with Abstract Types

The isa operator, which we learned in our first lesson, works beautifully with type hierarchies. Instances of concrete types are considered to be instances of all their parent abstract types:

# Type checking with abstract types
println("\n=== Type checking ===")
println("Is buddy an Animal? ", buddy isa Animal)
println("Is whiskers an Animal? ", whiskers isa Animal)
println("Type of buddy: ", typeof(buddy))
println("Type of whiskers: ", typeof(whiskers))

Even though buddy is specifically a Dog and whiskers is specifically a Cat, both instances are also considered to be Animal instances because of their place in the hierarchy. This polymorphic behavior allows us to treat different concrete types uniformly when we only care about their shared abstract characteristics. The typeof() function still returns the most specific concrete type, preserving the precise type information when needed.

=== Type checking ===
Is buddy an Animal? true
Is whiskers an Animal? true
Type of buddy: Dog
Type of whiskers: Cat

Working with Collections of Abstract Types

One of the most practical benefits of type hierarchies becomes apparent when working with collections. We can create arrays that hold different concrete types, as long as they share a common abstract parent:

# Working with collections of abstract types
println("\n=== Collections of animals ===")
animals = Animal[buddy, whiskers]
for animal in animals
    println("Animal: ", animal)
end

The array Animal[buddy, whiskers] can hold both Dog and Cat instances because both types are subtypes of Animal. This polymorphic collection allows us to process different concrete types uniformly, treating them all as Animal instances while preserving their individual identities. The iteration shows each instance with its specific concrete type and data, demonstrating that no information is lost in the abstraction.

=== Collections of animals ===
Animal: Dog("Buddy", "Golden Retriever")
Animal: Cat("Whiskers", "Orange")

Building Another Hierarchy

Let's reinforce these concepts by creating a second type hierarchy for geometric shapes. This example will show how the same hierarchical principles apply across different domains:

# Another simple hierarchy
println("\n=== Shape hierarchy ===")
abstract type Shape end

struct Circle <: Shape
    radius::Float64
end

struct Square <: Shape
    side::Float64
end

# Creating shapes
c1 = Circle(5.0)
s1 = Square(4.0)

println("Circle: ", c1)
println("Square: ", s1)

The Shape hierarchy follows the same pattern as our Animal hierarchy: an abstract parent type with concrete subtypes that have different field structures. Circle has a radius field while Square has a side field, yet both participate in the common Shape abstraction. This demonstrates how type hierarchies can organize conceptually related types with different data requirements under a unified interface.

=== Shape hierarchy ===
Circle: Circle(5.0)
Square: Square(4.0)

Multi-Level Type Checking

Our shape instances work with all the same type-checking operations as our animal instances, confirming that hierarchical behavior is consistent across different domains:

# Type checking shapes
println("\n=== Shape type checking ===")
println("Is c1 a Shape? ", c1 isa Shape)
println("Is s1 a Shape? ", s1 isa Shape)
println("Is Circle <: Shape? ", Circle <: Shape)

These type checks demonstrate the same polymorphic behavior with our Shape hierarchy. Individual instances are recognized as members of their abstract parent type, and the subtype relationships hold as expected. This consistency across different hierarchies shows that Julia's type system provides reliable, predictable behavior regardless of the specific domain you're modeling.

=== Shape type checking ===
Is c1 a Shape? true
Is s1 a Shape? true
Is Circle <: Shape? true

Conclusion and Next Steps

Excellent progress completing your third lesson in Julia Types and Multiple Dispatch! You've now mastered the art of creating type hierarchies, establishing parent-child relationships between abstract and concrete types, and leveraging these relationships for polymorphic programming. You understand how to use abstract types as organizational anchors, create concrete subtypes that inherit from abstractions, and work with collections that can hold different concrete types sharing common ancestry.

The hierarchical relationships you've learned to create form the foundation for Julia's multiple dispatch system, where functions can be written to operate on entire families of related types. Your concrete and abstract types can now participate in sophisticated method selection, allowing Julia to choose the most appropriate behavior based on the precise types involved. Get ready to put these concepts into practice in the upcoming exercises, where you'll design your own hierarchies, test complex type relationships, and build the intuition needed for mastering multiple dispatch in the advanced lessons ahead.

Sign up

Join the 1M+ learners on CodeSignal

Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal