Modern C++ development is built on a foundation of strict compiler checks. For most professional codebases, enabling -Wall -Wextra -Werror (or their MSVC equivalents) is a non-negotiable standard. These flags act as a first line of defense, catching silent bugs like declaring a variable but forgetting to use it.
But this strictness comes with a cost: the compiler lacks context. It cannot distinguish between a variable you forgot to use and a variable you intentionally ignored due to API contracts, debug-only assertions, or conditional compilation.
Managing this tension between strict compilation and developer intent has driven significant changes in the C++ standard. Here is the complete guide to handling unused entities, tracking the evolution from C++17’s [[maybe_unused]] attribute to the paradigm-shifting updates in C++26.
Pre-C++17 Workarounds
Before C++17, developers had no standardized way to tell the compiler that a variable was intentionally ignored. This led to codebases littered with compiler-specific macros or hacky casts.
To silence warnings, developers typically relied on casting the variable to void:
void on_mouse_click(int x, int y, int button_id) {
(void)button_id; // Hack to silence the unused variable warning
printf("Clicked at %d, %d\n", x, y);
}
Other alternatives included wrapping code in #pragma directives or using non-standard compiler extensions like __attribute__((unused)). These solutions were noisy, fragmented, and failed to clearly communicate developer intent to anyone reading the code.
C++17: Formalizing Intent with [[maybe_unused]]
C++17 revolutionized this space by introducing standard attributes, and chief among them was [[maybe_unused]]. This attribute provides a clean, standard-compliant way to express intent: "I am aware this entity might not be used in all compilation paths, and that is deliberate."
The beauty of [[maybe_unused]] lies in its versatility. It can be applied to almost any declaration in the language.
1. The assert Idiom (Local Variables)
The most frequent use case for [[maybe_unused]] is handling variables that exist only to support assert macros. Because assert is stripped out of Release builds (when NDEBUG is defined), any variable used exclusively inside an assertion will trigger an unused variable warning in production.
void process_data(const std::vector<int>& data) {
[[maybe_unused]] bool is_valid = validate_data(data);
assert(is_valid && "Data validation failed!");
// In Release mode, is_valid is never evaluated or read,
// but [[maybe_unused]] prevents the compiler from complaining.
}
2. API Contracts (Function Parameters)
When implementing a virtual function, fulfilling a concept, or writing a callback, the interface dictates the parameters you must accept—even if your specific implementation doesn't need them all.
#include <iostream>
// Marked [[maybe_unused]]
// but never instantiate Widget -> avoids -Wunused warnings.
class [[maybe_unused]] Widget {
public:
void render() const { std::cout << "Rendering widget\n"; }
};
int main() {
Widget w; // ok
// w.render();
return 0;
}
3. Conditional Compilation (Functions and Types)
Helper functions or structs that are only invoked under specific build configurations (like debug-only logging) often trigger unused function warnings.
[[maybe_unused]] static void print_debug_state() {
// ...
}
enum class [[maybe_unused]] InternalState {
Active,
Idle
};
Edge cases
if you done any multi-threading in C++, you may know that there is mutex that use RAII pattern in C++. For example like the following:
#include <iostream>
#include <mutex>
std::mutex mtx;
void safe_increment(int& counter) {
std::lock_guard<std::mutex> lock(mtx);
++counter;
}
int main() {
int counter{};
safe_increment(counter);
std::cout << counter;
}
You may notice that the mutex is completely unnecessary in this example, but that's not the point. The point is that you see the lock variable is actually unused. But there is no error/warning even if you turn on -Wall -Wextra -Werror. Why is that? It is not required by the standard, but it usually won't warn for variables whose type has a non-trivial destructor, because:
- The destructor has observable side effects (in lock_guard's case, unlocking the mutex).
- The variable is effectively "used" by virtue of its lifetime having meaning — it's an RAII guard.
So std::lock_guard<std::mutex> lock(mtx); is a deliberately unused variable, but the compiler recognizes the pattern and stays silent. For type that has a trivial destructor, it will warn. If you want to warn for for type that has trivial destructor, you can reference the following example:
#include <iostream>
struct __attribute__((warn_unused)) Object
{
Object(){}
void use() {}
};
int main()
{
Object myObject; // will give : warning: unused variable 'myObject' [-Wunused-variable]
//myObject.use(); // uncomment to hide this warning
}
but the attribute __attribute__((warn_unused)) is only available in clang/gcc. Running the same script in MSVC will likely give you a compile error.
The Structured Bindings Bottleneck
Despite its utility, [[maybe_unused]] had a glaring limitation in C++17 through C++23: it did not play well with structured bindings. If you wanted to unpack a tuple but ignore one of the values, the standard did not allow applying the attribute to an individual binding.
// You could apply it to the whole binding:
[[maybe_unused]] auto [x, y] = get_coordinates();
// But you could NOT standardly do this:
// auto [x, [[maybe_unused]] y] = get_coordinates();
C++26: unnamed placeholder (Name-Independent Declarations)
C++26 fundamentally shifts how we handle unused local variables by adopting a feature long loved by Python, Rust, and Go developers: Name-Independent Declarations (introduced via P2169).
In C++26, the single underscore _ becomes a special placeholder. It signals to the compiler that a variable is intentionally unnamed and unused. This drastically reduces the verbosity of [[maybe_unused]] in local scopes.
Furthermore, you can declare multiple variables named _ in the same scope without triggering a redefinition error.
Revolutionizing Structured Bindings
C++26 finally solves the structured binding limitation gracefully. Instead of awkward workarounds, you simply use the underscore for values you want to discard.
// C++26: Ignoring the second return value
auto [value, _] = do_something();
Cleaning Up RAII and Guards
RAII objects like std::lock_guard are often instantiated solely for their side effects (locking a mutex on creation, unlocking on destruction). They are never explicitly referenced by name, which historically required giving them dummy names or using [[maybe_unused]].
In C++26, the syntax becomes elegantly minimal:
void thread_safe_operation() {
// C++26: No [[maybe_unused]], no dummy name needed
std::lock_guard _ { my_mutex };
// Perform operations...
}
Summary: When to Use What?
As we move into the C++26 era, the toolkit for handling unused entities has never been richer. Here is a quick reference guide to modern best practices:
-
Use
_(C++26 onwards): For local RAII guards (locks, scoped timers), discarding specific elements in structured bindings, and ignoring return values. -
Use
[[maybe_unused]]: For function parameters in APIs/callbacks, functions that are conditionally compiled out, class/enum definitions, and template parameters heavily reliant onif constexpr. -
Avoid
(void)var;: Completely retire this C-style hack in modern codebases.
The journey from hacky void casts to standard attributes, and finally to name-independent declarations, highlights C++'s ongoing commitment to expressiveness. By leveraging [[maybe_unused]] and the new C++26 _ placeholder, you can maintain the rigorous safety of -Werror while keeping your code clean, intentional, and highly readable. If you want to know more about it, you can have a look at the standard draft here.
Top comments (0)