CS 70

Assignment for Custom Classes: Assignment Operators

Providing Assignment

  • Pig speaking

    I'd like MORE functionality!!! Can Barns support assignment, too?

  • LHS Cow speaking

    Again, before adding functionality, it's good to think about whether you actually need it.

  • RHS Cow speaking

    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);
  • LHS Cow speaking

    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 function operator=.

  • RHS Cow speaking

    Just like constructors and destructors, the compiler will synthesize an assignment operator unless we tell it otherwise.

  • LHS Cow speaking

    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

  • RHS Cow speaking

    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.

  • Cat speaking

    That sounds a lot like a destructor and a copy constructor!

  • LHS Cow speaking

    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};
  • RHS Cow speaking

    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!

  • LHS Cow speaking

    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.

  • Hedgehog speaking

    Blaaaargh! Whaaaat?!

  • LHS Cow speaking

    I promise it's not so bad! The return type will be a Barn&, and then we just return the current object!

  • Hedgehog speaking

    "Just." "Just," you say. How are we supposed to do that?

  • LHS Cow speaking

    By taking things one step at a time. Remember that this in C++ is a pointer to the current object?

  • Hedgehog speaking

    Yeah…?

  • LHS Cow speaking

    And if we have a pointer, how do we get the thing it's pointing to?

  • Dog speaking

    Ooh, ooh! We follow it, using the * operator!

  • LHS Cow speaking

    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

  • LHS Cow speaking

    If x is an int, it's totally valid to write x = x;. We would absolutely not expect that line to blow away the value of x, right?

  • RHS Cow speaking

    Right! But so far, the same can't be said for our Barn class. Do you see why?

Explain why, if olin is a Barn, running olin = olin; will break things horribly right now.

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

  • Goat speaking

    Meh. It works, but there's so much code duplication. Yuck!

  • LHS Cow speaking

    That's true! This is a pretty straightforward version of the assignment operator.

  • RHS Cow speaking

    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!

  • LHS Cow speaking

    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:

  1. Duplicate rhsBarn.
  2. Replace the contents of lhsBarn with our new duplicate.
  3. 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.

  • LHS Cow speaking

    Before we look at the code, there's a handy function that it uses that's worth mentioning.

  • RHS Cow speaking

    The system header <utility> provides std::swap, which takes two variables and swaps their contents.

  • LHS Cow speaking

    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.

  • RHS Cow speaking

    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;

  1. Duplicate rhsBarn
    • We now pass other by value rather than by constant reference, so we've made a copy.
  2. Replace the contents of lhsBarn with our new duplicate, other.
    • Our three calls to std::swap swap all the data members between lhsBarn (the target object of the member function) and other, our copy of rhsBarn.
    • Not only is lhsBarn now the duplicate copy, but other ends up with all the stuff we want to get rid of (the old contents of lhsBarn).
  3. 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.

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!

  • LHS Cow speaking

    Incidentally, having a swap function for your objects is a useful thing in general.

  • RHS Cow speaking

    So you could just write a swap member function, and then call it, making your assignment operator even shorter.

  • LHS Cow speaking

    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;
    }
    
  • RHS Cow speaking

    In CS 70, we expect you to know what an assignment operator needs to accomplish, and write one.

  • LHS Cow speaking

    You can use the copy-and-swap idiom, or write everything out explicitly. Both approaches work and are commonly seen in practice.

  • RHS Cow speaking

    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.)