Manual control of overloading existing methods

I am curious to know how much people want to have a feature/mechanism in Julia to manually control function overloading. Specifically, to disallow overloading already defined methods when intended.

One direct motivation for such a restriction is to prevent malicious/unwanted code injection. Consider the following example:

julia> import Base: + # First line after a freshly started Julia REPL

julia> +(::Int, ::Int) = zero(Int) # DANGEROUS!!!

Executing the second line of code will cause the Julia process to crash immediately.

As far as I know, there is no prevention from someone overloading an already defined method, which, in a worst-case scenario, can lead to the consequences of the above example.

Of course, one can argue that it is not Julia’s “responsibility” to prevent the end user from exploiting the vulnerability of multiple dispatch. In my opinion, the discussion about a language’s responsibility (e.g., restriction vs. freedom) for its users is fundamentally a philosophical topic in language design, which I shall refrain from further exploring. Instead, I would like to discuss my concerns and thoughts more concretely in relation to some more subtle scenarios and potential implementations.

Prevent silent function misuse and (some) type piracy issues

What if, when someone overloads a method, their modification does not crash the program but causes silent wrong/unintended behavior for other callers (e.g., functions) of that updated method? In my opinion, this happens much more easily when the user overloads a method from a library (module) not created by them. In other words, such behavior can be categorized as a special case of type piracy: defining a method f with respect to a set of argument types {T}, where neither f nor {T} is owned by the user.

Admittedly, there can be some convenient use cases to “justify” such a “piracy”. However, in my opinion, overloading already existing methods that the user does not own is almost certainly a bad practice, as it potentially introduces performance (compilation invalidation) or correctness (silent incorrect results) risks. As far as I know, currently, there is no formal way to prevent type piracy. Having manual control over whether a method can be overloaded can be a good indicator for the library developer to warn the user about which parts of the library are not encouraged to be modified or extended. One dilemma I sometimes encounter as a user is when I want to overload a function from a library, yet I’m unsure if it will cause unwanted side effects, as I don’t fully understand the source code like the developers. This risk is especially amplified when the user is also a developer for another library. Many Julia libraries attempt to depend on other upstream libraries by overloading public but non-exported functions. Therefore, I believe, it would be beneficial for the language and the entire Julia ecosystem to provide an opt-in feature that prevents further overloading of existing methods that “are not meant to be overloaded”.

Potential syntax proposal

I’m not sure whether such a feature can be realized by a macro or an entirely new keyword. For the purpose of demonstrating how syntactically this feature might work, I will use a macro “@protect” to illustrate. Please feel free to propose alternatives.

The basic syntax for a concrete method protected from further overloading can be:

@protect function foo1(a::Int, b::Int)
    a + b
end

Once foo1 is compiled (either through precompilation or JIT compilation) at a world age, it cannot be overloaded afterward with a new function body. This is the most basic case, but things can get a bit more interesting once we allow abstract types to be in the function argument signatures:

@protect function foo2(a::Real, b::Int)
    a + b
end

In this case, any new method foo2(a::T, b::Int) where T is a subtype of Real is disallowed to be overloaded. In other words, the function signatures with abstract types enclose a set of concrete methods that are all forbidden to be overloaded in the future. In an extreme case:

@protect function foo3(a, b=1)
    a + b
end

All methods of foo3 with one or two arguments shall not be allowed to overload. I haven’t thought about how to incorporate (optional) keyword arguments into this syntax, since they are not supposed to participate in multiple dispatch, even though there are lingering complications of its practical behaviors. At a minimum, we can disallow keyword arguments in this syntax.

Enable (potentially) more code optimization

I will preface by saying that I don’t know much about the low-level (on the IR or LLVM level) optimization of Julia code, so please correct me if I am wrong. And please point out if there are any more potential benefits!

Based on my understanding of multiple dispatch, a global method table is created to store all specialized methods when a Julia process starts. If some methods can be marked as “cannot be overloaded”, these methods can potentially be stored in a separate table for more specialized optimization, e.g., inlining methods. Furthermore, this feature may also help with type inference, as it can implicitly preserve more information about methods defined by abstract-type arguments (note that the function body of foo3 is fixed).

Compatibility

Since this is proposed as an opt-in feature, it is disabled by default unless the syntax is actively used. Hence, I think it can be added to Julia without introducing breaking changes, assuming it is realizable in principle.

This is overall a rough sketch of how I imagine we could potentially have better control over method overloading (and underlying multiple dispatch) in Julia. Regardless of whether it is necessary or practical, I would like to use this thread to initiate a discussion within the community. Any related comments are welcome!

Thank you for your time!

see also: feature request: implement error for overloading "final" methods · Issue #31222 · JuliaLang/julia · GitHub

Just at JuliaCon 2026, Cody (@topolarity) mentioned a potential proposal to introduce a macro @final to seal a type/function you define in your package, so it cannot be extended with new constructors/methods. As far as I understand it, it is very similar to what @protect does. For context, the full talk was about type piracy, and it’s very well presented in my opinion.

If more people desire such a feature or can voice their opinions about it here, maybe we will see it (or a variant of it) in the future release of Julia sooner.

I think you’re misunderstanding how Julia works. Methods can already get inlined, even if they can be overloaded, and overloading doesn’t prevent type inference as long as the concrete input types are known. (This is very different from the final specifier in C++, which applies to types … all concrete Julia types are already “final” in this sense.)

For example, if I define the function f(x) = 2x, then when I call f(1) it compiles a specialized version of f for Int arguments that inlines the specialized * method for integers, exploit the fact that 2 is a constant, and in fact it will usually compile to a single “shift” instruction. And it infers that f yields an Int result from an Int input, which cascades to enable type inference (and inline-abilty) of anything that calls f. Julia will then re-use this compiled and inferred f(::Int)::Int for any other Int argument like f(2) or f(x) when x is known to be Int.

(It is essential for this that the concrete type Int is “final”. If you call f(x) where x was inferred to be Int, Julia knows that x cannot be some user-defined subclass of Int. This is why the optional final keyword is critical for inlining in C++.)

The problems introduced by type piracy are different, and are more about composability. If module C overloads functions defined by module A for types defined by module B, then it can unexpectedly affect code unrelated to C that is not using C’s types or functions. And it can trigger recompilation of other code. (Even things that are not type piracy can trigger recompilation, e.g. of type-unstable code, slowing module-load times. But this does not generally affect the performance of the resulting compiled code, especially in the type-inferred cases that Julia is designed to be fast on.)

This would prevent editing and recompiling methods within an interactive session, whether on packages we own or not; in other words, we’d have to restart the session for every method edit that got compiled, and we don’t have a way to save the fraction of the JIT-ed code we’d like to carry over. Developers obviously could omit @protected for development, but I don’t think they want to give that to their users either; anybody can tinker and submit PRs, and it’s easy to undo type piracy in a standard text editor for Revise to automatically reevaluate. I can see the case being made for preventing +(::Int, ::Int) definitions that crash the process (that Julia-compiled code is essentially as critical as the C core we already don’t change per process), but that’s a small fraction of base Julia and exceptionally rare elsewhere.

I don’t know what frankwswang meant by preserving more information for methods with abstract-type arguments because an unedited un-@protect-ed foo3 would provide the exact same body to the compiler, but it does remove one issue with return-type inference of runtime dispatches not going beyond 4 applicable methods. But there’s still the issue of inferring more methods, and manually restricting return types is probably easier to handle.

Yes, “final” functions could help inference somewhat with type-unstable code (which is not Julia’s forte); currently this is worked around by call-site type declarations. Another way of addressing this sort of thing for the common case of constructors and convert was proposed: Require constructors and `convert` to return objects of stated type? · Issue #42372 · JuliaLang/julia · GitHub … but of course, you couldn’t declare convert as “final”.

You can also use something like EnforcedTypeSignatureCallables.jl right now.

Each individual method (with a concrete argument signature) can, of course, be inlined, but this does not mean the function name they tie to can be inlined for a collection of heterogeneous input types, especially when automatic union splitting fails:

#> Tested on Julia 1.12.6 (OS: Windows, CPU: 32 × AMD Ryzen 9 9950X)

struct A end; struct B end; struct C end; struct D end; struct E end
foo(::A) = 1; foo(::B) = 2; foo(::C) = 3; foo(::D) = 4; foo(::E) = 5
const AtoE = Union{A, B, C, D, E}

@inline function foo_branch(x::Any)
    if     x isa A; (return foo(x))
    elseif x isa B; (return foo(x))
    elseif x isa C; (return foo(x))
    elseif x isa D; (return foo(x))
    elseif x isa E; (return foo(x))
    else throw(ArgumentError("Type of `x` is not supported"))
    end
end

function sum_foo_core(f::F, xs) where {F}
    s = 0
    for x in xs
        s += f(x)  #> dynamic dispatch every iteration for `f=foo`
    end
    s
end

sum_foo(xs) = sum_foo_core(foo, xs)
sum_foo_branch(xs) = sum_foo_core(foo_branch, xs)


using BenchmarkTools
using Random
Random.seed!(1234)

v1 = [(A(), B(), C(), D(), E())[rand(1:3)] for _ in 1:2^20]; #> Vector{Any}
@btime sum_foo($v1)        #> 23.983 ms (1048329 allocations: 16.00 MiB)
@btime sum_foo_branch($v1) #> 3.921 ms (0 allocations: 0 bytes)

In the above example, even though each foo method is trivially defined such that each isolated call can be easily inlined, when the function foo is called inside sum_foo, it cannot be inlined like foo_branch (as in sum_foo_branch), thus resulting in looking through a single global method table (Core.methodtable) and triggers dynamic dispatch. If we simply allow more aggressive union splitting, which can only be tuned universally through max_union_splitting, it often just offloads more stress onto compilation.

However, if we use a macro like @protect (or some keyword more tightly coupled to the core semantics of Julia) to seal the methods of foo, we can potentially separate them from the rest of the functions stored in Core.methodtable, so that they can be treated separately:

  1. They don’t need world-age tracking, since their implementation should be frozen.
  2. They can have their individual union-splitting threshold, such that automatic union-splitting rules (like foo_branch) can be applied custom to those “protected” functions.

And those potential benefits are, of course, in addition to the apparent protection against invalidations from type piracy that cause recompilations.

As you said, a developer can always wait until they finish developing the code and then add @protect to the functions they want to protect. This is certainly a side effect I personally am willing to compromise on.

Yes, that’s why I caveated my statement with the condition that the concrete input types are known. This is really the case that Julia is designed for, not collections of highly heterogeneous collections … most performance-sensitive code in Julia does not look like your example.

For limited sets of heterogeneous types, another possible approach is something like Unityper.jl, in which the types themselves are combined into a single type similar to union types in C. There have been other experiments like Virtual.jl that have been explored. Neither seems to have been pursued very far, but it’s encouraging that such experiments can be made without changing the core language implementation.

Note also that the union-splitting limit was eliminated for many single-dispatch calls in RFC: inference: remove union-split limit for linear signatures - Pull Request #37378 - JuliaLang/julia - GitHub … I’m not sure why this didn’t help your sum example?

As far as I understand, just knowing (every element inside) the set of concrete input types is not enough. What’s worse, even if the number of concrete call signatures for a caller (the method at a specific entry point) is within the limit of union splitting, it is still not enough (i.e., sufficient) to avoid dynamic dispatch:

#> Tested on Julia 1.12.6 (OS: Windows, CPU: 18 × 12th Gen Intel(R) Core(TM) i9-12900HK)
const ThreeSets{T<:Real} = Union{T, Vector{T}, NTuple{3, T}}

function foo(init::T, v::AbstractVector) where {T<:Real}
    mapreduce(x->(foo(zero(T), x)), +, v; init)
end

function foo(init::T, v::NTuple{3, T}) where {T<:Real}
    init + sum(v)
end

function foo(init::T, v::T) where {T<:Real}
    init+v
end

@inline function foo_branch(init::T, v::Any) where {T<:Real}
    if v isa AbstractVector
        res = init
        if !isempty(v)
            for x in v
                res += foo_branch(zero(T), x)
            end
        end
        return res
    elseif v isa T
        return (init + v)
    elseif v isa NTuple{3, T}
        return (init + sum(v))
    else
        throw(ArgumentError("`v` of type `$(typeof(v))` is not supported"))
    end
end

using BenchmarkTools
using Random

const ThreeOfF64 = ThreeSets{Float64}

n = 2^16
m = 20
v1 = rand(-10:1e-3:10, n)
v2 = [rand(-10:1e-3:10, i) for i in rand(1:m, n)]
v3 = [Tuple(rand(-10:1e-3:10, 3)) for i in rand(1:m, n)]
v4 = reduce(vcat, [(v1, v2, v3)[i][rand(1:m, 10)] for i in rand(1:3, max(n÷128, m))], init=ThreeOfF64[])

v = convert(Vector{Union{Vector{ThreeOfF64}, ThreeOfF64}}, vcat(v1, v2, v3))
Random.seed!(1234)
shuffle!(v)

@btime foo(0., $v)        #> 6.881 ms (196608 allocations: 3.00 MiB)
@btime foo_branch(0., $v) #> 2.684 ms (0 allocations: 0 bytes)

In the example above, I specifically recover the element-type information in the heterogeneous container v through convert. So there is no information loss before the type inference. Moreover, aside from typeof(v) being used as the sole possible second-argument type at the top-level call, there are only at most four concrete types (not surpassing the union splitting limit) for inner recursive callers of foo or foo_branch through single-dispatch calls. But the former still triggered dynamic dispatch. In my opinion, you cannot argue that the elements in v are still considered “highly heterogeneous.”

It is possible to recover almost all the performance loss by applying a function barrier for foo by defining a wrapper function sum_foo:

function foo_sum(f::F, v::AbstractVector, init::T) where {F, T}
    mapfoldl(x->f(zero(T), x), f, v; init)
end

@btime foo_sum($foo, $v, 0.) #> 2.797 ms (1 allocation: 16 bytes)

If anything, I think this drastic performance change may actually indicate that having a single global method table track all methods of a function can confuse the compiler, even though at each local scope the heterogeneity of argument signatures is relatively low.

All I’m saying is that, due to the complexity of the type hierarchy and inference mechanism in Julia, in realistic scenarios, it’s hard (but not always impossible) to argue exactly when the compiler will automatically apply optimizations like inlining or AOT compilation just based on the type stability of each defined method or the information about input types. Yes, there are common practices, and I am aware of what you mentioned in the reply to my OP. Maybe someone can provide stronger proof that a macro like @protect offers absolutely no benefit for improving automatic code inlining or the performance of dynamic dispatch. But until then, I remain cautiously hopeful.

Thank you for sharing the links to those libraries. I’ll check them out!

A union type is not a concrete type.

I refer Union conceptually (and approximately) as a set; its elements are concrete types:

julia> all(Base.isconcretetype, Base.uniontypes(Union{Vector{ThreeOfF64}, ThreeOfF64}))
true

You wouldn’t say “a set of real numbers” is a real number, would you?

That wouldn’t work for users however. What could work there is if the protection can be toggled (a higher-order function would actually be easier than trying to undo a macro call), but that obviously means the compiler can’t assume the protection is always on, and malicious code injection can easily turn it off (though if your process is exposed to arbitrary eval, unwise method overloading is frankly the least of your problems).

I’m pretty sure stevengj is talking about static knowledge of one specific sequence of concrete input types at the call site, given “most performance-sensitive code”. No need for union-splitting or dynamic dispatch there.

AFAIK this is not a thing.

If you just want this feature right now, you could actually generate a function like foo_branch with all known foo methods and annotated input types at the time. Doesn’t stop overloading though.

Could you elaborate on what you meant by this? The point of adding @protect is to disallow users from overloading the function being protected. In a sense, there is no actual use scenario for “the user,”; only the person who first defines the function can add @protect for their function (in the module they created), and any imported functions cannot be protected with @protect either.

I’m not sure how the (second) example I showed later does not fall into this statement. There is indeed a specific sequence of concrete input types for foo (when called for each element in v):

foo(::Float64, ::Float64)
...
foo(::Float64, ::Vector{Float64})
...
foo(::Float64, ::NTuple{3, Float64})
...
foo(::Float64, ::Vector{Union{NTuple{3, Float64}, Vector{Float64}, Float64}})
...

But when the compiler does type inference, it has to predict all the possible concrete-type call signatures (that’s how you do specialization for multiple dispatch), for which the types of second argument for foo ended up becoming a Union of concrete types (i.e., Union{Vector{ThreeOfF64}, ThreeOfF64})), to resemble the concept of a set of concrete types. I understand that, strictly speaking, Julia’s type system isn’t a rigorous mirror of sets in math, but at least in this case, it’s close enough to serve as an analogy.

I was using loose language here to refer to the optimization where a function in a local scope has finite branches that invoke different (but a finite number of) methods. I’m not sure if it already exists in the current version of Julia, if your response is confirming that such an optimization indeed does not exist for now (thanks!!), then I am hoping that with the help of @protect, the method table for a function (labeled by the function name) isolated and fixed; hence, it MAY be possible to implement such an optimization by the compiler, which is effectively an inline optimization (namely a “branch-like inline”). And I think this is exactly the point where I think @stevengj and I started talking through each other… I wasn’t trying to say his statement

was plain wrong (even though there are edge cases…); I meant his statement isn’t sufficient to conclude that no other, more exotic version of “inline” optimization is enabled by introducing @protect, which is my interpretation of his immediate (quote and) comment

followed by a short explanation of Julia’s typical inline mechanism based on specialization and type inference. This is also why I replied to his reply with an example demonstrating this “branch-like inline”:

Please don’t get me wrong, I’m not trying to make a request for making Julia immediately have the capability to automatically replace functions like foo with functions like foo_branch. Of course, one should try to avoid writing foo in the first place if they know their input types are heterogeneous. I was just trying to elaborate on what I meant by how @protect could have “good” side effects (freezing the method table of a function) that may potentially help with “branch-like inline.” It’s a theoretical and prototypical discussion.

The example you gave was not concrete input types, as I said. A union is not a concrete type.

(You cannot have an instance of a union type, so it is not concrete. It is an abstract type that happens, in your example, to comprise a finite number of concrete types.)

Sorry, I don’t really understand what point you are trying to make here.

Assuming you were referring to the type inference of the elements of v as the second example for foo, then yes, the inferenced type of foo’s second argument is indeed an Union, which is not a concrete type (BTW, it’s not an abstract type either, you can verify using Base.isabstracttype). I agree with you on that.

But I thought I’ve made myself clear that I was not trying to invalidate your comment on my discussion for potential inline benefit based on @protect by saying your comments on how typical inline works is wrong, I was trying to clarify that the type of “inline” I hope Julia can realize through @protect exactly falls out of the scenarios you discussed…

I hope this clarifies the miscommunication. English is not my first language, so I apologize if my wording is confusing.

There’s actually nothing stopping us from modifying open-source software however we want before parsing it, including removing @protect calls. Julia’s philosophy so far has been to make edits easier than going through the whole write-parse-compile-run cycle every time: besides redefining methods, we never had language-level access modifiers and recently gained rebinding of global constants. That’s because there are actually routine scenarios for the users: debug lines, fixing bugs, optimizations. The easier the edits, the easier the PRs.

To clarify something, a toggle isn’t an argument against @protect’s existence. In fact, it would be useful for stopping people naively overloading functions in a way that defies informal interfaces e.g. defining map(::typeof(myfunction), ::MyArray) instead of more basic interface methods like getindex. However, the case for overloading even those functions could still be made, and I’m not sure we really want a bunch of forks to evade @protect.

If v’s element type is a type union or any other abstract type, there is not one specific sequence of concrete input types for foo that can be statically inferred in the loop. Indeed, you point out all the different call signatures the compiler must consider. stevengj is strictly talking about fully concrete inference there, nothing to do with any of the abstract inference examples you want to be optimized. He brought it up to point out that Julia tends to be written with fully concrete inference and won’t benefit from these optimizations.

I think this is a “bad” argument to make. It basically applies to any open-standard and open-source programming language. Does that mean we should stop making language design more robust and safer? First, including @protect does not mean every function has to be protected. It’s an opt-in feature. Developers who encourage some of their package functions to be overloaded certainly will not put @protect in front of those functions. Second, it does provide valid protection for avoiding (some of) type piracy, just as the JuliaCon talk has presented:

Personally, I’m not against the idea of having a global (Julia process) parameter, e.g., --protect-function, that toggles @protect, just like how --check-bounds controls bound checking. I just don’t know how it may/should affect downstream compatibility. For example, if a module B overloads a function f protected by @protect from module A while disabling --protect-function, should this f method be valid for a user who uses B while enabling --protect-function? It can easily become messy, IMO.

What do you mean by “one specific sequence”? Is this a standard term used to describe argument-type inference in Julia? I was trying to understand it literally and intuitively: if you are at a call site f(::T) where T is always a fixed type, how can it be called “a sequence of concrete input types”? There’s only ever one input type (singular, not plural). I assumed that when you said a sequence, you meant there are multiple options for different concrete types (which is different from T just being an abstract type like AbstractVector), so it can be a finite, non-empty Union representing a finite set of concrete types. Maybe you mean a “time-sequence” like “T, T, T, …”? When stevengj said

I also assumed he included the cases for Union of concrete types as input types. But of course, as I said, English is not my first language, if both of you were essentially saying:

An invocation f(a::T) where T is already inferred as a concrete type by the compiler, then f(a::T) may still be inlined even if it is overloaded (i.e., same type signature with different function body) at a different world age.

Then, I’m totally fine with that. Again, I was just thinking about the “Union of concrete types” input inference cases where the reliability of union splitting is not very predictable in complex scenarios. And I thought @protect may help with that.

I would be because I’m imagining the toggle-off to be used in packages, not just live development. If a package developer really believes they’re overloading a @protect-ed method correctly, which is possible without type piracy e.g. foo2(a::MyFloat, b::Int) in the context of your initial post, why force them to fork? You’re right that the toggle cannot stay off after the package module is evaluated though. How that API looks is unclear to me, but @unprotect could expand to toggling off the protection just for that one method definition or a begin block.

I mean sequence to refer to the tuple of positional arguments. f(::T) would have a sequence of length 1.

I wasn’t saying this, and I actually don’t know what this means.

Thanks for the explanation. So here is my latest understanding of your usage of “sequence” in your statement:

You were saying that for a function with N argument(s) (N>=1), the values of these arguments at a scope can be represented as a args::Tuple, namely a sequence. Consequently, the type inference for all these input values can be represented by the type inference of args: typeof(args).

just means that the types of the elements of args can all be inferred as concrete types (at compile time). Equivalently, typeof(args) is also inferred as a concrete type. Please feel free to correct me if I misunderstood you again.

This is my attempt to summarize what @stevengj originally tried to explain about the connection between inlined methods and “concrete-type” inference in the simplest case (single dispatch):