C++(第三篇):C++的类和对象(中 —— 万字详解:六个默认成员函数,编译器的优化问题,日期类的实现)

📒博客主页:要早起的杨同学的博客
🎉欢迎关注🔎点赞👍收藏⭐️留言📝
📌本文所属专栏: 【C++拒绝从入门到跑路】
✉️坚持和努力从早起开始!
💬参考在线编程网站:🌐牛客网🌐力扣
🙏作者水平有限,如果发现错误,敬请指正!感谢感谢!
在这里插入图片描述

一. 类的6个默认成员函数

在我们前面学习的类中,我们会定义成员变量和成员函数,这些我们自己定义的函数都是普通的成员函数,但是如若我们定义的类里什么也没有呢?是真的里面啥也没吗?如下:

class Date {};

如果一个类中什么成员都没有,简称为空类。空类中什么都没有吗?并不是的,任何一个类在我们不写的情况下,都会自动生成下面6个默认成员函数。

image-20220715121442045

什么是默认的成员函数?
是编译器自动在类中添加的特定的成员函数,不需要用户自己来写。

二. 构造函数

在C语言数据结构时,创建结构变量我们经常需要自行调用结构初始化函数,否则可能会报错,为了避免忘记调用初始化函数,C++对此做出了优化,即在创建对象时自动调用初始化函数,也就是构造函数

2.1 概念:

构造函数是特殊的成员函数,函数名和类名相同,不能有返回值(void类型也不行),在由类创建对象时,对对象的成员变量进行初始化,创建类类型对象时由编译器自动调用,保证每个数据成员都有一个合适的初始值,并且在对象的生命周期内只调用一次

需要注意的是,构造函数的虽然名称叫构造,但是需要注意的是构造函数的主要任务并不是开空间创建对象,而是初始化对象

2.2 特性:

  1. 函数名必须和类名一致
  2. 无返回值(没有返回值并不是void,void返回值是空)
  3. 对象实例化时编译器自动调用对应的构造函数(在C语言上的优化)
  4. 构造函数可以重载(可以存在多种初始化方式)
class Date
{
public:
    // 1.无参构造函数
    Date ()
    {}
    // 2.带参构造函数
    Date (int year, int month , int day )
    {
        _year = year ;
        _month = month ;
        _day = day ;
        }
        private :
        int _year ;
        int _month ;
        int _day ;
    }
};
void TestDate()
{
    Date d1; // 调用无参构造函数
    Date d2 (2015, 1, 1); // 调用带参的构造函数
    
    // 注意:如果通过无参构造函数创建对象时,对象后面不用跟括号,否则就成了函数声明
    // 以下代码的函数:声明了d3函数,该函数无参,返回一个日期类型的对象
    Date d3();
}

image-20220715144648813

注意:如果通过无参构造函数创建对象时,对象后面不用跟括号,否则就是函数声明。调用带参构造函数时需要加括号并且输入需要初始化的值。

//如果调用无参构造函数写成
//变成了函数声明,声明了一个函数名为d3,返回值为Date类的函数
Date d3();
  1. 如果类中没有显式定义构造函数(我们没有主动写),则C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义编译器将不再生成

    👉示例1:编译器自动生成的默认构造函数

class Date
{
public:
	/*
	// 如果用户显式定义了构造函数,编译器将不再生成
	Date (int year, int month, int day)
	{
	_year = year;
	_month = month;
	_day = day;
	}
	*/
private:
	int _year;
	int _month;
	int _day;
};
void Test()
{
	// 没有主动写构造函数,此处会调用编译器生成的默认构造函数
	Date d;
}

👉示例2:这段程序会编译报错,为什么呢?

因为用户显式定义了构造函数,编译器将不再生成默认构造函数了。

当实例化对象 d 时,会自动调用默认构造函数,但这里只有带参构造函数,没有默认构造函数。

class Date {
public:
    // 用户显式定义了构造函数,编译器将不再生成默认构造函数
	Date (int year, int month, int day) {
        _year = year;
        _month = month;
        _day = day;
	}

private:
	int _year;
	int _month;
	int _day;
};

int main() {
	Date d; // error:没有合适的默认构造函数可用
}
  1. 默认构造函数:不传参数就可以调用的构造函数。

    比如:「无参构造函数」和「全缺省构造函数」,并且默认构造函数只能有一个有参数但没有给缺省不属于默认构造函数

    默认构造函数的三种形式

    1. 我们不写,编译器自动生成的
    2. 无参构造函数,我们自己写的无参的构造函数
    3. 全缺省构造函数,我们写的全缺省,一般都用这个
class Date
{
public:
	//构造函数可以重载,因为可能我们想初始化对象的方式有多种
	Date()//无参构造函数
	{
		_year = 1900;
		_month = 1;
		_day = 1;
	}
	Date(int year = 1900, int month = 1, int day = 1)//全缺省构造函数
	{
		_year = year;
		_month = month;
		_day = day;
	}
private:
	int _year;
	int _month;
	int _day;
};
// 以下测试函数能通过编译吗?
void Test()
{
	Date d1;
}

image-20220701111858624

编译器不能明确知道该调用哪一个,所以报错了

image-20220715145021997

总结就是:不用传参就可以调用的构造函数就是默认构造函数。实际中,大多数情况都要自己写构造函数完成初始化,但是一般情况建议写一个全缺省的构造函数,这种方式能适应各种场景。

补充

关于编译器生成的默认成员函数,大家可能会有一个疑惑:用户没有显式定义构造函数的情况下,编译器会自动生成一个默认构造函数。在对象实例化时,调用编译器生成的默认构造函数,对象中的成员变量却被初始化成「随机值」了。这样看来,编译器生成的默认构造函数并没有什么用?

重点

解答:C++ 把类型分成「内置类型」(基本类型) 和「自定义类型」。

  • 内置类型:语法已经定义好的类型:如 int / char / int* / char* …

  • 自定义类型:我们使用 class / struct / union 自己定义的类型

自定义类型:我们使用的class、struct等定义的类型,编译器会去调用他们的默认构造函数初始化。所以,类A的成员变量里面如果声明的有类B,在调用的时候,会调用类B的默认构造函数初始化,然后再使用类A的成员函数。

我们不写,编译器自动生成的默认构造函数有什么价值?

自动生成的默认构造函数对内置类型(基本类型)成员变量不处理(为随机值)。(一些比较新的编译器也有可能会初始化为0,但是大多数编译器都不会处理,我们要当成不初始化)。

对自定义类型成员变量会去调用它的默认构造函数(不用传参的构造函数)进行初始化。

基本类型不处理,是C++语法设计的一个小缺陷,所以C++11给出了补充语法:声明缺省值。(这里是给缺省值,不是初始化)

👉示例

class A {
public:
	A() { _a = 0; }

private:
	int _a;
};

class B {
public:
	B() { _b = 0; }

private:
	int _b; // 基本类型(内置类型)
	A a;    // 自定义类型
};

int main()
{
	B b;
	return 0;
}

重点

构造函数在初始化列表中,它会做这两件事:

  1. 对于类中的内置类型成员 ------> 不处理(为随机值,除非声明时给了缺省值 - C++11)
  2. 对于类中的自定义类型成员 —> 自动调用它的默认构造函数(不要参数就可以调用的,比如 无参构造函数全缺省构造函数

三. 析构函数

3.1 概念:

析构函数:与构造函数功能相反,析构函数不是完成「对象的销毁」(局部对象销毁工作是由编译器完成的),而是在对象在生命周期结束时(出作用域)会自动调用析构函数,完成对象中一些「资源的清理」工作(一般是释放动态开辟的内存)

【注意】

析构函数不是完成对象的销毁,对象是存在函数栈帧里面的,函数结束,栈帧销毁,对象就销毁了。

3.2 特性:

  1. 析构函数名是在类名前加上字符 ~无参数无返回值,比如:~Date()
  2. 一个类有且只有一个析构函数,若未显式定义,系统会自动生成默认的析构函数(不能重载)
  3. 对象生命周期结束时(所在函数的栈帧销毁),C++ 编译系统会「自动调用」析构函数(在C语言上的优化),完成对象中资源的清理工作。
  4. 因为对象是定义在函数中,函数调用会建立栈帧,栈帧中的对象构造和析构也要符合后进先出

👉示例:析构函数的使用

typedef int DataType;
class SeqList
{
public:
	SeqList(int capacity = 10)//构造函数
	{
		_pData = (DataType*)malloc(capacity * sizeof(DataType));
		assert(_pData);
		_size = 0;
		_capacity = capacity;
	}
    // 注意:这里必须要显式的写析构函数,否则会发生内存泄漏
    
    // 析构函数:完成对象中资源的清理工作
	~SeqList()//析构函数
	{
		cout << "~SeqList()" << endl;
		if (_pData)
		{
			free(_pData); // 释放堆上的空间
			_pData = NULL; // 将指针置为空
			_capacity = 0;
			_size = 0;
		}
	}
private:
	int* _pData;
	size_t _size;
	size_t _capacity;
};

int main()
{
	SeqList sl;
	return 0;
}

【注意】

如果没有显式定义,编译器自动生成的析构函数,它会做哪些事情呢?

  1. 对于类中的内置类型成员 ------> 不处理
  2. 对于类中的自定义类型成员 —> 调用它的析构函数完成清理工作

【面试题】

  • 数据结构的栈和堆和我们讲的内存分段区域(操作系统)也有一个叫栈和堆,他们之间有什么区别和联系?

    1. 他们之间没有绝对的联系,因为他们属于两个学科的各自的一些命名,一个是数据结构,一个是分段(一段内存的命名)
    2. 数据结构的栈和系统分段栈(函数栈帧)中的对象都符合后进先出
  • 编译器生成的默认析构函数:

    1. 内置类型成员,不处理,因为没有资源需要清理

    2. 自定义类型成员,会调用它的析构函数

3.3 例题:

👉例题1在函数F中,本地变量a和b的构造函数(constructor)和析构函数(destructor)的调用顺序是?

Class A;
Class B;
void F()
{
    A a;
    B b;
}

顺序是: a构造 b构造 b析构 a析构

构造顺序是按照语句的顺序进行构造,对象析构要在生存作用域结束的时候才进行析构,析构是按照构造的相反顺序进行析构。

👉例题2设已经有A,B,C,D4个类的定义,程序中A,B,C,D析构函数调用顺序为?

C c;
int main()
{
    A a;
    B b;
    static D d;
    return 0;
}
  1. 类的析构函数调用一般按照构造函数调用的相反顺序进行调用,但是要注意static对象的存在, 因为static改变了对象的生存作用域,需要等待程序结束时才会析构释放对象
  2. 全局对象先于局部对象进行构造
  3. 局部对象按照出现的顺序进行构造,无论是否为static
  4. 所以构造的顺序为 c a b d
  5. 析构的顺序按照构造的相反顺序析构,只需注意static改变对象的生存作用域之后,会放在局部 对象之后进行析构
  6. 因此析构顺序为B A D C

四. 拷贝构造函数

4.1 概念:

在创建对象时,能否创建出一个与某个同类对象一模一样的新对象呢?

构造函数:

  • 只有「单个形参」,该形参是「对本类类型对象的引用」(一般常用 const 修饰)
  • 在用「已存在的同类对象」来初始化「新创建的对象」时,由编译器自动调用。

拷贝构造函数也是构造函数。

注意:如果实现了拷贝构造函数,但没有显式的定义构造函数,编译器就不会再生成构造函数了。

4.2 特征:

  1. 拷贝构造函数是构造函数的一个重载形式(参数不同)

  2. 拷贝构造函数也是一构造函数,因此不能有返回值

  3. 拷贝构造函数的参数只有一个必须使用引用传参使用传值方式会引发无穷递归调用。(如果用传值传参,要调用拷贝构造函数就要先传参,传参如果使用传值,形参需要拷贝实参的内容,而自定义类型成员的拷贝又会调用拷贝构造函数,循环往复)

    注意:若使用传值方式会引发无穷递归调用。如图:

    image-20220701111040202

函数传参,如果是自定义类型的对象,一般推荐使用引用传参。如果还继续用传值传参也可以,但是会重复调用拷贝构造函数进行传值的拷贝

👉示例:拷贝构造函数的正确写法

class Date
{
public:
	Date(int year = 1900, int month = 1, int day = 1)//构造函数
	{
		_year = year;
		_month = month;
		_day = day;
	}
	Date(const Date& d)//拷贝构造函函数
	{
		_year = d._year;
		_month = d._month;
		_day = d._day;
	}
private:
	int _year;
	int _month;
	int _day;
};
int main()
{
	Date d1;//调用构造函数
	Date d2(d1);//调用拷贝构造函数
	return 0;
}

若未显示定义,系统生成默认的拷贝构造函数

  • 默认的拷贝构造函数按对象的「内存存储字节序」完成拷贝,这种拷贝我们叫做「浅拷贝」,或者值拷贝。

  • 重要

    如果没有显式定义,编译器自动生成的拷贝构造函数,它会做哪些事情呢?

    1. 对于类中的内置类型成员 ------> 值拷贝
    2. 对于类中的自定义类型成员 —> 自动调用它的拷贝构造函数来完成拷贝初始化
class B
{
public:
    B(const B& bb)
        :_b(bb._b)
    {
       cout << "B(const B& bb)" << endl;
    }
    int _b;
};
class Date
{
public:
	Date(int year = 1900, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	}
private:
	int _year;
	int _month;
	int _day;
    B _b;
};
int main()
{
	Date d1;
	// 这里d2、d3调用的默认拷贝构造完成拷贝,d2、d3和d1的值也是一样的。
	Date d2(d1);
    Date d3 = d1;
    //都会打印 B(const B& bb)
	return 0;
}

思考:编译器自动生成的默认拷贝构造函数已经可以完成字节序的值拷贝了,我们还需要自己实现吗?

对于像日期类这样的类(只有指针类型的类)是没必要的,编译器生成的足够使用了,而对于存在指针类型的成员函数则会存现问题。

浅拷贝下,对于他们动态申请的内存空间,同样只会值拷贝把成员变量里面保存的地址的值拷贝过去,不会也申请同样大小的新内存空间,他俩指向同一块空间。这两个对象销毁,调用析构函数的时候,这同一块空间被释放两次,就会报错。

👉示例:需要深拷贝的类

class String
{
public:
    // 默认构造函数
	String(const char* str = "xxxx")
	{
		_str = (char*)malloc(strlen(str) + 1);
		strcpy(_str, str);
	}
	~String()//析构函数,进行释放动态开辟的空间
	{
		cout << "~String()" << endl;
		free(_str);
	}
private:
	char* _str;
};
int main()
{
	String s1("hello");
	String s2(s1);
}

编译器默认生成的拷贝构造函数是浅拷贝,会导致两个 string 对象中的字符指针 _str 指向的是同一个字符数组。(因为浅拷贝只拷贝了 _str 数组指针的 4 个字节的内容),s1和s2中的 str 指向的是同一份空间 ,析构时会被 释放 两次 ,所以会报错

img

如图:指向了同一块空间

image-20220417221717913

那么会引发什么问题呢?会导致 _str 指向的空间被释放两次,引发程序崩溃。

image-20220417221809176

这种需要深拷贝的对象,需要我们自己实现拷贝构造函数:

class String
{
public:
	String(const char* str = "xxxx")
	{
		_str = (char*)malloc(strlen(str) + 1);
		strcpy(_str, str);
	}
    //拷贝构造
    String(const string& s)
        :_str(new char[strlen(s._str) + 1])
        {
            strcpy(_str, s._str);
        }
	~String()//析构函数,进行释放动态开辟的空间
	{
		cout << "~String()" << endl;
		free(_str);
	}
private:
	char* _str;
};

4.3 使用默认拷贝构造函数对栈结构的影响:

像Stack这样的类,编译器默认生成的拷贝构造完成的是浅拷贝,拷贝出来的类B会和被拷贝的类A的成员指针变量指向同一块内存,不满足我们的需求

image-20220630100819441

总结

像Date(日期)这样的类,需要的就是浅拷贝(仅把值拷贝下来),那么默认生成的拷贝构造就够用了, 不需要自己写。

但是像Stack这样的类,需要的是深拷贝(申请空间),浅拷贝会导致析构两次、程序崩溃等问题,所以需要自己写。

4.4 隐式类型转换

class A
{
public:
	A(int a = 0)//构造函数
	{
		cout << "A(int a = 0)" << endl;
	}
	A(const A& aa)//拷贝构造函函数
	{
		cout << "A(const A& aa)" << endl;
	}
private:
	int _a;
};
int main()
{
	A a1(1);//调用构造函数
	A a2 = 2;//隐式类型转化,会创建一个临时变量A tmp(2),再用tmp拷贝构造a2,但是编译器优化后直接就调用一次拷贝构造函数
	return 0;
}

A a2 = 2 ,2和a2的类型不一样会进行隐式类型转换,隐式类型转换都会创建一个临时变量进行赋值,但是编译器优化了之后就会直接在这里调用构造函数。

五. 赋值运算符重载

5.1 运算符重载

C++为了让「自定义类型」可以像「内置类型」一样去使用某个运算符,并增强代码的可读性,引入了运算符重载,运算符重载是具有特殊函数名的函数,其返回值类型与参数列表与普通的函数类似。

  • 运算符重载跟函数重载都有重载这个词,但是两者之间没有关联。
  • 函数重载是支持定义同名函数
  • 运算符重载是为了让自定义类型可以像内置类型一样使用运算符,运算符重载要有一个类类型或者枚举类型的操作数(形参)

使用方法

  1. 函数名字为:关键字 operator 后面接需要重载的运算符符号。比如 operator= / operator++
  2. 函数原型:返回值类型 operator操作符(参数列表)

注意

  1. 不能通过连接其他符号来创建新的操作符,如operator==@==等
  2. 重载操作符必须有一个「类类型」或者「枚举类型」的操作数
  3. :: .* sizeof ? : . 注意以上5个运算符不能重载
  4. 用于内置类型的操作符,其含义不能改变,例如:内置的整型+,不能改变其含义
  5. 作为类成员的重载函数时,其形参看起来比操作数数目少1,成员函数的操作符有一个默认的形参this,限定为第一个形参。比如 + 有两个操作数,形参就只需要一个。

运算符重载,一般有两种方式:

  • 重载成类的成员函数(形参数目看起来比该运算符需要的参数少一个,因为成员函数有隐含的「this指针」,且函数的「第一个」形参就是「this指针」)
  • 重载成类的友元函数(必须有一个参数要是类的对象)(一般不这样做,而是重载成成员函数)

如果重载函数在类外,想要访问类里的成员变量,有两种方式

例:

// 全局的operator==
class Date
{ 
public:
    Date(int year = 1900, int month = 1, int day = 1)
    {
        _year = year;
        _month = month;
        _day = day;
    } 
//private:
    int _year;
    int _month;
    int _day;
};
// 这里会发现运算符重载成全局的,就需要成员变量是公有的,那么问题来了,封装性如何保证?
// 这里可以用友元解决,或者干脆重载成成员函数。
bool operator==(const Date& d1, const Date& d2) 
{
    return d1._year == d2._year
        && d1._month == d2._month
        && d1._day == d2._day; 
}
void Test ()
{
    Date d1(2018, 9, 26);
    Date d2(2018, 9, 27);
    cout<<(d1 == d2)<<endl;
}

运算符重载成成员函数

👉示例

运算符默认都是给「内置类型」变量用的,如果「自定义类型」的变量想要用这些运算符,必须要自己对这些运算符进行重载。

即根据该自定义类型的特点,写一个函数去重新定义这个运算符的行为,就能够像内置类型一样去用这些运算符了。

class Date
{
public:
	Date(int year = 1900, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	// bool operator==(Date* const this, const Date& d2)
	// 这里需要注意的是,左操作数是this指向的调用函数的对象
	bool operator==(const Date& d2)
	{
		return _year == d2._year
		&& _month == d2._month
		&& _day == d2._day;
	}
private:
	int _year;
	int _month;
	int _day;
};
void Test()
{
	Date d1(2018, 9, 26);
	Date d2(2018, 9, 27);
	cout << (d1 == d2) << endl;
}

5.2赋值运算符“=”重载

赋值运算符的重载是已经定义出来的对象之间的复制拷贝,默认生成的赋值运算符,特性跟拷贝构造函数一样。对基本类型完成浅拷贝,对自定义类型会调用它的operator=

⛄浅拷贝

class Date
{
public:
	Date(int year = 1900, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	Date(const Date& d)
	{
		_year = d._year;
		_month = d._month;
		_day = d._day;
	}
    //赋值运算符重载
	Date& operator=(const Date& d)//Date& operator=(Date* this, const Date& d)
	{
		if (this != &d)//避免对象赋值自己
		{
			_year = d._year;
			_month = d._month;
			_day = d._day;
		}
        return *this;
	}
private:
	int _year;
	int _month;
	int _day;
};

int main()
{
	Date d1(2022,2,1);//调用构造函数
	Date d2;
    
    d2 = d1;//调用赋值操作符重载, d2.operator(&d2, d1);
	return 0;
}

⛄深拷贝

S2赋值给S1,如果S1空间不够大,先销毁原来的空间,然后开一块跟S2一样大的空间。

class String
{
public:
	String(const char* str = "")//空串里面有个'\0'
	{
		_str = (char*)malloc(strlen(str) + 1);
		strcpy(_str, str);
	}
    String& operator=(const String& s)
    {
        if(this != &s)
        {
            delete[] _str;
            _str = new char[strlen(s._str)+1];
            strcpy(_str, s._str);
        }
        return *this;
    }
	~String()//析构函数,进行释放动态开辟的空间
	{
		cout << "~String()" << endl;
		free(_str);
	}
private:
	char* _str;
};

⛄赋值运算符特性

  1. 参数类型 const Date& d
  2. 返回值类型: Date& ,连续赋值的时候一般都是返回等号左边的变量的值
  3. 检测是否自己给自己赋值
  4. 返回 *this,即返回等号左边的,被赋值的变量
  5. 赋值运算符只能作为类的成员函数重载。赋值运算符在类中不显式实现时,编译器会生成一份默认的赋值运算符重载,完成对象按字节序的值拷贝。此时用户在类外再将赋值运算符重载为全局的,就和编译器生成的默认赋值运算符冲突了,故赋值运算符只能重载成成员函数

如果没有显式定义,编译器自动生成的赋值运算符重载函数,它会做哪些事情呢?

  1. 对于类中的内置类型成员 ------> 值拷贝
  2. 对于类中的自定义类型成员 —> 调用它的赋值重载函数来完成赋值初始化
class Date
{
public:
    Date(int year = 1900, int month = 1, int day = 1)
    {
        _year = year;
        _month = month;
        _day = day;
    }
private:
    int _year;
    int _month;
    int _day;
};
int main()
{
    Date d1;
    Date d2(2018,10, 1);

    // 这里d1调用的编译器生成operator=完成拷贝,d2和d1的值也是一样的。
    d1 = d2;
    return 0; 
}

重载的赋值运算符“=”和拷贝构造函数的区别

  1. 前者也是拷贝行为,不一样的是,拷贝构造是创建一个对象时,拿一个已经存在的同类对象初始化的拷贝
  2. 重载的赋值运算符的赋值运算是:两个对象已经都存在了都初始化过了,现在想把一个对象,复制拷贝给另一个对象

编译器默认生成赋值运算符跟拷贝构造的特性是一致的

  1. 针对内置类型,会完成浅拷贝,也就是说像Date这样的类不需要我们自己写赋值运算符重载,Stack要写
  2. 针对自定义类型,会调用它的赋值运算符重载函数

针对我们不写,编译器默认生成的函数总结一下:

构造析构的特性是类似的,我们不写编译器会对内置类型不处理,自定义类型会调用它的构造和析构函数处理

拷贝构造赋值重载特性类似,内置类型会完成浅拷贝,自定义类型会调用他们的拷贝构造和赋值重载。

5.3 前置++与后置++的重载

前置++ 和后置++都完成了++,不同的地方在于返回值不一样:

前置++ 返回++之后的值,后置++ 返回++之前的值,因为他们的运算符是一样的,函数名就是一样的。

为了区分,对后置++做了特殊处理,加了一个参数,构成函数重载:后置++ : operator++(int)

int参数不需要给实参,因为没用,他的作用是为了跟前置++构成函数重载

//++d -> d.operator(&d)
Date& operator++();
//d++ -> d.operator(&d,0)
Date& operator++(int);

5.4 输出运算符<< 和 输入运算符>> 重载

click on an element for detailed information

它们为什么能自动识别类型?

内置类型的对象,直接就能用cout cin输入输出,是因为库里面重载好了。

并且能自动识别类型,是因为他们之间构成函数重载

image-20220704100040488

这两个操作符如果放在类里面实现,会有争夺第一参数的问题,因为我们要保证cout或者cin是第一个参数,但如果在类里面就会导致第一参数是 this指针。

所以应该在全局实现,为了能够访问类里面的私有成员,可以要在类里面加上友元声明。

friend ostream& operator<<(ostream& out, const Date&d);

<<

ostream& operator<<(ostream& out, const Date&d)
{
    out << d._year << "-" << d._month << "-" << d._day << endl;
    
    return out;
}
//调用该函数
Date d1;
cout<< d1;

>>

ostream& operator>>(istream& in, Date&d)
{
    in >> d._year >> d._month >> d._day;
    return in;
}
//调用该函数
Date d2;
cin >> d2;

六. const成员

6.1 const修饰类的成员函数

  • 将 const 修饰的类成员函数称之为 「const 成员函数
  • const 修饰类成员函数,实际修饰的是该成员函数隐含的「this 指针」,表明在该成员函数中不能修改任何当前对象的成员变量

就比如打印函数:

我们实际对类不改变,只是读出数据,输出,所以我们用const修饰.

void Date::Print() const
{
    cout << _year << "年" << _month <<"月"<< _day <<"日"<< endl;
}

image-20220705203421555

在成员函数中,编译器处理以后会在它调用的成员(成员变量/成员函数)前面都会加上 this->

👉示例

下面代码编译不通过,是为什么呢?

class Date 
{
public:
	// 默认构造函数
	Date (int year = 1, int month = 1, int day = 1) 
    {
		_year = year;
		_month = month;
		_day = day;
	}

    // 打印日期
    // void Print(Date* const this)
	void Print() 
    {
		cout << _year << "/" << _month << "/" << _day << endl;
	}
    
private:
	int _year;
	int _month;
	int _day;
};

int main() {
    Date d1(2021, 1, 1);       // 普通对象
	const Date d2(2022, 1, 1); // const修饰的常对象

	d1.Print();

	d2.Print(); // d2.Print(&d2);
    // error : 不能将“this”指针从“const Date”转换为“Date&”
    
    return 0;
}	

原理分析:

image-20220421152819104

所以:const 对象是不能调非 const 成员函数的。

我们思考以下问题:

  1. const对象可以调用非const成员函数吗?

​ 不可以,const对象只能调用const成员函数,调用非const成员函数属于权限的放大

  1. 非const对象可以调用const成员函数吗?

​ 可以,这种调用属于权限的缩小(大调小)

  1. const成员函数内可以调用其它的非const成员函数吗?

​ 不可以,当调用其他函数时,其他函数也被认为时const函数,调不动(属于权限的缩小)

image-20220715145522964

  1. 非const成员函数内可以调用其它的const成员函数吗?

​ 可以,属于权限的缩小,所以可以调动.

image-20220715145621455

总结

  1. const 修饰成员函数是有好处的,这样 const 对象可以调用,非 const 对象也可以调用。
  2. 那是不是所有成员函数都要加 const 呢?不是。
  3. 要看成员函数的功能,如果成员函数是修改型(会修改成员变量),那就不能加;如果成员函数是只读型(不会修改成员变量),那就可以加。

6.2 const修饰*this的问题

image-20220722101447595

不修改成员变量的函数,都应该加const;但是本身就是修改数据的接口,如: push_back/insert/erase是不能加const

有些函数,应该实现const非const两个版本,比如[]和迭代器

const类型的对象调用const版本,返回const的引用值,避免被改变

形参加const与const类型的对象更匹配,调用函数的时候会优先使用const函数。可以理解为函数加const形参是为了调用那个返回值为const类型的函数。

总结

  1. 只读接口函数 + 加const,返回值+const
  2. 只写接口函数 + 不加const,返回值不加+const
  3. 可读可写接口 + const版本和非const版本
  4. 要不要加const看接口的功能性质

6.3 取地址及const取地址操作符重载

这两个默认成员函数一般不用重新定义 ,编译器默认会生成

class Date
{
public:
	Date* operator&()
	{
		return this;
	}
    //不想让别人获取类对象的地址,返回空
	const Date* operator&()const
	{
		return nullptr;
	}
private:
	int _year; // 年
	int _month; // 月
	int _day; // 日
};

这两个运算符一般不需要重载,使用编译器生成的默认取地址的重载即可,只有特殊情况,才需要重载,比如想让别人获取到指定的内容(不想让别人找到对象地址)!

七. 总结六个默认成员函数

学习完上文中的六个默认成员函数后,以Date日期类对象为例,我们再总结复习一下他们的特性和用法:

默认成员函数功能常用写法编译器默认生成的
构造函数完成成员变量的初始化Date (int year, int month , int day)对内置类型(基本类型)成员变量不处理,对自定义类型成员变量会去调用它的默认构造函数(不用传参的构造函数)进行初始化。
析构函数完成成员变量的资源清理(delete、free)~Date()对内置类型成员不处理,因为没有资源需要清理。对自定义类型成员,会调用它的析构函数
拷贝构造函数同类对象定义时的初始化Date(const Date& d),参数是本类类型对象按内存存储按字节序完成浅拷贝,对内置类型会完成浅拷贝,而对自定义类型成员会调用他的拷贝构造函数进行拷贝,若是不显示实现,也会进行浅拷贝。
赋值重载函数已存在的同类对象赋值Date& operator=(const Date& d)赋值运算符在类中不显式实现时,编译器会生成一份默认的赋值运算符重载,完成对象按字节序的浅拷贝。:: .* sizeof ? : . 注意以上5个运算符不能重载。赋值运算符只能重载成成员函数
取地址重载操作符函数对不是const修饰的对象取地址Date* operator&()
const取地址重载操作符函数对是const修饰的对象取地址const Date* operator&()const

构造析构的特性是类似的,我们不写编译器会对内置类型不处理,自定义类型会调用它的构造和析构函数处理

拷贝构造赋值重载特性类似,内置类型会完成浅拷贝,自定义类型会调用他们的拷贝构造和赋值重载

八. 编译器对传值传参和传值返回的优化

传值传参和传值返回都会生成一个拷贝对象,但编译器可能会进行优化。

函数传值传参

void f(Widget w)
{}

int main()
{
    f(Widget());
    // Widget(); // 匿名对象,它没有名字,特点是:它的声明周期只在这一行
}

这里本来是:先构造匿名对象,然后用匿名对象拷贝构造对象 w,但一般情况下编译器会进行优化,直接拿构造出来的匿名对象当作对象 w 来用了。

所以这里:只有一次构造,没有拷贝构造。

注意:下面示例中,传值传参是有一次拷贝构造

void f(Widget w)
{}

int main()
{
    Widget x; // 构造对象x
    f(x);     // 用对象x拷贝构造对象w
}

函数传值返回

Widget f()
{
    Widget w;
    return w;
}

int main()
{
    Widget x = f();
}

一般情况下,一个对象拷贝构造出一个临时对象,一个临时对象再去拷贝构造另一个对象,编译器会进行优化(不同编译器情况可能会有所不同),把中间对象优化掉,直接用第一个对象拷贝构造第三个对象(直接把临时对象当成对象 x 来用了)

所以这里的传值返回,只有一次拷贝构造。

image-20220422110909884


总结:在传值传参和传值返回的过程中,只要是在一个表达式调用的连续步骤中,就可能产生优化。


笔试题

以下代码共调用多少次拷贝构造函数: ( D )

Widget f(Widget u) { 
     Widget v(u);
     Widget w=v;
     return w;
}

int main() {
     Widget x;
     Widget y=f(f(x));
}

A .1

B .3

C .5

D .7 ✔

匿名对象优化问题。

编译器优化行为:在同一个表达式中,产生一个临时对象,再用临时对象去拷贝构造一个对象,那么编译器可能会优化。两个对象合二为一,直接构造出一个对象(C++标准并没有规定要优化,一般新一点的编译器会优化)。

传值返回会拷贝构造一个临时变量

image-20220715164415340

如果返回值被接受,在一个表达式中,连续多个构造函数,才会被编译器优化成一次

image-20220715165023215

image-20220715170727385

传引用返回就不会拷贝构造一个临时变量 。

总结一下:如果编译器要优化,只有构造和拷贝构造才会被优化合并,并且必须是在表达式连续的步骤中(通常是传参数和返回值),优化掉的都是临时对象或者匿名对象

九. 日期类的实现

学完上述有关类的基础知识后,我们就可以着手来尝试实现一个简单的日期来来巩固我们所学习的知识。

9.1 Date.h 文件(框架):

#pragma once

#include<iostream>
#include<cassert>
using namespace std;

class Date
{
public:
	// 获取某年某月的天数
	int GetMonthDay(int year, int month)
	{
		assert(month >= 1 && month <= 12);

		// 每月的天数(这个函数会被频繁调用,每次进来都要重新定义数组,所以将其定义为静态的)
        // 默认是平年
        static int days[] = { 0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
        int day = days[month];
        
        if (month == 2
            && ((year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)))
        {
            day = 29;
        }
        return day;
    }

	// 全缺省的默认构造函数
	Date(int year = 1, int month = 1, int day = 1)
	{
        if (year >= 0 
            && month > 0 && month < 13 
            && day <= GetMonthDay(year,month))
        {
            _year = year;
            _month = month;
            _day = day;
        }
        else
        {
            //这里应该抛出异常
            cout << "输入日期有误!" << endl;
        }
	}

	/* 拷贝构造、赋值运算符、析构函数用编译器自动生成的就可以了(因为Date类是浅拷贝) */
	
	// 日期 += 天数 --> d1 += 100
	Date& operator+=(int day);
    
    // 日期 + 天数 --> d1 + 100
	Date operator+(int day);

	// 日期 -= 天数 --> d1 -= 100
	Date& operator-=(int day);
    
    // 日期 - 天数 --> d1 - 100
	Date operator-(int day);

	// 日期 - 日期(返回相差天数) --> d1 - d2
	int operator-(const Date& d);

	// 注意:按正常的运算符重载规则,无法区分前置++和后置++
	// 为了区分,这里做了一个特殊处理,给后置++增加了一个int参数
	// 这个参数仅仅是为了区分,使前置++和后置++构成重载

	// 前置++
	Date& operator++(); // 编译器会解释为:Date& operator++(Date* const this);

	// 后置++
	Date operator++(int); // 编译器会解释为:Date& operator++(Date* const this, int);

	// 前置--
	Date& operator--();

	// 后置--
	Date operator--(int);
    
	// >运算符重载
	bool operator>(const Date& d)
	{
		if (_year > d._year)
			return true;
		else if (_year == d._year && _month > d._month)
			return true;
		else if (_year == d._year && _month == d._month && _day > d._day)
			return true;
		else
			return false;
	}

	// ==运算符重载
	bool operator==(const Date& d)
	{
		return _year == d._year && _month == d._month && _day == d._day;
	}

    /* 这里我们只需要把>和==运算符重载了,下面的运算符都可以复用其代码了 */
    
	// >=运算符重载
	bool operator>=(const Date& d)
	{
		return *this > d || *this == d;
	}

	// <运算符重载
	bool operator<(const Date& d)
	{
		return !(*this > d);
	}

	// <=运算符重载
	bool operator<=(const Date& d)
	{
		return !(*this > d);
	}

	// !=运算符重载
	bool operator!=(const Date& d)
	{
		return !(*this == d);
	}

	// 打印日期
	void Print()
	{
		cout << _year << "/" << _month << "/" << _day << endl;
	}

private:
	int _year;
	int _month;
	int _day;
};

9.2 Date.cpp 文件:常用运算符重载函数的定义

① 日期 += 天数

返回累加天数后的日期,比如:d1 += 100

注意:d1本身要被更改,天数累加到d1上面去

Date& Date::operator+=(int day)
{
	if (day < 0) // 如果day是负数,就向前计算,相当于 -=
	{
		return *this -= -day; // 调用-=运算符重载函数
	}
	else
	{
		_day += day; // 累加天数

		while (_day > GetMonthDay(_year, _month)) // 表示当前月的天数已经过完了
		{
			_day -= GetMonthDay(_year, _month);   // 减去当前月的天数

			_month++; // 月进位

			if (_month == 13) // 判断当前月份是否合法
			{
				_year++;      // 年进位
				_month = 1;   // 更新为1月
			}
		}
	}
	//有返回值才符合连续赋值的性质
	return *this;

	/* 写法二:复用 + 运算符重载函数的代码
	*this = *this + day; // d1等价于*this,对d1进行+天数操作,再赋值给d1
	return *this;        // 返回d1
	*/
}

② 日期 + 天数

返回累加天数后的日期,比如 :d1 + 100

注意:d1本身不能被更改,天数累加到一个临时对象上面去

// 写法一:
Date Date::operator+(int day)
{
	Date tmp(*this); // 拷贝构造一份临时对象,防止调用本函数的对象被更改

	tmp._day += day; // 累加天数
	while (tmp._day > GetMonthDay(tmp._year, tmp._month)) // 表示当前月的天数已经过完了
	{
		tmp._day -= GetMonthDay(tmp._year, tmp._month);   // 减去当前月的天数

		tmp._month++; // 月进位

		if (tmp._month == 13) // 判断当前月份是否合法
		{
			tmp._year++;      // 年进位
			tmp._month = 1;   // 更新为1月
		}
	}

	return tmp; // 返回临时对象
}

// 写法二:
Date Date::operator+(int day)
{
	/* 复用 += 运算符重载函数的代码 */

	Date tmp(*this); // 拷贝构造一份临时对象
	tmp += day;      // 对临时对象进行 += 天数操作
	return tmp;      // 返回临时对象
}

③ 日期 -= 天数

返回累减天数后的日期,比如:d1 -= 100

Date& Date::operator-=(int day)
{
	if (day < 0) // 如果day小于0,就往后计算,相当于 +=
	{
		return *this += -day; // 调用+=运算符重载函数
	}
	else
    {
        _day -= day; // 累减天数

        while (_day <= 0) // 说明天数不够减了,需要向上一个月去借
        {
            _month--; // 月份-1
            if (_month == 0)
            {
                _year--;
                _month = 12;
            }
            _day += GetMonthDay(_year, _month); // 借上一个月的天数
        }
    }

	return *this;
}

④ 日期 - 天数

返回累减天数后的日期,比如:d1 - 100

Date Date::operator-(int day)
{
	/* 复用 -= 运算符重载函数的代码 */

	Date tmp(*this); // 拷贝构造一份临时对象
	tmp -= day;      // 对临时对象进行 -= 天数操作
	return tmp;      // 返回临时对象
}

⑤ 前置++ 和 后置++

注意:按正常的运算符重载规则,无法区分 前置++ 和 后置++,为了区分,这里做了一个特殊处理,给 后置++ 增加了一个 int 参数,这个参数仅仅是为了区分,使 前置++ 和 后置++ 构成重载。

// 前置++
// ++d1
Date& Date::operator++()
{
	/* 复用 += 运算符重载函数的代码 */

	*this += 1;
	return *this;
}

// 后置++
// d1++
Date Date::operator++(int)
{
	Date tmp(*this); // 保存当前对象自减前的值

	*this += 1; // 复用 += 运算符重载函数的代码

	return tmp; // 返回当前对象自减前的值
}

⑥ 前置-- 和 后置–

// 前置--
// --d1
Date& Date::operator--()
{
	/* 复用 -= 运算符重载函数的代码 */

	*this -= 1;
	return *this;
}

// 后置--
// d1--
Date Date::operator--(int)
{
	Date tmp(*this); // 保存当前对象自减前的值

	*this -= 1; // 复用 -= 运算符重载函数的代码

	return tmp; // 返回当前对象自减前的值
}

⑦ 日期 - 日期

返回相差的天数,有正负之分,比如:d1 - d2

int Date::operator-(const Date& d)
{
	// 思路:让小的日期不断往后++,直到等于大的日期,统计加了多少次,就相差多少天
	
	// 加一个flag变量来控制天数的正负
	// 大的日期 - 小的日期 = 正的天数
	// 小的日期 - 大的日期 = 负的天数

	// 判断出大的日期和小的日期
	Date max = *this;
	Date min = d;
	int flag = 1;

	if (max < min)
	{
		max = d;
		min = *this;
		flag = -1;
	}

	// 让小的日期累加天数,加了多少次,说明就相差了多少天
	int count = 0;
	while (min != max)
	{
		++min;
		++count;
	}

	return flag * count;
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Morning_Yang丶

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值