C++:类和对象(上)

       在C语言中,想要解决一个过程,我们编写程序,将是解决问题的完整过程翻译成计算机语言,让计算机去执行,关注的是过程,分析出求解问题的步骤。而在C++中,引入了类和对象的概念,将一件事情拆分成不同的对象,靠对象之间的交互完成。

一、类的定义

       类的是C语言中结构体的升级版,它既可以定义成员变量,也可以定义成员函数。

       类的定义:

class className
{
// 类体:由成员函数和成员变量组成
}; // 一定要注意后面的分号

       class为定义类的关键字,ClassName为类的名字,{}中为类的主体,注意类定义结束时后面分号不能省略。类体中内容称为类的成员;类中的变量称为类的属性或成员变量; 类中的函数称为类的方法或者成员函数。

二、类的访问限定符

       访问限定符有三种:

       public(公有)、protected(保护)、privacy(私有)。

       特点:

  • 1. public修饰的成员在类外可以直接被访问;
  • 2. protected和private修饰的成员在类外不能直接被访问(此处protected和private是类似的);
  • 3. 访问权限作用域从该访问限定符出现的位置开始直到下一个访问限定符出现时为止;
  • 4. 如果后面没有访问限定符,作用域就到 } 即类结束。;
  • 5. class的默认访问权限为private,struct为public(因为struct要兼容C)。

       注意:访问限定符只在编译时有用,当数据映射到内存后,没有任何访问限定符上的区别。

三、C++中结构体和类的区别

       C++需要兼容C语言,所以C++中struct可以当成结构体使用。另外C++中struct还可以用来
定义类。和class定义类是一样的,区别是struct定义的类默认访问权限是public,class定义的类
默认访问权限是private。

       注意:在继承和模板参数列表位置,struct和class也有区别,后序给大家介绍。
       访问限定符的优势:1.使代码的使用更规范,只能使用规范的方式进行访问。C++一般将函数的权限设为公有,成员变量设为私有。

四、类域

       每一个类都定义了一个类域,它与命名空间类似,同名成员可以分别定义在不同的类域中。

class A
{
public:
	void Init();
private:
	int a;
	int b;
};

class B
{
public:
	void Init();
private:
	int a;
	int b;
};

        在定义函数时,需要加上类的作用域限定符。

void A::Init()
{
	//...
}

void B::Init()
{
	//...
}

       不加访问限定符编译器就认为它是一个全局函数,加上访问限定符之后编译器就知道了这个函数来自A类,下面的Init函数来自B这个类。

五、类的实例化

       在C++中,使用类创建对象叫类的实例化,一个类可以实例化出多个对象。

class A
{
public:
	void Init();
private:
	int a;
	int b;
};

int main()
{
	A a;
	A b;
	A c;//a,b,c都是实例化出的对象。
}

六、类的大小计算

       我们使用sizeof关键字计算类的大小,也可以计算实例化对象的大小,二者是一样的。我们会发现类的大小只等于成员变量的大小,不会计算成员函数的大小,因为成员函数是公共的,每一个实例化对象都可以访问它,从而将它放在一个公共的区域,每一个对象都存成员函数就会导致浪费。而每个实例化对象的成员变量都不同,需要存储不同的值,因此只计算成员变量的大小。
      空类的大小为1.,这1个字节不存储有效数字,表示对象被定义出来。若对象中只有一个成员函数,它的大小也为1,即类的大小只考虑成员变量

七、this指针

       先来看一段代码:

class A
{
public:
	void Init(int a,int b)
	{
		_a = a;
		_b = b;
	}
	void Print()
	{
		cout << _a << ' ' << _b << endl;
	}
private:
	int _a;
	int _b;
};

int main()
{
	A a;
	A b;
	a.Init(5, 6);
    b.Init(7, 8);
    a.Print();
    b.Print();
}

       我们先实例化了两个对象a和b,然后每个对象分调用初始化和打印函数,结果如图所示:

        它们调用的是一个函数,那么却打印出了不同的结果,那它是如何传参的呢?编译器会将对象的地址传给函数,实参是对象的地址,型参事this指针,然后this指针就访问每个对象的成员变量,然后打印它们的值。传递的不只是值,还有对象的地址。传地址和访问我们是看不到的。this指针是个形参,形参和局部变量存在于栈中。

八、类的默认成员函数

       一个类中总是会生成它的默认成员函数,例如构造函数、析构函数、拷贝构造和赋值重载等。它们各自完成不同的工作。构造函数主要任务是完成类中成员的初始化,析构函数的任务是完成类中的资源清理,拷贝构造函数的任务是使用类类型对象初始化正在创建的对象,赋值重载的任务是使用相同类型的对象赋值给另一个对象。下文我们将详细介绍它们的功能特性。

1.构造函数

       构造函数的任务是初始化类中的成员变量

       构造函数的特征:

  1. 函数名和类名相同
  2. 构造函数没有返回类型
  3. 创建对象时自动创建并调用构造函数
  4. 构造函数可以重载
#include<iostream>
using namespace std;

class Date
{
public:
	Date()
	{
		_year = 0;
		_month = 0;
		_day = 0;
	}
	int _year;
	int _month;
	int _day;
};

int main()
{
	Date date;
	cout << date._year << ' ' << date._month << ' ' << date._day << endl;
	return 0;
}

       其中的Date()函数是我们自己实现的构造函数。程序运行的结果如下:

       当我们把我们自己实现的构造函数注释掉,编译程序,并不会发生报错。因为此时类中不存在带参构造函数,编译器自动生成了一个的默认构造函数。构造函数的初始化规则是:对于内置类型,如果成员变量给了初始值,则用它来初始化成员,否则成员的初始化由各编译器决定。对于自定义类型,则会调用该自定义类型的默认构造函数,若该自定义类型中没有默认构造函数,且没有实现其他的带参数构造函数的情况下,编译器会自动生成一个。若我们不显示实现构造函数,仅靠编译器自动生成的构造函数,那么造成的结果是有可能类中的所有成员都不会被初始化,也就是编译器自动生成的构造函数是没有用的。但不同的编译器对内置类型的处理不同,vs2022编译器会将内置类型的成员变量初始化为0。

       所谓默认构造,就是不用传参数的构造函数。它包括无参的构造函数和全缺省的构造函数,还有编译器实现的构造函数。并且创建对象的时候就调用了默认构造。

#include<iostream>
using namespace std;

class Time
{
public:
	Time(int n)// 带参数的构造函数
	{
		_hour = n;
		_minute = n;
		_second = n;
		_newspace = nullptr;
		cout << "Time(int n)"  << endl;
	}
	Time()//无参数的构造函数
	{
		_hour = 0;
		_minute = 0;
		_second = 0;
		_newspace = nullptr;
		cout << "Time()" << endl;
	}
	int _hour;
	int _minute;
	int _second;
	int* _newspace;
};

class Date
{
public:
	Date()// 无参数的构造函数
	{
		_year = 0;
		_month = 1;
		_day = 2;
		cout << "Date()" << endl;
	}
	Date(int n)//带参数的构造函数
	{
		_year = n;
		_month = n;
		_day = n;
		cout << "Date(int n)" << endl;
	}
	int _year;
	int _month;
	int _day;
	Time time;//自定义类型
};

int main()
{
	Date date;//调用无参构造函数
	Date date1(2);//调用带参数的构造函数
	return 0;
}

       运行程序,可以看到,编译器先调用了Time类中的构造函数,再调用Date类中的构造函数。

       可以发现,无论是默认构造函数,还是自己实现的带参构造函数,对于自定义类型,只会调用自定义类型中的无参构造函数。若想要显示调用自定义类型中的带参构造函数,需要以下格式:

time.Time::Time(6);
/*
Time(int n)
{
	_hour = n;
	_minute = n;
	_second = n;
	_newspace = nullptr;
	cout << "Time(int n)"  << endl;
}
*/

       第一个time是指定变量,第二个Time是指定类,第三个Time是Time类中的构造函数,参数中是要传的值。

       由于构造函数可以重载,我们也可以实现多种类型的构造函数。

#include<iostream>
using namespace std;

class Date
{
public:
	Date()// 无参数的构造函数
	{
		_year = 0;
		_month = 1;
		_day = 2;
		cout << "Date()" << endl;
	}
	Date(int n)//带参数的构造函数
	{
		_year = n;
		_month = n;
		_day = n;
		cout << "Date(int n)" << endl;
	}
	int _year;
	int _month;
	int _day;
	//Time time;
};

int main()
{
	Date date;//创建date变量并同时调用无参构造函数
	Date date1(2);//创建date1变量调用带参数的构造函数
	return 0;
}

       运行程序,可以看到,先调用了无参构造函数,再调用了带参构造函数。

       你会发现,调用无参构造与调用普通无参函数的语句形式上会有所差别,调用无参构造时省去了括号,那是因为,如果加上括号,就变成了函数声明。声明了一个无参数的,返回一个Date类型的值的函数。为了和它区分开,在调用无参构造时,省去了括号。调用全缺省的也是如此。

       注意,默认构造函数只能有一个,否则会引发调用歧义。

2.构造函数的初始化列表

       如果类中的自定义类型成员中不含默认构造函数,但是含有带参数的默认构造函数,那么编译器将无法自动生成类的构造函数,因为无论生成的默认构造函数还是我们自己实现的带参数构造函数在初始化时会先找自定义类型成员中的默认构造函数。如果找不到就会尝试自动生成一个默认构造函数,但是此时自定义类型成员中含有已经实现过了的带参数构造函数,那么就不会自动生成默认构造函数,找不到默认构造函数,因此就无法生成类的构造函数,程序就会报错,错误原因是类中不存在默认构造函数。因此必须保证自己定义的每个类中都要自己实现一个默认构造函数。但是我们也可以通过初始化列表来进行初始化。

//初始化列表

Date()// 无参数的构造函数
	:// 自定义类型成员名(参数列表)
	,// 自定义类型成员名(参数列表)
	,// 自定义类型成员名(参数列表)
	,// 自定义类型成员名(参数列表)
	,// ......
{
	_year = 0;
	_month = 1;
	_day = 2;
	cout << "Date()" << endl;
}

        它的格式是冒号代表开始,逗号隔开。也可以写成一行的形式。例如我们要调用自定义类型成员变量time中的带参数构造函数:

class Date
{
public:
	Date()
		:time0(6)//调用Time类中的带参数构造函数
		,time1(7)
		,time2(8)
		,time3(9)
	{
		_year = 0;
		_month = 1;
		_day = 2;
		cout << "Date()" << endl;
	}
	Date(int n)//带参数的构造函数
		:time0(6)//调用Time类中的带参数构造函数
		,time1(7)
		,time2(8)
		,time3(9)
	{
		_year = n;
		_month = n;
		_day = n;
		cout << "Date(int n)" << endl;
	}
	int _year;
	int _month;
	int _day;
	Time time0;//自定义类型
	Time time1;//自定义类型
	Time time2;//自定义类型
	Time time3;//自定义类型
};

       初始化列表的作用是调用该成员变量中的带参构造函数。如time0(6)的意思是调用time0中的带参数构造函数。

       也可以将初始化列表里面的放到构造函数的函数体里面混着用,但是一般情况下,有了初始化列表,构造函数的函数体里面就不再有语句了,也就是初始化的任务都交给初始化列表来做。

       初始化列表可以理解为类中的每个成员定义的地方。

       有些成员必须使用初始化列表初始化。

  1. const修饰的成员。const修饰的成员必须在定义的时候初始化,它只有一次初始化的机会
  2. 引用类型的成员。
  3. 没有默认构造的自定义类型成员。在开头处原因笔者已经进行了详细地说明。

       初始化列表就算我们不实现,编译器也会实现一个。这也就是为什么编译器不会处理内置类型的原因。因为是初始化列表遍历了每个成员,对于内置类型,看有没有给初始值,如果给了就拿这个初始值来初始化内置类型,对于自定义类型,看有没有默认构造,若没有默认构造,就会报错。这也解释了构造函数那一块中,调用类中的构造函数会先调用自定义类型变量的默认构造函数,然后再调用该类的构造函数的原因。之前笔者说过,内置类型给初始值才能被初始化,实际上这个初始值是给隐式初始化列表使用的,但是当我们显示实现了初始化列表,那么这个初始值就会失效,以我们实现的初始化列表中对该内置类型成员变量的赋值为准。       

       初始化列表初始化的顺序是成员变量的声明顺序,与初始化列表中的顺序无关。不过最好保持声明顺序和初始化列表中的顺序一致。

3.拷贝构造函数

       在使用一个类类型的变量初始化一个将要创建的类类型的变量时,编译器会调用一个默认成员函数是拷贝构造函数。它只有单个形参,该形参是对本类类型对象的引用(一般常用const修饰)。

       拷贝构造函数也是特殊的成员函数,其特征如下:

  • 拷贝构造函数是构造函数的一个重载形式。
  • 拷贝构造函数的参数只有一个且必须是类类型对象的引用,使用传值方式编译器直接报错,因为会引发无穷递归调用。
  • 若未显式定义,编译器会生成默认的拷贝构造函数。 默认的拷贝构造函数对象按内存存储按
    字节序完成拷贝,这种拷贝叫做浅拷贝,或者值拷贝。

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

class Date
{
public:
	Date()
	{
		cout << "Date()" << endl;
	}
	Date(const Date& d)
	{
		_year = d._year;
		_month = d._month;
		_day = d._day;
		cout << "Date(const Date& d)" << endl;
	}
	int _year = 1;
	int _month = 1;
	int _day = 1;
};

int main()
{
	Date date1;
	date1._day = 2;
	Date date2 = date1;//使用变量date2初始化变量date1
	return 0;
}

       运行函数,打开监视,我们可以发现,date2中的_day成员变量变成了2。

       同时屏幕上也显示了调用了拷贝构造函数。

        对于没有资源申请的程序,是否显示实现拷贝构造都可以,但是一旦有资源申请的程序,编译器自动实现的拷贝构造函数则不足以完成拷贝,还会引发程序崩溃。假如有两个类类型的变量,类中有一个指针类型的成员,存储着申请过的空间的地址,当使用编译器自动生成的拷贝构造时,拷贝的方式只是浅拷贝(值拷贝),也就是将这块空间的地址也给赋到了另一个类类型变量中的指针成员里面,这样两个指针成员变量将指向一块空间,当程序退出时调用析构函数,两个类类型变量分别调用自己的析构函数,那么这块空间将会被析构两次,肯定会引发程序报错。所以当程序中涉及资源申请时,需要我们自己实现拷贝构造函数,即我们需要实现深拷贝,申请另一块空间,然后将这块空间的内容给保存到另一块空间中。

       拷贝构造典型的调用场景:

  • 使用已存在对象创建新对象
  • 函数参数类型为类类型对象
  • 函数返回值类型为类类型对象

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

       在使用对象初始化新对象时,编译器会调用拷贝构造函数,将不再调用构造函数。

4.析构函数

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

       析构函数是特殊的成员函数,其特征如下:

  • 析构函数名是在类名前加上字符 ‘~’。
  • 无参数无返回值类型。
  • 一个类只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。注意:析构函数不能重载。
  • 对象生命周期结束时,C++编译系统系统自动调用析构函数。

       对于类中的内置类型,不需要对它们进行析构操作,因为它们不需要资源清理,最后系统直接将其内存回收即可。对于自定义类型,编译器生成的默认析构函数会调用自定义类型中的析构函数。

#include<iostream>
using namespace std;

class Time
{
public:
	void Apply()
	{
		newspace = (int*)malloc(sizeof(int));
		if (newspace == NULL)
		{
			perror("malloc fail!");
		}
		else
		{
			*newspace = 1;
		}
	}//为变量newspace申请一块空间并将其指向的空间的值初始化为1.
	Time()
	{
		cout << "Time()" << endl;
	}
	~Time()
	{
		free(newspace);
		cout << "~Time()" << endl;
	}//析构函数
	int hour = 0;
	int minute = 0;
	int second = 0;
	int* newspace = nullptr;
};

class Date
{
public:
	Date()
	{
		cout << "Date()" << endl;
	}
	void Applynewspace ()
	{
		time.Apply();
	}
	
	int _year = 1;
	int _month = 1;
	int _day = 1;
	Time time;
};

int main()
{
	Date date;
	date.Applynewspace();
	//在Date类中定义一个函数,调用一下Time类中的申请函数,为Time类中的newspace变量申请一块新节点。
	return 0;
}

       这段代码中,定义了一个日期类,它包含四个成员:整形类型的年月日,一个自定义类型time。自定义类型time又包含了三个整形内置类型成员小时、分钟,秒钟和一个整形指针变量。并且日期类中还有一个简略的构造函数,里面只有一条打印语句,仅为了证明编译器调用了构造函数。时间类中也有一个申请新节点的函数,目的是为了验证析构函数对空间的释放作用。

       运行程序,结果如下:

       在日期类中,我们并没有显示实现日期类的构造函数,但是却调用了Time类中的构造函数,这也证明了编译器对日期类自动实现了一个析构函数,并且这个析构函数调用了Time类中的析构函数。

       但是编译器并不会释放我们申请下来的空间,它虽然会生成析构函数,但是它的析构规则是:若没有自定义类型,就无需析构,若有自定义类型,就调用该自定义类型的析构函数。若该自定义类型没有析构函数,则自动生成一个析构函数,再看该类型的成员变量是否含有内置类型,若没有则无需执行析构操作,若有,则继续下一层。从此我们也可以看出来,这也是一个无限套娃的过程。但是它却让我们省去了调用销毁函数的语句。有很多时候我们都可能忘记调用销毁函数,导致内存泄露等问题。而我们只需实现好析构函数就可以了,编译器会自动调用析构函数。

5.重载赋值运算符

       拷贝构造函数的作用是在新对象创建的过程中,将相同类型的类类型变量的每个成员的值拷贝初始化给将要创建的对象的相对应的成员变量中。与拷贝构造函数类似,类也可以控制其对象如何赋值。对象给对象赋值需要用到拷贝赋值运算符。

       在介绍拷贝赋值运算符之前,我们先要了解一下重载运算符的知识。

       重载运算符本质上是函数,其名字由operator关键字后接表示要定义的运算符的符号组成。因此,赋值运算符就是一个名为operator=的函数。类似于任何其他函数,重载运算符函数也有一个返回类型和参数列表。重载运算符的参数表示运算符的运算对象。某些运算符,包括赋值运算符,必须定义为成员函数,也就是拷贝赋值运算符。其左侧运算对象传给this指针,其右侧运算对象作为显示参数传递。拷贝赋值运算符接受的参数是与其所在类型相同的参数。

#include<iostream>
using namespace std;

class Date
{
public:
	Date()
	{
		cout << "Date()" << endl;
	}//构造函数
	Date(const Date& d)
	{
		_year = d._year;
		_month = d._month;
		_day = d._day;
		cout << "Date(const Date& d)" << endl;
	}//构造函数
	Date& operator=(const Date& d)
    {
	    _year = d._year;
	    _month = d._month;
	    _day = d._day;
	    cout << "Date & operator=(const Date & d)" << endl;
	    return *this;
    }//拷贝赋值运算符

	int _year = 1;
	int _month = 1;
	int _day = 1;
};

int main()
{
	Date date1;
	date1._day = 2;
	date1._month = 7;

	Date date2;
	date2._day = 5;
	date2 = date1;//我们先给date1和date2两个变量中成员赋不同的值,然后观察拷贝赋值运算符的作用。
	
	return 0;
}

       运行程序,可以看见屏幕上打印的信息, 即编译器调用了我们刚刚显示实现的拷贝赋值运算符。

       调试程序,可以看到编译器将date1中的成员变量中的值分别赋给了date2中的成员变量中。

       注意:“ .* ”、“ :: ”、“ sizeof ”、“ ? : ”、“ . ” 以上5个运算符不能重载。

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

       当然我们也可以定义其他的重载运算符,如operator==运算符,它可以用来判断两个类中成员的值是否相等,返回一个布尔值。也可以判断两个类之间的大小关系的运算符,比如operator>,或operator<。它们需要我们自己实现。

       对于内置类型,编译器自动实现的默认拷贝赋值运算符是浅拷贝,直接赋值。对于自定义类型,编译器会调用该自定义类型的拷贝赋值运算符,若没有显示实现,则自动生成一个。若该类中还有自定义成员,就调用该自定义类型中的拷贝赋值运算符,这还是一个无限套娃的过程,直到遇到类中没有自定义成员为止。若类中没有涉及到资源的申请,那么编译器自动生成的拷贝赋值运算符就足以完成赋值任务,若涉及资源的申请,则必须自己实现拷贝赋值运算符完成深拷贝赋值。

      

      

  • 24
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值