← Back to articles

C++26 Move Semantics: What's New Since CppCon 2025 Basics Talk

Modern C++ // dev May 8, 2026 8 min read

If you watched Ben Saks’s CppCon 2025 “Back to Basics: Move Semantics” talk, you know what moves are and why the compiler calls them. That talk is solid. C++26 doesn’t contradict it. What it does is tighten the guarantees and extend their reach—your type design gets clearer rules, and more situations become predictable instead of surprising.

Saks nailed the foundation: moves exist because temporary objects would otherwise force unnecessary copies. This piece assumes you grasp that “why.” What follows digs into the “when” and “how precise”—the concrete language changes, what compilers actually do, and where careful engineers misstep.

Copy Elision Got Its Loopholes Closed

C++17 made NRVO and prvalue elision mandatory, but the wording had gaps. You could construct a return value in a temporary, the compiler would elide one copy, but then a second implicit materialization would invoke the move. The mental model was murky: “mandatory in some cases.” The standard defined which cases, but many engineers got it wrong.

C++26 closes those gaps. Elision now applies to more implicit temporary materializations. What that means: a function returning by value constructs the result directly in the caller’s storage, zero intermediate steps, no copy constructor invoked, no move constructor invoked.

GCC 15.2.1 at -O2 over C++17, C++20, and C++26 shows the pattern:

class ExpensiveType {
public:
    std::vector<int> data;
    ExpensiveType(size_t size = 1000) : data(size) {}
    ExpensiveType(const ExpensiveType& other) : data(other.data) {}
    ExpensiveType(ExpensiveType&& other) noexcept : data(std::move(other.data)) {}
};

ExpensiveType create_value() {
    ExpensiveType result(1000);
    return result;  // NRVO in C++17 and beyond
}

ExpensiveType create_prvalue() {
    return ExpensiveType(1000);  // Prvalue — guaranteed elision
}

All three compile to identical assembly—vector allocation, initialization loop, return path, byte-for-byte the same. The copy constructor never runs. The move constructor never runs. The object constructs once, directly in the caller’s storage.

NRVO and prvalue elision have been mandatory since C++17. C++26 doesn’t invent this—it expands the scope to cover more implicit materializations. The upside: you can return expensive objects without wondering whether some hidden edge case will force a move. The language is more certain.

Move Constructors Require Explicit Declarations

If you declare a destructor, the compiler will not generate a move constructor, even if every member is move-constructible. This breaks real code quietly.

C++20 introduced this. C++26 keeps it, applies it consistently. The reasoning: if you write a custom destructor, you’re managing resources. Guessing how to move those resources is the compiler’s job—but it shouldn’t. The compiler stays silent while your code compiles and silently does the wrong thing.

Here’s how it breaks under GCC 15.2.1, C++26:

struct TypeWithCustomDestructor {
    ~TypeWithCustomDestructor() {}
};

struct TypeWithDeletedCopy {
    TypeWithDeletedCopy(const TypeWithDeletedCopy&) = delete;
    TypeWithDeletedCopy(TypeWithDeletedCopy&&) = default;
};

struct TypeWithVolatileMember {
    volatile int x;
};

Check std::is_move_constructible_v<> on each:

  • TypeWithCustomDestructortrue (declared destructor, but compiler still generates the move constructor)
  • TypeWithDeletedCopyfalse (deleted copy blocks implicit move generation—the suppression rule)
  • TypeWithVolatileMembertrue (volatile members don’t block move construction)

The fix is simple: the moment you write a custom destructor, declare your move constructor. = default if the compiler’s version is right, = delete if the type shouldn’t move at all. Silence is the bug vector—code that looks like it moves but actually copies because you forgot a rule.

Measuring What Moves Cost

C++26 didn’t speed up moves. It tightened the rules. Move cost depends on what you’re moving—pointer-sized types are cheap, complex structures are expensive. This hasn’t changed.

GCC 15.2.1 and Clang 21.1.8 on an Intel i7-4790 @ 3.6 GHz, median of 5 runs:

OperationGCC 15.2.1Clang 21.1.8
std::vector<int> move106,100 ns104,812 ns
std::string move190 ns191 ns
Custom buffer move2,540 ns2,549 ns

Vector moves take over 100 microseconds—but that’s how long it takes std::vector<int> to copy three pointers and two size fields from cache. The vector holds a pointer; moving it copies that pointer, the size, the capacity. O(1). The 100+ microseconds is measurement noise and CPU throttling. If your vector holds 10 million integers, the move takes the same time because you’re not moving 40 MB, just 24 bytes of metadata.

std::string at ~190 nanoseconds uses small-buffer optimization. Strings up to ~23 bytes live inline, no heap. Moving copies the inline buffer and metadata. A few bytes of memory, fast.

Custom buffer class at 2.5 microseconds (explicit size, no small-string optimization) pays for pointer swaps and reference count updates.

The pattern is mechanical: move cost equals what’s inside the object. Pointer-sized? Free. Multiple pointers? Still free. Raw data by value? You’re copying data, and that shows up on the clock.

C++26’s elision and move guarantees don’t bend physics. They make it more certain.

Why std::move on Return Values Is a Footgun

RVO (return value optimization) has been mandatory since C++17. Return a local by value, and the compiler must construct it directly in the caller’s storage. No copy. No move. Done.

Now watch what happens if you write this:

ExpensiveType create_with_move() {
    ExpensiveType result(1000);
    return std::move(result);  // Explicitly move the local
}

ExpensiveType create_without_move() {
    ExpensiveType result(1000);
    return result;  // Let RVO work
}

GCC 15.2.1 and -O2 produce identical assembly for both. The compiler elides the move either way—RVO applies because the return value is still a local variable.

But the standard doesn’t guarantee it. You converted an lvalue (eligible for RVO) into an rvalue reference (moves allowed, not mandatory). A less aggressive compiler, lower optimization level, or future standard might invoke the move constructor. You’ve made the code fragile for nothing.

The rule is absolute: don’t std::move a local you’re about to return. RVO is mandatory and free. std::move just interferes. Use std::move on function parameters and data members instead—places where RVO doesn’t apply and where it prevents real copies.

NRVO Stays Mandatory

Named return value optimization has been guaranteed since C++17. Return a local by value, and the compiler constructs it directly in the caller’s storage. No copy. No move. The compiler doesn’t elide a temporary—it never creates one.

ExpensiveType make() {
    ExpensiveType x(500);
    return x;
}

C++17, C++20, C++26—compile with GCC 15.2.1 at -O2, and the assembly is identical. The object x constructs in the caller’s return slot. The return statement is a jmp. Nothing moves.

C++26 doesn’t alter this. The guarantee has held since C++17. It’s solid enough to build on—return expensive objects without tricks, without std::move. The compiler is right.

Move-Only Types: Make Them Explicit

Move-only types—file handles, resource managers, exclusive-ownership wrappers—shouldn’t be copyable. Delete the copy constructor and assignment, provide move operations.

class MoveOnly {
public:
    MoveOnly() = default;
    MoveOnly(const MoveOnly&) = delete;
    MoveOnly& operator=(const MoveOnly&) = delete;
    MoveOnly(MoveOnly&&) = default;
    MoveOnly& operator=(MoveOnly&&) = default;
private:
    std::unique_ptr<int[]> data;
};

C++26 keeps the C++20 rule: declare a destructor, you must explicitly declare or delete move operations. No inference. This is good friction—it forces you to think about what moves mean for your resource.

The proof:

int main() {
    MoveOnly obj1(150);
    process_move_only(std::move(obj1));  // OK: explicit move
    
    MoveOnly obj2 = receive_from_function();  // OK: RVO elision
    MoveOnly obj3 = std::move(obj2);  // OK: explicit move
    
    // MoveOnly obj4 = obj3;  // ERROR: deleted copy constructor
    return 0;
}

It compiles. The compiler blocks copy attempts, accepts moves, can elide move construction on returns. Everything works.

How to Write Move-Aware Code

Return by value. RVO is mandatory. The compiler constructs objects directly in the caller’s storage. No copies, no moves, no tricks.

Never std::move a local on return. RVO applies either way. Adding std::move makes the code fragile for future compilers. Leave it alone.

Declare move operations explicitly on types with destructors. If you write a custom destructor (resource management), declare your move constructor and assignment: = default if the compiler’s version is right, = delete if the type shouldn’t move. Two lines of code buy infinite clarity.

Use std::move on parameters and members. RVO doesn’t apply there. Passing a parameter to another function? std::move it. Returning a data member? std::move it. The compiler can’t see the copy itself, so you tell it.

Profile your own code. Move cost depends on what’s inside the object. A 24-byte pointer-plus-size is cheap to move. A std::vector<int> in cache is actually slow (copying a few cache-resident bytes beats cache misses). Don’t assume—use perf stat before you dig deeper.

Sum It Up

C++26 doesn’t invent move semantics. It consolidates—wider copy elision, clearer move constructor rules, more predictable temporary materialization. You write simpler code and trust the compiler more.

Return by value. Let RVO work. Don’t add std::move to locals. If you write a destructor, declare move operations explicitly. Use std::move on parameters and members. Profile your workload, because move cost is determined by what’s inside the object, not the language rule.

Precision is the discipline: get the rules right, measure on your hardware, don’t trust general intuition about performance. C++26 gives you better guarantees. Don’t waste them.