C++初阶:类和对象

一.面向过程和面向对象

面向过程程序设计:

通过学习C语言,我们发现,面向过程的程序设计是一种自上而下的设计方法,以事件为中心,以功能为导向,分析出解决问题的步骤,按模块划分出程序任务并由函数实现,依次执行各函数,实现功能。其特征是以函数为中心,用函数来作为划分程序的基本单位,数据在过程式设计中往往处于从属的位置。                                                                                                                                                 面向过程的程序设计把数据和数据处理过程分离为相互独立的实体。当数据结构改变时,所有相关的处理过程都要进行相应的修改,每一种相对于老问题的新方法都要带来额外的开销,程序的可用性极差。特别是在大型项目中,面向过程的编程面临着巨大挑战。

面向对象程序设计:

面向对象程序设计描述的是客观世界中的事物,以对象代表一个具体的事物,把数据和数据的操作方法放在一起而形成的一个相互依存又不可分割的整体,再找出同类型对象的共有属性和行为,形成类,这一过程称为抽象。抽象是面向对象编程思想的本质,而类是其关键。类通过外部接口与外界发生关系,避免被外界函数意外改变,对象与对象之家通过消息进行通信,这样就保证了数据的独立性与安全性。封装,继承和多态是面向对象程序设计的三大特征。

小结:

C语言是面向过程的,关注的是过程,分析出求解问题的步骤,通过函数调用逐步解决问题。         C++是基于面向对象的,关注的是对象,将一件事情拆分成不同的对象,靠对象之间的交互完成。

二.类与对象

2.1.类和对象的关系

面向对象的编程思想力求在程序中对事物的描述与该事物在现实中的形态保持一致。为此,面向对象的思想中提出了两个概念:类和对象。其中,类是对某一类事物的抽象描述,对象表示现实中该类事物的个体。类是对多个对象共同特征的抽象描述。是对象的模板。对象用于描述实现中的个体,它是类的实例。

2.2.类的引用

在C语言中,结构体只能用于定义变量,而C++本身是兼容C的语法的,因此我们也可以在C++中使用关键字struct来定义只含变量的结构体:

//C语言
typedef struct ListNode_C
{
	struct ListNode_C* next;
	int val;
}LTNode;

//C++
struct ListNode_CPP
{
	struct ListNode_CPP* next;
	int val;
};

int main()
{

	LTNode st1;

	struct ListNode_CPP st3;
	ListNode_CPP st4;//在C++中,struct成为定义类的关键字,在对象声明中可以省略,但在C中不可省略

	return 0;
}

但是,在C++中,结构体内不仅可以定义变量,也可以定义函数:

//Stack既是类型又是类名
struct Stack
{
	void Init()
	{
		a = 0;
		top = capacity = 0;
	}

	void Push(int x)
	{
		//...
	}

	void Pop()
	{
		//...
	}

	int* a;
	int top;
	int capacity;
};

//上面结构体的定义,在C++中更喜欢用class来代替
int main()
{
	//法一:兼容c
	struct Stack st1;

	//法二:在c++中,Stack既是类名又是类型,前面的struct可以省略不写
	Stack st2;

	st1.Init();
	st1.Push(1);
	st1.Push(2);
	st1.Push(3);

	return 0;
}

小结:

与C语言中定义结构体类似,C++中也可以通过struct关键字定义类。使用struct关键字定义的类与class定义的类的区别是:类中成员默认的访问权限不同

2.3.类的定义

面向对象程序设计的核心就是通过对象来反映现实事物,为了在程序中创建对象,必须首先定义对象的所属类。类是对象的抽象,是一种自定义数据类型,它用于描述一组对象的共同特征和行为。类中可以定义数据成员和成员函数,数据成员用于描述对象特征,成员函数用于描述对象行为,其中数据成员也被称为属性,成员函数也被称为方法。类的定义形式如下所示:

class 类名
{
成员访问限定符:
	数据成员;
成员访问限定符:
	成员函数;
};

下面对类的定义语法进行简要说明:

  1. class是定义类的关键字;
  2. class 后是表示类名的标识符,为了做到见名知意,通常类名由若干单词构成,每个单词的首字符大写。类名和前面的class关键字需要用空格,制表符,换行符等任意的空白字符进行分隔;
  3. 类名后面要写一对大括号,类的成员要在其中说明。在说明成员时,通常使用成员访问限定符说明成员的访问规则;
  4. 右大括号后面的分号“;”表示类定义的结束。

类的两种定义方式:

1.声明和定义全部放在类体中。需要注意:成员函数如果在类中定义,编译器可能会(符合inline条件,即编译指令比较少)将其当成内联函数处理。

C++初阶:类和对象_第1张图片

2.类声明放在.h文件中,成员函数定义放在.cpp文件中。需要注意:成员函数名前需要加类名::

C++初阶:类和对象_第2张图片

小结:

一般情况下,更期望采用第二种方式。

2.4.类的访问限定符及封装

访问限定符

访问限定符声明了类中各个成员的访问权限。C++中可用的访问限定符有public,protected,private三个。下面对三种访问限定符的属性进行说明:

public:

被public修饰的成员也称为公有成员,具有类外交互的能力,可以被该类的其他成员函数及类外的其他函数使用。

private:

被private修饰的成员称为私有成员,只能由类中的函数访问,不可通过该类的其他成员函数及类外的其他函数使用。之所以引用private是因为在面向对象思想中最重要的一个特点就是封装,类中的成员不应该被随意更改,防止成员存入一个不合理的数值,数据操作都应该在可控范围之内。

protected:

被protected修饰的成员称为保护成员,其访问权限介于私有和公有之间,本类的成员和该类的派生类可以访问,类外的其他函数不可以访问。

注意:

  1. 若没有指定成员的访问权限,通过class关键字定义的类中成员默认具有private属性,通过struct关键字定义的类中成员默认具有public属性;
  2. 访问限定符只在编译时有用,当数据映射到内存后,没有任何访问限定符上的区别;
  3. 访问权限作用域从该访问限定符出现的位置开始直到下一个访问限定符出现时为止。如果后面没有访问限定符,作用域就到 } 即类结束。

面试题:

问:C++中struct和class的区别是什么?

答:C++需要兼容C语言,所以C++中struct可以当成结构体使用。另外C++中struct还可以用来定义类。和class定义类是一样的,区别是struct定义的类默认访问权限是public,class定义的类默认访问权限是private。注意:在继承和模板参数列表位置,struct和class也有区别,后序给大家介绍。

封装

封装:将数据和操作数据的方法进行有机结合,隐藏对象的属性和实现细节,仅对外公开接口来和对象进行交互。
封装本质上是一种管理,让用户更方便使用类。                                                                                     在C++语言中实现封装,可以通过类将数据以及操作数据的方法进行有机结合,通过访问权限来
隐藏对象内部实现细节,控制哪些方法可以在类外部直接被使用。

2.5.类的作用域

类定义了一个新的作用域,类的所有成员都在类的作用域中。在类体外定义成员时,需要使用 ::
作用域操作符指明成员属于哪个类域。

class Person
{
public:
	void PrintPersonInfo();
private:
	char _name[20];
	char _gender[3];
	int _age;
};

//这里需要指定PrintPersonInfo是属于Person这个类域
void Person::PrintPersonInfo()
{
	cout << _name << " " << _gender << " " << _age << endl;
}

2.6.类的实例化

用类类型创建对象的过程,称为类的实例化。

类是对对象进行描述的,是一个模型一样的东西,限定了类有哪些成员,定义出一个类并没有分配实际的内存空间来存储它;比如:入学时填写的学生信息表,表格就可以看成是一个类,来描述具体学生信息。

一个类可以实例化出多个对象,实例化出的对象才能实际存储数据,占用物理空间,存储类成员变量。

class Date
{
public:
	void Init(int year, int month, int day)
	{
		_year = year;
		_month = month;
		_day = day;
	}

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

int main()
{
	//类对象实例化:开辟空间
	Date d1;
	Date d2;

	//Person类是没有空间的,只有Person类实例化出的对象才有具体的年龄
	//Date._year = 1;
	
	//d1._year = 1;//私有的,无法直接访问

	return 0;
}

2.7.类对象模型

案例:

class A
{
public:
	void PrintA()
	{
		cout << _a << endl;
	}

private:
	char _a;
};

int main()
{
	A a;
	cout << "a的大小:" << sizeof(a) << endl;

	return 0;
}

问题:

类中既可以有成员变量,也可以有成员函数,那么一个类的对象包含了哪些成员?又如何计算一个对象的大小?

类对象的存储方式猜测

方式一:对象中包含类的各个成员

C++初阶:类和对象_第3张图片

缺陷:

每个对象中成员变量是不同的,但是调用同一份函数,如果按照此种方式存储,当一个类创建多个对象时,每个对象中都会保存一份代码,相同代码保存多次,浪费空间。 

方式二:代码只保存一份,在对象中保存存放代码的地址

C++初阶:类和对象_第4张图片

方式三:只保存成员变量,成员函数存放在公共的代码段

C++初阶:类和对象_第5张图片

问题:

对于上述三种存储方式,那计算机到底是按照哪种方式来存储的? 我们再通过对下面的不同对象分别获取大小来分析看下:

//类中仅有成员函数
class A2
{
public:
	void f2()
	{

	}
};

//空类
class A3
{

};

int main()
{
	A1 a1;
	A2 a2;
	A3 a3;

	cout << "a1的大小:" << sizeof(a1) << endl;
	cout << "a2的大小:" << sizeof(a2) << endl;
	cout << "a3的大小:" << sizeof(a3) << endl;

	return 0;
}

运行结果:

通常我们会认为,每个对象都要为自己的数据成员和成员函数分配空间,但事实并非如此。每个对象的数据成员描述的是本对象自身的属性,例如汽车对象,a汽车是红色,b汽车是白色,因此在创建对象时应该为每个对象分配一块独立的内存来存储数据成员值,与C语言中的普通局部变量一样,类中的普通数据成员也被分配在栈中。但是成员函数描述的是对象执行的动作,每个对象都应相同,为每个对象的成员函数也分配不同空间必然造成浪费。因此C++中用同一段空间存放同类对象的成员函数代码,每个对象调用同一段代码。

结论:

一个类的大小,实际就是该类中”成员变量”之和,当然要注意内存对齐。注意空类的大小,空类比较特殊,编译器给了空类一个字节来唯一标识这个类的对象。 

扩展:结构体内存对齐规则

  1. 第一个成员在与结构体偏移量为0的地址处;
  2. 其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处;注意:对齐数 = 编译器默认的一个对齐数与该成员大小的较小值。VS中默认的对齐数为8;
  3. 结构体总大小为:最大对齐数(所有变量类型最大者与默认对齐参数取最小)的整数倍;
  4. 如果嵌套了结构体的情况,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍。

2.8.this指针

通过前面介绍的知识我们了解到,类中每个对象的数据成员都占用独立空间,但成员函数是共享的,可是各个对象调用相同的函数时,显示的是对象各自的信息,这是如何实现的呢?

this指针的引出

class Date
{
public:
	void Init(int year, int month, int day)
	{
		_year = year;
		_month = month;
		_day = day;
	}

	void Print()
	{
		cout << _year << "-" << _month << "-" << _day << endl;
	}

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

int main()
{
	Date d1, d2;
	d1.Init(2022, 1, 11);
	d2.Init(2022, 1, 12);
	d1.Print();
	d2.Print();

	return 0;
}

对于上述类,有这样的一个问题:
Date类中有 Init 与 Print 两个成员函数,函数体中没有关于不同对象的区分,那当d1调用Init函数时,该函数是如何知道应该设置d1对象,而不是设置d2对象呢?

答:C++中通过引入this指针解决该问题,即:C++编译器给每个“非静态的成员函数“增加了一个隐藏的指针参数,让该指针指向当前对象(函数运行时调用该函数的对象),在函数体中所有“成员变量”的操作,都是通过该指针去访问。只不过所有的操作对用户是透明的,即用户不需要来传递,编译器自动完成

C++初阶:类和对象_第6张图片

我们将上述案例的代码进行改写,可以发现this指针确实是指向当前对象的。

class Date
{
public:
	void Init(int year, int month, int day)
	{
		cout << this << endl;
		this->_year = year;
		this->_month = month;
		this->_day = day;
	}

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

int main()
{
	//对象调用:.
	Date d1;
	Date d2;

	d1.Init(2022, 2, 2);
	cout << "&d1:" << &d1 << endl;

	cout << "------------" << endl;

	d2.Init(2022, 2, 2);
	cout << "&d2:" << &d2 << endl;

	return 0;
}

运行结果:

this指针的特性

  1. this指针的类型:类类型* const,即成员函数中,不能给this指针赋值。this指针是被const常所修饰的,为指针常量,指针本身的指向是不可修改的,但可以通过解引用的方式来修改指针所指向的内容;
  2. 只能在“成员函数”的内部使用;
  3. this指针本质上是“成员函数”的形参,当对象调用成员函数时,将对象地址作为实参传递给this形参。所以对象中不存储this指针;
  4. this指针是“成员函数”第一个隐含的指针形参,一般情况由编译器通过ecx寄存器自动传递,不需要用户传递。

面试题:

this指针存在哪里?

答:很多人认为this指针存在对象里面的,但其实并不是。C++规定,当一个成员函数被调用时,系统自动向它传递一个隐含的参数,也就是this指针是作为形参而存在的。而形参和函数中的局部变量都是存在函数栈帧里的,所以 this 指针可以认为是存在栈的。需要注意的是:VS下为了提高效率,编译器会对this指针的存储位置进行优化:将this指针存储在寄存器中,以此来提高读取和访问数据的效率。

this指针可以为空吗?

案例一:下面程序编译运行结果是?

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

private:
	int _a;
};

int main()
{
	A* p = nullptr;
	p->Print();

	return 0;
}

运行结果:

我们本以为对一个空指针进行解引用,会导致程序崩溃,但是并没有。当我们对this指针进行查看时,可以发现:

可以看到this指针所接收到的地址为空,因为我们在外界调用这个函数的对象就是一个指向为空的指针。成员函数的地址是存放在公共代码区的,并不是存放在对象中。因此调用成员函数Print时,并不会去访问指针p所指向的空间,这里只会把指针p传递给隐含的this指针,所以也就不存在空指针的解引用。

案例二:下面程序编译运行结果是?

class Date
{
public:
	void Init(int year, int month, int day)
	{
		cout << this << endl;
		this->_year = year;
		this->_month = month;
		this->_day = day;
	}

	void func()
	{
		cout << this << endl;
		cout << "func()" << endl;
	}

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

int main()
{
	//空指针问题
	
	//指针调用:->
	Date* ptr=nullptr;
	ptr->Init(2022, 2, 2);//运行崩溃:要用this去解引用成员变量

    ptr->func();//正常运行:不涉及this解引用
	(*ptr).func();//正常运行:不涉及this解引用

	return 0;
}

调试分析:

C++初阶:类和对象_第7张图片

当我们对程序进行调试时,Init初始化函数会发生空指针异常。因为在Init函数里访问成员变量时会补充上this->_year。由上一个案例可知,此时的隐含指针this是一个空指针,而对一个空指针进行解引用则会导致程序运行崩溃。

补充:

在使用指针ptr去调用成员函数时,使用->*均可。同时,我们可以在函数中使用this指针访问成员变量或调用成员函数:

(*this).成员变量或函数
this->成员变量或函数

由于this指针是指向当前对象的指针,所以我们可以在函数中把this指针当参数使用,或从函数中返回,用作返回值,形式如下所示:

return this;
return *this;

凡是想在成员函数中操作当前对象,都可以通过this指针完成。

小结:

this指针是存放在栈区的,它只是成员函数的一个隐式形参,但是却不和成员函数一样存放在公共代码区。成员函数只是编译器将其解析后的汇编指令存放在公共代码区,而对于函数内部的形参和临时变量依旧存放在栈区;
this指针可以为空,如果只是传递空对象地址但是并没有对其进行解引用,则不会引发异常。但若是在成员函数内部访问成员变量的话,无论你有无给出this->,都会因为对空指针进行解引用而导致程序异常。

三.构造函数和析构函数

从前面学到的知识可以发现,实例化了一个类的对象后,若想为对象中的数据成员赋值,需要直接访问成员或调用设置成员值的函数。若想在实例化对象的同时就为对象的数据成员进行赋值,可以通过调用构造函数的方法来实现。与之对应的,如果想在操作完对象之后,回收对象资源,可以通过调用析构函数来实现。构造函数和析构函数是类的特殊成员,它们分别在对象创建和撤销时被自动调用。

3.1.构造函数

构造函数是类中特殊的成员函数,用于初始化对象的数据成员。

3.1.1.构造函数概念

构造函数是一个特殊的成员函数,名字与类名相同,创建类类型对象时由编译器自动调用,以保证每个数据成员都有一个合适的初始值,并且在对象整个生命周期内只调用一次。

其语法格式如下:

class 类名
{
public:
	构造函数名称(参数表)
	{
		函数体
	}
private:
	数据成员;
};

构造函数的定义语法规定:

  1. 构造函数名与类名相同;
  2. 构造函数名前面没有返回值类型声明;
  3. 构造函数中不能通过return语句返回一个值;
  4. 通常构造函数具有public属性。

3.1.2.构造函数特征

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

特征一:构造函数名与类名相同;

特征二:构造函数名前面没有返回值类型声明;

特征三:对象实例化时编译器自动调用对应的构造函数;

特征四:构造函数可以重载;

通常我们希望能在对象创建时为数据成员提供有效初值,通过定义带参数的构造函数可以实现这样的功能。此外还可定义多个具有不同参数的构造函数,实现对不同数据成员的初始化。定义多个构造函数也就是构造函数的重载。

class Stack
{
public:
	//无参构造函数
	Stack()
	{
		cout << "不带参数:Stack()" << endl;
		_a = nullptr;
		_size = _capacity = 0;
	}

	//带参构造函数
	Stack(int n)
	{
		cout << "带参数:Stack()" << endl;

		_a = (int*)malloc(sizeof(int) * n);
		if (nullptr == _a)
		{
			perror("malloc申请空间失败");
			return;
		}

		_capacity = n;
		_size = 0;
	}

	void Push(int x)
	{
		//...
		_a[_size++] = x;
	}

	//...

	bool Empty()
	{
		//...
		return _size == 0;
	}

	int Top()
	{
		return _a[_size - 1];
	}

private:
	//成员变量
	int* _a;
	int _size;
	int _capacity;
};


int main()
{
	//对象实例化时编译器自动调用对应的构造函数
	//调用无参构造函数
	Stack st;
	//注意:如果通过无参构造函数创建对象时,对象后面不用跟括号,否则就成了函数声明
	//Stack st();//error

	//调用带参构造函数
	Stack st(4);

	st.Push(1);
	st.Push(2);
	st.Push(3);
	st.Push(4);

	return 0;
}

注意:

如果通过无参构造函数创建对象时,对象后面不用跟括号,否则就成了函数声明。

特征五:如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义编译器将不再生成;

只要类中定义了一个构造函数,C++将不再提供默认的构造函数。如果在类中定义的是带参构造函数,创建对象时想使用不带参数的构造函数,则需要再实现一个无参的构造函数,否则编译错误。如下所示:

class Date
{
public:
	//不带参
	//通常自己定义的无参构造函数将数据成员初始化为固定值
	/*Date()
	{
		_year = 1;
		_month = 1;
		_day = 1;
	}*/

	//带参
	Date(int year, int month, int day)
	{
		_year = year;
		_month = month;
		_day = day;
	}

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

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

int main()
{
	Date d1;

	return 0;
}

运行结果:

从运行结果可以看出,在通过调用无参构造函数创建对象时出错,因为类中已提供了一个带参数的构造函数,编译器不再提供默认的构造函数,若想调用无参构造函数必须显示定义。

特征六:在不实现构造函数的情况下,编译器会生成默认的构造函数。但是默认构造函数又没什么用:因为在d对象调用编译器生成的默认构造函数时,d对象的数据成员_year/_month/_day依旧是随机值。也就是说编译器生成的默认构造函数并没有什用。

如下所示:

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

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

int main()
{
	Date d;
	d.Print();

	return 0;
}

运行结果:

原因如下:

C++把类型分成内置类型(基本类型)和自定义类型。内置类型就是语言提供的数据类型,如:int/char...,自定义类型就是我们使用class/struct/union等自己定义的类型。看看下面的程序,就会发现编译器生成默认的构造函数会对自定类型成员_t调用的它的默认成员函数。

如下所示:

class Time
{
public:
	Time()
	{
		cout << "Time()" << endl;
		_hour = 0;
		_minute = 0;
		_second = 0;
	}

private:
	int _hour;
	int _minute;
	int _second;
};

class Date
{
private:
	//基本类型(内置类型)
	int _year;
	int _month;
	int _day;

	//自定义类型
	Time _t;
};

int main()
{
	Date d;
	return 0;
}

调试分析:

C++初阶:类和对象_第8张图片

运行结果:

分析:

C++中允许将一个已定义的类对象作为另一个类的数据成员,即类中的数据成员可以是其他类的对象,称这种成员是类的子对象或成员对象若类X中有成员对象,则创建X类对象时,先执行成员对象的构造函数,初始化成员对象,再执行类X的构造函数初始化其他非对象成员。并且,若类X的成员对象的构造函数带有参数,则定义X类的构造函数时,应使用初始化表的形式对成员对象进行初始化。

小结:

默认生成的构造函数(内置类型就是语言提供的数据类型,如:int / char...,自定义类型就是我们使用class / struct / union等自己定义的类型)

  1. 内置类型成员不作处理;
  2. 自定义类型的成员,会去调用它的默认构造(不用传参数的构造:无参的,全缺省的以及编译器自动生成的)。

注意:

C++11 中针对内置类型成员不初始化的缺陷,又打了补丁,即:内置类型成员变量在类中声明时可以给默认值。 

class Time
{
public:
	Time()
	{
		cout << "Time()" << endl;
		_hour = 0;
		_minute = 0;
		_second = 0;
	}

private:
	int _hour;
	int _minute;
	int _second;
};

class Date
{
private:
	//基本类型(内置类型)
	int _year = 1970;
	int _month = 1;
	int _day = 1;

	//自定义类型
	Time _t;
};

int main()
{
	Date d;
	return 0;
}

调试分析:

C++初阶:类和对象_第9张图片

运行结果:

特征七:无参的构造函数和全缺省的构造函数都称为默认构造函数,并且默认构造函数只能有一个。注意:无参构造函数、全缺省构造函数、我们没写编译器默认生成的构造函数,都可以认为
是默认构造函数。

如下所示:

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;
};

int main()
{
	Date d;

	return 0;
}

运行结果:

3.2.析构函数

前面介绍的构造函数用于在创建对象时完成数据成员的初始化,与之对应,对象生命期结束前应该完成对象资源的清理,这个工作由析构函数完成。比如创建对象时为数据成员开辟的空间,会通过析构函数在对象的生命期结束前进行释放。

3.2.1.析构函数概念

析构函数与构造函数功能相反,析构函数不是完成对对象本身的销毁,局部对象销毁工作是由编译器完成的。而对象在销毁时会自动调用析构函数,完成对象中资源的清理工作。

析构函数是类中特殊的成员函数,用于完成资源清理,析构函数的定义形式如下所示:

类名::~析构函数()
{
	函数体
}

3.2.2.析构函数特征

特征一:析构函数名是在类名前加上字符 ~;

特征二:析构函数没有参数且不能通过return语句返回一个值;

特征三:一个类只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。注意:析构函数不能重载;

特征四:对象生命周期结束时,C++编译系统系统自动调用析构函数;

class Stack
{
public:
	//无参构造函数
	Stack()
	{
		cout << "不带参数:Stack()" << endl;
		_a = nullptr;
		_size = _capacity = 0;
	}

	//带参构造函数
	Stack(int n)
	{
		cout << "带参数:Stack()" << endl;

		_a = (int*)malloc(sizeof(int) * n);
		if (nullptr == _a)
		{
			perror("malloc申请空间失败");
			return;
		}

		_capacity = n;
		_size = 0;
	}

	void Push(int x)
	{
		//...
		_a[_size++] = x;
	}

	//...

	bool Empty()
	{
		//...
		return _size == 0;
	}

	int Top()
	{
		return _a[_size - 1];
	}

	//析构函数
	//对象生命周期结束时,C++编译系统系统自动调用析构函数,不需要手动调用
	~Stack()
	{
		cout << "~Stack()" << endl;

		free(_a);
		_a = nullptr;
		_size = _capacity = 0;
	}

private:
	//成员变量
	int* _a;
	int _size;
	int _capacity;
};


int main()
{
	//调用带参构造函数
	Stack st(4);

	st.Push(1);
	st.Push(2);
	st.Push(3);
	st.Push(4);

	return 0;
}

运行结果:

特征五:关于编译器自动生成的析构函数,是否会完成一些事情呢?下面的程序我们会看到,编译器生成的默认析构函数,对自定类型成员调用它的析构函数。

class Time
{
public:
	~Time()
	{
		cout << "~Time()" << endl;
	}

private:
	int _hour;
	int _minute;
	int _second;
};

class Date
{
private:
	// 基本类型(内置类型)
	int _year = 1970;
	int _month = 1;
	int _day = 1;
	// 自定义类型
	Time _t;
};

int main()
{
	Date d;
	return 0;
}

运行结果:

分析:
在main方法中根本没有直接创建Time类的对象,为什么最后会调用Time类的析构函数?
因为:main方法中创建了Date对象d,而d中包含4个成员变量,其中_year, _month,_day三个是内置类型成员,销毁时不需要资源清理,最后系统直接将其内存回收即可;而_t是Time类对象,所以在d销毁时,要将其内部包含的Time类的_t对象销毁,所以要调用Time类的析构函数。但是:main函数中不能直接调用Time类的析构函数,实际要释放的是Date类对象,所以编译器会调用Date类的析构函数,而Date没有显式提供,则编译器会给Date类生成一个默认的析构函数,目的是在其内部调用Time类的析构函数,即当Date对象销毁时,要保证其内部每个自定义对象都可以正确销毁。

main函数中并没有直接调用Time类析构函数,而是显式调用编译器为Date类生成的默认析构函数。注意:创建哪个类的对象则调用该类的析构函数,销毁哪个类的对象则调用该类的析构函数。

小结:

默认生成的析构函数(内置类型就是语言提供的数据类型,如:int / char...,自定义类型就是我们使用class / struct / union等自己定义的类型)

  1. 对内置类型的成员不作处理;
  2. 对自定义类型的成员,会调用它的默认析构函数。

从运行结果看出,对象生命期结束时析构函数被自动调用,完成对资源的清理工作。若定义了多个类对象,析构函数的调用顺序和构造函数的调用顺序相反,后创建的对象先析构,先创建的对象后析构。

类型六:如果类中没有申请资源时,析构函数可以不写,直接使用编译器生成的默认析构函数,比如Date类;有资源申请时,一定要写,否则会造成资源泄漏,比如Stack类。

3.3.拷贝构造函数

通过学习构造函数,我们已经了解到可以通过编写带参数的构造函数完成对象属性的初始化。除此之外,我们可能还希望能根据已定义好的类对象完成新对象的属性初始化,就像定义了int a=3后,能够定义新变量int b=a,用a初始化同类型的新变量b。对于类来说,就是希望能够定义一个以类对象作为参数的构造函数,这就是本节要介绍的拷贝构造函数。

3.3.1.拷贝构造函数概念

拷贝构造函数是使用类对象的引用作为参数的构造函数,它能够将参数的属性值拷贝给新的对象,完成新对象的初始化。拷贝构造函数的定义形式如下所示:

class 类名
{
public:
	构造函数名称(类名& 变量名)
	{
		函数体
	}
	...
};

拷贝构造函数,只有单个形参,该形参是对本类类型对象的引用(一般常用const修饰),在用已存在的类类型对象创建新对象时由编译器自动调用。

3.3.2.拷贝构造函数特征

特征一:拷贝构造函数是构造函数的一个重载形式;

特征二:拷贝构造函数的参数只有一个且必须是类类型对象的引用,使用传值方式编译器直接报错,因为会引发无穷递归调用;

当我们将拷贝构造函数以传值方式进行编译时,如下所示:

class Date
{
public:
	//全缺省的构造函数
	Date(int y = 2000, int m = 1, int d = 1)
	{
		_year = y;
		_month = m;
		_day = d;
	}

	//拷贝构造函数
	Date(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;
}

运行结果:

通过编译可以发现程序运行出错,并且提示没有可用的复制构造函数,那这是为何呢?

我们先来回顾一下前期所学的函数传参的三种方式:传值,传引用以及传地址:

class Date
{
public:
	//全缺省的构造函数
	Date(int y = 2000, int m = 1, int d = 1)
	{
		_year = y;
		_month = m;
		_day = d;
	}

	//拷贝构造函数
	Date(Date& d)
	{
		cout << "Date(Date& d)" << endl;
		_year = d._year;
		_month = d._month;
		_day = d._day;
	}

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

//传值传参
//内置类型,编译器可以直接拷贝
//自定义类型的拷贝,需要调用拷贝构造(深拷贝的拷贝构造)
void Func1(Date d)
{
	cout << "Func1(Date d)" << endl;
}

//传引用传参
void Func2(Date& d)
{
	cout << "Func2(Date& d)" << endl;
}

//传地址传参
void Func3(Date* d)
{
	cout << "Func3(Date* d)" << endl;
}

int main()
{
	Date d1;
	
	//拷贝构造
	Date d2(d1);
	cout << "------" << endl;

	//传值
	Func1(d1);
	cout << "------" << endl;

	//传引用
	Func2(d1);
	cout << "------" << endl;

	//传地址
	Func3(&d1);
	cout << "------" << endl;

	return 0;
}

运行结果:

C++初阶:类和对象_第10张图片

分析:

Func1()是进行传值传参,因为传值传参的过程中会产生一个临时变量,所以首先会去调用Date类的拷贝构造函数生成一个临时变量,然后再去调用Func1()自身;Func2()是进行传引用传参,d是d1的别名,中间不会产生临时变量,直接调用Func2()自身;Func3()是进行传地址传参,类似于引用传参,中间也不会产生临时变量,直接调用Fun3()自身。

因此:

拷贝构造函数的形参必须为引用,即参数前加上“&”。使用引用是因为如果使用传值的方式将实参传给形参,中间过程需要经历一次对象拷贝的过程。而对象拷贝又需要调用拷贝构造函数,因此为了防止无限的构造,形成死循环递归,拷贝构造函数的形参必须为对象的引用。

C++初阶:类和对象_第11张图片

拷贝构造函数形参可以是const引用,也可以是非const引用。但是一般使用const,这样既能以常量对象(初始化后值不能改变的对象)作为参数去初始化别的对象,也能以非常量对象作为参数去初始化别的对象。

C++初阶:类和对象_第12张图片

延伸:

在函数传参的过程中,传地址与传引用功能相似,但是却不能通过传指针来实现拷贝构造函数。它不是拷贝构造函数,只是一个简单的构造函数。

C++初阶:类和对象_第13张图片

特征三:若未显式定义,编译器会生成默认的拷贝构造函数。默认的拷贝构造函数对象按内存存储按字节序完成拷贝,这种拷贝叫做浅拷贝,或者值拷贝。

跟构造函数和析构函数一样,如果用户不显示地定义拷贝构造函数,那么编译器就会自动生成拷贝构造函数。如下所示:

class Time
{
public:
	Time()
	{
		cout << "Time()" << endl;
		_hour = 1;
		_minute = 1;
		_second = 1;
	}

	Time(const Time& t)
	{
		cout << "Time::Time(const Time&)" << endl;
		_hour = t._hour;
		_minute = t._minute;
		_second = t._second;
	}
private:
	int _hour;
	int _minute;
	int _second;
};

class Date
{
private:
	//基本类型(内置类型)
	int _year = 1970;
	int _month = 1;
	int _day = 1;

	//自定义类型
	Time _t;
};

int main()
{
	Date d1;
	Date d2(d1);

	return 0;
}

调试分析:

C++初阶:类和对象_第14张图片

分析:

用已经存在的d1拷贝构造d2,此处会调用Date类的拷贝构造函数,但Date类并没有显式定义拷贝构造函数,则编译器会给Date类生成一个默认的拷贝构造函数。

注意:

在编译器生成的默认拷贝构造函数中,内置类型是按照字节方式直接拷贝的,而自定义类型是调用其拷贝构造函数完成拷贝的。

类型四:编译器生成的默认拷贝构造函数只能完成按字节序的值拷贝,像日期类这样的类是没必要自己实现拷贝构造函数的,而像Stack这样的类,涉及到资源申请时,则一定要自己实现。

如下所示:

typedef int DataType;
class Stack
{
public:
	Stack(size_t capacity = 10)
	{
		_array = (DataType*)malloc(capacity * sizeof(DataType));
		if (nullptr == _array)
		{
			perror("malloc申请空间失败");
			return;
		}

		_size = 0;
		_capacity = capacity;
	}

	void Push(const DataType& data)
	{
		_array[_size] = data;
		_size++;
	}

	~Stack()
	{
		if (_array)
		{
			free(_array);
			_array = nullptr;
			_capacity = 0;
			_size = 0;
		}
	}

private:
	DataType* _array;
	size_t _size;
	size_t _capacity;
};

int main()
{
	Stack s1;
	s1.Push(1);
	s1.Push(2);
	s1.Push(3);
	s1.Push(4);

	Stack s2(s1);

	return 0;
}

调试分析:

析构前:

C++初阶:类和对象_第15张图片

析构后:

C++初阶:类和对象_第16张图片

分析:

C++初阶:类和对象_第17张图片

注意:

类中如果没有涉及资源申请时,拷贝构造函数是否写都可以;一旦涉及到资源申请时,则拷贝构造函数是一定要写的,否则就是浅拷贝。 

3.3.3.拷贝构造函数使用场景

场景一:使用已存在对象创建新对象;                                                                                                         通过拷贝构造函数根据已创建好的对象初始化新对象。
场景二:函数参数类型为类类型对象;                                                                                                         当函数的实参为对象时,函数调用拷贝构造函数将实参传递给形参。
场景三:函数返回值类型为类类型对象。                                                                                                    当函数返回值为类对象时,将调用拷贝构造函数将返回值复制到临时对象中,用于数据传出。

如下所示:

class Date
{
public:
	Date(int year, int minute, int day)
	{
		cout << "Date(int,int,int):" << this << endl;
	}

	Date(const Date& d)
	{
		cout << "Date(const Date& d):" << this << endl;
	}

	~Date()
	{
		cout << "~Date():" << this << endl;
	}

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

Date Test(Date d)
{
	Date temp(d);
	return temp;
}

int main()
{
	Date d1(2022, 1, 13);
	Test(d1);

	return 0;
}

运行结果:

C++初阶:类和对象_第18张图片

分析: 

C++初阶:类和对象_第19张图片

小结:

为了提高程序效率,一般对象传参时,尽量使用引用类型,返回时根据实际场景,能用引用尽量使用引用

浅拷贝:在拷贝构造函数中,直接完成属性的值拷贝操作(默认的拷贝构造函数,就是浅拷贝);
深拷贝:在拷贝构造函数中,创建出来新的空间,新的属性中的指针指向的是一个新的空间。

3.3.4.模拟实现简单日期计算器

描述:

模拟日期计算器的实现:计算从某天开始到往后的某一天是几月几日,即做日期类的加法运算。平时我们所接触的加法运算都是满10进1,但是对于日期类而言,通常则是满足30天或者31天则会发生进位。除此之外,还要考虑平/闰年的二月份(28/29天)这种特殊情况。

实现:

class Date
{
public:
	//全缺省的构造函数
	Date(int year = 1990, int month = 1, int day = 1)
	{
		_year = year;
		_month=month;
		_day = day;
	}

	//拷贝构造函数的参数只有一个且必须是类类型对象的引用,使用传值方式编译器直接报错,因为会引发无穷递归调用
	//加const是为了防止对d进行修改
	Date(const Date& d)
	{
		cout << "Date(Date & d)" << endl;
		_year = d._year;
		_month = d._month;
		_day = d._day;
	}

	//获取每月对应的天数
	int GetMonthDay(int year, int month)
	{
		//判断所输入的月份是否合法
		assert(month > 0 && month < 13);
		int monthArray[13] = { 0,31,28,31,30,31,30,31,31,30,31,30,31 };//第一个位置空出不用,采用枚举法列出每个月的天数

		//如果是闰月
		if (month == 2 && ((year % 4 == 0 && year % 100 != 0) || (year % 400) == 0))
		{
			//将二月的天数修改为29天
			return 29;
		}
		else
		{
			//获取数组中二月的天数:28天
			return monthArray[month];
		}
	}

	//函数返回值类型为类类型对象
	//Date GetAfterXDay(int x)
	//{
	//	//拷贝构造:防止d1的成员被修改
	//	Date tmp(*this);
	//	//Date tmp=*this;

	//	tmp._day += x;

	//	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;
	//		}
	//	}

	//	//获取日期:返回自身
	//	return tmp;
	//}

	//+:不需要改变自己
	Date Add(int x)
	{
		//拷贝构造
		Date tmp(*this);
		//Date tmp=*this;

		tmp._day += x;

		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;
			}
		}

		//获取日期:返回自身
		//传值返回,返回的不是tmp,返回的是tmp的拷贝,而返回tmp的拷贝,是通过调用拷贝构造完成的
		//出了作用域,tmp就不在了
		return tmp;
	}

	//+=:需要改变自己
	//传引用,可以减少拷贝
	//函数返回时,出了函数作用域,如果返回对象还在(还没还给系统),则可以使用引用返回,如果已经还给系统了,则必须使用传值返回。
	Date& AddEqual(int x)
	{
		_day += x;

		while (_day > GetMonthDay(_year, _month))
		{
			//进位
			_day -= GetMonthDay(_year, _month);
			++_month;

			if (_month == 13)
			{
				_year++;
				_month = 1;
			}
		}

		//获取日期:返回自身
		//出了作用域,*this还在,而*this又表示d1
		//用引用返回,就可以返回它的别名,减少拷贝
		return *this;
	}

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

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

int main()
{
	//实现一个函数,获取多少天以后的一个日期
	Date d1(2023, 2, 3);
	Date d2 = d1.Add(100);
	Date d3 = d1.Add(150);

	d1.Print();
	d2.Print();
	d3.Print();

	d1.AddEqual(200);
	d1.Print();

	return 0;
};

运行结果:

C++初阶:类和对象_第20张图片

四.运算符重载

C++的一大特性就是重载,重载使得程序更加简洁高效在C++中不只函数可以重载,运算符也可以重载,由于一般的数据类型间的运算符没有重载的必要,所以运算符重载主要是面向对象之间的。

4.1.运算符重载

在C++中,运算符的操作对象只能是基本数据类型。实际编程中,对于很多用户自定义的类型(如类,结构体等),也需要运算操作。而运算符重载解决了这个问题。运算符重载会对已有的运算符赋予多重含义,使同一个运算符作用于不同类型的数据时做出不同的行为。

4.1.1.运算符重载概念

已知有日期类Date,其定义代码如下所示:

class Date
{
public:
	Date(int year = 0, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;	
	}
private:
	int _year;
	int _month;
	int _day;
};

根据上述代码,创建两个对象d1和d2,如果让两个对象直接进行四则运算显然是不行的,因为在C++中,运算符的操作对象只能是基本数据类型,那么如果要实现“d1==d2”这样的四则运算,我们就需要定义实现等于运算的函数。代码如下:

//使用引用传参,可以减少空间的创建
//d1==d2
bool Equal(const Date& x1, const Date& x2)
{
	//...

}

这样就实现了两个对象的四则运算,但有一点,这样看起来并不直观,而且如果要实现多种运算,如减法,乘法,大于,小于等,这样定义多个函数,函数名各种各样,那么使用起来很不方便。

针对上述情况,我们可以考虑用运算符作为函数名,这就是C++提供的运算符重载机制,重载运算符是具有特殊名字的函数:它们的名字由关键字operator和其后要重载的运算符共同组成。和其他函数一样,重载运算符的函数也包括返回类型,参数列表及函数体,其语法格式如下所示:

返回类型 operator 运算符(参数列表)
{
	函数体;
}

在上述语法格式中,operator是运算符重载的关键字,运算符就是要重载的运算符的名称(如+,-,*,/等),但必须是C++运行重载的运算符。接下来我们就以重载日期类Date的“==”运算符为例来讲解如何进行运算符重载。

类外:全局的operator==:

//全局的operator==
class Date
{
public:
	Date(int year = 1990, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	}

	void Print()
	{
		cout << _year << "/" << _month << "/" << _day << endl;
	}

public://为了便于类外函数的访问,将数据成员设置为公有
	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;
}

int main()
{
	Date d1(2023, 2, 4);
	Date d2(2023, 3, 4);

	//类外定义的全局函数
	cout << operator==(d1, d2) << endl;
	cout << (d1 == d2) << endl;//要加括号,否则会报错,因为运算符的优先级不同

	return 0;
}

这里会发现运算符重载成全局的就需要成员变量是公有的,那么问题来了,封装性如何保证?
这里其实可以用我们后面学习的友元解决,或者干脆重载成成员函数。

类内:成员函数operator==:

class Date
{
public:
	Date(int year = 1990, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	}

	void Print()
	{
		cout << _year << "/" << _month << "/" << _day << endl;
	}

	//bool operator==(const Date& x1, const Date& x2)//err
	//{
	//	return x1._year == x2._year && x1._month == x2._month && x1._day == x2._day;
	//}

	//d1==d2 转换成 d1.operator(d2)
	//成员函数默认含有一个隐藏的参数
	//bool operator==(Date* this, const Date& d2)
	//左操作数是this,指向调用函数的对象
	bool operator==(const Date& d)
	{
		return _year == d._year && _month == d._month && _day == d._day;
	}

private://为了便于类外函数的访问,将数据成员设置为公有
	int _year;
	int _month;
	int _day;
};

int main()
{
	Date d1(2023, 2, 4);
	Date d2(2023, 3, 4);

	//类外定义的全局函数
	cout << d1.operator==(d2) << endl;
	cout << (d1 == d2) << endl;//要加括号,否则会报错,因为运算符的优先级不同

	return 0;
}

需要说明的是:如果是双目运算符重载为类的成员函数,则它有两个操作数,左操作数是对象本身的数据,由this指针指出,右操作数则通过运算符重载函数的参数表来传递。其一般调用格式如下所示:

左操作数.运算符重载函数(右操作数);

如上述重载"=="运算符,当调用d1==d2时,其实就相当于函数调用d1.operator==(d2);

小结:

对于d1 == d2,编译器会自动转换。如果定义为全局函数,它会转换成operator==(d1, d2);如果定义为类的成员函数,它会转换成d1.operator(d2)。因此不管是定义为全局函数还是类的成员函数一般我们都是直接写d1 == d2。

扩展:

运算符重载和函数重载是不同的:

  1. 函数重载:支持函数名相同,参数不同的函数,同时可用;
  2. 运算符重载:自定义类型对象可以使用运算符。

4.1.2.运算符重载规则

规则如下:

  1. 重载操作符必须有一个类类型参数;
  2. 作为类成员函数重载时,其形参看起来比操作数数目少1,因为成员函数的第一个参数为隐
    藏的this;
  3. 只能重载C++中已有的运算符,不能创建新的运算符,比如operator@;
  4. 重载之后的运算符不能改变其优先级和结合性,也不能改变其操作数的个数及语法结构;
  5. 运算符重载是针对新类型数据的实际操作,对原有运算符进行的适当改造,重载的功能应当与原有功能向类似,避免没有目的地使用重载运算符;
  6. C++中有5个运算符不可能重载:类属关系运算符“.”,成员指针运算符“*”,作用域运算符“::”,sizeof运算符和三目运算符“?:”。 

4.1.3关系运算符重载

关系运算符一般都返回true或false值,重载关系运算符时保留了这些运算符的正常用法,同时允许用在条件表达式中。C++中常用的关系运算符有6个:==,!=,>,>=,<,<=。

class Date
{
public:
	Date(int year = 1990, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	}

	void Print()
	{
		cout << _year << "/" << _month << "/" << _day << endl;
	}

	//d1==d2 转换成 d1.operator(d2)
	//成员函数默认含有一个隐藏的参数
	//左操作数是this,指向调用函数的对象
	bool operator==(const Date& d)
	{
		return _year == d._year && _month == d._month && _day == d._day;
	}

	//d1d2
	bool operator>(const Date& d)
	{
		return !(*this <= d);
	}

	//d1>=d2
	bool operator>=(const Date& d)
	{
		return !(*this < d);
	}

	//d1!=d2
	bool operator!=(const Date& d)
	{
		return !(*this == d);
	}

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

4.1.4.输入输出运算符重载

IO标准库使用">>"和“<<”运算符执行输入输出操作,对于这两个运算符来说,IO库定义了其基本数据类型的操作,但若要直接对类对象进行输入输出,则需要在类中重载这两个运算符。

输入输出运算符不能重载为类的成员函数,因为它们左侧的运算对象必须是istream/ostream,而如果重载成类的成员函数,则左侧的操作对象将是我们定义的一个类对象。

重载"<<"和">>"运算符的一般格式如下所示:

//流插入
ostream& operator<<(ostream&, const 类对象引用);
//流提取
istream& operator>>(istream&, 类对象引用);

对于输出运算符"<<"来说,第一个参数是ostream对象引用,因为向流中写入数据会改变流的状态,所以不能用const修饰ostream对象。对于输入运算符来说,第一个参数是istream对象的引用,第二个参数要向其中存入数据的对象,不能为常量。

class Date
{
public:
	//获取某年某月的天数
	int GetMonthDay(int year, int month) const
	{
		//判断月份是否合法
		assert(month > 0 && month < 13);

		int monthArray[13] = { 0,31,28,31,30,31,30,31,31,30,31,30,31 };//第一个位置空出不用,并将二月的天数设为非闰月28天

		//判断二月是否为闰月
		if (month == 2 && ((year % 4 == 0 && year % 100 != 0) || (year % 400) == 0))
		{
			return 29;
		}
		else
		{
			return monthArray[month];
		}
	}

	Date(int year, int month, int day)
	{
		//合法性检查
		if ((month > 0 && month < 13) && (day > 0 && day <= GetMonthDay(year, month)))
		{
			_year = year;
			_month = month;
			_day = day;
		}
		else
		{
			cout << "日期非法" << endl;
		}
	}

	//友元函数:可以在外部访问私有成员变量
	//流插入
	friend ostream& operator<<(ostream& out, const Date& d);
	//流提取
	friend istream& operator>>(istream& in, Date& d);

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


//不返回this,只有成员函数才会用this返回自身
//自定义类型的流插入
ostream& operator<<(ostream& out, const Date& d)
{
	out << d._year << "年" << d._month << "月" << d._day << "日" << endl;
	return out;
}

//不加const
//自定义类型的流提取
istream& operator>>(istream& in, Date& d)
{
	in >> d._year >> d._month >> d._day;
	return in;
}

int main()
{
	//自定义类型的流插入
	Date d1(2023, 10, 10);
	Date d2(2023, 10, 12);

	cout << "d1:" << d1 << endl;
	cout << "d2:" << d2 << endl;

	cout << "请重新为d2对象输入数据:" << endl;
	cin >> d2;
	cout << "重新输入后d2:" << d2 << endl;
	
	return 0;
}

4.1.5.下标运算符重载

我们经常使用"[ ]"来访问数组中的元素,为了在类中也方便使用"[ ]",可以在类中重载这个运算符。重载“[ ]”运算符可以实现两个目的:

  1. “对象[下标]”的形式类似于“数组[下标]”,更加符合习惯;
  2. 可以对下标进行越界检查。

重载下标运算符的一般格式如下所示:

返回类型 operator[] (参数列表);

上述格式中,“[ ]”运算符重载函数有且只有一个参数,表示下标值,通常为整型。重载下标运算符时一般把返回值指定为一个引用。

class Array
{
public:
	int& operator[](int i)
	{
		assert(i < 10);
		return _a[i];
	}

	//重载:参数类型不同
	const int& operator[](int i) const
	{
		assert(i < 10);
		return _a[i];
	}

private:
	int _a[10];
	int _size;
};

void Func(const Array& aa)
{
	for (int i = 0; i < 10; i++)
	{
		cout << aa[i] << " ";
	}
}

int main()
{
	Array a;
	for (int i = 0; i < 10; i++)
	{
		a[i] = i + 10;//调用[]运算符重载函数赋值
	}

	for (int i = 0; i < 10; i++)
	{
		cout << a[i] << " ";
	}
	cout << endl;

	Func(a);

	return 0;
}

注意:下标运算符只能重载为类的成员函数。

4.2.赋值运算符重载

4.2.1.赋值运算符概念

对于赋值运算符来说,如果不重载,那么类会自动为我们提供一个赋值运算符。这个默认的赋值运算符跟默认的拷贝构造函数一样,就是把一个对象的数据成员的值复制给另一个对象对应的数据成员。但它实现的是浅拷贝。数据成员中如果有指针,则编译器默认的赋值运算符不能满足要求,会出现内存泄漏。这时我们应重载赋值函数,实现深拷贝。

4.2.2.赋值运算符特征

特征一:赋值运算符重载格式;

  1. 参数类型:const T&,传递引用可以提高传参效率。加&是为了减少传值调用而引发的拷贝构造,加const则是为了防止当前对象被修改;
  2. 返回值类型:T&,返回引用可以提高返回的效率,有返回值目的是为了支持连续赋值
  3. 检测是否自己给自己赋值;
  4. 返回*this:要复合连续赋值的含义。
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)
	{
		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(2023, 2, 4);
	Date d2(2023, 3, 4);
	Date d3(2023, 3, 4);

	//赋值运算符重载
	d1 = d2;//d1.operator=(d2);
	d1.Print();

	//自己给自己赋值
	d1 = d1;

	//连续赋值
	d3 = d1 = d2;
	d1.Print();

	Date d5 = d1;//拷贝构造
	Date d6(d1);

	return 0;
}

注意:

赋值运算符重载是用一个已初始化对象去赋值给另一个已初始化对象;拷贝构造函数是用一个已初始化对象去初始化另一个新的未初始化对象。

特征二:赋值运算符只能重载成类的成员函数不能重载成全局函数;

class Date
{
public:
	Date(int year = 1900, int month = 1, int day = 1)
	{
		_year = year;
		_month = month;
		_day = day;
	}
	int _year;
	int _month;
	int _day;
};

// 赋值运算符重载成全局函数,注意重载成全局函数时没有this指针了,需要给两个参数
Date& operator=(Date& left, const Date& right)
{
	if (&left != &right)
	{
		left._year = right._year;
		left._month = right._month;
		left._day = right._day;
	}

	return left;
}

int main()
{
	Date d1(2023, 10, 9);
	Date d2(2023, 10, 10);

	d1 = d2;

	return 0;
}

运行结果:

原因:

赋值运算符如果不显式实现,编译器会生成一个默认的。此时用户再在类外自己实现一个全局的赋值运算符重载,就和编译器在类中生成的默认赋值运算符重载冲突了,故赋值运算符重载只能是类的成员函数。

特征三:用户没有显式实现时,编译器会生成一个默认赋值运算符重载,以值的方式逐字节拷贝。

注意:内置类型成员变量是直接赋值的,而自定义类型成员变量需要调用对应类的赋值运算符
重载完成赋值。

class Time
{
public:
	Time()
	{
		_hour = 1;
		_minute = 1;
		_second = 1;
	}

	Time& operator=(const Time& t)
	{
		if (this != &t)
		{
			_hour = t._hour;
			_minute = t._minute;
			_second = t._second;
		}
		return *this;
	}

private:
	int _hour;
	int _minute;
	int _second;
};

class Date
{
public:
	Date(int y = 2000, int m = 1, int d = 1)
	{
		_year = y;
		_month = m;
		_day = d;
	}

private:
	// 基本类型(内置类型)
	int _year;
	int _month;
	int _day;

	// 自定义类型
	Time _t;
};


int main()
{
	Date d1(2023, 10, 10);
	Date d2;

	d2 = d1;

	return 0;
}

运行结果:

C++初阶:类和对象_第21张图片

既然编译器生成的默认赋值运算符重载函数已经可以完成字节序的值拷贝了,还需要自己实现吗?当然像日期类这样的类是没必要的。那么下面的类呢?验证一下试试?

typedef int DataType;

class Stack
{
public:
	Stack(size_t capacity = 10)
	{
		_array = (DataType*)malloc(capacity * sizeof(DataType));
		if (nullptr == _array)
		{
			perror("malloc申请空间失败");
			return;
		}
		
		_size = 0;
		_capacity = capacity;
	}

	void Push(const DataType& data)
	{
		// CheckCapacity();
		_array[_size] = data;
		_size++;
	}

	~Stack()
	{
		if (_array)
		{
			free(_array);
			_array = nullptr;
			_capacity = 0;
			_size = 0;
		}
	}

private:
	DataType* _array;
	size_t _size;
	size_t _capacity;
};

int main()
{
	Stack s1;
	s1.Push(1);
	s1.Push(2);
	s1.Push(3);
	s1.Push(4);

	Stack s2;
	s2 = s1;

	return 0;
}

运行结果:

C++初阶:类和对象_第22张图片

注意:

如果类中未涉及到资源管理,赋值运算符是否实现都可以;一旦涉及到资源管理则必须要实现。

C++初阶:类和对象_第23张图片

五.const成员函数

与类中用const修饰的数据成员类似,成员函数也可以用const修饰,声明为常成员函数。常成员的出现是为了实现数据保护,保证安全性。常成员函数使得对数据的访问只限定位读取,而保护了数据不被修改,在只需要获取数据的场合,通常使用常成员函数实现。对于常数据成员的访问通常使用常成员函数完成。

在类中,常成员函数通过const关键字说明,const关键字出现在形参列表后,常成员函数定义形式如下所示:

class 类名
{
public:
	函数返回值类型 函数名(形参列表) const
	{
		函数体;
	}

	...
};

将const修饰的“成员函数”称之为const成员函数,const修饰类成员函数,实际修饰该成员函数隐含的this指针,表明在该成员函数中不能对类的任何成员进行修改。C++初阶:类和对象_第24张图片

先来回顾一下前期有关this指针的内容:

  1. 每个类的成员函数都隐含有一个指向被调用对象的指针,这个指针被称为this指针,程序编译后,成员函数中会包含this指针;
  2. 由于this指针是指向当前对象的指针,所以可以在函数中把this指针当参数使用,或从函数中返回,用作返回值
  3. 凡是想在成员函数中操作当前对象,都可以通过this指针完成

扩展:

常量指针是将const放在*的左边,表示的是指针所指向的内容不能通过指针来修改,但指针变量本身可修改,例如:const int*p;不允许通过*p修改指向的变量的数值,但允许指针p指向别的变量或者/常量;指针常量是将const放在*的右边,表示的是指针变量本身的指向不能修改,但是指针指向的内容可以通过指针来修改,例如:int* const p;允许通过*p修改指向的变量的数值,但不允许指针p指向别的变量或者/常量。

再来看看下面的代码:

class Date
{
public:
	Date(int year, int month, int day)
	{
		_year = year;
		_month = month;
		_day = day;
	}

	void Print1()//void Print(Date* this)
	{
		cout << "Print()" << endl;
		cout << "year:" << _year << endl;
		cout << "month:" << _month << endl;
		cout << "day:" << _day << endl << endl;
	}

	void Print2() const//void Print(const Date* this)
	{
		cout << "Print()const" << endl;
		cout << "year:" << _year << endl;
		cout << "month:" << _month << endl;
		cout << "day:" << _day << endl << endl;
	}

private:
	int _year; //年
	int _month; //月
	int _day; //日
};

int main()
{
	Date d1(2023, 10, 10);
	d1.Print1();
	d1.Print2();

	const Date d2(2023, 10, 11);
	d2.Print1();
	d2.Print2();

	return 0;
}

运行结果:

分析:

每个类的成员函数都隐含有一个指向被调用对象的指针,程序编译后,成员函数中会包含this指针,所以void Print1()编译后变为void Print(Date* this),void Print2() const编译后变为void Print(const Date* this)。当对象d1调用Print1()时,会把一个不具有常性的对象的地址传递给不具有常性的this指针,由于权限相同,程序可以正常运行;当对象d1调用Print2()时,会把一个不具有常性的对象的地址传递给具有常性的this指针,由于权限缩小,程序也可以正常运行。当对象d2调用Print1()时,会把一个具有常性的对象的地址传递给不具有常性的this指针,由于权限放大,程序可以正常异常;当对象d2调用Print2()时,会把一个具有常性的对象的地址传递给具有常性的this指针,由于权限相同,程序可以正常运行。

六.取地址及const取地址操作符重载

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

class A
{
public:
	//const修饰*this
	//this的类型变成 const A*
	//内部不改变成员变量的成员函数,最好加上const,const对象和普通对象都可以调用
	void Print() const
	{
		cout << _a << endl;
	}

	//取地址及const取地址操作符重载
	A* operator&()
	{
		return this;
	}

	const A* operator&() const
	{
		return this;
		//return nullptr;
	}

private:
	int _a = 10;
};

void Func(const A& x)
{
	x.Print();
	cout << &x << endl;
}

int main()
{
	A aa;
	aa.Print();

	A bb;
	bb.Print();

	Func(aa);

	cout << &aa << endl;
	cout << &bb << endl;

	return 0;
}

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

七.日期类的实现

Date.h

#include
#include
using namespace std;

class Date
{
public:
	Date(int year = 1990, int month = 1, int day = 1);
	void Print() const;
	int GetMonthDay(int year, int month) const;


	//关系运算符重载
	bool operator==(const Date& d) const;
	//d1d2
	bool operator>(const Date& d) const;
	//d1>=d2
	bool operator>=(const Date& d) const;
	//d1!=d2
	bool operator!=(const Date& d) const;


	//日期计算相关运算符重载
	//+=
	Date& operator+=(int day);
	//+
	Date operator+(int day) const;
	//-=
	Date& operator-=(int day);
	//-
	Date operator-(int day) const;

	//d1-d2
	int operator-(const Date& d) const;

	//++d1
	Date& operator++();
	//d1++
	Date operator++(int);//int参数,仅仅是为了占位,跟前置重载区分
	//--d1 --> d1.operator--()
	Date& operator--();
	//d1-- --> d1.operator--(0)
	Date operator--(int);//int参数,仅仅是为了占位,跟前置重载区分,只能是整型,不可能是其他类型
private:
	int _year;
	int _month;
	int _day;
};

Date.cpp

#include"Date.h"

//获取某年某月的天数
int Date::GetMonthDay(int year, int month) const
{
	//判断月份是否合法
	assert(month > 0 && month < 13);

	int monthArray[13] = { 0,31,28,31,30,31,30,31,31,30,31,30,31 };//第一个位置空出不用,并将二月的天数设为非闰月28天

	//判断二月是否为闰月
	if (month == 2 && ((year % 4 == 0 && year % 100 != 0) || (year % 400) == 0))
	{
		return 29;
	}
	else
	{
		return monthArray[month];
	}
}

//构造函数
Date::Date(int year, int month, int day)
{
	//合法性检查
	if ((month > 0 && month < 13) && (day > 0 && day <= GetMonthDay(year, month)))
	{
		_year = year;
		_month = month;
		_day = day;
	}
	else
	{
		cout << "日期非法" << endl;
	}
}

//打印
void Date::Print() const
{
	cout << _year << "/" << _month << "/" << _day << endl;
}

//d1==d2 转换成 d1.operator(d2)
bool Date::operator==(const Date& d) const
{
	return _year == d._year && _month == d._month && _day == d._day;
}

//d1d2
bool Date::operator>(const Date& d) const
{
	return !(*this <= d);
}

//d1>=d2
bool Date::operator>=(const Date& d) const
{
	return !(*this < d);
}

//d1!=d2
bool Date::operator!=(const Date& d) const
{
	return !(*this == d);
}

//+=
//d1+=100
Date& Date::operator+=(int day)
{
	//当day是个负数时
	if (day < 0)
	{
		*this -= -day;//复用-=,加上一个负数等于减去一个正数
		return *this;
	}

	_day += day;
	while (_day > GetMonthDay(_year, _month))
	{
		_day -= GetMonthDay(_year, _month);
		_month++;

		if (_month == 13)
		{
			++_year;
			_month = 1;
		}
	}

	return *this;
}

//d1=d1+100
//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;
//		}
//	}
//
//	return tmp;
//}

//+:d1=d1+100
//实现+=,让+来复用+=
Date Date::operator+(int day) const
{
	Date tmp(*this);

	tmp += day;//复用+=

	return tmp;
}

//+=:d1+=100
//实现+,让+=来复用+
//Date& Date::operator+=(int day)
//{
//	*this = *this + 100;
//
//	return *this;
//}

//-=
Date& Date::operator-=(int day)
{
	if (day < 0)
	{
		*this += -day;
		return *this;
	}

	_day -= day;
	while (_day <= 0)
	{
		--_month;

		if (_month == 0)
		{
			--_year;
			_month = 12;
		}

		_day += GetMonthDay(_year, _month);
	}

	return *this;
}

//-
Date Date::operator-(int day) const
{
	Date tmp(*this);

	tmp -= day;//复用-=

	return tmp;
}

//++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) const
{
	//比较大小:假设当前的*this是为较大的日期,而形参中的d是小的那个日期
	Date max = *this;
	Date min = d;
	int flag = 1;

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

	int n = 0;
	//统计相差天数
	while (min != max)//复用!=
	{
		++min;
		++n;
	}

	return n * flag;
}

test.c

#include"Date.h"

void TestDate1()
{
	Date d1(2023, 2, 4);
	d1.Print();

	//Date d2(2023, 2, 29);//日期可能不合法,需在构造函数中进行检查
	//d2.Print();

	//+=运算
	Date d3 = d1;
	d3 += 100;
	d3.Print();
	d1.Print();

	//+
	Date d2 = d1 + 100;
	d2.Print();
	d1.Print();
}

void TestDate2()
{
	Date d1(2023, 2, 3);
	d1.Print();

	Date d2 = d1 + 100;
	d2.Print();

	Date d3 = d1 + 100;
	d3.Print();
}

void TestDate3()
{
	Date d1(2023, 2, 4);
	d1.Print();

	++d1;//d1.operator++()
	d1.Print();

	d1++;//d1.operator++(0)
	d1.Print();
}

void TestDate4()
{
	Date d1(2023, 2, 4);
	d1.Print();

	d1 -= 100;
	d1.Print();

	Date d2(2023, 2, 7);
	d2 += -100;
	d2.Print();

	Date d3(2023, 2, 7);
	d3.Print();
	d3 -= -100;
	d3.Print();
}

void TestDate5()
{
	Date d1(2023,2,4);
	d1.Print();

	Date ret1 = --d1;//d1.operator--()
	d1.Print();
	ret1.Print();

	Date ret2 = d1--;
	d1.Print();
	ret2.Print();
}

void TestDate6()
{
	Date d1(2023, 2, 4);
	d1.Print();

	Date d2(2023, 4, 5);
	d2.Print();

	cout << (d2 - d1) << endl;
	cout << (d1 - d2) << endl;
}

int main()
{
	TestDate6();

	return 0;
}

八.再谈构造函数

通常我们希望能在对象创建时为数据成员提供初始值,通过定义带参数的构造函可以实现这样的功能。

class Date
{
public:
	Date(int year, int month, int day)
	{
		_year = year;
		_month = month;
		_day = day;
	}
private:
	int _year;
	int _month;
	int _day;
};

虽然上述构造函数调用之后,对象中已经有了一个初始值,但是不能将其称为对对象中成员变量的初始化,构造函数体中的语句只能将其称为赋初值,而不能称作初始化。因为初始化只能初始化一次,而构造函数体内可以多次赋值。

8.1.初始化列表概念

初始化列表就是在构造函数的参数列标后加冒号“:”,然后列出参数的初始化表,有多个参数时,中间以逗号“,”隔开,具体格式如下所示:

类名::构造函数名(参数列表) :数据成员1(参数1), 数据成员2(参数2), ..., 数据成员n(参数n)
{
	构造函数体
}
class Date
{
public:
	Date(int year, int month, int day)
		: _year(year)
		, _month(month)
		, _day(day)
	{}

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

8.2.初始化列表特征

特征一:每个成员变量在初始化列表中只能出现一次(初始化只能初始化一次);

特征二:类中包含以下成员,必须放在初始化列表位置进行初始化:

  1. 引用成员变量;
  2. const成员变量;
  3. 自定义类型成员(且该类没有默认构造函数时)。
class B
{
public:
	B(int b) :_b(0)
	{
		cout << "B()" << endl;
	}

private:
	int _b;
};

class A
{
public:
	//1.哪个对象调用构造函数,那么初始化列表就是它所有成员变量定义的位置
	//2.不管是否显示在初始化列表写,编译器都会为每个变量在初始化列表进行初始化

	A() :_x(1), _a2(1), _ref(_a1), _bb(0)//没有默认成员函数的内置类型、引用以及const属性成员变量只能用初始化列表初始化
	{
		_a1++;
		_a2--;
	}

private:
	int _a1 = 1;//声明
	int _a2 = 2;

	const int _x;//const成员变量
	int& _ref;//引用成员变量
	B _bb;//自定义类型成员,没有默认成员函数的内置类型
};


int main()
{
	A aa;//对整体对象的定义,每个成员什么时候定义呢?必须给每个成员变量找一个定义的位置,不然像const这样的成员不好处理

	//必须在定义的位置初始化
	//const int i = 0;

	return 0;
}

特征三:尽量使用初始化列表初始化,因为不管你是否使用初始化列表,对于自定义类型成员变量,一定会先使用初始化列表初始化;

若类X中有成员对象,则创建X类对象时,先执行成员对象的构造函数,初始化成员对象,再执行类X的构造函数初始化其他非对象成员。并且,若类X的成员对象的构造函数带有参数,则定义X类的构造函数时,应使用初始化表的形式对成员对象进行初始化。

class Time
{
public:
	Time(int hour = 0)
		:_hour(hour)
	{
		cout << "Time()" << endl;
	}
private:
	int _hour;
};

class Date
{
public:
	Date(int day)
	{}
private:
	int _day;
	Time _t;
};

int main()
{
	Date d(1);

	return 0;
}

特征四:成员变量在类中声明次序就是其在初始化列表中的初始化顺序,与其在初始化列表中的先后次序无关。

class A
{
public:
	A(int a)
		:_a1(a)
		, _a2(_a1)
	{}

	void Print() 
	{
		cout << _a1 << " " << _a2 << endl;
	}
private:
	int _a2;
	int _a1;
};

int main() 
{
	A aa(1);
	aa.Print();

	return 0;
}

运行结果:

分析:

先初始化_a2,再去初始化的_a1。成员变量执行顺序不是根据在初始化列表中的初始化顺序,而是根据类中成员变量声明的顺序。

8.3.explicit关键字

构造函数不仅可以构造与初始化对象,对于单个参数或者除第一个参数无默认值其余均有默认值的构造函数,还具有类型转换的作用。

单参构造函数

class A
{
public:
	//单参
	//构造函数
	 A(int a)
		:_a1(a)
	{
		cout << "A(int a)" << endl;
	}

	/* explicit A(int a)
		 :_a1(a)
	 {
		 cout << "A(int a)" << endl;
	 }*/

	//拷贝构造函数
	A(const A& aa)
		:_a1(aa._a1)
	{
		cout << "A(const A& aa)" << endl;
	}

private:
	int _a1;
	int _a2;
};

int main()
{
	A aa1(1);//调用构造函数初始化

	//1.单参构造函数,没有使用explicit修饰,具有类型转换作用
    //explicit修饰构造函数,禁止类型转换---explicit去掉之后,代码可以通过编译

	A aa2 = 1;//隐式类型转换,用1去构造一个A类型的临时变量,然后临时变量再去拷贝构造aa2,最后再进行优化,即构造+拷贝+优化->构造

	//A& ref = 10;//err,中间会通过调用A类的构造函数产生一个同类型的临时变量,由于临时变量具有常性,而左边的对象ref不具有常性,所以会报错,当给一个const类型修饰对象则不会有问题
	const A& ref = 10;//当使用引用时,编译器会进行优化,即构造+拷贝->构造

	return 0;
}

运行结果:

分析:

A aa2 = 1:用整型变量1给对象aa2赋值,实际编译器背后会用整型变量1构造一个无名对象,最后用无名对象给对象aa2进行赋值。通过运行结果可知,该过程只会调用一次构造函数,但事实上,该过程会存在一个隐式类型转换。首先会用整型变量1去构造一个A类型的临时变量,然后临时变量再去拷贝构造aa2,最后编译器再进行优化,即将构造+拷贝构造优化为只调用一次构造函数。A& ref = 10:为了佐证这一点,我们使用引用来接收这个变量,会发现程序运行错误。在进行赋值的过程中,整型变量10会通过调用A类的构造函数产生一个同类型的临时变量,由于临时变量具有常性,而左边的对象ref不具有常性,会造成类型不匹配,所以会报错,当给一个const类型修饰对象则不会有问题。

若不想让这种隐式类型转换发生该怎么办?此时可以使用C++中的关键字:explicit。explicit修饰构造函数,禁止类型转换,explicit去掉之后,代码可以通过编译。

 explicit A(int a)
		 :_a1(a)
	 {
		 cout << "A(int a)" << endl;
	 }

运行结果:

多参构造函数

class A
{
public:
	//多参
	//构造函数
	A(int a1, int a2)
		:_a1(a1)
		, _a2(a2)
	{
		cout << "A(int a1, int a2)" << endl;
	}

	//拷贝构造函数
	A(const A& a)
		:_a1(a._a1)
		,_a2(a._a2)
	{
		cout << "A(const A& a)" << endl;
	}

private:
	int _a1;
	int _a2;
};

int main()
{
	//虽然有多个参数,但是创建对象时后两个参数可以不传递,没有使用explicit修饰,具有类型转换作用
	//explicit修饰构造函数,禁止类型转换
	A aa2(1, 1);

	//A aa3 = 1, 1;//err
	A aa4 = { 1,1 };//C++11,对多参构造进行初始化的时候要在外面加上一个{}

	const A& ref = { 2,2 };

	return 0;
}

运行结果:

同样的,若不想让这种隐式类型转换发生,也可以使用C++中的关键字:explicit。

explicit A(int a1, int a2)
		:_a1(a1)
		, _a2(a2)
	{
		cout << "A(int a1, int a2)" << endl;
	}

运行结果:

C++初阶:类和对象_第25张图片

九.static成员

声明为static的类成员称为类的静态成员,用static修饰的成员变量,称之为静态成员变量;用static修饰的成员函数,称之为静态成员函数。

面试题:实现一个类,计算程序中创建出了多少个类对象。                                                               答:定义一个全局变量count,然后在可能产生构造的地方进行对count进行累加即可。

#include

using std::cout;
using std::endl;

int count = 0;//会和std库中的count函数发生冲突

class A 
{
public:
	A()
	{
		count++;
	}

	A(const A& a)
	{
		count++;
	}

//private:
//	int count = 0;
};

void func(A a)
{
	count++;
}

int main(void)
{
	A aa1;
	A aa2(aa1);

	func(aa1);

	cout << count << endl;

	return 0;
}

但是这样实现会存在一个问题:由于count是个全局变量,它的生命周期从定义开始直到main函数运行结束,在这期间都可以对count进行访问,因而会导致全局变量count变得不安全,比如对其进行自增自减,都会影响最终的运行结果。为了解决这个问题,我们可以将count定义为类的数据成员。但是此时的count只为某个对象所拥有,若想要计算程序中创建出了多少个类对象 ,此时不同的对象对count进行访问显然会发生越界行为,显然是行不通的。

为了解决上述两个问题,我们便引入了static修饰的静态成员变量。

9.1.静态成员变量

有时候,我们希望某些特定的数据在内存中只有一份,而且能够被一个类的所有对象共享,此时就可以使用静态数据成员。使用静态数据成员可以实现类中多个对象的数据共享和交互,静态数据成员的值对每个对象都一样,并且可以更新。

C++中将使用static修饰的数据成员称为静态成员,具体声明形式如下所示:

static 类型标识符 静态数据成员名称;

对于静态数据成员来说,若其被声明具有public属性,则与普通的public数据成员类似,可以通过对象在类外完成访问,访问形式如下所示:

对象.公有静态数据成员=xx;

然而由于静态数据成员不属于任何对象,访问静态数据成员有其独特的方式,可以通过类名直接对它进行访问,而无需通过类对象,并且通常采用这种方式。访问形式如下所示:

类名::静态数据成员

若想对静态数据成员进行初始化,需要在类外通过“类名::静态数据成员=初值;”的方法提供初值,初始化形式如下所示:

类名::静态数据成员=初值;

静态数据成员和C语言中的静态变量相同,由于静态数据位于数据段上,因此它们的生命期从编译阶段开始,到程序运行结束才终止。通常类的定义形式会放在头文件中,类中成员函数的实现会独立保存在一个.cpp文件中,由于静态数据成员只有一份,静态数据成员在类的定义形式中只有声明,没有初始化语句,对于静态数据成员的初始化应该在.cpp中完成。

特征:

  1. 静态成员为所有类对象所共享,不属于某个具体的对象,存放在静态区;
  2. 静态成员变量必须在类外定义,定义时不添加static关键字,类中只是声明;
  3. 类静态成员即可用 类名::静态成员 或者 对象.静态成员 来访问;
  4. 静态成员也是类的成员,受public、protected、private 访问限定符的限制。
class A
{
public:
	//构造
	A(int a = 0)
	{
		cout << "A(int a = 0)" << endl;
		++count;
	}

	//拷贝构造
	A(const A& aa)
	{
		cout << "A(const A& aa)" << endl;
		++count;
	}

	//非静态成员函数,用以获取count
	int GetSumCount()
	{
		return count;
	}

	//静态成员函数:没有this指针
	//static int GetCount()
	//{
	//	//_a++;//err,静态成员函数不能去访问非静态成员,要想访问,必须通过参数传递的方式得到对象名,然后在静态成员函数中通过对象名访问非静态成员
	//	return count;
	//}

public:
	//不属于某个对象,属于所有对象,属于整个类
	//static int count = 0;//err,静态成员变量必须在类外定义
	static int count;//不会和std产生冲突,因为count属于这个类域,不会和全局的count产生冲突,不能赋予缺省值,只是进行声明
	int _a = 0;//缺省值
};

int A::count = 0;//定义初始化静态成员变量

//调用拷贝构造
void func(A a)
{

}

int main()
{
	A aa1;//构造
	A aa2(aa1);//拷贝构造
	func(aa1);//拷贝构造
	A aa3 = 1;//构造

	//当count是私有时,可以通过对象调用函数GetSumCount()来获取count的值
	cout << "count = " << aa1.GetSumCount() << endl;

	cout << A::count << endl;//err,count是私有的,不是私有的可以这样访问
	cout << aa2.count << endl;//err,count是私有的,不是私有的可以这样访问
	cout << aa3.count << endl;//同上

	A* ptr = nullptr;
	//count静态成员变量,虽然形式上放在类内,但它是存放在内存中的静态区,所以不涉及this指针解引用
	cout << ptr->count << endl;//正常运行:不涉及this解引用
	cout << (*ptr).count << endl;//正常运行:不涉及this解引用

	return 0;
}

9.2.静态成员函数

我们已经知道类中可以存在静态数据成员,用于保存各个对象的共享数据,静态数据数据成员不依托于对象的存在,在类只有一份拷贝,对静态数据成员可以通过类名访问。

上例中定义的静态数据成员count具有public属性,可以直接在类外修改,存在安全隐患,因此我们希望count具有private属性,希望通过在类中增加相应函数对它完成合理操作。C++中用于操作静态数据成员的函数可以定义为静态成员函数,由static关键字描述。

静态成员函数的定义形式如下所示:

class 类名
{
	static 函数返回值类 函数名(形参列表)
	{
		函数体
	}
};

与普通的成员函数定义形式类似,静态成员函数只是在函数返回类型前添加了static关键字。

虽然与普通的成员函数类似,静态成员函数可以通过"对象.静态成员函数名()"的形式完成调用,但由于静态成员函数通常用于操作静态数据成员,因此与静态数据成员类似,静态成员函数与类相关,不依赖于具体的对象,访问静态成员函数无需定义对象,访问方式仍然为"类名::函数调用",具体形式如下所示:

类名::静态成员函数(实参);

通常静态成员函数访问静态数据成员,也可访问其他静态成员函数。但是,若静态成员函数想访问非静态数据成员时,必须通过参数传递的方式得到对象名,然后在静态成员函数中通过对象名访问非静态成员。

特征:

  1. 类静态成员即可用 类名::静态成员 或者 对象.静态成员 来访问;
  2. 静态成员函数没有隐藏的this指针,不能访问任何非静态成员;
  3. 静态成员也是类的成员,受public、protected、private 访问限定符的限制。
class A
{
public:
	//构造
	A(int a = 0)
	{
		cout << "A(int a = 0)" << endl;
		++count;
	}

	//拷贝构造
	A(const A& aa)
	{
		cout << "A(const A& aa)" << endl;
		++count;
	}

	//非静态成员函数,用以获取count
	//int GetSumCount()
	//{
	//	return count;
	//}

	//静态成员函数:没有this指针
	static int GetSumCount()
	{
		//_a++;//err,静态成员函数不能直接去访问非静态数据成员,要想访问,必须通过参数传递的方式得到对象名,然后在静态成员函数中通过对象名访问非静态成员
		return count;
	}

	static int GetSumCount(A& a)
	{
		a._a++;//要想访问非静态数据成员,必须通过参数传递的方式得到对象名,然后在静态成员函数中通过对象名访问非静态成员
		cout << "_a:" << a._a << endl;
		return count;
	}

public:
	//不属于某个对象,属于所有对象,属于整个类
	//static int count = 0;//err,静态成员变量必须在类外定义
	static int count;//不会和std产生冲突,因为count属于这个类域,不会和全局的count产生冲突,不能赋予缺省值,只是进行声明
	int _a = 0;//缺省值
};

int A::count = 0;//定义初始化静态成员变量

void func()
{
	A aa1;
	A aa2(aa1);
	A aa3 = 1;
	A aa4[10];
}

int main()
{
	A aa;
	cout << aa.GetSumCount() - 1 << endl;
	cout << A::GetSumCount() << endl;
	cout << A::GetSumCount(aa) << endl;

	func();
	cout << A::GetSumCount() << endl;
	
	return 0;
}

问题:

1. 静态成员函数可以调用非静态成员函数吗?

答:静态成员函数不能调用非静态成员函数,因为静态成员函数不含this指针;

2. 非静态成员函数可以调用类的静态成员函数吗?

答:非静态成员函数可以调用静态成员函数,因为静态成员函数属于整个类。

9.3.笔试练习

题目:

求1+2+3+...+n,要求不能使用乘除法,for,while,if,else,switch,case等关键字及条件判断语句。

分析:

采用静态成员变量的方式。首先设置静态成员变量i和sum,i用于自增,sum用于累加,并在构造函数中进行实现;然后实例化一个具有n个对象的数组,用以对构造函数去进行n次调用,从而达到累加的效果;最后调用静态成员函数,用以获取到静态成员变量sum的值。

实现:

class Sum
{
public:
	Sum()
	{
		_sum += _i;
		++_i;
	}

	static int GetSum()
	{
		return _sum;
	}
private:
	static int _i;
	static int _sum;
};

int Sum::_i = 1;
int Sum::_sum = 0;

class Solution
{
public:
	int Sum_Solution(int n)
	{
		//Sum a[n];//vs不支持变长数组
		Sum* ptr = new Sum[n];
		return Sum::GetSum();
	}

	~Solution()
	{
		cout << "~Solution()" << endl;
	}
};

十.友元

类的封装和数据隐藏是面向对象编程思想的一个特点,这个特点使得只有类的成员函数才可访问类的私有成员,虽然这样更好地保护了数据,提高了安全性,但在某些情况下,为了提高效率和操作方便,需要允许一个函数或类访问另一个类的私有成员,这就需要通过友元实现。

友元可以是一个函数,该函数被称为友元函数,友元也可以是一个类,称为友元类。本节将针对友元函数和友元类进行详细讲解。

10.1.友元函数

友元函数是在类外定义的一个函数,它不是本类的成员函数,而是一个普通的函数或其他类的成员函数。若在类中声明某一函数为友元,则该函数可以操作类中的私有数据。

friend 函数返回值类型 友元函数名(形参列表);

现在尝试去重载operator<<,然后发现没办法将operator<<重载成成员函数。因为cout的输出流对象和隐含的this指针在抢占第一个参数的位置。this指针默认是第一个参数也就是左操作数了。

class Date
{
public:
	Date(int year, int month, int day)
		: _year(year)
		, _month(month)
		, _day(day)
	{}

	//d1 << cout; -> d1.operator<<(&d1, cout); 不符合常规调用
	//因为成员函数第一个参数一定是隐藏的this,所以d1必须放在<<的左侧
	ostream & operator<<(ostream& _cout)
	{
		_cout << _year << "-" << _month << "-" << _day << endl;
		return _cout;
	}

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

void TestDate()
{
	//内置类型的流插入
	int i = 1;
	double d = 1.11;
	//运算符重载+函数重载
	cout << i << endl;//cout.operator<<(i) //int
	cout << d << endl;//cout.operator<<(d) //double

	//自定义类型的流插入
	Date d1(2023, 2, 4);
	Date d2(2023, 1, 1);

	//cout << d1;//err,因为cout的输出流对象和隐含的this指针在抢占第一个参数的位置。this指针默认是第一个参数也就是左操作数了。
	d1.operator<<(cout);
	d1 << cout;
}

但是实际使用中cout需要是第一个形参对象,才能正常使用。所以要将operator<<重载成全局函数。但又会导致类外没办法访问成员,此时就需要友元来解决。友元函数可以直接访问类的私有成员,它是定义在类外部的普通函数,不属于任何类,但需要在类的内部声明,声明时需要加friend关键字。operator>>同理。

class Date
{
	friend ostream& operator<<(ostream& _cout, const Date& d);
	friend istream& operator>>(istream& _cin, Date& d);
public:
	Date(int year = 1900, int month = 1, int day = 1)
		: _year(year)
		, _month(month)
		, _day(day)
	{}

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

ostream& operator<<(ostream& _cout, const Date& d)
{
	_cout << d._year << "-" << d._month << "-" << d._day;
	return _cout;
}

istream& operator>>(istream& _cin, Date& d)
{
	_cin >> d._year;
	_cin >> d._month;
	_cin >> d._day;
	return _cin;
}

int main()
{
	Date d;
	cin >> d;
	cout << d << endl;

	return 0;
}

说明:

  1. 友元函数可访问类的私有和保护成员,但不是类的成员函数;
  2. 友元函数不能用const修饰;
  3. 友元函数可以在类定义的任何地方声明,不受类访问限定符限制;
  4. 一个函数可以是多个类的友元函数;
  5. 友元函数的调用与普通函数的调用原理相同。

使用场景:

  1. 普通函数作为友元函数;
  2. 类中的成员函数作为另一个类的友元函数。

10.2.友元类

除了可以声明函数为类的友元函数外,还可以将一个类声明为另一个类的友元函数。例如在类A中声明类B为友元类,则类B中的所有成员函数称为类A的友元函数,能访问类A中的私有数据。

与声明友元函数类似,声明友元类也需要使用关键字friend,声明友元类的形式如下所示:

class B;
class A
{
	...
		friend class B;//声明类B为类A的友元
	...
};

接下来通过一个案例说明友元类的使用:

class Time
{
	friend class Date; // 声明日期类为时间类的友元类,则在日期类中就直接访问Time类中的私有成员变量
public:
	Time(int hour = 0, int minute = 0, int second = 0)
		: _hour(hour)
		, _minute(minute)
		, _second(second)
	{}

private:
	int _hour;
	int _minute;
	int _second;
};

class Date
{
public:
	Date(int year = 1900, int month = 1, int day = 1)
		: _year(year)
		, _month(month)
		, _day(day)
	{}

	void SetTimeOfDate(int hour, int minute, int second)
	{
		// 直接访问时间类私有的成员变量
		_t._hour = hour;
		_t._minute = minute;
		_t._second = second;
	}

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

说明:

  1. 友元关系是单向的,不具有交换性。比如上述Time类和Date类,在Time类中声明Date类为其友元类,那么可以在Date类中直接访问Time类的私有成员变量,但想在Time类中访问Date类中私有的成员变量则不行;
  2. 友元关系不能传递。如果C是B的友元, B是A的友元,则不能说明C时A的友元;
  3. 友元关系不能继承,在继承位置再给大家详细介绍。

小结:

友元可以实现数据共享,但在一定程度上破坏了类的封装性,除非在提高效率上有很大帮助,一般不提倡使用友元,友元较多地用在运算符重载中。为了更好地使用友元,需要在共享和封装之间找到一个结合点。

十一.内部类

在C++中允许在类内部定义类,这样的类称为内部类。这个内部类所在的类称为外部类。内部类可以作为外部类的基础,外部类在内部类基础上扩充新的功能并且不会相互影响。

11.1.内部类概念

如果一个类定义在另一个类的内部,这个内部类就叫做内部类。内部类是一个独立的类,它不属于外部类,更不能通过外部类的对象去访问内部类的成员。外部类对内部类没有任何优越的访问限。

定义内部类的语法形式如下所示:

class 外部类名
{
	外部类成员;
访问限定符:
	class 内部类名
	{
		内部类成员
	};
};

注意:

内部类就是外部类的友元类,参见友元类的定义,内部类可以通过外部类的对象参数来访问外部类中的所有成员。但是外部类不是内部类的友元。

11.2.内部类特征

特征一:内部类可以定义在外部类的public、protected、private都是可以的;

特征二:注意内部类可以直接访问外部类中的static成员,不需要外部类的对象/类名;

特征三:sizeof(外部类)=外部类,和内部类没有任何关系。

class A
{
private:
	static int k;
	int h;
public:
	//内部类:跟A是独立的,只是受A的类域限制
	class B //B天生就是A的友元
	{
	public:
		//一个内部类对象可以访问创建它的外部类对象的内容,包括私有变量。
		//要想实现这个特性,内部类对象就必须有指向外部类对象的引用
		void foo(const A& a)
		{
			cout << k << endl;//内部类可以直接访问外部类中的static成员,不需要外部类的对象/类名
			cout << a.h << endl;
		}
	private:
		int b;
	};
};

int A::k = 1;

int main()
{
	A aa;
	cout << sizeof(aa) << endl;//sizeof(外部类) = 外部类,和内部类没有任何关系

	//B bb;//受A的类域限制,因为类B包在类A里面,所以要去实例化类B的对象时会受到类域的限制
	A::B bb;//使用作用域限定符定义内部类对象,公有内部类可行,私有内部类通不过

	return 0;
}

注意:

一个内部类对象可以访问创建它的外部类对象的内容,包括私有变量。要想实现这个特性,内部类对象就必须有指向外部类对象的引用。

十二.匿名对象

12.1.匿名对象概念

匿名对象,即没有对象名,也不需要对象名,直接使用类名创建对象。语法格式:类名()

class Date
{
public:
	Date(int a = 1, int b = 7)
	{
		cout << "Date(int a = 1, int b = 7)" << endl;
		_a = a;
		_b = b;
	}

	~Date()
	{
		cout << "~Date()" << endl;
	}

private:
	int _a;
	int _b;
};

int main()
{
	Date();//匿名对象

	return 0;
}

12.2.匿名对象特征

特征一:匿名对象的生命周期只有一行;

对于普通对象而言,当main函数即将销毁时才会去调用析构函数对已实例化的对象进行销毁;但是对于匿名对象而言,则是在当前行程序执行结束之后便会去调用析构函数。

特征二:匿名对象具有常性;

匿名对象作为一个临时变量具有常性,当我们对其进行引用时如果不加const修饰就会造成权限放大导致程序运行出错。注意:权限可以缩小和相同,但是不能被放大。

特征三:匿名对象被引用时,匿名对象的生命周期将会变长。

匿名对象和引用它的对象的生命周期相同,引用它的对象的生命周期有多长,该匿名对象的生命周期就有多长。

class Solution
{
private:
	class Sum
	{
	public:
		Sum()
		{
			_sum += _i;//内部类可以直接访问外部类中的static成员,不需要外部类的对象/类名;
			++_i;
		}

	    static int GetSum()
		{
			return _sum;
		}
	};

public:
	int Sum_Solution(int n)
	{
		//Sum a[n];//vs不支持变长数组
		Sum* ptr = new Sum[n];
		return Sum::GetSum();
	}

	~Solution()
	{
		cout << "~Solution()" << endl;
	}

private:
	static int _i;
	static int _sum;
};

int Solution::_i = 1;
int Solution::_sum = 0;

class A
{
public:
	A(int a = 0)
		:_a(a)
	{
		cout << "A(int a)" << endl;
	}

	~A()
	{
		cout << "~A()" << endl;
	}

private:
	int _a;
};

A func()
{
	int n;
	cin >> n;
	int ret = Solution().Sum_Solution(n);

	//匿名对象
	return A(ret);
}

int main()
{
	//通过创建对象实现
	//Solution s;
	//cout << s.Sum_Solution(10) << endl;
	
	//通过匿名对象实现
	cout << Solution().Sum_Solution(10) << endl;

	Solution();//匿名对象,生命周期只在这一行

	cout << "--------------------------------" << endl;

	A aa1;
	
	//A aa1();//不能这么定义对象,因为编译器无法识别下面是一个函数声明,还是对象定义
	//但是我们可以这么定义匿名对象,匿名对象的特点不用取名字,
	//但是他的生命周期只有这一行,我们可以看到下一行它就会自动调用析构函数
	A();

	//A& aa2 = A(2);//会造成权限放大,因为匿名对象和临时对象一样是具有常性,一个非常性的对象去引用一个常性的匿名对象就会造成错误
	const A& aa2 = A(2);//const常引用延长了匿名对象的生命周期

	return 0;
}

十三.拷贝对象时的一些编译器优化

在传参和传返回值的过程中,一般编译器会做一些优化,减少对象的拷贝,这个在一些场景下还是非常有用的。

class A
{
public:
	A(int a = 0)
		:_a(a)
	{
		cout << "A(int a)" << endl;
	}

	A(const A& aa)
		:_a(aa._a)
	{
		cout << "A(const A& aa)" << endl;
	}

	A& operator=(const A& aa)
	{
		cout << "A& operator=(const A& aa)" << endl;
		if (this != &aa)
		{
			_a = aa._a;
		}
		return *this;
	}

	~A()
	{
		cout << "~A()" << endl;
	}

private:
	int _a;
};

//传值传参
void func1(A aa)
{

}

//传引用传参:可以减少拷贝
void func2(const A& aa)//不加const会造成权限放大
{

}

//传值返回
A func3()
{
	A aa;
	return aa;
}

//传匿名对象返回
A func4()
{
	return A();
}

int main()
{
	//隐式类型转换,用1去构造一个A类型的临时变量,然后临时变量再去拷贝构造aa2,最后再进行优化,即构造 + 拷贝 + 优化->构造
	A aa1 = 1;//构造+拷贝构造=>优化为直接构造


	cout << "传值传参" << endl;

	func1(aa1);//构造+拷贝构造

	func1(2);//构造+拷贝构造=>优化为构造

	func1(A(3));//构造+拷贝构造=>优化为构造

	cout << "----------" << endl;



	cout << "传引用传参" << endl;

	//使用引用接收,那么形参aa就是aa1的别名,无需构造产生一个新的对象,也不用去拷贝构造产生一个新的对象
	func2(aa1);//引用传参,既不进行构造,也不进行拷贝构造

	func2(2);//构造,无优化,用3去构造一个A类型的临时变量

	func2(A(3));//临时对象或者匿名对象都具有常性,构造,无优化

	cout << "----------" << endl;



	cout << "传值返回" << endl;

	func3();//构造+拷贝构造

	A aa2 = func3();//构造+拷贝构造+拷贝构造=>优化为构造+拷贝构造

	//A aa5;//构造
	//aa5 = func3();//构造+拷贝构造+赋值运算符重载

	cout << "----------" << endl;



	cout << "传匿名对象返回" << endl;

	func4();//构造+拷贝构造=>优化为构造

	//不可以使用传引用返回,因为匿名对象构建出来的是一个临时对象,具有常性,传引用会造成权限放大
	A aa3 = func4();//构造+拷贝构造=>优化为构造

	return 0;
}

运行结果:

C++初阶:类和对象_第26张图片

小结:

对象返回问题:

  1. 接收返回值对象,尽量使用拷贝构造方式接收,不要使用赋值接收;
  2. 函数中遇到返回对象时,尽量返回匿名对象。

传参问题:

  1. 尽量使用const T&传参,可以减少拷贝同时防止权限放大。

你可能感兴趣的:(c++,c++)