Search code examples
juliacustom-object

Define as few methods as possible to get basic functionality for custom object (Julia)


I'm still a beginner with Julia, so apologies for the OOP terminology.

Let X and Y be two user-defined matrices with an element type of MyType. Let I be the identity matrix. MyType, in this case, happens to be an object with a single attribute and all the operations are done through this attribute. I'm trying to test if ((X-I)')^-1 is approximately Y. I want to do this by defining as few functions as possible. But I also don't want MyType to inherit from anything.


I would expect that I would only need to define:

  • one and zero for the identity matrix. The elements of I should know that they are of type MyType.
  • +(x::MyType, y::MyType), -, *, / for matrix multiplication and inversion.
  • adjoint for determining (X-I)'.
  • isapprox to give some kind of meaning.

However, in practice, I need far more than these. Is there any way that I can reduce the number of implemented methods for this?


Here is what I have so far. It took me some time to finally stop getting MethodErrors.

# Define MyType
struct MyType  # Don't inherit from anything
    value
end
value(x::MyType) = x.value

# Define its methods
Base.one(::Type{MyType}) = MyType(1)
Base.zero(::Type{MyType}) = MyType(0)

Base.:+(x::MyType, y::MyType) = MyType(value(x)+value(y))
Base.:*(x::MyType, y::MyType) = MyType(value(x)*value(y))
Base.:-(x::MyType, y::MyType) = MyType(value(x)-value(y))
Base.:/(x::MyType, y::MyType) = MyType(value(x)/value(y))

Base.adjoint(x::MyType) = MyType(adjoint(value(x)))

function Base.isapprox(x::MyType, y::MyType; atol, rtol)
    return isapprox(value(x), value(y), atol=atol, rtol=rtol)
end
function Base.rtoldefault(::Type{MyType}, ::Type{MyType}, z)
    # I don't really want to be restricted to Float64 tolerance.
    return Base.rtoldefault(Float64, Float64, z)
end

# Shouldn't have to define these
Base.one(::MyType) = one(MyType)
Base.zero(::MyType) = zero(MyType)

Base.:+(x::MyType, y) = MyType(value(x)+y)
Base.:*(x::MyType, y) = MyType(value(x)*y)

Base.abs(x::MyType) = MyType(abs(value(x)))
Base.:<(x::MyType, y::MyType) = value(x) < value(y)
Base.inv(x::MyType) = MyType(inv(value(x)))

Base.promote_rule(::Type{Any}, ::Type{MyType}) = MyType
Base.convert(::Type{MyType}, x::MyType) = x
Base.convert(::Type{MyType}, x) = MyType(x)

Base.iterate(x::MyType) = (value(x), nothing)
Base.iterate(::MyType, ::Any) = nothing
Base.length(x::MyType) = 1

# Begin check for ((X-I)')^-1 ≈ Y
X = [
    0.4 1;
    -0.2 0.8;
]
X = map(x -> MyType(x), X)

Y = [
    -0.625 0.625;
    -3.125 -1.875;
]
Y = map(x -> MyType(x), Y)

using LinearAlgebra
println((X-I)')
println(inv((X-I)'))
println(inv((X-I)') ≈ Y)

I know I can use macros to make this easier. But this is not the point of the exercise.

I'm sure I've made many stupid mistakes particularly with promote_rule and iterate. I've tried reading the documentation multiple times, so I must be pretty stupid.

Any help much appreciated.


Solution

  • The absence of fallback methods is sometimes an omission (and you could submit a pull request to fix that) and sometimes deliberate. Let's take the case of zero: why isn't there a fallback zero(x) = zero(typeof(x)), so that you wouldn't have to write that yourself? Well, obviously that wouldn't be hard to add, and one could argue that we should have it. But consider what happens if a user creates a new type and then ends up needing an undefined method:

    julia> struct MyMatrix{T}
               buffer::Vector{T}
               sz::Tuple{Int,Int}
           end
    
    julia> M = MyMatrix(rand(9), (3, 3));
    
    julia> zero(M)
    ERROR: MethodError: no method matching zero(::MyMatrix{Float64})
    Closest candidates are:
      zero(::Union{Type{P}, P}) where P<:Dates.Period at /home/tim/src/julia-master/usr/share/julia/stdlib/v1.7/Dates/src/periods.jl:53
      zero(::T) where T<:Dates.TimeType at /home/tim/src/julia-master/usr/share/julia/stdlib/v1.7/Dates/src/types.jl:423
      zero(::SparseArrays.AbstractSparseArray) at /home/tim/src/julia-master/usr/share/julia/stdlib/v1.7/SparseArrays/src/SparseArrays.jl:55
      ...
    Stacktrace:
     [1] top-level scope
       @ REPL[3]:1
    

    Now, I can easily imagine figuring out how to write this method: I have the element type, I have the size, so I am all set. Suppose instead we'd defined the fallback:

    julia> Base.zero(M::MyMatrix) = zero(typeof(M))
    
    julia> zero(M)
    ERROR: MethodError: no method matching zero(::Type{MyMatrix{Float64}})
    Closest candidates are:
      zero(::Union{Type{P}, P}) where P<:Dates.Period at /home/tim/src/julia-master/usr/share/julia/stdlib/v1.7/Dates/src/periods.jl:53
      zero(::MyMatrix) at REPL[4]:1
      zero(::T) where T<:Dates.TimeType at /home/tim/src/julia-master/usr/share/julia/stdlib/v1.7/Dates/src/types.jl:423
      ...
    Stacktrace:
     [1] zero(M::MyMatrix{Float64})
       @ Main ./REPL[4]:1
     [2] top-level scope
       @ REPL[5]:1
    

    OK, so now I sit down to implement zero(::Type{MyMatrix{T}}) ... and realize that I have no idea what size of matrix I should be creating. If I don't realize that I should not take the error message so literally, I'm really in trouble now.

    With a Number, you don't need any additional parameters to know what zero of that type should mean, but that's not necessarily true of everything. This highlights an advantage in having only sensible fallbacks. What that means in practice is a bit murky, but subtyping gives you one way of expressing your intuition for what's sensible.