Value Semantics
Objects with value semantics behave like built-in types—each copy is independent, mutations don't propagate across instances, and ownership is unambiguous.
Value Semanticssince C++98A type has value semantics when copying it produces a fully independent object—subsequent mutations to either instance have no effect on the other, exactly as with int or double.
Overview
Value semantics is C++'s default object model, and the entire Standard Library is built on it. A type with value semantics behaves like a built-in arithmetic type: copying produces a distinct, self-contained object; assigning replaces the target's complete state; destroying it affects nothing else. The alternative—reference semantics—is where objects share identity through pointers or handles, so a mutation through one alias is visible through every other.
Reference semantics makes local reasoning impossible. To know what an object contains, you must track every alias in the program. const becomes a shallow guarantee (this handle won't mutate, but others might). Invalidation silently breaks distant code. Value semantics inverts these properties: a const value is completely and transitively immutable, aliasing is structurally impossible, and every function can be reasoned about in isolation.
C++11 introduced move semantics, which eliminated the main performance argument against value types. A std::vector<std::string> returned from a function no longer copies its elements—it moves the internal buffer in O(1). Move semantics let value types be passed and returned cheaply without compromising the invariants that make them tractable. A type that is both cheap to move and independently copyable gives you the best of both worlds.
The Regular Type
A regular type is the formal articulation of a fully-featured value type. Since C++20, the standard captures this as std::regular in <concepts>, but the underlying requirements have been stable since C++98:
- Default constructible —
T t;produces a valid object - Copy constructible and copy assignable —
T b = a;andb = a;yield independent objects - Move constructible and move assignable (C++11) — resources transfer rather than duplicate
- Destructible — cleanup is deterministic and non-throwing where feasible
- Equality comparable —
a == btests value equivalence, not identity
Types satisfying all of these work as STL container elements, algorithm operands, and alternatives inside std::optional (C++17) and std::variant (C++17) without special-casing.
Syntax
When a type manages a raw resource, apply the Rule of Five (C++11). If you define any of the five special members, define or explicitly default/delete all five:
class Buffer {
public:
explicit Buffer(std::size_t n)
: data_(new int[n]), size_(n) {}
~Buffer() { delete[] data_; }
// copy — deep copy of the owned array
Buffer(const Buffer& other)
: data_(new int[other.size_]), size_(other.size_) {
std::copy(other.data_, other.data_ + size_, data_);
}
Buffer& operator=(const Buffer& other) {
Buffer tmp(other); // copy-and-swap: strong exception guarantee
swap(tmp);
return *this;
}
// move — C++11: transfer ownership, O(1), cannot fail
Buffer(Buffer&& other) noexcept
: data_(std::exchange(other.data_, nullptr)), // C++14
size_(std::exchange(other.size_, 0)) {}
Buffer& operator=(Buffer&& other) noexcept {
Buffer tmp(std::move(other));
swap(tmp);
return *this;
}
void swap(Buffer& other) noexcept {
using std::swap;
swap(data_, other.data_);
swap(size_, other.size_);
}
friend void swap(Buffer& a, Buffer& b) noexcept { a.swap(b); } // ADL
bool operator==(const Buffer& other) const {
return size_ == other.size_ &&
std::equal(data_, data_ + size_, other.data_);
}
private:
int* data_;
std::size_t size_;
};When the type holds no raw resources—its members already carry correct value semantics—apply the Rule of Zero: declare none of the five and let the compiler generate all of them correctly by induction over the members:
struct Point {
double x, y;
auto operator<=>(const Point&) const = default; // C++20: all six comparisons
bool operator==(const Point&) const = default; // C++20
};Examples
Value vs Reference Semantics
#include <string>
#include <memory>
// Value semantics: copies are independent
std::string a = "hello";
std::string b = a;
b[0] = 'H';
// a == "hello", b == "Hello" — no aliasing
// Reference semantics: shared identity
auto p = std::make_shared<std::string>("hello"); // C++11
auto q = p; // p and q name the same object
(*q)[0] = 'H';
// *p == "Hello" — mutation through q is visible via pshared_ptr reference semantics is occasionally necessary—genuinely shared mutable state, intrusive polymorphic hierarchies—but it is not the default.
STL Containers Require Value Semantics
#include <vector>
#include <algorithm>
struct Measurement {
double value;
int sensor_id;
auto operator<=>(const Measurement&) const = default; // C++20
bool operator==(const Measurement&) const = default; // C++20
};
std::vector<Measurement> readings;
readings.push_back({3.14, 42}); // copy into container
readings.emplace_back(2.71, 7); // construct in-place — C++11
Measurement m{1.41, 99};
readings.push_back(std::move(m)); // move — C++11; m valid but unspecified
std::sort(readings.begin(), readings.end()); // requires operator<=> or operator<
auto it = std::find(readings.begin(), readings.end(), Measurement{3.14, 42});std::vector relocates elements on reallocation. If your move constructor is not noexcept, the vector falls back to copying every element to preserve the strong exception guarantee. Mark move operations noexcept whenever they cannot fail—which for resource-owning types is almost always.
Return Values and Elision
#include <vector>
#include <string>
// C++17: guaranteed copy elision (prvalue materialisation) makes this free
// C++11/14: NRVO typically elides; move is the guaranteed fallback
std::vector<std::string> load_lines(const char* path) {
std::vector<std::string> lines;
// ... fill lines ...
return lines; // NRVO candidate; never write std::move() here
}
auto cfg = load_lines("app.conf"); // zero superfluous copiesExplicitly moving a named return value (return std::move(lines)) suppresses NRVO and can be measurably slower. Let the compiler optimise.
Value Semantics Wrapping a Polymorphic Implementation
Inheritance and value semantics are separable. Type erasure puts a value-semantic shell around a polymorphic core:
#include <memory>
#include <utility>
class Shape {
struct Concept {
virtual ~Concept() = default;
virtual double area() const = 0;
virtual std::unique_ptr<Concept> clone() const = 0; // C++11
};
template<typename T>
struct Model final : Concept {
T obj;
explicit Model(T t) : obj(std::move(t)) {}
double area() const override { return obj.area(); }
std::unique_ptr<Concept> clone() const override {
return std::make_unique<Model<T>>(obj); // C++14
}
};
std::unique_ptr<Concept> impl_;
public:
template<typename T>
explicit Shape(T t)
: impl_(std::make_unique<Model<T>>(std::move(t))) {} // C++14
Shape(const Shape& o) : impl_(o.impl_->clone()) {}
Shape& operator=(const Shape& o) { Shape tmp(o); swap(tmp); return *this; }
Shape(Shape&&) noexcept = default; // C++11
Shape& operator=(Shape&&) noexcept = default; // C++11
void swap(Shape& o) noexcept { impl_.swap(o.impl_); }
double area() const { return impl_->area(); }
};
// Shape now stores in std::vector, copies correctly, needs no pointer-to-base
std::vector<Shape> scene;
scene.push_back(Shape(Circle{5.0}));
scene.push_back(Shape(Rectangle{3.0, 4.0}));Best Practices
Default to value semantics; reach for pointers only when aliasing is genuinely required. shared_ptr solves real problems, but shared mutable state is a liability. If ownership is unique, unique_ptr is correct; if the object can be copied, copy it.
Mark move operations noexcept. std::vector, std::deque, and std::optional use std::move_if_noexcept during reallocation. A missing noexcept silently degrades O(1) relocation to O(n) copying.
Prefer the Rule of Zero. Compose value types from members with correct value semantics (std::string, std::vector, std::unique_ptr). Compiler-generated special members are correct by structural induction and never get out of sync with the member list.
Provide noexcept swap in the same namespace. Swap is the primitive underlying copy-and-swap assignment, std::sort rotations, and std::optional assignment. Make it findable via ADL.
Equality must be an equivalence relation. operator== must be reflexive (a == a), symmetric (a == b ↔ b == a), and transitive (a == b && b == c → a == c). C++20 defaulted == satisfies this automatically for types with comparable members.
Common Pitfalls
Shallow copy of raw pointer members. The compiler-generated copy constructor does memberwise copy of pointer values, not the pointees. Two copies then own the same allocation; the second destructor double-frees. Use std::unique_ptr or std::vector to make the Rule of Zero applicable.
Object slicing. Storing a derived object by value in a base-class variable silently discards derived members and loses virtual dispatch:
struct Animal { std::string name; virtual void speak() const {} };
struct Dog : Animal { int trick_count{}; };
Animal a = Dog{"Rex", 3}; // Dog::trick_count silently discarded
a.speak(); // calls Animal::speak, not Dog::speakValue semantics and open inheritance hierarchies are in tension. Store through unique_ptr<Base> or use type erasure when you need both polymorphism and value-type ergonomics.
Explicit std::move on return statements. return std::move(local) prevents Named Return Value Optimisation (NRVO). Since C++11 the compiler already moves an eligible local; since C++17 it may elide the move entirely. The explicit std::move is always wrong on a direct return of a local variable of the function's own return type.
Forgetting noexcept destroys container performance. A move constructor that might throw forces std::vector::push_back to copy during reallocation. For types that own heap memory, move never touches the allocator and cannot throw—declare it noexcept and make that fact verifiable at compile time with static_assert(std::is_nothrow_move_constructible_v<T>) (C++17).
Regular Types and the Concepts Hierarchy (C++20)
std::regular<T> is the machine-checkable contract for a fully-featured value type. It composes three sub-concepts: std::default_initializable<T>, std::copyable<T>, and std::equality_comparable<T>. std::semiregular<T> is the weaker variant that drops std::equality_comparable, useful for output-only or write-once types that cannot meaningfully compare values.
// C++20
#include <concepts>
struct Money {
long cents;
std::string currency;
bool operator==(const Money&) const = default; // C++20: member-wise equality
};
static_assert(std::regular<Money>); // default-constructible, copyable, equality-comparable
static_assert(std::copyable<Money>); // subset: copy + move + destructible
static_assert(std::equality_comparable<Money>);
// Types satisfying std::regular work transparently with STL algorithms
#include <vector>
#include <algorithm>
std::vector<Money> ledger = {{500, "USD"}, {300, "EUR"}, {500, "USD"}};
std::sort(ledger.begin(), ledger.end(),
[](const Money& a, const Money& b){ return a.cents < b.cents; });
auto it = std::find(ledger.begin(), ledger.end(), Money{300, "EUR"});A type that does not satisfy std::regular cannot be used as a std::ranges algorithm operand and will produce a concept-violation error rather than a silent wrong result. Static assertions at the class definition site catch regressions early.
// C++20
struct SemiregularSink {
int fd;
// No operator== — write-only resource handle
SemiregularSink() : fd(-1) {}
};
static_assert(std::semiregular<SemiregularSink>); // ok
static_assert(!std::regular<SemiregularSink>); // equality not providedValue Semantics vs Polymorphism: std::variant as Closed-Set Alternative
When the set of types is fixed at compile time, std::variant delivers value semantics without pointer indirection or heap allocation. Open-set polymorphism (virtual dispatch) requires a pointer or reference to a base—reference semantics—because the concrete type is unknown at the call site. A closed-set variant stores any of its alternatives inline, copies and moves correctly, and needs no virtual at all.
// C++17
#include <variant>
#include <numbers>
struct Circle { double radius; };
struct Square { double side; };
struct Triangle { double base, height; };
using Shape = std::variant<Circle, Square, Triangle>;
// Overloaded lambda visitor — the "overloaded" helper from cppreference
template<typename... Ts> struct overloaded : Ts... { using Ts::operator()...; };
template<typename... Ts> overloaded(Ts...) -> overloaded<Ts...>; // C++17 CTAD
double area(const Shape& s) {
return std::visit(overloaded{
[](const Circle& c) { return std::numbers::pi * c.radius * c.radius; },
[](const Square& q) { return q.side * q.side; },
[](const Triangle& t) { return 0.5 * t.base * t.height; },
}, s);
}Because Shape is a regular value type, it can be stored in std::vector<Shape>, returned from functions, and copied without manual lifetime management. Adding a new variant member is a compile error at every std::visit call site that does not handle it—exhaustiveness is enforced by the type system.
// C++17
#include <vector>
std::vector<Shape> scene = {Circle{3.0}, Square{2.0}, Triangle{4.0, 1.5}};
double total = 0.0;
for (const auto& s : scene) total += area(s);
// AST node example: every node kind is a distinct type, closed at compile time
using Expr = std::variant<struct Literal, struct BinaryOp, struct IfExpr>;Copy-on-Write (CoW) for Expensive Value Types
Copy-on-Write gives value-type semantics with deferred copying: shared copies pay no allocation cost until one of them is mutated. The idiom stores the payload behind a std::shared_ptr<const T>; copy constructors share the pointer cheaply; any write accessor checks the reference count and breaks sharing before modifying.
// C++11
#include <memory>
#include <string>
#include <vector>
class CowBuffer {
public:
using Data = std::vector<int>;
CowBuffer() : data_(std::make_shared<Data>()) {}
// Copy: share the pointer — O(1), no heap allocation
CowBuffer(const CowBuffer&) = default;
CowBuffer& operator=(const CowBuffer&) = default;
CowBuffer(CowBuffer&&) noexcept = default;
CowBuffer& operator=(CowBuffer&&) noexcept = default;
// Read accessor: no copy needed
const Data& read() const { return *data_; }
// Write accessor: copy only if shared
Data& write() {
if (data_.use_count() > 1)
data_ = std::make_shared<Data>(*data_); // lazy deep copy
return *data_;
}
private:
std::shared_ptr<Data> data_;
};Callers that only read never trigger a copy; the first caller that writes receives a private copy without any explicit action on the caller's part. Qt's QSharedData / QSharedDataPointer uses exactly this pattern—QSharedDataPointer::detach() corresponds to the use_count() > 1 branch above.
// C++11
CowBuffer a;
a.write().push_back(42);
CowBuffer b = a; // shares a's data — no allocation
b.write().push_back(7); // b detaches; a is unchanged
assert(a.read().size() == 1); // still {42}
assert(b.read().size() == 2); // {42, 7}Value Semantics in Generic Code: Pass by Value vs const&
The idiomatic C++11 rule for sink parameters is: accept by value and move into storage. This collapses two overloads (one for lvalues, one for rvalues) into a single function and lets the compiler choose copy-or-move at the call site. For parameters that are only read and not kept, const T& avoids the copy without sacrificing generality.
// C++11
#include <string>
#include <vector>
class NameList {
std::vector<std::string> names_;
public:
// Sink: caller provides a copy or an rvalue; we move into storage — one allocation
void push(std::string name) { // C++11: pass by value for sinks
names_.push_back(std::move(name));
}
// Read-only: const& avoids any copy
bool contains(const std::string& name) const {
for (const auto& n : names_) if (n == name) return true;
return false;
}
};In templates, use std::forward<T> to preserve the value category of the argument—this is perfect forwarding and lets the function behave like a sink without committing to copy or move at the definition site:
// C++11
template<typename T>
void store(std::vector<T>& vec, T&& val) { // forwarding reference
vec.emplace_back(std::forward<T>(val)); // copies lvalues, moves rvalues
}
std::string s = "hello";
std::vector<std::string> v;
store(v, s); // copies: s still valid
store(v, std::move(s)); // moves: cheaper, s left in valid-but-unspecified state
store(v, std::string{"x"}); // moves from temporary — C++11The decision tree: use T (by value) when the function will unconditionally own the argument; use const T& when only reading; use T&& (forwarding reference in a template) when forwarding to another function or constructor.
Comparing Value vs Entity Objects
Domain-Driven Design distinguishes value objects from entity objects at the semantic level, and that distinction maps directly to C++ type design. A value object is defined entirely by its attributes—two Money{100, "USD"} instances are interchangeable, and there is no meaningful notion of one being "the same object" as another. An entity object has an identity that persists through mutations: two User records with the same name might be different users.
// C++11 / C++20
#include <compare>
#include <cstdint>
// Value object: interchangeable by content; should be copyable and equality-comparable
struct Money {
long cents;
const char* currency;
bool operator==(const Money&) const = default; // C++20
auto operator<=>(const Money&) const = default; // C++20: full ordering
};
// Value objects are hashable — make them usable as map keys
#include <functional>
template<> struct std::hash<Money> {
std::size_t operator()(const Money& m) const noexcept {
return std::hash<long>{}(m.cents) ^
(std::hash<std::string_view>{}(m.currency) << 1);
}
};
// Entity object: identity is separate from content; equality by ID, not fields
struct User {
std::uint64_t id; // stable identity
std::string name; // mutable attribute
// Equality by identity, not by name
bool operator==(const User& o) const { return id == o.id; }
// Copying is semantically dubious — two User objects with the same ID
// represent the same user, so copies are usually unnecessary.
// Option: delete copy; use shared_ptr<User> or a repository.
User(const User&) = delete;
User& operator=(const User&) = delete;
User(User&&) noexcept = default;
User& operator=(User&&) noexcept = default;
explicit User(std::uint64_t id, std::string name)
: id(id), name(std::move(name)) {}
};Value objects should be: immutable or copy-safe, compared field-by-field, and hashable. Entity objects should be: non-copyable or identified by a stable key, compared by ID, and managed through a repository or smart pointer rather than copied around.
Value Semantics in Concurrent Code
Immutable value types are inherently thread-safe: there is no shared mutable state to protect. The most robust concurrent architecture passes copies of values across thread boundaries rather than sharing a pointer guarded by a mutex. Each thread owns its data completely; no synchronisation is needed for the data itself, only for the channel that transfers it.
// C++20
#include <thread>
#include <queue>
#include <mutex>
#include <optional>
#include <string>
struct LogEntry {
std::string message;
int severity;
};
class WorkQueue {
std::queue<LogEntry> q_;
std::mutex mu_;
public:
void push(LogEntry entry) { // receive by value — caller's copy
std::lock_guard lock(mu_); // C++17: CTAD for lock_guard
q_.push(std::move(entry)); // move into queue — O(1)
}
std::optional<LogEntry> pop() { // C++17: std::optional
std::lock_guard lock(mu_);
if (q_.empty()) return std::nullopt;
LogEntry e = std::move(q_.front()); // move out — no copy
q_.pop();
return e;
}
};
WorkQueue wq;
std::jthread producer([&wq](std::stop_token st) { // C++20: std::jthread
int n = 0;
while (!st.stop_requested())
wq.push({"event " + std::to_string(n++), 1});
});
std::jthread consumer([&wq](std::stop_token st) { // C++20
while (!st.stop_requested())
if (auto e = wq.pop()) { /* process *e */ }
});
// jthread joins automatically on destruction — no explicit join neededThe mutex in this pattern protects only the queue's structural integrity, not the LogEntry data—once a consumer pops an entry it owns a private copy with no further synchronisation needed. Contrast this with shared-state concurrency where a shared_ptr<LogEntry> passed to multiple threads requires either immutability guarantees or a second mutex protecting the entry's fields.
See Also
std::regular(C++20) — concept formalising the full value-type contractstd::copyable,std::movable(C++20) — sub-concepts composable intostd::regularstd::is_nothrow_move_constructible(C++11) — trait for verifyingnoexceptmovestd::swap— standard swap; overload in your type's namespace for ADL pickup- Copy-and-Swap idiom — assignment operator technique providing the strong exception guarantee