Part C - Encapsulation

Member Operators

Overload operators to form expressions involving objects of a class
Describe the syntax for overloading operators that are members of a class
Describe casting and conversion operations on instances of a class

"Programmers hate surprises: Overload operators only for good reason, and preserve natural semantics; if that's difficult, you might be misusing operator overloading" (Sutter, Alexandrescu, 2005)

Operations | Binary | Unary | Conversions | Casts | Temporaries | Summary | Exercise


An important feature of object-oriented programs is support for expressions composed of objects.  An expression consists of an operator and a set of operands.  The expression evaluates to a value of specific type.  In languages like C++, all operators are built-in.  The core language defines the logic for the operands of fundamental type.  To support expressions with operands of class type, we need to overload the built-in operators for those operands.  Overloading an operator entails declaring a corresponding function in the class definition and defining its logic in the implementation file. 

This chapter lists the C++ operators that we may overload and describes the syntax for overloading operators using member functions.  These functions cover unary and binary operations on the current object.  This chapter also describes how to define casting operations and how to use temporary object effectively.


Operations

In the C++ language, the keyword operator identifies an overloaded operation.  We follow the keyword by the operator's symbol.  The signature of a member function that overloads an operator consists of the keyword, the symbol and the type of the right operand, if any, within parentheses.  The left operand of any member operator is the current object.

For example, an overloaded assignment operator for a Student right operand takes the form

     Student& operator=(const Student&); 

Candidates for Overloading

C++ lets us overload the following operators (amongst others): 

  • binary arithmetic (+ - * / %)
  • assignment - simple and compound (= += -= *= /= %=)
  • unary - pre-fix post-fix plus minus (++ -- + -)
  • relational (== < > <= >= !=)
  • logical (&& || !)
  • insertion, extraction (<< >>)

C++ DOES NOT ALLOW overloading of the following operators (amongst others):

  • the scope resolution operator (::)
  • the member selection operator (.)
  • the member selection through pointer to member operator (.*)
  • the conditional operator (?:)

C++ DOES NOT let us introduce or define new operators. 

Classifying Operators

We classify operators by the number of operands that they take:

  • unary - one operand - post-fix increment/decrement, pre-fix increment/decrement, pre-fix plus, pre-fix minus
  • binary - two operand - assignment, compound assignment, arithmetic, relational, logical
  • ternary - three operands - conditional operator

Members and Helpers

We overload operators in either of two ways, as:

  • member operators - part of the class definition with direct access to the class representation
  • helper operators - supporting the class, without direct access to its representation

We prefer to declare operators that change the state of their left operand as member operators.  Helper operators are described separately in the chapter entitled Helper Functions.

Overloading a Member Operator

Signature

The signature of an overloaded member operator consists of:

  • the operator keyword
  • the operation symbol
  • the type of its right operand, if any
  • the const status of the operation

The compiler binds an expression to the member function with the signature that matches the operator symbol, the operand type and the const status. 

Promotion or Narrowing of Arguments

If the compiler cannot find an exact match to an operation's signature, the compiler will attempt a rather complicated selection process to find an optimal fit, promoting or narrowing the operand value into a related type if necessary. 

Type of the Evaluated Expression

The return type of the member function declaration identifies the type of the evaluated expression.

Good Design Practice

Programmers expect an operator to perform its operation in a way similar if not identical to the way that the operator performs its operation on any fundamental type as defined by the core language.  For instance, + implies addition of two values in a binary operation (not subtraction).  In defining a member operator we code its logic to be consistent with operations on other types.


Binary Operators

A binary operation consists of one operator and two operands.  In a binary member operator, the left operand is the current object and the member function takes one explicit parameter: the right operand. 

The declaration of a binary member operator takes the form

 return_type operator symbol (type [identifier])

return_type is the type of the evaluated expression.  operator identifies the function as an operation.  symbol specifies the kind of operation.  type is the type of the right operand.  identifier is the right operand's name. 

Example

Let us overload the += operator for a float as the right operand, in order to add a single grade to a Student object:

 // Overloading Operators
 // operators.cpp

 #include <iostream>
 using namespace std;
 const int NG = 20;

 class Student {
     int no;
     float grade[NG];
     int ng;
     void set(int, const float*, int);
 public:
     Student();
     Student(int, const float*, int);
     void display() const;
     Student& operator+=(float g);
 };

 Student::Student() {
     no = 0;
     ng = 0;
 }

 Student::Student(int sn, const float* g, int ng_) {
     set(sn, g, ng_);
 }

 void Student::set(int sn, const float* g, int ng_) { 
     bool valid = sn > 0 && g != nullptr && ng_ >= 0;
     if (valid)
         for (int i = 0; i < ng_ && valid; i++)
             valid = g[i] >= 0.0f && g[i] <= 100.0f;

     if (valid) {
         // accept the client's data
         no = sn;
         ng = ng_ < NG ? ng_ : NG;
         for (int i = 0; i < ng; i++)
             grade[i] = g[i];
     } else {
         no = 0;
         ng = 0;
     }
 }

 void Student::display() const {
     if (no > 0) {
         cout << no << ":\n";
         cout.setf(ios::fixed);
         cout.precision(2);
         for (int i = 0; i < ng; i++) { 
             cout.width(6);
             cout << grade[i] << endl;
         }
         cout.unsetf(ios::fixed);
         cout.precision(6);
     } else {
         cout << "no data available" << endl; 
     }
 }

 Student& Student::operator+=(float g) {
     if (no != 0 && ng < NG && g >= 0.f && g <= 100.f)
         grade[ng++] = g;
     return *this;
 }

 int main () {
     float gh[] = {89.4f, 67.8f, 45.5f};
     Student harry(1234, gh, 3);
     harry.display();
     harry += 78.23f;
     harry.display();
 }


































































 1234:
  89.40
  67.80
  45.50
 1234:
  89.40
  67.80
  45.50
  78.23 

Unary Operators

A unary operation consists of one operator and one operand.  The left operand of a unary member operator is the current object.  The operator does not take any explicit parameters (with one exception - see post-fix operators below). 

The header for a unary member operator takes the form

 return_type operator symbol()

return_type is the type of the evaluated expression.  operator identifies an operation.  symbol identifies the kind of operation. 

Pre-Fix Operators

We overload the pre-fix increment/decrement operators to increment/decrement the current object and return a reference to its updated value.  The header for a pre-fix operator takes the form

 Type& operator++()  or  Type& operator--()

Example

Let us overload the pre-fix increment operator for our Student class so that a pre-fix expression increases all of the Student's grades by one mark, if possible:

 // Pre-Fix Operators
 // preFixOps.cpp

 #include <iostream>
 using namespace std;
 const int NG = 20;

 class Student {
     int no;
     float grade[NG];
     int ng;
     void set(int, const float*, int);
 public:
     Student();
     Student(int, const float*, int);
     void display() const;
     Student& operator++();
 };

 Student::Student() {
     no = 0;
     ng = 0;
 }

 Student::Student(int sn, const float* g, int ng_) {
     set(sn, g, ng_);
 }

 void Student::set(int sn, const float* g, int ng_) { 
     bool valid = sn > 0 && g != nullptr && ng_ >= 0;
     if (valid)
         for (int i = 0; i < ng_ && valid; i++)
             valid = g[i] >= 0.0f && g[i] <= 100.0f;

     if (valid) {
         // accept the client's data
         no = sn;
         ng = ng_ < NG ? ng_ : NG;
         for (int i = 0; i < ng; i++)
             grade[i] = g[i];
     } else {
         no = 0;
         ng = 0;
     }
 }

 void Student::display() const {
     if (no > 0) {
         cout << no << ":\n";
         cout.setf(ios::fixed);
         cout.precision(2);
         for (int i = 0; i < ng; i++) { 
             cout.width(6);
             cout << grade[i] << endl;
         }
         cout.unsetf(ios::fixed);
         cout.precision(6);
     } else {
         cout << "no data available" << endl; 
     }
 }

 Student& Student::operator++() {
     for (int i = 0; i < ng; i++)
         if (grade[i] < 99.0f) grade[i] += 1.f;
     return *this;
 }

 int main () {
     float gh[] = {89.4f, 67.8f, 45.5f};
     Student harry(1234, gh, 3), backup;
     harry.display();
     backup = ++harry;
     harry.display();
     backup.display();
 }
































































 1234:
  89.40
  67.80
  45.50
 1234:
  90.40
  68.80
  46.50 
 1234:
  90.40
  68.80
  46.50 

Post-Fix Operators

We overload the post-fix operators to increment/decrement the current object after returning its value.  The header for a post-fix operator takes the form

 return_type operator++(int)  or  Type operator--(int)

The int type in the header distinguishes the post-fix operators from their pre-fix counterparts.

Example

Let us overload the incrementing post-fix operator for our Student class so that a post-fix expression increases all of the Student's grades by one mark, if possible:

 // Post-Fix Operators
 // postFixOps.cpp

 #include <iostream>
 using namespace std;
 const int NG = 20;

 class Student {
     int no;
     float grade[NG];
     int ng;
     void set(int, const float*, int);
 public:
     Student();
     Student(int, const float*, int);
     void display() const;
     Student& operator++();
     Student operator++(int);
 };

 Student::Student() {
     no = 0;
     ng = 0;
 }

 Student::Student(int sn, const float* g, int ng_) {
     set(sn, g, ng_);
 }

 void Student::set(int sn, const float* g, int ng_) { 
     bool valid = sn > 0 && g != nullptr && ng_ >= 0;
     if (valid)
         for (int i = 0; i < ng_ && valid; i++)
             valid = g[i] >= 0.0f && g[i] <= 100.0f;

     if (valid) {
         // accept the client's data
         no = sn;
         ng = ng_ < NG ? ng_ : NG;
         for (int i = 0; i < ng; i++)
             grade[i] = g[i];
     } else {
         no = 0;
         ng = 0;
     }
 }

 void Student::display() const {
     if (no > 0) {
         cout << no << ":\n";
         cout.setf(ios::fixed);
         cout.precision(2);
         for (int i = 0; i < ng; i++) { 
             cout.width(6);
             cout << grade[i] << endl;
         }
         cout.unsetf(ios::fixed);
         cout.precision(6);
     } else {
         cout << "no data available" << endl; 
     }
 }

 Student& Student::operator++() {
     for (int i = 0; i < ng; i++)
         if (grade[i] < 99.0f) grade[i] += 1.f;
     return *this;
 }

 Student Student::operator++(int) {
     Student s = *this;  // save the original
     ++(*this);          // call the pre-fix operator
     return s;           // return the original
 }

 int main () {
     float gh[] = {89.4f, 67.8f, 45.5f};
     Student harry(1234, gh, 3), backup;
     harry.display();
     backup = harry++;
     harry.display();
     backup.display();
 }







































































 1234:
  89.40
  67.80
  45.50
 1234:
  90.40
  68.80
  46.50 
 1234:
  89.40
  67.80
  45.50

We avoid duplicating logic by calling the pre-fix operator from the post-fix operator. 

Return Types

The return types of the pre-fix and post-fix operators differ.  The post-fix operator returns a copy of the current object as it was before any changes took effect.  The pre-fix operator returns a reference to the current object, which accesses the data after the changes have taken effect.


Type Conversion Operators

Type conversion operators define implicit conversions to different types, including fundamental types. 

For the following code to compile, the compiler needs information on how to convert a Student object to a bool value:

 Student harry;

 if (harry)
     harry.display();

bool operator

Let us define a conversion operator that returns true if the Student object has valid data and false if the object is in a safe empty state. 

We add the following declaration to the class definition:

 const int NG = 20;

 class Student {
     int no;
     float grade[NG];
     int ng;
     void set(int, const float*, int); 
 public:
     Student();
     Student(int, const float*, int);
     void display() const;
     operator bool() const;
 };

We define the conversion operator in the implementation file

 #include "Student.h"

 // ...

 Student::operator bool() const { return no != 0; } 

Good Design Tip

Conversion operators easily lead to ambiguities.  Good design uses them quite sparingly and keeps their implementations trivial. 


Cast Operator

C++ defines the casting operation for a class type in terms of a single-argument constructor.  This overloaded constructor defines the rule for casting a value of its parameter type to the class type, as well as constructing an object from an argument of the parameter type. 

The following program demonstrates both uses of a single-argument constructor on an int argument:

 // Casting
 // casting.cpp

 #include <iostream>
 using namespace std;
 const int NG = 20;

 class Student {
     int no;
     float grade[NG];
     int ng;
     void set(int, const float*, int);
 public:
     Student();
     Student(int);
     Student(int, const float*, int);
     void display() const;
 };

 Student::Student() {
     no = 0;
     ng = 0;
 }

 Student::Student(int sn) {
     float g[] = {0.0f};
     set(sn, g, 0);
 }

 Student::Student(int sn, const float* g, int ng_) {
     set(sn, g, ng_);
 }

 void Student::set(int sn, const float* g, int ng_) { 
     bool valid = sn > 0 && g != nullptr && ng_ >= 0;
     if (valid)
         for (int i = 0; i < ng_ && valid; i++)
             valid = g[i] >= 0.0f && g[i] <= 100.0f;

     if (valid) {
         // accept the client's data
         no = sn;
         ng = ng_ < NG ? ng_ : NG;
         for (int i = 0; i < ng; i++)
             grade[i] = g[i];
     } else {
         no = 0;
         ng = 0;
     }
 }

 void Student::display() const {
     if (no > 0) {
         cout << no << ":\n";
         cout.setf(ios::fixed);
         cout.precision(2);
         for (int i = 0; i < ng; i++) { 
             cout.width(6);
             cout << grade[i] << endl;
         }
         cout.unsetf(ios::fixed);
         cout.precision(6);
     } else {
         cout << "no data available" << endl; 
     }
 }

 int main () {
     Student harry(975), nancy;

     harry.display();
     nancy = (Student)428;
     nancy.display();
 }






































































 975: 

 428: 

The first use converts 975 to the Student object harry.  The second use casts 428 to a Student object containing the number 428.  Both objects hold empty grade lists. 

Promotion

For the same result as the above cast, we may omit the cast operator and defer to the compiler promoting the int value 428 to a Student object before assigning the object to nancy:

 int main () {
     Student harry(975), nancy;

     harry.display();
     cout  << endl;
     nancy = 428; // promotes an int to a Student 
     nancy.display();
     cout  << endl;
 }



 975 


 428 


The compiler inserts code that creates a temporary Student object using the single-argument constructor.  The constructor receives the value 428 and initializes no to 428 and ng to 0.  Then, the assignment operator copies the temporary object to nancy.  Finally, the compiler inserts code that destroys the temporary object removing it from memory. 

Explicit

Declaring several single-argument constructors raise the possibility of potential ambiguities in automatic conversions form one type to another.  Limiting the number of single-argument constructors in a class definition helps avoid such potential ambiguities.

To prohibit the compiler from using a single-argument constructor for any implicit conversion, we declare that constructor explicit:

 class Student {
     int no;
     char grade[M+1];
     void set(int, const float*, int); 
 public:
     Student();
     explicit Student(int);
     Student(int, const float*, int);
     void display() const;
 };

With such a declaration, the second invocation in the example at the start of the section above (nancy = 428) would generate a compiler error. 


Temporary Objects

C++ compilers create temporary objects in a variety of situations.  A temporary object has no name and is destroyed as the last step in evaluating the expression that contains its creation point. 

Consider the assignment expression below:

 int main () {
     Student harry(975), nancy;

     harry.display();
     nancy = Student(428); // temporary Student object 
     nancy.display();
 }



 975: 

 428: 

Localizing Constructor Logic

We can use temporary objects to access validation logic localized within one constructor.  Note the temporary object assignments to the current object (*this) in the one-argument and three-argument constructors below:

 // Localized Validation
 // localize.cpp

 #include <iostream>
 using namespace std;
 const int NG = 20;

 class Student {
     int no;
     float grade[NG];
     int ng;
 public:
     Student();
     Student(int);
     Student(int, const float*, int);
     void display() const;
 };

 Student::Student() {
     // safe empty state
     no = 0;
     ng = 0;
 }

 Student::Student(int sn) {
     float g[] = {0.0f};
     *this = Student(sn, g, 0);
 }

 Student::Student(int sn, const float* g, int ng_) { 
     bool valid = sn > 0 && g != nullptr && ng_ >= 0;
     if (valid)
         for (int i = 0; i < ng_ && valid; i++)
             valid = g[i] >= 0.0f && g[i] <= 100.0f;

     if (valid) {
         // accept the client's data
         no = sn;
         ng = ng_ < NG ? ng_ : NG;
         for (int i = 0; i < ng; i++)
             grade[i] = g[i];
     } else {
         *this = Student();
     }
 }

 void Student::display() const {
     if (no > 0) {
         cout << no << ":\n";
         cout.setf(ios::fixed);
         cout.precision(2);
         for (int i = 0; i < ng; i++) { 
             cout.width(6);
             cout << grade[i] << endl;
         }
         cout.unsetf(ios::fixed);
         cout.precision(6);
     } else {
         cout << "no data available" << endl; 
     }
 }

 int main () {
     float gh[] = {89.4f, 67.8f, 45.5f};
     Student harry(1234, gh, 3), josee(1235), empty;
     harry.display();
     josee.display();
     empty.display();
 }






























































 1234:
  89.40
  67.80
  45.50
 1235:
 no data available 

The three-argument constructor validates all data received from client code.  If the validation fails, this constructor creates a temporary object in a safe empty state and assigns that temporary object to the current object.  Note that the single-argument constructor uses the temporary object created by the three-argument constructor to initialize the current object. 

Good Design Tip

Using temporary objects to avoid repeated logic is good programming practice.  If we update the logic later, there is no chance that we will update the logic in one part of the source code and neglect to update identical logic in another part of the code. 


Summary

  • C++ allows overloading of most of the operators for operands of class type
  • we cannot define new operators or redefine operations on the fundamental types
  • the keyword operator followed by a symbol identifies an operation
  • the left operand in an overloaded member operator is the current object
  • we use member operators to overload operations that modify the left operand
  • the int keyword in the signature for increment/decrement operator identifies the post-fix operation distinguishing it from the pre-fix operation
  • we use temporary objects to localize logic, which improves maintainability

Exercise



Previous Reading  Previous: The Current Object Next: Classes and Resources   Next Reading


  Designed by Chris Szalwinski   Copying From This Site   
Logo
Creative Commons License