Loading

欢乐C++ —— 14. 运算符重载

简介

重载的运算符,其实是有特殊名字的函数。运算符重载的基本思想是为了使类类型的运算和内置类型保持一致。以下是几个准则:

  • 一个重载的运算符函数至少要有一个类类型,不能为基本类型重定义运算操作

    若没有类类型参数,运算符重载就没有任何意义。

  • 大部分运算符都可重载,不能重载的运算符有四种:作用域运算符:: 成员访问运算符 . 条件运算符 ?: 成员解引用运算符 .*

  • 虽然有些运算符可以重载,但是为了和内置类型保持一致起见,避免行为异于常态。

    例如逗号运算符, 和 取地址运算符& 如果贸然重载,可能会使其行为与常态不符,在使用的过程中就很有可能出现逻辑错误。

  • 运算符重载的实质毕竟还是函数调用,所以在有些情况下会改变原先运算符求值顺序或求值属性。

    例如逻辑运算符 && || 在重载后会丧失短路运算的属性。

  • 除了重载函数调用运算符外,其它运算符重载都不能有默认实参

    重载运算符的实参就是参与运算的对象;而函数调用运算符就是使对象的行为类似于函数,当然可以有默认实参。

  • 重载为非成员函数的运算符要设为类的友元函数。

  • 成员函数和非成员函数

    赋值 = 下标[] 调用 () 箭头 -> 必须重载为成员函数。

    输入输出 >> << 应该重载为非成员函数。

    改变对象状态的运算符通常是成员函数,例如 递增 ++ 递减 --

    而具有对称性的运算符(左右两个对象可交换)通常为非成员函数。如相等 == 逻辑与&

运算符

输入输出 >> <<

注意输入运算符,应有错误处理。

关系运算符
  • == !=:一般而言这两个重载运算符都是成对出现的,并且一般只实现 ==!=只是对==的调用结果取反
  • 大于小于 > < :如果该类具有明确的顺序,并且需要这种顺序,那么为了可以使用标准库的泛型排序算法,一般会定义 < ,必要情况下,结合!=< 可以定义 >
赋值运算符
  • = :二元运算符,重载为成员函数,并且返回左侧对象的引用。

    当有使用花括号列表做为参数时,可使用可变参数类 initializer_list<>

  • += :通常也重载为成员函数,并返回左侧对象的引用。

算术运算符

通常情况下重载为非成员函数,并且应该使用复合赋值运算符来实现算数运算符,减少代码冗余,易于维护。

下标运算符

[] :注意有两个版本,分别返回引用和返回常量引用。

递增递减
  • 前置 ++ -- :注意应该返回引用。

  • 后置 ++ -- :为了与前置版本区分开,其额外增加一个不会被使用的int 形参,注意应该返回临时量。

成员访问
  • 解引用 * :返回所指对象的引用

  • 成员访问 -> :当一个类重载 -> 时,对它就有两种使用 -> 运算符的方式

    //A 为重载-> 的类
    class A* a1 = new A();
    a1->member;		//内置的 -> 运算符的含义
    
    class A a2;
    a2->member;		//将调用重载的 -> 运算符
    

    此外,当对对象调用重载 -> 时,它的返回类型可以有两种:

    1. 返回指针。然后调用内置的 -> 含义

    2. 返回重载-> 的对象。 然后循环调用 -> 直到返回指针。

    也就意味着,如果重载 -> 的返回值是一个重载 -> 的对象的话,那么最后一次重载 -> 的调用一定是返回指针。

示例:

image-20200511092608196

这个特性意味着有这个使用方式(可能实际中不会使用,但可以帮助我们理解语法规则),如果能理解这个就更好了

image-20200511093053457
函数调用运算符

函数调用 ():如果类定义了函数调用运算符,我们就称这个类的对象为函数对象,其行为就像函数。

和一般的函数比起来,其可以具有状态:

class PrintString {
public:
	PrintString(ostream &o = cout, char ch = ' ') :os(o), end(ch) { }
	void operator()(const string &str) const {
		os << str << end;
	}
private:
	ostream &os;
	char end;
};
与lambda 关系

当我们使用 lambda 实质上在使用函数对象,编译器会将lambda 表达式翻译成重载函数调用运算符的类。在使用lambda 表达式的地方生成该类的匿名对象。

类型转换运算符

类型转换运算符必须为成员函数,有返回值,但没有显式的返回类型,形参为空,一般为const

当我们定义了一个构造函数,若没有声明为 explicit,则从某种角度而言相当于定义了其它类型向类类型的转换。相似的,我们也可以通过定义类型转换运算符从而定义了从类类型向其它类型的转换。

class MyInt {
private:
	int x;
public:
	MyInt(int n = 0) :x(n) { }		//若声明为explicit ,否则10、11行隐式生成对象会报错
	operator int( )const { return x; }	//若为explicit 第12行隐式调用会出错
};

int main( ) {
	MyInt x = 10;
    x = 666;
	cout << x;

	return 0;
}

要注意,使用类型转换运算符的前提是该类具有明确,清晰,无二义性的含义,并且尽量加上explicit 限制符防止隐式转化,通过 static_cast<Type>() 显式调用转换运算符。

如无特别必要,不要定义类型转换运算符,如果定义了,尽量声明为 explicit 。

下面是一些建议和反面示例:

  • 不要令两个类互相转换,如果A类定义了接受B类的构造函数,那么B类则不要定义向A类转换的运算符。

    class B;
    class A {
    public:
    	A(const B&other){ }
    };
    
    class B {
    public:
    	operator A( )const { }
    };
    
    void fun(const A&other) {}
    
    int main( ) {
    	B b;
    	A a(b);
    	fun(b);		//出现二义性调用
        return 0;
    }
    
  • 避免转换目标是内置算术类型的类型转换,如果定义了,那么接下来

    • 不要再定义接受算数类型的重载运算符。

      image-20200512092635344
    • 不要定义多种算术类型的转换。

      image-20200512093429275
  • 当既有类型转换(从类到别的类型或从别的类型到类),又有重载函数时要特别注意

    image-20200512092838998
new delete 运算符

为什么使用 new [] 形式申请资源时必须要使用 delete[] 形式释放?为什么new 申请的不能使用delete []释放?

答案在于分配内存的形式上, new 只是申请单个对象;而 new [] 申请多个对象,一般而言,new []会会额外申请一点空间,记录对象的个数,稍后delete [] 会读取这个区域,从而调用适当个数的析构函数。而delete 会认为你只想要释放单个资源,所以就只调用一次构造函数。

重载

一般而言,重载new delete 的原因有以下几个:

  1. 用来检测运行错误。自己实现的new 可以在分配内存时做额外的内存签名。
  2. 为了提高效率。标准库的new 其实是为了满足各种使用场景而采取中庸之道。而对于某些特定场景,自行定制的版本性能会更高。
  3. 收集使用上的统计数据。获取程序对内存的使用信息,大小如何?频度如何?等等,基于此类信息,才能个性化定制。

new 的过程:

  1. 调用operator new 的函数,分配一块原始空间。
  2. 编译器运行构造函数,在这些空间上构造对象。
  3. 返回该对象的指针。

delete 过程:

  1. 先执行析构函数。
  2. 再调用 operator delete 函数释放空间。

我们重载new delete 运算符,实际上是重载上述过程中的operator new / delete 函数,而new 的三个行为,delete 的两个行为始终无法改变。

当 new 或 delete 一个类对象时,若没有明确作用域,则编译器会先在类内查找是否重载 new delete ,然后在全局作用域中查找,如果没找到,则使用标准库提供的版本。

如果定义在类的成员函数,则它们是隐式静态的。因为在没有对象之前也需要new 对象

void *operator new(size_t);
void *operator new[](size_t);
void operator delete(void*)noexcept;
void operator delete[](void*)noexcept;

void *operator new(size_t,nothrow_t&)noexcept;
void *operator new[](size_t,nothrow_t&)noexcept;
void operator delete(void*,nothrow_t&)noexcept;
void operator delete[](void*,nothrow_t&)noexcept;

值得注意的是:

  • nothrow_t 是定义在头文件 里面的一个空结构体,在这个头文件中同时定义了nothrow_t 类型的const 对象 nothrow 用户可以通过这个const 对象来选择不抛出异常的版本。

    建议少使用。effective c++ 给出的建议是它的局限性很强。

  • delete 不会抛出异常,所以需要声明为noexcept

  • new 的返回类型必须是void *,第一个形参必须是size_t 且不能有默认实参。

  • 当我们自定义new 时可以给指定额外的实参,此时使用这些new 时必须通过定位new 的形式将实参传递给形参。要注意:标准库的定位 new 版本不允许重载:void * operator new(size_t, void*);

malloc 与 free 函数

c 语言控制内存分配与释放的函数。我们将利用它来真正控制内存分配与释放。

定位 new

operator new 调用不会构造对象,而要使用定位new 来构造对象。

new (ptr) int;
new (ptr) int(0);
new (ptr) int[size_t];
new (ptr) int[4]{1,2,3,4};

甚至在非 operator new分配的空间中也可以构造对象。

operator new 分配的空间,无法使用allocator.construct 来构造对象。而反过来后者分配的空间可以通过定位new 来构造对象。

显式调用 析构函数:只会调用对象的析构函数,而不会释放空间。

new-handler

我们知道,当内存分配失败的时候会抛出 bad_alloc 的异常。C++ 支持我们自定义分配失败时的操作。

set_new_handler(void (*)(void))noexcept; 定义在 中,它接受一个函数,在new 失败时会调用这个函数。其返回值是原先设定的函数。

void handler(void) {
	cout << "new error" << endl;
	abort( );
}
int main( ) {
	set_new_handler(handler);
	new int[0xfffffff];
	new int[0xfffffff];
	new int[0xfffffff];
	new int[0xfffffff];

	return 0;
}

继承中的new-handler

关于new-handler 正确高效使用有很多经验,见Effective C++

内存对齐

有些计算机体系上,如果没有数据没有放置在特定的内存地址会导致运行期硬件异常,而有些体系则比较宽松,只是引起效率降低。例如int 一般存放在4的整数倍上。

当处于第一种目的重写new 时,如果我们添加了特定标记而影响了内存对齐。例如分配8字节的double 而前面增加了4字节的签名。这时候就有可能会导致double 不在8的整数倍上。

posted @ 2020-05-12 21:42  沉云  阅读(156)  评论(0)    收藏  举报