一.类中生成的默认成员函数详解
0.类的6个默认成员函数
编译器会给类生成六个默认成员函数,在类中即使我们什么都不做,也会自动生成。
默认成员函数:用户没有显式实现,编译器会自动生成的成员函数称为默认成员函数。
下面我们逐个来讲解这些函数。
1.构造函数
1.1概念
对于以下Date类:
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;
};
对于我们写的这个类来说,我们使用时需要用Init的公有方法给对象设置日期,但是如果我们每次都要使用Init方法给对象设置日期信息,未免有些过于繁琐,那么有没有办法能够在对象创建时就将信息设置进去呢?
构造函数即可完成这件事情。
构造函数是一个特殊的成员函数,它的名字和类名相同,在创建类类型对象时编译器会自动调用,以保证每个数据成员都有一个合适的初始值,并且在对象整个生命周期内只调用一次。
1.2特性
构造函数虽然叫构造函数,但其作用并不是开一个空间来创建对象,而是初始化对象。
其特征如下:
- 函数名与类名相同
- 无返回值
- 对象实例化时编译器会自动调用相应的构造函数
- 构造函数可以重载
下面我们来验证一下以上四个特性:
class Date
{
public:
Date()//无返回值而且函数名与类名相同
{
_year = 2024;
_month = 5;
_day = 28;
}
Date(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 A;//调用无参构造
A.Print();
Date B(3, 4, 5);//调用三个参数的构造
B.Print();
return 0;
}
在上面这段代码中,通过打印,我们可以观察到一个调用了无参构造,一个调用了有参构造。
- 如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义,编译器将不再生成默认构造函数。
可以看到,我们在将构造函数屏蔽了之后,依旧是可以通过编译的,因此当我们不显式定义构造函数时,编译器会自动生成默认构造函数。
现在我们执行下这段代码
我们发现了一个奇怪的现象,这里生成了随机值,那么默认构造函数做了什么事呢?
答案:C++将类型分为了内置类型和自定义类型,内置类型就是语言提供的类型,而自定义类型则是我们使用class/union/struct等关键字自己定义的类型。
而默认构造函数会对自定义类型成员调用它的默认构造函数。
我们可以用以下代码验证一下:
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 _a;
};
我们在main函数中初始化了一个Date类型的对象,而Date类型的对象中声明了一个Time类型的对象,由于我们没有写Date类的默认构造函数,因此它会调用编译器自动生成的默认构造函数,这时默认的构造函数所作的事情就是去调用Date类中的自定义类型的构造函数。而Time类的构函数中打印了Time(),因此这段程序的最终输出结果为上图所示。
注意点:在C++11版本中,为我们无法对内置类型生成默认构造的问题打了一个补丁,我们可以在声明时给一个缺省值。
如下图所示
无参的构造函数和全缺省的构造函数都被称为默认构造函数,并且默认构造函数只能有一个。
主要注意的是:无参的构造函数、全缺省的构造函数、编译器生成的构造函数只能有一个,否则会有调用歧义。
譬如下图代码,编译器则会不知道调用的是哪个函数。
总结:不用传递参数就可以调用的构造函数就是默认构造函数。
2.析构函数
2.1概念
刚刚我们学习了构造函数,现在我们已经知道一个对象是怎么来的了,那么它是怎么没的呢?这就需要析构函数来大显神威了!
析构函数:与构造函数功能相反,析构函数不是完成对对象的销毁,局部对象的销毁工作(生命周期)是由编译器完成的。而对象在销毁时会自动调用析构函数,完成对象中资源的清理工作。
2.2特性
析构函数是特殊的成员函数,其特征如下:
- 析构函数名是在类名前加上~
- 无参数无返回值类型
- 一个类只能有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。
- 析构函数不能重载
- 对象生命周期结束时,C++编译系统自动调用析构函数。
下面我们还是来使用一下它
typedef int DataType;
class STack
{
public:
STack(size_t capacity = 3)
{
_a = (DataType*)malloc(sizeof(DataType) * capacity);
if (!_a)
{
perror("malloc fail!");
return;
}
_capacity = capacity;
_size = 0;
}
void Push(DataType data)
{
_a[_size] = data;
_size++;
}
~STack()
{
if (_a)
{
cout << "我析构啦!" << endl;
free(_a);
_a = NULL;
_capacity = 0;
_size = 0;
}
}
private:
DataType* _a;
int _capacity;
int _size;
};
int main()
{
STack s;
s.Push(1);
}
这里我们可以发现,当s的生命周期要结束时,编译器自动调用了析构函数。
- 与构造函数类似,编译器生成的析构函数也只会调用自定义类型的析构函数
class Time
{
public:
~Time()
{
cout << "我析构啦!" << 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类生成的默认析构函数。
注意:创建哪个类的对象则调用该类的析构函数,销毁那个类的对象则调用该类的析构函数
- 如果类中没有在堆上申请资源,析构函数可以不写,直接使用编译器生成的默认析构函数,比如Date类;有资源申请时,一定要写,否则会造成资源泄漏,比如Stack类。。
3.拷贝构造函数
3.1概念
在现实生活中,我们在超市买小面包,可以认为同一种类型的两袋小面包是一样的。
那么,有没有一样的对象呢?我们能否创建一个跟已有对象一样的对象呢?
这就引入了我们的拷贝构造函数。
拷贝构造函数:只有单个形参,该形参是对本类类型对象的引用(一般常用const修饰),再用已存在的类类型对象创建新对象时由编译器自动调用。
3.2特性
- 拷贝构造函数是构造函数的一个重载形式。
- 拷贝构造函数的参数只有一个而且必须是类类型对象的引用,使用传值方式编译器会报错,因为会引发无穷递归调用。
class Date
{
public:
Date(int year = 1900, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
// Date(const Date d) // 错误写法:编译报错,会引发无穷递归
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;
}
- 若未显式定义,编译器会生成默认的拷贝构造函数。 默认的拷贝构造对象按照内存存储字节序完成拷贝,这种拷贝叫做浅拷贝,或者值拷贝。(和memcpy的拷贝模式一样)。
对于我们的Date类来说,值拷贝就已经够用了,但是别的类呢?就譬如说我们的栈
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(s1);
return 0;
}
我们在运行这段代码时会发现它崩溃了,这是为什么呢?我们的s1对象中使用malloc动态开辟了一块内存,而我们是字节序拷贝的,因此我们的s2的array数组也指向了这块内存,而在程序退出时,s2和s1都要销毁,此时s2先销毁,s1再销毁(原因是栈区先进后出),s2已经将malloc的那块内存释放掉了,s1再去释放就会崩溃了。
因此,我们可以得到如此一个结论:类中如果没有涉及到资源的申请时,拷贝构造函数是否写都可以;一旦涉及到资源申请时,则拷贝构造函数必须要写。
3.3拷贝构造的使用方法
我们的拷贝构造有两种写法,分别如下:
Stack s2(s1);//新创一个对象s2,并将s1拷贝给s2。
Stack s2 = s1;//这样也是可以完成拷贝的。
4.运算符重载
C++为了增强代码的可读性引入了运算符重载,运算符重载是具有特殊函数名的函数,也具有其
返回值类型,函数名字以及参数列表,其返回值类型与参数列表与普通的函数类似。
函数名字为:关键字operator后面接需要重载的运算符符号。
函数原型:返回值类型 operator操作符(参数列表)
注意点:
- 不能通过连接其他符号来创建新的操作符:比如operator@
- 重载操作符必须有一个类类型参数
- 用于内置类型的运算符,其含义不能改变,例如:内置的整型+,不 能改变其含义
- 作为类成员函数重载时,其形参看起来比操作数数目少1,因为成员函数的第一个参数为隐
- 藏的this。
- .* :: sizeof ?: . 注意以上5个运算符不能重载。
现在我们来重载一个运算符:
//类内-->成员函数
bool operator==(const Date& d)
{
return _year == d._year
&& _month == d._month
&& _day == d._day;
}
//类外-->全局函数
bool operator==(const Date& d1, const Date& d2)
{
return d1._year == d2._year
&& d1._month == d2._month
&& d1._day == d2._day;
}
在这里提一句题外话,我们在写代码的途中,能用引用返回或者引用传值时就尽量用引用,因为它它的效率会比较高。至于能否用引用返回,这需要我们关注他们的生命周期。
5.赋值运算符重载
- 参数类型:const Date&, 传递引用可以提高传参效率
- 返回值类型 Date&,返回引用可以提高返回的效率,有返回值的目的是为了方便连续赋值
- 检测是否自己给自己赋值
- 返回*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;
}
//成员函数-->第一个参数是隐藏的this指针
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;
};
//全局函数-->没有隐式的this,需要传递两个参数。
Date& operator=(const Date& Mythis,const Date& d)
{
if (Mythis != &d)
{
Mythis._year = d._year;
Mythis._month = d._month;
Mythis._day = d._day;
}
return *Mythis;
}
按理来说,上述的代码是可以跑的过去的,但是实际上会产生编译错误。
为什么赋值运算符必须是成员函数呢?
这是因为赋值运算符如果不显式实现,编译器会生成一个默认的赋值运算符,此时用户再在类外自己实现一个全局的赋值运算符重载就会和编译器在类内生成的默认赋值运算符重载冲突。因此运算符重载只能是类的成员函数。
如果我们不显式实现赋值运算符的话,编译器生成的赋值运算符是以值的方式逐字节拷贝。
注意:
- 1.内置类型成员变量是直接赋值的,而自定义类型的成员变量需要调用对应的类的赋值运算符重载完成赋值。
- 需要深拷贝的类需要我们手写赋值运算符,浅拷贝即可解决需求的类用默认生成的即可。
关于运算符重载的知识这里不再阐述,博主会在后续写一篇日期类的实现讲运算符重载更加深入的讲解。
6.const修饰函数
如果我们想要用const修饰类内某个成员函数的this指针,应该如何实现呢?
CPP将const修饰的成员函数称为const成员函数,而const修饰类成员函数,实际上修饰的是该成员函数隐含的this指针,表明在该成员函数中不能对类的任何成员进行修改。
譬如这段代码
class Date
{
public:
Date(int year, int month, int day)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << "Print()" << endl;
cout << "year:" << _year << endl;
cout << "month:" << _month << endl;
cout << "day:" << _day << endl << endl;
}
private:
int _year; // 年
int _month; // 月
int _day; // 日
};
int main()
{
const Date d2(2022, 1, 13);
d2.Print();//error
return 0;
}
由于我们传递的d2是const的,但是在调用Print函数时,*this并没有加const修饰,这就造成了权限的放大,是不被允许的。因此我们应当将Print函数加上const修饰。
void Print() const
{
cout << "Print()" << endl;
cout << "year:" << _year << endl;
cout << "month:" << _month << endl;
cout << "day:" << _day << endl << endl;
}
7.取地址及const取地址操作符重载
取地址(&)也是可以重载的,不过一般情况下不需要我们写重载函数。
class Date
{
public:
Date* operator&()
{
return this;
}
const Date* operator&()const
{
return this;
}
private:
int _year; // 年
int _month; // 月
int _day; // 日
};
二.struct和class的对比
1struct
1.1struct的成员
在C++中,struct中不仅可以放数据类型,而且可以放函数。这个函数可以在类内定义,也可以在类外定义。
- 类内定义
#include <iostream>
using namespace std;
struct kuzi
{
int Add(int a, int b)
{
return a + b;
}
};
int main()
{
struct kuzi trousers;
printf("%d", trousers.Add(3, 2));
return 0;
}
- 类外定义
1.1.2结构体的默认访问权限
在结构体中,为了兼容C语言,数据成员的默认访问权限为public,这也就代表着它们可以在结构体的外部直接进行访问。
如以下代码,我们可以直接给trousers这个对象的a和b进行赋值。
struct kuzi
{
int a;
int b;
};
int main()
{
struct kuzi trousers;
trousers.a = 3;
trousers.b = 5;
return 0;
}
1.1.3访问权限控制
在C++11的标准中,引入了结构体成员的引用控制修饰符(public,privata,protect),我们可以显式的控制它的成员的访问权限。
struct kuzi
{
//默认是public
int a;
int b;
private:
int c;
int d;
};
1.2.class
在C++中,类(class)是一种重要的概念,可以创建用户定义的数据类型,其中可以包括数据成员和成员函数。而且,C++中的类支持继承和多态等面向对象的概念,允许你通过基类创建派生类,实现代码的扩展和重用。剩下的知识点已经讲解过了,这里不再赘述。
1.3结构体Struct和类Class之间的区别以及各自使用场景
C++中的结构体(struct)和类(class)有一些相似之处,但也存在一些关键的区别。以下是结构体和类之间的主要区别:
1、默认访问权限:
结构体的成员默认访问权限是公共(public),因此结构体的成员在外部可以直接访问。
类的成员默认访问权限是私有(private),因此类的成员在外部不能直接访问,需要通过公共的成员函数来访问。
2、成员函数:
类可以包含成员函数,这些函数可以操作类的私有成员,并且可以实现类的行为和功能。
结构体也可以有成员函数,但是它们的主要目的是为了实现一些操作,而不是类似于类的行为。
3、继承:
类可以通过继承实现子类与父类之间的关系,可以使用公共、保护或私有继承来控制成员的访问权限。
结构体也可以继承,但由于其成员默认是公共的,继承可能导致访问权限问题,当然我们也可以控制其访问权限,让其可以继承。
4、构造函数和析构函数:
类拥有构造函数和析构函数,用于对象的初始化和清理。
结构体也可以有构造函数和析构函数(但不会默认生成),但是它们的使用场景通常是比较简单的数据封装。
5、默认成员访问标签(Access Labels):
在类和结构体中,可以使用访问标签(public、private、protected)来指定成员的访问权限。
6、 使用场景:
结构体的使用场景:
用于存储一组相关的数据,但没有复杂的操作和逻辑。
类的使用场景:
当你需要封装数据并附加操作和行为时,类更适合,因为它可以将数据和操作封装在一起。
在实现更复杂的数据结构,如树、图等,使用类也更加合适。
总结:
虽然结构体和类在某些方面很相似,但它们的默认行为、访问权限、使用场景以及是否支持面向对象编程的特性(如继承、多态等)都有一些差异。在选择使用结构体还是类时,需要考虑你的代码的需求和设计目标。但是,结构体可以做的事类都可以干,结构体不可以干的事类也都可以干!
码字不易,如果你觉得博主写的不错,给个三联关注评论把!!!