Mutating function-like objects, enforcing "!"?

For many users, this is probably a minor bike-shed thing but I wanted to dump it ;) maybe it’s even a bug or let’s say, design flaw (see at the end), no idea.

I use function-like objects a lot in my packages and I constantly bump into this.

Given the following simple example

julia> struct Foo
         buffer::Vector{Float64}
         Foo() = new(Float64[])
       end
julia> function (f::Foo)(arr)
         resize!(f.buffer, length(arr))
         # do some stuff, use the `buffer`, mutate `arr` etc.
         reverse!(arr)  # just that we mutate...
       end

we will end up with:

julia> f = Foo()
Foo(Float64[])

julia> f([1,2,3])
3-element Vector{Int64}:
 3
 2
 1

My problem is that there is of course no ! to indicate that f is mutating, so the only way to make it look nicer (i.e. signal that it’s mutating) is discipline, which is a source of error in the sense that users of the package will likely name their Foo() whatever they like and not realise that calling it will mess with the input values, so they will not even think about adding an !.

So this is the “discipline” which is required, when creating Foo():

julia> f! = Foo()
Foo(Float64[])

julia> f!([2,3,4])   # add the !
3-element Vector{Int64}:
 4
 3
 2

What I find confusing however (this is the bug or design flaw part) is that you can defeine `(f::Foo)!(arr) and will not give any error, but then nothing is callable:

julia> struct Foo
         buffer::Vector{Float64}
         Foo() = new(Float64[])
       end

julia> function (f::Foo)!(arr)
         resize!(f.buffer, length(arr))
         # do some stuff, use the `buffer`, mutate `arr` etc.
         reverse!(arr)  # just that we mutate...
       end
#16 (generic function with 1 method)

julia> f = Foo()
Foo(Float64[])

julia> f!([2,3,4])
ERROR: UndefVarError: `f!` not defined
Stacktrace:
 [1] top-level scope
   @ REPL[4]:1

julia> f([2,3,4])
ERROR: MethodError: objects of type Foo are not callable
Stacktrace:
 [1] top-level scope
   @ REPL[5]:1

So I am wondering what happens when defining (f::Foo)!(arr), where is the !? ;)

To me it would make sense that however Foo() is named later, I can call however!(arr) when defining (f::Foo)!(arr).

I assume this would not be a breaking change since it did not work before, so maybe something for Julia 1.10 or 1.11, unless I am overlooking something.

Any thoughts?

function (f::Foo)!(arr)
  arr
end

is equivalent to

function (f::Foo) # anonymous function
  !(arr) # undefined arr
  arr
end

@macroexpand shows these expressions are equivalent, can also put these expressions through dump to display them as ASTs.

You can’t append a character to a name from outside parentheses (f::Foo)!, you must write it with the name (f!::Foo). And like all other methods belonging to Foo, f!::Foo is a local variable like the other arguments, not a global variable you can use to call.

2 Likes

Yes you are right, I just realised that the syntax would then obviously be a breaking change ;)

Maybe you could at least name your struct Foo!, which might act as a little reminder for your users that callable instances of it mutate their arguments?

julia> struct Foo!
           buffer::Vector{Float64}
           Foo!() = new(Float64[])
       end

julia> function (f::Foo!)(arr)
           resize!(f.buffer, length(arr))
           # do some stuff, use the `buffer`, mutate `arr` etc.
           reverse!(arr)  # just that we mutate...
       end

# Seeing the ! in the type name might remind me (as a user)
# to append a ! to the instance name as well
julia> f! = Foo!()
Foo!(Float64[])

julia> a= [1,2,3]
3-element Vector{Int64}:
 1
 2
 3

julia> f!(a); a
3-element Vector{Int64}:
 3
 2
 1
2 Likes

That would make me think it’s the constructor method that mutates the arguments, not the callable instances.

3 Likes

You just explained why the callable object function should not be a mutating function. In case you want to mutate your object, create a standalone mutating function and pass the object as the first argument.

julia> function use!(f::Foo, arr)
         resize!(f.buffer, length(arr))
         # do some stuff, use the `buffer`, mutate `arr` etc.
         reverse!(arr)  # just that we mutate...
       end

There. Keep callable object functions for stuff without side effects.

5 Likes

I guess that’s the best workaround :wink: