HiveBrain v1.2.0
Get Started
← Back to all entries
patterncppMinor

A tuple unpacking macro that approximates Python elegance

Submitted by: @import:stackexchange-codereview··
0
Viewed 0 times
elegancetuplethatmacropythonunpackingapproximates

Problem

Motivation

Although I love coding in C++, I sometimes yearn for the syntactic sugar of Python. C++11 has somewhat eased the pain by such beautiful analogies like this:

# python
for i in [1, 5, 7]:

// C++11
for(auto i: {1, 5, 7})


Unfortunately, C++ puts an end to this as soon as you want to do such crazy things as having the element index in a range based for.

Where Python gives you

for index, element in enumerate(elements):


C++ leaves us with writing a "normal" loop like

for(int index = 0; index < elements.size(); ++i)
   //do something with elements[index]


(which is slow with non random access containers) or with uglier loops that involve iterators. Fortunately, it is possible to translate the beauty of Python and write a range adaptor that returns an (index, element&) tuple (or pair) and could be created and used like this:

for(auto indexElementPair: enumerate(elements))


Yet, this is only half the beauty of Python since we have to deal with this ugly pair using it like indexElementPair.first which is not the nicest form.

Usage

This is where my code comes into play. I wanted to emulate the behavior of Python as close as possible. Although it is not possible to declare multiple (auto) variables in the range based for header (and unpack the tuple into them) we can do so right after it:

for(auto indexElementPair: enumerate(elements)) {
    DECLARE_AND_TIE((index, element), indexElementPair);
    // use index and element like in the python code
}


Note that the index and element variables are both declared as references and their type is automatically deduced from the corresponding tuple element.

The code is on github and in the following files:

declare_and_tie.hpp

```
#ifndef _guard_DECLARE_AND_TIE_HPP_
#define _guard_DECLARE_AND_TIE_HPP_

#if not(BOOST_PP_VARIADICS)
#error "Boost preprocessor variadics support needed for DECLARE_AND_TIE!"
#endif

#include
#include
#include
#include
#include
#include

Solution

Reviewing the Implementation

Setting aside the question of whether this is the right approach, I really wanted to try to review the code you did write. I was worried that it required more of a learning curve than I could allocate time for, as I'm not yet familiar with using BOOST_PP_. I can say now that your macro implementation is fairly straightforward to understand (assuming the BOOST_PP_ macros do what they have to in order for your code to work). I agree 100% with your focus on trying to provide clear compilation errors when the usage is incorrect. This is doubly important in cases like this with magical syntax. My biggest complaint is small: I cannot figure out how you chose the order of your different macros; they seem neither top-down, bottom-up, or alphabetically sorted. (Well, biggest complaint aside from the macros being macros.)

After all the side thoughts about handling an enumerate helper that seemed to return a std::pair, I was surprised to see nothing that appears to handle std::pair after all. Instead it would handle a 2-tuple, but this suggests it would be similarly unable to handle tie-ing inside a range-for over a std::map. It wouldn't surprise me if there's a way to extend the macros to handle this, but I do strongly suspect diminishing returns.

One of your questions was that of turning tests that fail to compile into actual live tests. Sometimes you can use techniques based on SFINAE in order to create such tests, but I'm not sure how to do that here. Everything I can think of would require turning your error cases into run-time exception throwing cases instead, which is a poor trade-off. And that still only handles things like mismatched sizes; it wouldn't handle a case you don't have a test for: reusing local names in multiple DECLARE_AND_TIE calls. All I can suggest here is considering using an #if defined(VERIFY_BAD_SYNTAX) instead of locally commenting the tests out to make it easier to verify them if you update your macros.

Anyway, definitely +1 for a cool question and clean code.

Are There Better Ways?

Now I want to bring back the question of whether this is the right approach. I'm still thinking it is not. In the enumerate that returns a std::pair, I think we all agree it's way too much hidden complexity, and even probably too much usage complexity for its benefit. For the stronger motivating case where you want to iterate several collections in lock-step, I'm still not sold on the position of the code that the user has to write. Don't get me wrong; I do agree that

for(auto tuple: zip(numbers, names, ages, addresses))  {
    DECLARE_AND_TIE((number, name, age, address), tuple);
    : : :
}


is more comfortable to write than the corresponding manual reference declarations. But I'm not sure that it's more comfortable to read. Macros break the standard rules of the language. And while this is carefully crafted to be only a tiny burden to the reader, it is still yet another burden. It's a shame there's no way to use std::tie to name references to a std::tuple.

In a comment I proposed a magic unimplemented syntax that would instead read something like this:

for (auto rec : NamedTupleZip(numbers, names, ages, addresses)) {
    // refer to rec.number, rec.name, etc.
}


Aside from the impossibility of this syntax, I think it places the complexity in the right place. Somehow NamedTupleZip would have to create a namedtuple-inspired struct that replaced the use of a std::tuple, and iterated over the respective collections like zip to populate each iteration's values.

You counter with two worries: one is that this could result in re-implementing the named tuple for each instance of a corresponding iteration, and that sometimes you are given a std::tuple rather than the opportunity to build your own. The second doesn't seem important to me; if the helper struct can construct from a std::tuple, it's easy to convert. The first may be a legitimate concern, but until we have an implementation, it can't be measured. And your point about ownership make sense; I would expect the original source (whether a zip'd collection or a referenced tuple) to maintain ownership of the actual data; perhaps this should be a NamedRefTuple instead.

Was This the Right Question?

To end with, I want to raise the question of whether you're using the correct data structures in the first place. In Python it's easy to create tons of arbitrary tuples by zipping structures in arbitrary order. But it's a lot less clear what the motivation is for doing that in C++. If you have a lot of cases where you're handling numbers, names, ages, addresses, it seems like you should already have a PersonInfo struct (or PersonInfoRef) that can construct from a std::tuple, and then all you need is zip:

for (PersonInfo rec : zip(numbers, names, ages, addresses))  {
    // rec.number, rec.name, ...
}


If instead you want to handle lots of different arbitrar

Code Snippets

for(auto tuple: zip(numbers, names, ages, addresses))  {
    DECLARE_AND_TIE((number, name, age, address), tuple);
    : : :
}
for (auto rec : NamedTupleZip(numbers, names, ages, addresses)) {
    // refer to rec.number, rec.name, etc.
}
for (PersonInfo rec : zip(numbers, names, ages, addresses))  {
    // rec.number, rec.name, ...
}
for(auto tuple: zip(numbers, names, ages, addresses))  {
    auto &number = std::get<0>(tuple);
    auto &name = std::get<1>(tuple);
    auto &age = std::get<2>(tuple);
    auto &address = std::get<3>(tuple);
}
const int i_max = std::min({numbers.size(), names.size(), ages.size(), addresses.size()});
for(int i = 0; i < i_max; ++i)  {
    auto &number = numbers[i];
    auto &name = names[i];
    auto &age = ages[i];
    auto &address = addresses[i];
}

Context

StackExchange Code Review Q#61499, answer score: 4

Revisions (0)

No revisions yet.