[课业] 03 | C++ | 单目操作符重载,特殊要求的操作符重载

单目操作符重载

单目操作符重载有两种形式

重载为类成员函数

形式如下

<return type> operator # ();

注意

  1. 其中,'#'代表被重载的操作符;
  2. 注意到参数列表是空的,这是因为定义为类成员函数的话默认单目操作符是成员函数被隐藏的参数this。

重载为全局函数

形式如下

<return type> operator # (<arg>);

注意

  1. 这种情况下该函数还应该被声明为所涉及自定义类的友元;
  2. 参数列表里参数即位仅有的操作数。

大多数情况下,单目操作符偏爱采用重载为类函数的方法,因为较稳妥

特殊要求的操作符重载

1.自加、自减操作符(++, --)

prefix++ __ ++a __ 返回左值(一个已经加了1的a)
postfix++ __ a++ __ 返回右值(之前的a值,a本身已经加过1)
重载时如何区别?

class Counter{
	int value;
public:
	Counter(){ value = 0; }

	//prefix <-> ++a
	Counter& operator ++ (){
		value++;
		return *this;
	}
	
	//postfix <-> a++
	Counter operator ++ (int){
		Counter temp = *this;
		value++;
		return temp;
	}
};

注意

  1. postfix重载版的参数列表里有一个没用过的’int’,这是哑元常量值(dummy argument),这里用于区别两个重载函数;
  2. 注意到两个版本的重载函数的返回值不同,这与他们的本身语义有关:
    1). prefix版本返回的要求是一个左值(理解为必须要返回一个可以被修改赋值的量),必须要返回当前对象,即*this;
    2). postfix版本返回的是值(literally),根据语义,返回的应该是没有经过++的值,故将旧的值返回去,对像本身是++的。

2.赋值操作符(=)

  1. 注意到,即使对于自定义对象,没有重载赋值操作符的情况,compiler也会自动生成并调用一个默认版(就像对拷贝构造函数似的);默认版的功能为:逐个成员赋值,若有对象成员则对对象成员的类递归赋值
  2. 重载函数返回的引用应当是非常量引用,能够满足一下两种使用需求:
    //第一种:这里第一次返回被当作右值使用,并不要求是非常量
    a = b = c;
    
    //第二种:这里返回后被当作类似左值,即函数f()可能改变a的状态,要求是非常量
    (a = b).f();
    
  3. 赋值操作符的重载不可被继承(again,就像拷贝构造函数似的);理由是:如果继承,那么派生类自己独有的部分就不能很好处理

例1
如代码

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;
		//注意到new char的长度为长度加1,因为要保存字符串结束字符
		p = new char[strlen(a.p) + 1];
		strcpy(p, a.p);
		return *this;
	}
};
...
//执行
A a, b;
a = b;

注意

  1. 如果不用自定义重载函数,析构了对象b后,对象a之中的成员指针*p指向的内容被释放掉;但是对象a依旧存在,故a中指针p被悬垂 =>严重的问题;

  2. 像如上所示代码一样,使用深拷贝来重载操作符,仍会有不安全情况:当内存不够时,可能new char失败,这样就会抛出异常;如此,由于p的内容被delete,p又没有指向新内容(因为原本负责给p指新内容的那句抛了异常),p又成为了悬垂指针;为了解决这一问题,使指针p安全,就要保证delete之前p是一直指向内容的,对重载函数的函数体做出更改(见下);这样,若new char失败,但是p还是指向原来他指向的对象,不会生成悬垂指针;

    //先保存
    char* pOrig = p;
    p = new char[strlen(a.p) + 1];
    strcpy(p, a.p);
    //全部完成之后再delete原p指的内容
    delete pOrig;
    return * this;
    
  3. 假设原代码new char不会出现问题,还有一个问题:若赋值操作左右同一个对象,就会有问题(见下);

    class A{... A void f(A& a);...};
    void f(A& a1, A& a2);
    int f2(Derived& rd, Base& rb);
    //a.p指向释放的空间,后续酒泉会出错
    
  4. 为防止自我赋值,判断是否是自我赋值(根据对象ID来判断);方法有两种:
    1). 看两对象是否是同一内容(开销大)
    2). 看对象在内存中是否处在同一位置,如果是,则说明是同一对象

    if(this == &a)
    	return *this;
    delete p;
    ...	
    

    3). 不过内存位置不同有时候不足以说明就不是同一对象,还可以通过给对象生成ID值的方式(ID值采用hash表),如

    class A{
    public:
    	ObjectID identity() const;
    	...
    };
    A* p1, * p2;
    ...
    p1->identity() == p2->identity();
    

    4). 这种情况下,考虑上方防止new char异常的代码

    //先保存
    char* pOrig = p;
    p = new char[strlen(a.p) + 1];
    strcpy(p, a.p);
    //全部完成之后再delete原p指的内容
    delete pOrig;
    return * this;
    

    这个代码也可以解决自我赋值问题,两个位置上的指针,即使是自我赋值,也就是挪了位置,不会产生异常。

3.下标操作符([])

class string{
	char* p;
public:
	string(char* p1){
		p = new char[strlen(p1) + 1];
		strcpy(p, p1);
	}
	char operator [] (int i) const{
		return p[i];
	}
	char& operator [] (int i){
		return p[i];
	}
	
	virtual ~string(){
		delete[] p;
	}
};
...
string s("aacd");

注意:两种重载函数,满足了两种使用需要

//作为左值
s[2] = 'b';

//作为常量
const string cs("const");
cout << cs[0];

注意到,返回值不同不是重载,但是后面加const是(相当于参数列表里加了const)

应用:多维数组

一个idea:多维数组是由一维数组叠加起来的

二维数组
class Array2D{
	int n1, n2;
	int* p;
public:
	Array2D(int l, int c):n1(l),n2(c){
		p = new int[n1 * n2];
	}
	virtual ~Array2D(){delete[] p;}
};
int& Array2D::getElem(int i, int j){...}
Array2D data(2,3);
data.getElem(1,2) = 0;

以上代码是普通的使用二维数组的情况,现想要以

"data[1][2]"
的形式来取用该位置的元素,就要使用[]操作符重载

 
注意:这里不可以直接重载[][],而是应该重载[];
如下图,一个二维数组的内存逻辑结构
Alt

  1. 要获取data[1][2],先要data[1]的首地址,再偏移两个单位。
  2. data[1][2]可以解释成
    data.operator[](1)[2];
    
  3. data[1]返回值类型为int*
三维数组
  1. 三维数组的情况下,就要对第一个、第二个[]符重载;
  2. data[1][2][3]可以解释成
    data.operatot[](1).operator[](2)[3];
    
  3. 第一次重载的返回值就不可以是地址了,因为无法对地址继续调用重载函数;第一次的返回值类型应该是自定义对象;而这个对象需要有与上方相同的地址属性;这里就用到surragate(代理对象)
surrogate(代理对象)

代理对象(以此三维数组为例),我们既期望返回得到上一级的地址,有期望返回得到一个自定义类型,我们就将地址返回到一个类中返回,这个类只作为地址的包裹;再将操作符重载函数写在这个类里,这样,对第一次调用操作符重载函数返回的东西就可以继续调用重载函数了

class Array1D{
	int* q;
	int& operator[](j){
		return q[j];
	}
}

用代理类重载:将原本的地址变成自定类,便于下次的重载
完整代码

class Array2D{
public:
	class Array1D{
	public:
		Array1D(int* p){
			this->p = p;
		}
		int& operator[](int index){ 
			return p[index];
		}
		const int operator[](int index)const{
			return p[index];
		}
	private:
		int* p;
	};
	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;	
	}
	const Array1D operator[](int index)const{
		return p + index * num2;
	}
private:
	int* p;
	int num1, num2;
};

一个特别之处:利用构造函数进行类型转换:
当一个自定义类型带有一个一参数构造函数,该构造函数:
1)不是显式(explicit)的
2)只在需要创建对象时能调用

这样就表示该构造函数可以进行隐式调用,由编译器调用

4.()(函数调用 | 类型转换)

函数调用

某功能的代码

class Func{
	double para;
	int lowerBound, upperBound;
public:
	double operator()(double, int, int);
};
...
Func f;	//函数对象
f(2.4, 0, 8);
  1. 对象变成函数;
  2. 对象可作为参数;
  3. 函数对象可作为参数传递(与函数指针相似)

注意:1. 必须是成员函数
2. 函数对象可用泛型算法的形参
一例

class Array2D{
	int n1, n2;
	int* p;
public:
	Array2D(int l,int c):n1(l),n2(c){
		p = new int[n1 * n2];
		virtual ~Array2D(){
			delete[] p;
		}
		int& operator()(int i,intj){
			return (p + i * n2)[j];
		}
	}
};

类型转换

针对自定义类来做类型转换
基本类型转换为自定义类型:靠隐式构造函数;
自定义类型转换为基本类型:靠类型转换函数的重载;
注意:以上两种方法不可以同时使用,同时执行会出错(编译器既识别到可以做r转x又可以识别到可以x转r,有歧义)

class Rational{
public:
	Rational(int n1, int n2){
		n = n1;
		d = n2;
	}
	operator double(){return (double)n/d;)
private:
	int n, d;
};
Rational r(1, 2);
double x = r;
x = x + r;

类型转换函数名字前很明显不指定类型,(也没有参数),函数名中指定返回值类型如上面的:

operator double(){...}

用处:

  1. 减少混合计算中需要定义的操作符重载函数数量;
  2. 如代码
    ostream f("abc.txt");
    if(f){...}
    
    这种简便的实现就需要f转为bool值,这就可以用重载函数做类型转换了

其他的一些话:

  1. 为什么"="必须作为成员函数来重载?
    因为如果没有在类内进行重载,编译器会搞一个默认版本;而识别重载函数的顺序是先在类内查找,再在全局查找,这样全局定义的重载函数始终都会在默认的使用优先级之后,就没有用;
    其他三个都是左对象,又something的形式,用作成员更合理
  2. 一些个别的总结:
    ·默认的赋值重载函数:拷贝赋值,有对象成员的类递归赋值
    ·赋值操作符的重载不能被继承以避免派生类不能正确赋值
    ·对赋值操作符何时去定义的问题:(与拷贝构造函数相同)当这个类型有其他资源(如堆上内存)时,为避免复制过程中由于一方被析构造成悬垂指针、赋值过程中覆盖了原来的资源导致内存泄漏,这种时候自定义赋值操作符进行深拷贝
    ·重定义赋值操作符时要注意不要自我赋值:
    方法一:通过条件判断
    方法二:可以用临时变量来存放原来的资源,在复制成功之后再进行delete,这样就可以确保不泄漏、不悬垂
    ·下标操作符:
    与引用类似,但有副作用
    取出下标对应的对象,有两种情况
    情况一:取出下标对应对象,是独立的,可被赋值,返回引用;
    情况二:取出常量,不能被赋值,返回const T const
    这两种皆是需求,故下标操作符需要重载两个版本:
    char* //引用版本
    const //常版本
    

·代理类型:作为后续调用重载函数的包裹
·函数调用操作符:
将对象编程函数对象,便于使用
定义时,返回值就是函数的返回值,参数为函数参数
·类型转换操作符:
重载函数没有返回值,目标类型作为方法名
须重载为成员函数,是单目操作符,被转换的就是本对象

5.间接引用操作符(->)

  1. 间接引用操作符"->",又叫智能指针(smart pointer),用于获取指针指向对象的成员
  2. 两个参数:对象,成员
  3. 间接引用操作符只能重载为成员函数

一例

//想要进行这样的引用,考虑如果只以这种形式引用会有什么问题
A a;
a->f();
a.operator->(f)

问题为:操作符的后一个参数形式可以有好多,可以不仅仅是一个变量,即第二个参数不好表示
解决办法:间接引用操作符的重载按照一元操作符的重载进行描述,引用成员时额外添加引用成员项
写法为:

a.operator->()->f();
a->f();

并且返回指针类型,这样不管后面额外添加怎么样的引用,都可以在语法上成立
注意:如果不返回指针类型的话,还可以返回已经进行过自定义间接引用操作符的某类对象(接下来类似递归调用)
一例:画图板

//画笔
class CPen{
	int m_color;
	int m_width;
public:
	void setColor(int c){
		m_color = c;
	}
	int getWidth(){
		return m_width;
	}
};
//画板
class CPanel{
	//画笔
	CPen m_pen;
	//背景色
	int m_bkColor;
public:
	CPen* getPen(){
		return &m_pen;
	}
	void setBkColor(int c){
		m_bkColor = c;
	}
};

这种方案下,使用画板类的getPen()方法就可以获得画笔并且设置笔了

CPanel c;
c.getPen()->setColor(16);

但是这种方式不好,因为这个方法用到了内部对象
另一种设计方式使获得当前画笔的方法返回的是笔的指针对象,如下设计:

class CPen{
	int m_color;
	int m_width;
public:
	void setColor(int c){
		m_color = c;
	}
	int getWidth(){
		return m_width;
	}
};
class CPanel{
	CPen m_pen;
	int m_bkColor;
public:
	CPen* operator->(){
		return &m_pen;
	}
	void setBkColor(int c){
		m_bkColor = c;
	}
};
CPanel c;
c->setColor(16);
//<=>c.operator->()->setColor(16);
//<=>c.m_pen.setColor(16);
c->getWidth();
//<=>c.operator->()->getWidth();
//<=>c.m_pen.getWidth();

这种方式使用上方便(不过设计理念上还不算好)
另一例:

class A{
public:
	void f();
	int g(double);
	void h(char);
};
void test(){
	A* p = new A;
	......
	p->f();
	......
	p->g(1.1);
	......
	p->h('A');
	......
	delete p;
}

存在问题:
4. 可能忘记delete p
5. f()可能有异常,使调用终止,可能对test函数而言,因为异常或return有多出口情况;多出口函数,不好管理堆上对象内存,故要在函数的每一个出口都要保证delete p;为了更好管理,可用间接引用操作符重载来解决
这里创建新类型

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

这个新类型本身没有什么自己的逻辑,只是用来包裹A的指针
使用这个之后,案例代码:

void test(){
	AWrapper p(new A);
	......
	p->f();
	......
	p->g(1.1);
	......
	p->h('A');
	......
}

这里将new A返回的指针包裹在AWrapper对象中间
好处是:
不再需要自己写delete;p是栈上对象,生命期在test()之内;当test()结束时,p就会被析构;而析构函数确保函数出口无论在哪,p内指针一定会被delete掉
这种方法叫资源获取及初始化

  1. 当获取到新的资源时(本例中new A),利用对象初始化(p),再利用该对象的析构将资源释放掉
  2. 任何资源都可以这样做
  3. 局限性:资源必须符合compiler控制对象的生命周期
  4. 没有操作符重载时,每次通过对象使用资源要用get方法;使用操作符重载之后,AWrapper对象p的使用形式与他使用内部指针是一样的:
    	p->f();
    	p->g();
    	.......
    

6.new与delete操作符

管理堆上的动态内存时:new 创建空间;delete释放资源
使用new表达式:

  1. 调用系统提供的方式来分配一块足够大的无名内存空间
  2. compiler运行构造函数,构造对象、赋予初始值
  3. 完成之后返回指向该空间的指针

对一些特殊情况,调用存储管理影响效率;有许多内存管理的要求如碎片化管理等;而在这些要求下,compiler的默认方法太中庸;更好的方法是一些特定应用有特殊需求,这就要求内存分配可以由自己管理

一个这样的例子:
一个频繁使用的网络,会不断地创建、释放
可以调用系统的存储分配申请一块大空间;每次请求时直接在这块大空间内分配、释放
注意:此处自己管理的是存储的分配、去配

注意区分:

  1. 一个new/delete语句是包含分配、去配内存的多个过程
  2. 一个new/delete操作符只包含分配、去配内存的过程

new表达式:先调用new操作符分配内存;后构造、返回指针
delete表达式:先析构;后调用delete操作符释放内存

这里提的“重载”是对new/delete操作符的重载,达到改变分配、去配内存的效果

注意:构造部分无法更改

重载时:

  1. 形式为:
    void* operator new();
    
  2. 重载为全局、成员函数都可以
    若重载为全局函数:任何使用new都会用重载的方式来分配内存
    若重载为成员函数:只能针对当前类型使用
  3. 编译器使用不同new的优先级:最先在基类里找;其次在全局找;最后的选择是调用标准库版本
  4. 重载的new/delete操作符是静态成员,不过是隐式静态(不过显式静态即标注static也不错)
    原因:new操作符是在对象被操作前调用的,delete是在对象销毁之后调用的,二者必然是静态的
    而且不能操纵类的任何成员,参数也有规定
  5. 遵循类的访问控制
  6. 重载的new/delete也可以继承:
    delete的继承不会有太多问题(因为delete本身是虚函数)
    new的继承之后要判断是否是当前函数针对的版本(因为派生类与基类大小不一)

重载new

  1. 重载形式为:
    void* operator new (size_t size, ...);
    
  2. 函数名:operator new
  3. 返回类型:void*
    因为返回不同类型的指针都可以被void* 接受
  4. 第一个参数:size_t类型(unsigned int)
    系统自动计算对象的大小,并传值给size
    这里必须为size_t类型,表示字节数
    由于由系统自动计算对象大小,后传给size,故不可以有默认实参
  5. 其他参数:可有可无
    A* p = new (...) A;
    //...表示传给new的其他实参
    
    使用时,A的大小会自动传给size_t,在经过派生之后,size还是对象的实际大小,因为是参照对象的实际类型计算的
  6. 形参列表里的size是给我们使用的,如
    if(size != sizeof(base))
    	return ::operator new (size);
    /**
     * 这是另一个知识点:
     * return句中使用的是全局域名,调用的是标准库里的默认new
     *  
     * 这段代码进行的是判断过程:判断当前对象与基类对象的大小是否一致,如果不一致就使用默认的库函数的new
     */
    
    
  7. new的重载有两种:
    operator new ...;
    operator new [] ...;
    
    后者用于类似"new A[10]"这样的情况
    并:要是想用重载的,还得重载这个,要不然还会用全局的new
  8. 使用operator new时,size为当前对象的字节数
    使用operator new [] 时,size为整个数组元素所需空间
  9. 允许其他参数:new的重载可以有多版本
    仅有size_t类型参数的是默认版本
    还有一版本不可被重载
    void* operator new(size_t, void* );
    
    这个是有一个第二参数的版本,是标准库版本
额外参数的作用

一例:

void* operator new (size_t size, ostream& log);

这个例子的作用:每new一个对象就记录到log对象中,形成日志记录
另一例:指定位置
一种情况:若第二参数为指针类型,则这个new是定位 new(placement new)
此时,new是特别的:当通过地址作为额外参数去调用new时,不再分配内存,仅仅返回指针对应实参;而new表达式负责在指针所指的地方初始化对象
定位new允许在已分配好的内存地址来构造对象
定位new的好处:
new操作符在堆中查找一个足够大的剩余空间,不仅慢,还会可能出现无法分配内存的异常;定位new可以解决这些问题,先准备好内存在缓存区,不需要查内存,不会在运行途中发生内存异常
定位new适合用于对时间要求高、不想被打断的程序
定位new一例:

class A{};
//现创建一个对象,对象可能很大,一开始就可以分配空间,甚至可以分配栈上的空间(new可以new在栈上),如下(默认重载的new已经定义好):
char buf[sizeof(A)];
A* a = new (buf) A;
//这样执行时,对象就被创建在buf指的位置上

资源释放时:
若在堆中,用delete释放;若在栈上,不必

如果通过new重载之后还想硬用内置new,加上域名标记"::"

重载delete

  1. 重载形式为:
    void operator delete (void* p, size_t size);
    
  2. 方法名:
    operator delete
    
  3. 返回类型:void
  4. 形参列表:
    首参必为void*,表示被delete的那个指针;
    次为size_t,表示该对象的大小
  5. 若delete/delete[]被定义为成员函数,函数可以包含另一个size_t参数,表示继承体系中间的一个对象的大小,他可以用来删除继承体系中对象;
  6. 如果基类有一个虚析构函数,传给成员delete重载,字节数会根据待删除所指向的动态类型改动,即实际上operator delet参数版本由对象类型动态决定;
    想要删除继承体系中间的对象时,用operator delete声明成成员函数,size_t可以吻合
  7. delete的重载仅一个(表示真正调用对象是方式使用的是单个)
    若重载delete,通过delete指向对象时不会调用默认的内置delete
    注意:如果这个对象是由定位new来创建的,则无法调用正常的delete(参数不匹配),这种情况下应该写出相应的版本(自己添加)




     
     

___Fin___
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值