14.1 Operator overloading

Table of Contents

1. How to overload operators

1. Non-member function overloading

2. Member function overloading

2. Support overloaded operators

3. Overload restrictions

1. There must be a class type parameter

2. No short circuit characteristics

3. Priority and combination

4.Default actual parameters are not supported

4. Overloading principle

1. Do not redefine operators with built-in meanings

2. Use operator overloading with caution

3. Keep definitions consistent

4. Choose member or non-member implementation

5.Use cases

1.Input and output

2. Addition operation

3.Equal and not equal to

4. Assignment operator

5. Subscript operator

6. Dereference and arrow operators

7. Self-increasing and self-decreasing

8. Call operators and function objects


Through operator overloading, user-defined class types can behave the same as built-in types, simplifying use and easy to understand. However, operator overloading also has many traps. The purpose of this section is to introduce how to use and how to avoid traps.

1. How to overload operators

Operator overloading is by defining a special function. The function name starts with operator, followed by the corresponding operator. For example, operator + is the definition of + operator. There are two ways to reconstruct the operator:

  1. Non-member function overloading
  2. Member function overloading

In theory, it is recommended to use member functions to overload operators that will modify data members.

Let’s take a Fruit class as an example. Suppose we define such a class with two data members, the name and weight of the fruit. There is no default constructor, but there is a synthetic copy constructor

#include <string>
#include <iostream>
#include <sstream>

class Fruit {
public:
    Fruit(const std::string & amp;s, unsigned w) : name(s), weight(w) {}
    std::string str() {
        std::ostringstream os;
        os << "name:" << name << ",weight:" << weight ;
        return os.str();
    }
private:
    std::string name;
    unsigned weight;
};
1. Non-member function overloading

We can use non-member functions to overload the operator + operation, the function identifier operator + (Fruit & amp;, Fruit & amp;). In order to access Fruit’s data members, set the function as a friend of Fruit.

Fruit operator + (Fruit & amp; f1, Fruit & amp; f2) {
    Fruit f3(f1);
    f3.weight + = f2.weight;
    return f3;
}

To set a function as a friend of the Fruit class, you need to add a new line to the class definition.

class Fruit {
friend Fruit operator + (Fruit & amp; f1, Fruit & amp; f2);
public:
... // Save space, the class definition given in the subsequent code is consistent
};

After completing these two parts, we can use the + operator on Fruit.

int main() {
    Fruit f1("Apple", 1);
    Fruit f2("Orange", 2);
    Fruit f3 = f1 + f2;
    std::cout << "f1:" << f1.str() << std::endl; // print f1:name:Apple,weight:1
    std::cout << "f2:" << f2.str() << std::endl; // print f2:name:Orange,weight:2
    std::cout << "f3:" << f3.str() << std::endl; // print f3:name:Apple,weight:3
}
2. Member function overloading

When using member function overloading, there is no need to explicitly provide the left operand, and no friends are allowed to be defined. Operator overloading of the same type cannot be defined multiple times because the compiler cannot determine which one to call.

Fruit Fruit::operator + (Fruit & amp; f2) {
    Fruit f3(*this);
    f3.weight + = f2.weight;
    return f3;
}
2. Support overloaded operators

Operators that support overloading are divided into 5 categories, common arithmetic operations, bit operations, logical operations, assignments, and unique calls in C++, including subscripts, calls, arrow operators, new and delete.

Arithmetic operations

+

*

/

%

+ =

-=

*=

/=

%=

Bit operations

&

|

^

~

<<

>>

&=

|=

^=

<<=

>>=

logic operation

>

>=

<

<=

==

!=

& amp; & amp;

||

!

Assignment

=

,

transfer

[]

()

->

->*

new

new []

delete

delete []

There are 4 operators that do not support overloading, namely

  1. Scope operator ::
  2. .*
  3. Call operator.
  4. Conditional operator ?:
3. Overload restriction
1. There must be a class type parameter

For example, redefining the operator + for two ints is not allowed. When defining a non-member built-in function, one parameter must be of class type.

int operator + (int i1, int i2)

For member functions, the first parameter of the member function overload of operator + defaults to the object pointed to by this, so there must be a class type parameter.

2. No short-circuit characteristics

The built-in logical operations, & & & and || all have short-circuit characteristics. The values of the operands are calculated sequentially from left to right. Once the value of the entire expression can be determined, the remaining parts will not be calculated. After overloading an operator, the evaluation order of the operator’s operands is not guaranteed, and the short-circuit characteristic of the logical operator is not guaranteed.

3. Priority and Associativity

Overloaded operators retain the parameter number, precedence, and associativity of the original operator. For example, in the following expression, b + c is always calculated first, and then the calculation result is used as a == operation with a.

a == b + c
4. Default parameters are not supported

Except for calling operator(), all operators are overloaded and do not support default arguments.

4. Overloading principle
1. Do not redefine operators with built-in meanings

Functions such as comma, assignment, address taking, logical AND, logical OR have default meanings. Programmers are well aware of these default meanings. Changing the definitions rashly will seriously violate the user’s intuition.

  1. Comma, calculated from left to right, returns the rightmost value
  2. Assign value, assign value to each member one by one, and call the assignment function of each member in turn.
  3. Get the address, get the memory address of the object
  4. Logical AND/Logical OR, for numeric types, non-0 means true; short-circuit operation
2. Use operator overloading with caution

Most operator overloading is meaningless. Before designing operator overloading, define the interface of the class first. After confirming the interface, determine which operations in the interface are defined as operator overloading. This can simplify the use without being counterintuitive. Only logically compound operator behavior can be achieved. Operations should be considered for overloading.

3. Maintain consistency of definitions

If you define an operator for arithmetic operations or bitwise operations, it is wise to define the corresponding compound assignment operands at the same time. For example, if you define the + operator, you should also define the + = operator, and the behavior of the + = operator , it should be the same as first using the + operator on the left and right operands, and then using the = operator to assign the result to the left operand.

The key of the associated container needs to support the < operator. Many generic algorithms require the type to support the == operation. For example, the generic algorithm sort requires the element to support the < operator, and find requires the element to support the == operator. If a class redefines the == operator, the class should also redefine the != operator. If the class defines the < operator, then it must also define the associated operators <=, >, >=. At the same time, if the two operators a < b are false and b < a are also false, a == b should be made.

4. Choose member or non-member implementation
  1. Operators such as assignment =, call(), subscript [], arrow -> must be implemented by members, otherwise a compilation error will be prompted.
  2. For compound assignment, it is recommended to choose member implementation, but defining it as a non-member implementation will not cause compilation problems.
  3. Operators that change the state of an object, such as auto-increment, auto-decrement, etc., are recommended to be implemented by members.
  4. If it is closely continuous with the given type, such as dereference, it is recommended to choose member implementation.
  5. For symmetric operators, such as arithmetic operations, bitwise operations, relational operations, and equality operators, it is recommended to choose non-member implementations.
5.Use case
1. Input and output

The input and output operators must be defined as non-member functions because the left operand is an iostream and we cannot modify the class definition of iostream.

We define a minimalist Person class

class Person {
friend std::ostream & amp; operator<<(std::ostream & amp; s, Person & amp; p);
friend std::istream & amp; operator>>(std::istream & amp; s, Person & amp; p);
public:
    Person() :name("-"), age(0) {}
    Person(const std::string n, unsigned a) :name(n), age(a) {}
private:
    std::string name;
    unsigned age;
};

Defining overloaded operators through non-member functions

std::ostream & amp; operator<<(std::ostream & amp; s, Person & amp; p) {
    s << p.name << " " << p.age;
    if (!s) {
        p = Person();
    }
    return s;
}

std::istream & amp; operator>>(std::istream & amp; s, Person & amp; p) {
    s >> p.name >> p.age;
    return s;
}

test operator

int main() {
    Person p1("randy", 18);
    std::cout << "p1:" << p1 << std::endl;

    std::istringstream is("henry 8");
    Person p2;
    is >> p2;
    std::cout << "p2:" << p2 << std::endl;
}

After defining the output operator, the output of object status is greatly simplified. The check of iostream status in the case is not rigorous enough, especially when there are input errors, such as the data format read is not what we expected, EOF is read, etc. This type of error can be caused by setting the iostream status, failbit, badbit, eofbit, etc. After the input and output is completed, an application will check the iostream status by itself.

2. Addition operation

Arithmetic Operators term Symmetric operators, in general, choose non-member function implementations. For example, the + operator generally needs to be implemented at the same time as the + = operator. According to the overloading principle learned before, compound assignment operations should be defined as member functions. In order to make the assignment logic of + = and = consistent, we choose to call the + = operator inside the = operation. First provide the definition of the + = operator. Other codes are omitted here, and only the definition of the + = operator is shown.

class Person {
...
    Person & amp; operator + =(Person & amp; p2) {
        name + = p2.name;
        age + = p2.age;
        return *this;
    }
...
};

Then we define the + operator. The + operation does not modify the left and right operands and generates a new result object. Note that the return value here is not a reference.

Person operator + (Person & amp; p1, Person & amp; p2) {
    Person p(p1);
    p + = p2;
    return p;
}
3. Equal to and not equal to

The meaning of the == operator is that two objects are equivalent and have the same data. If operator== is defined, operator!= should also be defined at the same time, and the definitions of the == operator and the != operation should be consistent. One of the functions implements the logic, and the other is only responsible for calling. ==Get the results of comparing all members

bool operator==(Person & amp; p1, Person & amp; p2) {
    return p1.name == p2.name & amp; & amp; p1.age == p2.age;
}

The != operator is implemented by calling ==

bool operator!=(Person & amp; p1, Person & amp; p2) {
    return !(p1 == p2);
}
4. Assignment operator

As mentioned in the chapter 1. Copy Control, if there is no explicitly defined assignment operator, the compiler will automatically synthesize one. The default is to copy each data member in turn, and the pointer type uses a shallow copy. You can overload multiple assignment operators for a class definition, as long as the formal parameters of the functions are different.

According to the principle of overloaded operators, assignment operators are implemented using member functions. For example, we define two assignment functions

Person & amp; Person::operator=(Person & amp; p1) {
    name = p1.name;
    age = p1.age;
    return *this;
}
Person & amp; Person::operator=(std::string & amp; n1) {
    name = n1;
    return *this;
}

Test through the following program. The assignment operator definition must return a reference to *this. If it is a reference type, rvalue cannot be used as an input parameter.

int main() {
    Person p0;
    Person p1("randy", 18);
    std::cout << "p0:" << p0 << std::endl << "p1:" << p1 << std::endl;
    //Output p0:- 0 p1:randy 18

    p0 = p1;
    std::cout << "p0:" << p0 << std::endl << "p1:" << p1 << std::endl;
    //Output p0:randy 18 p1:randy 18

    std::string s = "henry";
    p0 = s;
    std::cout << "p0:" << p0 << std::endl << "p1:" << p1 << std::endl;
    //Output p0:henry 18 p1:randy 18
}
5. Subscript operator

The subscript operator is defined through operator[] and must be implemented using member functions. The difficulty lies in the fact that the subscript operator can be used as a left or right operand. Subscript operands generally define two versions, one returns a non-const reference, and the other returns a const reference.

char & amp; Person::operator[](std::string::size_type idx) {
    if (idx < 0 || idx >= name.size()) {
        throw std::out_of_range("out of range");
    }
    else {
        return name[idx];
    }
}
const char & amp; Person::operator[](std::string::size_type idx) const {
    return operator[](idx);
}

Non-const references can be used as lvalues, and const references can be used as rvalues.

6. Dereference and Arrow Operator

The reference to the object returned by the dereference operation can be const or non-const, and non-const can be used as the left operand. The pointer returned by the arrow operator. Normally, the arrow operation should have two operands. Because C++ does not have a type to represent the member function, the arrow operation only gives one operator. The right operand of the arrow is used by the compiler and is automatically called. The arrow operator defines the member function of the object corresponding to the pointer returned by the function.

class Person {
    friend std::ostream & amp; operator<<(std::ostream & amp; s, Person & amp; p);
public:
    Person() : name(0), age(0) {}
    Person(std::string* n, unsigned a) :name(n), age(a) {}
    std::string & amp; operator*() {
        return *name;
    }
    std::string* operator->() {
        return name;
    }
private:
    std::string* name;
    unsigned age;
};

You can test dereference and arrow operators with the following code

std::string* s = new std::string("randy");
Person p1(s, 18);

std::cout << (*p1)[0] << (*p1)[1] << std::endl; // *p returns a reference to the data member name
std::string::size_type size = p1->size();
        //Call the member function of the object pointed to by the name pointer
std::cout << "string size:" << size << std::endl; 

p1->size() actually calls p1.operator->()->size(), because p1.operator() returns a pointer to the data member name. The -> operator must return a pointer to a class type object, or a class type object that redefines the -> operation.

7. Self-increasing and self-decreasing

The increment/decrement operator modifies the object state and is generally defined as a member function. The increment/decrement operation returns a reference to the current object. The auto-increment/auto-decrement operation has only one operand, and it is impossible to distinguish between pre/post operations. It is distinguished by adding an additional useless int formal parameter. No formal parameter indicates a pre-operation, and a formal parameter indicates a post-operation. Let’s take a look at a simple example to define auto-increment and auto-decrement operations for Person. The pre-increment is for age + 1, and the post-pointer is for age + 2.

class Person {
    friend std::ostream & amp; operator<<(std::ostream & amp; s, Person & amp; p);
public:
    Person() : name(0), age(0) {}
    Person(std::string* n, unsigned a) :name(n), age(a) {}
    Person & operator + + () {
        age + + ;
        return *this;
    }
    Person operator + + (int i) {
        Person copy = Person(*this);
        age + = 2;
        return copy;
    }
private:
    std::string* name;
    unsigned age;
};

What needs to be noted here is that the pre-increment returns a reference to the Person object, because it is the object pointed to by this and is still valid outside the function. Post-increment, because the state before modification is to be saved, a new local object is created, because the local object is invalid outside the function and can no longer return pointers or references.

std::string* s = new std::string("randy");
Person p1(s, 18);
std::cout << (p1 + + ) << std::endl; // Returns 18, the actual value is already 20
std::cout << p1 << std::endl; // 20
std::cout << ( + + p1) << std::endl; // 21
std::cout << p1 << std::endl; // 21
8. Calling operators and function objects

Objects of classes that overload the call operator can be called like functions and are also called function objects.

struct OP {
public:
    int operator() (int i) {
        std::cout << "op_call:" << i << std::endl;
        return i + 100;
    }
};

We can call this function object like this

OP fn = OP();
std::cout << "OP: " << fn(10) << std::endl;

The function object belongs to the type defined by the std::function template. When defining the formal parameters of the function object, we can do this

std::function<int(int)> fp = OP();
std::cout << "FP:" << fp(99) << std::endl;