C++ Functions, References, and Pass-By-Value
Write C++ functions that pass data correctly: by value, by reference, by const reference, with overloading, default arguments, and clear ownership.
What you'll learn
- ✓Choose pass-by-value, reference, or const-reference correctly
- ✓Use default arguments and overloading without abuse
- ✓Return values cleanly and avoid dangling references
- ✓Declare functions with auto trailing return types
- ✓Spot the cost of copies in hot paths
Prerequisites
- •C++ syntax basics — see /blog/cpp-control-flow
Functions are how C++ programs are composed. The choices you make at the signature level — value, reference, const reference — decide both performance and correctness.
Function basics
A C++ function has a return type, a name, a parameter list, and a body.
int add(int a, int b) {
return a + b;
}
Declare functions in headers so other translation units can call them. Define them in .cpp files unless they are templates or short inline helpers.
// math.h
int add(int a, int b);
// math.cpp
#include "math.h"
int add(int a, int b) { return a + b; }
Pass by value
Parameters declared without & are copies. Modifications inside the function do not affect the caller.
void increment(int x) { ++x; } // caller's x unchanged
int n = 5;
increment(n); // n is still 5
Pass small, trivially copyable types (int, double, pointers, small structs) by value. Copying them is essentially free.
Pass by reference
A reference is an alias for the caller’s variable. Marking a parameter T& lets the function mutate the original.
void increment(int& x) { ++x; }
int n = 5;
increment(n); // n is now 6
References cannot be null and cannot be rebound after initialization. They are safer than pointers when you need a non-owning, always-valid handle.
Pass by const reference
For larger types — std::string, std::vector, custom classes — pass by const T&. You avoid the copy and the function cannot mutate the caller’s value.
#include <string>
std::size_t length(const std::string& s) {
return s.size();
}
Rule of thumb: if the type is bigger than two pointers, pass by const&. The standard string and container types definitely qualify.
Returning values
Return by value for most functions. Modern compilers apply return-value optimization (RVO) and move semantics so returning a std::vector<int> is cheap.
std::vector<int> make_range(int n) {
std::vector<int> out;
out.reserve(n);
for (int i = 0; i < n; ++i) out.push_back(i);
return out; // moved, not copied
}
Never return a reference to a local. The local dies at the closing brace and you hand the caller a dangling reference.
const std::string& bad() {
std::string s = "oops";
return s; // dangling — undefined behavior on use
}
Default arguments
You can supply defaults from the rightmost parameter inward. Declare them in the header only; the definition omits them.
// header
void log(const std::string& msg, int level = 1);
// source
void log(const std::string& msg, int level) { /* ... */ }
Default arguments are convenient but they make overload resolution trickier. If a function has more than two defaults, consider an options struct instead.
Function overloading
Multiple functions can share a name as long as their parameter lists differ.
int max(int a, int b) { return a > b ? a : b; }
double max(double a, double b) { return a > b ? a : b; }
Overloading is resolved at compile time based on the argument types. Avoid overloads that differ only in subtle conversions; the call site becomes a puzzle.
Trailing return types and auto
For complex return types, the trailing form is often clearer.
auto make_pair(int a, int b) -> std::pair<int, int> {
return {a, b};
}
In simple cases you can let the compiler deduce the return type:
auto square(int x) { return x * x; }
Use return deduction sparingly in public APIs; explicit types document intent.
Inline and constexpr functions
inline was originally a hint to inline the body. Today it mostly tells the linker that multiple definitions are allowed, which is necessary for functions defined in headers.
inline int double_it(int x) { return x * 2; }
constexpr allows the function to run at compile time when arguments are constants.
constexpr int factorial(int n) {
return n <= 1 ? 1 : n * factorial(n - 1);
}
constexpr int twenty_four = factorial(4); // computed at compile time
Lambdas as inline functions
Lambdas create anonymous function objects. Use them for small callbacks.
#include <algorithm>
#include <vector>
std::vector<int> v{3, 1, 4, 1, 5};
std::sort(v.begin(), v.end(), [](int a, int b) { return a > b; });
The square brackets are the capture list. [&] captures surrounding variables by reference, [=] by value. Be deliberate: capturing by reference into a callback that outlives the scope is another dangling-reference trap.
A worked example
A function that trims whitespace from a string, mutating in place via reference:
#include <string>
#include <cctype>
void trim(std::string& s) {
auto is_space = [](unsigned char c) { return std::isspace(c); };
while (!s.empty() && is_space(s.back())) s.pop_back();
auto it = s.begin();
while (it != s.end() && is_space(*it)) ++it;
s.erase(s.begin(), it);
}
The reference parameter signals mutation. If you preferred a pure version, return std::string by value.
Common pitfalls
- Returning references or pointers to locals.
- Accidentally copying a large parameter by forgetting
&. - Overloading on types that convert to each other implicitly.
- Capturing a lambda by reference and storing it past the scope.
What is next
Functions usually need to manipulate memory directly at some point. Read Pointers and Memory next, then Classes, Constructors, and the Rule of Three to build your own types.
Wrap up
Pass small types by value, large types by const&, mutate via &, return by value and trust the compiler. Reach for overloads and defaults when they clarify; avoid them when they obscure. Signatures are a contract — write them with intent.