NTuples: named tuples with compile-time field names and strong typing
C++Weekly
NTuples Library
Hello Jason Turner,
I’ve been working on a small C++ library called NTuples that provides named tuples with compile-time field names and strong typing. It integrates seamlessly with ranges and structured bindings, and allows both predefined field names or creating them on the fly.
Here’s a minimal example:
for (auto &&e : std::views::iota(1, 10)
| std::views::transform([](auto i) {
return nt::ntuple(
index = i,
index_squared = i * i,
nt_field(cubed) = int(i * i * i), // names on the fly
nt_field(str_conversion) =
"this is the string number " + std::to_string(i)
);
})
| std::views::filter([](auto &&t) {
return t.cubed >= 216; // filter on named field
}))
{
// Access by name
std::cout << e.index << " ";
std::cout << e.index_squared.v << " ";
std::cout << e.cubed << " ";
// Access by index (like std::tuple)
std::cout << nt::get<3>(e) << " ";
// Print entire ntuple
std::cout << e << std::endl;
// Structured bindings also work
auto [index, index_squared, cubed, str_conversion] = e;
}
With NTuple, you can:
Use predefined names with nt_new_field_name(index); for consistency across a project,
Or create ad-hoc names on the fly with nt_field(cubed), which is great for prototyping or quick transformations.
The library is header-only, constexpr-friendly, and easy to install via vcpkg:
vcpkg install rp-ntuples
Additional resources:
📄 More detailed documentation is available here: NTuple PDF
💻 An example repository that uses the library can be found here: ntuples-test
It might make for an interesting C++ Weekly episode on how modern C++ metaprogramming can turn tuples into something expressive and practical.
Best regards,
Length
around 10 minutes
In which scenarios would this be useful over simply defining your own struct?
Thanks for the question!
NTuple is similar to an ordinary tuple in that it’s a generic container, but it differs from a plain struct or std::tuple in several key ways:
- No upfront definition needed, ntuples can be created on the fly without declaring a struct type first.
- Compile-time reflection, each ntuple “knows” what fields it has and their names.
- Fields carry their identity, an individual field remembers its own name, so reusing it in another ntuple automatically keeps the same identity.
For example:
auto nt1 = nt::ntuple(
nt_field(a) = 1,
nt_field(b) = 2.0,
nt_field(c) = std::string("hello")
);
auto nt2 = nt::ntuple(
nt1.a, // reuses the field "a" from nt1
nt_field(b) = 3.0
);
Here, nt1.a is reused in nt2, and the new ntuple automatically retains the correct field name.
This means you can also predefine certain names that should stay consistent across an entire project. It further allows you to define comparison operators that, for example, only look at the shared fields in two ntuples.
Data-frame–like structures
The mechanism extends beyond single ntuples into data-frame–like structures. The example repository includes a vector_frame class, which is essentially a std::vector of ntuples with extra functionality.
For instance:
auto vf1 = nt::fill_vector_frame(10,
[](auto i) {
return nt::ntuple(
index = i,
index_squared = i * i,
nt_field(cubed) = (double) i * i * i
);
});
A vector_frame can be printed directly, but it also provides column access: each field of the underlying ntuples can be viewed as an array (nt::span).
Example:
for (auto &&e : vf1.cubed() ) {
std::cout << e << std::endl;
}
This is especially useful when combining with algorithms that don’t know about your ntuple types. Suppose you have a generic average function:
double average(const nt::span<double>& data);
You can call it directly on a column:
std::cout << "Average: "
<< average(vf1.cubed.get_primitive())
<< std::endl;
This way you get the benefits of named tuple–like objects in your code, while still being able to “fall back” to primitive arrays when needed.
Named function arguments
In addition, NTuple can also be used to implement named function arguments with defaults and compile-time checking:
template <typename... ARGS>
void my_function(ARGS &&...args)
{
auto t0 = bind_args(
nt_field(argument1) = 1,
nt_field(argument2) = 15,
argument3 = nt::required<int>(), // required
argument4 = std::string("hello") // default
)(args...);
std::cout << t0 << std::endl;
}
This function defines four arguments inside bind_args. Only argument3 is required; the others have defaults.
It can be called like this:
void function_with_named_arguments() {
my_function(
6, // positional argument → argument1
argument3 = 15, // required argument (out of order is fine)
nt_field(argument2) = 42
// argument4 is not provided → default "hello"
);
}
If you call the function with the wrong arguments, or forget to set argument3, an error will be issued. For functions with many arguments, this pattern makes calls safer and more readable.
If you have any further questions, please let me know.