Jonathan Boccara's blog

How to Implement operator= When a Data Member Is a Lambda

Published October 2, 2020 - 0 Comments

In C++, some types of class members make it tricky to implement a copy assignment operator, operator=. For example references, const members, and… lambdas. Indeed, in the majority of cases, lambdas don’t have an operator=.

(In case you’re wondering in what case lambdas have an operator=, it is in C++20 and when they don’t capture anything.)

As a result, if your class contains a lambda, the compiler won’t be able to implement an operator= for you. To illustrate, consider the following class:

template<typename Lambda>
class MyClass
{
public:
    explicit MyClass(Lambda lambda) : lambda_(std::move(lambda)){}
    // it looks as though the compiler will implement an operator= as usual, but it won't
private:
    Lambda lambda_;
};

Indeed, if we try to call its operator=:

auto const N = 3;
auto myObject = MyClass([N](int n){ return n * N; });
auto const myObject2 = myObject;

myObject = myObject2;

(note that despite the fact that line 3 contains the = character, it is not a call to operator=, but rather a call the the copy constructor of MyClass.)

The above code fails to compile, with the following errors (gcc 8.1, full code here):

<source>: In function 'int main()':
<source>:19:16: error: use of deleted function 'MyClass<main()::<lambda(int)> >& MyClass<main()::<lambda(int)> >::operator=(const MyClass<main()::<lambda(int)> >&)'
     myObject = myObject2;
                ^~~~~~~~~
<source>:4:7: note: 'MyClass<main()::<lambda(int)> >& MyClass<main()::<lambda(int)> >::operator=(const MyClass<main()::<lambda(int)> >&)' is implicitly deleted because the default definition would be ill-formed:
 class MyClass
       ^~~~~~~
<source>:4:7: error: use of deleted function 'main()::<lambda(int)>& main()::<lambda(int)>::operator=(const main()::<lambda(int)>&)'
<source>:16:31: note: a lambda closure type has a deleted copy assignment operator
     auto myObject = MyClass([N](int n){ return n * N; });

As reminded by the compiler on the highlighted line above, lambdas don’t have an operator=.

Before seeing how to solve this problem, is it really a problem? Has anybody ever encountered it in their life? The above code looks like a contrived example. Are there more realistic examples?

Motivating example: smart iterators

I encountered this problem when working on a ranges library that was in the spirit of Boost ranges and range-v3. Ranges libraries offer fantastic tools to write expressive code.

Range libraries contain smart iterators (at least that’s how I call them), that are iterators that don’t just iterate or give access to elements in a collection. They contain logic that allow to perform complex operations, in very concise code. If you haven’t heard about them yet, it’s really worth it to discover them.

Some of this logic is performed via functions and functions objects, including lambdas. And some implementations of algorithms call operator= on iterators.

And there we are, we get in a situation where we try to call operator= on a class than contains a lambda (the iterator), and that fails.

To illustrate, consider the following code using Boost Ranges (demo on godbolt):

auto const numbers = std::vector<int>{1, 2, 3, 4, 5};
auto filteredNumbers = numbers | boost::adaptors::filtered([](int n){ return n == 2; });

auto filteredIt = filteredNumbers.begin();
auto filteredIt2 = filteredNumbers.end();
filteredIt = filteredIt2;

This code doesn’t compile, because it fails to call operator= on the lambda.

Do we reproduce the same problem with the range-v3 library, the supporting library for the Ranges proposal that was integrated in C++20?

Let’s try:

auto const numbers = std::vector<int>{1, 2, 3, 4, 5};
auto filteredNumbers = numbers | ranges::view::filter([](int n){ return n == 2; });

auto filteredIt = filteredNumbers.begin();
auto filteredIt2 = filteredNumbers.end();
filteredIt = filteredIt2;

And the code… compiles fine! See demo on godbolt.

Let’s see how range-v3 solves this problem.

Wrapping the lambda in an optional

To be able to implement an operator= for its iterators, range-v3 resorts to using optionals to wrap the lambdas. So the iterator contains an optional, that itself contains the lambda.

Then the implementation of the operator= works in two steps: 1) empty out the optional of this, and 2) call emplace to fill it with the object assigned from. Here is the corresponding code in range-v3:

RANGES_CXX14_CONSTEXPR
semiregular_copy_assign &operator=(semiregular_copy_assign const &that)
    noexcept(std::is_nothrow_copy_constructible<T>::value)
{
    this->reset();
    if (that)
        this->emplace(*that);
    return *this;
}

Don’t worry too much about the rest of the code (in particular the prototype) if it is not clear to you, it is not related to the problem at hand.

Why does this solve the problem? Because it calls the copy constructor of the lambda instead of its operator=. And even if lambdas don’t have an operator=, they do have a copy constructor.

The need to call emplace

Then why call emplace and not just operator= on the optional? Indeed, when an optional has an underlying value that is not initialized, its operator= calls the copy constructor of its underlying.

The problem is that the code of the operator= of optional contains a mention to the operator= of its underlying. In pseudo-code the operator= of optional looks like this:

if this is empty
{
    if other is not empty
    {
        copy construct underlying from other
    }
}
else
{
    if other is empty
    {
        empty this
    }
    else
    {
        underlying = underlying of other
    }
}

Even if, at runtime, the operator= of the underlying won’t be called, the code has to be compiled with it, and it fails to compile. Note that even an if constexpr wouldn’t solve the problem because we don’t know at compile time that the optional will be empty.

Therefore, a better solution is to call emplace, that only call constructors and not operator= on the underlying.

Note that all this doesn’t only apply to the copy assignment operator, but to the move assignment operator too.

Other solutions and workarounds

Here are other ways to work around the problem, including a scandalous hack that only C++ aficionados can appreciate.

Using a version of Boost Ranges >= 1.68

The issue we illustrated with Boost Ranges can be reproduced up until Boost 1.67 included. But the code compiles fine with Boost 1.68 (demo on godbolt).

I don’t understand why, since I couldn’t find what changed in the related source code of Boost between 1.67 and.1.68 If you see why this starts working in Boost 1.68, please let us know in a comment below!

Old versions of Boost

If you don’t have C++17 and your implementation of optional is boost::optional, then you can call emplace only from Boost version 1.56. Before this, you can resort to using the “in-place factories”:

template<typename Lambda>
class MyClass
{
public:
    explicit MyClass(Lambda lambda) : lambda_(std::move(lambda)){}
    MyClass& operator=(MyClass const& other)
    {
        lambda_.reset();
        lambda_ = boost::in_place(*other.lambda_);
        return *this;
    }
private:
    boost::optional<Lambda> lambda_;
};

Back to functors

Another solution is to go back to the 20th century and use C++98’s functors (or, more accurately, old function objects):

struct Times3
{
    int operator()(int n){ return n * 3; }
};

auto myObject = MyClass(Times3());

// ...

And as we know, functors are not dead for other contexts too.

A scandalous hack

Lets’ finish up with a special “solution” when your lambda doesn’t capture anything: prefix it with a +. Yes, a +:

template<typename Lambda>
class MyClass
{
public:
    explicit MyClass(Lambda lambda) : lambda_(std::move(lambda)){}
    // it looks as though the compiler will implement an operator= as usual, but it won't
private:
    Lambda lambda_;
};

int main()
{
    auto myObject = MyClass(+[](int n){ return n * 3; });
    
    auto const myObject2 = myObject;
    
    myObject = myObject2;
}

And it compiles fine. What the…??

Lambdas don’t have an operator+ of course. But to resolve this call to operator+, the compiler checks if it could use an implicit conversion. And there is one that works: converting the lambda into a function pointer (this conversion exists for lambdas that don’t capture anything), and calling this unary + on the resulting pointer.

And calling a unary + on a pointer does nothing. It’s like calling +42. It is the same thing as 42.

But in our context, the result is a function pointer, that has an operator=, just like all pointers do.

Of course, this solution is not robust because it stops working as soon as the lambda captures something. But at least it can be a nice conversation topic for an after diner chat in a C++ conference. Or not. It’s up to you.

You will also like

Don't want to miss out ? Follow:   twitterlinkedinrss
Share this post!Facebooktwitterlinkedin