Aussie AI
Chapter 9. Error Checking
-
Book Excerpt from "Safe C++: Fixing Memory Safety Issues"
-
by David Spuler
Error Checking
Everyone’s always known that it’s good programming style to check all error return codes.
It’s extra work, but everyone does it anyway, because it’s so important.
I’ve been coding for years, and I’ve never seen a printf or fopen without an if statement immediately after it.
Yeah, right! Many people don't even know that printf returns a value,
and as for fopen, this is common:
fp = fopen(fname, "r");
assert(fp != NULL);
Oh, well, technically that is an error return check! A better way to code it is:
fp = fopen(fname, "r");
if (fp == NULL) {
// Complain...
}
This is a very common approach in C++ programming, but when you think about it, there's a few things wrong with it:
- Relies on the programmer to add this code.
- Copy-paste errors in having error handling sequences everywhere.
- No way to enforce consistency in error handling.
- Enforcing the requirement for return checking is difficult.
This chapter examines some solutions to these problems.
Types of Error Checking
Everyone knows the basic idea of checking error returns from system functions. Let's have a look at the basic ideas:
- Checking standard library error returns
- Checking your own functions
But there are some other related issues:
- Validating input parameter values (in your own code).
- Validating standard C++ system parameter values (in a debug wrapper).
- Checking
errnovalues (these are not "returned" but set).
And there's the issue of how to integrate your basic error return checking in a way that's consistent with:
- Exception handing (i.e.,
try...catch). - Assertion failure handling
- Self-testing code
- Unit tests and regression tests
- Signal handling
For example, would you want an error detected in a function's error return
to throw an exception?
Function Return Attribute: nodiscard
The [[nodiscard]] attribute can be used in the return type of function declarations.
It encourages the compiler to issue a warning (not an error)
if the function is then used in a way that discards its return check.
There are two ways to use it: the basic way, or a way with an optional message parameter.
[[nodiscard]] int my_important_function();
[[nodiscard("Returns important status")]] int my_important_function();
Note that the optional parameter can only be a string literal. It cannot be a computed expression — not even a compile-time constant expression.
Obviously, this is helpful for enforcing a policy that certain functions should always get error-checked. Also, some of the standard library functions will have this setting, but it's implementation-specific which ones will.
Suppressing with void casts.
You can suppress a warning about discarding a return value,
simply by adding a type cast to void.
Examples include:
(void)my_important_function(); // Not that important
void(my_important_function()); // Also not
Compatibility before C++17.
Note that nodiscard was defined in C++17,
but there have been some earlier attributes used in some C++ compilers,
such as the _Noreturn attribute.
You probably shouldn't use these old attributes in newly-written code, but you might
see them during code maintenance.
There is also a generalized version, added in C++20, that allows an optional string literal containing a message or an explanation. This is obviously useful, but I feel like it could also get a little abused. Here's an example:
[[nodiscard("You idiot!")]] int my_important_function();
Detecting void casts.
I feel like there should be a way to detect where the code uses a type cast to void
so as to override these settings.
Otherwise, programmers can simply work around the nodiscard settings without
getting publicly shamed on the internal Slack channel.
Unfortunately, I'm not aware of a compiler setting for this.
Maybe some of the static analyzer tools have this capability,
but you could hand-code a simple solution with grep:
grep '[(][ ]*void[ ]*[)]' *.cpp
grep 'void[ ]*[(][ ]*[a-zA-Z_].*[ ]*[)]' *.cpp
The first one is very specific, but the second one might need some refining to avoid false positives.
Recursive Macro Error Checks
C++ allows macros to be recursive in the sense that they can use their own name. It’s not actually “recursive” and is actually limited to a once-only expansion, rather than an infinitely recursive expansion. This feature is a longstanding feature of C and C++ languages since they were created, so you can rely upon it. For example, these would be harmless:
#define memset(a,b,c) memset(a,b,c)
#define memcpy(a,b,c,d) memcpy(a,b,c,d)
The idea is to automatically add the error check macros:
#define memset(a,b,c) AUSSIE_ERRORCHECK(memset(a,b,c))
#define memcpy(a,b,c) AUSSIE_ERRORCHECK(memcpy(a,b,c))
But that doesn’t quite work, when used with this type of call:
errval = memcpy(....);
The do...while(0) trick expands out to give a compilation syntax error:
errval = do { ... // etc.
Similarly, the version with a combined macro and inline function also gets a
different type of compilation error:
errval = aussie_check_function(....)
The problem is that the return type of the inline function is void.
Hence, we’d need to go back and fix any code that uses the return value of memcpy or memset,
which would be a good job for a coding copilot, if only I didn’t have so many trust issues.
Instead, we can just fix the return type to be void* and use a pass-through of the return value:
#define AUSSIE_ERRORCHECK3(codeexpression) \
aussie_check_function2((codeexpression), "__func__, __FILE__, __LINE__)
inline void * aussie_check_function2(
void *expression,
const char *func, const char *fname, int lnum)
{
if (expression == NULL) {
fprintf(stderr,
"ERROR: System function returned NULL: in %s at %s:%d\n",
func /*__func__*/,
fname /*__FILE__*/,
lnum /*__LINE__*/ );
}
return expression; // pass through!
}
And we really should add a ridiculous number of round brackets around the macro parameters,
and also use #undef
for total macro safety:
#undef memset // safety
#undef memcpy
#define memset(a,b,c) (AUSSIE_ERRORCHECK3(memset((a),(b),(c))))
#define memcpy(a,b,c,d) (AUSSIE_ERRORCHECK3(memcpy((a),(b),(c),(d))))
Voila!
Now we have a set of macros that automatically adds return code error checking around
all calls to memcpy and memset.
And it should work irrespective of whether their returned values are used or not in the calls.
To use them properly,
we just need to #include a header file near the top of every C++ source file.
But it has to be
after any system header files because those system
header files have prototype declarations of functions like memcpy that our tricky macros will break.
Now we only have to add similar recursive macros for all 1,657 of the Standard C++ API functions. No, relax, I’m just kidding. There's only a few that matter.
Macro Intercepted Debug Wrapper Functions
Is there any way you can level up? We’ve already auto-added the error checking macros around all the standard library function calls. Can we do better?
Of course, we can!
One extension is to build debug wrapper function versions for the main API calls. These functions can then perform more extensive error self-checking than is performed within the standard library.
#undef memcpy
void* aussie_memcpy_wrapper(void *destp, const void *srcp, size_t sz)
{
void *ret = AUSSIE_ERRORCHECK3(memcpy(destp,srcp,sz));
return err;
}
#define memcpy(a,b,c) aussie_memcpy_wrapper(a,b,c) // Intercept!
Note that the #undef is really important here,
and must be before the wrapper function body.
If we’re not careful, our wrapper function can wrap itself, and become infinitely recursive.
The above example doesn’t do any extra error checking, other than what we’ve already put
into the error checking macro (i.e., AUSSIE_ERRORCHECK3).
However, we could add extra self-checking code for common errors
that arise from memcpy copy-pasting:
- Destination or source pointers are null
- Destination or source pointers are the wrong address scopes
- Destination pointer equals source pointer
The standard library may already find some
of those errors, and valgrind or other sanitizers would find even more.
However, we could go further with our analysis.
For example, some more extensive error checks possible could be:
memcpysize argument is zero or negative (after conversion tosize_t).memcpyarguments appear to be in reverse order.
The possible error checks from this type of system function interception are discussed further in the full chapter on debug wrapper functions.
Reporting and Handling Errors
What should an error checking macro do on failures? Some of the many options include:
- Print an error message
- Print the error code number, such as errno and its name with
strerror - Give source code context information
- Exit the program (or not?)
That’s not the full list, and some more advanced ideas for production-grade error handling include:
- Throw an exception and hope someone’s listening.
- Full stack trace (e.g.,
std::backtracein C++23). - Report a full error context for supportability in the wild.
- Log information to a file, not just to
stderr. - Abort the program to generate a “
core” file.
Reporting Error Context
A key aspect of reporting the error context is the C++ statements that triggered the issue. The basics of error context are these macros:
__func____FILE____LINE__
I don’t know why one is lower case and two are upper case, but it’s called international standardization. That’s an example of what makes C++ programming so fun.
However, I have to say that I think these source code context
macros are on their way out.
Once reporting the full stack trace in C++23
with std::backtrace is widespread,
why would we need those macros?
Also gone would be lots of preprocessor macro tricks that only exist
in order to report the source code context.
Instead, use an inline function and std::backtrace.
More advanced error context that can help with supportability includes things like:
- Date and time of error.
- User query that triggered the failure.
- Random number seed (for reproducibility of AI errors).
- Full stack trace (if available)
I feel like there should be an LLM for this. Maybe I’ll go look on Hugging Face.
Limitations of Macro Error Checking
Some problem areas include:
- Cannot intercept everything (e.g., can't intercept arithmetic operators to check for overflow or divide-by-zero).
- Macro interception is not perfect, with some valid syntax causing compile errors.
Two of these methods rely on preprocessor macro interception to auto-wrap the calls with debug checks. Unfortunately, macro interception isn’t a perfect solution, and some of the problems that macros may have include:
- Interception of
newanddeleteoperators is only possible at link-time. - Namespace-scoped calls fail: e.g.,
std::memcpy(...)orstd::memset(...) - Use of these standard function names as function pointers won’t work.
- Non-standard calling syntax: e.g., parentheses around the function name.
Much better than macro interception would be a way to link to a debug version of the standard C++ library. Many more complex error checks are possible than are performed, and this would significantly improve the timeframe to detect many types of coding errors.
But I have to finish by saying that the really major limitation is this:
Remembering to add it every time!
I’ve given a few suggestions for auto-fixing that issue above, but they’re far from perfect. Maybe the Standard C++ library needs a callback mechanism, or some other method whereby programmers can ensure that they never miss an error return.
|
• Online: Table of Contents • PDF: Free PDF book download |
|
Safe C++: Fixing Memory Safety Issues:
Get it from Amazon: Safe C++: Fixing Memory Safety Issues |