-
-
Notifications
You must be signed in to change notification settings - Fork 5.5k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
julep: "chain of custody" error handling #7026
Comments
+1 for the syntax addition. I have to admit that I am not sold on the idea of expected and unexpected exceptions. I would find it rather "unexpected" if I indicate to catch something that is then not catched ;-) For me the real issue in such situations i that the exception hierarchy is not well designed and/or that the wrong exception is used from the exception hierarchy. |
The problem with typed exceptions is that they give you a sense of precision, but they're not really precise at all. Making the exception part of the type of a function is a good idea – that part Java got right. What's not so good is forcing the caller to handle every kind of exception a function it uses can throw. Here's a motivating scenario: let's say you implement a new kind of AbstractVector – say a kind of sparse vector or something – let's call it SpArray. Internally, it stores values in normal Arrays. Someone is using this and finds that sometimes they end up making indexing errors – they use a typed catch block to handle this. But you've made a programming error in implementing the SpArray type and it's actually SpArray that's encountering the indexing error internally because of a mistake. But this is caught and treated as if was a user-level indexing error. With this proposal, that situation won't occur because at the point where the SpArray implementation is incorrect and causes an indexing error, there is no |
I absolutely see your point that it can lead to hard to find bugs if one try/catches too generic exceptions and I have been running into this myself in C++ a lot. ("Oh this throws exceptions all the time. Put To play around with your example maybe this should be handled by throwing |
That's a common solution offered in languages with exception hierarchies and typed catch, but I don't buy it. The logical extreme of that approach is to have an error type for almost every single location where an exception can be thrown. At that point, what you're really doing is labeling individual exception locations and using labels to catch things – albeit labels that are carefully arranged into a hierarchy. That need for a carefully arranged hierarchy to make the approach tenable is also fishy – and easily abused. I came up with this approach by thinking about how to compromise between a broad classification scheme with a set of standard exception types and individual labels for exception locations. |
Yes you are absolutely right. Exception hierarchies have the potential to get very specific up to the point that specific lines get specific error types. But as one subtypes from broader exception types I don't see the issue with that. But maybe I just have bad taste to not think this is fishy :-) |
Talking about this as "labeling locations" gets me thinking: would it be possible just to label the locations? Every exception could consist of an exception object and a label (symbol). The SpArray library could throw |
You're right that using typed exceptions correctly in Java is annoying. Everyone throws RuntimeException subclases, and I've trained myself to see the Exception type as primarily informational, useful only for logging. But my fear is, annotating exceptional (?) call sites all the way down is going to turn out to be equally tedious. Is this something that is easy enough to reason about and implement, so that it will be widely practiced? |
@JeffBezanson – I thought about just labeling locations, but it seems too granular. Sometimes different locations throw the same error, no? Also, how is that different from having an exception type for every location? I guess you wouldn't have to declare the types, but you also wouldn't get any type hierarchy. How would labels be scoped? By module? |
Yes, you'd probably want some kind of scoping, which remains to be worked out. But different locations could in fact throw the same error; in my overly-simple formulation they could just use the same symbol. The idea is that exception and origin are orthogonal: what and where-from. "What" is the exception type hierarchy, which describes problems, and "where-from" is the locations, which might follow the module hierarchy, or have no hierarchy. |
@aviks – I think this should actually be pretty rare. Having a "throws" annotation is essentially saying "throwing this error is part of the official API of this function". Currently we basically consider all exceptions to be runtime errors, so anything is real problem and not part of the functions official behavior. That would continue to be the default. Only when you decide that something like BoundsErrors or KeyError is part of the official interface of AbstractArray or Associative would you put |
The labeling idea is interesting. Still it increases the dimensionality of the dispatch mechanism from a one-dimensional to a two-dimensional thing. I am all for putting more context into exceptions. In C# for instance one usually also gets the line numbers where the exception was thrown. Further when debugging exceptions I found it very useful to have exception chains, i.e. when an exception is rethrown (as a different exception type) the original exception is put as an "inner exception" to the outer exception. My own experience is that in small projects and research code exceptions are mainly an error mechanism for debugging "not yet correct" code. In these situations one will hardly use try/catch and reason about what exception to catch. The intensive use of the |
I am in the process of porting my Tcl AWS library to Julia as a way to learn Julia, (and so that I can then experiment with implementing projects reliant on AWS in Julia). In a first-pass of the Julia docs, I saw try/catch/finally and made a mental check "that is supported". In distributed, networked systems individual nodes and connections can fail there are propagation delays, data models are eventually-consistent. Clear, robust, exception handling is a must. I want to write code that assumes everything is reliable, immediately globally updated etc and deal with the exceptional glitches in one place in high-level retry or conflict resolution loops. I am not a fan of type-based exception handling, having had the misfortune of working with Java etc. Tcl has no types, so exceptions are trapped based by matching an error-code prefix. An error-code in Tcl is just a list. e.g. proc get {url} {
...
throw {HTTP 404} "Error: $url Not Found"
...
throw [list HTTP 300 $location] "Error: $url has moved to $location"
...
}
try {
get $url
} trap {HTTP 404} {} {
puts "get can't find $url"
} trap {HTTP 300} {message info} {
set location [lindex $info 2]
get $location
}
# Ignore missing dict key...
proc lazy_get {dict key} {
try {
dict get $mydict "key"
} trap {TCL LOOKUP DICT} {} {}
}
# Catch exit status of shell command...
retry count 2 {
exec ./create_queue.tcl "foobar"
} trap EX_TEMPFAIL {} {
after 60000
} Every error (exception) in a Tcl program is supposed to have a human-readable error message and a machine-readable errorcode. The source of an error can include as much or as little machine-readable info as it likes. Safe exception handling is achieved by trapping only sufficiently specific errorcodes. Of course you can shoot yourself in the foot by writing "trap {}" if you want to... http://www.tcl.tk/man/tcl8.6/TclCmd/try.htm I think using types to match exceptions isn't great. But since Julia matches methods by type as a core feature, perhaps it makes sense for Julia. I would prefer something like passing a Dict() to throw and having a convenient syntax for catching only exceptions that match a dictionary pattern. The key thing is readability of the try/catch code. Below are some fragments of Tcl code that I am face with porting to Julia. try {
set web_user [create_aws_iam_user $aws $region-web-user]
} trap EntityAlreadyExists {} {
delete_aws_iam_user_credentials $aws $region-web-user
set web_user [create_aws_iam_user_credentials $aws $region-web-user]
} retry count 4 {
set res [aws_sqs $aws CreateQueue QueueName $name {*}$attributes]
} trap QueueAlreadyExists {} {
delete_aws_sqs_queue [aws_sqs_queue $aws $name]
} trap AWS.SimpleQueueService.QueueDeletedRecently {} {
puts "Waiting 1 minute to re-create SQS Queue \"$name\"..."
after 60000
} proc fetch {bucket key {byte_count {}}} {
if {$byte_count ne {}} {
set byte_count [list Range bytes=0-$byte_count]
}
try {
s3 get $::aws $bucket $key {*}$byte_count
} trap NoSuchKey {} {
} trap AccessDenied {} {}
} proc aws_ec2_associate_address {ec2 elastic_ip} {
puts "Assigning Elastic IP: $elastic_ip"
retry count 3 {
aws_ec2 $ec2 AssociateAddress InstanceId [get $ec2 id] \
PublicIp $elastic_ip
} trap InvalidInstanceID {} {
after [expr {$count * $count * 1000}]
}
}
proc create_aws_s3_bucket {aws bucket} {
try {
aws_rest $aws PUT $bucket Content-Type text/plain Content [subst {
<CreateBucketConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<LocationConstraint>[aws_bucket_region $bucket]</LocationConstraint>
</CreateBucketConfiguration>
}]
} trap BucketAlreadyOwnedByYou {} {}
} proc delete_aws_sqs_queue {queue} {
try {
aws_sqs $queue DeleteQueue
} trap AWS.SimpleQueueService.NonExistentQueue {} {}
} proc aws_s3_exists {aws bucket path} {
try {
aws_s3_get $aws $bucket $path Range bytes=0-0
return 1
} trap NoSuchKey {} {
} trap AccessDenied {} {}
return 0
} retry count 3 {
return [$command $aws {*}$args]
} trap {TCL LOOKUP DICT AWSAccessKeyId} {} {
set aws [get_aws_ec2_instance_credentials $aws]
} trap ExpiredToken {message info} {
if {![exists ::oc_aws_ec2_instance_credentials]} {
return -options $info $message
}
puts "Refreshing EC2 Instance Credentials..."
set aws [get_aws_ec2_instance_credentials $aws -force-refresh]
} try {
aws_iam $aws DeleteInstanceProfile InstanceProfileName $name
} trap NoSuchEntity {} {}
set response [aws_iam $aws CreateInstanceProfile \
InstanceProfileName $name \
Path $path] |
This seems like an edge case, if you keep the try block small (e.g. just the line you're expecting a failure) it shouldn't be the case... if it's critically different why not subclass BazError? +1, a syntax for typed exceptions would be great. IMO catch err::BazError
# deal with *expected* BazError only
catch err::FooError
# deal with *expected* FooError only
end |
The syntax proposed by @StefanKarpinski is really just a more compact syntactic sugar for a try block around the important line (or is it intended to apply to an expression?) which passes the exception to the outer catch.
You can't when its thrown by a library you called, not your code. The concept of identifying several small regions where "it is understood that this may throw xxxerror, and I'm prepared to handle it in a common handler" seems a good idea, but as currently proposed this is likely to surprise those coming from other languages (who don't RTFM because they expect all languages to be the same :) This also doesn't provide for one of the traditional use-cases where you want to catch some types of errors no matter where they occur, but let the others go: try
a big fast but flakey algorithm
catch err::matherror # oh no underflowed, overflowed or divided by zero
alternative slow algorithm
end |
Even if the try block only calls a single function, that function can do absolutely anything and throw any kind of exception for many different reasons. In generic code this is particularly bad since you don't really know which implementation of the function you're calling is going to be invoked or how it's implemented. Of course, you shouldn't have to know this to use it correctly – and that's precisely the problem with type-based exception handling: there's massive abstraction leakage between the implementation of the function being trapped and how to trap it. Let's say you're doing You could introduce new error types, but that's just completely annoying and impractical. Let's say I add a new subtype of |
@samoconnor – I really don't think try/catch is a good mechanism for handling that kind of networking timeout situation. I'm not sure what a better mechanism is, but this doesn't really feel right. In particular, you probably want to be able to retry things, which try/catch does not handle. |
A networking timeout situation is usually handled by a state machine. We have gotos with labels now which makes writing state machines easier. |
@StefanKarpinski - Re: "retry", note that several of my examples above use a "retry" variant of "try". Viral Shah said in a separate conversation "The short answer to your question is yes, it is easy to do retry with Julia’s macros". So I'm assuming that "retry" can be made to work as a seamless "try" variant in Julia. Re: "network timeouts", that is not really what my examples above address. I'm dealing with timeouts like this:
I have no choice here but to use try/catch because the underlying sockets library throws UVError. It might be nice to simplify the catch code a little
or maybe
... the "if" syntax would the allow:
Re: typing of exceptions, note that in my examples above there is no mention of trapping timeout exemptions. The things I'm trapping are all well-defined and precise. e.g. EntityAlreadyExists, AWS.SimpleQueueService.QueueDeletedRecently, AccessDenied, ExpiredToken, {TCL LOOKUP DICT AWSAccessKeyId}, BucketAlreadyOwnedByYou. While it is true that there is code out there that makes a mess of exception meta-data (I've written some android code) there are plenty of APIs, e.g. AWS, that have robust exception identification. It is essential to have an out-of-band, stack-frame-jumping, exception mechanism in complex code that sits on top of imperfect web services. The alternative is that all of the code is dominated by deciding what error information needs to be passed back up the stack and how far. As an example of converting HTTP exceptions, In my Julia AWS library I currently do this:
and
I think I'm too new to Julia to contribute great solutions, but I hope that my real-world examples help in some way... |
Following up on: "In particular, you probably want to be able to retry things, which try/catch does not handle." I've managed to figure out enough macro-fu to implement an exception handling retry loop. On the first n-1 attempts, the catch block has the opportunity to "@Retry". If the catch block does not call @Retry, the error is rethrown automatically. On the nth attempt the try block is executed naked (without a catch block) so errors are passed up. Example follows... (please let me know if my posts on this issue are too off-topic, and/or where a better place to post would be.) # Try up to 4 times to get a token.
# Retry when we get a "Busy" or "Throttle" exception.
# No retry or catch if we get a "Crash" exception (or no exception).
@with_retry_limit 4 try
println("Trying to get token...")
println("Token: " * get_token())
catch e
if e.msg == "Busy"
println("Got " * string(e) * ", try again...\n")
@retry
elseif e.msg == "Throttle"
println("Backing off...\n")
sleep(1 + rand())
@retry
end
end
# Unreliable operation simulator.
# Often busy, sometimes asks us to back off, sometimes crashes.
function get_token()
if rand() > 0.2
error("Busy")
end
if rand() > 0.8
error("Throttle")
end
if rand() > 0.9
error("Crash")
end
return "12345"
end
# Implementation of retry
macro with_retry_limit(max::Integer, try_expr::Expr)
@assert string(try_expr.head) == "try"
# Split try_expr into component parts...
(try_block, exception, catch_block) = try_expr.args
# Insert a rethrow() at the end of the catch_block...
push!(catch_block.args, :(rethrow($exception)))
# Build retry expression...
retry_expr = quote
# Loop one less than "max" times...
for i in [1 : $max - 1]
# Execute the "try_expr".
# It can do "continue" if it wants to retry...
$(esc(try_expr))
# Only get to here if "try_expr" executed cleanly...
return
end
# On the last of "max" attempts, execute the "try_block" naked
# so that exceptions get thrown up the stack...
$(esc(try_block))
end
end
# Conveniance "@retry" keyword...
macro retry() :(continue) end
|
Here is an attempt to avoid having to put rethrow(e) at the end of a catch block. This is intended both as a way to remove a bit of noise from the code, and as a safety net to avoid accidentally catching unintended exceptions. I think the thing that made me most scared about Julia's "catch" at first was that everything is caught by default, then you have to be really careful to rethrow() the right exceptions. #!/Applications/Julia-0.3.0-rc1-a327b47bbf.app/Contents/Resources/julia/bin/julia
# Re-write "try_expr" to provide an automatic rethrow() safety net.
# The catch block can suppress the rethrow() by doing "e = nothing".
macro safetynet(try_expr::Expr)
@assert string(try_expr.head) == "try"
(try_block, exception, catch_block) = try_expr.args
push!(catch_block.args, :(isa($exception, Exception) && rethrow($exception)))
return try_expr
end
@safetynet try
d = {"Foo" => "Bar"}
println("Foo" * d["Foo"])
println("Foo" * d["Bar"])
@assert false # Not reached.
catch e
if isa(e, KeyError) && e.key == "Bar"
println("ignoring: " * string(e))
e = nothing
end
end
println("Done!") Question about macros, is there any way I can omit the "try" keyword and have the macro insert it for me? e.g. @safetry
...
catch
...
end I can't figure out if this is possible with Julia's macro system. |
@StefanKarpinski Just for clarification, I take it that the "chain of custody" works on a per-method basis? So to modify your original example: function test(x)
try
# tomfoolery
foo(x) throws BazError
# highjinks
catch e::BazError
# deal with BazError
end
end
function foo(x::Number)
bar(x) throws BazError
end
function foo(x::String)
bar(x)
end
function bar(a)
throw BazError()
end test(1) catches the error, but test("foo") doesn't? |
Also, there's at least one place this would be useful in Base, in the display code. Checking the function that threw the exception improves the situation but is still pretty brittle, and it sounds like this proposal would solve that problem. |
@one-more-minute, yes, that's how I think it should work. In other words even though Also, +1 for "tomfoolery" and "hijinks" :-) |
@samoconnor – I think that handling this kind of complex I/O error is a really important problem but I also think it's beyond the scope of this issue, which is pretty focused: it solves just the problem of trapping the right errors and not accidentally trapping other ones. |
@JeffBezanson, so here's what I think is wrong with the type and/or label idea. Let's say you have code that expects an julia> a = [1,2,3]
3-element Array{Int64,1}:
1
2
3
julia> a[4]
ERROR: BoundsError()
in anonymous at ./inference.jl:365 (repeats 2 times) Your write some code that expects this and handles it somehow: function frizz(a::AbstractArrary)
try a[4]
catch err::BoundsError
# handle a not being long enough
end
end If someone calls Now, let's say someone else comes along and implements a new kind of The key observation is that you can have two situations with the exact same throw and catch sites: one where error should be caught and the other where the error shouldn't be caught. The type and/or label idea can't solve this problem since it still only depends on the throw and catch sites – one error is caught if and only if the other one is. To actually solve the problem, whether an error is caught must depend, not only on the throw and catch sites, but also on the stack between them. This is precisely what the chain of custody idea does: it makes catching an error depend on the stack between the thrower and the catcher. The exact details of how to express that are up for debate, but I think this argument makes it clear that we either decide we don't care about this problem, or we need a solution where catching an error depends on the stack that comes between thrower and the catcher. |
Ok, well I'm not running right out to implement the label idea :) The chain of custody is a really clever idea, but I feel it has two fatal issues: (1) It's not clear that it can be implemented efficiently. A naive implementation would probably slow down the whole language. (2) I believe it will strike most people as un-julian. Even though it is better than java's mechanism and not quite as verbose, I suspect most people will still find it fussy. |
That's why I left "decide we don't care about this problem" as one of our possible courses of action. |
Keep in mind that this is completely backwards compatible: no existing code would behave any differently with this proposal. So it's hard to argue that it's somehow more annoying that what we have currently. It is possible to argue that it's more annoying than having typed catch clauses would be, but we've been getting by without those, so that's also hard to see as really a huge problem. It's possible that more of the chain could be implicit, but I rather suspect that these chains will typically be very short – usually just one or two calls deep. Deeper errors are certainly possible, but they are usually of the "catch anything and try to recover" variety, not the "catch a very specific, expected error in this specific method call" variety – which by its very nature would tend not to be a very deeply nested error. |
With that idea, you'd probably want some nice syntactic sugar to cover a few cases similar to what Swift does:
|
Sounds like a great feature to have. I guess it fits well with how other things work Julia; start with somewhat slow but correct and simple implementation and then add annotations to make things robust and fast (e.g., |
Yes I think it would fit nicely. But it's not exactly about making the slow->fast / safe->unsafe tradeoff. With this approach we may get both correctness and speed:
|
Right, it's more like speed-usability tradeoff rather than speed-safety tradeoff. I still think this is a tradeoff since we loose exactly where the exception is thrown with this throw-by-return approach. |
Fair enough. But I think it's a great tradeoff to make because in the case where you have the chain of custody in place you already know exactly where to expect the error from and you've got some cleanup code ready to go — there's very little reason to want a backtrace in that case. If you do want the backtrace you can use a normal try-catch without the chain of custody. In this scheme it becomes clearer that certain errors really shouldn't be catchable. For example |
I thought the chain of custody is a tree, rather than a linear chain. For example, consider: foo1() = rand() > 0.5 ? (throw BazError) : 1
foo2() = rand() > 0.5 ? (throw BazError) : 2
function bar()
x = foo1() throws BazError
y = foo2() throws BazError
return x + y
end
bar() Then I suppose it is impossible to know if it is |
Agreed, it's a tree, and you won't get perfect information about how the error was thrown internally within Another thought about that: if callers do want to distinguish between a failure in |
I probably should have said debuggability rather than usability. I don't think the expressiveness is the problem. I was trying to point out that stack trace would contain less information and so impeding fast debug. Something like post-mortem debugger would also be impossible with this approach. |
Oh sorry, I think I see what you're getting at. The point is that the chain of custody is Interesting. In that case I guess there's a design decision to make about where the rethrow happens but the most obvious would be that the error is rethrown and appear to come from line 1 or 2 of |
On no, I don't think it's broken. I just think throw-by-return is harder to debug. I still think it's a nice feature to have. But I don't think it's entirely free (i.e., there is a speed-debuggability tradeoff).
My first example was the simplest case where the chains were short. But I don't think it is possible to recover the full trace in general. Consider foo1() = rand() > 0.5 ? (throw BazError) : 1
foo2() = rand() > 0.5 ? (throw BazError) : 2
foo3() = rand() > 0.5 ? (foo1() throws BazError) : (foo2() throws BazError)
foo4() = rand() > 0.5 ? (foo1() throws BazError) : (foo2() throws BazError)
function bar2()
x = foo3() throws BazError
y = foo4() throws BazError
return x + y
end I don't think the line number of |
@c42f I think another relation of the chain of custody to the structured concurrency is the multi-exception handling. Should function baz()
(@sync begin
@spawn foo() throws FooError
@spawn bar() throws BarError
end) throws FooError BarError # ???
end
qux() = baz() throws FooError BarError # ??? My guess is that the answer is "no" and the function using |
Agreed, I think But this observation about "unwrapping to get at the real error" is not even limited to these cases. Consider I feel like the way to deal with this is some kind of pattern matching which constructs a predicate from an expression like To expand this suggestion in rough made up syntax try
foo(filename) throws IOError
catch @match SystemError(_, FILE_NOT_FOUND, @var(extrainfo))
# Only get here if a `SystemError` with FILE_NOT_FOUND error code
# *would have* been thrown inside `foo`.
# The variable `extrainfo` now contains the value it would have had if
# the exception actually been thrown.
println("File not found (extra info $extrainfo)")
end I'll admit there's a tension here in matching internal structure of types which is often considered an implementation detail in Julia. A rather speculative solution to that could be to make the suggested |
In terms of compilation, I guess it is not beneficial to have value-based matching in try
...
catch err::ExceptionType if PREDICATE
...
end The reason to prefer this over This also lets you do the match based on information other than exception try
foo(filename) throws SystemError
catch err::SystemError if endswith(filename, ".jl") && err.errnum == FILE_NOT_FOUND
println("Julia file not found (extra info $(err.extrainfo))")
end Going to this direction, it would be nice to have some kind of uniform public accessor/query API. One simple API may be dueto(err::CompositeException, FooError)
dueto(err::SystemError, FILE_NOT_FOUND) But defining a nice full API for |
From the point of view of the compiler the matching function is just a closure and this doesn't seem very different from the many other places where we currently pass closures around and specialize the called function based on the type of the closure. So I think the general idea is harmonious with the other features we already use in the language and may not be super hard on the compiler, provided that the chain of custody for errors isn't deep on average (cf. #7026 (comment)). (The amount of specialization can also be tuned using compiler heuristics, as usual.) The reason I like this idea is that it unlocks some optimization opportunities which are more or less impossible in normal exception systems. Usually it's a choice between:
The decision about which to use should belong to the caller, but typically it's the callee which makes this decision. (In rare circumstances the callee provides an option to select one or the other. For example the What I'm suggesting here is a general mechanism which allows every throwing function to benefit from the efficiencies of having a flag like |
What I meant to ask was if it makes sense to have "structured" match expression (e.g.,
This would be great 👍 |
Right, from the perspective of the compiler the use of |
I tried implementing a prototype of the exception-matcher-as-closure idea and it seems very promising. However a full prototype seems to require language support because we need the closure to not participate in dispatch (though we very much do want it to participate in specialization). The issues are almost the same as for keyword argument dispatch and Jeff's proposed solution at #9498 (comment) applies in a very similar way. That makes me think it would be useful to have generic runtime support for arguments which are ignored during dispatch, which have some rule for "filling them in" when they're not specified, and some way to pass them implicitly. Another case where something like this might be useful is macro calls and the magic |
As a potential source of inspiration, one piece of prior art I don't see mentioned here is Pony's error handling. On the surface it smells very similar to this proposal. Basically, any time a function can throw an error it becomes a "partial function" which must be marked with a Of course, Pony without multi-methods that can be extended by users occupies a very different space in language-design than Julia, but I still thought the similarities with this proposal were striking. |
I haven't read through all of the discussion here. But I would encourage people to look at the Common Lisp condition system for inspiration. Dan Weinreb designed a system that covers a lot of useful cases. Here's a good discussion: |
Of course, the Common Lisp ideas were carried over to Dylan's exception handling. But I would look at Lunar. It looks like Dave Moon (of Common Lisp and Dylan worlds, among many other accomplishments) has distilled this down and moved it firmly to the generic function world, in his proposed Lunar language: |
Beautiful, thanks for these links. I agree the common lisp condition system is quite interesting. And it's particularly interesting to have the modern take on it in that Lunar writeup. There was some very related discussion of algebraic effect systems over at #33248 (comment) (see also the following discussion). Though for a dynamic language like Julia the common lisp condition system seems a better analogy than the algebraic effects systems in strongly typed functional languages. I had a quick look at the Lunar writeup (will need an in-depth read later). I think it's exactly the kind of thing which would fit in well in Julia; in fact it's almost exactly the design I had in mind after reading about algebraic effects, including the behavior of the chain of catch handlers. In particular, the following paragraph about Lunar was very close to what I had in mind:
(For people following along, note that Lunar's I'd kind of like to bundle all these things which provide restarts under the label "effects systems" and I have a half-written summary about this and how it could be applied in Julia. Actually I feel all this is possible to prototype already — indeed @MikeInnes has prototyped https://github.com/MikeInnes/Effects.jl which is super cool and very much relevant. The really difficult thing is figuring out how to fit this stuff into the runtime so that it becomes a reliable language feature which doesn't stress the compiler too much and which also generates really fast code when it matters. I feel like we'll only truly succeed with an effects system if the runtime can generate code which
If we don't succeed at (1), people will continue to need return codes; if we don't succeed at (2) the compilation time will (presumably) be unacceptable. But if we can do both things, we may get one consistent error handling strategy everywhere. |
A of the major issues with try/catch is that it's possible to catch the wrong error and think that you are handling a mundane, expected problem, when in fact something far worse is wrong and the problem is an unexpected one that should terminate your program. Partly for this reason, Julia discourages the use of try/catch and tries to provide APIs where you can check for situations that may cause problems without actually raising an exception. That way try/catch is really left primarily as a way to trap all errors and make sure your program continues as best it can.
This is also why we have not introduced any sort of typed catch clause into the language. This proposal introduces typed catch clauses, but they only catch errors of that type under certain circumstances in a way that's designed to avoid the problem of accidentally catching errors from an unexpected location that really should terminate the program. Here's what a Java-inspired typed catch clause might look like in Julia:
Of course, we don't have this in Julia, but it can be simulated like this:
Although this does limit the kinds of errors one might catch, it fundamentally doesn't solve the problem, which is that while there are some places where you may be expecting a BazError to be thrown, other code that you're calling inside the try block might also throw a BazError where you didn't expect it, in which case you shouldn't try to handle because you'll just be covering up a real, unexpected problem. This problem is especially bad in the generic programming contexts where you don't really know what kind of code a given function call might end up running.
To address this, I'm proposing adding a hypothetical
throws
keyword that allows you to annotate function calls with a type that they're expected to throw and which you can catch with a type catch clause (it's a little more subtle than this, but bear with me). The above example would be written like this:The only difference is that the
throws BazError
comment after the call tofoo
became syntax. So the question is what does this annotation do? The answer is that without that annotation indicating that you expect the expression to throw an error of type BazError, you can't catch it with a typed catch. In the original version where thethrows BazError
was just a comment, the typed catch block would not catch a BazError thrown by the call tofoo
– because the lack of annotation implies that such an error is unxepected.There is a bit more, however: the
foo
function also has to annotate the point where the BazError might come from. So, let's say you had these two definitions:Consider something like this:
This also introduce a hypothetical keyword form of
throw
– more on that below. These definitions result in the following behavior:The rule is that a typed catch only catches the an error if every single call site from the current function down to the one that actually throws the error is annotated with
throws ErrorType
where the actual thrown error object is an instance ofErrorType
. An untyped catch still catches all errors, leaving the existing behavior unchanged:So what is the rationale behind this proposal and why is it any better than just having typed catch clauses? The key point is that in order to do a typed catch, there has to be a "chain of custody" from the throw site all the way up to the catch site, and each step has to expect getting the kind of type that's thrown. There are two ways to catch the wrong error:
The first kind of mistake is prevented by giving an error type, while the second kind of mistake is prevented by the "chain of custody" between the throw site and the catch site.
Another way of thinking about this is that the
throws ErrorType
annotations are a way of making what exceptions a function throws part of its official type behavior. This is akin to how Java putsthrows ErrorType
after the signature of the function. But in Java it's a usability disaster because you are required to have the throws annotation as soon as you do something that could throw some kind of non-runtime exception. In practice, this is so annoying that it's pretty common to just raise RuntimeErrors instead of changing all the type signatures in your whole system. Instead of making runtime excpetion vs. non-runtime exception a property of the error types as Java does, this proposal makes it a property of how the error occurs. If an error is occurs in an expected way, then it's a non-runtime exception and you can catch it with a typed catch clause. If an error occurs in an unexpected way, then it's a runtime exception and you can only catch it with an untyped catch clause. You can only catch errors by type if they are part of the "official behavior" of the function you're calling, where official behavior is indicated withthrows ErrorType
annotations.One detail of this proposal that I haven't mentioned yet is that
throw
would become a keyword instead of just a function. The reason for this is that writingseems awfully verbose and redundant. For compatibility, we could allow
throw()
to still invoke the functionthrow
whilethrow ErrorType(args...)
would be translated toThis brings up another issue – what scope should the right-hand-side of
throws
be evaluated in? In one sense, it really only makes sense to evaluate it in the same scope that the function signature is evalutated in. However, this is likely to be confusing since all other expressions inside of function bodies are evaluated in the local scope of the function. It would be feasible to do this too, and just rely on the fact that most of the time it will be possible to statically evaluate what type expression produces. Of course, when that isn't possible, still be necessary to emit code that will do the right thing depending on the runtime value of the expression. If the r-h-s is evaluated in local scope, then we can say thatthrows expr
is equivalent to this:Usually it will be possible to staticly determine what
typeof(expr)
is, and it is a simpler approach to explain than restricting the syntax of the expression after thethrow
keyword.Note that under this proposal, these are not the same:
Also note that this proposal is, other than syntax additions that are not likely to cause big problems, completely backwards compatible: existing untyped catch clauses continue to work the way they used to – they catch everything. It would only be new typed catch clauses that only catch exceptions that have an unbroken "chain of custody".
The text was updated successfully, but these errors were encountered: