A better UE_LOG, part 3
Update: UE_LOGFMT fixes most of the issues with UE_LOG. These techniques can still be useful if you want something more comfortable to use.
I have also published a sample library that implements some of the ideas presented by this series.
So far, we’ve replaced the fragile, legacy nightmare of FString::Printf
with
a modern, type-safe formatter suited to your taste that can be used with UE_LOG
and/or AddOnScreenDebugMessage as desired.
Other than this directly making anything debuggable like AActor*
or your own
custom types having a readable string representation without having to go
through hacks such as Thing ? *Thing->GetName() : "null"
, it also completely
eliminates entire classes of issues, such as wasting time on “debugging” why a
number was “always 0.0” when it was an int
formatted with %f
and correct all
along, crashing into a null %s
, or forgetting yet again that %hs
exists
(this one seems to be popular).
If you opted to go with an operator<<
or std::format
-based solution, that
also prevents a mismatch between parameter placeholders and parameters at
compile-time. Nice!
Welcome to the early 2000s in other programming languages.
Next steps
One frequently-sought feature of logging is the ability to completely eliminate it from a shipping build, that is, absolutely 0 runtime CPU cost, and no string fragments for the… curious players to data mine. This was lightly touched upon in Part 1, but as always, we can do better.
In Part 3, we’ll add two new features to our fledging logging system, namely the ability to eliminate anything from Shipping builds, not just debug prints, and finer-grained runtime control of what gets debugged with cvars.
I’ll be going through various ideas on how to do this, that will hopefully serve as inspiration for your own logging system that matches your needs and preferences.
CVars
CVars, or console variables, are global variables directly exposed to the Unreal console and changeable at runtime by any developer or tester.
Unreal also provides some functionality to disable these in Shipping, but we’ll go one step beyond and conditionally not compile them.
There are many ways to define a cvar from code, here’s one:
TAutoConsoleVariable<int> GMyCVar(TEXT("MyCVar"), 0 /*default value*/,
TEXT("Description of this cvar"));
// Then read from it like so:
int Value = GMyCVar.GetValueOnAnyThread(); // or GetValueOnGameThread, etc.
Unless you care about thread synchronization for these cvars (like Unreal does for game thread ↔ rendering), it’s fine to read it like so; the worst that can happen is that your debugs are one tick late. Since you’ll be typing a console command manually, you won’t be tick accurate anyway.
There’s some amount of boilerplate in the code above, so let’s do what LOCTEXT_NAMESPACE does and define a “debug namespace”.
For simplicity, the following code blocks will assume that you have unity build
OFF, which is NOT the default.
If you want to make this work for unity builds, you’ll need to mangle a variable
name based on the Name
parameter or CURRENT_FILE_ID
(generated by UHT, so it
won’t work in all files) or add an extra variable name parameter to your debug
namespace macro.
namespace{}
is also less useful, since it will apply to your entire unity
translation unit.
Auto-cvar
With unity off, we can comfortably place a macro at the top of a .cpp file without it leaking to other files, no #undef required.
#define DEBUG_NAMESPACE(Name) namespace { \
TAutoConsoleVariable<int> GThisFileDebugCVar(TEXT("MyProject." #Name), 0, \
TEXT("Debug level for " #Name)); \
} static_assert(true)
The static_assert(true)
is the second best thing we can do to enforce a
semicolon, since do{...}while(0)
doesn’t work at this level.
In Shipping, #define this to just static_assert(true)
to not have the cvar.
Near the top of a .cpp file, DEBUG_NAMESPACE(Example);
will provide a cvar
called MyProject.Example
which will be available in GThisFileDebugCVar
.
Since this is all in an unnamed namespace, every .cpp can safely have its own
copy of the global variable, like a static
global variable would work in C.
A wrapper macro using it could look like this:
#define MY_WARNING(...) do { \
if (GThisFileDebugCVar.GetValueOnAnyThread() != 0) \
MyWarningImpl(__VA_ARGS__); \
} while(0)
Using if constexpr
The macro above covers debug prints, but there are many other debug things that
you might want to do conditionally, such as drawing shapes.
These calls can get very complex and ugly if shoehorned into one line of code,
so ideally, we’d like if an entire code block could be conditionally compiled
out, similar to checkCode
, but hopefully better.
The if
can be moved out of the logging macro:
// The cvar is interpreted as 0=fatal only, 1=fatal+warnings, 2+=everything
#if NO_LOGGING
#define IF_MY_TRACE_ENABLED() if constexpr (false)
#define IF_MY_WARNING_ENABLED() if constexpr (false)
#define IF_MY_FATAL_ENABLED() if constexpr (false)
#else
#define IF_MY_TRACE_ENABLED() if (GThisFileDebugCVar.GetValueOnAnyThread() >= 2)
#define IF_MY_WARNING_ENABLED() if (GThisFileDebugCVar.GetValueOnAnyThread() >= 1)
#define IF_MY_FATAL_ENABLED() if constexpr (true)
#endif
The empty ()
makes the macro look more like an if()
, which makes
clang-format behave if you use it with IfMacros
.
Otherwise, it’s not needed as far as C++ is concerned, and you can drop it.
These macros let you write if
-looking code that will even cleanly work with
else
!
IF_MY_TRACE_ENABLED()
{
FVector From = CallExpensiveThing();
FVector To = CallLengthyOtherThing(With, Many, Parameters, Because, Why, Not);
DrawDebugLine(GetWorld(), From, To, ...);
}
Due to the if constexpr, this entire code block will not exist in Shipping.
With these if macros, the logging ones no longer have to be conditionally #defined
to (void)0
in Shipping, and can always be the same:
#define MY_WARNING(...) \
do{IF_MY_WARNING_ENABLED() MyWarningImpl(__VA_ARGS__);}while(0)
operator<<
If you’re using an operator<<
-based formatter, conditional compilation gets a
little trickier.
You can if constexpr
away using it, but if you don’t want to indent every
single usage and retain the ability to use one-liners, the next best thing is to
define the formatter as a trivial no-op in shipping.
This WILL evaluate its arguments, though, and you’ll be relying on the
compiler to discard as many useless parts as it can.
This could (and probably will) lead to strings leaking.
struct FShippingLog
{
template<typename T>
FShippingLog& operator<<(T&&){return *this;}
};
As a hybrid solution, you can macro the entire expression so that it can be
erased in Shipping.
Consider operator,
or a concatenating variadic function from Part 1 and
__VA_ARGS__
in this case, though.
#define MY_EXAMPLE_LOG(Expr) \
do{FMyLogger()<<Expr;}while(0)
// Use as: MY_EXAMPLE_LOG("Player " << PlayerID << " has logged off");
Reducing debug spam
This section targets AddOnScreenDebugMessage specifically and doesn’t affect UE_LOG.
One feature of AddOnScreenDebugMessage is that you can provide a Key
for your
text item, which will make any subsequent pieces of text with the same key
replace its existing counterpart.
This is really useful for messages that are repeated on tick: you can even build
a small debug visualizer with clever use of keys with none of the additional
code that you would use for a real debug visualizer (which remains useful in its
own right and out of scope for this article).
Adding a keyed variant to our debug macros is trivial, but once again, we can do a little better than that.
Assuming that we don’t really care about two debug namespaces being turned on at
the same time, we can rely on __LINE__
to generate debug keys that will be
unique in a single file.
This could be integrated into the macro:
// Spam this on tick, it will only display 1 line on screen:
#define MY_WARNING_UNIQUE(...) \
do{MyWarningImpl(__LINE__, __VA_ARGS__);}while(0)
// with MyWarningImpl taking uint64 Key as its first parameter
Or, if you want a few if/else writes to target the same key, you can use a slightly different macro:
void AActress::Tick(float DeltaSeconds)
{
Super::Tick(DeltaSeconds);
constexpr uint64 DebugKey = __LINE__;
if (bAngry)
MY_TRACE_KEYED(DebugKey, "{0} is angry due to {1}", this, AngerReason);
else if (bFrightened)
MY_TRACE_KEYED(DebugKey, "{0} is frightened", this);
else
MY_TRACE_KEYED(DebugKey, "{0} is in normal state", this);
}
The __LINE__
boilerplate can be easily wrapped in yet another macro if you
prefer:
#define MY_DEBUG_KEY(Name) constexpr uint64 Name = __LINE__
// Or, going all out with modern C++ if you prefer:
#define MY_DEBUG_KEY(Name) constexpr uint64 Name = \
std::source_location::current().line()
Since these keys are 64-bit wide, you can comfortably use their higher bits for
a per-file numeric identifier to eliminate any collisions.
This can be defined in, e.g., DEBUG_NAMESPACE
:
#define DEBUG_NAMESPACE(ID, Name) namespace \
{ constexpr uint64 GThisFileHighKey = ((uint64)ID) << 32; \
/* original macro contents go here */ }
#define MY_DEBUG_KEY(Name) constexpr uint64 Name = GThisFileHighKey | __LINE__
Use this version as, e.g., DEBUG_NAMESPACE('STAT', Stats)
, or
DEBUG_NAMESPACE('WEPN', Weapons)
.
Note that it’s '
and not "
.
If you really want to automate this to the brim, you can use a constexpr
function to hash __FILE__
and hope for no collisions, abuse &__FILE__
being
a valid pointer, or drop the constexpr
and instead use something like:
// Define it somewhere with a relatively high starting value
extern std::atomic<int> GMyGlobalDebugCounter;
#define MY_DEBUG_KEY(Name) static const uint64 Name = ++GMyGlobalDebugCounter
This works for both the UNIQUE
and KEYED
flavors of the macros above.
Place the variable inside the scope of the do-while for UNIQUE
.