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 Talso accepts both move and copy semantics. The problem is that afterconst T, you can’t move from it — you can only call the copy constructor. This limitsconst 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
Tis 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 returnsstd::vector<int>. Ifconst 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++17C++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, deducingTasstd::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