Should except handlers save/restore the prior value of the capture variable instead of deleting it?

Currently if you have the code:

e = 1
try:
    ...
except Exception as e:
    ...
print(e)

It will print 1 if no exception is raised, but if an exception is raised, it will raise NameError (UnboundLocalError inside a function) on the print(e) line.

The exception handler will clobber the previous value of e with the exception instance, and then at the end of the exception block, delete the exception instance (to avoid reference cycles that would frequently result from keeping it alive), leaving e unassigned.

Now that PEP 709 (comprehension inlining) has landed, we have both a language precedent and the compiler machinery to support “sub-scopes” where a temporary variable can be isolated from a variable of the same name in the rest of the function. This means it would now be pretty easy to change the above behavior such that print(e) would always print 1, regardless of whether an exception were raised.

Is this an improvement? Does it matter to anyone either way? Does this require a PEP?

10 Likes

This seems like a clear improvement to me; there have been questions about the behaviour before, and it’s hard to imagine that anyone would explicitly want locals that existed outside the try to be removed. Although it’s a pretty rare case, implementing an actual separate scope (which is functionally what save/restore accomplishes, yes?) sounds much more elegant than the current del (which could also be argued to be needlessly implicit).

4 Likes

This makes sense to me. I’d prefer we got rid of the implicit del also if possible.

2 Likes

Yes, I always thought the implicit del was a bit of a wart on the language. Making it behave like comprehension variables would be cleaner.

We’ll have to think about backwards compatibility. I can think of two possible issues:

  • Some code that would previously have thrown a NameError now will not.
  • Some resources may be kept around for longer because they were previously implicitly deleted. If this happens in a module or class scope, the variable may even be kept around for the lifetime of the program. Contrived example:
e = [1] * 100_000  # really big list
try: 1/0
except ZeroDivisionError as e: pass # implicitly clean up the huge list

I think both of those cases are obscure enough that we can afford to change them, but we’ll have to think carefully about it.

Yes, I think a change in the core behavior of the language requires a PEP.

6 Likes

What’s confusing about

e = 1
try:
    ...
except Exception as e:
    ...
print(e)

Isn’t that e isn’t 1, as that follows normal assignment / scoping rules in Python as once a variable is assigned in the same scope to something else but then the new assignment is deleted it doesn’t magically return to it’s previous assignment. What’s confusing is that e isn’t an instance of Exception as the delete at the end of an exception block is special, it doesn’t happen during other assignments which involve a block, e.g. for and with.

Now I know the reasoning for why this happens, pretty much every developer at some points hits this UnboundLocalError and eventually finds the relevant docs.

If there is a “special” solution to this I would strongly argue it is that this exception is somehow made special to tell you about the implicit delete or give you a link to the documentation and not some new weird scoping rule special to Exceptions.

Scoping is already hard to accurately mentally model[1] and it seems it’s going to get harder with PEP 695[2] and PEP 649 in Python 3.12 and 3.13 respectively, but at least they are solving difficult problems and making Python systemically better rather than solving a mild nuisance the first time you some across it. My 2 cents anyway.


[1]. https://github.com/gvanrossum/gvanrossum.github.io/blob/main/formal/scopesblog.md
[2]. https://discuss.python.org/t/please-consider-delaying-or-even-rejecting-pep-695

2 Likes

IIRC the del e was added when we added exception chaining (though I can’t find a trace of this in PEP 3134, so I may misremember the exact timing), to address the problem of reference cycles through the locals (locals → err → traceback → frame → locals). Before then e would not only overwrite the prior definition, it would survive until after the except clause – unless, of course, the except clause wasn’t executed.

Like Damian, I believe we should think long and hard before we introduce more one-off scoping exceptions – as we learned during the PEP 695 discussions, Python scopes are already quite weird (despite the “LEGB” meme, which didn’t quite capture all the rules even before PEP 695). Maybe we should consider a more general way of introducing local scopes, and then specify the except scope in terms of those. Ideally PEP 695 scopes would also be defined in those terms, as well as comprehension scopes – though the latter are even weirder (see PEP 572), so who knows if that’s even possible.

And surely introducing a local scope concept disconnected from function scopes will also increase the pressure to “fix” the scope of for variables, and possibly with variables, and what’s next? Walrus variables?

8 Likes

" When an exception has been assigned using as target, it is cleared at the end of the except clause." appears to have been added in 3.0. (There is no ‘added’ note and not in 2.7 that I could see.)

1 Like

I’m not a fan of these exceptions to the usual behaviour of Python (also PEP 695 :slight_smile: ). The reason is mostly that it creates a dilemma when teaching these structures: Do you give an incomplete account of the rules, or do you risk confusing students by mentioning the exceptions to the rules? I find the latter can create a sense of anxiety (“There’s this thing I have to remember…”) through mere existence of such exceptions, even if they are perfectly logical and easily understood.

So instead of making except ... as ... even more divergent from with ... as ... and normal scoping rules, perhaps it’s possible to fix the issue going the other way, working around the reference cycles problem by clearing e.g. the traceback in the exception at the end of the block, or the locals in the traceback?

2 Likes

I would DEFINITELY dislike this. The object is the same, but now it has less information in it? No thanks. With the current system, there’s a clear workaround: except Exception as ee: e = ee and then it gets retained. How would you force it to be retained if the object itself is being mutated?

5 Likes

That was just an off-the-cuff example. My point is: this thread is talking about fixing a symptom of a deeper problem (i.e. the reference cycles, or whatever else it may be if it wasn’t that). Why not put the effort in fixing the original cause in a better way?

Thanks all for the feedback!

I’m curious what sort of thing you’re envisioning here. Do you mean more general syntax that introduces a local scope (e.g. as straw man, something like a scope: block that isolates local variables within it), and then the documentation can define the behavior of except blocks and comprehensions with reference to scope: blocks?

This does surface the question (not explicitly specified in my OP) whether all variables assigned within an except handler would be scoped to just the handler block, or just the exception-capturing variable. I had intended the latter (and I don’t think the former is feasible or a good idea, it would have much wider backwards-compatibility problems). But this does make it a pretty weird case, that I don’t think would be specifiable in terms of a more general “nested scopes” feature.

I think really the only argument in favor is that the deletion is already a weird case, and this would be still weird but arguably a nicer version of it.

That’s a reasonable position. I agree that it would be strange for except Exception as e: to introduce a locally-scoped temporary variable, when no other block does that.

That would indeed be ideal, if there were a reasonable way to do that. I’m not sure there is.

Instead of deling the name, assign it to a new object – something like:

>>> try:
...     1/0
... except Exception as e:
...     pass
>>> e
<deleted ZeroDivisionError: division by zero>

The only thing left would be the type and message of the originating exception.

3 Likes

Maybe when evaluating e it could still raise an exception (so where the error is coming from doesn’t get obscured) with an Exception Note that says why and where e was deleted?

1 Like

It was always possible to push the value of e to the stack (or NULL if it isn’t defined) and restore it on exit from the handler, instead of wiping it out. It doesn’t require a new scope. I think it wasn’t done to avoid confusion about what the value of e is after an except block was executed. I doubt the reason was performance, since we’re talking about an exception handling code path.

Yes, it was always possible, although it would not previously have been easy, and now it is. Doing this correctly in all scopes requires a number of compiler changes that were made as part of PEP 709. (A variant of LOAD_FAST that is OK with loading NULL, a variant of STORE_FAST that isn’t assumed to store a non-NULL, and the capacity to temporarily use fast-locals in class and module scope where they aren’t normally used.)

I agree that the suggestion in this thread should not really be described as a “new scope” at all, since it would only apply to a single variable, not all assignments made within a block.

Just out of historical curiousity, would it not have been possible for any of those to be a weakref instead?

I don’t think so. The link from locals → err is the one you’d want to break, but local variables are not weakly referenced by their scope.

FWIW, based on other responses in this thread, I think that we should just leave this alone.

4 Likes

Hmm. Intuitively I would have thought traceback → frame is the point to break, since the frame is what naturally loses a reference at the end of scope when returning from the function, and because frame and locals exist even when there isn’t an exception. But I’ve barely looked at the C code, so.

Tracebacks need to keep the frame alive, since they are most often used after the frame has already exited. If the frame was weakly referenced, it would not be available to traceback printers, post-mortem debuggers, and the like.

1 Like

Possibly off-topic note from end-user perspective, I wish as x was consistent:

>>> with open("/etc/passwd") as f: pass
... 
>>> f
<_io.TextIOWrapper name='/etc/passwd' mode='r' encoding='UTF-8'>

and yet

>>> try: 1/0
... except Exception as e: pass
... 
>>> e
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'e' is not defined

Likewise:

>>> with open("/etc/passwd") as os.environ: pass
... 
>>> os.environ
<_io.TextIOWrapper name='/etc/passwd' mode='r' encoding='UTF-8'>

and yet

>>> try: 1/0
... except Exception as os.environ: pass
  File "<stdin>", line 2
    except Exception as os.environ: pass
                          ^
SyntaxError: invalid syntax