Custom implicit type conversion

I would like to specify my own implicit type conversion and according to Conversion and Promotion · The Julia Language I should be able to do something like:

julia> struct AA end

julia> Base.convert(::Type{Float64}, x::AA) = 1.0

julia> sin(AA())
ERROR: MethodError: no method matching sin(::AA)

Closest candidates are:
  sin(::Float16)
   @ Base math.jl:1558
  sin(::Irrational{:π})
   @ Base mathconstants.jl:126
  sin(::BigFloat)
   @ Base mpfr.jl:801
  ...

Stacktrace:
 [1] top-level scope
   @ REPL[3]:1


julia> versioninfo()
Julia Version 1.10.4
Commit 48d4fd48430 (2024-06-04 10:41 UTC)
Build Info:
  Official https://julialang.org/ release
Platform Info:
  OS: Linux (x86_64-linux-gnu)
  CPU: 16 × 12th Gen Intel(R) Core(TM) i5-12600K
  WORD_SIZE: 64
  LIBM: libopenlibm
  LLVM: libLLVM-15.0.7 (ORCJIT, alderlake)
Threads: 1 default, 0 interactive, 1 GC (on 16 virtual cores)
Environment:
  JULIA_PKG_USE_CLI_GIT = true

julia> 

Why is AA() not implicitly converted to Float64?

to use promotion and conversion you need two more ingredients:

  • AA <: Number
  • defined promote_rule
julia> struct AA <: Number end

julia> Base.convert(::Type{Float64}, x::AA) = 1.0

julia> Base.promote_rule(::Type{Float64}, ::Type{AA}) = Float64

julia> AA() + 2.0
3.0

It still doesn’t give you sin(AA()) because no method of sin() takes generic Number

3 Likes

Conversion and promotion only automatically occur in some methods or syntax, and the method dispatched by sin(::AA) does not do that, in fact it does not exist. Technically it’s not inherently automatic either because it’s implemented somewhere, which lets you implement your own methods with automatic conversions or promotions. Off the top of my head, conversions are attempted automatically in base Julia when assigning to a type-annotated variable or field, returning from a method with an annotated return type, and pushing into an array.

julia> y::Float64 = AA(); y
1.0

julia> ys = Float64[]; push!(ys, AA())
1-element Vector{Float64}:
 1.0

If you instead subtyped AA<:Real, then you do reach a method that converts a type, though not with convert:

julia> begin
       struct AA<:Real end
       Base.float(::AA) = 1.0
       sin(AA()) # dispatches to sin(x::Real)
       end
0.8414709848078965
1 Like

Thank you @jling and @Benny , your answers and a reread of the manual suggest to me that Julia will not implicitly call convert in order to find a matching method. My example above was just an MWE to help me understand convert, what I really want to do is something like:

struct MyType <: Function end

(::MyType)(x) = println("Hello $x")
Base.convert(::Type{MyType}, x::Function) = MyType()

dosomething(x::MyType, y) = println(x(y), " says hello")

dosomething(8) do x
    x^2
end

which I now can see does not work because the new convert method is not implicitly called. The main goal here is to be able to use do syntax without changing (or adding methods to) dosomething. Is there something akin to convert that can be used to achieve this?

That’s impossible. Every call is dispatched to a most fitting method, and if that method doesn’t exist or doesn’t properly implement the conversion you need, then you’re just stuck with manual converts in serial calls, like dosomething(convert(MyType, x->x^2), 8). do syntax in particular needs _(::Function, _) or _(::Any, _) methods.

If you can add a method, this can be done with a forwarding method, like:

dosomething(x::Function, y) = dosomething(convert(MyType, x), y)

In fact, MyType already subtypes Function so the 2 methods can be merged and still work for your example:

dosomething2(x::Function, y) = println(convert(MyType, x)(y), " says hello")

though I’d prefer to separate responsibilities. Unlike most types, Function-annotated arguments are not specialized over as a heuristic, so you’ll want to make a parametric method to opt into specialization, search the performance tips for examples.
P.S. (::MyType)(x) currently returns nothing, so println(x(y), " says hello") will print nothing says hello. My gut feeling is that’s not your intention.

1 Like

Thank you for your insights, at least it is useful to know that there is no immediately obvious slick way of handling this. I will just have to either accept that straight forward do syntax is not possible or redesign the framework. I did think of a little hack that allows me to use do syntax although less elegantly:

enable_do_syntax(f1::Function, f2::Function, args...; kwargs...) = f2(convert(MyType, f1), args...; kwargs...)

enable_do_syntax(dosomething, 8) do x
    x^2
end

Yes, this was a very shaved down MWE, I did realise this, but decided that as a whole it illustrated what I was trying to achieve, namely to use do syntax on a specialised type.

IMO do syntax is unnecessary and ideally wouldn’t be part of the language. What’s wrong with assigning a function to a variable?

sq = x -> x^2
enable_do_syntax(sq, dosomething, 8)

… or …

function sq(x)
    x^2
end
enable_do_syntax(sq, dosomething, 8)

So using do syntax just makes code less readable and less debuggable.

seems a little elaborate, and the name suggests something too general for such a specific conversion. If you’re not trying to call dosomething directly (because you also can’t add the obviously missing method), then just make a do-supporting derivative function forwarding to it:

dosomething2(x::Function, y) = dosomething(convert(MyType, x), y)

but I suppose the implication is that there are more functions like dosomething with missing _(::Function, _) methods, hence a higher-order function to handle any of them. A more specific name would be in order, but honestly makeMyType(dosomething, 8) do x isn’t really preferable to a manual convert prior to a typical call to me.

The do-less examples assigned to a global variable that’ll persist, but a let block is a simple alternative:

let sq = x->x^2
  dosomething2(sq, 8)
end

in fact if you allow yourself to annotate MyType on the assignment, it converts and you can just use the original function:

let sq::MyType = x->x^2
  dosomething(sq, 8)
end

clarity which I would prefer in the case where MyType isn’t actually special and there’s a whole host of types to convert functions to.

1 Like

Thank you both @nsajko and @Benny for your comments.

We clearly disagree on whether do syntax should be part of Julia, I feel that it looks very neat, but I will consider your suggestion, especially if do is handicapped with a call to enable_do_syntax.

Hopefully I can come up with a better name.

Exactly, there are many dosomethings and they will be spread out over multiple packages. Initially I wanted to keep this question simple, but now that we are in so deep, I feel that I should mention that there are many MyTypes as well, all with one abstract supertype which is what the dosomethings actually have as their first parameter.

For a multi-line anonymous function, I’m understanding that you are saying that you would prefer syntax like:

dosomething(convert(MyType, x -> begin
   println("starting...")
   for vv in some_global_variable
      if vv > length(vv)
         fsad....
         fdsa...
      elseif dsfdsa....
         fsdaf...
         fafad...
      else
         error("dsfasdfada")
      end
   end
end), 8)

or for the let version:

let sq = x -> begin
   println("starting...")
   for vv in some_global_variable
      if vv > length(vv)
         fsad....
         fdsa...
      elseif dsfdsa....
         fsdaf...
         fafad...
      else
         error("dsfasdfada")
      end
   end
   dosomething(convert(MyType, sq), 8)
end

It is especially in situations like this where I feel that do syntax looks neater which is why I want to preserve the option to use it.

I wasn’t talking about the syntax but specifying the type to convert the function to. You have confirmed that there are many such types, and manual specification makes far more sense than higher order boilerplate functions inlining the types e.g. dosomething1, dosomething2, etc.

It is conceivable for a macro to transform the neater do syntax to the code converting the input function prior to the higher-order call. I imagine:

@convertdo MyType dosomething(8) do x
  x^2
end
1 Like

Not sure if this really works, I usually take way longer to get a macro working but:

julia> macro convertdo(type::Symbol, doblock::Expr)
         if doblock.head != :do  throw(ArgumentError("@convertdo's 2nd argument must be do-block.")) end
         callexpr = doblock.args[1]
         anonfunc = doblock.args[2]
         insert!(callexpr.args, 2, :(convert($type, $anonfunc)) )
         callexpr
       end
@convertdo (macro with 1 method)

julia> @macroexpand @convertdo MyType dosomething(8) do x
         x^2
       end
:(Main.dosomething(Main.convert(Main.MyType, ((var"#1#x",)->begin
                  #= REPL[7]:2 =#
                  var"#1#x" ^ 2
              end)), 8))

julia> @convertdo MyType dosomething(8) do x
         x^2
       end
Hello 8
nothing says hello

Thank you, I have tried it on my own code and it has worked on everything except when keyword arguments are specified with ;, e.g.:

dosomething(x::MyType, args...; kwargs...) = println(args..., kwargs)

@convertdo MyType dosomething([1 2;3 4], "EE"; hi="hello") do x
x^2
end

which produces an “invalid syntax” error. I don’t know enough about writing macros to find the bug, hopefully you or someone else can see what needs to be done.

1 Like

Thanks for the feedback, this is the sort of stuff that would be caught in a good test. From what I can tell, it’s because I just insert the converted function expression to the 2nd element, shifting the previous 2nd and following elements back, for the 1st argument of a call without ;. For a call with ;, the 2nd element instead holds the post-; arguments, and shifting into the 3rd element turns them into a mangled 2nd argument (see output of @macroexpand). I can make a check that gets rid of the mangling, hopefully that covers any calls because I don’t have the code to run tests:

julia> macro convertdo(type::Symbol, doblock::Expr)
         if doblock.head != :do  throw(ArgumentError("@convertdo's 2nd argument must be do-block.")) end
         callexpr, anonfunc = doblock.args[1], doblock.args[2]
         arg1 = if (callexpr.args[2] isa Expr && callexpr.args[2].head == :parameters) 3 else 2 end
         insert!(callexpr.args, arg1, :(convert($type, $anonfunc)) )
         callexpr
       end
@convertdo (macro with 1 method)
1 Like

Thank you, I have now tested this on quite a lot of examples (even a @convertdo inside a @convertdo) and it worked perfectly every time.

Spotted a rookie macro mistake in my code. I only bothered to experiment a little within the Main module, so it doesn’t cover macro hygiene at all; basically, that’s whether the variables in the expression are considered to belong to the macro definition’s module or the macro call’s module. As it is, all the variables will be considered to belong to the macro definition module, but in this context, you likely want the code you write to belong to whereever you write it, and the only new symbol the macro introduces, convert, should belong to Base unconditionally. So, I’m putting the amended macro here, it’s a very small change:

julia> macro convertdo(type::Symbol, doblock::Expr)
         if doblock.head != :do  throw(ArgumentError("@convertdo's 2nd argument must be do-block.")) end
         callexpr, anonfunc = doblock.args[1], doblock.args[2]
         arg1 = if (callexpr.args[2] isa Expr && callexpr.args[2].head == :parameters) 3 else 2 end
         insert!(callexpr.args, arg1, :(Base.convert($type, $anonfunc)) )
         esc(callexpr) # entire expression belongs to macro call module
       end
@convertdo (macro with 1 method)

julia> @macroexpand @convertdo MyType dosomething([1 2;3 4], "EE"; hi="hello") do x
       x^2
       end
:(dosomething(Base.convert(MyType, ((x,)->begin
                  #= REPL[4]:2 =#
                  x ^ 2
              end)), [1 2; 3 4], "EE"; hi = "hello"))

See how all the Main._ from the first version’s @macroexpand vanishes, and the do-block’s argument stays as written instead of being changed? Those changes were the variables being resolved in the macro definition scope by default. If you ever need to use the macro outside its definition module, I expect this would fix the immediate bugs.

1 Like