Skip to content

Commit

Permalink
inference: add reflection utility for exception type analysis
Browse files Browse the repository at this point in the history
This commit defines functions that mirror our tools for analyzing return
types and computational effects.

The key point to discuss is that this commit introduces two functions:
`Base.exception_types` and `Base.exception_type`. `Base.exception_types`
acts like `Base.return_types`, giving a list of exception types for each
method that matches with the given call signature. On the other hand,
`Base.exception_type` is akin to `Base.infer_effects`, returning a
single exception type that covers all potential outcomes entailed by the
given call signature. I personally lean towards the latter for its
utility, particularly in testing scenarios, but I included
`exception_types` too for consistency with `return_types`.
I'd welcome any feedback on this approach.
  • Loading branch information
aviatesk committed Nov 20, 2023
1 parent 67161a3 commit 1b7362a
Show file tree
Hide file tree
Showing 2 changed files with 264 additions and 21 deletions.
258 changes: 237 additions & 21 deletions base/reflection.jl
Original file line number Diff line number Diff line change
Expand Up @@ -1525,7 +1525,7 @@ internals.
- `world::UInt = Base.get_world_counter()`: optional, controls the world age to use
when looking up methods, use current world age if not specified.
- `interp::Core.Compiler.AbstractInterpreter = Core.Compiler.NativeInterpreter(world)`:
optional, controls the abstract interpreter to use, use the native interpreter if not specified.
optional, controls the abstract interpreter to use, use The abstract interpreter if not specified.
# Example
Expand Down Expand Up @@ -1628,7 +1628,7 @@ internals.
- `world::UInt = Base.get_world_counter()`: optional, controls the world age to use
when looking up methods, use current world age if not specified.
- `interp::Core.Compiler.AbstractInterpreter = Core.Compiler.NativeInterpreter(world)`:
optional, controls the abstract interpreter to use, use the native interpreter if not specified.
optional, controls the abstract interpreter to use, use The abstract interpreter if not specified.
- `optimize_until::Union{Integer,AbstractString,Nothing} = nothing`: optional,
controls the optimization passes to run.
If it is a string, it specifies the name of the pass up to which the optimizer is run.
Expand Down Expand Up @@ -1692,15 +1692,40 @@ function code_ircode_by_type(
return asts
end

function _builtin_return_type(interp::Core.Compiler.AbstractInterpreter,
@nospecialize(f::Core.Builtin), @nospecialize(types))
argtypes = Any[to_tuple_type(types).parameters...]
rt = Core.Compiler.builtin_tfunction(interp, f, argtypes, nothing)
return Core.Compiler.widenconst(rt)
end

function _builtin_effects(interp::Core.Compiler.AbstractInterpreter,
@nospecialize(f::Core.Builtin), @nospecialize(types))
argtypes = Any[to_tuple_type(types).parameters...]
rt = Core.Compiler.builtin_tfunction(interp, f, argtypes, nothing)
return Core.Compiler.builtin_effects(Core.Compiler.typeinf_lattice(interp), f, argtypes, rt)
end

"""
Base.return_types(f::Function, types::DataType=default_tt(f);
world::UInt=get_world_counter(), interp::NativeInterpreter=Core.Compiler.NativeInterpreter(world))
Base.return_types(f, types=default_tt(f);
world::UInt=get_world_counter(),
interp::NativeInterpreter=Core.Compiler.NativeInterpreter(world)) -> rts::Vector{Any}
Return a list of possible return types for a given function `f` and argument types `types`.
The list corresponds to the results of type inference on all the possible method match
candidates for `f` and `types` (see also [`methods(f, types)`](@ref methods).
# Arguments
- `f`: The function to analyze.
- `types` (optional): The argument types of the function. Defaults to the default tuple type of `f`.
- `world` (optional): The world counter to use for the analysis. Defaults to the current world counter.
- `interp` (optional): The abstract interpreter to use for the analysis. Defaults to a new `Core.Compiler.NativeInterpreter` with the specified `world`.
# Returns
- `rts::Vector{Any}`: The list of return types that are figured out by inference on
methods matching with the given `f` and `types`. The list's order matches the order
returned by `methods(f, types)`.
# Example
```julia
Expand Down Expand Up @@ -1734,13 +1759,11 @@ function return_types(@nospecialize(f), @nospecialize(types=default_tt(f));
_, rt = only(code_typed_opaque_closure(f))
return Any[rt]
end

if isa(f, Core.Builtin)
argtypes = Any[to_tuple_type(types).parameters...]
rt = Core.Compiler.builtin_tfunction(interp, f, argtypes, nothing)
return Any[Core.Compiler.widenconst(rt)]
rt = _builtin_return_type(interp, f, types)
return Any[rt]
end
rts = []
rts = Any[]
tt = signature_type(f, types)
matches = _methods_by_ftype(tt, #=lim=#-1, world)::Vector
for match in matches
Expand All @@ -1752,32 +1775,228 @@ function return_types(@nospecialize(f), @nospecialize(types=default_tt(f));
end

"""
infer_effects(f, types=default_tt(f); world=get_world_counter(), interp=Core.Compiler.NativeInterpreter(world))
Base.exception_types(f, types=default_tt(f);
world::UInt=get_world_counter(),
interp::NativeInterpreter=Core.Compiler.NativeInterpreter(world)) -> excts::Vector{Any}
Return a list of possible exception types for a given function `f` and argument types `types`.
The list corresponds to the results of type inference on all the possible method match
candidates for `f` and `types` (see also [`methods(f, types)`](@ref methods).
It works like [`Base.return_types`](@ref), but it infers the exception types instead of the return types.
# Arguments
- `f`: The function to analyze.
- `types` (optional): The argument types of the function. Defaults to the default tuple type of `f`.
- `world` (optional): The world counter to use for the analysis. Defaults to the current world counter.
- `interp` (optional): The abstract interpreter to use for the analysis. Defaults to a new `Core.Compiler.NativeInterpreter` with the specified `world`.
# Returns
- `excts::Vector{Any}`: The list of exception types that are figured out by inference on
methods matching with the given `f` and `types`. The list's order matches the order
returned by `methods(f, types)`.
# Example
```julia
julia> throw_if_number(::Number) = error("number is given")
julia> throw_if_number(::Any) = nothing
julia> Base.exception_types(throw_if_number, (Int,))
1-element Vector{Any}:
ErrorException
julia> methods(throw_if_number, (Any,))
# 2 methods for generic function "throw_if_number" from Main:
[1] throw_if_number(x::Number)
@ REPL[60]:1
[2] throw_if_number(::Any)
@ REPL[61]:1
julia> Base.exception_types(throw_if_number, (Any,))
2-element Vector{Any}:
ErrorException # the result of inference on `throw_if_number(::Number)`
Union{} # the result of inference on `throw_if_number(::Any)`
```
!!! warning
The `exception_types` function should not be used from generated functions;
doing so will result in an error.
"""
function exception_types(@nospecialize(f), @nospecialize(types=default_tt(f));
world::UInt=get_world_counter(),
interp::Core.Compiler.AbstractInterpreter=Core.Compiler.NativeInterpreter(world))
(ccall(:jl_is_in_pure_context, Bool, ()) || world == typemax(UInt)) &&
error("code reflection cannot be used from generated functions")
if isa(f, Core.OpaqueClosure)
return Any[Any] # TODO
end
if isa(f, Core.Builtin)
effects = _builtin_effects(interp, f, types)
exct = Core.Compiler.is_nothrow(effects) ? Union{} : Any
return Any[exct]
end
excts = Any[]
tt = signature_type(f, types)
matches = _methods_by_ftype(tt, #=lim=#-1, world)::Vector
for match in matches
match = match::Core.MethodMatch
frame = Core.Compiler.typeinf_frame(interp, match, #=run_optimizer=#false)
if frame === nothing
exct = Any
else
exct = Core.Compiler.widenconst(frame.result.exc_result)
end
push!(excts, exct)
end
return excts
end

may_throw_methoderror(matches::Core.Compiler.MethodLookupResult) =
matches.ambig || !any(match::Core.MethodMatch->match.fully_covers, matches.matches)

"""
Base.exception_type(f, types=default_tt(f);
world::UInt=get_world_counter(),
interp::Core.Compiler.AbstractInterpreter=Core.Compiler.NativeInterpreter(world)) -> exct::Type
Returns the type of exception potentially thrown by the function call specified by `f` and `types`.
# Arguments
- `f`: The function to analyze.
- `types` (optional): The argument types of the function. Defaults to the default tuple type of `f`.
- `world` (optional): The world counter to use for the analysis. Defaults to the current world counter.
- `interp` (optional): The abstract interpreter to use for the analysis. Defaults to a new `Core.Compiler.NativeInterpreter` with the specified `world`.
# Returns
- `exct::Effects`: The inferred type of exception that can be thrown by the function call
specified by the given call signature.
!!! note
Note that, different from [`Base.exception_types`](@ref), this doesn't give you the list
exception types for every possible matching method with the given `f` and `types`.
It provides a single exception type, taking into account all potential outcomes of
any function call entailed by the given signature type.
# Example
```julia
julia> function f1(x)
y = x * 2
return y
end;
julia> Base.exception_type(f1, (Int,))
Union{}
```
The exception inferred as `Union{}` indicates that `f1(::Int)` will not throw any exception.
```julia
julia> function f2(x::Int)
y = x * 2
return y
end;
julia> Base.exception_type(f2, (Integer,))
MethodError
```
This case is pretty much the same as with `f1`, but there's a key difference to note. For
`f2`, the argument type is limited to `Int`, while the argument type is given as `Tuple{Integer}`.
Because of this, taking into account the chance of the method error entailed by the call
signature, the exception type is widened to `MethodError`.
!!! warning
The `exception_type` function should not be used from generated functions;
doing so will result in an error.
"""
function exception_type(@nospecialize(f), @nospecialize(types=default_tt(f));
world::UInt=get_world_counter(),
interp::Core.Compiler.AbstractInterpreter=Core.Compiler.NativeInterpreter(world))
(ccall(:jl_is_in_pure_context, Bool, ()) || world == typemax(UInt)) &&
error("code reflection cannot be used from generated functions")
if isa(f, Core.OpaqueClosure)
return Any # TODO
end
if isa(f, Core.Builtin)
effects = _builtin_effects(interp, f, types)
return Core.Compiler.is_nothrow(effects) ? Union{} : Any
end
tt = signature_type(f, types)
matches = Core.Compiler.findall(tt, Core.Compiler.method_table(interp))
if matches === nothing
# unanalyzable call, i.e. the interpreter world might be newer than the world where
# the `f` is defined, return the unknown exception type
return Any
end
exct = Union{}
if may_throw_methoderror(matches)
# account for the fact that we may encounter a MethodError with a non-covered or ambiguous signature.
exct = Core.Compiler.tmerge(exct, MethodError)
end
for match in matches.matches
match = match::Core.MethodMatch
frame = Core.Compiler.typeinf_frame(interp, match, #=run_optimizer=#false)
frame === nothing && return Any
exct = Core.Compiler.tmerge(exct, Core.Compiler.widenconst(frame.result.exc_result))
end
return exct
end

"""
infer_effects(f, types=default_tt(f);
world::UInt=get_world_counter(),
interp::Core.Compiler.AbstractInterpreter=Core.Compiler.NativeInterpreter(world)) -> effects::Effects
Compute the `Effects` of a function `f` with argument types `types`. The `Effects` represents the computational effects of the function call, such as whether it is free of side effects, guaranteed not to throw an exception, guaranteed to terminate, etc. The `world` and `interp` arguments specify the world counter and the native interpreter to use for the analysis.
Returns the possible computation effects of the function call specified by `f` and `types`.
# Arguments
- `f`: The function to analyze.
- `types` (optional): The argument types of the function. Defaults to the default tuple type of `f`.
- `world` (optional): The world counter to use for the analysis. Defaults to the current world counter.
- `interp` (optional): The native interpreter to use for the analysis. Defaults to a new `Core.Compiler.NativeInterpreter` with the specified `world`.
- `interp` (optional): The abstract interpreter to use for the analysis. Defaults to a new `Core.Compiler.NativeInterpreter` with the specified `world`.
# Returns
- `effects::Effects`: The computed effects of the function call.
- `effects::Effects`: The computed effects of the function call specified by the given call signature.
See the documentation of [`Effects`](@ref Core.Compiler.Effects) or [`Base.@assume_effects`](@ref)
for more information on the various effect properties.
!!! note
Note that, different from [`Base.return_types`](@ref), this doesn't give you the list
effect analysis results for every possible matching method with the given `f` and `types`.
It provides a single effect, taking into account all potential outcomes of any function
call entailed by the given signature type.
# Example
```julia
julia> function foo(x)
julia> function f1(x)
y = x * 2
return y
end;
julia> effects = Base.infer_effects(foo, (Int,))
julia> Base.infer_effects(f1, (Int,))
(+c,+e,+n,+t,+s,+m,+i)
```
This function will return an `Effects` object with information about the computational effects of the function `foo` when called with an `Int` argument. See the documentation for `Effects` for more information on the various effect properties.
This function will return an `Effects` object with information about the computational
effects of the function `f1` when called with an `Int` argument.
```julia
julia> function f2(x::Int)
y = x * 2
return y
end;
julia> Base.infer_effects(f2, (Integer,))
(+c,+e,!n,+t,+s,+m,+i)
```
This case is pretty much the same as with `f1`, but there's a key difference to note. For
`f2`, the argument type is limited to `Int`, while the argument type is given as `Tuple{Integer}`.
Because of this, taking into account the chance of the method error entailed by the call
signature, the `:nothrow` bit gets tainted.
!!! warning
The `infer_effects` function should not be used from generated functions;
Expand All @@ -1793,10 +2012,7 @@ function infer_effects(@nospecialize(f), @nospecialize(types=default_tt(f));
(ccall(:jl_is_in_pure_context, Bool, ()) || world == typemax(UInt)) &&
error("code reflection cannot be used from generated functions")
if isa(f, Core.Builtin)
types = to_tuple_type(types)
argtypes = Any[types.parameters...]
rt = Core.Compiler.builtin_tfunction(interp, f, argtypes, nothing)
return Core.Compiler.builtin_effects(Core.Compiler.typeinf_lattice(interp), f, argtypes, rt)
return _builtin_effects(interp, f, types)
end
tt = signature_type(f, types)
matches = Core.Compiler.findall(tt, Core.Compiler.method_table(interp))
Expand All @@ -1806,7 +2022,7 @@ function infer_effects(@nospecialize(f), @nospecialize(types=default_tt(f));
return Core.Compiler.Effects()
end
effects = Core.Compiler.EFFECTS_TOTAL
if matches.ambig || !any(match::Core.MethodMatch->match.fully_covers, matches.matches)
if may_throw_methoderror(matches)
# account for the fact that we may encounter a MethodError with a non-covered or ambiguous signature.
effects = Core.Compiler.Effects(effects; nothrow=false)
end
Expand Down
27 changes: 27 additions & 0 deletions test/reflection.jl
Original file line number Diff line number Diff line change
Expand Up @@ -1047,6 +1047,33 @@ ambig_effects_test(a, b) = 1
@test (Base.infer_effects(Core.Intrinsics.mul_int, ()); true) # `intrinsic_effects` shouldn't throw on empty `argtypes`
end

@testset "exception_type[s]" begin
# generic functions
@test Base.exception_type(issue41694, (Int,)) == only(Base.exception_types(issue41694, (Int,))) == ErrorException
@test Base.exception_type((Int,)) do x
issue41694(x)
end == Base.exception_types((Int,)) do x
issue41694(x)
end |> only == ErrorException
@test Base.exception_type(issue41694) == only(Base.exception_types(issue41694)) == ErrorException # use `default_tt`
let excts = Base.exception_types(maybe_effectful, (Any,))
@test any(==(Any), excts)
@test any(==(Union{}), excts)
end
@test Base.exception_type(maybe_effectful, (Any,)) == Any
# `exception_type` should account for MethodError
@test Base.exception_type(issue41694, (Float64,)) == MethodError # definitive dispatch error
@test Base.exception_type(issue41694, (Integer,)) == Union{MethodError,ErrorException} # possible dispatch error
@test Base.exception_type(f_no_methods) == MethodError # no possible matching methods
@test Base.exception_type(ambig_effects_test, (Int,Int)) == MethodError # ambiguity error
@test Base.exception_type(ambig_effects_test, (Int,Any)) == MethodError # ambiguity error
# builtins
@test Base.exception_type(typeof, (Any,)) === only(Base.exception_types(typeof, (Any,))) === Union{}
@test Base.exception_type(===, (Any,Any)) === only(Base.exception_types(===, (Any,Any))) === Union{}
@test (Base.exception_type(setfield!, ()); Base.exception_types(setfield!, ()); true) # `exception_type[s]` shouldn't throw on empty `argtypes`
@test (Base.exception_type(Core.Intrinsics.mul_int, ()); Base.exception_types(Core.Intrinsics.mul_int, ()); true) # `exception_type[s]` shouldn't throw on empty `argtypes`
end

@test Base._methods_by_ftype(Tuple{}, -1, Base.get_world_counter()) == Any[]
@test length(methods(Base.Broadcast.broadcasted, Tuple{Any, Any, Vararg})) >
length(methods(Base.Broadcast.broadcasted, Tuple{Base.Broadcast.BroadcastStyle, Any, Vararg})) >=
Expand Down

0 comments on commit 1b7362a

Please sign in to comment.