Do-Block Syntax with Where Keyword

I want to use a finalizer for a parametric type using the type parameter in the finalizer. I have a solution for that, but I wondered whether I can use the do syntax, but I simply do not know where the where needs to be put to be syntactically correct.
The following examples need the REPL to fire the finalizer.

Using the do syntax without a parameter works:

mutable struct MySimpleStruct
    x::String
end
s = MySimpleStruct("What is the meaning of life?");
finalizer(s) do s
    @async println("Finalizing: $(s.x)")
    return s
end;
finalize(s)

With a parametrized type, this gets a “T not defined” error, because where is missing and I do not know where to put it:

mutable struct MyStruct{T}
    x::T
end
s = MyStruct("What is the meaning of life?");
finalizer(s::T) do s
    @async println("Finalizing: $(s.x) using $(T |> nameof)")
    return s
end

Working around this is possible, but misses the typical Julia elegance:

mutable struct MyStruct{T}
    x::T
end
s = MyStruct("What is the meaning of life?");
function myfinalize(s::T) where T
    @async println("Finalizing: $(s.x) using $(T |> nameof)")
    return s
end;
finalizer(myfinalize, s);
finalize(s)
1 Like

I don’t recall anonymous functions (which is what do clauses really are) supporting where clauses see below for an example by @savq — the main purpose of where is to control dispatch, which doesn’t arise for anonymous functions, since they don’t (normally) have multiple methods.

You can just do T = typeof(s.x) in the body of your function (do clause) to get the type.

4 Likes

You could also pass an anonymous function without using do:

mutable struct MyStruct{T}
    x::T
end

s = MyStruct("What is the meaning of life?");

finalizer(
    function(s::MyStruct{T}) where T
        println("Finalizing: $(s.x) using $(T)")
    end,
    s
)
3 Likes

I think this is a parser thing, not a functionality thing. It’s doable with anonymous functions:

julia> f = ((x::Vector{T}) where T) ->  println(T)
#1 (generic function with 1 method)

julia> f(rand(4))
Float64

julia> f(rand(Float32,4))
Float32

However, doing the same with the do syntax fails because it’s parsed differently.

julia> dump(:(((x::Vector{T}) where T) ->  println(T)))
Expr
  head: Symbol ->
  args: Array{Any}((2,))
    1: Expr
      head: Symbol where
      args: Array{Any}((2,))
        1: Expr
          head: Symbol ::
          args: Array{Any}((2,))
            1: Symbol x
            2: Expr
              head: Symbol curly
              args: Array{Any}((2,))
                1: Symbol Vector
                2: Symbol T
        2: Symbol T
...

whereas the do arguments are parsed diffently:

julia> dump(:(finalizer(v) do ((x::Vector{T}) where T); println(T) end))
Expr
  head: Symbol do
  args: Array{Any}((2,))
    1: Expr
      head: Symbol call
      args: Array{Any}((2,))
        1: Symbol finalizer
        2: Symbol v
    2: Expr
      head: Symbol ->
      args: Array{Any}((2,))
        1: Expr
          head: Symbol tuple
          args: Array{Any}((1,))
            1: Expr
              head: Symbol ::
              args: Array{Any}((2,))
                1: Symbol x
                2: Expr
                  head: Symbol where
                  args: Array{Any}((2,))
                    1: Expr
                      head: Symbol curly
                      args: Array{Any}((2,))
                        1: Symbol Vector
                        2: Symbol T
                    2: Symbol T
   
 ...

i.e. a tuple arg, not a where arg for the -> operator.

1 Like

NB: this is incorrect:

julia> f = () -> 3
#1 (generic function with 1 method)

julia> (::typeof(f))(::Any) = 7

julia> f
#1 (generic function with 2 methods)

julia> methods(f)
# 2 methods for anonymous function "#1":
 [1] (::var"#1#2")()
     @ REPL[1]:1
 [2] (::var"#1#2")(::Any)
     @ REPL[2]:1
3 Likes

Thanks for the insights. Sorry that I didn’t mention it, but inside the finalizer T is used to hand over a parameter to a @ccall (as Ref{T}), so using typeof is unfortunately not an option, as it results in:

ERROR: LoadError: could not evaluate ccall argument type (it might depend on a local variable)

Although where’s main purpose is to control dispatch, it’s not its only purpose.

That’s pretty ingenious.
Following from that, this is probably trivial, but I needed to see it, too, for anonymous functions created with a do-block:

julia> global global_f

julia> savef(f) = global global_f = f
savef (generic function with 1 method)

julia> savef() do x
           "any"
       end
#1 (generic function with 1 method)

julia> (::typeof(global_f))() = 7

julia> global_f
#1 (generic function with 2 methods)

1 Like

Do you know if there is a technical reason (conflicting syntax) why the parser cannot be extended to support the where clause?

In my opinion we have now three good reasons why the do-block should support the where clause and I should fill a feature request:

  • To support multiple dispatch in case of multiple method definitions
  • To support cases where the type is used statically
  • For consistency reasons: Ideally each way of defining functions should support all functionality

This is interesting, as based on that, the following will also work without stretching the finalizer call:

function(s::MyStruct{T}) where T
    println("Finalizing: $(s.x) using $(T)")
end |> Base.Fix2(finalizer, s)

Rather than finalizer(s::T) do s (with a where… somewhere), the syntax should be something like:

finalizer(s) do s::T where T
    #...
end

Unfortunately this does currently mean something — it’s an anonymous function like:

function anon(s::T where T)
    # no T binding available here :(
end

The do syntax doesn’t use parentheses, so I don’t think there’s a way to “move” the where clause outside of parentheses that don’t exist. I suppose we might be able to use something like do (elt::T) where T, but boy is that fiddly — especially when you start adding multiple args.

1 Like

It’s actually possible with a quite simple macro which rewrites the do syntax to -> syntax. I haven’t tested it thoroughly. I suspect it’s just a bit of luck with the parser.

using MacroTools: @capture

macro wheredo(ex)
    @capture(ex, F_(fargs__) do args__; body__; end) || 
        error("Can't find do construct: $ex")
    :($F($(args...) -> $(body...), $(fargs...))) |> esc
end

@wheredo map(1:4, ["a","b","c","d"]) do (x::S, y::T) where {S, T}
    println("S: ", S, ", T: ",T)
    string(x) * string(y)
end
1 Like

Maybe it would be possible to change the parsing of

finalizer(s) do s::T where T
    #...
end

to mean:

function anon(s::T) where T
    # T binding available here :)
end

I see the following advantages for new code:

  • As there are no parentheses, that’s exactly what I would expect seeing this code
  • Every use case where T is only available in the function arguments should also be possible if T is additionally available in the function body
  • If there should come up the need of having T only available in the function arguments this could be added at any time by supporting a notation using parentheses (adding previously non-existing parentheses is far simpler than removing previously non-existing ones ;-))

I understand that, strictly speaking, this would be an incompatible change. However, I assume the risk is really low, because

  • If someone had used do together with where that would have been based on undocumented behavior
  • The current syntax seems to support only single arguments, where the T is only available in function arguments, and this is rather pointless
  • The behavior would only change if there is an identically named identifier (typically T) already used inside the do function body

The compound probability of all three small probabilities seems to be very small. If we wanted to be even more careful, we could probably parse the packages registered in the Julia package registry before and after the parser change and verify that the Exprs are unchanged.
The risk of non-public code being changed would not be eliminated, but it could strengthen the assumption that the probability for this is very low.

Interesting solution, thanks. Both using the parentheses after the do and the curly braces after the where seem to be essential in order to have this working.

I think it might be fairly easy to allow ... do (x::T) where T, i.e. with parentheses. And curly braces around {T, S} if more type variables. It’s already being parsed, and the simple rewriting to -> in the above @wheredo is sufficient. The parser rewrites to -> as well, but in a slightly different manner which makes this construction fail. However, who knows if this would break something.

However, the precedence of where must allow constructions like f(x::S, y::T where S<:T<:Real) where S = x+y being parsed as (T where S<:T<:Real). For this reason the @wheredo macro above will fail with ... do x::T where T, i.e. T will not be visible inside the body. This form of do is already legal and works, but T is not visible.

For this reason, where has a different precedence if it follows a ::. That’s why constructions like f(x::S, y::T)::S where {S,T} = x + y don’t work, you need parentheses: (f(x::S, y::T)::S) where {S,T} = x + y.

1 Like

The Julia project already uses something like this as part of some workflows, Nanosoldier uses PkgEval to run the test suites of all (or selected) registered packages before and after a change to Julia. See here:

1 Like

I figured out it’s a two-liner in JuliaSyntax/src/parser.jl to support where clauses in do, provided the argument list is enclosed in parentheses.

2308c2308,2309
<     emit(ps, m, K"tuple")
---
>     k = peek_behind(ps, position(ps)).kind
>     k != K"where" && emit(ps, m, K"tuple")

Then you can do things like:

julia> foreach([1,2.0,"foo", false]) do (x::T) where T; println(T); end
Int64
Float64
String
Bool

I haven’t got the time to follow up a PR, but please do anyone.

3 Likes

Thanks for this extra explanation. I never understood before, why the parentheses are necessary in the case of defining a return type.