TL;DR: Pass by const reference is still a good idea in C++, all things considered. Not a premature optimization.
TL;DR2: Most adages don't make sense, until they do.
Aim
This answer just tries to extend the linked item on the C++ Core Guidelines(first mentioned in amon's comment) a little bit.
This answer does not try to address the issue of how to think and apply properly the various adages that were widely circulated within programmers' circles, especially the issue of reconciling between conflicting conclusions or evidence.
Applicability
This answer applies to function calls (non-detachable nested scopes on the same thread) only.
(Side note.) When passable things can escape the scope (i.e. have a lifetime that potentially exceeds the outer scope), it becomes more important to satisfy the application's need for object lifetime management before anything else. Usually, this requires using references that are also capable of lifetime management, such as smart pointers. An alternative might be using a manager. Note that, lambda is a kind of detachable scope; lambda captures behave like having object scope. Therefore, be careful with lambda captures. Also be careful with how the lambda itself is passed - by copy or by reference.
When to pass by value
For values that are scalar (standard primitives that fit within a machine register and have value semantic) for which there is no need for communication-by-mutability (shared reference), pass by value.
For situations where callee require a cloning of an object or aggregate, pass by value, in which the callee's copy fulfills the need for a cloned object.
When to pass by reference, etc.
for all other situations, pass by pointers, references, smart pointers, handles (see: handle-body idiom), etc. Whenever this advice is followed, apply the principle of const-correctness as usual.
Things (aggregates, objects, arrays, data structures) that are sufficiently large in memory footprint should always be designed to facilitate pass-by-reference, for performance reasons. This advice definitely applies when it is hundreds of bytes or more. This advice is borderline when it is tens of bytes.
Unusual paradigms
There are special-purpose programming paradigms which are copy-heavy by intention. For example, string processing, serialization, network communication, isolation, wrapping of third-party libraries, shared-memory inter-process communication, etc. In these application areas or programming paradigms, data is copied from structs to structs, or sometimes repackaged into byte arrays.
How the language specification affects this answer, before optimization is considered.
Sub-TL;DR Propagating a reference should invoke no code; passing by const-reference satisfies this criterion. However, all other languages satisfy this criterion effortlessly.
(Novice C++ programmers are advised to skip this section entirely.)
(The beginning of this section is partly inspired by gnasher729's answer. However, a different conclusion is reached.)
C++ allows user-defined copy constructors and assignment operators.
(This is (was) a bold choice that is (was) both amazing and regrettable. It is definitely a divergence from today's acceptable norm in language design.)
Even if the C++ programmer does not define one, the C++ compiler must generate such methods based on language principles, and then determine whether additional code needs to be executed other than memcpy
. For example, a class
/struct
that contains a std::vector
member has to have a copy-constructor and an assignment operator that is non-trivial.
In other languages, copy constructors and object cloning are discouraged (except where absolutely necessary and/or meaningful to the application's semantics), because objects have reference semantics, by language design. These languages will typically have garbage collection mechanism that is based on reachability instead of scope-based ownership or reference-counting.
When a reference or pointer (including const reference) is passed around in C++ (or C), the programmer is assured that no special code (user-defined or compiler-generated functions) will be executed, other than the propagation of the address value (reference or pointer). This is a clarity of behavior that C++ programmers find comfortable with.
However, the backdrop is that the C++ language is unnecessarily complicated, such that this clarity of behavior is like an oasis (a survivable habitat) somewhere around a nuclear fallout zone.
To add more blessings (or insult), C++ introduces universal references (r-values) in order to facilitate user-defined move operators (move-constructors and move-assignment operators) with good performance. This benefits a highly relevant use case (the moving (transfer) of objects from one instance to another), by means of reducing the need for copying and deep-cloning. However, in other languages, it is illogical to speak of such moving of objects.
(Off-topic section) A section dedicated to an article, "Want Speed? Pass by Value!" written in circa 2009.
That article was written in 2009 and explains the design justification for r-value in C++. That article presents a valid counter-argument to my conclusion in the previous section. However, the article's code example and performance claim has long been refuted.
Sub-TL;DR The design of r-value semantics in C++ allows for a surprisingly elegant user-side semantics on a Sort
function, for example. This elegant is impossible to model (imitate) in other languages.
A sort function is applied to a whole data structure. As mentioned above, it would be slow if a lot of copying is involved. As a performance optimization (that is practically relevant), a sort function is designed to be destructive in quite a few languages other than C++. Destructive means that the target data structure is modified to achieve the sorting goal.
In C++, the user can choose to call one of the two implementations: a destructive one with better performance, or a normal one which does not modify the input. (Template is omitted for brevity.)
/*caller specifically passes in input argument destructively*/
std::vector<T> my_sort(std::vector<T>&& input)
{
std::vector<T> result(std::move(input)); /* destructive move */
std::sort(result.begin(), result.end()); /* in-place sorting */
return result; /* return-value optimization (RVO) */
}
/*caller specifically passes in read-only argument*/
std::vector<T> my_sort(const std::vector<T>& input)
{
/* reuse destructive implementation by letting it work on a clone. */
/* Several things involved; e.g. expiring temporaries as r-value */
/* return-value optimization, etc. */
return my_sort(std::vector<T>(input));
}
/*caller can select which to call, by selecting r-value*/
std::vector<T> v1 = {...};
std::vector<T> v2 = my_sort(v1); /*non-destructive*/
std::vector<T> v3 = my_sort(std::move(v1)); /*v1 is gutted*/
Aside from sorting, this elegance is also useful in the implementation of destructive median finding algorithm in an array (initially unsorted), by recursive partitioning.
However, note that, most languages would apply a balanced binary search tree approach to sorting, instead of applying a destructive sorting algorithm to arrays. Therefore, the practical relevance of this technique is not as high as it seems.
How compiler optimization affects this answer
When inlining (and also whole-program optimization / link-time optimization) is applied across several levels of function calls, the compiler is able to see (sometimes exhaustively) the flow of data. When this happens, compiler can apply many optimizations, some of which can eliminate the creation of whole objects in memory. Typically, when this situation applies, it doesn't matter if the parameters are passed by value or by const-reference, because the compiler can analyze exhaustively.
However, if the lower level function calls something that is beyond analysis (e.g. something in a different library outside compilation, or a call graph that is simply too complicated), then the compiler must optimize defensively.
Objects larger than a machine register value might be copied by explicit memory load/store instructions, or by a call to the venerable memcpy
function. On some platforms, the compiler generates SIMD instructions in order to move between two memory locations, each instruction moving tens of bytes (16 or 32).
Discussion on the issue of verbosity or visual clutter
C++ programmers are accustomed to this, i.e. as long as a programmer doesn't hate C++, the overhead of writing or reading const-reference in source code isn't horrible.
The cost-benefit analyses might have been done many times before. I don't know if there's any scientific ones which should be cited. I guess most analyses would be non-scientific or non-reproducible.
Here is what I imagine (without proof or credible references)...
- Yes, it affects the performance of software written in this language.
- If compilers can understand the purpose of code, it could potentially be smart enough to automate that
- Unfortunately, in languages that favor mutability (as opposed to functional purity), the compiler would classify most things as being mutated, therefore the automated deduction of constness would reject most things as non-const
- The mental overhead depends on people; people who find this to be a high mental overhead would have rejected C++ as a viable programming language.
const T
toconst T&
) which is not permitted by the standart for a good number of reasons: From thread-safety (are the functions reentrant?) to application logic (the class may e.g. use amutable
cache to optimize calculations) where using a (const) reference instead of a (const) copy would break things.