The use of exceptions in C# is basically okay, but you should be careful not to use too many exceptions. If exceptions are constantly used for all possibilities, the code can become slow and inefficient. These exceptions are actually meant to be used when something unexpected happens or the normal program is messed up. It would be good to use these exceptions only for such special cases so that the code remains efficient. Sometimes other methods such as return values or special error codes are better to make things faster and smoother.
This is exactly where this article comes in, to show an alternative, how it can be done “differently”! (but it doesn’t have to!! 😉, as is so often the case in IT, it depends….)
The problem:
In C#, an exception is often thrown to report error states. Like this, for example:
public int Divide(int numerator, int denominator)
{
if (denominator == 0)
{
throw new Exception("Division by zero is not allowed.");
}
if (denominator == 1)
{
throw new Exception("Division by 1 always results in the dividend.");
}
return numerator / denominator;
}
The challenge is that exceptions are thrown when errors occur in this method, which often leads to suboptimal efficiency. This is largely due to the additional tasks involved in throwing exceptions, which can lead to slowdowns.
These can be:
Performance aspects: Raising an exception requires additional resources to activate the exception mechanism and process the exception. Compared to other methods, this process can be more time-consuming.
Stack manipulation: When exceptions are thrown, the stack must be searched to find the appropriate catch block. This process can cause additional overhead.
Interruption of the control flow: Raising an exception leads to an interruption of the normal control flow of the application. This can lead to inefficient code, especially when exceptions are used to handle normal conditions or errors that are not considered “exceptional”.
Design principles: Exceptions should typically be reserved for “exceptional and unpredictable events”. Throwing exceptions for normal program control can lead to a suboptimal design and affect the readability of the code.
The “Result Pattern” is an alternative to exception-based error handling. Instead of triggering exceptions, a special result object is returned that contains the success or failure of an operation as well as error information. This allows the normal program flow to run more smoothly. Developers can check the result object and react appropriately, resulting in clearer and more predictable error handling in the code.
The Result-Objekt:
The Result pattern introduces its own result type, which represents the success or failure of an operation. This can be represented by a generic class:
public class Result<TValue,TError>
{
public readonly TValue? Value;
public readonly TError? Error;
private bool _isSuccess;
private Result(TValue value)
{
_isSuccess = true;
value = value;
error = default;
}
private Result(TError error)
{
_isSuccess = false;
value = default;
error = error;
}
//happy path
public static implicit operator Result<TValue, TError>(TValue value) => new Result<TValue, TError>(value);
//error path
public static implicit operator Result<TValue, TError> (TError error)=> new Result<TValue, TError>(error);
public Result<TValue, TError> Match(Func<TValue, Result<TValue, TError>> success, Func<TError, Result<TValue, TError>> failure)
{
if (_isSuccess)
{
return success(Value!);
}
return failure(Error!);
}
}
This code defines a generic class called Result. The class has two generic types, TValue for the success value and TError for the error value.
- The class has public fields Value and Error, which hold either the success value or the error value. There is also a private field _isSuccess, which indicates whether the operation was successful.
- There are two private constructors, one for the success path (_isSuccess is true) and one for the error path (_isSuccess is false).
- The class also contains two static methods (implicit operator) that allow an instance of the class to be created by passing either a success value or an error value.
- The Match method accepts two functions, success and failure, and depending on whether the operation was successful or not, the corresponding function is called.
In summary, this class is used to represent the success or failure of an operation in a application and provides mechanisms to deal with both cases.
These implicit operators allow instances of the Result class to be created in a compact way, depending on whether a success value or an error value is passed. The implicit keyword means that the conversion takes place automatically without the developer having to explicitly write a conversion expression.
Creating the error type
The success value of an operation is kept generic, as the expected value should not be defined here. Of course, you could also define a success value type here, but this would have no added value.
The situation is different for the error value, where an error object can be created which can be used to transport the error.
This is because an error object usually consists of an error code and a message that describes the respective error.
To do this, it is sufficient to create a record which we seal.
public sealed record Error(string Code, string? Message = null);
This gives us an error type that can contain any error code and the corresponding error message.
We can now use this error type in our result type.
Defining error objects:
One advantage of the Result Pattern is the readability of the code.
In this example, we are implementing a mathematical operation, the division. Within this context, we emphasize that division by zero is not allowed. To this end, we will return an error as the result.
Furthermore, we specify that a division by 1 is also erroneous.
To do this, we create a static class that only contains error objects for division, which has the advantage that the code remains very easy to read.
public static class DivedErrors
{
public static readonly Error DivisionByZero = new("Dived.DivisionByZero",
"Division by zero is not allowed.");
public static readonly Error DivisionByOne = new("Dived.DivisionByOne",
"Division by 1 always results in the dividend.");
}
Use of the result pattern:
Now we can change the method (from The problem) for the division using the Result pattern:
By using the implicit operator from the Result object, we don’t even need a conversion anymore, but can directly throw a specific error.
public Result<int, Error> Divide(int numerator, int denominator)
{
if (denominator == 0)
{
return DivedErrors.DivisionByZero;
}
if (denominator == 1)
{
return DivedErrors.DivisionByOne;
}
int result = numerator / denominator;
return result;
}
This makes the code very clear and easy to read.
In this example, the code is straightforward, but I think you can imagine that with code that is “longer”, this advantage becomes much clearer.
Use of pattern matching:
The Match method in this Result<TValue, TError> is used to react to a value of the Result type based on the success or error status of the Result object.
The method checks the internal status (_isSuccess) of the Result object. If the status is set to success (true), the success function is called and the result is returned. Otherwise, the failure function is called and the result is returned.
Pattern matching is thus implemented and evaluated in the caller:
var divisionResult = mathOperation.Divide(numerator, denominator);
var rslt = divisionResult.Match(
resultValue => resultValue,
error => error);
if (rslt.Error != null)
{
Console.WriteLine(rslt.Error.Message);
}
else
{
Console.WriteLine(rslt.Value);
}
Advantages of the Result Pattern:
- Explicit error handling: Developers have to consciously deal with success or failure.
- Clear readability: Code becomes more readable as deeply nested try-catch blocks can be avoided.
- Performance improvement: Avoiding exceptions can improve performance.
Extended use:
Extended use:
- Combine result types: You can combine the Result type for complex scenarios, e.g. if you need error details.
- Creation of auxiliary methods: Auxiliary methods can be created to facilitate the use of the result pattern.
Conclusion:
The Result Pattern in C# provides a structured method for dealing with errors. It promotes clearer code and allows developers to more precisely control how they want to handle error conditions without resorting to the expense of exceptions. It is particularly useful in situations where errors are expected and an exception-based approach would be inefficient.
The performance benefits of the Result pattern compared to using exceptions can be particularly evident in situations where errors occur relatively frequently. Here are some reasons why the Result Pattern is often considered to be more performant:
Cost of exceptions: Throwing and catching exceptions is a resource-intensive operation. When an exception is thrown, the CLR (Common Language Runtime) must traverse the caller stack to find the appropriate exception handling block. This can lead to a noticeable overhead, especially if exceptions occur frequently.
Control flow: The result pattern enables a clearer control flow in the code. With exceptions, the normal program flow is interrupted by the throwing and catching of exceptions. This can lead to higher overhead, especially if the control flow constantly switches between normal and exception states.
Avoidance of unnecessary stack tracing:
When using the Result pattern, the control flow is controlled by the if and else statements without throwing an exception. This reduces the need for stack tracing, which can lead to better performance.
Predictability: The Result Pattern allows for more accurate prediction of program flow. Developers can better estimate which parts of the code will run successfully and which parts may contain errors. This is important for the writeability and maintainability of the code.
Easier optimization: The code that uses the Result Pattern can often be optimized more easily. Compilers can generate simpler machine code if the control flow is clearer. This can lead to better runtime performance.
Asynchronous programming: In asynchronous scenarios, the use of exceptions can lead to complex problems. The result pattern can offer a clearer and more performant solution here.
Benchmarking and profiling: In scenarios where performance is critical, it is advisable to use benchmarking and profiling tools. These can provide more accurate insights into the runtime performance of the code and help to identify bottlenecks.
Combination with other techniques: The Result Pattern can be combined with other techniques such as caching and memoization to further improve performance.
It is important to note that the performance gains from using the Result Pattern may not be dramatic in all situations. In many cases, modern JIT compilers and hardware can mitigate the effects of exceptions. However, it is advisable to consider the specific requirements and characteristics of a project and perform appropriate tests and measurements to determine the optimal approach.