Overloading arithmetic operators for custom types

I’m doing doing exercises on exercism.org to get familiar with Julia (most of my experience is in Python, C++, or Java). For one exercise I’ve made a custom Clock type, and I want to overload Base.show to print a timestring field, and overload + and - to operate on the actual time value like so:

struct Clock{Time}
    time::Time
    timestring::AbstractString
    function Clock(hours::Int64, minutes::Int64)
    
        delta_hours = div(minutes, 60)
        delta_minutes = delta_hours * 60
        hours += delta_hours
        minutes -= delta_minutes
        
        if hours < 0
            hours %= 24
            hours += 24
        end
        
        if minutes < 0
            hours -= 1
            hours %= 24
            minutes += 60
        end
        
        minutes %= 60
        hours %= 24
    
        time = Time(Hour(hours), Minute(minutes))
        timestring = Dates.format(time, "HH:MM")
    end
end
    

Base.:(+)(c::Clock, m::Dates.Minute) = getfield(c, time) + m
Base.:(+)(c::Clock, h::Dates.Hour) = getfield(c, time) + h

Base.:(-)(c::Clock, m::Dates.Minute) = getfield(c, time) - m
Base.:(-)(c::Clock, h::Dates.Hour) = getfield(c, time) - h;

Base.:(-)(m::Dates.Minute, c::Clock) = m - getfield(c, time) + m
Base.:(-)(h::Dates.Hour, c::Clock) = m - getfield(c, time) + h

Base.show(io::IO, c::Clock{Time}) = print(io, timestring)

As far as I can tell from the docs, this should do what I’m wanting, but when I attempt, for example,

Clock(10, 0) + Dates.Minute(3) == Clock(10, 3)

I get the following error:

MethodError: no method matching +(::String, ::Dates.Minute)
Closest candidates are:
  +(::Any, ::Any, !Matched::Any, !Matched::Any...) at operators.jl:591
  +(!Matched::ExercismTestReports.Clock, ::Dates.Minute) at ./clock.jl:31
  +(!Matched::P, ::P) where P<:Dates.Period at /usr/local/julia/share/julia/stdlib/v1.8/Dates/src/periods.jl:77

This is exactly what I’m trying to address by overloading. Where am I going wrong?

Thanks in advance, and here is the stacktrace from exercism, in case it’s helpful:

Stacktrace:
 [1] macro expansion
   @ /usr/local/julia/share/julia/stdlib/v1.8/Test/src/Test.jl:464 [inlined]
 [2] macro expansion
   @ ./runtests.jl:69 [inlined]
 [3] macro expansion
   @ /usr/local/julia/share/julia/stdlib/v1.8/Test/src/Test.jl:1363 [inlined]
 [4] top-level scope
   @ ./runtests.jl:69```

it looks like your Clock function returns a string instead of a Clock object. You can use the new function to create a Clock object. Add something like this to the end of your function:

        time = Time(Hour(hours), Minute(minutes))
        timestring = Dates.format(time, "HH:MM")
        return new{Time}(time,timestring)
    end
3 Likes

Time is not an abstract type, so there is no need to parameterize that.

You are incorrect in thinking the fields are within the constructor’s local scope. They are not.

You can use a short hand notation for binary operators.

import Base: +, -
using Dates: Time, Hour, Minute

struct Clock{S}
    time::Time
    timestring::S
    function Clock(hours::Int64, minutes::Int64)
    
        delta_hours = div(minutes, 60)
        delta_minutes = delta_hours * 60
        hours += delta_hours
        minutes -= delta_minutes
        
        if hours < 0
            hours %= 24
            hours += 24
        end
        
        if minutes < 0
            hours -= 1
            hours %= 24
            minutes += 60
        end
        
        minutes %= 60
        hours %= 24
        time = Time(Hour(hours), Minute(minutes))
        return Clock(time)
    end
    function Clock(time::Time)       
        timestring = Dates.format(time, "HH:MM")
        return new{typeof(timestring)}(time, timestring)
    end
end

c::Clock + m::Minute = Clock(c.time + m)
c::Clock + h::Hour = Clock(c.time + h)
# Addition is commutative
t + c::Clock = c + t

c::Clock - m::Minute = Clock(c.time - m)
c::Clock - h::Hour = Clock(c.time - h)


Base.show(io::IO, ::MIME"text/plain", c::Clock) = print(io, c.timestring)

Here is a demo.

julia> c = Clock(4,5)                                                       
04:05

julia> c + Minute(5)                                                        
04:10  
                                                                                                                                                                                                                                                                                         
julia> c + Minute(5)                                                        
04:10   
                                                                                                                                              
julia> c + Hour(6)                                                          
10:05   
                                                                                                                                             
julia> Hour(7) + c                                                          
11:05      
                                                                                                                                         
julia> Minute(2) + c                                                        
04:07
3 Likes

This is the most obscure and unintuitive way to define a function. For the sake of readability and understandability, I would not recommend that approach.

4 Likes

Fair enough, but the above makes my eyes hurt.

1 Like

Of course it comes down to a matter of taste, but the only unusual thing about Base.:(+)(c::Clock, m::Minute) = ... is the quoting of the function name.

1 Like

Do you mean because it is not immediately cleare that you are defining the behaviour of + there, or because the definition it’s not clear?

I must admit I actually like the way @mkitti wrote those lines. But maybe that’s because I’m still a mathematician before being a programmer :laughing:

6 Likes

That’s really cool, I didn’t know that you could define infix operators like that. I’d argue that it’s more intuitive to define an infix operator using infix notation. But it is definitely more obscure.

3 Likes

It can also be a foot gun if you do not know what you are doing.

julia> a + b = a                                                            
+ (generic function with 1 method)                                                                                                                      

julia> 3 + 4                                                                
3
10 Likes

It is a foot gun, but the problem isn’t really what happens when you do want to define operations. Rather, the problem is when you are not trying to do that, and end up doing it inadvertently.

Perhaps using this syntax will make you more aware of it, paradoxically reducing the risk of doing it in error.

The only way to remove the foot gun is to disallow the syntax.

4 Likes

Yeah, it doesn’t really look like a function definition. It looks like a typo:

julia> a = 1; b = 2;

julia> a - b == -1
true

julia> a + b = 3
+ (generic function with 1 method)

julia> # Oops, I just redefined addition.

julia> 10 + 20
3

But I guess the argument is that if we can define a non-infix function via foo(x, y) = ..., then we should be able to do the same with infix functions.

3 Likes

Thanks for the quick response! That totally makes sense, it would need to return a clock object in order to get the string and the time.

is the ‘::MIME"text/plain"’ argument required, or is it just to give a nicer print? I thought from my reading of the show() docs that one could define a two argument show method without issues

That’s actually what my thoughts were. Overloading basic arithmetic operators feels like dangerous territory to me, so I wanted to write it in a way that would make me think about exactly what I was doing. It probably is uglier, honestly, but I think it’s worth it when doing something so potentially risky.

1 Like

I would have thought it would only be commutative if you explicitly defined both ways–do all of the base operators for commutative arithmetic operations just automatically recognize commutative arguments?

1 Like

No, if you only define a single show method it should be the 2-argument version. By default, the 3-argument version calls the 2-argument version.

You only ever define a 3-argument ::MIME"text/plain" method for show in addition to defining a 2-argument method, and only do so if you want a more verbose display for e.g. REPL output of a single value.

1 Like

The code you’re quoting is doing exactly that. It defines the right-addition of Clock values in terms of the left-addition.

Good lord. This insidious mechanism should be removed from the language forthrightly, or at least be quarantined behind a feature flag.

KILL IT WITH FIRE BEFORE IT LAYS EGGS!

2 Likes

Overloading arithmetic is completely normal, and required to achieve basic functionality for many new types. Just don’t do it on someone else’s types, that’s type piracy.

The syntax for achieving it is a separate matter.

8 Likes

I mean, there IS a flag of sort, right? The overloading is not possible unless you import the base function you want to overload. And you would do it only with the intention of venturing into that “dangerous territory”.

And on the other hand, that syntax is amazing for people like me who gets into coding from other territories.

Let’s all try to be open minded :smile_cat:

4 Likes