Assignment for Custom Classes: Assignment Operators
Providing Assignment
I'd like MORE functionality!!! Can
Barns
support assignment, too?Again, before adding functionality, it's good to think about whether you actually need it.
But if an instance of a class can be copied, it usually makes sense for it to support assignment as well.
Let's make sure we're clear about what assignment means. If we have two integers, we expect to be able to write
int x = 70;
int y = 42;
x = y;
and have the value of x
become the same as the value of y
. Remember that x
and y
should still be completely separate objects! If we make another change to y
, it shouldn't change x
:
y = 640;
// x is still 42
Our goal in this section is to make the same thing work for instances of the Barn
class:
Barn olin{4};
olin.addCow("bessie", 2);
Barn mcgregor{8};
mcgregor.addCow("elsie", 3);
mcgregor.addCow("mabel" 1);
olin = mcgregor;
mcgregor.addCow("shaun", 4);
The function that defines how this works is called an assignment operator. We call it by writing
=
, but=
isn't a very good member-function name. Instead, C++ expects us to name this functionoperator=
.Just like constructors and destructors, the compiler will synthesize an assignment operator unless we tell it otherwise.
Also like constructors and destructors, the compiler's synthesized assignment operator will lead to awful memory errors if our class is managing its own dynamically allocated memory!
A Starter Assignment Operator
The assignment operator needs to clean up all of the memory the object was using, then set up all of the new memory for the object.
That sounds a lot like a destructor and a copy constructor!
Yes! Let's start off with a version of our assignment operator that holds the body of our destructor and the body of our copy constructor.
void Barn::operator=(const Barn& other) {
// First clean up memory currently in use (code from the destructor)
for (Cow**p = residentCows_; p < residentCows_ + cowCount_; ++p) {
delete *p;
}
delete[] residentCows_;
// Now set up memory for the new data (code adapted from copy constructor)
cowCapacity_ = other.cowCapacity_;
cowCount_ = 0;
residentCows_ = new Cow*[cowCapacity_];
for (size_t i = 0; i < other.cowCount_; ++i) {
// Fetch pointer to the Cow we want to copy from the array
Cow* otherCowPtr = other.residentCows_[i];
// Make a copy.
addCow(*otherCowPtr);
}
}
Adding a Return Type
There's one other thing that's super weird about assignment operators.
The C++ standard says it's okay to chain assignments together, as in
x = y = z = 3;
So for our Barn
class, we need to be able to write
olin = mcgregor = Barn{3};
Note that need is a very strong word, here. The C++ standard says that assignment operators need to support this functionality, and the signature of an assignment operator is impacted by it. But you should pretty much never find yourself in a spot in CS 70 (or maybe ever) where writing code that depends on chaining assignments together is a good idea. Please don't do it!
Wise words. And yet, we still have to make our assignment operator meet the standard. To do so, it has to return a reference to a
Barn
.Blaaaargh! Whaaaat?!
I promise it's not so bad! The return type will be a
Barn&
, and then we just return the current object!"Just." "Just," you say. How are we supposed to do that?
By taking things one step at a time. Remember that
this
in C++ is a pointer to the current object?Yeah…?
And if we have a pointer, how do we get the thing it's pointing to?
Ooh, ooh! We follow it, using the
*
operator!Yes! If
this
is a pointer to the current object, then*this
is the object itself! Let's look at our assignment operator now…
Barn& Barn::operator=(const Barn& other) {
// First clean up memory currently in use (code from the destructor)
for (Cow**p = residentCows_; p < residentCows_ + cowCount_; ++p) {
delete *p;
}
delete[] residentCows_;
// Now set up memory for the new data (code adapted from copy constructor)
cowCapacity_ = other.cowCapacity_;
cowCount_ = 0;
residentCows_ = new Cow*[cowCapacity_];
for (Cow** p = residentCows_; p < residentCows_ + other.cowCount_; ++p) {
addCow(**p);
}
return *this;
}
Dealing with Self-Assignment
If
x
is anint
, it's totally valid to writex = x;
. We would absolutely not expect that line to blow away the value ofx
, right?Right! But so far, the same can't be said for our
Barn
class. Do you see why?
The very first thing our assignment operator does is wipe out (and give back) all of the memory being used by the current Barn
object. Then it fills it in with the content from the new Barn
object.
If the two Barn
objects are the same, though, there's no valid memory left to copy from!
Luckily, this is a pretty quick fix: we'll check at the very beginning to see if we're dealing with a case of self-assignment. If we are, we'll return without doing any work at all.
Barn& Barn::operator=(const Barn& other) {
// check for self assignment
if (this == &other) { // If this == &other, then these "two" Barns are at the same address!
return *this;
}
// First clean up memory currently in use (code from the destructor)
for (Cow**p = residentCows_; p < residentCows_ + cowCount_; ++p) {
delete *p;
}
delete[] residentCows_;
// Now set up memory for the new data (code adapted from copy constructor)
cowCapacity_ = other.cowCapacity_;
cowCount_ = 0;
residentCows_ = new Cow*[cowCapacity_];
for (Cow** p = residentCows_; p < residentCows_ + other.cowCount_; ++p) {
addCow(**p);
}
return *this;
}
Less Code! The Copy-and-Swap Idiom for Assignment
Meh. It works, but there's so much code duplication. Yuck!
That's true! This is a pretty straightforward version of the assignment operator.
It's (hopefully) clear what each part does, but it's inelegant and not great for maintainability. If we ever change the destructor, we have to remember to change our code here, too!
Luckily, there is a more clever way, though it takes a bit of explanation.
First, let's color-code the duplicated code, with the code from the destructor in purple and the code from the copy constructor in darkcyan:
Barn& Barn::operator=(const Barn& other) { // check for self assignment if (this == &other) { // If this == &other, then these "two" Barns are at the same address! return *this; } // First clean up memory currently in use (code from the destructor) for (Cow**p = residentCows_; p < residentCows_ + cowCount_; ++p) { delete *p; } delete[] residentCows_; // Now set up memory for the new data (code adapted from copy constructor) cowCapacity_ = other.cowCapacity_; cowCount_ = 0; residentCows_ = new Cow*[cowCapacity_]; for (Cow** p = residentCows_; p < residentCows_ + other.cowCount_; ++p) { addCow(**p); } return *this; }
We've seen that when we have to deal with an assignment operator, such as lhsBarn = rhsBarn
, there are three major things we need to do:
- Duplicate
rhsBarn
. - Replace the contents of
lhsBarn
with our new duplicate. - Destroy the old contents of
lhsBarn
.
Much of this work is similar to the work of the copy constructor and the destructor. Rather than duplicating the code from those functions, what if we could just call them to do the work for us?
Unfortunately we can't just call a constructor on an object that already exists. But there is a clever way to get around that restriction: the copy-and-swap idiom (or sometimes “the swap trick”), and it's widely used.
Before we look at the code, there's a handy function that it uses that's worth mentioning.
The system header
<utility>
providesstd::swap
, which takes two variables and swaps their contents.It works for just about all types provided by C++ and its standard library, from primitive types like numbers and pointers, to types like strings.
In fact,
std::swap
on strings is constant time, no matter how large the strings are!
Here's how we can write the assignment operator using the copy-and-swap idiom:
Barn& Barn::operator=(Barn other) { // other is copy-constructed using the Barn given on the right // side of the =. // Swap all the data members between *this and other std::swap(cowCapacity_, other.cowCapacity_); std::swap(cowCount_, other.cowCount_); std::swap(residentCows_, other.residentCows_); return *this; // other now has *this's old member values and has "taken over" its // heap memory. other is destroyed at this curly brace; so the // destructor cleans the old value that used to be in *this }
And here's the same code with normal syntax highlighting:
Barn& Barn::operator=(Barn other) {
// Copy-and-swap idiom
std::swap(cowCapacity_, other.cowCapacity_);
std::swap(cowCount_, other.cowCount_);
std::swap(residentCows_, other.residentCows_);
return *this;
}
How can a function so short get the job done?
Let's look at the things we need to do when we're running lhsBarn = rhsBarn;
- Duplicate
rhsBarn
- We now pass
other
by value rather than by constant reference, so we've made a copy.
- We now pass
- Replace the contents of
lhsBarn
with our new duplicate,other
.- Our three calls to
std::swap
swap all the data members betweenlhsBarn
(the target object of the member function) andother
, our copy ofrhsBarn
. - Not only is
lhsBarn
now the duplicate copy, butother
ends up with all the stuff we want to get rid of (the old contents oflhsBarn
).
- Our three calls to
- Destroy the old contents of
lhsBarn
.- All the stuff we want to get rid of is in
other
now. - When the function ends,
other
will be destroyed and our destructor will take care of it.
- All the stuff we want to get rid of is in
Also, the order in which we're doing things is a bit different than before (previously we did things in the order 3, 1, 2).
Our original ordering required us to check for self assignment, whereas the destroy-the-old-stuff last approach does not!
Incidentally, having a
swap
function for your objects is a useful thing in general.So you could just write a
swap
member function, and then call it, making your assignment operator even shorter.Like this:
void Barn::swap(Barn& other) { std::swap(cowCapacity_, other.cowCapacity_); std::swap(cowCount_, other.cowCount_); std::swap(residentCows_, other.residentCows_); } Barn& Barn::operator=(Barn other) { swap(other); return *this; }
In CS 70, we expect you to know what an assignment operator needs to accomplish, and write one.
You can use the copy-and-swap idiom, or write everything out explicitly. Both approaches work and are commonly seen in practice.
Choose whichever one is easier for you to understand and maintain.
You can check out our final version of the code:
(When logged in, completion status appears here.)