This lecture will cover the following:
Suppose you want to write a function that can sometimes fail depending on what arguments are passed to it (or other context). For example, suppose you want to write a function log_base_10() that computes the base-10 logarithm of the given argument. The base-10 logarithm of 0 is undefined, so if 0 is passed as the argument, the function will fail.
When a function fails (assuming it can fail), if the function itself can't handle the error, then it needs some way of communicating the error back to the call site (i.e., back to the function that called it) so that it can be handled elsewhere within the program. For example, log_base_10() probably can't handle the error in which zero is passed as an argument because the exact way that the error should be handled depends on the context in which it's called (e.g., should the error be logged to a file? Should a message printed to the terminal? Should a pop-up window appear indicating the error? Should the program crash? It all depends on the context, and the log_base_10() function isn't responsible for that context).
So, how does a function communicate errors back to the call site? In some programming languages, the only idiomatic way of communicating errors is via return values or similar. For example, in the C programming language, it's common for a function to communicate errors by returning an integer representing an error code, such as 0 to represent success vs some nonzero value when an error occurs (the exact nonzero value would depend on the function and the kind of error that it encountered).
This can be messy for various reasons. In the particular case of C (and most other statically typed languages), a function can only have one static return type, so when a function that can return an error also needs to be able to communicate actual outputs when they're successful, they either have to use some sort of "trick" to represent both of these things in a single return type (e.g., by wrapping the error code and the actual output as optionals in a structure, or by using a tagged union), or they have to use some other communication channel to communicate the non-error output (e.g., pointer parameters).
The larger concern with communicating errors by returning error codes, though, is in representation and control flow. Return values, by nature, are ignorable—when you call a function that returns a value, you don't have to do anything with that value. By default, if the caller fails to acknowledge a return value of a function call, absolutely nothing happens; the program just moves on as if the function call didn't return anything to begin with:
# Even if this function call returns something, we don't DO anything
# with its return value. But that's okay and even desired in some
# cases.
my_cool_function(1, 2, 3, 'Hello')
# The program just moves on as normal...That's to say, return values are ignored by default. If you don't want a return value to be ignored, you must intentionally write the program in a way that doesn't ignore the return value. For example, you might store the return value in a variable for use later on (e.g., some_variable = my_cool_function(1, 2, 3, 'Hello')).
But what about errors? If a function fails due to an error (e.g., log_base_10(0)), what do you want to happen by default? Do you really want nothing to happen by default? Perhaps in some cases, but in the vast majority of cases, you want your program to detect that an error has occurred and do something about it (e.g., correct the error if possible, print an error message / log the error for diagnostics, etc). But if your function uses return values (e.g., error codes) to communicate errors, then errors are ignored by default (since return values are ignored by default).
To be clear, you can use return values to communicate errors, but it makes it easy to accidentally (or intentionally) ignore those errors when they occur, which leads to bugs. Many programming language developers were thinking about these issues as early as the 1960s, and some of their ideas culminated in a now-common language feature known as exceptions. Exceptions were first (?) introduced by Bjarne Stroustrup in the C++ programming language, though the idea built off of some other similar, earlier ideas (e.g., MacLisp's ERR, ERRSET, CATCH, and THROW mechanisms).
An exception is an alternative to a return value. That's to say, a function can either a) return a value, or b) throw an exception. Doing either of these things ends the function immediately, jumping back to the call site (but what happens from there depends on which of those two things occurred). Return values are used to communicate normal, intended outputs, whereas exceptions are used to communicate that something went wrong (i.e., an exception occurred), preventing the function from computing and returning the normal / intended value (e.g., "I can't compute the logarithm of 0, so here's an exception instead").
By default, when a return value is ignored, nothing happens. But by default, when an exception is ignored, the entire program crashes—usually printing a stack trace (or traceback) in the process, which makes diagnosing the error extremely easy. We've actually seen a few examples of this in past lectures (e.g., IndexError when indexing a list with an out-of-bounds index).
But that's only what happens by default. As it turns out, it's possible to catch exceptions, handling them however you see fit. You can also throw your own exceptions. There's a lot of syntax surrounding exceptions, even in Python. Let's deal with the syntax one piece at a time.
When a function call (or operator, etc) throws an exception, by default, the entire program crashes. To prevent this from happening, you can wrap the function call in a try block. The syntax is as follows:
try:
<try block body>
except <exception type> as <name>:
<except block body>Replace <try block body> with a block of code that you want your program to "try" to do (e.g., call some functions that might fail), acknowledging that an exception might be thrown in the process. Replace <exception type> with the type of exception that might be thrown during the try block body. Replace <name> with the name that you want to use to represent the exception variable (yes, exceptions are values and can be stored in variables). Finally, replace <except block body> with a block of code that you want your program to execute when an exception of the specified type is, indeed, thrown within the try block body (i.e., this is the block of code that "handles" the error after it has occurred).
Here's an example to illustrate:
try_except.pyfrom traceback import print_exc
def main() -> None:
best_numbers = [42, 7, 777, 24, 25]
chosen_index = int(input('Which number would you like to see? '
'For example, type "0" to see the first number, "1" to see '
'the second number, and so on: '))
try:
# Try to do the following, knowing that an error might occur
# in the process. NOTE: THIS EXAMPLE IS JUST TO DEMONSTRATE THE
# SYNTAX OF EXCEPTIONS. I DO NOT ACTUALLY RECOMMEND THAT YOU
# USE EXCEPTIONS FOR INDEX / BOUNDS CHECKING. READ THE REST
# OF THIS SECTION CAREFULLY.
print(f'The number you chose is: {best_numbers[chosen_index]}')
# If chosen_index is not between 0 and 4,
# best_numbers[chosen_index] will attempt to access an
# element that doesn't exist, throwing an IndexError.
# In such a case, this whole try block immediately ends, and
# the program jumps to the except block down below.
except IndexError as e:
# If the try block above throws an IndexError at any point,
# the program will jump to this except block. This is where
# we "handle" the error (whatever that means, depending on
# the context). In this case, we'll just tell the user that
# that they entered an invalid index.
print('Error: The index must be between 0 and 4, inclusive.')
# The error (exception) itself is a value, and it's stored in
# the variable e (that's what I named it on line 23 above---you
# can name it whatever you want). e contains information about
# the error that occurred. For example, you can print it to
# the terminal, which prints a short message describing
# the error:
print()
print(e)
# You can also print its traceback via print_exc() (this can
# only be done within an except block). print_exc is provided
# by the traceback module (imported on line 1 above). This is
# what would normally be printed if the program failed to catch
# the exception and crashed as a result:
print()
print('Traceback:')
print_exc()
# Once the above try / except blocks are done executing, the
# program continues normally from here. It does NOT simply crash /
# end immediately.
if __name__ == '__main__':
main()Here's an example run wherein I supply a valid index:
(env) $ python try_except.py
Which number would you like to see? For example, type "0" to see the first number, "1" to see the second number, and so on: 1
The number you chose is: 7
And here's an example run wherein I supply an invalid index:
(env) $ python try_except.py
Which number would you like to see? For example, type "0" to see the first number, "1" to see the second number, and so on: 100
Error: The index must be between 0 and 4, inclusive.
list index out of range
Traceback:
Traceback (most recent call last):
File "/home/alex/instructor/static-content/guyera.github.io/code-samples/exceptions/try_except.py", line 16, in main
print(f'The number you chose is: {best_numbers[chosen_index]}')
If a single block of code is capable of throwing more than one kind of exception, you can have multiple except blocks, one for each kind of exception that might be thrown. The syntax looks like this:
try:
<try block body>
except <exception type 1> as <name 1>:
<handle the exception>
except <exception type 2> as <name 2>:
<handle the exception>(And so on). Each of the exceptions can actually have the same name if you'd like (e.g., <name 1> and <name 2> can both be e, if that's what you want).
To be clear, at most one of the above except blocks will be executed. If an exception of type <exception type 1> is thrown within the try block, then the first except block will be executed. If an exception of type <exception type 2> is thrown within the try block, then the second except block will be executed. If neither of these kinds of exceptions are thrown within the try block, then neither of the except blocks will be executed. This is why Mypy is okay with all of the exception variables having the same name.
When the try block terminates normally (without throwing any exceptions), or when an except block is run to completion, the program then proceeds normally, continuing to execute whatever code appears after the last except block.
Suppose you want to create a single except block that can catch any kind of exception. To do this, simply use Exception as the type of the exception that your except block catches:
try:
<try block body>
except Exception as e:
<except block body>In the above example, if the try block throws any kind of exception, the except block will be executed.
Finally, suppose that a line of code throws an exception, but it's not capable of catching and handling that exception. For example, suppose that the line of code is inside a try block that has a single except block which is capable of catching an IndexError, but a ValueError is thrown instead. Or suppose that the line of code throwing the exception isn't inside a try block at all. I previously said that, by default, this causes the program to crash, printing a traceback to the terminal. But that's actually not exactly what happens. If an exception is thrown outside of a try block with an accompanying except block that's capable of catching the exception, the program doesn't immediately crash. Rather, it automatically re-throws, or propagates, the uncaught exception down the call stack. That's to say, the function in question re-throws the exception up to the function that called it. And if that function isn't capable of catching the exception, it will re-throw the exception up to the function that called it, and so on. This happens over and over again until, eventually the exception propagates all the way up to the module scope where call stack originated. If the module scope also isn't set up to catch the exception, then the program crashes.
Here's an example:
exceptions_propagate.pydef c() -> None:
the_list = ['hello']
# Try to print the 100th element, which causes an IndexError
# to be thrown. Since the below line of code isn't inside a
# try block with an accompanying except block that's set up
# to catch an IndexError, the exception will propagate to the
# function that called THIS function.
print(the_list[99])
def b() -> None:
# Call c(), which will throw an IndexError. Since the below
# line of code isn't inside a try block with an accompanying
# except block that's set up to catch an IndexError, the
# exception will propagate to the function that called THIS
# function.
c()
def a() -> None:
# Call b(), which will throw an IndexError. Since the below
# line of code isn't inside a try block with an accompanying
# except block that's set up to catch an IndexError, the
# exception will propagate to the function that called THIS
# function.
b()
def main() -> None:
try:
# Call a(), which will throw an IndexError. But here, we catch
# it with the below except block.
a()
except IndexError as e:
print('Exception caught!')
if __name__ == '__main__':
main()
Running the above program produces the following output:
(env) $ python exceptions_propagate.py
Exception caught!
If the main() function hadn't caught the exception, it would have propagated up one more time to the module scope (i.e., to the very last line of code in the program where the main() function was called). If that function call was wrapped in a try block with an accompanying except block that's set up to catch the index error, the program would catch it there instead. Only if the exception propagates all the way up to the module scope, and then still isn't caught within the module scope, does the program immediately crash (but if it was caught at the very end of the module scope, the program would immediately end after the except block is executed anyways).
There is some other syntax surrounding try / except that you can use as well, but we won't discuss it. (For the curious reader, look up finally blocks and else blocks attached to try / except chains).
In many cases, you can solve the same problem in two different ways: a) checking some condition before attempting an operation, or b) attempting the operation in a try block, catching an exception if the operation fails. In such cases, there's a simple rule of thumb: you should generally prefer option a). That is, if there's a simple way to check whether an operation will fail before attempting it, you should almost always prefer to simply put that operation in an if statement, attempting it only if you know that it will succeed, rather than putting it in a try block and catching the exception when it inevitably occurs. Indeed, this means that the above example violates this rule of thumb. Rather than using try and except, we could simply use an if statement to check whether the user's specified index is out of bounds. And, indeed, that's probably what we should have done. But in other cases where an exception is the only reasonable or idiomatic solution to the problem (e.g., when sanitizing user-input strings in Python), using exceptions is perfectly fine.
For the curious reader, the reason for this rule of thumb will be discussed in an optional section shortly.
In Python, a very common use case for exceptions is sanitizing user input. For example, suppose you want to write a program that asks the user to enter an integer. But suppose that, when the user runs the program, they don't enter an integer—suppose that they enter something else entirely. As you know, the input() function returns the user's input as a string, which you can then type-cast into the desired type. But what you probably didn't know is that explicit type casts can throw exceptions. When you try to cast a string into an integer, it will throw a ValueError if the string does not actually contain text representing an integer. You can then catch that exception, scold the user, and reprompt (for example):
sanitize_inputs.pydef prompt_for_integer(prompt: str) -> int:
supplied_valid_input = False
while not supplied_valid_input:
try:
# If the user types in something other than an integer,
# the int() type cast will fail and throw a ValueError,
# jumping to the below except block. Otherwise, it will
# continue on and set supplied_valid_input to True.
chosen_integer = int(input(prompt))
supplied_valid_input = True
except ValueError as e:
print('Error: You must enter an integer!')
# Leave supplied_valid_input False, forcing the loop
# to run again.
# The while loop has ended, which must mean that the user supplied
# an invalid input. Return it.
return chosen_integer
def main() -> None:
favorite_number = prompt_for_integer(
'Enter your favorite whole number: '
)
if favorite_number == 42:
print("You must be a fan of Hitchhiker's Guide to the Galaxy!")
else:
print('Ah, I see...')
if __name__ == '__main__':
main()
Here's an example run:
(env) $ python sanitize_inputs.py
Enter your favorite whole number: Hello, World!
Error: You must enter an integer!
Enter your favorite whole number: But what if I don't want to enter an integer?
Error: You must enter an integer!
Enter your favorite whole number: 1.5
Error: You must enter an integer!
Enter your favorite whole number: 42
You must be a fan of Hitchhiker's Guide to the Galaxy!
Notice that a ValueError is even thrown when the user types in 1.5. This might surprise you. After all, it's perfectly okay to type-cast a float into an int (doing so truncates the value to produce a whole number, but it doesn't thrown an exception). While that's true, the exact rules vary by what type you're casting from and what type you're casting to. Although 1.5 (the float) can be type-casted into an int value, '1.5' (the string) cannot. Or, at least, not directly—you could type-cast it into a float and then type-cast that into an int (because '1.5' can be casted to a float, and float values can in general be type-casted into int values, truncating them in the process).
And, although '1.5' cannot be directly type-casted into an int value, '42' (for example, or some other integer-containing string) can be directly type-casted into a float (or into an int, of course).
I strongly recommend that you use the above prompt_for_integer() function (or similar) throughout all of your homework assignments and labs where you need to to prompt the user for a whole number. That's why I made the prompt itself a parameter—it can be reused for any assignment by simply switching the argument out for whatever message you want to be printed (feel free to use it as-is or modify it to your heart's content; you might also, for example, parameterize the error message rather than leaving it generic). I also recommend creating similar functions that prompt the user for other kinds of data.
So, exceptions can be caught, but how are they generated? Where do they come from? For example, if you wanted to write your own function that explicitly throws an exception in a certain situation (i.e., without executing some other function that throws an exception, such as a bad type cast or out-of-bounds indexing operation), how can you accomplish that?
Enter the raise keyword. To raise an exception means to be the first one to throw it (i.e., to throw an exception without it first being thrown and propagated from some deeper function call). Here's the syntax:
raise <exception>Replace <exception> with an exception value that you would like to raise.
Of course, in order to do this, you need to know how to create exceptions. There are various kinds of built-in exceptions, such as IndexError, ValueError, and so on. You can also create your own kinds of exceptions, but you should prefer to use one of the built-in types if they're applicable to your situation (I'm also not going to show you how to create your own exception types, and it wouldn't make sense if I did until we've covered inheritance later on in the term; feel free to look it up if you're curious). To create an exception using one of the built-in exception types, simply write out the name of the type followed by a brief error message string enclosed in parentheses.
Here's a somewhat contrived example:
raise.pydef base_10_log(value: float) -> float:
if value == 0:
# Cannot compute the log of 0. Raise a ValueError, immediately
# throwing it back to the call site.
raise ValueError('Cannot compute the log of 0!')
# Otherwise, proceed to compute the log
# (Computing the log of a value is actually not trivial. In
# practice, you'd use the built-in math.log() function rather
# than implementing this yourself. But just pretend that there's
# some really cool, complicated code here that computes the
# log of the given value, rather than the (wildly incorrect) return
# statement below.)
return 1000.0
def main() -> None:
chosen_value = int(input('What value would you like to compute '
'the log of?: '))
try:
# Try to compute the base-10 log, catching a ValueError
# with the below except block if one is thrown
answer = base_10_log(chosen_value)
except ValueError as e:
# A value error was thrown. Print its message to the terminal
print(e)
if __name__ == '__main__':
main()
Here's an example run:
(env) $ python raise.py
What value would you like to compute the log of?: 0
Cannot compute the log of 0!
Notice that print(e) simply prints the exception's message, which is the very same message as the one that was provided between the parentheses when the ValueError was created within the base_10_log() function.
Python has many types of built-in exceptions besides ValueError. Here's a complete list of built-in exception types and their purposes.
Throwing an exception out of a function (i.e., raising an exception, or calling another function that raises an exception, and not immediately catching it within the same function body) is an alternative to returning a value from a function. That is, any given function may terminate in one of three ways: 1) reaching the end of its control block (body); 2) returning a value; or 3) throwing an exception.
(Technically, when a function reaches the end of its control block, it implicitly returns None. So 1) is actually a subset of 2).)
If a function terminates by throwing an exception, then it does not return anything. Note that this is different from returning None. None is an actual object that can be returned. Throwing an exception does not cause the function to return the object None. Rather, it literally means the function does not return anything whatsoever.
You might be surprised to hear that Mypy is okay with this. Ordinarily, Mypy is very strict about requiring functions with a given return type to always return something of that type. But the reason is simple: when an exception is thrown from a function call, it affects the program's control flow (i.e., the order of execution of various lines of code) differently from how a return value would. When a function returns a value, the program resumes at the call site (i.e., where the function was called from), replacing the function call itself with the return value. In contrast, when a function throws an exception, the program behaves quite differently: it proceeds to unwind the call stack until the exception is caught, as explained earlier. For example, when the raise operator is used to raise an exception, the interpreter checks whether the raise operator was used inside a try block with an accompanying except block that's capable of catching the raised exception. If so, it simply jumps to the except block and executes its body, and then it proceeds from there, executing whatever comes after the except block. But if not, then it throws (propagates) the raised exception to the call site (i.e., to the function that called the one that raised the exception). It then checks if that function call was inside a try block with an accompanying except block that's capable of catching the thrown exception. If so, it jumps to that except block, executes its body, and proceeds from there. But if not, it throws (propagates) the exception further down the call stack, back to the function that called that function, and so on. It continues doing this until either a) the exception is caught, or b) the exception is propagated all the way out of global / module scope, at which point the program crashes and prints a traceback.
Because of the way that exceptions affect the program's control flow, when a function throws an exception, its return value is completely unnecessary. After all, the point of a return value is to be substituted into the function call expression at the call site. But if a function terminates by throwing an exception, then the call site never gets a chance to evaluate the function call expression anyways; it immediately jumps to an except block or propagates the exception down the call stack.
Consider this example:
raisenotreturn.pyfrom traceback import print_exc
def cool_function(x: int) -> int:
if x == 1:
return 100
elif x == 2:
return 1000
raise ValueError(f'x must be either 1 or 2, but got {x}')
def main() -> None:
# Prints 100
print(cool_function(1))
# Prints 1000
print(cool_function(2))
try:
# Never gets a chance to print anything. It raises an
# exception, which gets thrown here to main(). It then
# immediately jumps to the except block below.
print(cool_function(3))
except ValueError as e:
print("cool_function(3) failed. Here's the traceback: ")
print_exc()
# Also never gets a chance to print anything. It raises an
# exception, which gets thrown here to main(). But we don't
# catch it here, so it gets propagated down the call stack
# (in this case, it's never caught, so the program crashes).
print(cool_function(4))
if __name__ == '__main__':
main()
Running the above program produces the following output:
$ python raisenotreturn.py
100
1000
cool_function(3) failed. Here's the traceback:
Traceback (most recent call last):
File "/nfs/stak/users/guyera/instructor/static-content/guyera.github.io/code-samples/cs162/exceptions/raisenotreturn.py", line 22, in main
print(cool_function(3))
File "/nfs/stak/users/guyera/instructor/static-content/guyera.github.io/code-samples/cs162/exceptions/raisenotreturn.py", line 9, in cool_function
raise ValueError(f'x must be either 1 or 2, but got {x}')
ValueError: x must be either 1 or 2, but got 3
Traceback (most recent call last):
File "/nfs/stak/users/guyera/instructor/static-content/guyera.github.io/code-samples/cs162/exceptions/raisenotreturn.py", line 35, in <module>
main()
File "/nfs/stak/users/guyera/instructor/static-content/guyera.github.io/code-samples/cs162/exceptions/raisenotreturn.py", line 31, in main
print(cool_function(4))
File "/nfs/stak/users/guyera/instructor/static-content/guyera.github.io/code-samples/cs162/exceptions/raisenotreturn.py", line 9, in cool_function
raise ValueError(f'x must be either 1 or 2, but got {x}')
ValueError: x must be either 1 or 2, but got 4
Pay close attention to the code comments. When a value other than 1 or 2 is passed to cool_function(), it throws an exception, which is either caught at the call site or propagated down the call stack. In either case, the function call is never fully evaluated, and its value is never printed. For example, print(cool_function(3)) doesn't actually get a chance to print anything whatsoever; the cool_function(3) call throws an exception, altering the program's control flow before the enclosing print() function call gets a chance to start. The same goes for print(cool_function(4)) (but in that case, the entire program crashes).
This is why throwing an exception is an alternative to returning a value. If an exception is thrown out of a function, then it never gets a chance to return anything. And that's okay since its return value will never get a chance to be used at the call site anyways.
And indeed, Mypy is okay with this. Even though cool_function() is annotated as having a return type of int, it doesn't actually return an int when x is anything other than 1 or 2. But since it throws an exception in all other cases, Mypy doesn't complain:
$ mypy raisenotreturn.py
Success: no issues found in 1 source file
There's a lot of debate within the software engineering community surrounding exceptions. I'd be remiss if I didn't mention it.
First of all, I mentioned a rule of thumb earlier: if you can detect that an error will occur using an if statement, that should be preferred over simply executing the operation in a try block and catching the error with an except block.
There are a few reasons for this rule of thumb. The main reason is performance. If statements are simple, and computers are very good at evaluating and interpreting them. On the other hand, try, except, and raise are much more complicated. They require your computer to accrue exception information, propagate it down the call stack (also called "stack unwinding"), detect whether a given except block is capable of handling the thrown exception, and many other minute details that we won't talk about. Moreover, if the error can be anticipated early using an if statement rather than caught when it occurs using try / except, the program can often avoid several unnecessary operations.
Another (weaker) reason is philosophical: many people say that exceptions should only ever be thrown in exceptional circumstances. These people often say that simple, common errors (e.g., user errors) should be handled by if statements instead. A similar philosophy states that exceptions should only handle programming errors (e.g., for development and debugging purposes)—not user errors or other runtime errors.
All that being said, even the performance issue usually isn't a practical concern, and much of this debate is probably bikeshedding. Python 3.11 and C++, for example, both implement zero-cost exception handling, meaning that exceptions are only slower than if statements (to a degree that might matter) when an exception is actually thrown, so unless exceptions are being thrown hundreds or thousands of times per second (which is almost never the case when this debate comes up), it likely makes no difference whether you use try/except vs an if statement, and spending energy thinking about it is likely premature optimization.
Still, there's no real reason not to follow this rule of thumb. It just doesn't matter as much as some people think it does.
There's a lot more to the debate, though. Some people say that exceptions are inherently problematic and wish that programming languages did not support them at all. Their argument usually has to do with control flow and transparency: if any function can throw any exception at any time, then any function call can result in any exception being thrown, and that can affect the program's control flow in complicated ways. This makes it hard to track and understand a program's control flow. Consider the following simple example:
def main():
foo()
bar()Without carefully analyzing the foo() function's documentation, you have no way of knowing whether the above program will even execute the bar() function. Indeed, if the foo() function returns (terminates normally), then the bar() function will be executed in turn. But if the foo() function throws an exception, the program will suddenly start doing something else entirely—the main() function, which is not equipped with a try/except mechanism to catch and handle the exception, will propagate it further down the call stack until it's eventually caught. Perhaps that's fine, but that control flow—that manner in which the program moves from one instruction to the next—is not transparent. That's to say, looking at the above code, it seems reasonable to assume that it will execute foo() followed by bar(), but exceptions break this assumption. The fact that Python supports exceptions at all means there's no saying what the above program will do unless you're certain about whether foo() will or will not throw an exception. And you can't be certain of that without looking at the foo() function's documentation (assuming its exceptions are documented to begin with—hopefully they are!).
Of course, as in all good debates, there's a counterargument: exceptions are part of a function's interface, meaning that if you don't know what kinds of exceptions a function might throw, then you have no business calling that function whatsoever. For reference, a function's name, parameters and return type are also a part of its interface. You clearly can't call a function without knowing these things, and types of exceptions that the function might throw are no different.
Still, knowing what types of exceptions the foo() function might throw does not inherently make the above code's control flow "transparent". Just looking at the above code in isolation, there's no syntax that would suggest that the bar() function might not be executed. To figure that out, you'd have to look elsewhere. And that's precisely the issue: you must look at the foo() function's documentation in order to understand the control flow of the main() function.
To some people, control flow should be obvious and transparent, and this is a very big deal. To others, the advantage of exceptions—of separating actual outputs and errors into two distinct communication channels that are each dealt with in different ways by default—outweighs their disadvantages.
Lastly, some people believe that exceptions are particularly problematic in programming languages that do not require them to be a part of the static type signatures of functions. For example, when creating a function in Python, you must annotate its return type, or else Mypy will generate errors (at least when run in strict mode). However, you don't need to annotate the types of exceptions that it might throw. So the argument goes: if the exceptions that a function might throw are a part of its interface, then they ought to be subject to static analysis like the rest of the function's interface. Yet, for complicated reasons (some of which are historical), lots of programming languages (including Python + Mypy) do not consider exception types to be a part of a function's type signature, so they don't support static analysis of exceptions in basically any way. The same is true in C++, the first language to popularize exceptions.
Terminology: exceptions that are not annotated in a function's static type signature are sometimes called unchecked exceptions, whereas exceptions that are annotated in a function's type signature and subject to static analysis are called checked exceptions. Python and C++ do not support checked exceptions—only unchecked exceptions.
Some other languages do support checked exceptions with rigorous static analysis (e.g., Java, where exceptions are deeply engrained into the language, and you have no choice but to think about them all the time... Though it still doesn't solve the problem completely).
Various error-handling mechanisms have appeared in other programming languages (especially newer ones), partly in response to this debate (see, e.g., Rust's match feature). But they all have their pros and cons.