Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 43

Variadic Macros for Enhanced Logging

Recommended resources

Hello! Welcome back to our project on building a high-performance telemetry engine.

In our last lesson, we explored the crucial role of C++ preprocessor macros in instrumentation. We learned that they are the industry-standard tool for injecting profiling code because they allow for automatic context capture (using __FILE__, __LINE__) and can be compiled out to nothing, achieving the zero-overhead goal that is central to our project.

Today, we'll put that theory into practice. Our goal is to implement a variadic macro that captures source location, timestamp, and a formatted message. We'll build the first version of our primary instrumentation macro, which will be the main way we feed data into our telemetry system. This macro needs to be as flexible and easy to use as a function like printf, capable of accepting a variable number of arguments.

1. The Need for Flexibility: Introducing Variadic Macros

Our previous discussion focused on macros like PROFILE_SCOPE("name") which take a single, fixed argument. This is great for timing function scopes, but for logging discrete events, we need more power. We want to be able to log simple messages, but also messages with data, like this:

// Desired usage
PROFILE_EVENT("Player has loaded into the map.");
PROFILE_EVENT("Health changed: %d, Armor changed: %d", new_health, new_armor);

To achieve this, we need a variadic macro. This is a special type of function-like macro that can accept a variable number of arguments. The syntax uses an ellipsis (...) to represent the variable part, and the special identifier __VA_ARGS__ to access those arguments.

Let's start by understanding the basics of macros and how they can capture source context.

C++ Macros, defines, __LINE__, and std::source_location (C++ 20) | Modern Cpp Series Ep. 107

The video 'C++ Macros, defines, LINE, and std::source_location' by Mike Shah provides an excellent refresher on how macros work, including the predefined macros that are essential for our task.

Please watch the segments from 10:20 to 12:44 and 16:21 to 18:25. Focus on: How a function-like macro is defined with parameters. The purpose of the predefined macros __FILE__ and __LINE__ for automatically capturing source code location.


2. The Anatomy of a Variadic Logging Macro

Now that we've recapped the basics, let's see how to combine these ideas to build the macro we need. The standard pattern is to have the macro be a simple wrapper that calls a real C++ function, passing along the automatic context and the user's arguments.

A Stack Overflow discussion provides a perfect, concise example of this pattern.

C/C++ Variadic Macro Function Overloading

This Stack Overflow answer for 'C/C++ Variadic Macro Function Overloading' demonstrates the exact technique we need.

Read the top-rated answer (by KamilCuk) and the code block from the original question. Focus on these two parts: The Macro Definition: Observe the simple and powerful line #define p(...) _p(__FILE__, __LINE__, __VA_ARGS__). This shows how the macro p takes all its arguments (...), which are represented by __VA_ARGS__, and passes them to a helper function _p after prepending the file and line number. The Helper Function: Look at the void _p(...) function implementations. Notice how they use va_list, va_start, vfprintf, and va_end. This is the standard C-style way to handle a variable number of function arguments.

This pattern is exactly what we will implement. The macro handles the boilerplate of grabbing context, and a helper function does the real work of formatting and outputting the message.


3. Capturing a High-Resolution Timestamp

Besides source location, another critical piece of data for every event is a timestamp. For performance analysis, we need a timestamp that is both high-resolution and monotonically increasing.

  • High-Resolution: We need to measure intervals that could be microseconds or even nanoseconds.
  • Monotonic: The clock must only move forward and should not be affected by changes to the system's wall-clock time (e.g., from daylight saving or network time synchronization).

The C++ <chrono> library provides the perfect tool for this: std::chrono::steady_clock.

C++ std::chrono::steady_clock::now() Example
This image shows the fundamental operation for getting a timestamp. `std::chrono::steady_clock::now()` returns a `time_point` object representing the current moment. For our data, we'll convert this `time_point` into a simple integer (like nanoseconds since the clock started) to store in our ring buffer.

To get a numerical value, you can use .time_since_epoch().count(). For steady_clock, "epoch" typically means when the system booted.


4. Assembling the First Version of PROFILE_EVENT

We now have all the components to build our macro and its helper function. Let's put them together. For now, our helper will just print to the console. In a later lesson, this is where we'll add the logic to push data into our lock-free ring buffer.

#include <iostream>
#include <chrono>
#include <cstdarg> // Required for va_list, va_start, va_end

// The helper function that does the actual work
void TelemetryLog(const char* file, int line, const char* format, ...) {
    // 1. Capture the timestamp
    long long timestamp_ns = std::chrono::steady_clock::now().time_since_epoch().count();

    // 2. Print the automatic context
    printf("[%lld] %s:%d - ", timestamp_ns, file, line);

    // 3. Process the variadic arguments and print the user's message
    va_list args;
    va_start(args, format);
    vprintf(format, args);
    va_end(args);

    printf("\n");
}

// The variadic macro that captures context and calls the helper
#define PROFILE_EVENT(format, ...) TelemetryLog(__FILE__, __LINE__, format, __VA_ARGS__)

int main() {
    std::cout << "Application starting...\n";

    PROFILE_EVENT("User logged in."); // This will cause a problem!
    
    int user_id = 123;
    float score = 98.5f;
    PROFILE_EVENT("User %d achieved score: %.1f", user_id, score);

    return 0;
}

If you try to compile the code above, you'll likely hit a syntax error on the line PROFILE_EVENT("User logged in.");. Why?

The preprocessor expands that line to: TelemetryLog(__FILE__, __LINE__, "User logged in.", );

Notice the trailing comma after the format string. This happens because __VA_ARGS__ expands to nothing when no arguments are provided, leaving the comma that was meant to separate it from the format argument.


5. Solving the Trailing Comma Problem

This is a classic issue with variadic macros. Fortunately, modern C++ has a clean, standard solution.

How to implement C/C++ variadic logging macro with ...

This Stack Overflow question, 'How to implement C/C++ variadic logging macro', directly addresses the trailing comma problem and presents the modern solution.

Read the question and the accepted answer. Pay attention to: The explanation of why MYLOG("hello world"); fails to compile. The non-standard GCC/Clang solution using ##__VA_ARGS__. The standard C++20 solution using __VA_OPT__(,), which is the one we will adopt.

The C++20 feature __VA_OPT__(content) is a special preprocessor instruction. It expands to content only if __VA_ARGS__ is not empty. If __VA_ARGS__ is empty, it expands to nothing.

By using __VA_OPT__(,), we tell the preprocessor: "Insert a comma here, but only if there are actual variadic arguments to follow."

Let's fix our macro:

// The CORRECTED variadic macro (requires C++20 or later)
#define PROFILE_EVENT(format, ...) \
    TelemetryLog(__FILE__, __LINE__, format __VA_OPT__(,) __VA_ARGS__)

With this change, our code now compiles and works perfectly for both cases!

Test your understanding!

Imagine you have the corrected PROFILE_EVENT macro. What would the preprocessor expand the following two lines into?

  1. PROFILE_EVENT("Game Over");
  2. PROFILE_EVENT("Level %d complete", 5);
Show answer
  1. TelemetryLog("path/to/your/file.cpp", 42, "Game Over");

    • __VA_OPT__(,) expands to nothing because __VA_ARGS__ is empty.
  2. TelemetryLog("path/to/your/file.cpp", 43, "Level %d complete" , 5);

    • __VA_OPT__(,) expands to a comma (,) because __VA_ARGS__ contains 5.

(Note: The file path and line numbers are just examples.)


Conclusion

Congratulations! You have successfully implemented a powerful, flexible, and type-safe (in the C++20 version) variadic macro for instrumentation. This is a non-trivial piece of systems programming and a cornerstone of our telemetry engine.

Key Takeaways:

  • Variadic macros use ... and __VA_ARGS__ to accept a variable number of arguments, enabling flexible APIs like our PROFILE_EVENT.
  • std::chrono::steady_clock is the correct tool for capturing high-resolution, monotonic timestamps essential for performance analysis.
  • The trailing comma is a common pitfall when a variadic macro is called with no optional arguments.
  • __VA_OPT__(,) is the standard C++20 solution to the trailing comma problem, providing the comma only when needed.
  • The best practice is to have the macro be a simple wrapper that calls a helper function, passing along automatically captured context like __FILE__ and __LINE__.

What's Next?

Our macro is now functional, but it's always active. In the next lesson, we will address the final and most critical requirement for a high-performance profiler: making the instrumentation have zero cost in a release build. We'll learn how to use preprocessor directives to make the capture macro a no-op when a profiling flag is disabled, fully realizing the "zero-overhead" promise of our system.