C++ Signatures: It’s Not Just About Performance, It’s About Ownership

Published: (January 31, 2026 at 08:24 PM EST)
7 min read
Source: Dev.to

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

CategoryDescriptionExample
rvalueTemporary objects that do not have a persistent memory address (e.g., literals, temporary return values).std::println("{}", 1); // prints an rvalue
lvalueObjects 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:

  1. Owning methods – the function takes ownership of the argument.
  2. Non‑owning methods – the function merely observes the argument.
  3. 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

CaseWhat happensOperations
1 – rvaluestr is move‑constructed from the temporary, then moved into temp_person.name.2 moves
2 – lvaluestr is copy‑constructed from name, then moved.1 copy + 1 move
3 – lvalue caststr 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

SituationRecommended signatureReason
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 / observevoid f(const T& obj)No ownership change; works for both lvalues and rvalues.
Modify without taking ownershipvoid f(T& obj)Caller retains ownership; modifications are visible.
Support both lvalues and rvalues efficientlyProvide overloads: void f(T&); void f(T&&);Each overload can move from its parameter, avoiding extra copies.
Deal with move‑only typesPass‑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

#SituationWhat Happens
1Passing an rvalueBinds to a const std::string&. Because the reference is const, the language permits binding an rvalue. No copies or moves occur.
2Passing an lvalueBinds 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
  • auto return 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 NULL will be forwarded as the integer 0.
  • It does not work well with std::initializer_list deduction.

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

CategoryRecommended SignatureWhy?
Owning Methods (Sink)void f(T arg) + std::move insideEfficiently takes ownership (move or copy).
Pipe MethodsT& arg or T f(T arg)Modifies in‑place or via “copy‑modify‑return”, depending on ownership intent.
Non‑owning Methods (Read)const T& argObserves the object without copying.
Non‑owning Methods (Small*)T argSmall types are faster to pass by value.
Generic Methodstemplate <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.

Back to Blog

Related posts

Read more »

C# 14 extension blocks

Introduction Learn about C 14 extension blockshttps://learn.microsoft.com/en-us/dotnet/csharp/programming-guide/classes-and-structs/extension-methodsdeclare-ex...

This Year in LLVM (2025)

Article URL: https://www.npopov.com/2026/01/31/This-year-in-LLVM-2025.html Comments URL: https://news.ycombinator.com/item?id=46841187 Points: 17 Comments: 0...