CIS Logo SVC Logo

   Computing & Information Systems
   Department

 

Schoology Facebook        Search CIS Site      Tutorials

Software Design Using C++



More Complex Object-Oriented Features

Look at the following files. They rewrite our Product class, used in the Objects and Classes section of these Web pages, to include a number of more advanced object-oriented features and to use string objects instead of the old character array strings (C-style strings). You can see the old version of this presentation on more complex object-oriented features, if you wish. Note that C++ compilers are making it more difficult to use the old C-style strings, and especially functions like strcat and strcpy that can lead to significant security risks, primarily buffer overflow attacks.

Constructors

Let's start by looking at the so-called constructors. In the header file you will see that there are two constructor functions. The default constructor, mentioned first, has the following declaration:


Product(string InitialName = "", int InitialYear = 0, double InitialPrice = 0.0);

Notice that we supply simple initial values for the 3 parameters of the object being constructed. Here is the code for that constructor:


/* Given:  InitialName   A string containing the product name.
           InitialYear   The year this product was made.
           InitialPrice  The product's price.
   Task:   This is the default constructor.  It creates a new Product object
           containing the 3 items listed above.
   Return: Nothing is directly returned, but the object is created.
*/
Product::Product(string InitialName, int InitialYear, double InitialPrice)
{
    Name = InitialName;
    Year = InitialYear;
    Price = InitialPrice;
}

Next, we look at the copy constructor, which has the following declaration. Note that neither constructor has a return value, not even void. Also recall that the name of any constructor has to match the name of the class, Product in this case. The two constructors that we have here can be distinguished by their different parameter lists. Our default constructor takes 3 parameters, but the copy constructor has only one parameter.


Product(const Product & OldProduct);

The copy constructor takes an existing Product object and creates a new object with a copy of the data from the existing one. In essence it let's you easily "clone" an object. Here is the code for this function:


/* Given:  OldProduct  An existing Product object.
   Task:   This is a copy constructor.  It creates a new Product object
           containing a copy of the data from OldProduct.
   Return: Nothing is directly returned, but the implicit object is created.
*/
Product::Product(const Product & OldProduct)
{
    Name = OldProduct.Name;
    Year = OldProduct.Year;
    Price = OldProduct.Price;
}

Because we are using string objects, we can copy a string into a string field such as Name with a simple assignment statement. (That would not work with C-style, character array strings.) Recall that when we give the field names with no object name in front (as in Name, Year, and Price) these refer to fields of the implicit object, the object that we are using this function on. In this case, that means the object that we are creating with this constructor.

Now look at the test program to see how the copy constructor is called. There are two equivalent ways of calling it:


Product ProductB(ProductA);   // one way to call the copy constructor
Product ProductC = ProductA;  // another way to call the copy constructor

The first one creates a new object ProductB that is a clone of ProductA. The second creates ProductC as another clone of ProductA.

Overloaded Operator

In the header file you will see that there is a boolean operator function with the following prototype. It is listed as a public function and can thus be applied to any Product object whether used in an application program, in the code for another class's functions, etc.


bool operator<(const Product & RHS) const;

Operators are seen as a special type of function that uses a function name consisting of operator followed by the operator symbol(s). There are some operator symbols that cannot be overloaded to work on your own objects. See a good reference book for details. In this case we are trying to set up an overloaded less than operator that compares two Product objects. You might wonder where the two objects are. One is the implicit object, and the other is the RHS parameter (short for right-hand side). Note that the parameter is a constant reference parameter for efficiency reasons: We do not want a call to this function to have to copy the entire object that is used as the parameter. The const at the end of the above line indicates that the function itself is a constant function. In other words, it does not change the object to which it is applied (the "left-hand side" object, if you will).

Using the overloaded boolean operator is easy. If you look at the test program, you will see the following call. It is as easy as testing something like 2 < 3. In this case, ProductA matches up with the implicit object; it is the left-hand side of the comparison. ProductD matches up with the RHS parameter; it is the right-hand side of the comparison.


if (ProductA < ProductD)
   cout << endl << "ProductA is less than ProductD in price" << endl;
else
   cout << endl << "ProductA is NOT less than ProductD in price" << endl;

The following is the complete code for this overloaded operator. It is pretty easy to follow. The hard part is getting used to the notation in the function prototype and figuring out which object is the left-hand side and which is the right-hand side.


/* Given:  RHS   A Product object (the right-hand side of the < ).
   Task:   To compare the price of the implicit Product object to that
           of the RHS.
   Return: In the function name return true if the implicit object is
           less than RHS in price, false otherwise.
*/
bool Product::operator<(const Product & RHS) const
   {
   if (Price < RHS.Price)
      return true;
   else
      return false;
   }

Friend Function

There is another way to create an overloaded operator. In general an overloaded operator is a standard operator (like +, =, <) that is extended to allow it to be used on objects (including user-defined objects) with which the operator otherwise could not be used. The usual way to set up such an operator is as a method of the class like we did above. However, it is also possible to create the operator as a friend function. A friend function does not belong to the class, but the class does contain a notice like the following to tell the class to give the friend function direct access to any of its private fields. Many people say that this defeats the purpose of information hiding and so avoid using friend functions. Nevertheless, we will take a look at how an operator can be set up as a friend function.


friend bool operator==(const Product & LHS, const Product & RHS);

For a boolean operator created as a friend function, the two objects that it applies to are passed as parameters. Thus you see the parameters LHS and RHS above. (These are short for left-hand side and right-hand side, of course.) The operator is used in the obvious way. Here is a call of this operator taken from our test program:


if (ProductA == ProductB)
   cout << "ProductA and ProductB have the same price" << endl << endl;
else
   cout << "ProductA and ProductB do NOT have the same price" << endl << endl;

The code for this operator is shown below. Note that as a non-class function it has no implicit object. When comparing the two Price fields you must specify which object is meant in each case.


/* Given:  LHS   A Product object (the left-hand side of the == ).
           RHS   A Product object (the right-hand side of the == ).
   Task:   To compare the prices of the LHS and RHS objects.
   Return: In the function name return true if the LHS object is equal
           to the RHS object in price, false otherwise.
*/
bool operator==(const Product & LHS, const Product & RHS)
   {
   if (LHS.Price == RHS.Price)
      return true;
   else
      return false;
   }

Comparison operators such as == and < can be implemented either as class functions or as friend functions. Because of the principle of information hiding, that it is best not to allow any more access to data than necessary, the class function route is usually the best to use.

Overloaded Stream Operator

As another example of an overloaded operator implemented as a friend function, see the overloaded << stream operator. In the header file you will find the following which lets the Product class know that this operator should be allowed direct access to the private fields of objects of this class.


friend ostream & operator<<(ostream & OutputStream, const Product & Prod);

The following use of this overloaded operator is similar to those in our test program. Note how nicely it lets us output a Product object. The operator is seen as a function with operator concatenated with two less than symbols, all as the function name. There are two parameters to the function: The first is the output stream (cout in the example below), and the second is the Product object to output (ProductC below). After each field of the product has been output, a reference to the stream is returned so that as many items as desired can be chained together in an output statement. For example, after ProductC is output below, it is as if we then did cout << endl at that point. It takes a little getting used to, but once you see the pattern, it is easy.


cout << ProductC << endl;

The code for our overloaded operator is shown below. Note the use of the return to return the modified file stream (modified since we have written to it).


/* Given:  OutputStream  An output file stream.
           Prod          A Product object.
   Task:   To print the contents of Prod onto the OutputStream.
   Return: The modified file stream is returned in the function name.
           This allows one to chain together more than one << operator
           as in:   cout << ProductA << endl << ProductB << endl;
*/
ostream & operator<<(ostream & OutputStream, const Product & Prod)
   {
   OutputStream << "Product name: " << Prod.Name << endl
      << "Year made: " << Prod.Year << endl
      << "Price: " << Prod.Price << endl << endl;

   return OutputStream;
   }

Compare the above operator << with the following ordinary Print function. The code inside is nearly identical. One difference is that this Print function only prints to cout, the screen, whereas the overloaded operator << can print to any reasonable output stream, including cout. Another difference is that << can be used many times in one command in order to output several Product objects.


void Product::Print(void) const
{
   cout << "Product name: " << Prod.Name << endl
      << "Year made: " << Prod.Year << endl
      << "Price: " << Prod.Price << endl << endl;
}

The test program shows an example of using our overloaded stream operator << to output a Product object to a text file. It works just as well as using the << operator with cout.


// Let's try writing to a text file:
OutFile.open("try3.txt", ios::out);
if (OutFile.fail())
   {
   cerr << "Could not create file try3.txt" << endl;
   exit(1);
   }

OutFile << "This is ProductD:" << endl << ProductD;
OutFile.close();

Overloaded Assignment Operator

Finally, let's look at the overloaded = (assignment) operator found in our Product class. It has the following prototype in the header file. Note that the right-hand side of the assignment is seen as the parameter to the operator= function. The function returns a reference to a Product object so that assignment statements can be chained together.


Product & operator=(const Product & RHS);

The complete code for this overloaded assignment operator is given below. Note that the RHS parameter gets copied into the implicit object, the one that the operator= function is applied to.


/* Given:  RHS   An existing Product object.
   Task:   This is an overloaded assignment operator.  It copies all of
           the data from the RHS object into the implicit object.
   Return: A reference to the implicit object is returned in the function
           name so as to allow one to chain assignments together as
           in:  ProductA = ProductB = ProductC;
           The implicit object is modified.
*/
Product & Product::operator=(const Product & RHS)
   {
   Name = RHS.Name;
   Year = RHS.Year;
   Price = RHS.Price;
   return *this;
   }

The one new feature found in the above code is the use of the this pointer. Inside a class function, the this pointer is automatically set up to point to the implicit object, the one the function was applied to. By applying the * operator to the this pointer one gets the implicit object itself. Thus our return statement does indeed return the implicit object (for use in chaining assignments together). (There is a complete section discussing pointers, should you wish to refer to it.)

The test program uses the overloaded assignment operator as shown below. Note that two assignments have been chained together. First, the ProductD object is assigned into ProductC. Second, the return value is assigned into ProductB. Thus, both ProductC and ProductB become copies of the ProductD object. The return value of the leftmost assignment operation is not used; it is simply thrown away.


// Test of the overloaded assignment operator:
ProductB = ProductC = ProductD;

Note the similarity of the overloaded assignment operator to the copy constructor. Both are used to produce copies of an existing object. However, with the copy constructor the copied object is brought into existence, but with the overloaded assignment operator the destination object already existed (though it may have contained different data beforehand).

The Destructor

The destructor class function is more or less the reverse of the constructor function. The constructor is used to create a new object of the class; the destructor is used to destroy it, to wipe it out. The name of the destructor is the class name with a ~ in front of it. For example, the destructor for our Product class is ~Product.

If we did not write a destructor for our Product class (or any other class), it would still have an automatic destructor function that is used when you don't write any code for your own destructor. All that the automatic destructor function does is to wipe out the object. That is, it reclaims the memory space taken up by the object itself. Note that the automatic destructor is like the automatic constructor that is available to build a new object. The automatic constructor allocates memory space for a new object. If any initialization work needs to be done, then you must write the code for your own constructor function.

Usually you will see no explicit call of the destructor function. Rather, it is called automatically when an object variable goes out of scope. For a local variable this occurs when we exit the function. For a global variable (generally a bad thing to use) this happens when the program finishes. For a variable declared inside of a {...} block, this occurs when we reach the closing }. For an array of objects, the destructor gets called for each object in the array when the array goes out of scope.

To see when the destructor is called in our example using the Product class, a simple destructor function was added to the product3.cpp file. This destructor just prints out a message when it is called. (Then the automatic destructor takes over and wipes out the memory space taken up by the object.)


/* Given:  Nothing other than the implicit object.
   Task:   This is the destructor.  It prints a message and the contents of
           the implicit object. Then it reclaims the memory space of this
           implicit object.
   Return: Nothing
*/
Product::~Product(void)
   {
   cout << "The destructor has been called on an object containing:" << endl;
   (*this).Print();
   // The automatic destructor runs at this point, reclaiming the memory
   // space used by the implicit Product object.
   } 

Remember that the this pointer automatically points to the implicit object. Thus the Print is being called on the implicit object, the one being destroyed. This is so that we can see the data right before the object gets wiped out.

Running an Experiment

In order to understand when the destructor wipes out various objects, our test program contains a function named experiment. This function is called from the main function and has a reference parameter and a value parameter, both for Product objects. This function also has a local variable that is a Product object. Here is the code for this experiment function:


/* Given: ReferenceParamProduct - a reference to an existing product in main.
*         ValueParamProduct - a copy of a product object in main.
*  Task:  Create a local variable, ProductLocal, that is a Product object and
*         then simply print the data that is in ReferenceParamProduct,
*         ValueParamProduct, and ProductLocal.
*/
void experiment(Product & ReferenceParamProduct, Product ValueParamProduct)
{
    Product ProductLocal("Local variable used as a test", 2021, 999.99);
    cout << endl << "In function experiment, ReferenceParamProduct prints out as:"
        << endl << ReferenceParamProduct;
    cout << "ValueParamProduct prints out as: " << endl << ValueParamProduct;
    cout << "Local variable ProductLocal prints out as:" << endl << ProductLocal;
    cout << "This is the last line of function experiment" << endl;
}

From the output below, we can tell the following:

  • When the experiment function prints its reference parameter, it prints the data from the object that the reference points to back in the main function.
  • Printing the value parameter prints the data that is in the value parameter. Since value parameters contain a copy of their actual parameters, we print a copy of what the corresponding actual parameter contained back in the main function.
  • Printing the local variable prints exactly the data that was put into it with the constructor used by the experiment function.
  • When we reach the last line of the experiment function, the destructor wipes out the local variable.
  • Also, at this same point, the second use of the destructor wipes out the value parameter (but not the actual parameter back in main that was copied into it).
  • After we return to the main function and reach the end of that function, all 4 objects in main are wiped out by automatic calls of the destructor for each of these. This includes the dishwasher object in main that the experiment function had a reference parameter to.
  • Essentially, objects are wiped out by the destructor when they go out of scope.
  • Let's see how object parameters and local variables behave
    
    In function experiment, ReferenceParamProduct prints out as:
    Product name: dishwasher
    Year made: 2020
    Price: 289.95
    
    ValueParamProduct prints out as:
    Product name: mixer, 5 speed
    Year made: 2019
    Price: 22.95
    
    Local variable ProductLocal prints out as:
    Product name: Local variable used as a test
    Year made: 2021
    Price: 999.99
    
    This is the last line of function experiment
    The destructor has been called on an object containing:
    Product name: Local variable used as a test
    Year made: 2021
    Price: 999.99
    
    The destructor has been called on an object containing:
    Product name: mixer, 5 speed
    Year made: 2019
    Price: 22.95
    
    This is the first line in main after returning from function experiment
    This is the last line before main does its return 0;
    The destructor has been called on an object containing:
    Product name: mixer, 5 speed
    Year made: 2019
    Price: 22.95
    
    The destructor has been called on an object containing:
    Product name: mixer, 5 speed
    Year made: 2019
    Price: 22.95
    
    The destructor has been called on an object containing:
    Product name: mixer, 5 speed
    Year made: 2019
    Price: 22.95
    
    The destructor has been called on an object containing:
    Product name: dishwasher
    Year made: 2020
    Price: 289.95
    

    To keep track of parameters and local variables when a function gets called, such as the experiment function above, a drawing of the run-time stack is quite helpful. Here is a somewhat simplified picture of the run-time stack after the experiment function gets called:

    [picture of the run-time stack]

    Short example data is shown in the picture for the Name, Year, and Price fields. It is not the same data that was used in our test program above. Note that the stack frame for function main contains the data for the 4 objects that main uses, objects ProductA, ProductB, ProductC, and ProductD. Initially, that is all that is on the stack for this program.

    When the experiment function is called, a stack frame for this function is added to the main memory region above the stack frame for the main function. Recall that an ampersand character & is used in front of a formal parameter to indicate that it is a reference parameter. Since we have a reference parameter named ReferenceParamProduct, that parameter contains only a reference, a pointer, to its corresponding actual parameter, ProductA. This is because ProductA is filled in as the first parameter (which is the reference parameter) when main calls the experiment function. Whenever the experiment function refers to any of the 3 fields of ReferenceParamProduct, it just follows the pointer and looks at the fields of ProductA in the main function.

    The second parameter for the experiment function, ValueParamProduct, does contain data. This is because it is not a reference parameter. Rather, it is what is called a value parameter, one that uses no & character. The value (or values) in the actual parameter get copied into the formal parameter. Thus you see in the picture that ValueParamProduct contains copies of all of the data from the actual parameter ProductB in the main function.

    Next in the stack frame for the experiment function is what is called a return address. This is the location the computer is to go to when it is done executing the experiment function. Above that in the stack frame is a Product object named ProductLocal. It is created by the first line of code inside of the experiment function. Since it is created in this function, it is local to this function. As soon as this function ends, this variable's lifetime is over. At that point, control returns to the main program, to the command after the call to function experiment.

    Summary of What is in the Product Class

    The following shows everything that is in our example class Product. Besides the items already discussed above, this class contains both a get function and a set function for each of the 3 fields in any object of this class.

    
    class Product
    {
        friend bool operator==(const Product & LHS, const Product & RHS);
        friend ostream & operator<<(ostream & OutputStream, const Product & Prod);
    private:
        string Name;
        int Year;
        double Price;
    public:
        Product(string InitialName = "", int InitialYear = 0, double InitialPrice = 0.0);
        Product(const Product & OldProduct);
        ~Product(void);
        void Print(void) const;
        string GetName(void) const;
        int GetYear(void) const;
        double GetPrice(void) const;
        void SetName(string NewName);
        void SetPrice(double NewPrice);
        void SetYear(int NewYear);
        bool operator<(const Product & RHS) const;
        Product & operator=(const Product & RHS);
    };
    

    If you wish to glance ahead at related topics, look at the example on Dynamically Allocating Objects in the Pointers section of these Web pages.

    Related Items

    Back to the main page for Software Design Using C++

    Author: Br. David Carlson with contributions by Br. Isidore Minerd
    Last updated: April 03, 2021
    Disclaimer