14 Special Members and Friends
In Chapter 12 you learned how to define classes with constructors, destructors, and member functions. Those tools let you create well-behaved types, but two questions remain. First, when your class manages a resource like raw memory, how do you make sure copying, moving, and cleanup all work correctly? Second, how do you give an outside function or class access to private data when making it a member is not an option? This chapter covers both: the special member functions the compiler can generate (and the rules for writing your own), and the friend keyword for granting controlled access.
14.1 Special Member Functions and the Rule of Five
When you write a class, the compiler can automatically generate six special member functions. You already know the default constructor from Chapter 12; this chapter is about the other five:
- Destructor — cleans up when the object is destroyed
- Copy constructor — creates a new object as a copy of another
- Copy assignment operator — replaces an existing object’s contents with a copy of another
- Move constructor — creates a new object by moving from another
- Move assignment operator — replaces an existing object’s contents by moving from another
If your class manages a resource (like raw heap memory), and you write any one of these five, you almost certainly need to write all five. This is called the Rule of Five.
Here is a simplified example of a class that manages its own heap memory. The example uses two C string functions from <cstring> whose signatures are:
size_t strlen(const char* str); // length of a C string
char* strcpy(char* dest, const char* src); // copies src into dest#include <iostream>
#include <cstring>
class Lyric {
private:
char *text;
public:
// constructor
Lyric(const char *t) {
text = new char[std::strlen(t) + 1];
std::strcpy(text, t);
}
// destructor
~Lyric() {
delete[] text;
}
// copy constructor
Lyric(const Lyric &other) {
text = new char[std::strlen(other.text) + 1];
std::strcpy(text, other.text);
}
// copy assignment (simplified --- see trap below)
Lyric &operator=(const Lyric &other) {
if (this != &other) {
delete[] text;
text = new char[std::strlen(other.text) + 1];
std::strcpy(text, other.text);
}
return *this;
}
// move constructor
Lyric(Lyric &&other) noexcept : text(other.text) {
other.text = nullptr;
}
// move assignment
Lyric &operator=(Lyric &&other) noexcept {
if (this != &other) {
delete[] text;
text = other.text;
other.text = nullptr;
}
return *this;
}
void print() const {
if (text) {
std::cout << text << std::endl;
}
}
};Notice the noexcept keyword on the move constructor and move assignment operator. As you learned in Chapter 11, noexcept promises the compiler that these functions will not throw exceptions. Standard library containers like std::vector will only use your move operations (instead of slower copies) during reallocation if they are marked noexcept (when a class cannot be copied at all, the vector has no choice but to move).
14.1.1 Why the Move Constructor Matters
Trap: The copy assignment operator above is not exception-safe. It does delete[] text; first, then text = new char[...]. If new throws (out of memory), text is left as a dangling pointer to memory the program already freed. The destructor will then delete[] it a second time — a double-free that corrupts the heap.
The standard fix is the copy-and-swap idiom: build a temporary copy first, then swap it with *this. Gorgo Continuing C++ covers it in the chapter on RAII and Resource Management. For everyday code, the rule of zero (managing resources via standard library types) avoids the problem entirely.
You saw rvalue references (&&) in Chapter 6 and move semantics in Chapter 13. Now you can see why the move constructor and move assignment operator are special: they are what the compiler calls when it has an rvalue — a temporary or a std::move’d object — and needs to construct or assign from it.
Without a move constructor, every time you return a large object from a function, push one into a vector, or pass a temporary to a constructor, the compiler has to copy — allocating new memory and duplicating every byte. With a move constructor, those operations transfer ownership in constant time.
Consider what happens when you push Lyric objects into a vector:
#include <vector>
int main() {
std::vector<Lyric> lyrics;
// Without move: each push_back copies the temporary, allocating
// a new char buffer and copying every character.
// With move: the vector steals the temporary's buffer directly.
lyrics.push_back(Lyric("Yo te quiero"));
lyrics.push_back(Lyric("Tell me why"));
return 0;
}Each Lyric(...) creates a temporary — an rvalue. The vector’s push_back sees the rvalue and calls the move constructor instead of the copy constructor. The move constructor takes the temporary’s text pointer directly and sets the temporary’s pointer to nullptr, avoiding a heap allocation entirely.
The same thing happens when you use std::move explicitly to transfer ownership of an lvalue you no longer need:
Lyric a("Yo te quiero, yo te adoro");
Lyric b = std::move(a); // calls move constructor
// a.text is now nullptr --- its contents were transferred to bWithout the move constructor, that line would call the copy constructor, allocating a fresh char array and copying every character. For objects managing large resources — like a vector with millions of elements or a string with megabytes of text — the difference between a pointer swap and a full copy is enormous.
Tip: The compiler will use the move constructor whenever it has an rvalue. Temporaries (like function return values and unnamed objects) are rvalues automatically. Named variables become rvalues when you cast them with std::move.
That is a lot of code just to manage a string. This brings us to better tools for controlling what the compiler generates.
14.2 Defaulted and Deleted Functions
The compiler’s auto-generation rules create two practical problems. The first is that writing any constructor suppresses the default constructor, and writing a destructor or copy operation can suppress the move operations. Sometimes the compiler-generated versions are exactly what you want, but you have to write them by hand just to get them back. The second problem is the opposite: sometimes the compiler happily generates a function that makes no sense for your type. Imagine a class that represents an open connection to a hardware device — copying it would mean two objects fighting over the same device, but the compiler will generate a copy constructor anyway unless you stop it.
Before C++11, the workaround for the second problem was to declare the unwanted function private and never define it. That “worked,” but anyone who accidentally called it got a confusing linker error instead of a clear explanation.
C++ gives you two tools to solve these problems: = default and = delete.
14.2.1 = default
When you write = default, you are saying “generate the default version of this function for me.” This solves the first problem: adding one special member function suppresses the compiler’s auto-generation of others, but you still want the default behavior:
class Song {
private:
std::string title;
std::string artist;
public:
Song(const std::string &t, const std::string &a)
: title(t), artist(a) {}
// A custom constructor suppresses the default constructor.
// Bring it back:
Song() = default;
// The compiler-generated versions are fine for these:
Song(const Song &) = default;
Song &operator=(const Song &) = default;
~Song() = default;
};= default also documents your intent: it tells anyone reading the code “I thought about this and the compiler’s version is correct.”
14.2.2 = delete
= delete solves the second problem. When you write = delete, the function exists but calling it is a compile-time error. This lets you prevent operations that do not make sense for your type:
class AudioStream {
private:
int device_id;
public:
AudioStream(int id) : device_id(id) {}
// Copying an active audio stream would cause two objects
// to fight over the same hardware device.
AudioStream(const AudioStream &) = delete;
AudioStream &operator=(const AudioStream &) = delete;
// Moving is fine --- ownership transfers cleanly.
AudioStream(AudioStream &&other) noexcept
: device_id(other.device_id) {
other.device_id = -1;
}
AudioStream &operator=(AudioStream &&other) noexcept {
if (this != &other) {
device_id = other.device_id;
other.device_id = -1;
}
return *this;
}
};AudioStream a(1);
AudioStream b = a; // ERROR: copy constructor is deleted
AudioStream c = std::move(a); // OK: move constructor is availableYou can delete any function, not just special members. A common use is preventing implicit conversions:
void set_volume(int v);
void set_volume(double) = delete; // prevent set_volume(3.14)
Tip: = delete gives a clear compiler error with a message like “use of deleted function.” This is much better than making a function private and leaving it undefined, which was the pre-C++11 workaround and produced cryptic linker errors instead.
14.3 The Rule of Zero
The Rule of Zero says: if your class does not manage a resource directly, do not write any of the five special member functions. Let the compiler generate them for you.
This is closely related to RAII (Resource Acquisition Is Initialization), a fundamental C++ pattern where you acquire resources in the constructor and release them in the destructor. When you follow the Rule of Zero, you rely on types that already implement RAII (like std::string, std::vector, and std::unique_ptr) so your class does not need to.
How do you avoid managing resources directly? Use smart pointers and standard library types that already handle their own memory:
#include <iostream>
#include <string>
class Lyric {
private:
std::string text; // std::string manages its own memory
public:
Lyric(const std::string &t) : text(t) {}
void print() const {
std::cout << text << std::endl;
}
};This version does the same thing as the Rule of Five version above, but in a fraction of the code. The compiler-generated copy constructor, move constructor, and destructor all do the right thing because std::string already knows how to copy, move, and clean up after itself.
Tip: Follow the Rule of Zero whenever you can. Use std::string instead of char*, std::vector instead of raw arrays, and std::unique_ptr instead of raw new/delete. If all your members manage themselves, you do not need to write any special member functions.
14.4 Friends
So far, only a class’s own member functions can access its private data. But sometimes an outside function or another class genuinely needs that access, and making it a member function is inconvenient or impossible.
Consider printing a Playlist with std::cout. You would like to write std::cout << my_playlist, but operator<< cannot be a member function of Playlist — the left operand of << is a std::ostream, not a Playlist, so the overload would have to be added to std::ostream, which you do not own. It has to be a free function, and a free function cannot access private members.
Or consider a class that manages another class’s internals — like a DJ that manipulates a Playlist’s track order. You could make DJ a subclass or merge the two classes together, but neither makes sense: a DJ is not a kind of playlist, and a playlist does not need DJ behavior baked in.
C++ solves both problems with the friend keyword. A class can declare specific functions or classes as friends, granting them access to its private and protected members.
14.4.1 Friend Functions
To make a free function a friend, declare it with the friend keyword inside the class:
#include <iostream>
#include <string>
#include <vector>
class Playlist {
private:
std::string name;
std::vector<std::string> songs;
public:
Playlist(const std::string &n) : name(n) {}
void add(const std::string &song) {
songs.push_back(song);
}
friend std::ostream &operator<<(std::ostream &os,
const Playlist &p);
};
std::ostream &operator<<(std::ostream &os, const Playlist &p) {
os << p.name << ":" << std::endl;
for (size_t i = 0; i < p.songs.size(); ++i) {
os << " " << i + 1 << ". " << p.songs[i] << std::endl;
}
return os;
}Playlist p("90s Jams");
p.add("I'll Be There for You");
p.add("Torn");
std::cout << p;Output:
90s Jams:
1. I'll Be There for You
2. TornThe friend declaration inside Playlist tells the compiler that operator<< may access name and songs directly, even though it is not a member function. The function itself is defined outside the class, just like any free function.
Notice that operator<< returns std::ostream & so that calls can be chained: std::cout << a << b. This is the same pattern the standard library uses for std::cout << "hello" << std::endl — each << returns the stream, ready for the next one.
14.4.2 Friend Classes
You can also make an entire class a friend. Every member function of the friend class then has access to the private members:
class Playlist {
private:
std::string name;
std::vector<std::string> songs;
public:
Playlist(const std::string &n) : name(n) {}
void add(const std::string &song) {
songs.push_back(song);
}
void print() const {
std::cout << name << ":" << std::endl;
for (size_t i = 0; i < songs.size(); ++i) {
std::cout << " " << i + 1 << ". " << songs[i]
<< std::endl;
}
}
friend class DJ;
};
class DJ {
private:
std::string name;
public:
DJ(const std::string &n) : name(n) {}
void intro(const Playlist &p) const {
std::cout << name << ": up next, " << p.songs.size()
<< " tracks from " << p.name << "!" << std::endl;
}
void swap_first_last(Playlist &p) const {
if (p.songs.size() > 1) {
std::string temp = p.songs.front();
p.songs.front() = p.songs.back();
p.songs.back() = temp;
}
}
};Playlist p("90s Mix");
p.add("Torn");
p.add("Kiss from a Rose");
p.add("I'll Be There for You");
DJ dj("DJ Jazzy Jeff");
dj.intro(p);
p.print();Output:
DJ Jazzy Jeff: up next, 3 tracks from 90s Mix!
90s Mix:
1. Torn
2. Kiss from a Rose
3. I'll Be There for YouThe DJ class can read and modify Playlist’s private songs and name because Playlist declared DJ as a friend. Notice that the friendship is one-directional — DJ can access Playlist’s private members, but Playlist cannot access DJ’s private members.
14.4.3 Rules of Friendship
Friendship has a few important rules:
- Friendship is granted, not taken. A class must declare its own friends inside its definition. You cannot claim friendship from outside.
- Friendship is not mutual. If
PlaylistdeclaresDJas a friend,DJcan accessPlaylist’s private members, butPlaylistcannot accessDJ’s private members unlessDJalso declaresPlaylistas a friend. - Friendship is not inherited. If
DJis a friend ofPlaylist, a class derived fromDJdoes not automatically get that friendship. - Friendship is not transitive. If
Ais a friend ofB, andBis a friend ofC,Ais not automatically a friend ofC.
Tip: Use friend sparingly. Every friend is a piece of outside code that depends on your class’s internal representation. If you change how the class stores its data, you have to update every friend too. Prefer member functions or public interfaces when possible, and reserve friend for cases like operator<< where there is no alternative.
Trap: Declaring too many friends defeats the purpose of making members private in the first place. If you find yourself adding friends frequently, consider whether the class’s public interface is missing something.
14.5 Try It: Special Members Starter
Here is a program with all five special member functions instrumented so each one announces itself when it runs. The names are printed in brackets so you can see when a moved-from object is left holding an empty string. Type it in, compile it, and watch which special member fires on each line of main:
#include <iostream>
#include <string>
#include <utility>
class Tracer {
private:
std::string name;
public:
Tracer(const std::string &n) : name(n) {
std::cout << "ctor: [" << name << "]\n";
}
~Tracer() {
std::cout << "dtor: [" << name << "]\n";
}
Tracer(const Tracer &other) : name(other.name) {
std::cout << "copy ctor: [" << name << "]\n";
}
Tracer &operator=(const Tracer &other) {
name = other.name;
std::cout << "copy assign: [" << name << "]\n";
return *this;
}
Tracer(Tracer &&other) noexcept : name(std::move(other.name)) {
std::cout << "move ctor: [" << name << "]\n";
}
Tracer &operator=(Tracer &&other) noexcept {
name = std::move(other.name);
std::cout << "move assign: [" << name << "]\n";
return *this;
}
};
Tracer make_tracer() {
return Tracer("returned");
}
int main() {
Tracer a("alpha");
Tracer b = a; // copy ctor
Tracer c = std::move(a); // move ctor --- a's string is stolen
Tracer d = make_tracer(); // no copy, no move: guaranteed elision
Tracer e("epsilon");
e = b; // copy assign
e = std::move(c); // move assign --- c's string is stolen
return 0;
}Output:
ctor: [alpha]
copy ctor: [alpha]
move ctor: [alpha]
ctor: [returned]
ctor: [epsilon]
copy assign: [alpha]
move assign: [alpha]
dtor: [alpha]
dtor: [returned]
dtor: []
dtor: [alpha]
dtor: []Notice that Tracer d = make_tracer(); prints only one ctor line. Since C++17 copy elision is guaranteed here — d is constructed in place, so neither the copy constructor nor the move constructor runs. Also notice the two dtor: [] lines: those are a and c, which gave up their strings when they were moved from. The destructors run in reverse order of construction — e, d, c, b, then a.
Things to try:
- Trace the output by hand before running the program, then check your prediction against the real output.
- Delete the move constructor and move assignment operator and recompile. Which lines of the output change from
movetocopy? - Add
Tracer(const Tracer &) = delete;and see exactly which lines ofmainstop compiling. - Replace the
Tracer e("epsilon");block withstd::vector<Tracer> v; v.push_back(b);(include<vector>). How many copies and moves do you see? - Remove
noexceptfrom the move constructor, thenpush_backseveral objects so the vector reallocates. Does the vector still move your objects, or does it fall back to copying?
14.6 Key Points
- The compiler can generate five special member functions: destructor, copy constructor, copy assignment, move constructor, and move assignment.
- The move constructor and move assignment operator accept rvalue references (
&&) and transfer resources instead of copying them. - The compiler calls the move versions automatically for temporaries and
std::move’d objects — this is what makes returning large objects and pushing into vectors efficient. - The Rule of Five: if you write any one of the five, write all five.
= defaultexplicitly requests the compiler-generated version;= deleteprevents a function from being called.- Use
= deleteto make a type non-copyable, non-movable, or to prevent implicit conversions. - The Rule of Zero: prefer types that manage themselves so you do not need to write any special member functions.
- RAII ties resource lifetimes to object lifetimes — acquire in the constructor, release in the destructor.
- The
friendkeyword grants a specific function or class access to private members. - Use
friendfor operators like<<where the left operand is not your class. - Friendship is granted, not taken; it is not mutual, inherited, or transitive.
14.7 Exercises
Explain the difference between the Rule of Five and the Rule of Zero. Which one should you prefer and why?
A coworker writes a class with a move constructor but
std::vectorkeeps copying objects instead of moving them during reallocation. What is wrong with the move constructor?class Track { private: std::string title; std::vector<int> samples; public: Track(const std::string &t) : title(t) {} Track(const Track &) = default; Track &operator=(const Track &) = default; Track(Track &&other) : title(std::move(other.title)), samples(std::move(other.samples)) {} };What does
= defaultdo when applied to a special member function? Why would you writeSong() = default;instead of just omitting the default constructor?What does the following code do, and why is it useful?
class Connection { public: Connection(int fd) : fd_(fd) {} Connection(const Connection &) = delete; Connection &operator=(const Connection &) = delete; private: int fd_; };Why does
operator<<for output have to be a free function (or a friend) rather than a member function of your class?What does the following program print?
#include <iostream> #include <string> class Vault { private: std::string secret; public: Vault(const std::string &s) : secret(s) {} friend void peek(const Vault &v); }; void peek(const Vault &v) { std::cout << v.secret << std::endl; } int main() { Vault v("Vogue"); peek(v); return 0; }If class
Adeclares classBas a friend, and classBdeclares classCas a friend, canCaccessA’s private members? Why or why not?A class has a
std::string name, astd::vector<int> scoreswith 3 elements, and anint id. How many of the five special member functions do you need to write if all members are standard library types or built-in types?Write a class called
Albumwith private members fortitle(string),artist(string), andtrack_count(int). Give it a parameterized constructor, aconstmember function that prints the album info, an overloaded==operator that compares all three fields, and a friendoperator<<for output. Test it inmain()by creating two albums, printing them with<<, and comparing them.Write a program that defines a class
Bufferthat owns a heap-allocatedchar *and asize_tlength. Implement all five special member functions explicitly:- destructor
- copy constructor
- copy assignment operator
- move constructor (mark
noexcept) - move assignment operator (mark
noexcept)
Add a small helper that prints which special member is running (“copy ctor”, “move ctor”, and so on) so you can watch them fire. In
main, create oneBuffer, copy it into another, move-construct a third, and let all of them go out of scope. Trace the output by hand before you run it and confirm it matches.Where is the bug?
class Buffer { char *data; std::size_t len; public: Buffer(std::size_t n) : data(new char[n]), len(n) {} ~Buffer() { delete[] data; } Buffer &operator=(const Buffer &other) { delete[] data; len = other.len; data = new char[len]; for (std::size_t i = 0; i < len; ++i) { data[i] = other.data[i]; } return *this; } }; int main() { Buffer b(100); b = b; // assign to itself return 0; }Walk through what happens inside
operator=when the right-hand side is the left-hand side. Why do the buffer’s contents end up destroyed, and what is the standard fix?Think about it: Why does
std::vectorinsist that the move constructor and move assignment operator be markednoexceptbefore it will use them? What does the vector do instead if your move operations are notnoexcept, and what is the performance cost?What does this print? Trace through the output, paying attention to which special member function is called for each line.
#include <iostream> #include <string> #include <utility> class Track { std::string title; public: Track(const std::string &t) : title(t) { std::cout << "ctor: " << title << "\n"; } Track(const Track &other) : title(other.title) { std::cout << "copy: " << title << "\n"; } Track(Track &&other) noexcept : title(std::move(other.title)) { std::cout << "move: " << title << "\n"; } }; int main() { Track a("Only Happy When It Rains"); Track b = a; Track c = std::move(a); Track d = Track("Standing Outside a Broken Phone Booth"); return 0; }(Note: since C++17 the last move is guaranteed to be elided —
dis constructed in place. Explain what the output is, and what it would have been if the temporary were materialized and moved.)Think about it: Consider a function that accepts a
std::unique_ptr<Widget>by value. Why does this force the caller to usestd::move? What happens to the caller’sunique_ptrafter the call? Why is this a useful pattern for expressing “this function takes ownership”?Calculation: On a typical 64-bit Linux system where
std::stringis 32 bytes andstd::size_tis 8 bytes, what issizeof(Buffer)for the class in exercise 11 (char *data; std::size_t len;)? What issizeof(Track)for the class in exercise 13 (a singlestd::string title;member)? Account for any padding the compiler is likely to insert.