avatarEmma Boudreau

Summary

The article discusses the extension of Julia's functionality by dispatching new methods for the Person type, focusing on seven key methods such as getindex, setindex!, vect, display, show, iterate, and +.

Abstract

The Julia programming language is distinguished by its use of multiple dispatch as a core paradigm, allowing developers to extend the language's capabilities by adding new methods to existing functions. The article delves into the process of extending functions for a custom Person type, demonstrating how to implement and dispatch seven important methods from Julia's Base library. These methods include getindex for indexing, setindex! for mutating elements, vect for creating vectors, display and show for output formatting, iterate for iteration, and the + operator for custom operations. By extending these methods, the article illustrates how to create more natural and extensible functionality within Julia, showcasing the language's unparalleled extensibility and the elegance of multiple dispatch.

Opinions

  • The author expresses that Julia's extensibility is unmatched by other programming languages, largely due to its multiple dispatch feature.
  • The naturalness of extending functions in Julia is highlighted as a significant advantage, making the language more intuitive for developers.
  • The use of multiple dispatch is praised for its ability to create clean and elegant code extensions without altering the core language.
  • The article suggests that extending methods in Julia can lead to the discovery of lesser-known but useful Base methods, such as Base.vect.
  • The author's subjective view is that the default output of constructed types in Julia is aesthetically unpleasing, advocating for the customization of display and show methods for improved readability

7 Important Julia Methods To Extend

Integrate your new project into Julian syntax by dispatching these vital methods.

image by FeeLoona on pixabay

The Julia language is a very interesting one, not just for its speed and application, but also in paradigm. Julia uses multiple dispatch as a paradigm, bringing the relatively rudimentary implementations of the concept in other languages full circle and applying it to everything. One of the coolest things in that regard is the ability to add new methods to a given function using multiple dispatch. This helps phenomenally to make the language far more extensible than most other programming languages. I recently discussed this topic in detail in another article, which if you are interested in you may read here:

The best part is, this is not even the only technique one can use to extend Julia. Additionally, extending functions feels very natural because it is very much based on the things that one might typically do in Julia. Extending functions is as simple as writing functions, and that is pretty awesome.

When it comes to extending methods, there are many in Julia’s Base that are frequently extended in order to provide new functionality. Today I wanted to cover some common functions to extend for your type, and how to write new methods for that type to create some awesome functionality.

Before we get into extending methods for our type, let us quickly create a type to be used with the examples. For this, I am making a simple Person constructor with a few different fields:

mutable struct Person
    name::String
    age::Int64
    class::Symbol
end

We also want to make sure we directly import all of the Base fields we want to extend:

import Base: getindex, setindex!, display, show, vect, iterate, +, (:)
import Base: push!

If you would like to dive into this code and reference it for yourself, here is a link to a notebook with all of the code for this article inside of it:

№1: getindex

The first, and likely most obvious, method that you might want to extend is the getindex method. This method comes directly from Base and is called each time that we use [] on a given type to access elements. Calling this method with a Vector and an Integer as an argument, for example, will yield the element contained at the index of our Integer .

julia> getindex([5, 10], 2)
10

This is equivalent to

julia> [5, 10][2]
10

We could dispatch this to Person, however indexing for person really could not do much as it is not really a collection. However, we can also make an appropriate dispatch for multiple Persons inside of a Vector by dispatching Vector{Person} .

typeof([Person("emmy", 22, :history)])
Vector{Person} (alias for Array{Person, 1})

Let us say that we wanted to be able to index by name. We would add this functionality by dispatching getindex to our Vector{Person} type with a String, like so:

getindex(v::Vector{Person}, name::String) = v[findall(person -> person.name == name, v)[1]]

In order to get this information, I use the findall method from Base. The method of this function takes a function and a vector as positional arguments. The return is a Vector of Integers, which contains each index where the function returned true. In this case, the function returns true if the the Person’s name is equal to the provided name. Finally, we index the Vector{Person} by the first element of the Vector{Int64}, returning the value stored at the first found person.

people = [Person("emmy", 22, :history), Person("john", 25, :math)]
people["emmy"]
Person("emmy", 22, :history)

Because this is all done with multiple dispatch, we could also add more indexing rules for different types, such as the Symbol that represents our class:

function getindex(v::Vector{Person}, class::Symbol)
    pos::Vector{Int64} = findall(person -> person.class == class, v)
    [v[p] for p in pos]::Vector{Person}
end
people[:history]
1-element Vector{Person}:
 Person("emmy", 22, :history)

№2: setindex!

The accompaniment to the getindex function is the setindex! function. Given that this function has an exclamation point at the end, we know that this function mutates our type. Of course, this makes a lot of sense for setting an index as we are probably mutating elements using this method. For this, I am going to make it so that we can change a person’s class by indexing a name and setting it to a Symbol. One thing I find odd is that the value we are setting is the center argument, not the rightmost — just thought this was an interesting note to make, as I have written it wrong many times due to the way you think about it in your head. We can recycle our getindex binding from before in order to make this function incredibly simple:

setindex!(v::Vector{Person}, class::Symbol, name::String) = v[name].class = class

Setting a name index to a Symbol will now yield a mutation to that Person:

people["emmy"] = :math
people[:math]
2-element Vector{Person}:
 Person("emmy", 22, :math)
 Person("john", 25, :math)
people[:history]
Person[]

№3: vect

Another cool method to dispatch to is the vect() method. Dispatching this method will change what happens whenever we vectorize our type and create a Vector{Person}(). In this case, just as an example, we want all of our names to be unique. We can easily create this by making a check to see if each name is unique whenever Base.vect is called, and then promptly returning a Vector as the method is meant to do.

function vect(p::Person ...)
    names::Vector{String} = [person.name for person in p]
    if length(Set(names))!= length(names) throw(ArgumentError("names not unique")) end
    Vector{Person}([person for person in p])::Vector{Person}
end

I added this functionality by checking if a Set of the names’ length is equal to the length of the names themselves. A Set is just a list of each unique value inside of a given Vector, so if the names are not the same length then there must be a duplicate name. If we provide the names correctly, we get exactly what is intended from before:

people = [Person(“emmy”, 22, :history), Person(“john”, 25, :math)]
2-element Vector{Person}:
 Person("emmy", 22, :history)
 Person("john", 25, :math)

However, providing emmy twice now throws an ArgumentError:

people = [Person("emmy", 22, :history), Person("emmy", 25, :math)]
ArgumentError: names not unique

№4: display (and show)

The default showing of most constructed types will yield all of the different fields along with the type name printed in their outer constructor form. This is some pretty ugly output, in my subjective view. Fortunately, we can change this sort of output by binding our type to display. We can also bind our display to different mimes in order to make the way we show our type change depending on the circumstances. I actually wrote an entire article, which will go more into detail on display; as well as show, which you may read here:

function display(p::Person)
    display("text/markdown", """### $(p.name)
        This person is in $(p.class) class, and is $(p.age) years old.""")
end

Now calling the display method on our type will display the little markdown summary we just created:

display(people["emmy"])

In order to make this show by default for the person, we will need to bind this to show:

show(io::IO, p::Person) = display(p)

To make this a bit cooler, let us also bind show to a Vector{Person}, as well:

show(io::IO, p::Vector{Person}) = [display(person) for person in p]; return

№5: iterate

For our application, we really do not need to bind iterate, but in this example I am going to make iteration take place specifically on the names. Of course, this does not really make sense in most instances, if we wanted to iterate the names, then we would simply make a new method that creates a Vector of each name and call that each time we iterate. It makes a lot more sense to avoid dispatching iterate when possible. However, for today’s example I want to demonstrate just that! If you want to extend this method, the documentation actually gives a great write-up on exactly how to do so:

?(iterate)

“”” iterate(iter [, state]) -> Union{Nothing, Tuple{Any, Any}}

Advance the iterator to obtain the next element. If no elements remain, nothing should be returned. Otherwise, a 2-tuple of the next element and the new iteration state should be returned.

“””

Pay close attention to the input and outputs. Our iterate method will be receiving both the iterable, as well as the current iteration. It is our job to then provide an adequate return. We also need to determine when the iteration is done, and if this is the case, we need to return nothing. If we do not do this, then our iterator will continue going forever. With each step, we return the current loop variable and the step (advanced by one) inside of a 2-element tuple.

function iterate(x::Vector{Person}, state::Int64)
    if state > length(x)
        nothing
    else
        x[state].name, state + 1
    end
end

Now iterating a Vector{Person} will yield the Person’s name on each iteration:

for p in people
    println(p)
end
emmy
john

№6: push!

One of the most commonly used methods in Julia when working with collections is push!. The push! method is used to add new elements to an iterable. I have an entire article that discusses push! in more detail which might be helpful if this method is new to you:

Extending push! is a lot more simple than iterate, and in most cases we will not need to do this at all. However, as we wanted to check the names when calling Base.vect, we might also want to do this here, so that is the application I will be binding push! to for this example.

function push!(v::Vector{Person}, p::Person)
    names::Vector{String} = [person.name for person in p]
    push!(names, p.name)
    if length(Set(names))!= length(names) throw(ArgumentError("names not unique")) end
    append!(v, p)
end

I originally tried this, and it was actually quite funny because I was confused at first why I was getting String does not have field name , but this occurred because we binded iterate before, which is another great reason not to do that. Anyway, here is that comprehension modified in order to simply grab the name itself:

function push!(v::Vector{Person}, p::Person)
    names::Vector{String} = [person for person in v]
    push!(names, p.name)
    if length(Set(names))!= length(names) throw(ArgumentError("names not unique")) end
    append!(v, p)
end

Pushing myself into the people array yields:

push!(people, Person("emmy", 22, :math))
ArgumentError: names not unique

№7: Base operators

The final thing that it might be important to extend in Julia is the Base operators. This is primarily useful if you are working with numerical values. Of course, this is not really the case here, but we could still make use of operators for concatenation and grouping. In this case, however, I am simply going to be binding + to add people’s ages together. As to what application that would actually be used for, I have no idea.

+(p1::Person, p2::Person) = p1.age + p2.age
people[1] + people[2]
47

I also have an entire article on extending Julia’s Base articles (I have a lot of articles about Julia,) which can certainly provide more information on this topic. If interested, here is a link:

In terms of extensibility, Julia really is a language that has yet to be matched by any other. This is shown very clearly by the examples that can be made using Julia’s Base. Compared to a lot of the other techniques that are commonly used in other languages to provide this kind of functionality, I think that multiple dispatch does this very elegantly! Thank you so much for reading my article, it really does mean the world to me! I hope that this article brought some methods that were previously unknown to your attention, as that was my intention! One of these that I actually did not know about until recently is Base.vect, which I am thankful to have learned!

Julia
Programming
Software
Computer Science
Software Development
Recommended from ReadMedium