In this article, we’ll discuss a small but practical question: when should you use each kind of function parameter type?

Classifying Parameters

Suppose we have a type T (to keep things simple, T here is not a generic type parameter). How many ways are there to pass a parameter of this type to a function? Let’s break it down:

  • Qualifier: const-qualified or not
  • Reference category: by value, by lvalue reference, by rvalue reference

Based on this classification, we get exactly 5 parameter types:

const? Reference? Parameter Type
Yes By value const T
Yes Lvalue ref const T&
No By value T
No Lvalue ref T&
No Rvalue ref T&&

const T&& is meaningless.

Now let’s look at when to use each.

Transferring Ownership

If you need to transfer the parameter’s lifetime into the function, in most cases simply using T as the parameter type works fine.

For example, suppose we have a class A whose constructor accepts and takes ownership of a std::vector<int>. The parameter type should be std::vector<int>.

class A {
public:
    A(std::vector<int> v) : v_(std::move(v)) {}

private:
    std::vector<int> v_;
};

Consider two scenarios for constructing an A object. In the first, we won’t use the std::vector<int> object after passing it in. We can use move semantics:

std::vector<int> v;
// ...
A a(std::move(v));

In the second, we’ll still use the object afterward. We need copy semantics:

std::vector<int> v;
// ...
A a(v);

Either way, using T as the parameter type handles both cases.

Using const T also accepts both move and copy semantics. The problem is that after const T, you can’t move from it — you can only call the copy constructor. This limits const T’s usefulness:

class A {
public:
    A(const std::vector<int> v)
        : v_(std::move(v)) // always copy-constructs
        {}

private:
    std::vector<int> v_;
};

There’s a special case in ownership transfer: the function may move ownership, or it may not. For example:

std::vector<int> v = {1, 2, 3};
bool ok = may_or_maynot_move(std::move(v));
assert(ok ^ !v.empty());

The function may_or_maynot_move() may move v. If it does, it returns true and v becomes empty; if it doesn’t, it returns false and v remains intact. In this case, T cannot be the parameter type, because v would already be moved-from at the call site, becoming empty regardless.

For this scenario, use T&& as the parameter type. The function would roughly look like this:

bool may_or_maynot_move(std::vector<int> &&v) {
    bool need_move = ...;
    if (need_move) {
        move_func(std::move(v)); // avoid reference collapsing
    }
    return need_move;
}

Read-Only Parameters

If you don’t need ownership of the parameter — you just want to read its data — use const T&. const T& accepts both lvalues and rvalues. However, while const T& can accept an rvalue, it won’t invoke the move constructor, so the object referenced by the rvalue remains unchanged during the call. This is just a convenience to simplify passing function return values directly as arguments.

Of course, if the parameter size is small, using plain T is better.

size_t func(const std::vector<int> &v) {
    return v.size();
}

std::vector<int> v = {1, 2, 3};

func(v); // 3
func(std::move(v)); // 3
func(v); // 3

Suppose we have a function gen_vec() that returns std::vector<int>. If const T& didn’t accept rvalues, we’d have to write:

std::vector<int> v = gen_vec();
func(v);

Since const T& accepts rvalues, we can eliminate the temporary:

func(gen_vec());

Output Parameters

To obtain a function’s result while avoiding the overhead of return-by-value, you can use a T& parameter. This is a common pattern.

void func(std::vector<int> &v) {
    v.push_back(1);
}

Unlike const T&, T& only accepts an lvalue. That’s because an rvalue means the object’s lifetime is ending — modifying such data is meaningless.

Template Type Deduction

For templates, if you explicitly specify the template parameters, choosing a parameter type is no different from the non-template cases. But when implicit type deduction is involved, things get more complex.

Let’s return to the ownership transfer scenario. Can we still use T as the parameter type? Consider this example:

template<typename T>
void func(T v) {
    func2(std::move(v)); // type 1
    func2(v); // type 2
}

We chose T as the parameter type, but if we want to forward this parameter to another function, we encounter a problem: we don’t know whether to use copy or move semantics. T’s type is unknown — it could be copy-only or move-only. So neither copy nor move is appropriate here.

The correct choice here is T&& (a bare template parameter T with &&). But in this context, T&& does not mean rvalue reference — it’s a universal reference (also called a forwarding reference). Its semantics: if the argument is an lvalue reference, T&& collapses to an lvalue reference; if the argument is an rvalue reference, T&& collapses to an rvalue reference.

But this alone doesn’t solve the problem. Due to reference collapsing, an rvalue reference passed as a parameter defaults to an lvalue reference and needs std::move to become an rvalue reference again. So we need something like std::move but conditional: lvalue references stay lvalue references, rvalue references stay rvalue references. This behavior is called perfect forwarding and is implemented by std::forward.

template<typename T>
void func(T &&v) {
    func2(std::forward<T>(v)); // perfect forwarding
}

Reference collapsing exists for a reason. When you pass an rvalue reference onward to another function, the rvalue’s lifetime doesn’t necessarily end in that other function. So you must explicitly opt into move semantics.

void func(int&& a) {
    func2(a); // After func2, a's lifetime may not end, so func2 receives an lvalue reference
    func3(std::move(a)); // Move semantics: a's lifetime ends inside func3
}

With T&& combined with std::forward, we effectively delegate the choice of lvalue vs. rvalue to the caller further up the chain. The caller decides whether to pass with copy or move semantics.

A special case is template type deduction in constructors. Before C++17, if a constructor used the class template’s template parameters, the compiler couldn’t deduce the template arguments from constructor arguments. For example:

template<typename T>
class B {
public:
    B(T v) {}
};

B b(std::vector<int>{1, 2, 3}); // Won't compile before C++17

C++17 added class template argument deduction (CTAD) from constructors, but T&& in a constructor does not act as a universal reference — it’s just an rvalue reference. In the following example, T&& only accepts rvalue references, deducing T as std::vector<int>. Passing an lvalue reference will cause the compiler to fail to find a matching constructor:

template<typename T>
class C {
public:
    C(T &&v) : v_(std::forward<T>(v)) {}
private:
    T v_;
};

std::vector<int> v = {1, 2, 3};
C c1(v); // Bad
C c2(std::move(v)); // Good