Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How can I tell whether I'm forwarding to a copy constructor?

Tags:

c++

templates

If I'm writing a generic function that forwards arguments to a constructor, is there a way to tell whether that is a copy constructor? Essentially I want to do:

template <typename T, typename... Args>
void CreateTAndDoSomething(Args&&... args) {
  // Special case: if this is copy construction, do something different.
  if constexpr (...) { ... }

  // Otherwise do something else.
  ...
}

The best I've come up with is checking for sizeof...(args) == 1 and then looking at std::is_same_v<Args..., const T&> || std::is_same_v<Args..., T&>. But I think this misses edge cases like volatile-qualified inputs and things that are implicitly convertible to T.

To be honest I'm not entirely sure this question is well-defined, so feel free to tell me it isn't (and why) as well. If it helps you can assume that the only single-argument constructors for T are T(const T&) and T(T&&).

If I'm right that this isn't well-defined because a copy constructor isn't a Thing, then maybe this can be made more precise by saying "how can I tell whether the expression T(std::forward<Args>(args)...) selects an overload that accepts const T&?

like image 850
jacobsa Avatar asked Oct 14 '20 08:10

jacobsa


People also ask

What is the difference between a move constructor and a copy constructor?

If any constructor is being called, it means a new object is being created in memory. So, the only difference between a copy constructor and a move constructor is whether the source object that is passed to the constructor will have its member fields copied or moved into the new object.

How many times copy constructor will be invoked?

how many times a copy-constructor is called, according to me its 5 but answer is 7.

Why do we pass address in copy constructor?

It is necessary to pass object as reference and not by value because if you pass it by value its copy is constructed using the copy constructor. This means the copy constructor would call itself to make copy. This process will go on until the compiler runs out of memory.

Can we pass by value to copy constructor?

Passing by value (rather than by reference) means a copy needs to be made. So passing by value into your copy constructor means you need to make a copy before the copy constructor is invoked, but to make a copy you first need to call the copy constructor.


Video Answer


2 Answers

You can use remove_cv_t:

#include <type_traits>

template <typename T, typename... Args>
void CreateTAndDoSomething(Args&&... args) {
  // Special case: if this is copy construction, do something different.
  if constexpr (sizeof...(Args) == 1 && is_same_v<T&, remove_cv_t<Args...> >) { ... }

  // Otherwise do something else.
  ...
}

This covers all "copy constructors" as defined by the standard, not considering possible default arguments (it is hard to determine whether a given function parameter -- for the function that would be invoked given these parameters -- is defaulted or not).

like image 60
Anonymous1847 Avatar answered Oct 19 '22 20:10

Anonymous1847


You had the right idea. Everything that is needed is encoded in the deduced type of Args. Though, if you want to account for all of the cv-qualified cases, there will be a lot to go through. Let's first recognize the different cases that might arise:

  1. Construction (implicit conversions are construction)
  2. Copy construction (commonly T(const T&))
  3. Move construction (commonly T(T&&))
  4. Slicing (calling Base(const Base&) or Base(Base&&) with a Derived)

If weird move or copy constructors are not considered (ones with default parameters), the cases 2-4 could only happen a single argument is passed, everything else is construction. Hence, it is sensible to provide an overload for the single argument case. Trying to do all of these cases in the variadic template is going to be ugly, as you have to use fold expressions or something like std::conjuction/std::disjuction for the if statements to be valid.

We will also find out that recognizing move and copy separately in every single case is impossible. If there is no need to consider copies and moves separately, the solution is easy. But if these cases need to be separated, one can only make a good guess, which should work almost always.

What comes to to slicing, I would probably opt to disable it with a static_assert.

Move and copy combined

Here is the solution using a single argument overload. Let's go over it in detail next.

#include <utility>
#include <type_trait>
#include <iostream>


// Multi-argument case is almost always construction
template<typename T, typename... Args>
void CreateTAndDoSomething(Args&&... args)
{   
    std::cout << "Constructed" << '\n';
    T val(std::forward<Args>(args)...);
}

template<typename T, typename U>
void CreateTAndDoSomething(U&& arg)
{
    // U without references and cv-qualifiers
    // std::remove_cvref_t in C++20
    using StrippedU = std::remove_cv_t<std::remove_reference_t<U>>;

    // Extra check is needed because T is a base for itself
    static_assert(
        std::is_same_v<StrippedU, T> || !std::is_base_of_v<T, StrippedU>, 
        "Attempting to slice"
    );
    
    if constexpr (std::is_same_v<StrippedU, T>)
    {
        std::cout << "Copied or moved" << '\n';
    }
    else
    {
        std::cout << "Constructed" << '\n';
    }
    
    T val(std::forward<U>(arg));
}

Here we make use of the fact that U&& (and Args&&) is a forwarding reference. With forwarding references the deduced template argument U is different depending on the value category of the passed arg. Given an arg of type T, the U is deduced such that:

  • If the arg was an lvalue, the deduced U is T& (cv-qualifiers included).
  • If the arg was an rvalue, the deduced U is T (cv-qualifiers included).

NOTE: U might deduce to a cv-qualified reference (eg. const Foo&). std::remove_cv only removes toplevel cv-qualifiers, and references can't have toplevel cv-qualifiers. This is why std::remove_cv needs to applied to a non-reference type. If only std::remove_cv was used, the template would fail to recognize cases where U would be const T&, volatile T& or const volatile T&.

Only copy

A copy constructor is called (usually, see note) when U is deduced to T& const T&, volatile T& or const volatile T&. Because we have three cases where the deduced U is a cv-qualified reference and std::remove_cv doesn't work with these, we should just check these cases explicitly:

template<typename T, typename U>
void CreateTAndDoSomething(U&& arg)
{
    // U without references and cv-qualifiers
    // std::remove_cvref_t in C++20
    using StrippedU = std::remove_cv_t<std::remove_reference_t<U>>;

    // Extra check is needed because T is a base for itself
    static_assert(
        std::is_same_v<StrippedU, T> || !std::is_base_of_v<T, StrippedU>, 
        "Attempting to slice"
    );
    
    if constexpr (std::is_same_v<T&, U> 
        || std::is_same_v<const T&, U>
        || std::is_same_v<volatile T&, U>
        || std::is_same_v<const volatile T&, U>)
    {
        std::cout << "Copied" << '\n';
    }
    else
    {
        std::cout << "Constructed" << '\n';
    }
    
    T val(std::forward<U>(arg));
}

NOTE: This does not recognize copy construction when a move constructor is not available, and the copy constructor with the signature T(const T&) is available. This is because the result of the call to std::forward with an rvalue arg is an xvalue, which can bind to const T&.

Move and copy seperated

DISCLAIMER: this solution only works for the general case (see the pitfalls)

Let's assume that T has a copy constructor with the signature T(const T&) and move constructor with the signature T(T&&), which is really common. const-qualified move constructors do not really make sense, as the moved object needs to be modified almost always.

With this assumption the expression T val(std::forward<U>(arg)); move constructs val, if U was deduced to a non-const T (arg is an non-const rvalue). This gives us two cases:

  1. U is deduced to T
  2. U is deduced to volatile T

By first removing the volatile qualifier from U we can account for both of these cases. When the move construction recognized first, the rest are copy construction:

template<typename T, typename U>
void CreateTAndDoSomething(U&& arg)
{
    // U without references and cv-qualifiers
    using StrippedU = std::remove_cv_t<std::remove_reference_t<U>>;
    
    // Extra check is needed because T is a base for itself
    static_assert(
        std::is_same_v<StrippedU, T> || !std::is_base_of_v<T, StrippedU>, 
        "Attempting to slice"
    );
    
    if constexpr (std::is_same_v<std::remove_volatile_t<U>, T>)
    {
        std::cout << "Moved (usually)" << '\n';
    }
    else if constexpr (std::is_same_v<StrippedU, T>)
    {
        std::cout << "Copied (usually)" << '\n';
    }
    else
    {
        std::cout << "Constructed" << '\n';
    }
    
    T val(std::forward<U>(arg));
}

If you want to play around with the solution, it's available in godbolt. I've also implemented a special class that hopefully helps to visualize the different constructor calls.

Pitfalls of the solution

When the assumption stated earlier is not true, it is impossible to determine exactly whether copy or move constructor is called. There are at least few special cases that cause ambiguity:

  1. If move constructor for T is not available, arg is a rvalue of type T, and the copy constructor has the signature T(const T&):

    The xvalue returned by std::forward<U>(arg) will bind to the const T&. This was also discussed in the "only copy" case.

    Move recognized, but a copy happens.

  2. If T has a move constructor with the signature T(const T&&) and arg is a const rvalue of type T:

    Copy recognized, but a move happens. Similar case with T(const volatile T&&).

I've also decided not to account for the case, when the user explicitly specifies U (T&& and volatile T&& will compile but not recognize properly).

like image 23
Rane Avatar answered Oct 19 '22 20:10

Rane