Skip to content

Locally-Scoped Named Functions have Surprising Behavior that Causes Poor Performance #47760

Closed
@uniment

Description

@uniment

Background:

We have four different syntaxes to declare functions: two named function syntaxes and two anonymous function syntaxes. The differentiating feature of the named function syntaxes is function identifier type declaration to stabilize the type. This is convenient (don’t need keyword const), great for performance, and quite useful for declaring recursive functions.

Example:

Named function syntax outperforms [non-const] anonymous functions:

julia> function fib(n) n  1 ? n : fib(n-1)+fib(n-2) end
fib (generic function with 1 method)

julia> @btime fib(10)
  364.796 ns (0 allocations: 0 bytes)
55

julia> fib_sad = function(n) n  1 ? n : fib_sad(n-1)+fib_sad(n-2) end
#1 (generic function with 1 method)

julia> @btime $fib_sad(10)
  6.900 μs (0 allocations: 0 bytes)
55

Problem:

For whatever reason, in local scopes the named function syntax seems to behave for all intents and purposes like anonymous function syntax by allowing the function's identifier to be reassigned. That means that within a local scope, we have loss of performance and four redundant syntaxes for doing basically the same thing. It’s also a break from the behavior that we come to expect from interacting with these syntaxes at global scope. And because recursive functions require their identifier to be declared simultaneous to their function body, we can’t use the let block trick that works for other variables captured by closures, and we can’t use type-annotation. And because const is disallowed in local scopes, the problem is inescapable.

Examples:

Locally-declared named functions can be reassigned arbitrarily, causing surprising behavior:

julia> f = let
           function test(item) "Let us test $item very carefully." end
           test(item::CarefulType) = "Especially $item must be treated with care."
           test="Syke!"
       end
"Syke!"

Locally-declared recursive functions have very poor performance due to boxing their self-reference:

julia> function fib(n) n  1 ? n : fib(n-1)+fib(n-2) end
fib (generic function with 1 method)

julia> @btime fib(10)
  358.081 ns (0 allocations: 0 bytes)
55

julia> fibby = let
           function fib(n) n  1 ? n : fib(n-1)+fib(n-2) end
       end
(::var"#fib#4") (generic function with 1 method)

julia> @btime $fibby(10)
  6.250 μs (0 allocations: 0 bytes)
55

Current Solutions:

It is suggested here to declare the functions as global. This however circumvents the issue and pollutes the global namespace with what should have been local function definitions.

For recursive functions, it is suggested here to use var"#self#". However this is not official API, and also circumvents the issue.

For recursive functions, it is suggested here to use Y-combinators. However this causes performance degradation, and also circumvents the issue.

For recursive functions, it is suggested here to rewrite recursive functions to take themselves as an argument, and wrap them in a calling closure, in a macro-automated fashion. However the extra closure causes increased compile time, and also circumvents the issue.

Proposed Solution:

It seems the right thing to do is to disallow functions declared with named function syntax in local scopes from having their identifier reassigned, consistent with the behavior of named function syntax at the global scope, so that references to them don’t need to be boxed and so that their identifiers' behavior is unsurprising.

Would Solve:

This problem (non-recursive).

This problem (recursive).

This problem (non-recursive)

This problem (recursive)

Probably more.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions