【C++】类与对象详解——其二

本文介绍了C++中的类与对象相关概念,包括构造函数(全缺省构造、系统自动生成、构造函数重载)、析构函数、拷贝构造(浅拷贝与深拷贝)、赋值重载,以及const成员函数的作用和使用。文章强调了在涉及资源管理时,自定义构造和析构函数的重要性,同时也讨论了C++11对内置类型初始化的改进。
摘要由CSDN通过智能技术生成

0.知识补充

C++把类型分成内置类型(基本类型)和自定义类型

  • 内置类型:int/char/double…/任意指针
  • 自定义类型:class/struct…定义的类型

默认构造:不用传参的构造都是默认构造——无参的全缺省的编译器自动生成的

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

首先,对于任何一个类,都需要Init初始化和Destroy释放空间,在C语言中,我们每次使用结构体都需要自己手动对其初始化和释放清理空间,使用起来十分的不方便。
所以C++中添加了可以让编译器在类中自动生成初始化、空间释放清理等成员函数的功能。

若在一个类中什么都不写(空类),则编译器会在其中自动生成6个默认成员函数,分别是:

  • 构造函数
  • 析构函数
  • 拷贝构造
  • 赋值重载
  • 取地址
  • const取地址

!!!默认成员函数都是天选之子,即使不写也会自动生成!!!

2.构造函数

首先编写一个简单的Stack类进行引入:

class Stack
{
public:
	void Init(int capacity = 4)//初始化
	{
		_a = (int*)malloc(sizeof(int) * capacity);
		if (_a = nullptr)
		{
			perror("malloc申请空间失败");
			exit(-1);
		}
		_capacity = capacity;
		_size = 0;
	}
	void Destroy()//释放空间
	{
		//...
	}
	void Push(int x = 0)//插入
	{
		//...
	}

private:
	int* _a;
	int _capacity;
	int _size;
};


int main()
{
	Stack s1;
	s1.Init(4);
	s1.Push(1);
	s1.Destroy();
	return 0;
}

对于Strack类,我们每次创建一个对象都要通过Init来初始化对象,使用起来不方便,所以引入了构造函数来解决这个问题。

2.1.构造函数的特性

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

构造函数的特征:

  1. 函数名与类名相同。
  2. 无返回值。
  3. 对象实例化时编译器自动调用对应的构造函数。
  4. 构造函数可以重载。
  5. 如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦 用户显式定义任意一个,编译器将不再生成。
  6. 单参数的构造函数支持隐式类型的转换,可以将自定义类型转换为内置类型

特征解析:
1.以Stack类为例,它的构造函数名也是Stack;
2.没有返回值的意思不是返回void,而是实际意义上的没有返回值,直接写为Stack;
3.会自动调用,即即使用户不手动调用初始化对象,编译器也会自动对初始化,这点比较重要;
4.构造函数可以重载,意思是一个类可以有多个构造函数即一个类可以有多种初始化的方式,至于会使用哪个构造函数来初始化,则是取决于参数;
5.只要自己显式定义任意一个构造函数,编译器就不会自动生成了,就算是自己定义了有参的,没有写无参的,而调用的时候需要使用的是无参构造函数,编译器依旧不会自动生成无参默认构造函数;

以栈类为例:

class Stack
{
    //无参构造函数
    Stack()
    {
        _a = nullptr;
        _size = _capacity = 0;
    }
    //构造函数支持函数重载
    //有参构造函数
	Stack(int capacity)
	{
		_a = (int*)malloc(sizeof(int) * capacity);
		if (_a = nullptr)
		{
			perror("malloc申请空间失败");
			return;
		}
		_capacity = capacity;
		_size = 0;
	}
};

int main()
{
	Stack s1;//实例化对象s1,自动调用析无参构造函数对s1进行初始化
	Stack s2(4);//实例化对象s2,自动调用有参的析构函数对s2进行初始化
	return 0;
}

2.1.1.全缺省构造函数

有些构造函数中,重载的两个构造函数可以通过全缺省参数合二为一:

//以日期类为例:
class Date
{
public:
    Date(int year = 1, int month = 1, int day = 1)
    {
        _year = year;
        _month = month;
        _day = day;
    }
    
private:
    int _year;
    int _month;
    int _day;
};

2.1.2.构造函数的调用方法

以日期类的调用为例说明
1.无参构造函数:Date d1;——通过无参构造函数创建对象时,对象后面不用跟括号,否则就成了函数声明。
2.有参构造函数:Date d2(2023,1,1);——这句不能理解为d2.Date(2023,1,1);因为在走这句程序的时候d2还没有实例化,是定义好了才在后面初始化的;其次这句加了括号为什么不会判定为函数声明呢?因为如果是函数声明的话括号内应该有参数类型。

2.2.系统自动生成的默认构造函数

C++规定对象在实例化的时候必须调用构造函数!
如果我们自己写了任意一个构造函数,就会调用我们自己写的那个,不再生成默认构造函数;
如果我们没有写构造函数,则编译器会自动生成一个默认构造函数,并调用这个自动生成的。

//以日期类为例
class Date
{
public:
    Date(int year, int month, int day)
    {}
    
private:
    int _year;
    int _month;
    int _day;
};
int main()
{
    Data d1;//初始化失败,因为我们自己写了有参的构造函数,而这里调用的是无参的
            //并且因为我们已经写了一个构造函数,所以系统不再会自动生成无参构造函数
    return 0;
}

那么系统自动生成的默认构造函数是什么样的呢?

默认生成的构造函数:

  1. 内置类型的成员不做处理
  2. 自定义类型的成员,调用它的默认构造(不用传参的构造都是默认构造——无参的、全缺省的、编译器自动生成的)

因为对内置类型不做处理,所以若是类中的成员变量是内置类型,在通过自动生成的构造函数初始化时虽然能初始化成功,但是这些内置类型的成员变量会赋随机值。

2.2.1.不应该使用自动生成默认构造函数的场景

//以日期类为例:
class Date
{
public:
    //自动生成默认构造函数
    void Print()
    {
        cout << _year << "/" << _month << "/" << _day << endl;
    }
private:
    int _year;
    int _month;
    int _day;
};
int main()
{
    Date.d1;
    d1.Print();//日期类中的年月日设置为私有,要通过函数访问打印
               //打印出的年月日为随机值
    return 0;
}

解析:
因为日期类中的年月日都是内置类型int,自动生成的构造函数对其不做处理,会赋随机值。
所以,对于像日期类这种类型的类,不要使用默认生成的构造函数。

2.2.2.可以使用自动生成默认构造函数的场景

class Stack
{
public:
	Stack(int capacity = 4)
	{
		_a = (int*)malloc(sizeof(int) * capacity);
		if (_a = nullptr)
		{
			perror("malloc申请空间失败");
			return;
		}
		_capacity = capacity;
		_size = 0;
		}
	
    ~Stack()
    {
        free(_a);
        _a = nullptr;
        _size = _capacity = 0;
    }
private:
    int* _a;
    int _size;
    int _capacity;
};

class MyQueue
{
public:
    //默认生成构造函数,对自定义类型调用它的默认构造
    void Push(int x)
    {}
private:
    Stack _pushST;//自定义类型
    Stack _popST;//自定义类型
}
int main()
{
    MyQueue.q1;//自动生成默认构造函数并调用
    return 0;
}

解析:
MyQueue类中的成员变量都是自定义类型Stack,自动生成的构造函数调用它的默认构造(不用传参的构造)
这里同样不用显式定义析构函数,因为自动生成的析构函数~ MyQueue会对自定义类型调用它的默认析构~Stack
所以在应用是直接MyQueue.q1;对对象进行实例化即可,其他的编译器会自动完成,如果用C语言编写这个程序会麻烦很多,在这种情况下应用自动生成的默认构造很便利。
在这里插入图片描述

2.3.C++11对内置类型不初始化的补丁

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

这样就可以处理自动生成的构造函数对内置类型不处理的问题了,会使用缺省值初始化,而不是赋随机值。如果显式初始化了,那么就不用缺省值。

//以日期类为例
class Date
{
public:
    //自动生成默认构造函数
    void Print()
    {
        cout << _year << "/" << _month << "/" << _day << endl;
    }
private:
    int _year = 1;//在声明的时候可以给缺省值
    int _month = 1;//在声明的时候可以给缺省值
    int _day = 1;//在声明的时候可以给缺省值
};

3.析构函数

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

3.1.析构函数的特征

析构函数的许多性质与构造函数类似:

  1. 析构函数名是在类名前加上字符 ~
  2. 无参数、无返回值类型。
  3. 一个类只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。
    注意:析构函数不能重载。
  4. 对象生命周期结束时,C++编译系统系统自动调用析构函数。
  5. 内置类型的成员不做处理;对自定义类型的成员,调用它的默认析构。
  6. 如果类中没有申请资源时,析构函数可以不写,直接使用编译器生成的默认析构函数,比如Date类;有资源申请时,一定要写,否则会造成资源泄漏,比如Stack类。

C语言和C++释放清理空间对比:

//这里只写函数的调用方法对比
//C语言
--------------------
//...
Stack s1;
StackInit(& s1);
StackPush(&s1, 1);
StackDestroy(&s1);
//...
--------------------
//C++
--------------------
//...
Stack s1(4);//调用有参构造函数
s1.Push(1);
//没有申请资源的话不用显式调用析构,可以自动调用默认析构函数
//...
--------------------
//析构函数
~Stack()
{
	free(_a);
	_a = nullptr;
	_size = _capacity = 0;
}

4.拷贝构造

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

class Date
{
public:
    //构造函数
    Date(int year = 2023, 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 = 1;
    int _month = 1;
    int _day = 1;
};
int main()
{
    Date d1(2023, 1, 1);
    Date d2(d1);//拷贝构造
    return 0;
}

补充:拷贝构造的使用方法:

//将d1拷贝给d2
Date d2(d1);
Date d2 = d1;//也可以这样写,二者都是拷贝构造

注意第二种用法,不是赋值,是拷贝构造

4.1.拷贝构造的特征

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

4.1.1.自定义类型传值传参的拷贝构造

传值传参需要对实参进行拷贝。
对于传值传参

  1. 内置类型,编译器可以直接拷贝
  2. 自定义类型,需要调用拷贝构造
//传值传参
void Func1(Date d)
{}
//传引用传参
void Func2(Date& d)
{}

那么为什么对于自定义类型的参数,编译器不能直接拷贝而需要调用其拷贝构造呢?
因为对于自定义类型对象的拷贝,有时候能成功,有时候会出错:

1.可以直接拷贝的自定义类型:比如日期类——浅拷贝
在这里插入图片描述
2.不可以直接拷贝的自定义类型:比如栈类——深拷贝
在这里插入图片描述
st1拷贝st2时,若直接拷贝,则会将st2中_a指针所包含的地址直接拷贝到st1的_a指针中导致两个指针指向同一块空间。
当其中一个对象调用析构函数,将_a所指向的空间释放了,再过一会另一个对象也要调用析构函数释放这块空间,但是一块空间不能析构两次。
其次由于两个指针指向同一块空间,会导致数据的覆盖写入。

综上所述:
自定义类型需要调用拷贝构造拷贝——深拷贝

4.4.2.拷贝构造只能用传引用传参的解析

拷贝构造如果用传引用传参会造成无穷递归:
在这里插入图片描述而传引用传参在传参数时不用拷贝,就不存在自定义类型调用拷贝构造而导致无穷递归的问题了。
注意:使用传引用传参的时候要用const修饰,因为传引用传参时创建了实参的别名传入,等同于直接传入实参,加const是为了防止实参被修改。同时如果传入了const对象,这里不用const修饰的话会导致权限放大。

4.2.自动生成的默认拷贝构造解析

默认生成的拷贝构造:
1.对于内置类型,会进行值拷贝或者浅拷贝;
2.对于自定义类型,会去调用它的拷贝构造;
既然对于内置类型和自定义类型他都会做处理,是不是说明我们可以在任何情况下都是用自动生成的拷贝构造呢?答案很明显错误。
例:

class Stack
{
public:
	Stack(int capacity = 10)
	{
		_a = (int*)malloc(sizeof(int) * capacity);
		if (_a = nullptr)
		{
			perror("malloc fail");
			exit(-1);
		}
		_size = 0;
		_capacity = capacity;
	}
	~Stack()
	{
		if (_a != nullptr)
		{
			free(_a);
			_a = nullptr;
			_size = _capacity = 0;
		}
	}
private:
	int* _a;
	int _size;
	int _capacity;
};
int main()
{
    Stack s1;
    Stack s2(s1);//使用默认生成的拷贝构造
    return 0;
}

若对栈类直接调用默认生成的拷贝构造,会导致有两个指针指向同一块空间,会造成数据的覆盖、多次调用析构导致空间的多次释放程序崩溃等问题。
补充:在上面的程序中,s2先析构,s1后析构,因为后定义的会先析构,s1先定义先进栈,s2后定义后进栈,后进栈的先出栈。所以在s2先调用析构释放了空间以后,s1的指针会指向已经释放的空间。
在这种情况下要使用深拷贝(后续讲解)。

那么我们什么时候应该自己实现拷贝构造,什么时候可以使用自动生成的拷贝构造呢?
——当我们自己实现了析构函数来释放空间就需要自己来实现拷贝构造。
因为如果自己实现了析构函数,就说明涉及了资源管理,自然需要自己实现。

使用自动生成的拷贝构造例:MyQueue

class Stack
{
public:
	Stack(int capacity = 10)
	{
		_a = (int*)malloc(sizeof(int) * capacity);
		if (_a = nullptr)
		{
			perror("malloc fail");
			exit(-1);
		}
		_size = 0;
		_capacity = capacity;
	}
	//简易的深拷贝拷贝构造
	Stack(const Stack& s)
	{
		_a = (int*)malloc(sizeof(int) * s._capacity);
		if (_a = nullptr)
		{
			perror("malloc fail");
			exit(-1);
		}
		memcpy(_a, s._a, sizeof(int) * s._size);
		_size = s._size;
		_capacity = s._capacity;
	}
	~Stack()
	{
		if (_a != nullptr)
		{
			free(_a);
			_a = nullptr;
			_size = _capacity = 0;
		}
	}
private:
	int* _a;
	int _size;
	int _capacity;
};
class MyQueue
{
public:
	//构造函数:默认生成,对于自定义类型Stack会去调用它的构造函数;对于内置类型int不会处理,但是给了缺省值
	//析构函数:默认生成,对于自定义类型stack会去调用它的析构函数;对于内置类型int不做处理,但是不影响
	//拷贝构造:默认生成,对于自定义类型stack会去调用它的拷贝构造;对于内置类型int完成值拷贝
private:
	int size = 0;
	Stack st1;
	Stack st2;
};

5.赋值重载

默认生成的赋值重载:
对内置类型,调用这个成员的赋值重载
对自定义类型,完成浅拷贝/值拷贝

5.1.运算符重载operator

1.运算符重载的目的:增强程序的可读性。
运算符重载是具有特殊函数名的函数,也具有其返回值类型,函数名以及参数列表,其返回值类型和参数列表与普通的函数类似。
2.函数名:关键字operator + 需要重载的运算符符号。
3.函数组成:返回值类型 operator操作符(参数列表)

补充:函数重载和运算符重载虽然都叫重载,但是完全不一样:
运算符重载:让自定义类型的对象可以使用运算符。
函数重载:支持函数名相同、参数不同的函数可以同时使用。

5.1.1.运算符重载的实现

//以日期类为例
//实现==的运算符重载
//1.写在类外:
bool operator==(const Date& d1, const Date& d2)
{
	return d1._year == d2._year
		&& d1._month == d2._month
		&& d1._day == d2._day;
}
//2.写在类内:
//第一个参数d1是this
//即d1:左操作数;d2:右操作数
bool operator==(const Date& d2)
{
	return _year == d2._year
		&& _month == d2._month
		&& _day == d2._day;
}

注意:

  1. 有几个操作数,就有几个参数,一一对应;
  2. 如果有两个参数,则第一个参数是左操作数,第二个参数是右操作数;
  3. 作为类成员函数重载时,其形参看起来比操作数数目少一个,因为成员函数的第一个参数为隐藏的this。
  4. 在使用流插入直接对运算符运算符重载打印时,要注意<<和重载运算符的优先级,加上括号再打印比较好。
  5. 不能通过连接其他符号来创建新的操作符:比如operator@
  6. 重载操作符必须有一个自定义类型的参数
  7. 用于内置类型的运算符,其含义不能改变
  8. .* 域作用限定符::、类型及算sizeof、三目运算符? : 、成员访问符 . 注意以上5个运算符不能重载 (第一个.*平时基本不会使用)

5.1.2.运算符重载的使用方式

可以直接把“operator运算符(参数)”看作是函数名,并且可以对其直接显式调用。也可以正常使用,直接使用运算符,编译器会将其自动转换成“operator运算符(参数)”的形式运行。
以前面写的运算符重载为例来使用:

	cout << operator==(d1, d2) << endl;//写在类外的重载
	cout << d1.operator==(d2) << endl;//写在类内的重载
	cout << (d1 == d2) << endl;
	//一般用第二种,两者含义相同

5.2.赋值运算符重载

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

赋值运算符的特点:
1.赋值运算符有返回值;
2.赋值运算符支持连续赋值;

i = j = k;
//k先赋值给j,返回值是j,然后再将j赋值给i

赋值运算符重载的实现:

//d1 = d2
Date& operator=(const Date& d)
{
	if (this != &d)
	{
		_year = d._year;
		_month = d._month;
		_day = d._day;
	}
	return *this;
}

解析:

  • 参数类型:const T&,传递引用可以提高传参效率
  • 返回值类型:T&,返回引用可以提高返回的效率,有返回值目的是为了支持连续赋值检测是否自己给自己赋值
  • 返回*this :要复合连续赋值mn的含义
  • if的判断是为了解决出现d1 = d1这种自己给自己赋值的情况发生时出现浪费。

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

用户没有显式实现时,编译器会生成一个默认赋值运算符重载,以值的方式逐字节拷贝。
注意:如果类中未涉及到资源管理,赋值运算符是否实现都可以;一旦涉及到资源管理则必须要实现。

5.2.1.赋值运算符重载的使用

不要混淆赋值运算符重载和拷贝构造的使用:
已经实例化好的两个对象是赋值运算符重载;
而拷贝构造是用一个已经实例化好的对象去初始化另外一个对象

//拷贝构造
Date d1(d2);
Date d1 = d2;
//赋值运算符重载
d1 = d2;

6.const成员函数

将const修饰的“成员函数”称之为const成员函数,const修饰类成员函数,实际修饰该成员函数隐含的this指针,表明在该成员函数中不能对类的任何成员进行修改。

注:const成员函数,const对象和普通对象都可以调用。

6.1.const成员函数的使用方法:

使用格式:
返回值类型 函数名 () const
{}

const在函数的声明和定义处都要添加。
例:

class A
{
public:
//const修饰*this
//因为*this是隐藏参数,不能直接加const
//所以只能加后面,现在指针类型变成了const A*
	void print() const
	{
		cout << _a << endl;
	}
private:
	int _a = 20;
};

注意:
如果在类定义时,用const修饰,则在调用成员函数传参的时候传入的是const A* this,此时如果成员函数没有使用const成员函数,则会出现权限的放大,会导致编译器报错。

const对象不可以调用非const成员函数!
因为如果是const对象,则调用成员函数的时候传入的隐藏this指针也是const修饰的const A* this,传入普通的A* this中,会出现权限放大!

例如:

class A
{
public:
	void print()
	{
		cout << _a << endl;
	}
private:
	int _a = 20;
};

int main()
{
	A a1;
	const A a2;
	a1.print();//可以正常运行
	a2.print();//权限放大,const A* this传参时传入A* this
	return 0;
}

总结:对于内部不改变成员变量的成员函数,最好都加上const,因为const成员函数不管是不是const对象都可以调用。


7.取地址操作符重载、const取地址操作符重载

这两个运算符一般不需要重载,使用编译器生成的默认取地址的重载即可。
因为没有自己实现重载的价值,没有可以操作的空间。

例:

class A
{
public:
	//取地址操作符重载
	A* operator&()
	{
	    return this;
	}
	//const取地址操作符重载
	A* operator&() const
	{
	    return this;
	}	
private:
	int _a = 10;
};

int main()
{
	A a1;
	const A a2;
	cout << &a1 << endl;//取地址
	cout << &a2 << endl;//const取地址
	return 0;
}

补充:只有特殊情况,才需要重载,比如想让别人获取到指定的内容。

8.知识补充

1.友元函数friend
在类外可以访问类内的私有成员。
用法,以Add函数为例:
在类内加入friend void Add(const Date& d1, const Date& d2);即可。

class Date
{
    friend void Add(const Date& d1, const Date& d2);
public:
//...
private:
//...
}
//定义在类外
void Add(const Date& d1, const Date& d2)
{//...}

2.定义在类内的函数默认为内联函数inline,使用时直接原地展开

评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值