Almost one year ago, clang-tidy threw an unexpected and unfamiliar message in my face: warning: constness of 'str' prevents automatic move [performance-no-automatic-move] (documented here). The incriminated code looked something like this:

auto build_path(const std::string& basedir, int index) {
	const auto str = fmt::format("{}/output/{}", basedir, index);
	
	fmt::print("{}\n", str);
	// ... do some work with 'str'...
	
	return str; // <-- the warning was here
}

I didn’t have time to investigate, so I wrote down a note, swore I would look into it later, and just forgot… Until today. I thought it would be simple. It was not. Because, as always, C++ is full of surprises.

Let me tell you the story of clang-tidy, const and (N)RVO.

Almost Always Const Auto (AACA)#

Almost Always Auto (or AAA) is a very common practice in modern C++. The acronym was coined by Herb Sutter in episode 94 of Guru Of The Week (GotW) series.

Another common practice is to use const (almost) by default. In Scott Meyers’s Effective C++, item 3 is “use const whenever possible” and another episode of GotW begins with “always use const as much as possible, but no more”.

In his book C++ Best Practices, Jason Turner includes 3 rules about auto and const(expr):

  • Rule 12: “const Everything That’s Not constexpr
  • Rule 13: “constexpr Everything Known at Compile Time”
  • Rule 14: “Prefer auto in Many Cases”

Combine these two principles, and we have coined another acronym: Almost Always Const Auto (AACA). I have been using this coding style for a few years now, especially after discovering Rust. In Rust, variables are constant by default, and we must use the mut modifier to make them mutable, which the opposite of C++ with const.

This is why in the code sample from the introduction, I declare str with const auto.

auto is not relevant when decoding clang-tidy’s warning message. const is. Apparently, I should not use const here, as it prevents the value from being moved when it’s returned from the function.

Have I been wrong to use const so much during all these years? I’m not the only developer following the advice of the C++ legends mentioned above. For instance, see here, here, here or there.

Complete Example#

Let’s write a complete example that we can run and analyze. I’ve chosen to avoid auto because I want to focus on const, and you don’t have the type overlays from my IDE showing the deduced types 😉 (of course, in real life, I would use auto).

main.cpp:

#include <fmt/std.h>

std::string build_path(const std::string& basedir, int index) {
	const std::string str = fmt::format("{}/output/{}", basedir, index);

	fmt::print("{}\n", str);
	// ... do some work with 'str'...

	return str;
}

int main() {
	const std::string dir = build_path("my/base/dir", 0);
}

This code compiles perfectly and prints my/base/dir/output/0.

We can run clang-tidy on this file:

$ clang-tidy -p cmake-build-debug --checks="clang-*,performance-*" main.cpp
106 warnings generated.
/home/pierre/cpp/main.cpp:9:9: warning: constness of 'str' prevents automatic move [performance-no-automatic-move]
        return str;
               ^
Suppressed 105 warnings (105 in non-user code).
Use -header-filter=.* to display errors from all non-system headers. Use -system-headers to display errors from system headers as well.

According to clang-tidy, the string cannot be moved. It means it is copied, which is slower.

Because we use std::string, it’s not easy to check what the compiler is actually doing. The simplest solution is to replace std::string with our own type so that we can print messages in its constructors.

With A Custom Class#

Let’s introduce a new class called Path.

#include <fmt/std.h>

class Path {
public:
	explicit Path(std::string path) :
		value_m{std::move(path)} {
		fmt::println("Constructor called");
	}

	Path(const Path& other) :
		value_m{other.value_m} {
		fmt::println("Copy constructor called");
	}

	Path(Path&& other) noexcept :

		value_m{std::move(other.value_m)} {
		fmt::println("Move constructor called");
	}

	[[nodiscard]] const std::string& value() const {
		return value_m;
	}

private:
	std::string value_m;
};

Path build_path(const std::string& basedir, int index) {
	const Path path = Path(fmt::format("{}/output/{}", basedir, index));

	fmt::print("{}\n", path.value());
	// ... do some work with 'path'...

	return path;
}

int main() {
	const Path dir = build_path("my/base/dir", 0);
}

This enhanced code compiles perfectly too and produces the following output:

Constructor called
my/base/dir/output/0

Great: path is not copied; it’s not even moved. Otherwise, we would see the text printed from their bodies.

And yet, clang-tidy still emits the same warning on return path;.

This feels a little awkward. On the one hand, our static analyzer warns that we might hit a performance penalty; on the other hand, our compiler generates optimal code. We can use Compiler Explorer to check whether this behavior is consistent across several compilers and versions:

clang gcc
20.1.0 ✅ 15.1 ✅
8.0.0 ✅ 8.1 ✅

As I write these lines, these are the latest versions and fairly old versions.

To understand what’s going on here, it’s time to introduce the third character of this story: (N)RVO.

(Named) Return Value Optimization#

RVO (Return Value Optimization) and NRVO (Named Return Value Optimization) are two common optimizations that the compiler may use when returning a local variable from a function. Since C++17, RVO is even mandatory. PVS-Studio has a great post explaining how these optimizations work and what the difference is between them. People tend to use both terms interchangeably, but in some cases (like our case today), the difference is relevant.

Our code is an exact match for NRVO: we have a named local variable whose type exactly matches the return type of the function, and we return this variable. The compiler optimizes the code by neither copying nor moving the variable path. Instead, it constructs the object directly in place, into dir in the main function. Hence the program output: we don’t see any messages from the copy or move constructors, because they are not called.

Note that we can use a slightly modified version of our code (removing the use of the fmt library) with cppinsights. It tells us that path is indeed an “NRVO variable”:

cppinsight tells that path is an NRVO variable

Can we say that clang-tidy’s warning is a false positive? In this specific case, we can probably assume so.

Can we blindly disable the warning by adding -performance-no-automatic-move to our command-line or .clang-tidy file? Probably not… But we need to look at more complex cases to understand why.

NRVO is optional#

This is an important point that I want to stress again: NRVO is optional. We have no guarantee that the future versions of clang or gcc will still optimize our code the same way. That’s why I used the word “assume” in the previous section.

How easy it to fall into situations where the optimization is no longer applied? Let’s experiment by modifying build_path() and observing how the program’s output changes. Each variation can then be tested using Compiler Explorer with the same compilers as before. For each variation, I indicate whether NRVO is applied or whether a constructor (copy or move) is called.

Variation 1#

We just add an early return (for which RVO will/must be applied). Try it here.

Path build_path(const std::string& basedir, int index) {
	if (index < 0) {
		return Path(basedir);
	}

	const Path path = Path(fmt::format("{}/output/{}", basedir, index));

	fmt::print("{}\n", path.value());
	// ... do some work with 'path'...

	return path;
}
clang gcc
20.1.0 => ✅ NRVO 15.1 => ❌ copy
8.0.0 => ❌ copy 8.1 => ❌ copy

Variation 2#

We don’t name the variable and return temporary in both branches. Try it here.

Path build_path(const std::string& basedir, int index) {
	if (index < 0) {
        return Path(basedir);
    } else {
        return Path(fmt::format("{}/output/{}", basedir, index));
    }
}
clang gcc
20.1.0 => ✅ NRVO 15.1 => ✅ NRVO
8.0.0 => ✅ NRVO 8.1 => ✅ NRVO

This is in fact RVO, not NRVO. This behavior is guaranteed since C++17. However, adding -std=c++17 doesn’t change the outputs in Compiler Explorer. It’s very likely that every decent compiler use RVO in this case, even before C++17.

Variation 3#

This is the same as variation 1, except that the variable is not used locally, it’s just returned. Try it here.

Path build_path(const std::string& basedir, int index) {
	if (index < 0) {
		return Path(basedir);
	} else {
		const auto path = Path(fmt::format("{}/output/{}", basedir, index));
		return path;
	}
}
clang gcc
20.1.0 => ✅ NRVO 15.1 => ❌ copy
8.0.0 => ✅ NRVO 8.1 => ❌ copy

Variation 4#

This is the same as variation 3, but path is not const . Try it here.

Path build_path(const std::string& basedir, int index) {
	if (index < 0) {
		return Path(basedir);
	} else {
		auto path = Path(fmt::format("{}/output/{}", basedir, index));
		return path;
	}
}
clang gcc
20.1.0 => ✅ NRVO 15.1 => ⚠️ move
8.0.0 => ✅ NRVO 8.1 => ⚠️ move

Summary#

clang applies NRVO more aggressively and consistently than gcc. Minor alterations of the code may prevent from applying NRVO, and we switch for an optimal situation to either a probably cheap move operation or a maybe-no-so-cheap copy operation.

The Warning Is Not About NRVO#

Does clang-tidy emit a warning for all variations? No:

Variation Warning
1 Yes ⚠️
2 No ✅
3 Yes ⚠️
4 No ✅

Maybe I blurred the lines earlier, so let me clarify: the warning is not about NRVO failing to apply. It’s about the fact that a move operation is inhibited, and that a copy will be made instead. Hence its name: performance-no-automatic-move 😉

This is exactly what the previous table illustrates:

  • When clang-tidy emits a warning, gcc performs a copy.
  • When clang-tidy is silent, gcc performs a move.

This behavior is consistent with what PVS-Studio explains:

The C++11 standard says that if a compiler cannot apply an optional optimization, it must do the following. First, it must apply the move constructor. Then apply the copy constructor for local variables or formal function parameters.

const objects cannot be moved. When a variable is declared const and NRVO is not applied, moving it is impossible, so the compiler has no choice but to copy. That’s exactly what we observe in variation 3: gcc uses the copy constructor because the object is const, while it performs a move in variation 4.

(You might think that I am shaming gcc here, but clang was also doing a copy for variation 1 before version 15.0…)

How To Handle The Warning#

clang-tidy cannot possibly know whether NRVO will be applied or not, so it must assume it won’t. If a move operation is impossible because of const (as in variation 3), it emits a warning; otherwise, it remains silent (as in variation 4).

Let’s get back to our question:

Can we blindly disable the warning by adding -performance-no-automatic-move to our command-line or .clang-tidy file?

The answer is “no, we can’t”. If NRVO is actually performed, it’s a false positive. Otherwise, it’s not, and we may want to change our code to avoid a copy in favor of a move.

In my opinion, there is no universal solution. It depends entirely on the context. Here are a few points to consider:

  • If the entire project does not have strong performance requirements, and we receive many warnings, we might disable it entirely.
  • If the function is not performance-critical, we can just suppress the warning locally by adding // NOLINT(performance-no-automatic-move) on the same line as the return. For example, I originally discovered this warning on a function that builds the output path of the application. This function is called once at startup, and the process then runs several minutes or hours. Copying a string was acceptable (NRVO was applied anyway).
  • If we target specific compilers, we can manually analyze the code and/or experiment with Compiler Explorer to determine if NRVO is likely to be applied (as we did earlier in this post). If the answer is “yes”, we can add // NOLINT(performance-no-automatic-move) too.
  • If this specific compiler is gcc 14 or newer, we can enable the -Wnrvo flag to help decide (see next section).
  • If performance is important, and we target any compiler, we might prefer to remove const. It’s better to add a comment explaining why, otherwise someone will probably reintroduce const afterwards. This is especially true for libraries, since we have no idea how people will use them.

The great Bartłomiej Filipek from C++ Stories wrote a post “Const, Move and RVO”. I could not agree more with his last piece of advice:

Still, if you’re unsure and you’re working with some larger objects (that have move enabled), it’s best to measure measure measure.

Be Warned When NRVO Is Not Possible#

Released in May 2024, gcc 14 introduced a new compiler flag: -Wnrvo. From the documentation:

Warn if the compiler does not elide the copy from a local variable to the return value of a function in a context where it is allowed by [class.copy.elision]. This elision is commonly known as the Named Return Value Optimization. For instance, in the example below the compiler cannot elide copies from both v1 and v2, so it elides neither.

Jason Turner has dedicated episode 434 of his C++Weekly series to this feature.

Let’s try recompiling every version of our code with this flag:

Version Warning
Original No ✅
Variation1 Yes ⚠️
Variation2 No ✅ => this is not NRVO anyway, it is RVO
Variation3 Yes ⚠️
Variation4 Yes ⚠️

The compiler’s output looks like this:

<source>: In function 'Path build_path(const std::string&, int)':
<source>:33:24: warning: not eliding copy on return in 'Path build_path(const std::string&, int)' [-Wnrvo]
   33 |                 return path;
      |   

This is great to handle clang-tidy’s warning:

  • If both gcc and clang-tidy complains, we know we are paying for a copy instead of a move.
  • If gcc is silent but clang-tidy complains, adding // NOLINT(performance-no-automatic-move) is perfectly fine.
  • If gcc complains but clang-tidy is silent, it probably means the type is not moveable. Since performance-no-automatic-move is not applicable, clang-tidy has nothing to report. But because NRVO is not applied, a copy will be made.

Unfortunately, as of June 2025, clang does not offer an equivalent flag in any released version. However, the in-progress changelog for upcoming version (21.0.0) says:

New option -Wnrvo added and disabled by default to warn about missed NRVO opportunities.

Stay tuned.

Disabling (N)RVO with -fno-elide-constructors#

If you are curious, you can disable (N)RVO entirely.

gcc and clang provide a flag to prevent constructor elision when it is allowed (but not required) by the standard: -fno-elide-constructors. According to gcc’s documentation:

The C++ standard allows an implementation to omit creating a temporary that is only used to initialize another object of the same type. Specifying this option disables that optimization, and forces G++ to call the copy constructor in all cases. This option also causes G++ to call trivial member functions which otherwise would be expanded inline.

In C++17, the compiler is required to omit these temporaries, but this option still affects trivial member functions.

Let’s examine its effect on our original code, and a slightly modified version where path is not const, for both C++14 and C++17.

C++14#

Original code:#

Constructor called
Move constructor called
my/base/dir/output/0
Copy constructor called <-- There is a copy (just like `clang-tidy` warns)
Move constructor called

Original code without the const:#

Constructor called
Move constructor called
my/base/dir/output/0
Move constructor called  <-- The copy is now a move
Move constructor called

C++17#

Original code:#

Constructor called
my/base/dir/output/0
Copy constructor called <-- Same copy as before

Original code without the const:#

Constructor called
my/base/dir/output/0
Move constructor called <-- Same move as before

Apart from educational purpose or curiosity, this flag can be useful is to experiment with “the worst case scenario” of a compiler that (almost) never performs constructor elision. See this discussion on stackoverflow.

Conclusion#

Whew! You’re still here! 😅 This article was a real challenge to write, and probably not the easiest to read either. Thanks & kudos for making it through.

We’ve explored how NRVO behaves, how it’s affected by const, and what clang-tidy’s performance-no-automatic-move] warning really means. Because NRVO (and RVO before C++17) is optional, small changes to a function can unexpectedly inhibit optimizations and introduce costly copies. This is also why having automated benchmarks in your CI is a good practice.

The warning by clang-tidy might be a false positive. Well, it’s not really a false positive: actually, the code does inhibit a move; it’s just that the compiler might just invalidate the warning by applying NRVO. Depending on the situation:

  • The warning may be irrelevant in practice (because we can ensure that NRVO is reliably applied), and we can safely suppress it locally.
  • We can decide that we don’t want to change the code, that the performance penalty is acceptable, and we suppress it locally as well.
  • We change the code to guarantee optimal performance.

With this knowledge, you’re now better equipped to ensure your code behaves as expected and performs at its best. Happy optimized coding! 👋