Is it defined behavior to return const references to objects created with ranges transform?

Viewed 139

Is it defined behavior to combine a transformation that creates objects with a transformation that extracts attributes from these objects by constant reference? The following code does not work as expected in Visual Studio 16.10.2, but it seems to work with gcc 11.1.

#include <array>
#include <iostream>
#include <ranges>
#include <string>

struct A {
    std::string s;
};

int main() {
    auto values = std::array{"a", "b"};

    // transform 1: create objects
    auto objects = values | std::views::transform(
        [](const auto& s) { return A{s}; }
    );

    // transform 2: access object attributes
    auto lambdaReturningConstRef = [](const auto& a) -> const auto& { return a.s; };    
    auto result = objects | std::views::transform(lambdaReturningConstRef);

    for (const auto& s : result) {
        std::cout << s << " ";
    }
}

The expected output is "a b", but the actual output with Visual Studio is empty. Also, in a clean Release build (but not in Debug), I get a warning C4172 about returning the address of a local variable or temporary.

Adding a getter in A returning a constant reference and using std::mem_fn instead of the lambda removes the warning entirely, but still does not produce the expected result. Same for using &A::s instead of the lambda.

Removing the explicit return type from the lambda obviously fixes the issue.

1 Answers

I'm going to write out the full pipeline in one go so it's clearer as to what's going on:

auto values = std::array{"a", "b"};

auto results = values
             | transform([](const auto& s){ return A{s}; }
             | transform([](const auto& a) -> const auto& { return a.s; });

So first, we create a range of prvalue As. Then we take create a range of lvalue strings... into those As. But when you return a reference, you need to ensure that it's to an existing object, otherwise it dangles. But into which A are these strings referring?

The prvalue As. The ones that are then immediately destroyed. So your reference dangles.

Put differently, you're doing something like this:

auto __f(const auto& a) -> const auto& { return a.s; }
const auto& s = __f(A{"a"});

And it's hopefully clearer why this latter formulation gives you a dangling reference. The A goes out of scope at the end of the full expression, but s is a reference into it.

The Ranges code is doing the same kind of thing, just in a less obvious way.

Related