A better UE_LOG, part 2
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.
In part 2, we’ll revisit the logging system made in part 1 and look into how it
can be made better using new C++ features. UE4 can relatively easily be switched
to C++17, and as of UE4.27, C++20 is not that painful either (you’ll need to ask
for CppStandardVersion.Latest
and possibly disable shared PCHs).
UE5 is C++17 by default and C++20 is easy to turn on even with the binary Epic
builds: just add CppStandard = CppStandardVersion.Cpp20;
to your Build.cs.
Your mileage may vary depending on what platforms you plan to support.
C++17
if constexpr
This is the big one. Remember that typename = void
hack that we had to do in
part 1? What if we could write all of those as one single function so there is
no overloading hell?
if constexpr
, like its name suggests, evaluates its condition at compile time.
However, as a bonus, you can call functions that don’t exist if the compiler can
tell that the if
is not taken.
Armed with this knowledge we can merge most FillArgs()
overloads into one:
// This will get better in C++20
template<typename T, typename = int>
struct THasToString : std::false_type {};
template<typename T>
struct THasToString<T, decltype(std::declval<T>().ToString(), 0)> : std::true_type {};
// You still need this:
void FillArgs(FStringFormatOrderedArguments& Args)
{
}
template<typename F, typename... R> // no void!
void FillArgs(FStringFormatOrderedArguments& Args, F&& First, R&&... Rest)
{
if constexpr (std::is_constructible_v<FStringFormatArg, F>)
Args.Add(Forward<F>(First));
else if constexpr (THasToString<F>::value)
Args.Add(First.ToString());
FillArgs(Args, Forward<R>(Rest)...);
}
Try writing the missing branches for ->GetName()
and LexToString()
!
The solution is in Appendix A.
Did you notice the silent-but-horrible omission from the function above?
If F
matches none of the conditions, the parameter will be skipped without a
warning. The obvious fix would be else static_assert(false);
but that will
just make sure this will never compile.
The real fix is uglier: the condition has to depend on a template parameter to make sure it only gets evaluated when the template is instantiated.
// The left-hand side doesn't matter; it will be false anyway.
else
static_assert(std::is_void_v<F> && false);
A less hacky but slightly longer way of fixing this is to define a template
constant for false
:
template<typename> constexpr bool bFalse = false;
// ...
else
static_assert(bFalse<F>);
Fold expressions
The First, Rest...
parameter “unpacking” was a hard requirement in C++14, but
C++17 is able to iterate on parameter packs without resorting to recursion.
Running code on them is somewhat hacky and involves abusing operator,
so you
might want to keep the recursive version anyway. Here it goes:
// No need for the empty overload if you do this!
template<typename... T>
void FillArgs(FStringFormatOrderedArguments& Args, T&&... Params)
{
([&](auto&& Arg)
{
// If you'd like a type shortcut:
using F = decltype(Arg);
// if constexpr goes here
}(Forward<T>(Params)), ...);
}
This technique works with a separate single-param FillArg
template function,
or alternatively you can name the lambda like
auto FillArg = [&](auto&& Arg){/*code*/};
. Both of these are invoked this way:
(FillArg(Forward<T>(Params)), ...);
C++20
Template lambdas
The fold expression above can be rewritten using an explicit template parameter
instead of auto&&
if you prefer:
// This:
[&](auto&& Arg)
// Becomes this:
[&]<typename F>(F&& Arg)
Abbreviated function templates
Speaking of new template syntax, regular functions got parity with lambdas so
that you can use auto
parameters:
// This is a template
void FillArgs(FStringFormatOrderedArguments& Args, auto&& First, auto&&... Rest)
{
// ...
}
You don’t have to commit to one style:
template<typename F>
void FillArgs(FStringFormatOrderedArguments& Args, F&& First, auto&&... Rest)
{
// ...
}
Concepts… almost
Concepts would’ve been great to replace the SFINAE hacks from part 1, but we’ve
already replaced them with if constexpr
and don’t need to do this anymore.
As a reference this is how it could’ve looked:
template<typename T>
concept THasToString = requires(T Value) { Value.ToString(); };
template<THasToString F, typename... R>
void FillArgs(FStringFormatOrderedArguments& Args, F&& First, R&&... Rest);
// or, with auto:
void FillArgs(FStringFormatOrderedArguments& Args, THasToString auto&& First,
auto&&... Rest);
// or...
if constexpr (THasToString<F>) // no ::value, concepts work as constexpr bool
Args.Add(First.ToString());
Not just full concept
s, but requires
expressions themselves also work as
constexpr bool
. That means all the type checks can be directly contained
within FillArgs()
:
// You still need this for the recursive version:
void FillArgs(FStringFormatOrderedArguments& Args)
{
}
template<typename F, typename... R>
void FillArgs(FStringFormatOrderedArguments& Args, F&& First, R&&... Rest)
{
// if constexpr (std::is_constructible_v<FStringFormatArg, F>) still works
if constexpr (requires { FStringFormatArg(Forward<F>(First)); })
Args.Add(Forward<F>(First));
else if constexpr (requires { First.ToString(); })
Args.Add(First.ToString());
else if constexpr (requires { First->GetName(); })
Args.Add(First->GetName());
else if constexpr (requires { LexToString(Forward<F>(First)); })
Args.Add(LexToString(Forward<F>(First)));
else
// Pick a name you expect to never use for a requires-based solution,
// or feel free to stick with one of the C++17 methods.
static_assert(requires { First._spanish_inquisition_; });
FillArgs(Args, Forward<R>(Rest)...);
}
It’s not the nicest thing to read, but at least it’s compact and self-contained. Combine with fold expression lambdas to taste.
If you’re from the future and using C++23, you can use static_assert(false)
directly, without the requires
hack.
__VA_OPT__
##__VA_ARGS__
from part 1 was a nonstandard hack.
With C++20 there’s finally a standard way to handle 0 parameters:
// Replace this:
#define MY_LOG(Format, ...) MyLog(TEXT(Format), ##__VA_ARGS__)
// With this:
#define MY_LOG(Format, ...) MyLog(TEXT(Format) __VA_OPT__(,) __VA_ARGS__)
On MSVC, this requires setting bStrictPreprocessorConformance
to true in your
BuildConfiguration.xml or equivalent (-StrictPreprocessor
on the command line).
std::source_location
There’s now a non-macro replacement of __FILE__
, __LINE__
, et al., it even
supports columns as standard!
Normally you’d use it like this and rely on the default value:
void MyLogMaybe(const FString& Message,
std::source_location Location = std::source_location::current())
{
// ...
}
But this doesn’t really play well with variadic parameters. You can easily add it to a macro wrapper though:
#define MY_LOG(Format, ...) MyLog(std::source_location::current(), \
TEXT(Format) __VA_OPT__(,) __VA_ARGS__)
Note that __func__
is also standard since C++11 if you cannot use C++20; it’s
a variable though, not a macro. If you’re reading this from the future, you
might find std::stacktrace_entry
(C++23) useful, too.
Appendix: GetName/LexToString C++17 solutions
This is how you do ->GetName()
:
template<typename T, typename = int>
struct THasGetName : std::false_type {};
template<typename T>
struct THasGetName<T, decltype(std::declval<T>()->GetName(), 0)> : std::true_type {};
else if constexpr (THasGetName<F>::value)
Args.Add(First->GetName());
Unfortunately LexToString
is problematic. A lot of things attempt to take this
branch, for example pointers implicit converting to bool
.
This will be a warning-as-error and not compile. I’d suggest keeping this one
last, making sure more complex cases are handled first and/or handling the types
causing warnings separately and explicitly (e.g. std::is_pointer_v
).
template<typename T, typename = int>
struct THasLexToString : std::false_type {};
template<typename T>
struct THasLexToString<T, decltype(LexToString(std::declval<T>()), 0)> : std::true_type {};
else if constexpr (THasLexToString<F>::value)
Args.Add(LexToString(Forward<F>(First)));