C++面向对象高级

从本篇开始,就进入C++面向对象高级技巧的内容了。

操作符重载

又称为运算符重载。操作符重载的实质还是函数重载,可以将操作符视为一种特殊函数,同样具有参数和返回值。

基础语法

声明重载操作符的方法是<返回值类型> operator op(<参数..>)

实现方式分为两种,第一种是放在类内部作为一个类的成员函数,第二种是放在类的外部,作为全局函数声明和实现,但是一般要在类内做友元声明。

返回值能不能用引用类型,关键要看返回的对象在重载的函数结束时会不会被销毁。如果不会则最好使用引用,减少拷贝开销。如果会消失则不能返回引用或者指针,否则可能造成悬挂指针的问题。

类成员函数

将重载函数声明在类内部,则会带有一个默认参数T* const this ,而且这个this是默认作为被重载的操作符的第一个参数的。也就是说如果要重载双目操作符,只需要再显式传入一个参数即可。

下面是一个类成员函数的操作符重载例子:

#include <iostream>
using namespace std;

class Integer{
    int value;
public:
    Integer(int value) : value(value) {}

    //不可以将返回值声明为引用,因为函数体中返回的是临时变量
    //返回的对象本身就是外面传来的引用或者在堆中,可以用引用。重点看函数退出后对象本体会不会消失
    //能够按引用传递参数就不要按值传递。
    Integer operator + (const Integer& integer) const{
        Integer result(value+integer.value);
        return result;
    }

    //输出流<<操作符必须用全局函数实现
    friend std::ostream &operator<<(std::ostream &os, const Integer &integer);
};

std::ostream &operator<<(std::ostream &os, const Integer &integer) {
    os << "value: " << integer.value<<endl;
    return os;
}

int main(){
    Integer integer1(1);
    Integer integer2(2);
    Integer integer3=integer1+integer2;
    Integer integer4=integer1.operator+(integer2);//也可以这么写

    cout<<integer1;
    cout<<integer2;
    cout<<integer3;
    operator<<(cout,integer4); //也可以这样写
}

全局函数

将重载函数声明在外部,这时没有默认参数,需要传入操作符的所有参数。C++规定,如果在类的外部重载操作符,其中至少有一个参数是用户自定义的类型(类、结构体、联合体)。使用全局函数的方法的好处是,可以很方便的看清楚和指定双目操作符两个参数的位置。

上面的例子中,重载<<操作符就用到了全局函数的方式。

重载操作符的使用方法

通过语法可以看出,重载操作符的时候操作符前面都会加上operator,目的是加以区分。重载之后,在使用到这个操作符(重载的函数)时,一般都直接使用这个操作符了。但是还有另一种写法也是可以的,就是用函数调用的方式。例如重载了+,那么operator+就是一个函数名,可以如同正常函数一样调用,就像上面的例子中那样。

选择重载方式

重载操作符时,应该考虑到使用类成员函数还是全局函数参数定义返回值类型以及重载函数的行为这四个方面的问题。

类成员or全局

C++中大部分的操作符都可以用类成员函数和全局函数两种方式重载,但是也有一部分必须用其中某一种方式重载的。参见下表(以下表格来自C++运算符重载-上篇 (Boolan)):

运算符 名称或类别 方法还是全局friend函数 何时重载 示例原型
operator+ operator- operator* operator/ operator% 二元算术运算符 建议使用全局friend函数 类需要提供这些操作时 friend const T operator+(const T&, const T&); friend T operator+(const T&, const E&);
operator- operator+ operator~ 一元算术运算符和按位运算符 建议使用成员函数 类需要提供这些操作时 const T operator~() const;
operator++ operator– 前缀递增运算符和递减运算符 建议使用成员函数 重载了++和–时 T& operator++();
operator++ operator– 后缀递增运算符和递减运算符 建议使用成员函数 重载了++和–时 T operator++(int);
operator= 赋值运算符 必须使用成员函数 在类中动态分配了内存或资源,或者成员是引用时 T& operator=(const T&);
operator+= operator-= operator*= operator/= operator%= 算术运算符赋值的简写 建议使用成员函数 重载了二元算术运算符,且类没有设计为不可变时 T& operator+=(const T&); T& operator=(const E&);
operator<< operator>> operator& operator ▏ operator^ 二元按位运算符 建议使用全局friend函数 需要提供这些操作时 friend const T operator<<(const T&, const T&); friend T operator<<(const T&, const E&);
operator<<= operator>>= operator&= operator ▏= operator^= 按位运算符赋值的简写 建议使用成员函数 重载了二元按位运算符,类没有设计为不可变时 T& operator<<=(const T&); T& operator<<=(const E&);
operator< operator> operator<= operator>= operator== operator!= 二元比较运算符 建议使用全局friend函数 需要提供这些操作时 friend bool operator<(const T&, const T&); friend bool operator<(const T&, const E&);
operator<< operator>> I/O流运算符(插入操作和提取操作) 必须使用全局friend函数 需要提供这些操作时 friend ostream& operator<<(ostream&, const T&); friend istream& operator>>(istream&, T&);
operator! 布尔非运算符 建议采用成员函数 很少重载:应该改为bool或void*类型转换 bool operator!() const;
operator&& operator ▏▏ 二元布尔运算符 建议使用全局friend函数 不建议重载 friend bool operator&&(const T& lhs, const T& rhs);
operator[] 下标(数组索引)运算符 必须使用成员函数 需要支持下标访问时 E& operator; const E& operator const;
operator() 函数调用运算符 必须使用成员函数 需要让对象的行为和函数指针一致时 返回类型和参数可以多种多样,下文有详细讲解
operator type() 转换(或强制类型转换,cast)运算符(每种类型有不同的运算符) 必须使用成员函数 需要将自己编写的类型转换为其它类型时 operator type() const;
operator new operator new[] 内存分配例程 必须使用静态成员函数 需要控制类的内存分配时(很少见) void* operator new(size_t size); void* operator new[](size_t size);
operator delete operator delete[] 内存释放例程 必须使用静态成员函数 重载了内存分配例程时 void* operator delete(void* ptr) noexcept; void* operator delete[](void* ptr) noexcept;
operator* operator-> 解除引用运算符 对于operator*,建议成员函数;对于operator->,必须成员函数 适用于智能指针 E& operator() const; E operator->() const;
operator& 取地址运算符 不可用 永远不要 不可用
operator->* 解除引用指针-成员 不可用 永远不要 不可用
operator, 逗号运算符 不可用 永远不要 不可用

考虑的因素主要包括:

  1. 第一个操作数是不是this*,如果不是,则不能使用成员函数。比如IO流的<<和>>操作符。
  2. 有没有编译器提供的默认版本,如果有,则不能使用全局函数,比如赋值=和取地址操作符&。
  3. 特例
  4. 其他操作符一般两种方法都可以,但是建议双目使用全局函数,而单目使用成员函数。

参数的确定

操作符基本上都是固定参数个数的,重载时必须保持参数个数相同,这一点和普通的函数重载有区别。另外,参数的类型应该有意义,比如Integer之间+运算重载时,接受两个string类型的参数就不合理。

尽量使用引用传递。除非确定要修改参数对象,否则应该声明为const。

返回值类型

C++不根据返回值判断是否是重载函数,但是返回值也应该尽量合理。

尽量使用引用作为返回值。但是如果返回的是一个函数中的局部变量,则不可以返回引用。

如果返回值是左值,则不应用const修饰返回值,除非想保证不能修改。如果返回右值则应当用const修饰。大部分操作符都会返回左值,例如一系列具有赋值功能的操作符(=、+=、-=等)。

行为

重载函数的行为应该尽量符合操作符本身的意义,提高可读性,而不是做一些无关的事情。

禁止和警告

绝大多数操作符都能重载,但又几个不可以重载。另外还有一些虽然可以但是强烈不推荐重载。

禁止重载的操作符

  1. . 成员访问操作符
  2. .* 成员指针运算符
  3. :: 名空间(作用域)运算符
  4. ?: 三目条件运算符
  5. # 预编译操作符

强烈不推荐重载

  1. & 取地址
  2. , 逗号运算符
  3. ->*解引用成员
  4. ||或&&与操作符,重载后导致短路现象消失

效率的考量

下面一个例子将说明,应当尽量追求效率而不是过分追求效率。在重载操作符的时候更应该考虑到重载后的函数在任何情况使用都不会有差错,然后再考量效率的问题。

如下,有一段代码

class Rational { 
public:
    Rational(int,int);
    const Rational operator *(const Rational& r) const;
private:
    int n, d;    
};

const Rational Rational::operator *(const Rational& r) const{
    return Rational(n*r.n, d*r.d);
}

某人认为*操作符重载函数返回时要进行拷贝,效率不佳,于是做了一下更改:

class Rational { 
public:
    Rational(int,int);
    // 返回值为引用类型
    const Rational& operator *(const Rational& r) const;
private:
    int n, d;    
};

const Rational& Rational::operator *(const Rational& r) const{
    Rational *result  = new Rational(n*r.n, d*r.d);
    return *result;
}

但是这样修改会导致问题:进行连乘,比如w=x*y*z的时候,w只拿到了第二次乘法的引用,而第一次乘法结果在堆中的对象丢失了,造成了内存泄漏。

于是此人由将函数体更改为:

class Rational {
    //这里用一个静态的成员变量储存每次乘法的结果
    static Rational result;
public:
    Rational(int,int);
    // 返回值为引用类型
    const Rational& operator *(const Rational& r) const;
private:
    int n, d;    
};

const Rational& Rational::operator *(const Rational& r) const{
    result.n = n*r.n;  
    result.d = d*r.d; 
    return result;
}

但是,这样一来,(x*y)==(w*z)就成了永真式。还是存在问题。。最后不得已改回了最初的版本。这说明重载操作符的时候尽量遵循经验上的定义,避免出现各种问题,而不应该过分追求效率。

特殊操作符重载

自增(自减)操作符的重载++/–

原型:前置自增(++a)Counter& operator ++();;后置自增(a++)Counter operator ++(int);

建议使用成员函数。

class Counter
{       int value;
public:
    Counter() { value = 0; }
    Counter& operator ++(){ // ++a 前置  
        value++;
        return *this;
    }
    Counter operator ++(int){ //a++ 后置
        Counter temp=*this;
        value++;
        return temp;
    }
};

前置自增操作符由于是先加1,所以结果是一个左值,直接返回自身的引用;后置自增操作符结果是右值,需要创建临时变量进行返回。

为了区分,后置自增参数括号中有一个int,作为一个假参数。没有任何实际意义,只是为了方便区分。

赋值操作符=

原型示例:A &operator=(const A &a);或者A &operator=(A &a);。前者可以传入一个右值,而后者不可以。

有默认的赋值操作符函数,默认在对象赋值的时候把对象的所有成员变量依次赋值(递归)。但是对于需要深层次赋值的情况,默认的赋值就无法满足要求了,需要自定义重载函数,进行深层次的拷贝。

赋值操作符的重载不能继承,因为派生类往往有自己的成员变量,使用基类的函数无法满足每个成员变量依次赋值。

必须使用成员函数。这是因为如果不在类内定义,编译器会提供默认版本。而且运行时优先匹配类内的版本,因此如果在类外重载赋值操作符,则永远不会生效。

案例分析:

#include <cstring>

class A {
    int x, y;
    char *p;
public:
    A(int i, int j, char *s) : x(i), y(j) {
        p = new char[strlen(s) + 1];
        strcpy(p, s);
    }

    virtual ~A() { delete[] p; }

    // 如果不重载,为浅拷贝。会导致悬垂指针和内存泄漏的问题。
    A &operator=(A &a) {
        x = a.x;
        y = a.y;
        delete[]p;
        p = new char[strlen(a.p) + 1];
        strcpy(p, a.p);
        return *this;
    }
};

考虑如上的赋值操作符重载。乍一看好像没问题,确实将指针指向的内容也做了更换赋值,基本上算是达到要求了。但是也存在一些问题:

  1. 如果new内存申请失败,则会产生悬垂指针。可以把内存分配放到delete之前。先用一个新的指针指向之前的内容,然后尝试new分配空间,如果成功,再通过新的指针(指向旧内容)删除就内容。这种做法对于异常是安全的。
  2. 如果出现自我赋值,类似于a=a这种,由于指针指向的内容被自己删除了,这里就成了悬挂指针。尽管通常不会显式地自我赋值,但是如果是作为函数参数,可能会在调用者不知情的情况下出现自我赋值的情况。这个时候要想办法区分赋值的右侧和左侧不是来自同一个对象。有三种办法:根据内容判断(不太可靠,而且开销大);根据内存位置判断(比较可靠,但是对象地址不同并不代表其中的成员对象地址不同,成员对象如果是同一个也会出错。还是一种浅层比较);根据对象hash值(很可靠,但是需要自定义计算hash的办法。)

下标操作符[]

一般来说需要同时重载两个版本:const版本和非const版本。原型分别为char &operator[](int i)const char &operator[](int i) const。这样可以保证,如果声明的对象类型为const,不能修改下标操作符的返回值;如果非const,则可以修改。

必须使用成员函数。

#include <iostream>
#include <cstring>

using namespace std;

class string {
    char *p;
public:
    string(char *p1) {
        p = new char[strlen(p1) + 1];
        strcpy(p, p1);
    }

    char &operator[](int i) { return p[i]; }

    const char &operator[](int i) const { return p[i]; }

    virtual ~string() { delete[] p; }
};

int main() {
    ::string str("1234");
    str[0]='9'; //正确,调用的是非const版本
    
    const ::string s("abcd");
    s[0]='b'; //错误,调用char &operator[](int i) const函数返回的是const类型的值
    char current = s[0]; //正确,可以将这个返回值赋值给其他的变量,自动转为右值
    current = 'a'; //正确
}

const版本函数的返回值是否需要用引用,这一点需要考量。一般情况都使用引用可以提高效率,但是如果返回的是堆中新创建的对象,调用者可能并不知情也不会主动delete,这个时候不应使用引用,以免导致内存泄漏。

另外,还可以借助代理类完成多维数组空间管理:

class Array2D {
public:
    //代理类
    class Array1D {
        int *p;
    public:
        Array1D(int *p) { this->p = p; }
        int &operator[](int index) { return p[index]; }
        const int &operator[](int index) const { return p[index]; }
    };

    Array2D(int n1, int n2) {
        p = new int[n1 * n2];
        num1 = n1;
        num2 = n2;
    }

    virtual ~Array2D() { delete[] p; }

    Array1D operator[](int index) { return p + index * num2; }

    // 这里用函数返回的方法隐式调用Array1D的构造函数,产生一个可以使用的临时变量。
    // 此处不要用引用的方式返回,因为调用者不会delete,导致内存泄漏
    const Array1D operator[](int index) const { return p + index * num2; }

private:
    int *p;
    int num1, num2;
};

函数调用操作符()

函数原型:double operator () (double,int,int);。说是重载,其实更像是给对象新定义了一种操作的方式。把一个对象当做函数来使用,形成所谓的函数对象。

必须使用成员函数。

最常见的用法是函数对象中,可以直接如同函数一样使用函数对象进行调用和操作。如下例:

#include <iostream>
using namespace std;

class Func
{
public:
    void operator() (int i)
    {
        cout<<"Hello C++!"<<i<<endl;
    }
};

int main() {
    Func f;        //函数对象
    f(1);
    return 0;
}

类型转换()

函数原型:operator double();。写法很特殊,不在前面写返回值类型,而把实际的返回值类型写在operator后面。

必须使用成员函数。

例如:

#include <iostream>
using namespace std;

class Rational {
    int p, q;
public:
    Rational(int p, int q) : p(p), q(q) {}

    // 定义,到double的类型转换
    operator double() {
        return (double) p / q;
    }
};

int main() {
    Rational r(1, 2);
    cout << 0.4 + r; //正确,编译器可以根据类型转换函数自动转换类型
}

解引用成员访问操作符->

本身是一个二元操作符,第一个参数是对象,第二个参数是成员。但是在定义的时候按照单目操作符的写法重载。这个操作符由于正常情况下都是给指针使用的,所以这里给对象重载其实是扩充了操作符的意味,不必参考本身的意义。

这个操作符写法比较特殊,有自己的规范:函数原型E* operator->();,返回的类型应当是一个指针(或者已经自定义了->的对象,递归。但是最终应当是指针)。调用的写法a->f(),等价于a->operator()->f()(如果是访问成员变量则是a->ia->operator()->i)。

必须使用成员函数。

用途是可以简化访问对象的成员的成员的写法,或者定义智能指针,例如:

#include <iostream>
using namespace std;

class A {
public:
    void hello(){
        cout<<"hello";
    }
};

class AWrapper {
    A *p;
public:
    AWrapper(A *p) { this->p = p; }
    ~AWrapper() { delete p; }
    A *operator->() { return p; }
};

int main(){
    AWrapper p(new A);
    p->hello();
}

A在堆上,所以有可能忘记delete或者出现异常,导致内存泄漏,或者悬挂指针。这是用一个“智能指针”AWapper包裹A,析构时自动delete掉A。这样无论出现什么情况,只要主程序退出,A就可以被正确地析构释放。而->操作符的重载使得我们操作这个智能指针就如同操作对象本身一样,非常方便。局限性是对象的声明周期也被限制了,AWapper声明周期一致,如果需要在更大的范围使用,则需要自己管理。配合模板,可以用这样的智能指针管理任意的类。

new和delete

重载这两个操作符是为了方便自主管理内存,从而根据需要提高效率。申请内存后自主去储存和分配。重载的new和delete是静态成员,遵循类的访问控制,可以被继承,因此都建议写成静态的成员函数,但是我们没必要写static,因为这两个函数重载都是隐式静态的。

需要注意的是,new表达式和new操作符,以及delete表达式和delete操作符的区别:new表达式先调用new操作符,之后调用对象的构造函数;而delete表达式则先调用对象的析构函数再调用delete操作符。这里重载的是new和delete的操作符,因此只需要自己实现管理内存,不需要实现构造对象的过程。

函数原型:void *operator new (size_t size, …);void operator delete(void *p, size_t size);。其中size_t size是系统自动计算对象需要占用的空间并传入的,在函数体中可以直接使用,而在调用的时候也不需要显示地传入。size_t 其实是一个unsigned int类型。new的其他参数是可选的。

如果是从基类继承来的new,则可以利用size进行一下判断:

if (size!=sizeof(Base)) return ::operator new(size);
// 如果派生类大小不一致,则去调用全局标准库里的new来分配。
// 标准库中的函数永远可以使用,只需要像这样指定即可。

另外需要注意,new 和new []是两个操作符,不能用new的重载代替new[]的重载,delete和delete[]同理。

系统在调用new和delete时,会先在类中和基类中寻找重载版本,没有则在全局函数中寻找,再没有才会调用标准库中已经定义的版本。所以实际上也可以写成全局函数,但是不推荐。

IO流操作符>>和<<

这两个操作符一般配合cout和cin进行使用。重载一般使用全局函数的方式,达到的目的是对于对象也可以如同普通的整数、字符串那样使用cout进行控制台输出、输入操作,非常便利。

必须使用友元全局函数。这是因为成员函数重载的第一个参数是this*而这里需要先传入的第一个参数需要是ostream或者istream类型。

函数原型:friend std::ostream &operator<<(std::ostream &os, const Integer &integer);friend std::istream &operator>>(std::istream &in, Integer &integer);注意两点,这是双目操作符,所以传入两个参数分别是输入输出流引用和对象引用;返回值是输入输出流引用,这是因为需要支持连续输出和输入,比如std::cout<<A<<B;这样的。

一份实例代码:

#include <iostream>

class Integer {
    int num;
public:
    Integer() = default; // 编译器提供的默认版本构造函数
    Integer(int num) : num(num) {}

    friend std::ostream &operator<<(std::ostream &os, const Integer &integer);

    //可以把实现写在类内,虽然是全局友元函数。并且不需要类外有函数签名,编译器能自己找到。不要误当成成员函数!
    friend std::istream &operator>>(std::istream &in, Integer &integer) {
        in >> integer.num;
        return in;
    }
};

// 实现推荐写在类外,作为全局函数更清楚,不容易和成员函数弄混
std::ostream &operator<<(std::ostream &os, const Integer &integer) {
    os << "num: " << integer.num;
    return os;
}

int main() {
    Integer integer;
    std::cin >> integer;
    std::cout << integer;
}

值得一提的是,在CLion中Alt +Insert 组合键,可以选择自动生成输出流重载的函数。还有相等和关系操作符也可以这样快捷重载。

下面这份代码展示了,可以把输出操作符重载当做非虚借口,内部调用虚函数。这样可以满足继承和多态关系:

class CPoint2D {
    double x, y;
public:
    ...
    virtual void display(ostream &out) { out << x << ',' << y << endl; }
};

ostream &operator<<(ostream &out, CPoint2D &a) {
    a.display(out);
    return out;
}

class CPoint3D : public CPoint2D {
    double z;
public:
    ...
    void display(ostream &out) {
        CPoint2D::display();
        out << ',' << z << endl;
    }
};