Performance[perf]
C++ pass by value or const reference: the rule since C++17
- Section
- Performance [perf]
- Standard
- C++20
- Compilers
- MSVC 19.44, Clang 19.1
- Published
- Length
- 16 min read
In this answer9 clauses
- 1The short answer, by what the function does with the argument
- 2Read-only parameters: cheap types by value, the rest by const reference
- 3Read-only sequences: std::string_view and std::span by value
- 4Parameters the function keeps: take by value and move
- 5What C++17 guaranteed copy elision changed for arguments
- 6Constructors and setters get different answers
- 7Count the copies yourself
- 8Templates: forwarding references for generic sinks
- 9Let clang-tidy find the candidates
Abstract
Take a parameter the function only reads by value when it is small and cheap to copy (int, double, pointers, iterators, std::string_view, std::span) and by const reference otherwise; take one it keeps by value and std::move it into place, except in a setter, where const T& (plus a T&& overload) lets the member reuse the buffer it already has. Since C++17 a temporary argument initializes a by-value parameter directly, so passing one costs no copy and no move.

Choose by what the function does with the argument. A parameter it only reads goes by value when the type is small and cheap to copy (int, double, a pointer or an iterator, std::string_view, std::span) and by const T& otherwise. A parameter it keeps, in a member, a container or its return value, goes by value and is std::moved into place. One it changes for the caller is a T&, and a move-only owner such as std::unique_ptr that it takes over goes by value.
This guide is written for C++20, and three standards changed the answer. C++11 added moves, which made by-value cheap for an argument the function keeps. C++17 made the copy of a temporary argument disappear by rule rather than by optimization, and added std::string_view; C++20 added std::span. The older advice, pass anything bigger than a pointer by const reference, still holds for read-only parameters and costs a copy for the ones a function keeps. Every sample was compiled with MSVC 19.44 (/std:c++20 /EHsc /W4 /permissive-) and Clang 19.1 (-std=c++20 -Wall -Wextra, on Microsoft’s standard library) on one Windows x64 machine, without optimization unless the text says otherwise; the outputs and counts shown are what those builds produced there.
What changed: a parameter the function keeps is taken by by value and moved into place.const T& and copied
since C++11
The short answer, by what the function does with the argument
| The function | Take | Example |
|---|---|---|
| reads it, and it is cheap to copy | T |
double length(Point p) |
| reads it, and copying it costs | const T& |
void print(const std:: |
| reads text or a contiguous sequence | a view, by value | std::string_view, std::span<const T> |
| keeps it | T, then std::move |
Person(std:: |
| changes the caller’s object | T& |
void normalize(std:: |
| takes over a move-only owner | T |
void adopt(std:: |
| passes it on, in a template | T&& and std::forward |
template <class S> void add(S&& s) |
Point there is a struct of two doubles, 16 bytes and trivially copyable, and Widget a class whose copy allocates. Results go out through the return value rather than an output parameter (Core Guidelines F.20): returning a local costs at most a move for a movable type, as the guide to move semantics and std::move shows. The rest of this guide takes the rows in turn.
Read-only parameters: cheap types by value, the rest by const reference
A const T& parameter is passed as an address, and the function reads the object through it. For small types the C++ Core Guidelines’ F.16 prefers the copy: “two or three words (doubles, pointers, references) are usually best passed by value”, since a copy “does not require an extra indirection to access from the function”. Its enforcement notes put numbers on it: warn when a by-value parameter is larger than 4 * sizeof(void*), and when a const& one is no larger than 2 * sizeof(void*). On a 64-bit target that is 32 and 16 bytes.
Size is half the test. Abseil’s Tip #234 adds the other half: a type of up to 16 bytes, a figure its footnote gives for x86-64 Linux, can be passed efficiently by value, in registers, when its copy and move operations and its destructor are all trivial. On Windows, the footnote adds, only types of 8 bytes or fewer are passed in registers. This program prints both properties for some common types:
#include <cstdio>
#include <functional>
#include <memory>
#include <span>
#include <string>
#include <string_view>
#include <type_traits>
#include <vector>
template <class T>
void row(const char* name) {
std::printf("%-24s %3zu %s\n", name, sizeof(T),
std::is_trivially_copyable_v<T> ? "yes" : "no");
}
int main() {
std::printf("%-24s %3s %s\n", "type", "size", "trivially copyable");
row<int>("int");
row<double>("double");
row<std::string_view>("std::string_view");
row<std::span<const int>>("std::span<const int>");
row<std::unique_ptr<int>>("std::unique_ptr<int>");
row<std::shared_ptr<int>>("std::shared_ptr<int>");
row<std::string>("std::string");
row<std::vector<int>>("std::vector<int>");
row<std::function<void()>>("std::function<void()>");
}— end example]
Both compilers print the same table for x64 with Microsoft’s STL; other standard libraries may give other sizes, so run it on yours:
| Type | Size in bytes | Trivially copyable |
|---|---|---|
int |
4 | yes |
double |
8 | yes |
std::string_view |
16 | yes |
std::span<const int> |
16 | yes |
std::unique_ptr<int> |
8 | no |
std::shared_ptr<int> |
16 | no |
std::string |
32 | no |
std::vector<int> |
24 | no |
std:: |
64 | no |
std::string is 32 bytes, inside F.16’s by-value limit, and is still a const std::string& parameter: copying a long one allocates. Cheap to copy means small and trivially copyable, or close to it. std::unique_ptr is small but not trivially copyable, and it is never copied anyway: it goes by value only when the function takes ownership.
By value also protects the function from aliasing, which a const reference does not:
#include <iostream>
#include <vector>
void scale_ref(std::vector<double>& v, const double& factor) {
for (double& x : v) x *= factor;
}
void scale_val(std::vector<double>& v, double factor) {
for (double& x : v) x *= factor;
}
int main() {
std::vector<double> a{2, 3};
std::vector<double> b{2, 3};
scale_ref(a, a[0]); // factor refers to a[0], which the loop changes
scale_val(b, b[0]); // factor is a copy of b[0]
std::cout << a[0] << ' ' << a[1] << '\n'; // 4 12
std::cout << b[0] << ' ' << b[1] << '\n'; // 4 6
}— end example]
const double& only promises that scale_ref won’t write through factor. Here factor is a[0], so the first iteration changes it to 4 and the second multiplies by 4. A copy can’t change behind the function’s back, and the compiler doesn’t have to assume that it might.
Read-only sequences: std::string_view and std::span by value
For text the function only reads, take std::string_view (C++17) by value. It is a pointer and a length, trivially copyable, and it binds to a std::string, a string literal or a char array without copying a character. A const std::string& parameter handed a literal has to construct a temporary std::string first, which allocates when the literal is long. std::span<const T> (C++20) does the same for contiguous elements: a std::vector<T>, a std::array<T, N> or a built-in array. A std::span<T> without the const lets the function change the elements in place.
#include <cstddef>
#include <cstdio>
#include <span>
#include <string>
#include <string_view>
#include <vector>
std::size_t count_spaces(std::string_view text) {
std::size_t n = 0;
for (char c : text) n += (c == ' ');
return n;
}
double sum(std::span<const double> xs) {
double total = 0;
for (double x : xs) total += x;
return total;
}
void scale(std::span<double> xs, double factor) { // in-out: span<T>
for (double& x : xs) x *= factor;
}
int main() {
std::string line = "one two three";
std::vector<double> v{1.5, 2.5};
double a[]{1.0, 2.0, 3.0};
std::size_t s1 = count_spaces(line); // a std::string: no copy
std::size_t s2 = count_spaces("a b c"); // a literal: no std::string
scale(v, 2.0); // changes v's elements
double t1 = sum(v); // a std::vector
double t2 = sum(a); // a built-in array
std::printf("%zu %zu %g %g\n", s1, s2, t1, t2); // 2 2 8 6
}— end example]
Three rules come with views:
- Pass them by value. A
const std::adds an indirection to something that already refers to the characters.string_view& - A view owns nothing. Don’t keep one after the call unless its owner is sure to outlive it; a function that keeps the text takes a
std::string, as the next section shows. std::string_viewis not null-terminated. Don’t handtext.data()to a C function that expects a C string; build astd::stringfor that.
std::span first shipped in GCC 10.1 (libstdc++), in LLVM 7’s libc++ and in Visual Studio 2019 16.6; the C++20 table of Since When has the row and the vendor pages it comes from, and its C++17 table has std::string_view.
Parameters the function keeps: take by value and move
class Person {
public:
explicit Person(std::string name) // keep: by value...
: name_(std::move(name)) {} // ...then move into place
private:
std::string name_;
};— end example]
The caller decides what the parameter costs. An lvalue is copied into name, std::move(x) is moved into it, and a temporary initializes it directly; then the constructor moves name into the member. Here is each kind of argument counted with a type that logs its special members, for three ways of writing the constructor (the program that printed this is under “Count the copies yourself” below; “make” is the construction from a string literal):
| Argument | const T& |
T, then std::move |
const T& and T&& |
|---|---|---|---|
an lvalue t |
copy | copy, move | copy |
std::move(t) |
copy | move, move | move |
a temporary Tracer("x") |
make, copy | make, move | make, move |
a literal "x" |
make, copy | make, move | make, move |
By value costs one move more than the overload pair for an lvalue and for std::move(t), the same for a temporary or a literal, and it is one function instead of two; with two kept parameters the pairs become four constructors. The extra move copies a few words for std::string, std::vector or std::unique_ptr. For a type whose move is a copy, such as std:: or a struct of plain data, it is a second full copy, so take those by const T&.
For a function that always consumes its argument, the Core Guidelines’ F.18 prefers X&& with std::move inside, which makes callers spell out std::move for an lvalue. It makes an exception for cheap-to-move owners such as std::unique_ptr, which “can also be passed by value which is simpler to write and achieves the same effect”.
What C++17 guaranteed copy elision changed for arguments
Before C++17, a temporary passed by value was an object of its own that the compiler was allowed, not required, to build in the parameter’s place, and the type needed an accessible copy or move constructor either way. Since C++17 a prvalue is not materialized until it is needed, and then it is constructed directly in its final destination (cppreference’s copy elision page states the rule). For an argument, the destination is the parameter. So a type that can be neither copied nor moved can still be passed by value:
struct Pinned { // can be neither copied nor moved
explicit Pinned(int v) : value(v) {}
Pinned(const Pinned&) = delete;
Pinned(Pinned&&) = delete;
int value;
};
Pinned make_pinned(int v) { return Pinned(v); }
int read(Pinned p) { return p.value; }
int main() {
int a = read(Pinned(1)); // C++17: p is initialized directly
int b = read(make_pinned(2)); // C++17: still no copy or move
// Pinned p(3); read(p); // error in every standard: no copy
return a + b == 3 ? 0 : 1;
}— end example]
Both compilers accept it with C++17 and C++20 flags, and both reject it with /std:c++14 and -std=c++14, where every line that passes or returns a Pinned needs the deleted Pinned(Pinned&&). Guaranteed copy elision first shipped in GCC 7, Clang 4 and Visual Studio 2017 15.6, per the C++17 table of Since When. The move semantics guide shows the same rule for return values.
What C++17 did not change:
- An lvalue argument is still copied. Uncomment the
read(p)line and neither compiler accepts it, under any standard. std::move(x)is an xvalue, not a prvalue, so the parameter is move-constructed from it: the “move, move” row above.- A parameter is never elided on return.
return p;inside the function moves it implicitly; it is never constructed in the caller’s object. - Trivial types may still be copied in passing. For a type whose eligible copy and move constructors are trivial and whose destructor is trivial or deleted, [class.temporary] lets the implementation create extra temporaries “to allow objects to be passed to or returned from functions in registers”. Those copies run no code of yours.
Constructors and setters get different answers
A constructor builds its member from nothing, so the copy has to happen somewhere, and by value only adds a move. A setter assigns to a member that already exists and may already own a buffer large enough for the new value. With const std::string&, name_ = n can copy the characters into that buffer. By value, the copy into the parameter allocates a new buffer, the move assignment hands it to the member, and the member’s old buffer is freed. This program counts calls to operator new while a 30-character string is assigned into a member that already holds 40, once from an lvalue and once from a temporary:
#include <cstdio>
#include <cstdlib>
#include <new>
#include <string>
#include <utility>
int allocations = 0; // calls to the global operator new
void* operator new(std::size_t n) {
++allocations;
if (void* p = std::malloc(n)) return p;
throw std::bad_alloc{};
}
void operator delete(void* p) noexcept { std::free(p); }
void operator delete(void* p, std::size_t) noexcept { std::free(p); }
class ByConstRef {
public:
void set_name(const std::string& n) { name_ = n; }
private:
std::string name_ = std::string(40, 'a'); // already on the heap
};
class ByValue {
public:
void set_name(std::string n) { name_ = std::move(n); }
private:
std::string name_ = std::string(40, 'a');
};
class Overloads {
public:
void set_name(const std::string& n) { name_ = n; }
void set_name(std::string&& n) { name_ = std::move(n); }
private:
std::string name_ = std::string(40, 'a');
};
template <class T>
void count(const char* label) {
const std::string next(30, 'b'); // too long for the inline buffer
T obj;
allocations = 0;
obj.set_name(next); // an lvalue
int lvalue = allocations;
allocations = 0;
obj.set_name(std::string(30, 'c')); // a temporary
std::printf("%-10s lvalue %d, temporary %d\n", label, lvalue,
allocations);
}
int main() {
count<ByConstRef>("const&");
count<ByValue>("by value");
count<Overloads>("overloads");
}— end example]
Both compilers print:
const& lvalue 0, temporary 1
by value lvalue 1, temporary 1
overloads lvalue 0, temporary 1— end example]
The one allocation for a temporary is the temporary itself; after that, the by-value and && setters move its buffer in, and the const& setter copies its characters. The standard doesn’t require copy assignment to reuse capacity. Microsoft’s STL did here, and the program will show what yours does. So the advice splits:
- Constructors: by value, then
std::move. One function, at most one extra move. clang-tidy’s modernize-pass-by-value rewritesconst&constructor parameters that are copied into members this way, and its documentation says that “only constructors are transformed”. Abseil’s tip gives the same advice for a constructor that stores its argument. - Setters of types that own a buffer (
std::string,std::vector):const T&, plus aT&&overload that moves if callers often pass temporaries. That pair is F.16’s own form for an argument the function stores elsewhere, which F.16 files under advanced uses, for when rvalues are worth optimizing for. - By value still suits some setters: one whose member is usually empty, since there is no buffer to reuse, or one whose callers mostly pass temporaries, which it moves in.
Coroutines are the exception to all of this. A coroutine copies its parameters into its frame, and for a reference parameter that copies only the reference, which dangles if the caller’s object is gone by the time the coroutine resumes. Take coroutine parameters by value (Core Guidelines CP.53); for that reason the current clang-tidy documentation gives performance-unnecessary-value-param an IgnoreCoroutines option, on by default (clang-tidy 19’s doesn’t list it). The guide to generators and co_yield shows the trap.
Count the copies yourself
Here is the program that printed the table under “Parameters the function keeps”. Tracer logs each of its special members, and show runs one construction and prints what happened:
#include <cstdio>
#include <string>
#include <utility>
std::string events; // what the special members did, in order
struct Tracer {
Tracer(const char*) { events += " make"; }
Tracer(const Tracer&) { events += " copy"; }
Tracer(Tracer&&) noexcept { events += " move"; }
Tracer& operator=(const Tracer&) {
events += " copy=";
return *this;
}
Tracer& operator=(Tracer&&) noexcept {
events += " move=";
return *this;
}
};
struct ByConstRef {
explicit ByConstRef(const Tracer& t) : t_(t) {}
Tracer t_;
};
struct ByValue {
explicit ByValue(Tracer t) : t_(std::move(t)) {}
Tracer t_;
};
struct Overloads {
explicit Overloads(const Tracer& t) : t_(t) {}
explicit Overloads(Tracer&& t) : t_(std::move(t)) {}
Tracer t_;
};
template <class F>
void show(const char* label, F f) {
events.clear();
f();
std::printf("%-22s%s\n", label, events.c_str());
}
int main() {
Tracer lv("x");
show("const& lvalue", [&] { ByConstRef a(lv); });
show("const& xvalue", [&] { ByConstRef a(std::move(lv)); });
show("const& prvalue", [&] { ByConstRef a(Tracer("x")); });
show("const& literal", [&] { ByConstRef a("x"); });
show("value lvalue", [&] { ByValue a(lv); });
show("value xvalue", [&] { ByValue a(std::move(lv)); });
show("value prvalue", [&] { ByValue a(Tracer("x")); });
show("value literal", [&] { ByValue a("x"); });
show("pair lvalue", [&] { Overloads a(lv); });
show("pair xvalue", [&] { Overloads a(std::move(lv)); });
show("pair prvalue", [&] { Overloads a(Tracer("x")); });
show("pair literal", [&] { Overloads a("x"); });
}— end example]
Both compilers print the same lines, with C++20 flags and again with C++17 flags:
const& lvalue copy
const& xvalue copy
const& prvalue make copy
const& literal make copy
value lvalue copy move
value xvalue move move
value prvalue make move
value literal make move
pair lvalue copy
pair xvalue move
pair prvalue make move
pair literal make move— end example]
Tracer holds no state, so reusing lv after std::move(lv) is harmless here; in real code it would be a use-after-move. Return a local Tracer from a function and the counts start to depend on the compiler and its flags, because eliding a named return value is allowed, not required; the move semantics guide shows MSVC eliding or moving the same return depending on /permissive- and /O2. Counts tell you what the code does, not what it costs. To learn that for your own hot path, time it with a benchmarking library such as Google Benchmark or nanobench, both listed on Toolchain; the Performance section covers the rest of where copies hide.
Templates: forwarding references for generic sinks
In a template, a parameter S&& whose S is deduced is a forwarding reference: it binds to any argument and keeps its value category, and std::forward<S> passes it on as the lvalue or rvalue it was. A sink written this way does the minimum for every argument:
#include <concepts>
#include <cstdio>
#include <string>
#include <utility>
#include <vector>
class Roster {
public:
template <class S>
requires std::constructible_from<std::string, S>
void add(S&& name) {
names_.emplace_back(std::forward<S>(name));
}
std::size_t size() const { return names_.size(); }
private:
std::vector<std::string> names_;
};
int main() {
Roster r;
std::string ada = "Ada";
r.add(ada); // copies ada into the vector
r.add(std::move(ada)); // moves it
r.add("Grace"); // builds the string in place from the literal
// r.add(42); // rejected by the constraint, not deep inside
std::printf("%zu\n", r.size());
}— end example]
emplace_back copies ada, moves std::move(ada), and builds the third string in place from the literal with no temporary std::string at all. The cost is a template: it lives in a header, it is instantiated for each argument type, and without the requires clause (C++20) it accepts anything and fails deep inside emplace_back. With the clause, r.add(42) stops at the call, and both compilers name std:: as the constraint that failed. F.16 points to perfect forwarding for functions with several stored parameters, where overload pairs multiply. A forwarding constructor needs a constraint for one more reason: left unconstrained, it is a better match than the copy constructor for a non-const lvalue of its own class. How std::forward decides is in the move semantics guide.
Let clang-tidy find the candidates
Three clang-tidy checks find most of the parameters worth changing. Put them in a .clang-tidy file at the root of the project:
Checks: >
-*,
modernize-pass-by-value,
performance-unnecessary-value-param,
performance-move-const-arg— end example]
Then run clang-tidy params.cpp -- -std=c++20 on this file:
#include <cstddef>
#include <string>
#include <utility>
#include <vector>
class Config {
public:
explicit Config(const std::string& path) : path_(path) {}
private:
std::string path_;
};
std::size_t total(std::vector<std::string> lines) {
std::size_t n = 0;
for (const auto& line : lines) n += line.size();
return n;
}
void log_line(const std::string& line);
void report(std::string message) {
log_line(std::move(message));
}
int main() {
Config c("app.toml");
return static_cast<int>(total({"a", "bc"})) - 3;
}
void log_line(const std::string&) {}— end example]
clang-tidy 19.1, as shipped with Visual Studio 2022 17.14, gives four warnings:
Config: “pass by value and use std::move” (modernize-pass-by-value).totalandreport: “the parameter … is copied for each invocation but only used as a const reference” (performance-unnecessary-value-param).reportlooks like a sink, butlog_linetakes aconst std::string&, so nothing is moved.std::move(message): “passing result of std::move() as a const reference argument; no move will actually happen” (performance-move-const-arg).
Each warning comes with a fix that -fix applies; review it, since changing a signature can break callers in other files. modernize-pass-by-value leaves setters alone, so the setter decision above stays with you. Toolchain lists clang-tidy with the other analyzers.
Questions
Is passing by const reference always faster than passing by value?
No. For small trivially copyable types, by value saves the indirection a reference needs and lets the value travel in registers where the calling convention allows it, which is why the Core Guidelines pass two or three words by value. For types whose copy allocates, const T& avoids the copy, and for an argument the function keeps, by value lets callers move instead of copy. On a hot path, measure both with a benchmarking library rather than trusting a rule of thumb.
Should a constructor take std::string by value or by const reference?
By value, then std::move it into the member: an lvalue argument costs one copy and one move, std::move(s) costs two moves and a temporary one move, all from a single constructor. That is the form clang-tidy’s modernize-pass-by-value rewrites constructors to. If the extra move matters, the Core Guidelines’ F.16 gives the alternative, a const std::string& overload plus a std::string&& overload.
Should I pass std::string_view by value or by const reference?
By value. A std::string_view is a pointer and a length, 16 bytes and trivially copyable in the x64 builds checked here, so a reference to it only adds an indirection to something that already refers to the characters. The same goes for std::span.
Should I pass std::shared_ptr by value or by const reference?
It depends on whether the function shares ownership. Take std::shared_ptr<T> by value when it will keep a copy, and move it into place (Core Guidelines R.34); take const std::shared_ptr<T>& when it only might keep one (R.36), and std::shared_ptr<T>& when it may reseat the caller's pointer (R.35). If it only uses the object, take T& or T*, which spares the reference-count update that copying a shared_ptr makes.
Does guaranteed copy elision mean passing by value never copies?
No. Since C++17 only a prvalue argument, such as a temporary or a function’s return value, initializes the parameter directly. An lvalue argument is still copied and std::move(x) is still moved, and for types with trivial copy and move constructors and a trivial destructor the standard lets implementations create extra temporaries so the object can travel in registers.
Sources
- Copy elisioncppreference.com
- F.16: For “in” parameters, pass cheaply-copied types by value and others by reference to constC++ Core Guidelines
- Tip of the Week #234: Pass by Value, by Pointer, or by Reference?Abseil
- clang-tidy: modernize-pass-by-valueLLVM Project