I'll answer from the point of view of C++. I'm pretty sure all the core concepts are transferable to C#.
It sounds like your preferred style is "always throw exceptions":
int CalculateArea(int x, int y) {
if (x < 0 || y < 0) {
throw Exception("negative side lengths");
}
return x * y;
}
This can be a problem for C++ code because exception-handling is heavy — it makes the failure case run slowly, and makes the failure case allocate memory (which sometimes isn't even available), and generally makes things less predictable. The heavyweightness of EH is one reason you hear people saying things like "Don't use exceptions for control flow."
So some libraries (such as <filesystem>
) use what C++ calls a "dual API," or what C# calls the Try-Parse
pattern (thanks Peter for the tip!)
int CalculateArea(int x, int y) {
if (x < 0 || y < 0) {
throw Exception("negative side lengths");
}
return x * y;
}
bool TryCalculateArea(int x, int y, int& result) {
if (x < 0 || y < 0) {
return false;
}
result = x * y;
return true;
}
int a1 = CalculateArea(x, y);
int a2;
if (TryCalculateArea(x, y, a2)) {
// use a2
}
You can see the problem with "dual APIs" right away: lots of code duplication, no guidance for users as to which API is the "right" one to use, and the user must make a hard choice between useful error messages (CalculateArea
) and speed (TryCalculateArea
) because the faster version takes our useful "negative side lengths"
exception and flattens it down into a useless false
— "something went wrong, don't ask me what or where." (Some dual APIs use a more expressive error type, such as int errno
or C++'s std::error_code
, but that still doesn't tell you where the error occurred — just that it did occur somewhere.)
If you can't decide how your code should behave, you can always kick the decision up to the caller!
template<class F>
int CalculateArea(int x, int y, F errorCallback) {
if (x < 0 || y < 0) {
return errorCallback(x, y, "negative side lengths");
}
return x * y;
}
int a1 = CalculateArea(x, y, [](auto...) { return 0; });
int a2 = CalculateArea(x, y, [](int, int, auto msg) { throw Exception(msg); });
int a3 = CalculateArea(x, y, [](int, int, auto) { return x * y; });
This is essentially what your coworker is doing; except that he's factoring out the "error handler" into a global variable:
std::function<int(const char *)> g_errorCallback;
int CalculateArea(int x, int y) {
if (x < 0 || y < 0) {
return g_errorCallback("negative side lengths");
}
return x * y;
}
g_errorCallback = [](auto) { return 0; };
int a1 = CalculateArea(x, y);
g_errorCallback = [](const char *msg) { throw Exception(msg); };
int a2 = CalculateArea(x, y);
Moving important parameters from explicit function parameters into global state is almost always a bad idea. I do not recommend it. (The fact that it's not global state in your case but simply instance-wide member state mitigates the badness a little bit, but not much.)
Furthermore, your coworker is unnecessarily limiting the number of possible error handling behaviors. Rather than permit any error-handling lambda, he's decided on just two:
bool g_errorViaException;
int CalculateArea(int x, int y) {
if (x < 0 || y < 0) {
return g_errorViaException ? throw Exception("negative side lengths") : 0;
}
return x * y;
}
g_errorViaException = false;
int a1 = CalculateArea(x, y);
g_errorViaException = true;
int a2 = CalculateArea(x, y);
This is probably the "sour spot" out of any of these possible strategies. You've taken all of the flexibility away from the end-user by forcing them to use one of your exactly two error-handling callbacks; and you've got all the problems of shared global state; and you're still paying for that conditional branch everywhere.
Finally, a common solution in C++ (or any language with conditional compilation) would be to force the user to make the decision for their entire program, globally, at compile time, so that the un-taken codepath can be optimized out entirely:
int CalculateArea(int x, int y) {
if (x < 0 || y < 0) {
#ifdef NEXCEPTIONS
return 0;
#else
throw Exception("negative side lengths");
#endif
}
return x * y;
}
// Now these two function calls *must* have the same behavior,
// which is a nice property for a program to have.
// Improves understandability.
//
int a1 = CalculateArea(x, y);
int a2 = CalculateArea(x, y);
An example of something that works this way is the assert
macro in C and C++, which conditions its behavior on the preprocessor macro NDEBUG
.