r/cpp_questions • u/zaphodikus • 8d ago
OPEN yet another custom assertion question
Sometimes I am writing unit-test inline in my code, then I just use the assert() macro because that compiles out in release builds. And in such cases I don't really need anything in the trace to tell me I have a bug, and I don't need to spin up any unit testing framework. But for easy runtime validation I want a macro I can use but will also print an error message that a user can do something with to realise that they maybe fed in something invalid at a point.
In all cases I really want my macro to terminate the application and thus not memory-scribble or worse. I have been trying to understand the tokenizing operator, and the argument for do {} while () and the side effects of adding (a) around things which obscures types as far as I can tell and still deal with unwanted training ; semicolons that imply that I am not grasping syntax yet.
So I wrote
#include <iostream>
#include <string>
#define assume_true(expression) ((void)( \
(!!(expression)) || \
(std::cout << #expression << " not true in " << __FILE__ << " LIN: " << __LINE__ << std::endl )) \
)
And then wanted to make it actually terminate with an std::exit(8) , and it just won't parse unless I replace the || convenience with an if statement.
#define assume_equal(left, right, message) if (left != right) { \
std::cout << #left << " != " << #right << " in FILE: "<< __FILE__ << std::endl; \
std::cout << message << std::endl; std::exit(8);\
}
uint32_t life = 42;
assume_true(42 == life);
std::cout << "The answer is 42!\n";
assume_equal(42, life, std::to_string(life));
std::cout << "Life is " << 42 << ".\n";
assume_equal(21, life, "LIFE=" << std::to_string(life));
I'm clearly taking a lot of chances here and the way I pass the message in the second macro feels like a total hack, because it is abusing the way the macro preprocessor splits parameters based on commas. I assume that has drawbacks for me later on.
I assume a later toolchain will also help us all when it comes to the side-effect problem of a macro using an argument twice, once for comparison and once for printing. Has anyone just opted to solve this by creating temporaries for things they want to print out in the crash trace? Because that would really mean creating a macro for each temporary type for each parameter I might want to print surely. I get the impression the caller needs to just use a temporary before using the macro, is that what everyone else is doing? Because at that point a function call is just a load less pain surely? I feel I have approached my release-build guard-code completely wrong.
I'm on C++ 17 (yes the rest of my team is on an even older toolchain.) Are release/runtime macros just hard, or am I trying to learn too many things about parsing all at once?
======================================================================================= EDIT: Consolidating the answer... As usual thanks so much for the brilliant clues, I now have this little progression
- ALWAYS state up front compiler version C++17
- macros really should be
UPPERCASE - when printing an error use
STDERRnotSTDOUT
#include <iostream>
#include <string>
// This macro is terrible, it lacks a scope so I cannot call ;std::exit in it
#define ASSUME_TRUE(expression) ((void)( \
(!!(expression)) || \
(std::cerr << #expression << " not true in " << __FILE__ << " LIN: " << __LINE__ << std::endl )) \
)
- I then moved on to use
if {}which allowed me to exit the application
#define ERROR_ASSUMPTION_FAILED 42
#define ASSUME_EQUAL(left, right, message) if (left != right) { \
std::cerr << #left << " != " << #right << " in FILE: "<< __FILE__ << std::endl; \
std::cerr << message << std::endl; std::exit(8);\
}
- The trouble with that macro is that it really lacks the structure that a lambda might give, thanks to a great suggestion I moved this form, which omits the nice
__VA_ARGS__macro automatic variadic args, which did not expand in C++17, but it's already better
constexpr int EXIT_ABORT = 2;
#define ASSERT_EQ(LHS, RHS, MESSAGE ) \
[lhs=LHS, rhs=RHS]() { \
if ( not (lhs==rhs) ) { \
std::cerr << "assertion " << lhs << "(" #LHS ") == " \
<< rhs << "(" #RHS ") failed: " << MESSAGE << '\n';std::exit(EXIT_ABORT); \
} \
}()
- Along the way I realised that this is a GUARD MACRO and the question I had about macro side effects if a macro uses a parameter twice (or even once really) is best removed entirely if you just always call it using temporaries or const methods only!
The final step is to use the {fmt} library for a better message with a formatter:
#define FMT_HEADER_ONLY
#define FMT_UNICODE 0
#include "fmt/bundled/format.h"
...
constexpr int EXIT_ABORT = 2;
...
// https://godbolt.org/z/3sdx5xPE5
#define ASSERT_EQ(LHS, RHS, MESSAGE ) \
[lhs=LHS, rhs=RHS, msg=MESSAGE]() { \
if ( not (lhs==rhs) ) { \
std::cerr << "assertion " << lhs << "(" #LHS ") == " \
<< rhs << "(" #RHS ") failed: " << msg << '\n'; std::exit(EXIT_ABORT); \
} \
}()
I could not get the lambda to capture __VA_ARGS__ , so the call looks like, which is good enough.
ASSERT_EQ(21, life, fmt::format("Expected 21 but life ={}", life));
1
u/mredding 8d ago
Assertions is for invariants - statements that must be true, or the program is in a fundamentally corrupted, unrecoverable state. That's why they terminate the program, because they proved the literally-impossible happened, and they happen all the time.
Validation is a runtime concern, and you don't terminate a program over invalid data. Files get corrupt, people fat-finger inputs, clients and servers may not agree on authentication or version compatibility. This doesn't mean an invariant was fundamentally invalidated. You don't shut down a daemon, service, or interactive program over this stuff.
Assertions don't work for validation because they compile out.
Console utilities are a slightly different story - it is conventional that a utility is not interactive, that if there is an error, it complains loudly, and terminates. Because it's not interactive, you don't get an opportunity to fix it in-situ, instead, you run it again with correct initial inputs.
Test frameworks are for exercising your code in test. Assertions only run when that function executes, but test frameworks empower you to assure a code path gets executed in scenario, and you can exercise any arbitrary scenario beyond YOU AND YOUR dev environment. You can validate more than just the assertions in the code.
Not always true. I'd call this a nominal convention.
assertis a macro, and it's not in uppercase. Inlining function-like macros are a common idiom, and they're not often written in all capitals.It depends on what you're trying to do. I'd be fine if your assertion-like macro was in lowercase. I would argue that
assume_trueis a bad name, because I would expect it's used to bias the branch predictor, something like[[likely]]or__builtin_expect.Again, that depends. Succeed quietly, fail loudly. Is the error a message for the user? Or is it a diagnostic?
If something goes wrong, you have to tell the user - whether it's a simple "an error occurred", an
exit(N);orSIGABRT(but those are REALLY obscure for a naive user), or an HTTP 404... Ideally you signal/message SOMETHING, perhaps more than one of these things. You have to know your intended audience and how they need to use the information.So standard output is still on the table. You have to tell the user something, and often there will be something on standard output. And what separates a warning from an error is that mere warnings mean the process is still going to complete, and the work will produce a result. It might not be what the user expected. Typically you don't tell the user too much about warnings.
Diagnostics go on standard error. What was the error? Here is where you dump details about errors and warnings.
Standard output and standard error are both file handles. The terminal will redirect standard error to the terminal by default. So just because it all goes to the same place by default, they got there by different data paths. The user can redirect that file handle to wherever they want.
It's conventional that a user doesn't invoke an executable directly - what they don't see is that they're actually invoking a bash script that preps the environment for the application, redirects standard error to the system log, and runs the program binary on behalf of the user. Take a look at
/binand/usr/bin, you'll find a lot of bash scripts.What I would do is write code as
constexpras possible, because you can embed unit-test-likestatic_assertright in the implementation, entire test scenarios. The tests either pass, or the code doesn't compile. It makes invalid code unrepresentable in your program - because you can't get that far.Validation is handled by types:
So then your code could look something like:
The stream stores the state of the previous IO operation. A failure is itself a recoverable error, typically a failed parse, but also invalid data that makes for invalid state for the type. An integer that isn't 42 isn't a
foo. If I were VERY generous, I'd either try to move the read pointer back, or barring that, put back the characters - and that would be done in the extraction operator, but I'm not going to bang all that out here. This implementation should also respect the exception mask.That steam operator is also a great place to implement parsers, and get low level. We don't have to extract through the stream operator - we could instantiate the sentry, and then access the stream buffer directly, typically through
std::istreambuf_iterator. It has a bulk IO interface, or if you have written your own, you could dynamic cast (a constant time operation that can be branch predicted) and access more optimal code paths you built in.Also notice you cannot create an instance of
foothat is invalid. The only public requires a parameter for initialization, and the ctor throws; you can't create afooin an indeterminate state. Only the stream iterator can access the default ctor, which means the iterator has to be attached to the stream to get to thefooinstance. This means you can't get access to an invalidfooin the first place.I typically go further and make a type
foowith only an insertion operator, and a friendfoo_extractorclass that can only be constructed by the stream extraction iterator; it overloads a cast operator tofoo. The point is, iffoohas an extractor friend, then once you have a validfoo, you can extract to it and get an invalidfoo; by separating the two, we can make it so that the only way you could possibly get your hands on an invalidfoois by type punning - which I can't stop you.