11.6.1. Person.cpp: Copy Constructor and Assignment Operator

Time: 00:5:40 | Download: Large, Large (CC), Small | Streaming, Streaming (CC) | Slides (PDF)
Review

The Person class has appeared previously in two object-copying examples. The first demonstrating the copy constructor and the second demonstrating the assignment operator. Both examples lack authenticity because they provide an identification number rather than a name, arguably the most frequently used attribute characterizing a "person." The following examples replace the ID number with a name implemented as a C-string. The first example manages the name as a fixed-length character array and the second as a character pointer.

Array vs. Pointer Implementation

Unless programmers deliberately choose another argument-passing mechanism, C++ passes them by value. C-strings are the exception to this rule, with C++ passing them by pointer. Consequently, functions in both versions use pointers, so the primary difference between them is where the Person class allocates the C-string storing a person's name. The array version allocates a fixed-length array on the stack, while the pointer version allocates a variable-length array on the heap. The following figures highlight the differences between the array- and pointer-based versions.

Person1: Array VersionPerson2: Pointer Version
#include <iostream>
#include <cstring>
using namespace std;

class Person
{
    private:
        char    name[100] = "";			// (a)
        int     weight = 0;
        double  height = 0;

    public:
        Person() {}
        Person(char* n, int w, double h) :
            weight(w), height(h)		// (c)
            { strcpy(name, n); }

        Person(const Person& p);
        Person& operator=(Person& p);



        void change(char* n, int w, double h);
        friend ostream& operator<<(ostream& out, Person& me);
        friend istream& operator>>(istream& in, Person& me);
};
#include <iostream>
#include <cstring>
using namespace std;

class Person
{
    private:
        char*   name = nullptr;			     // (b)
        int     weight = 0;
        double  height = 0;
 
   public:
        Person() {}
        Person(char* n, int w, double h) :
            name(strcpy(new char[strlen(n)+1], n)),  // (d)
            weight(w), height(h) {}

        Person(const Person& p);
        Person& operator=(Person& p);

        ~Person() { if (name != nullptr) delete name; }

        void change(char* n, int w, double h);
        friend ostream& operator<<(ostream& out, Person& me);
        friend istream& operator>>(istream& in, Person& me);
};
Person class specification. The specification of the name attribute, (a) and (b), is the driving difference between the two versions.
  1. Creates a character array and makes it an empty C-string by inserting a null at name[0] (see Null vs. empty C-strings (b)).
  2. Creates a character pointer called name and sets it to nullptr, indicating that it does not yet point to allocated memory (see Null vs. empty C-strings (a)).
  3. The first line of the highlighted syntax is the initializer list (the ":" is on the preceding line). The second line is the function's body, surrounded by braces. The strcpy function copies the parameter n to the member variable name.
  4. The highlighted code is the general constructor's initializer list with the empty body at its end. The second line initializes the weight and height members using the standard initializer list notation described previously. The first line is more complex and most easily explained from the center outward. For clarity and brevity, ellipses replace the previously explained operations in subsequent steps. The example marks widely separated parentheses to help match them.
    • The sub-expression strlen(n)+1 is the size of the array the class allocates on the heap to store the person's name. n is the constructor parameter with the name, the strlen function counts and returns the number of characters in n and increases the size by 1, allowing for the null termination character.
    • Working outward, the next sub-expression, new char[...], allocates memory on the heap to store the name; new returns the address of the allocated memory.
    • The C-string function strcpy(..., n) copies n to the allocated memory. Recall that the function's first argument is a pointer and that it returns a pointer.
    • Finally, name(...) forms the first element of the initializer list, initializing the name member variable.

Sometimes, it's difficult to distinguish which copy operation a C++ statement represents. For example, the statement Person p2 = p1; looks like an invocation of the assignment operator. However, it creates a new object, p2, by calling the copy constructor. To clarify the examples, the copy constructors and assignment operators announce when they run, a practice not included in "real" functions.

Person1: Array VersionPerson2: Pointer Version
Person::Person(const Person& p)
{
    cout << "Copy Constructor" << endl;
    memcpy(this, &p, sizeof(Person));
    /*strcpy(name, p.name);
    weight = p.weight;
    height = p.height;*/
}
 
Person::Person(const Person& p)
{
    cout << "Copy Constructor" << endl;
    memcpy(this, &p, sizeof(Person));
    name = new char[strlen(p.name)+1];
    strcpy(name, p.name);
    //weight = p.weight;
    //height = p.height;
}
(a)(b)
Person copy constructor. Copy constructors may perform a member-by-member copy (blue) with single-element copy operations (e.g., the strcpy function and the non-overloaded assignment operator). Alternatively, they can copy an object en masse with one memcpy function call (pink).
  1. Copying "simple" objects en masse requires a single function call, making the size of the copy constructor independent of the number of member variables in the class. A member-by-member copy requires a copy operation for each class member variable.
  2. Copying "complex" objects is challenging, regardless of which copying technique the function uses. The constructor must allocate and copy the data for each pointer member (green). Depending on the member type, one statement may accomplish both tasks1, but two are typically required. The statement order is significant when performing an en masse copy: the memcpy call (pink) must precede the pointer copies (green). Can you explain why?2
Person1: Array VersionPerson2: Pointer Version
Person& Person::operator=(Person& p)
{
    cout << "Assignment Operator" << endl;
    if (this == &p)
        return *this;

    memcpy(this, &p, sizeof(Person));
    /*strcpy(name, p.name);
    weight = p.weight;
    height = p.height;*/

    return *this;
}
 
Person& Person::operator=(Person& p)
{
    cout << "Assignment Operator" << endl;
    if (this == &p)
        return *this;

    memcpy(this, &p, sizeof(Person));
    name = new char[strlen(p.name)+1];
    strcpy(name, p.name);
    //weight = p.weight;
    //height = p.height;

    return *this;
}
Person assignment operator. The overloaded assignment operator and the copy constructor share the primary task of copying data from one object to another. Given this overlap in purpose, it isn't surprising that they also share a significant amount of code. The central part of each function, highlighted with yellow, is identical to the corresponding copy constructor, making the elaborations in the previous figure relevant.

The assignment operator has two tasks not shared with the copy constructor. First, it must check for self-assignment, for example, p = p, skipping the copy if it is detected (blue). This test improves the function's efficiency by avoiding needless copy operations and protects the memcpy function (if used), which doesn't handle overlapping memory. Second, the function must return a reference to the operator's left-hand operand (pink), allowing programs to form operator chains: p3 = p2 = p1. Member functions implementing overloaded operators refer to the left-hand operand with the this pointer, but the function's return type is not a pointer. Therefore, the return statements dereference this, forming a return-by-reference.

Person1: Array VersionPerson2: Pointer Version
istream& operator>>(istream& in, Person& me)
{
    in.getline(me.name, 100);
    in >> me.weight;
    in >> me.height;
    return in;
}
 
 
 
 
 
istream& operator>>(istream& in, Person& me)
{
    if (me.name != nullptr);			// (i)
        delete me.name;
    char name[100];				// (ii)
    in.getline(name, 100);			// (iii)
    me.name = new char[strlen(name)+1];		// (iv)
    strcpy(me.name, name);			// (v)
    in >> me.weight;
    in >> me.height;
    return in;
}
(a)(b)
Person extractor. Although this section focuses on copying objects with the copy constructor and assignment operator, it includes other frequently needed functions for completeness. Whether overloaded for "simple" or "complex" classes, the I/O operators continue to follow the basic patterns outlined previously.
  1. The extractor operator overloaded for a "simple" class.
  2. Implementing the person's name as a pointer makes the Person class "complex." Making name a variable-length C-string efficiently utilizes memory but at the expense of additional, complicated operations (blue).
    1. The lengths of the new and existing names may be different, so the most efficient implementation checks for and discards an existing name.
    2. A temporary character array long enough to hold an unusually long name.
    3. Reads a name from the input stream, saving it in the temporary array.
    4. Allocates space - the number of characters in the temporary array plus one for the null terminator - on the heap for the object's new name.
    5. Copies the name from the temporary array to the object's name member variable.

Common Functions

void Person::change(char* n, int w, double h)
{
    strcpy(name, n);
    weight = w;
    height = h;
}
 
ostream& operator<<(ostream& out, Person& me)
{
    out << me.name << ", ";
    out << me.weight << ", ";
    out << me.height;
    return out;
}
(a)(b)
Support functions. Two functions are identical in both versions of the Person example.
  1. The change function changes the values saved in a Person object, demonstrating that after a proper copy operation, a program can change one object without affecting the other.
  2. The extractor function operates the same with both C-string implementations.
int main()
{
    char n1[] = "Dilbert";							// (a)
    Person p1(n1, 150, 60);
    Person p2(p1);
    cout << p2 << endl;

    cout << "Changing p2\n";							// (b)
    char n2[] = "Wally";
    p2.change(n2, 160, 55);
    cout << p1 << endl;
    cout << p2 << endl;

    Person p3;									// (c)
    p3 = p1;
    cout << p3 << endl;

    cout << "Changing p3\n";							// (d)
    char n3[] = "Asok";
    p3.change(n3, 140, 65);
    cout << p1 << endl;
    cout << p3 << endl;

    cout << "Extractor Test: name<return> weight<return> height<return>\n";	// (e)
    cin >> p3;
    cout << p3;

    return 0;
}
main. Surprisingly, the driver code does not depend on the implementation of the Person class, demonstrating a significant object-oriented design principle. A stable, well-designed class should "hide" its implementation. Its public interface should not change even when its internal, private features do, allowing existing client programs to continue working without modification.
  1. Creates an object, copies it with the copy constructor, and prints both to the console, demonstrating the copy.
  2. Changes the second object created in (a) and prints both to the console, demonstrating the objects' independence.
  3. Creates a third object, copies the fist to it with the assignment operator, and prints both to the console, demonstrating the copy.
  4. Changes the third object created in (c), prints the original and the copy, demonstrating their independence.
  5. Reads new information into an existing Person object and prints the object to the console, demonstrating the input operation.

Downloadable Code

ImplementationViewDownload
Array Person1.cpp Person1.cpp
Pointer Person2.cpp Person2.cpp

  1. C includes a function named strdup that duplicates a C-string by allocating heap memory and copying one C-string to another. Some C++ compilers have adopted the function, but the ANSI C++ standard doesn't require it. With the function, we can replace the two-statement copy operation illustrated in Figure 2(b) as follows:
    //name = new char[strlen(p.name)+1];
    //strcpy(name, p.name);
    name = strdup(p.name);
    I've found two common C++ compilers that recognize the strdup function, but it may not be compatible with all C++ compilers, making programs using it non-portable. Furthermore, it allocates memory on the heap, so programmers must remember to delete the duplicated C-string when appropriate to avoid a memory leak.
  2. Use questions like this one to self-check your understanding. Answering or trying to answer them underscores the concepts you understand and identifies those you must review, ultimately deepening your understanding and making the solution more useful and memorable.

    The memcpy function copies all of an object's data, including the addresses saved in pointers, to another object. If the memcpy function call follows the name = new... statement, it overwrites the address just saved in name, introducing a memory leak and failing to copy the original object fully.