13.2. Copy Control and Resource Management
FundamentalOrdinarily, classes that manage resources that do not reside in the class must define the copy-control members. As we saw in § 13.1.4 (p. 504), such classes will need destructors to free the resources allocated by the object. Once a class needs a destructor, it almost surely needs a copy constructor and copy-assignment operator as well.
In order to define these members, we first have to decide what copying an object of our type will mean. In general, we have two choices: We can define the copy operations to make the class behave like a value or like a pointer.
Classes that behave like values have their own state. When we copy a valuelike object, the copy and the original are independent of each other. Changes made to the copy have no effect on the original, and vice versa.
Classes that act like pointers share state. When we copy objects of such classes, the copy and the original use the same underlying data. Changes made to the copy also change the original, and vice versa.
Of the library classes we’ve used, the library containers and string
class have valuelike behavior. Not surprisingly, the shared_ptr
class provides pointerlike behavior, as does our StrBlob
class (§ 12.1.1, p. 456). The IO types and unique_ptr
do not allow copying or assignment, so they provide neither valuelike nor pointerlike behavior.
To illustrate these two approaches, we’ll define the copy-control members for the HasPtr
class used in the exercises. First, we’ll make the class act like a value; then we’ll reimplement the class making it behave like a pointer.
Our HasPtr
class has two members, an int
and a pointer to string
. Ordinarily, classes copy members of built-in type (other than pointers) directly; such members are values and hence ordinarily ought to behave like values. What we do when we copy the pointer member determines whether a class like HasPtr
has valuelike or pointerlike behavior.
INFO
Exercises Section 13.2
Exercise 13.22: Assume that we want HasPtr
to behave like a value. That is, each object should have its own copy of the string
to which the objects point. We’ll show the definitions of the copy-control members in the next section. However, you already know everything you need to know to implement these members. Write the HasPtr
copy constructor and copy-assignment operator before reading on.
13.2.1. Classes That Act Like Values
FundamentalTo provide valuelike behavior, each object has to have its own copy of the resource that the class manages. That means each HasPtr
object must have its own copy of the string
to which ps
points. To implement valuelike behavior HasPtr
needs
- A copy constructor that copies the
string
, not just the pointer - A destructor to free the
string
- A copy-assignment operator to free the object’s existing
string
and copy thestring
from its right-hand operand
The valuelike version of HasPtr
is
class HasPtr {
public:
HasPtr(const std::string &s = std::string()):
ps(new std::string(s)), i(0) { }
// each HasPtr has its own copy of the string to which ps points
HasPtr(const HasPtr &p):
ps(new std::string(*p.ps)), i(p.i) { }
HasPtr& operator=(const HasPtr &);
~HasPtr() { delete ps; }
private:
std::string *ps;
int i;
};
Our class is simple enough that we’ve defined all but the assignment operator in the class body. The first constructor takes an (optional) string
argument. That constructor dynamically allocates its own copy of that string
and stores a pointer to that string
in ps
. The copy constructor also allocates its own, separate copy of the string
. The destructor frees the memory allocated in its constructors by executing delete
on the pointer member, ps
.
Valuelike Copy-Assignment Operator
Assignment operators typically combine the actions of the destructor and the copy constructor. Like the destructor, assignment destroys the left-hand operand’s resources. Like the copy constructor, assignment copies data from the right-hand operand. However, it is crucially important that these actions be done in a sequence that is correct even if an object is assigned to itself. Moreover, when possible, we should also write our assignment operators so that they will leave the left-hand operand in a sensible state should an exception occur (§ 5.6.2, p. 196).
In this case, we can handle self-assignment—and make our code safe should an exception happen—by first copying the right-hand side. After the copy is made, we’ll free the left-hand side and update the pointer to point to the newly allocated string
:
HasPtr& HasPtr::operator=(const HasPtr &rhs)
{
auto newp = new string(*rhs.ps); // copy the underlying string
delete ps; // free the old memory
ps = newp; // copy data from rhs into this object
i = rhs.i;
return *this; // return this object
}
In this assignment operator, we quite clearly first do the work of the constructor: The initializer of newp
is identical to the initializer of ps
in HasPtr
’s copy constructor. As in the destructor, we next delete
the string
to which ps
currently points. What remains is to copy the pointer to the newly allocated string
and the int
value from rhs
into this object.
INFO
Key Concept: Assignment Operators
There are two points to keep in mind when you write an assignment operator:
- Assignment operators must work correctly if an object is assigned to itself.
- Most assignment operators share work with the destructor and copy constructor.
A good pattern to use when you write an assignment operator is to first copy the right-hand operand into a local temporary. After the copy is done, it is safe to destroy the existing members of the left-hand operand. Once the left-hand operand is destroyed, copy the data from the temporary into the members of the left-hand operand.
To illustrate the importance of guarding against self-assignment, consider what would happen if we wrote the assignment operator as
// WRONG way to write an assignment operator!
HasPtr&
HasPtr::operator=(const HasPtr &rhs)
{
delete ps; // frees the string to which this object points
// if rhs and *this are the same object, we're copying from deleted memory!
ps = new string(*(rhs.ps));
i = rhs.i;
return *this;
}
If rhs
and this object are the same object, deleting ps
frees the string
to which both *this
and rhs
point. When we attempt to copy * (rhs.ps)
in the new
expression, that pointer points to invalid memory. What happens is undefined.
WARNING
It is crucially important for assignment operators to work correctly, even when an object is assigned to itself. A good way to do so is to copy the right-hand operand before destroying the left-hand operand.
INFO
Exercises Section 13.2.1
Exercise 13.23: Compare the copy-control members that you wrote for the solutions to the previous section’s exercises to the code presented here. Be sure you understand the differences, if any, between your code and ours.
Exercise 13.24: What would happen if the version of HasPtr
in this section didn’t define a destructor? What if HasPtr
didn’t define the copy constructor?
Exercise 13.25: Assume we want to define a version of StrBlob
that acts like a value. Also assume that we want to continue to use a shared_ptr
so that our StrBlobPtr
class can still use a weak_ptr
to the vector
. Your revised class will need a copy constructor and copy-assignment operator but will not need a destructor. Explain what the copy constructor and copy-assignment operators must do. Explain why the class does not need a destructor.
Exercise 13.26: Write your own version of the StrBlob
class described in the previous exercise.
13.2.2. Defining Classes That Act Like Pointers
FundamentalFor our HasPtr
class to act like a pointer, we need the copy constructor and copy-assignment operator to copy the pointer member, not the string
to which that pointer points. Our class will still need its own destructor to free the memory allocated by the constructor that takes a string
(§ 13.1.4, p. 504). In this case, though, the destructor cannot unilaterally free its associated string
. It can do so only when the last HasPtr
pointing to that string
goes away.
The easiest way to make a class act like a pointer is to use shared_ptr
s to manage the resources in the class. Copying (or assigning) a shared_ptr
copies (assigns) the pointer to which the shared_ptr
points. The shared_ptr
class itself keeps track of how many users are sharing the pointed-to object. When there are no more users, the shared_ptr
class takes care of freeing the resource.
However, sometimes we want to manage a resource directly. In such cases, it can be useful to use a reference count (§ 12.1.1, p. 452). To show how reference counting works, we’ll redefine HasPtr
to provide pointerlike behavior, but we will do our own reference counting.
Reference Counts
Reference counting works as follows:
- In addition to initializing the object, each constructor (other than the copy constructor) creates a counter. This counter will keep track of how many objects share state with the object we are creating. When we create an object, there is only one such object, so we initialize the counter to 1.
- The copy constructor does not allocate a new counter; instead, it copies the data members of its given object, including the counter. The copy constructor increments this shared counter, indicating that there is another user of that object’s state.
- The destructor decrements the counter, indicating that there is one less user of the shared state. If the count goes to zero, the destructor deletes that state.
- The copy-assignment operator increments the right-hand operand’s counter and decrements the counter of the left-hand operand. If the counter for the left-hand operand goes to zero, there are no more users. In this case, the copy-assignment operator must destroy the state of the left-hand operand.
The only wrinkle is deciding where to put the reference count. The counter cannot be a direct member of a HasPtr
object. To see why, consider what happens in the following example:
HasPtr p1("Hiya!");
HasPtr p2(p1); // p1 and p2 point to the same string
HasPtr p3(p1); // p1, p2, and p3 all point to the same string
If the reference count is stored in each object, how can we update it correctly when p3
is created? We could increment the count in p1
and copy that count into p3
, but how would we update the counter in p2?
One way to solve this problem is to store the counter in dynamic memory. When we create an object, we’ll also allocate a new counter. When we copy or assign an object, we’ll copy the pointer to the counter. That way the copy and the original will point to the same counter.
Defining a Reference-Counted Class
Using a reference count, we can write the pointerlike version of HasPtr
as follows:
class HasPtr {
public:
// constructor allocates a new string and a new counter, which it sets to 1
HasPtr(const std::string &s = std::string()):
ps(new std::string(s)), i(0), use(new std::size_t(1)) {}
// copy constructor copies all three data members and increments the counter
HasPtr(const HasPtr &p):
ps(p.ps), i(p.i), use(p.use) { ++*use; }
HasPtr& operator=(const HasPtr&);
~HasPtr();
private:
std::string *ps;
int i;
std::size_t *use; // member to keep track of how many objects share *ps
};
Here, we’ve added a new data member named use
that will keep track of how many objects share the same string
. The constructor that takes a string
allocates this counter and initializes it to 1
, indicating that there is one user of this object’s string
member.
Pointerlike Copy Members “Fiddle” the Reference Count
When we copy or assign a HasPtr
object, we want the copy and the original to point to the same string
. That is, when we copy a HasPtr
, we’ll copy ps
itself, not the string
to which ps
points. When we make a copy, we also increment the counter associated with that string
.
The copy constructor (which we defined inside the class) copies all three members from its given HasPtr
. This constructor also increments the use
member, indicating that there is another user for the string
to which ps
and p.ps
point.
The destructor cannot unconditionally delete ps
—there might be other objects pointing to that memory. Instead, the destructor decrements the reference count, indicating that one less object shares the string
. If the counter goes to zero, then the destructor frees the memory to which both ps
and use
point:
HasPtr::~HasPtr()
{
if (--*use == 0) { // if the reference count goes to 0
delete ps; // delete the string
delete use; // and the counter
}
}
The copy-assignment operator, as usual, does the work common to the copy constructor and to the destructor. That is, the assignment operator must increment the counter of the right-hand operand (i.e., the work of the copy constructor) and decrement the counter of the left-hand operand, deleting the memory used if appropriate (i.e., the work of the destructor).
Also, as usual, the operator must handle self-assignment. We do so by incrementing the count in rhs
before decrementing the count in the left-hand object. That way if both objects are the same, the counter will have been incremented before we check to see if ps
(and use
) should be deleted:
HasPtr& HasPtr::operator=(const HasPtr &rhs)
{
++*rhs.use; // increment the use count of the right-hand operand
if (--*use == 0) { // then decrement this object's counter
delete ps; // if no other users
delete use; // free this object's allocated members
}
ps = rhs.ps; // copy data from rhs into this object
i = rhs.i;
use = rhs.use;
return *this; // return this object
}
INFO
Exercises Section 13.2.2
Exercise 13.27: Define your own reference-counted version of HasPtr
.
Exercise 13.28: Given the following classes, implement a default constructor and the necessary copy-control members.
(a)
class TreeNode {
private:
std::string value;
int count;
TreeNode *left;
TreeNode *right;
};
(b)
class BinStrTree {
private:
TreeNode *root;
};