Question

Pass by Value vs const std::string& in Modern C++

cppc++11

Question

I heard a recent talk by Herb Sutter suggesting that the usual reasons for passing std::vector and std::string as const& are mostly gone. He implied that a function like this is often preferable today:

std::string do_something(std::string inval)
{
    std::string return_val;
    // ... do stuff ...
    return return_val;
}

I understand that return_val is returned as an rvalue, so move semantics can make the return inexpensive. However, inval is still larger than a reference, which is typically implemented as a pointer. A std::string usually contains multiple members, such as a heap pointer and storage for short string optimization. Because of that, it seems like passing by const std::string& should still be a good idea.

Why might passing std::string by value be preferable in modern C++? In what situations is pass-by-value better than const std::string&, and what role do move semantics and copy elision play here?

Short Answer

By the end of this page, you will understand why modern C++ sometimes prefers passing std::string or std::vector by value instead of const&. You will learn the trade-offs, how move semantics help, when pass-by-value is efficient, and when const& is still the better choice.

Concept

In older C++, passing large objects like std::string by const& was the default advice because copying them could be expensive. A reference is small, avoids copying, and lets the function read the original object safely.

Modern C++ changes the picture because of move semantics.

When you pass an object by value, the function receives its own local copy:

void f(std::string s);

That sounds expensive at first, but the real cost depends on what the caller passes:

  • If the caller passes an lvalue (a named variable), s is copied.
  • If the caller passes an rvalue (a temporary or moved object), s can be moved into the function.

Moving a std::string is usually much cheaper than copying it, because the internal buffer can often be transferred rather than duplicated.

This leads to an important design idea:

  • If the function only reads the argument, const std::string& is often still best.
  • If the function needs to make its own copy anyway, then taking by value can be better.

Why? Because with pass-by-value, you pay:

  • one copy for lvalues, or
  • one move for rvalues.

But with const&, if the function later needs its own copy, then it must copy from the reference inside the function anyway. That means:

  • lvalue call: still one copy,
  • rvalue call: you lose the chance to move directly into the parameter unless you add another overload.

For example:

class Widget {
    std::string name;
public:
    void set_name(std::string s) {
        name = std::move(s);
    }
};

This single function handles both cases well:

  • w.set_name(existing_string); → copy into s, then move into name
  • w.set_name("temporary"); → construct/move efficiently, then move into name

Without pass-by-value, you might need multiple overloads:

void set_name(const std::string& s) { name = s; }
void set_name(std::string&& s) { name = std::move(s); }

So the modern advice is not "always pass by value." It is:

  • Pass by value when you want to store, own, or modify a copy of the argument.
  • Pass by const& when you only need to observe it.

Copy elision also helps in some cases, especially when returning values, but it is more relevant to return values than to ordinary function parameters.

Mental Model

Think of a function parameter as receiving a package.

  • Passing by const& is like giving the function a view of your package through a window. The function can inspect it, but it does not own it.
  • Passing by value is like handing over a package of its own. The function now owns that package and can keep it, change it, or move parts of it elsewhere.

Now imagine the function needs its own package anyway.

If you pass by const&, the function looks through the window first and then says, "Actually, I need my own package," so it makes a copy.

If you pass by value, the function already has its own package. And if the caller gave it a temporary package, ownership can be transferred cheaply instead of copied.

So pass-by-value is often best when the function needs ownership. const& is best when the function only needs to look.

Take Quiz

Syntax and Examples

The two main forms are:

void read_only(const std::string& s);
void takes_ownership(std::string s);

Example 1: Read-only parameter

#include <iostream>
#include <string>

void print_name(const std::string& name) {
    std::cout << name << '\n';
}

Use const std::string& when:

  • the function only reads the string
  • the function does not need its own copy
  • you want to avoid unnecessary copying

Example 2: Function stores the argument

#include <string>
#include <utility>

class User {
    std::string name;
public:
    void set_name(std::string new_name) {
        name = std::move(new_name);
    }
};

Why this works well:

  • If called with an lvalue, the argument is copied into new_name, then moved into name.
  • If called with an rvalue, the argument is moved into new_name, then moved into name.

Example 3: Alternative with overloads

class User {
    std::string name;
public:
    void set_name(const std::string& new_name) {
        name = new_name;
    }

    void set_name(std::string&& new_name) {
        name = std::move(new_name);
    }
};

This can be efficient too, but it is more verbose. Pass-by-value often simplifies the interface.

Example 4: Returning by value

std::string make_message() {
    std::string msg = "hello";
    return msg;
}

Modern C++ can often optimize this return:

  • by move semantics
  • and often by copy elision / RVO

Returning by value is now normal and efficient in most cases.

Step by Step Execution

Consider this code:

#include <string>
#include <utility>

class User {
    std::string name;
public:
    void set_name(std::string new_name) {
        name = std::move(new_name);
    }
};

Now trace two calls.

Case 1: Passing an lvalue

std::string s = "Alice";
User u;
u.set_name(s);

Step by step:

  1. s is a named variable, so it is an lvalue.
  2. The parameter new_name is constructed by copying s.
  3. Inside the function, name = std::move(new_name); moves the contents from new_name into name.
  4. new_name is left in a valid but unspecified moved-from state.
  5. The function ends, and new_name is destroyed.

Cost summary:

  • one copy
  • one move

Case 2: Passing an rvalue

User u;
u.set_name("Alice");

Step by step:

  1. "Alice" is used to create a temporary std::string.
  2. That temporary is used to initialize new_name.
  3. This initialization can be optimized, and moving is cheap compared with copying a full string buffer.
  4. Inside the function, name = std::move(new_name); moves into the member.
  5. new_name is destroyed.

Cost summary:

  • no extra deep copy of an existing string object
  • move-friendly path

Compare with const&

void set_name(const std::string& new_name) {
    name = new_name;
}

For an lvalue:

  • no copy at parameter passing time
  • one copy when assigning into name

For an rvalue:

  • still one copy into name, because new_name is const and cannot be moved from

That is why by-value can be better when the function needs to keep its own copy.

Real World Use Cases

Setter functions

A very common example is assigning into a class member:

void set_title(std::string title) {
    this->title = std::move(title);
}

This is popular in application code because it is simple and handles both lvalues and rvalues well.

Constructors that store strings or vectors

class File {
    std::string path;
public:
    File(std::string p) : path(std::move(p)) {}
};

If callers already have a temporary or moved string, this can be efficient.

Data transfer objects

When building request or response objects, fields are often accepted by value and then moved into members.

Parsing and transformation pipelines

If a function needs to take input text, modify it, and return a new owned value, pass-by-value may fit naturally:

std::string normalize(std::string text) {
    // modify text in place
    return text;
}

The caller can pass a temporary cheaply, and the function owns a modifiable string.

APIs that consume ownership

If a function conceptually consumes the input, pass-by-value communicates that clearly. The function receives its own object and may move from it internally.

Take Quiz

Real Codebase Usage

In real C++ codebases, developers usually choose parameter style based on intent.

Common pattern: read-only input

bool is_valid_email(const std::string& email);

This is best when the function only inspects the string.

Common pattern: sink parameter

A sink parameter is one the function is expected to take ownership of.

void save_message(std::string message) {
    stored_message = std::move(message);
}

This is a common modern pattern.

Common pattern: constructor/member initialization

class Config {
    std::string filename;
public:
    Config(std::string f) : filename(std::move(f)) {}
};

This reduces overload clutter.

Guard clauses and validation

Functions may take by value, validate, then store:

void set_username(std::string username) {
    if (username.empty()) {
        throw std::invalid_argument("username cannot be empty");
    }
    this->username = std::move(username);
}

The function owns username, so it can sanitize or transform it before moving it into the object.

Normalization before storing

void set_tag(std::string tag) {
    for (char& c : tag) {
        c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
    }
    this->tag = std::move(tag);
}

Taking by value is useful because the function is allowed to modify its local copy.

Avoiding overload explosions

Without pass-by-value, APIs may need multiple overloads for const& and &&. By-value often gives one clean function instead.

Common Mistakes

Mistake 1: Assuming pass-by-value is always faster

It is not always faster.

If a function only reads a string, this is usually unnecessary work:

void print_text(std::string text) {
    std::cout << text;
}

Better:

void print_text(const std::string& text) {
    std::cout << text;
}

Mistake 2: Forgetting to move from the by-value parameter when storing it

void set_name(std::string name) {
    this->name = name; // copies again
}

Better:

void set_name(std::string name) {
    this->name = std::move(name);
}

Mistake 3: Using std::move on a const&

void set_name(const std::string& name) {
    this->name = std::move(name); // does not do what you want
}

Because name is const, moving from it will not give a normal movable source. This usually falls back to copy behavior.

Mistake 4: Confusing return optimization with parameter passing

Return value optimization and copy elision help a lot with return values, but they do not mean every by-value parameter is free.

Mistake 5: Over-optimizing without considering usage

If most calls pass lvalues and the function only reads them, const& remains appropriate.

Mistake 6: Ignoring small string optimization details

Some small strings are stored inside the object itself. That means moving them may still involve copying a few characters internally. Move is not magic; it is just often cheaper or more flexible than deep copy.

Comparisons

Situationconst std::string&std::string by value
Function only reads argumentUsually bestUsually unnecessary copy/move
Function stores its own copyMay require copy inside functionOften a good choice
Caller passes lvalueNo parameter copyCopies into parameter
Caller passes rvalueCannot directly consume it for moving if only const&Can move into parameter
Need one simple API instead of overloadsMay need const& + && overloadsOne function often handles both
Function modifies local copyMust create copy manuallyAlready has owned local copy

By-value vs overloads

ApproachProsCons
const& onlySimple, efficient for read-only usePoor when you need ownership from rvalues
const& + && overloadsVery efficient and explicitMore code, more maintenance
By-valueSimple and move-friendlyMay add a copy for lvalue callers

Important rule of thumb

  • Use const& for input-only, no ownership.
  • Use by-value for input you intend to keep, modify, or move from.
Take Quiz

Cheat Sheet

// Read only
void f(const std::string& s);

// Takes ownership / stores / modifies local copy
void f(std::string s);

// Typical sink pattern
void set_name(std::string s) {
    name = std::move(s);
}

Quick rules

  • Use const T& when:
    • the function only reads the argument
    • you do not need a copy
  • Use T by value when:
    • the function needs its own copy anyway
    • the function will store the value
    • the function will modify the local copy
    • you want one function that works well for both lvalues and rvalues

Key ideas

  • lvalue passed to by-value parameter → copy
  • rvalue passed to by-value parameter → move
  • const& avoids parameter copy
  • const& cannot usually be moved from meaningfully
  • returning by value is usually efficient in modern C++

Watch out

  • By-value is not automatically faster
  • Remember std::move(param) when transferring from a by-value parameter
  • Copy elision mainly helps returns and object construction, not every parameter case

FAQ

Is passing std::string by value always better in modern C++?

No. It is usually better only when the function needs to own, store, or modify its own copy.

When should I still use const std::string&?

Use it when the function only reads the string and does not need to keep a copy.

Why does pass-by-value help with rvalues?

Because temporaries and moved objects can initialize the parameter using move semantics, which is often cheaper than copying.

Why not always write both const& and && overloads?

You can, and sometimes performance-critical code does. But pass-by-value often gives similar benefits with simpler code.

Does returning a std::string by value cause a copy?

Usually not in the expensive sense. Modern C++ uses copy elision and move semantics to make returning by value efficient.

Is moving a std::string always cheap?

Usually cheap, but not always free. Implementation details like small string optimization can affect the exact cost.

What did Herb Sutter likely mean by saying the old reasons are largely gone?

He likely meant that modern C++ lets you design simpler APIs using value parameters when ownership is needed, because moves make that pattern efficient in many common cases.

Take Quiz

Mini Project

Description

Build a small Profile class that stores a username and a bio. The project demonstrates when passing std::string by value is useful: the class will accept incoming strings, validate or normalize them, and then move them into member variables.

Goal

Create a class that accepts text input efficiently and stores owned copies using the pass-by-value plus std::move pattern.

Requirements

  • Create a Profile class with username and bio string members.
  • Add setter functions that take std::string by value.
  • Validate that username is not empty.
  • Normalize username to lowercase before storing it.
  • Print the final profile data in main().
Take Quiz

Keep learning

Related questions

View more