This article focuses exclusively on how C# and .NET deal with exceptions and when to catch them.
Introduction
In this article, I will be focusing exclusively on how C# and .NET deal with exceptions and when to catch them. It might be relatable to how other languages use them (I know for sure it is to some extent similar to exceptions in both Java and C++) but it is definitely very different to how Python uses exceptions, as there it is used to stop iterators, something we can, but should never do, in C#.
Bad Example - Please Don't Write Code Like This
Take a look at this piece of code:
var cachedNames = new string[3];
try
{
PopulateCachedNames(cachedNames);
}
catch
{
}
UseOurPopulatedCache(cachedNames);
The PopulateCachedNames
could be a "very complex method" but, for brevity in this article, let's assume this is its code:
private static void PopulateCachedNames(string[] array)
{
for (int i=1; i<=array.Length; i++)
array[i] = i.ToString();
}
Did You Spot the Error?
Array indexes go from 0
to Length-1
(or "less than" Length
) yet I wrote the for
loop from 1
to Length
, meaning I never filled the index 0
and also that the last index this code tries to access is just invalid.
.NET doesn't allow us to access invalid array indexes and corrupt memory. So, it gives us a "safe" exception instead of causing any memory corruption. Such an exception is there to let us know that we did something wrong as the language could just do nothing in that situation, but that would be just "hiding bugs" instead of making them visible, as the code would still have the off by 1 error.
Yet, exceptions can be caught and, in this "sample", I did exactly that, leaving the cachedNames
array "mostly" populated. I can go even further and say that depending on how the method UseOurPopulatedCache
uses the array, this might work with no further issues.
Yet, we just ignored an exception that could be easily fixed if we used the right indexes in the loop. Wouldn't that be better? And, assuming we fix the loop, should we keep that try
/catch
"just in case"?
As I don't want to leave any doubts, the answers are, respectively, yes and no. We should fix the code, and we should not be "pro-actively" trying to catch exceptions "just-in-case".
Possible Call Outcomes
Focusing on C#, what are the possible kind of outcomes we might get when calling a method, assuming "exceptions" are an alternative outcome to a call?
I would love if the answer to the question was just valid outcomes and nothing else, but unfortunately I see the situation more like this:
- Direct return values (or
void
) - Return values that include error codes
- Treatable exceptions - Red Flag
- Pre-condition exceptions
- Post-condition exceptions
- Asserts - Another way of throwing exceptions
- Asynchronous Exceptions
There are, of course, even more outcomes, like the entire OS crashing, but I am definitely not focusing on these odd cases as my focus is really on returns and the managed exceptions provided by C#/.NET.
So, coming back to the list I just presented, I can say that the results seem to be of any of these three categories:
- Expected valid results - Just return values
- Expected invalid results - Error code and maybe treatable exceptions
- This should never happen on a released app - all the others
And, for the "this should never happen" situation I could sub-divide it into "this should never happen - it's my own code doing something wrong" and "this should never happen - users of my code did something wrong".
Independently on "why" a situation that "should never happen" happened, it is probably best to just log the incident and let the app die.
Seriously... treating exceptions about things that should not have happened to begin with means something is way wrong, and there is a chance that keeping the app running by catching the exception will lead to more and more corrupted states... and also a bad crash at some point.
Exploring the Situations
Let's explore each one of the situations:
Direct Return Values (or void)
This is actually the most common case and that will happen most of the time. We call a function, method or get or set a property value and things just work. If we expected return values, they are the return values we wanted, not an "error code" or similar.
Return Values That Include Error Codes
In this case, the method or function clearly has a return value, which is prepared to give users at least "a basic indication" that the expected call failed. This can happen by giving "magic values" as returns (like returning -1
when only 0
or positive values are expected) or by returning more complex types. In any case, it doesn't matter how much error information is included in the result, it can be seen as either "valid" or "invalid" without dealing with exceptions or "alternative return paths".
Treatable Exceptions
This is the last case of "valid outcomes", yet I would put this one in a very special category named "we should not do this except if we are forced to by existing frameworks".
OK... maybe that's too harsh... but I am focusing on the fact that generating exceptions is costly and "disrupts" normal return values. Generating exceptions at bad places, that didn't expect for an exception, means that we might leave corrupted data around but, as I said in the beginning of the article, Python uses exceptions to stop iterators, and the same can be done in .NET if we wanted... so, they might work as expected, yet, I would seriously ask nobody to do that, except, maybe, as an exercise.
I will not argue that using try
/catch
, try
/finally
or just the using
clauses in C# helps us avoid data corruption. All those exception handling mechanisms definitely do help our current code behave well if an exception is thrown, yet they don't guarantee external code to behave correctly. For example, I know many C++ libraries that just disable exception treatment because it "doesn't work as expected". This means that if such a library is part of our app and it calls a C# method that throws, it would just kill the app (what's worse, with possibly a very cryptic call stack). So... assuming we created the treatable exception, we might reconsider this and just use return values that include error codes instead.
Exploring this a little further, I am always reminded of the Stream
class for its disparity between the Read
and Write
methods. Read
returns an int
, telling how much data was actually read, which can be less than the amount we asked, and can also return 0
to tell that the Stream
ended or negative values to indicate errors. Write
is void
, so it normally doesn't return but, if an error happens - like the disk is full, or the connection was lost - an exception is thrown.
While I can see why this was seen as a good idea, I would prefer to have 2 sets of methods, like Read
and Write
returning void
and throwing if needed, and another set of methods free of exceptions, like TryRead
and TryWrite
, returning how much data was actually read or written or returning error codes.
Aside from the dangers of these exceptions, they still exist, and maybe you still prefer to use them to keep "most of the code" clean from having lots of "ifs" to deal with very rare error conditions. Assuming we have exceptions like this, I would say these are the only ones we can (and possibly should) be catching without rethrowing.
Pre-Condition Exceptions
These are the most common exceptions, at least when we are talking about the Base Class Libraries or code that does the proper validations before going forward, and so I will say that they are mostly exceptions caused when validating input arguments.
For example: We create a List
. Add 5 items (which are indexed from 0 to 4)... if we try to access index -1 or lower, or index 5 or higher, we get an exception.
To some developers that is annoying, especially if it is just an "off-by-one" situation that kills our running app. But such an exception is really telling us that we did something wrong, and we will get rid of all those exceptions as soon as our code is fixed.
So, assuming our code is right, those situations should never happen and no exception would be thrown. We can see them as helpful validations that tell us we did something wrong as soon as we make a mistake (instead of just corrupting memory and letting bad things happen). Should we ever catch
an exception like that to keep our app running?
Assuming those are really pre-conditions, the call that just threw the exception probably didn't corrupt any states and would be OK to keep using the object after a catch
. Yet, if we were sure that we were not passing in invalid arguments and we got the exception, we have a serious problem that needs to be fixed. Either our code, or the code that we are calling, is bugged.
Catching the exception when the object that threw the exception is going out of scope might be OK (I would still avoid it), but if we are still going to use that object again, we have big chances of just corrupting (more) data and we will not really keep the app running. On the contrary, we are just making it harder to spot where the real issue happened. So, should we catch these exceptions?
Post-Condition Exceptions
Post-condition exceptions means that we are validating states after a call finished. For example, a method that should never return null
, but actually does, could be seen as violating a "post-condition" and the programming language could translate this into an exception.
Honestly, I don't see many language resources to validate those post-conditions and see, instead, some callers validating the results of their method calls, at every call, maybe using a helper method to do the required validations, or completely ignoring the fact that the results can be wrong. There's not much to do here "exception wise" if a method already returned something it should not, aside from fixing such a method if we have access to its source code.
Anyways, if we are actually having post-condition exceptions, this means the code just detected something wrong after finishing an action, which most probably means that the method is bugged and that we now have an object with some invalid internal states.
This is the kind of situation that "should never happen" as this means the called code just did something wrong. If all our input values are correctly validated, our code is working, how did we end-up with any invalid result or state? And, independently if the invalid value was "forcibly put" using the debugger, is there anything we can really do to keep using this object?
If not, at least the current object needs to die. But assuming we don't know the inner workings of a method call, we don't know if it is depending on static states or interacting with other objects, so there might be more bad things around.
Asserts - Another Way of Throwing Exceptions
Asserts are, mostly, just another way to generate exceptions. It is also confusing that the same name (that is, "assert
") is used by many Unit-Test frameworks, but with a somewhat different purpose.
In unit-tests, asserts are the way for tests to validate the conditions and allow the test to pass or fail. They are test specific things and don't affect non-unit-test code.
Outside of unit-tests, asserts are usually used to check that the internal states of our objects are OK. This is very similar to the normal pre-conditions and post-conditions but, instead of validating that our input arguments are valid, we are validating that the internal fields of our object are in a good state.
When an assert fails, it usually generates an exception. I say "usually" because different assert implementations can do different things, like showing a window with the option to ignore it, just killing the app immediately and the like.
Personally, if an assert is doing a valid check (instead of being just a "bad assert"), ignoring it when it fails is just dangerous... yet this is what happens in most release builds. To allow the code to "run faster", the assert checks are usually not compiled in release builds. Yet, by doing so, we might lose very helpful information if the bad situation indeed happens in those release builds.
Disabling asserts on Release builds is such a controversial topic that it would probably deserve its own article - Just keep in mind that the "asserts" may not be going to run on specific builds.
In any case: If our assert just proved that our object is corrupted, can we keep going forward? Should we catch
the exception and keep using the object that is already known to corrupted?
Asynchronous Exceptions
Asynchronous exceptions happen when an exception is thrown and it was not even generated by the code we were invoking. The greatest example of an asynchronous exception in .NET is the ThreadAbortException
, that is thrown when there's a call to Thread.Abort()
. Although we can catch ThreadAbortException
s and even cancel the abort call altogether, the .NET itself is not really written in a way that guarantees our most basic objects would be in a sane state when this happens. So, assuming we got such an exception coming from a different thread and facing the possibility of anything being broken... should we really keep going? Is it a good idea to catch this exception and maybe cancel it?
When Should We Catch Exceptions?
I think that after seeing my list, one of the things that might come to mind is that "we should not catch exceptions".
And... well, I was mostly saying that... but what I really mean is that we should almost never "treat" exceptions. That is, catching an exception without rethrowing it is, in most cases, a bad thing. And, if we don't rethrow them, they usually need to be exceptions made to be treated.
Although nothing on the exceptions themselves say that, the purpose and the documentation of the exception might make it clear that they can be treated, like IOException
s thrown by Stream.Write
(but not the other exceptions, like ArgumentNullException
).
Main App Loop vs Local Exception Treatment
I once did an interview where the interviewer liked the fact that my code was throwing for invalid input arguments, but didn't like that my code didn't have any try
/catch
blocks. According to the interviewer, the "main app loop" should have try
/catch
blocks to treat any exception and let the app keep running if something unexpected happened.
Funny enough, the interviewer would be OK if my code didn't have any try
/catch
clauses as long as my code was not doing the throw
. Like, if I accessed an "invalid index" of an array, it would be OK that such an exception was not caught as it was not "my code".
Views like that, that "our code" needs to behave differently from the main .NET code, are just off to me. If it is failing for the exact same reason, but in one case it needs to be treated and in the other it doesn't, means that we are just using two different measures to the same problem based on who wrote the code instead of using a single measure based on what the code is doing.
Anyways, the idea of having a main place to treat all the exceptions, like closest to the "main app loop" instead of where the exception really happened, just increases the chance of bad things happening.
It is perfectly OK to have a generic exception handler that catches all exceptions if we are just logging the error and quitting, but it is not OK to just "discard" the exception and keep running as if nothing had happened when we have no idea why the exception was thrown in the first place. Doing so might work for small apps, especially if people start to generate exceptions instead of having proper error return mechanisms, but the chances of completely corrupting the app memory become more and more real the more external libraries we import and just the bigger the app becomes.
If a "main loop" mechanism might need to deal with any random exception and still keep the app running, we should consider using AppDomain
s or AssemblyLoadContext
s to be able to "unload" and then "reload" the bad libraries when unknown exceptions happen.
Conclusion
We should avoid treating exceptions just "to be sure the app is not going to crash". For that, the best we can do is to write bug-free code.
It is better to let the app crash if we don't know why the exception was thrown and, if we know, we should deal with the problem by either using alternative methods that might provide error codes, or by catching a treatable exception at the closest place from where it was thrown, not in a generic solution. If the app really needs a generic solution to keep running, like a web server or similar, seek the alternatives with AppDomain
s or AssemblyLoadContext
s, as these are probably going to work much better and will just avoid "bad things from happening" because of corrupted states and objects were still alive.
History
- 27th October, 2021: Initial version