Using parametric abstract type instead of function

I have a function that takes an arg and callable type (shown below). I need to pass additional parameter information with the function. One of the clean ways that worked with Julia, is to define an abstract type with a parameter and define a function with the parameter (added dummy example). Probably this is not the intended use, and an alternative would be to define a function with Type or Val as input and use an anonymous function.

Will this lead to any unintended issues or performance deterioration? Also is there any other clean way to do this, without modifying the function main?

function main(arg, fn)
    res =  do_work(arg)
    fn(res)
end 

abstract type fun{T} end

fun{T}(x) where {T}  = convert(T, x)

# Example
do_work(x) = x
main(2.0, fun{Int}) # Return 2 (type int)

I

I don’t know about performance but another approach is

function curried_converter(T::Type)
    return (x) -> convert(T, x) 
end

f = curried_converter(Int)
main(2.0, f)
# 2

Approach #2 (small edit to main)

function main(arg, T::Type)
    res::T =  do_work(arg)
    return res
end 

# Example
do_work(x) = x
main(2.0, Int) # Return 2 (type int)

I’m not at my computer, but my guess it that the last approach is best because it doesn’t need to compile an extra function. But I’m not sure how hard your requirement to not edit main is.

EDIT: Error in main in approach 2. fixed.

Ran some benchmarks:

Setup Code
function main1(arg, fn)
    res = do_work(arg)
    fn(res)
end
do_work(x) = (x)

abstract type fun{T} end
fun{T}(x) where {T} = convert(T,x)

function curried_converter(T::Type)
    return (x) -> convert(T, x) 
end
f = curried_converter(Int)

function main2(arg, T::Type)
    res::T =  do_work(arg)
    return res
end 

Timing Results

@btime main1($(2.0), $(fun{Int}))
# first run: 150.000 ns (1 allocation: 16 bytes)
# 142.823 ns (1 allocation: 16 bytes)
@btime main1($(2.0), $(f))
# first run: 244.053 ns (1 allocation: 16 bytes)
# 129.499 ns (1 allocation: 16 bytes
@btime main2($(2.0), $(Int))
# first run: 141.543 ns (1 allocation: 16 bytes)
# 119.778 ns (1 allocation: 16 bytes)

If this is only going to be run once, the anonymous function is going to cost you compilation time. After running once, it’s faster to use the anonymous function. If you can edit main at all, looks like you get some benefit from using a more “normal” approach to specifying an output.

You really want to parameterise T in this case, or you are not specializing the function, and are actually putting a DataType in the field of the anonymous function struct instead of just inserting the the known T right into the convert method at compile time.

function curried_converter(::Type{T}) where T
    return (x) -> convert(T, x) 
end
f = curried_converter(Int)

And its a few ns:

@btime main1($(2.0), $(f))
julia> @btime main1($(2.0), $(f))
  4.354 ns (0 allocations: 0 bytes)
2
2 Likes

Nice. Just learned about the impact of using parametric types in function signatures allowing generated functions recently. Clearly I have not internalized it yet!

While trying this (and variants thereof), I found strange behavior. Some sort of path-dependence in a Julia session that depends on whether it previously threw an error, it seems.

In this demo, I make it throw an error and it runs slow forevermore. Then I restart the session, re-execute without throwing an error, and it runs fast.

Any ideas what’s going on here?

PS C:\Users\unime> julia
               _
   _       _ _(_)_     |  Documentation: https://docs.julialang.org
  (_)     | (_) (_)    |
   _ _   _| |_  __ _   |  Type "?" for help, "]?" for Pkg help.
  | | | | | | |/ _` |  |
  | | |_| | | | (_| |  |  Version 1.9.0-beta4 (2023-02-07)
 _/ |\__'_|_|_|\__'_|  |  Official https://julialang.org/ release
|__/                   |

julia> using BenchmarkTools
       function main(arg, fn)
           res =  do_work(arg)
           fn(res)
       end
       function curried_converter(::Type{T}) where T
           return x -> convert(T, x)
       end;

julia> main(2.0, curried_converter(Int))
ERROR: UndefVarError: `do_work` not defined
Stacktrace:
 [1] main(arg::Float64, fn::var"#3#4"{Int64}) @ Main .\REPL[1]:3
 [2] top-level scope @ REPL[2]:1

julia> do_work(x) = x
       @btime main($2.0, f) setup=(f=curried_converter(Int))
  30.519 ns (1 allocation: 16 bytes)
2

julia> exit()
PS C:\Users\unime> julia
               _
   _       _ _(_)_     |  Documentation: https://docs.julialang.org
  (_)     | (_) (_)    |
   _ _   _| |_  __ _   |  Type "?" for help, "]?" for Pkg help.
  | | | | | | |/ _` |  |
  | | |_| | | | (_| |  |  Version 1.9.0-beta4 (2023-02-07)
 _/ |\__'_|_|_|\__'_|  |  Official https://julialang.org/ release
|__/                   |

julia> using BenchmarkTools
       function main(arg, fn)
           res =  do_work(arg)
           fn(res)
       end
       function curried_converter(::Type{T}) where T
           return x -> convert(T, x)
       end;

julia> do_work(x) = x
       @btime main($2.0, f) setup=(f=curried_converter(Int))
  4.900 ns (0 allocations: 0 bytes)
2

Probably not coincidentally, Base.Fix1 and my PartialFuns Fix1 suffer from this too, as does the OP’s type-parameterized abstract typctor.

There do seems to be some bugs like this around. Sometimes including the code with Revise.jl will “fix” them.

But I’m not sure if its the fact the error was thrown, or just that it was compiled unspecialised to make a runtime function call and that’s what it’s still doing after you define do_work.

Your second version is only compiled after do_work is defined so will just inline it.

Anyway, that’s just a guess, probably actually investigating it further will give better answers. (e.g. with @code_lowered and @code_native or Cthulhu.@descend)

1 Like