C++ Signatures: It’s Not Just About Performance, It’s About Ownership
Source: Dev.to
Passing by Value vs. Passing by Reference
In Java and Python every object is tracked by a pointer, but the language hides that pointer to make developers’ lives easier.
If you want to pass something by value, you must actively build a deep copy (or call a method that creates a deep copy internally).
For these languages— which usually care more about usability than raw performance—passing by reference is a good generic way to do things.
You may be wondering: “But passing by reference is always better in C++ too! You literally avoid creating a new object.”
Why would anyone need a different approach? The devil lies in the details.
Ownership in managed vs. unmanaged languages
- Managed languages (Java, Python, …) hide ownership of variables. If ownership gets hard to track, who cares? The garbage collector will clean up the mess for us after all!
- Unmanaged languages (C++, Rust, …) delegate ownership management to the developer. Passing things by reference can become a nightmare. That’s why smart pointers and RAII were created: to ensure that whoever allocated memory will not forget to deallocate it, or worse, deallocate it twice.
This brief article discusses some rules of thumb to help you keep track of ownership when writing API signatures.
C++ Primer: rvalues vs. lvalues
| Category | Description | Example |
|---|---|---|
| rvalue | Temporary objects that do not have a persistent memory address (e.g., literals, temporary return values). | std::println("{}", 1); // prints an rvalue |
| lvalue | Objects that have an identifiable location in memory (an address) and a name (e.g., named variables). | int x = 2; std::println("{}", x); // prints an lvalue |
When writing a function you usually don’t know (or care) whether the argument will be an rvalue or an lvalue. The following rules of thumb help you decide which signature to use without over‑thinking and while avoiding ownership pitfalls.
API‑Signature Categories
The main keyword is OWNERSHIP. Methods can be divided into three categories:
- Owning methods – the function takes ownership of the argument.
- Non‑owning methods – the function merely observes the argument.
- Generic methods – the function works with both owning and non‑owning callers.
Below we break each category into sub‑types, but the guiding principle remains the same: who owns what.
1. Owning Methods (pass‑by‑value)
You receive the argument by value and own it (usually by moving it).
Person temp_person;
void set_name(std::string str) {
temp_person.name = std::move(str); // take ownership
}
Call sites
set_name(std::string("Jane Doe")); // 1 – rvalue
std::string name = "John Doe";
set_name(name); // 2 – lvalue (copy + move)
set_name(std::move(name)); // 3 – lvalue cast to rvalue (move only)
Explanation of the three cases
| Case | What happens | Operations |
|---|---|---|
| 1 – rvalue | str is move‑constructed from the temporary, then moved into temp_person.name. | 2 moves |
| 2 – lvalue | str is copy‑constructed from name, then moved. | 1 copy + 1 move |
| 3 – lvalue cast | str is move‑constructed (because std::move(name) yields an rvalue). | 1 move |
If this pattern sits on a hot path and you need to shave off the extra copy, overloads can be used:
// lvalue overload
void set_name(std::string& str) {
temp_person.name = std::move(str);
}
// rvalue overload
void set_name(std::string&& str) {
temp_person.name = std::move(str);
}
Now each call performs only one move, regardless of the argument’s value category.
Pitfall demonstration
Person temp_person;
void set_name(std::string& str) { temp_person.name = std::move(str); }
void set_name(std::string&& str){ temp_person.name = std::move(str); }
std::string my_precious_string = "password123";
set_name(my_precious_string); // ownership transferred!
std::println("My password: {}", my_precious_string); // undefined / empty
Takeaway: Passing by copy (value) is usually a safe design choice because ownership semantics are explicit.
2. Generic (pipe) Methods – non‑owning
A pipe is a non‑owner that has temporary access to an object. It receives a reference without ever taking ownership.
void add_exclamation(std::string& str) {
// DO NOT take ownership here – this is an antipattern.
str.push_back('!');
}
Because the function does not own the argument, any modification is visible to the caller.
3. Return‑by‑value (ownership transfer)
Passing by value, modifying, and then returning the object is a clean way to transfer ownership back to the caller.
std::string add_exclamation(std::string str) {
// Taking ownership here is fine.
str.push_back('!');
return str; // move‑return (NRVO or move)
}
std::string phrase = "Lorem Ipsum";
phrase = add_exclamation(phrase); // ownership stays with `phrase`
4. Move‑only Types
Pass‑by‑value works naturally with move‑only objects such as std::unique_ptr or std::thread.
std::unique_ptr foo(std::unique_ptr ptr) {
// `ptr` is owned inside the function.
// Modifications do NOT affect the caller.
return ptr; // move‑return
}
void bar(std::unique_ptr& ptr) {
// `ptr` is NOT owned here.
// Modifications affect the caller.
}
5. Read‑only Access (non‑owning, const reference)
When you only need to observe an object, take a const reference.
void print_name(const std::string& str) {
std::println("Name: {}", str);
}
print_name(std::string("Jane Doe")); // rvalue – binds to const ref
std::string name = "John Doe";
print_name(name); // lvalue – binds to const ref
Summary of Rules of Thumb
| Situation | Recommended signature | Reason |
|---|---|---|
| Take ownership (you will store or move from the argument) | void f(T obj) (pass‑by‑value) | Guarantees a local copy/move; caller knows ownership is transferred. |
| Only read / observe | void f(const T& obj) | No ownership change; works for both lvalues and rvalues. |
| Modify without taking ownership | void f(T& obj) | Caller retains ownership; modifications are visible. |
| Support both lvalues and rvalues efficiently | Provide overloads: void f(T&); void f(T&&); | Each overload can move from its parameter, avoiding extra copies. |
| Deal with move‑only types | Pass‑by‑value (or T&& overload) | Allows moving the resource into the function. |
By keeping ownership explicit in your API signatures, you avoid subtle bugs, make intent clear to users, and let the compiler help you enforce the correct semantics.
Understanding Parameter Passing and Forwarding References
print_name(std::string("Jane Doe")); // rvalue – binds to const ref
std::string name = "John Doe";
print_name(name); // lvalue – binds to const ref
The Cases
| # | Situation | What Happens |
|---|---|---|
| 1 | Passing an rvalue | Binds to a const std::string&. Because the reference is const, the language permits binding an rvalue. No copies or moves occur. |
| 2 | Passing an lvalue | Binds to a const std::string& as well. No copies or moves occur. |
Note: The only exception is when the type you pass is smaller than or equal to a reference (typically ≤ 8 bytes), e.g.
int,char,bool, and many others. In those cases passing by value may be preferable.
This topic is still debated; you might keep const T& for consistency unless the optimization is on a hot path.
When to Use Forwarding References
When you need a highly generic method, use:
- Forwarding references
autoreturn types- Perfect forwarding
Caution: Reserve forwarding references for generic wrappers and factory‑style utilities. Overusing them in regular APIs often leads to hard‑to‑debug overload‑resolution problems.
Syntax (templates only)
template <class T>
decltype(auto) omega_foo(T&& t) { // T&& is a forwarding reference
// Forward the argument preserving its value‑category
return omega_bar(std::forward<T>(t));
}
The pattern is used throughout the STL; the classic example is emplace_back.
std::vector<std::pair<int,int>> v;
v.emplace_back(1, 1); // perfect‑forwarded arguments
std::pair<int,int> x = {2, 2};
v.emplace_back(x); // copy
v.emplace_back(std::move(x)); // move
In each call the appropriate (usually optimized) constructor is selected.
Building Objects with Forwarding References
Simple wrapper
template <class T, class U>
auto constructor_wrapper(U&& u) {
// Build and return an object of type T initialized with `u`
return T(std::forward<U>(u));
}
Variadic version
template <class T, class... Args>
auto constructor_wrapper(Args&&... args) {
// Build and return an object of type T initialized with whatever was passed
return T(std::forward<Args>(args)...);
}
Restricting Accepted Arguments
Because the variadic version accepts everything, you can constrain it:
template <class T, class... Args>
concept ValidArg = !std::is_null_pointer_v<std::remove_cvref_t<Args>>;
template <class T, class... Args>
requires (ValidArg<Args> && ...)
auto constructor_wrapper(Args&&... args) {
return T(std::forward<Args>(args)...);
}
- This implementation rejects
std::nullptr_t. - Passing
NULLwill be forwarded as the integer0. - It does not work well with
std::initializer_listdeduction.
Another subtlety: the wrapper uses the parenthesis constructor T(...).
That can differ from the uniform‑initialization form T{...} (e.g., for std::vector).
Choosing the Right Parameter Signature
| Category | Recommended Signature | Why? |
|---|---|---|
| Owning Methods (Sink) | void f(T arg) + std::move inside | Efficiently takes ownership (move or copy). |
| Pipe Methods | T& arg or T f(T arg) | Modifies in‑place or via “copy‑modify‑return”, depending on ownership intent. |
| Non‑owning Methods (Read) | const T& arg | Observes the object without copying. |
| Non‑owning Methods (Small*) | T arg | Small types are faster to pass by value. |
| Generic Methods | template <class T> f(T&& arg) | Perfect forwarding in wrappers/factories. |
*Small types: int, double, bool, std::string_view, std::span, etc.
Final Thoughts
Things are not as easy as they seem in these examples. To fully grasp the concepts and corner cases, read Modern Effective C++.
Remember: “APIs live longer than optimizations.”
Use the appropriate signature for the right situation, and only optimise when it truly matters.