Recently, I stumbled upon the use of std::print in C++ code, which intrigued me. I wondered how C++ had adopted the printf mechanism from C, especially since there is cout. After looking at a few examples, I realized the brilliance of this new method, which provides a more concise and convenient formatting output than cout by using a variadic parameter mechanism similar to printf. Let’s take a look at how the variadic parameter mechanism works in C++.
Limitations of Variadic Parameters in C Language
First, let’s review the support for variadic parameters in traditional C language. The core relies on four macros: va_list, va_start, va_arg, and va_end (defined in <stdarg.h>). We won’t go into detail about the specific methods, but let’s look at the following code to see how to implement a function that calculates the sum of “any number of ints” in C language.
#include <stdarg.h>
#include <stdio.h>
// The first parameter is fixed: it indicates the number of subsequent variadic parameters.
int sum_int(int count, ...) {
int total = 0;
va_list args; // 1. Define a pointer for the parameter pack to traverse the variadic parameters
// 2. Initialize: bind the parameter pack to the "fixed parameter count" after it
va_start(args, count);
// 3. Traverse the parameter pack: retrieve one int value each time
for (int i = 0; i < count; i++) {
// va_arg(parameter pack, type): get the next parameter, must specify type
total += va_arg(args, int);
}
// 4. Release the resources of the parameter pack
va_end(args);
return total;
}
int main() {
// Pass 3 ints: count=3, followed by 1, 2, 3
printf("sum: %d\n", sum_int(3, 1, 2, 3)); // Output sum: 6
// Pass 5 ints: count=5, followed by 10, 20, 30, 40, 50
printf("sum: %d\n", sum_int(5, 10, 20, 30, 40, 50)); // Output sum: 150
return 0;
}
This is the core logic of variadic parameters in C language: using a “fixed parameter” (like count) to mark the number/type of variadic parameters, and then using macros to traverse the parameters.
However, this method has several issues:
- Type Safety Issuesva_arg requires manual type specification and strict matching. If the type is incorrect, the program may crash or produce errors. For example, if a floating-point type parameter is passed like sum_int(3, 1, 2.5, 3), the result will be garbled. The compiler cannot detect this error.
- Must Have a Fixed Parameter at the BeginningCannot directly write sum_int(1,2,3); must first pass count=3, which is cumbersome and easy to forget.
- Does Not Support Complex TypesCannot pass class object types like std::string or std::vector, only simple types (POD types) like int, char *.
Evolution of Variadic Parameters in C++
C++98 introduced compatibility with the C language’s va_list mechanism, but it also had the above issues. Therefore, starting from C++11, several safer and more flexible variadic parameter solutions were gradually introduced.
initializer_list: A Simplified Solution for Same-Type Parameters
If your function requires “multiple same-type parameters” (like multiple ints or multiple strings), initializer_list is the simplest choice — it is a “lightweight container” introduced in C++11, specifically designed to receive a “list of same-type parameters determined at compile time”.
Let’s see how to implement the above “sum function” using initializer_list.
#include <initializer_list>
#include <iostream>
// Calculate the sum of multiple ints: the parameter is initializer_list<int>
int sum_int(std::initializer_list<int> nums) {
int total = 0;
// Traverse initializer_list like a vector
for (auto num : nums) {
total += num;
}
return total;
}
int main() {
// Call with parameters wrapped in {}, no need to pass "number of parameters"
std::cout << "sum: " << sum_int({1, 2, 3}) << std::endl; // Output sum: 6
std::cout << "sum: " << sum_int({10, 20, 30, 40, 50}) << std::endl; // Output sum: 150
// Error: cannot pass different types of parameters
sum_int({1, 2.5, 3}); // Compilation error: type mismatch (int and double)
return 0;
}
Key Features:
- Same-Type Constraintinitializer_list<T> has a fixed type T, only same-type parameters can be passed (for example, initializer_list<int> can only pass int); if the compiler can implicitly convert, different type parameters can also be passed (for example, int can be passed as float, but not vice versa).
- Compile-Time Determined LengthThe parameter list must be wrapped in {}, and the length is determined at compile time.
- Type SafetyThe compiler checks parameter types, and passing the wrong type results in a direct error.
- Supports Complex TypesCan pass C++ class objects like std::string, std::vector, etc.
Why can’t initializer_list accept different types of parameters?
Because initializer_list is essentially a class template, defined as template <class E> class initializer_list; — it only has one “element type E”, so it can only store same-type data. If different types are needed, the following “variadic templates” must be used.
Variadic Templates: The Universal Solution for Any Type and Any Number
If you need “different types and any number” of parameters (for example, passing int, string, double simultaneously), the “variadic templates” introduced in C++11 are the ultimate solution. Its core is the “parameter pack” — represented by … to indicate “a group of uncertain parameters”. The compiler will automatically deduce the parameter types and quantities and generate the corresponding function instances.
For example, to implement a function that can print any type and any number of parameters, we can use variadic templates.
First, it is important to know thatvariadic templates require “recursion” to traverse the parameter pack. The implementation steps are as follows:
- Write a “variadic template function”: receive “the first parameter” and “the remaining parameter pack”;
- Print the first parameter, then recursively call itself to handle the remaining parameter pack;
- Write a “termination function”: when the parameter pack is empty, end the recursion (otherwise, it will result in a compilation error).
#include <iostream>
#include <string>
// 1. Recursive termination function: called when the parameter pack is empty (must have!)
void print() {
std::cout << std::endl; // Finally, print a newline
}
// 2. Variadic template function: T is the type of the first parameter, Args are the remaining parameter pack
template <typename T, typename... Args>
void print(T first, Args... args) {
// Print the first parameter
std::cout << first;
// C++17's if constexpr: compile-time check if the parameter pack is empty
if constexpr (sizeof...(args) > 0) {
std::cout << " "; // If not the last parameter, add a space separator
}
// Recursive call: handle the remaining parameter pack (args... is "unpacking the parameter pack")
print(args...);
}
int main() {
// Test 1: int + string + double
print(10, "Hello", 3.14); // Output 10 Hello 3.14
// Test 2: bool + string + int + double
print(true, "C++", 2024, 5.20); // Output 1 C++ 2024 5.20
// Test 3: empty parameters (call termination function)
print(); // Output a newline
return 0;
}
Code Analysis:
- Template Parameter Packtypename… Args — represents “a group of uncertain template type parameters”, Args is the name of the parameter pack;
- Function Parameter PackArgs… args — represents “a group of uncertain function parameters”, args is the name of the parameter pack;
- Parameter Pack Expansionargs… — expands the parameter pack into individual parameters (for example, if args is (10, “hello”), it expands to 10, “hello”);
- sizeof…(Args)calculates the length of the parameter pack (for example, if Args has 3 types, the result is 3).
Let’s see the effect of the function implementation; isn’t it very flexible? The compiler automatically deduces the type of each parameter without manual specification (for example, print(3.14) will deduce T=double). There is no need to redundantly specify the number of parameters; it can be calculated through expressions.However, do you find the template recursion expansion steps a bit cumbersome? Especially since a separate termination function must be written. For those who pursue the ultimate “beauty of code”, this is simply unacceptable. Don’t worry, let’s learn a more elegant way below.
Fold Expressions: Syntax Sugar to Simplify Variadic Templates
C++17 introduced “fold expressions”, which can replace recursion with a single line of code, directly “folding” the parameter pack for conventional calculations (like summation, concatenation), perfectly embodying the ultimate simplicity of code.
Let’s see how to implement the above print function using fold expressions.
No need for a recursive termination function; printing is achieved in one line of code.
#include <iostream>
#include <string>
// Fold expression printing: add a space after each parameter, and finally print a newline
template <typename... Args>
void print(Args... args) {
// (std::cout << args << " ") is the operation for each parameter, ... expands the parameter pack
((std::cout << args << " "), ...) << std::endl;
}
int main() {
print(10, "Hello", 3.14, true); // Output 10 Hello 3.14 1
return 0;
}
Line 8 of the code applies the fold expression, and the compiler will expand it to a form similar to(std::cout << arg1 << ” “), (std::cout << arg2 << ” “), … to avoid manual recursive calls.
Three Common Forms of Fold Expressions:
|
Form |
Meaning |
Applicable Scenarios |
|
(args + …) |
Left fold: ((a+b)+c)+d |
Addition, multiplication, and other associative operations |
|
(… + args) |
Right fold: a+(b+(c+d)) |
Subtraction, division, and other non-associative operations |
|
(args + … + 0) |
Left fold with initial value |
Handle empty parameter packs (sum() returns 0) |
For example, handling empty parameter packs:
template <typename... Args>auto sum_safe(Args... args) {
// Return 0 for empty parameters, sum for non-empty
return (args + ... + 0);
}
int main() {
std::cout << sum_safe() << std::endl; // Output 0 (empty parameters)
std::cout << sum_safe(1,2,3) << std::endl; // Output 6
return 0;
}
However, this method also has limitations; fold expressions require the parameter objects to support the operators used, and can only apply operations of a single type simultaneously. For example, you cannot perform both addition and multiplication in the same fold expression.
Differences and Scenarios of Four Variadic Parameter Usages
Let’s compare the C language solution with the three C++ solutions to see their respective advantages and disadvantages.
|
Usage |
Parameter Type |
Flexibility of Quantity |
Type Safety |
Supports Complex Types |
Code Complexity |
Applicable Scenarios |
|
C-style Variadic Parameters |
Any (must specify manually) |
Any |
None |
Limited (only POD) |
Medium |
Maintaining old C code, scenarios where changes are not allowed |
|
initializer_list |
Single Type |
Determined at Compile Time |
Yes |
Yes |
Low |
Multiple same-type parameters (like sum, batch printing of the same type) |
|
Variadic Templates (C++11) |
Any Type |
Any |
Yes |
Yes |
Medium |
General scenarios (like general printing, serialization) |
|
Fold Expressions (C++17) |
Any Type (must support operators) |
Any |
Yes |
Yes |
Low |
Simplifying variadic templates (like summation, concatenation, batch operations) |
Selection Recommendations:
- Prefer Simple SolutionsIf the parameters are of the same type, use initializer_list directly (the code is simplest and least error-prone);
- Use Variadic Templates + Fold Expressions for Complex ScenariosWhen different types and any number of parameters are needed, use this combination (C++17 and above);
- Use C-style Only for Old CodeNew projects should absolutely avoid C-style variadic parameters unless compatibility with old C libraries is required;
- Prioritize Mature Libraries in Actual ProjectsFor example, use std::format (C++20), fmt library for printing, serialization with protobuf, and avoid writing your own variadic templates (to avoid reinventing the wheel). In fact, std::format is implemented using variadic templates.
Common Pitfalls of Variadic Parameters
While variadic parameters are flexible, improper use can lead to pitfalls. Let’s look at some code examples.
-
Variadic Templates: Forgetting to Write the Recursive Termination Function
If you only write the variadic template function without writing the no-argument termination function, the compilation will fail (no matching function found). For example:
#include <iostream>// Error: only wrote the variadic template function, no termination function
template <typename T, typename... Args>
void print(T first, Args... args) {
std::cout << first << " ";
print(args...); // When args is empty, there is no matching print() function
}
int main() {
print(10, "hello"); // Compilation error: no matching function for call to 'print()'
return 0;
}
Solution: Either write a termination function or use C++17’s fold expressions.
-
initializer_list: Local Object Lifetime Issues
initializer_list itself does not store data; it only points to “external data”. If you return a local variable’s initializer_list, after the local variable is destroyed, the initializer_list will point to invalid memory (dangling pointer). For example:
#include <initializer_list>
#include <iostream>// Error: returning an initializer_list of a local variable
std::initializer_list<int> get_nums() {
int a = 1, b = 2, c = 3;
return {a, b, c}; // a, b, c are destroyed after the function returns, initializer_list points to invalid memory
}
int main() {
auto nums = get_nums();
for (auto num : nums) {
std::cout << num << " "; // Undefined behavior, outputs garbled text
}
return 0;
}
Solution: Do not return a local initializer_list; instead, use std::vector or other containers (containers will copy the data).
-
Fold Expressions: Parameters Do Not Support Operators
The operators used in fold expressions (like +, <<) require the parameters to support them. If parameters that do not support the operator are passed, compilation will fail. For example:
#include <iostream>// Fold expression uses + for summation, but custom class does not support + operator
class MyClass {};
template <typename... Args>auto sum(Args... args) {
return (args + ...); // Error: MyClass does not have a + operator
}
int main() {
MyClass a, b;
sum(a, b); // Compilation error: no match for 'operator+' (operand types are 'MyClass' and 'MyClass')
return 0;
}
Solution: Either overload the corresponding operator for the custom class (like operator+) or use C++20’s concepts to restrict parameter types (only allow types that support the operator).
-
Empty Parameter Pack: Forgetting to Handle Empty Parameters
Problem: If the variadic template does not handle the “empty parameters” case, calling it with empty parameters will result in an error. For example:
#include <iostream>// Error: did not handle empty parameter pack
template <typename... Args>auto sum(Args... args) {
return (args + ...); // For empty parameters, there are no parameters to fold, compilation error
}
int main() {
sum(); // Compilation error: fold of empty expansion over operator+
return 0;
}
Solution: Use a “fold expression with an initial value” (like (args + … + 0)), or add special handling for empty parameters.
template <typename... Args>auto sum(Args... args) {
return (args + ... + 0); // Return 0 for empty parameters
}
int main() {
sum(); // Output 0, runs normally
return 0;
}
Conclusion: How to Choose the Right Variadic Parameter Method
Use a piece of “pseudo-code” to help you quickly choose:
- Are my parametersof the same type? → Yes → Use initializer_list;
- No → Do I needany type, any numberof parameters? → Yes → Use “variadic templates + fold expressions” (C++17+);
- No → Am Imaintaining old C code? → Yes → Use C-style variadic parameters;
- No → Prioritize mature libraries (like std::format, fmt), and avoid writing your own.
Variadic parameters are an important reflection of C++’s evolution from “compatibility with C” to “surpassing C” — from C language’s “unsafe, inflexible” to C++’s “type-safe, flexible and easy to use”, which is a continuous evolution of the language standard. This is not just a change in syntax, but a revolution in programming philosophy.
Previous ArticlesC++ deque container: A tool balancing double-ended operations and random accessIn-depth understanding of C++ class constructors: from default functions to compiler optimizationsC++ move semantics: from “movers” to performance optimization secret weapons