目录
- 前言
- 一、默认成员函数
- 二、构造函数
- 三、析构函数
- 四、拷贝构造函数
- 五、赋值运算符重载
前言
前面我们学习了一些类和对象的基本知识,知道了什么是类,类中包括什么东西,以及能够使用一个类来实例化对象,并且会计算类对象的大小。这次我们重点是学习类中的一些非常重要的知识:默认成员函数和运算符重载。默认成员函数一共有6个,但是比较重要的就是四个:构造函数,析构函数,拷贝构造函数,赋值运算符重载。
一、默认成员函数
在学习类的几个默认成员函数之前,我们首先需要知道什么是默认成员函数,默认成员函数和普通成员函数有什么区别?
- 默认成员函数也是一种成员函数,其之所以称为默认成员函数,是因为,就算我们不写,编译器也会帮我们生成一个以完成对应的功能,类中常见的默认成员函数有:构造函数,析构函数,拷贝构造函数,赋值运算符重载函数。
- 普通成员函数也是一种成员函数,但是和默认成员函数的区别就是,只有我们定义实现的时候才是存在的,如果我们没有显示定义实现,那么就是不存在的,编译器不会帮助我们生成一个。像前面文章中实现的类中的初始化函数和打印函数是我们自己定义实现的,不自己定义实现编译器也不会生成,所以是普通成员函数
二、构造函数
构造函数是C++类中提出来的一个概念,是类中的一个默认成员函数,其主要功能就是在类创建对象的时候自动调用该函数完成对类实例化的对象中成员变量的初始化工作。需要注意的是:此函数虽然叫做构造函数,但是其并不是完成对象的构造,只是完成对实例化出的对象中成员变量属性的初始化,其功能类似于前面实现的初始化函数。
- 构造函数的特性:
- 函数名与类名相同。
- 无返回值。
- 对象实例化时编译器自动调用对象对应的类的构造函数完成对这个对象中成员变量的初始化。
- 构造函数可以重载。参数中可以是无参,半缺省,全缺省。
当无参版本的构造函数和全缺省版本的构造函数同时存在时,当我们创建对象的时候没有进行传参,因为,无参版本的构造函数和全缺省参数的构造函数都可以不用传参就能够调用,所以此时编译器就会出现歧义,不知道调用哪一个,所以会报错。
- 修改后代码:只留下全缺省版本
class Date
{
public:
// 构造函数
// 全缺省
Date(int year = 1, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
半缺省
//Date(int year , int month, int day = 1)
//{
// _year = year;
// _month = month;
// _day = day;
//}
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1;
d1.Print();
return 0;
}
- 调式
- 运行结果
对于全缺省版本的构造函数,我们也可以在调用的时候进行传参
5. 如果类中没有显式定义构造函数,则C++编译器会自动生成一个无参的默认构造函数,一旦用户显式定义编译器将不再生成
6. 无参的构造函数和全缺省的构造函数都称为默认构造函数,并且默认构造函数只能有一个。注意:无参构造函数、全缺省构造函数、我们没写编译器默认生成的构造函数,都可以认为是默认成员函数。
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;
}
上述代码中日期类中并没有实现构造函数,所以编译器会帮助我们自动生成一个,但是需要知道的是,编译器帮我们生成的默认构造函数对类中的内置类型成员是不会进行处理的,而只会处理自定义类型的成员
下面举一个类中有自定义成员的例子
从上面的例子中我们可以看出,我们在日期类中多加了一个A类的成员变量,A类属于自定义类型,年月日都属于整型,属于内置类型,我们在日期类中并没有实现构造函数,所以编译器会帮我们自动生成一个默认构造函数,这个默认构造函数对于对象中的内置类型并不会做任何处理,对于对象中的自定义类型就会去调用这个类型的默认构造函数,我们在A类中实现了一个默认构造函数对A类型中的成员变量进行了初始化,所以这个日期类中的A类型成员可以正常进行初始化。
-
假如A类型中不实现构造函数
显然这个时候日期类去调用A类的默认构造函数时,编译器检测到A类中没有实现构造函数,所以编译器也会帮助A类实现一个默认构造函数,而我们知道编译器生成的默认构造函数对于内置类型不会进行任何处理,所以,此时日期类中的A类中的成员变量是随机值,并没有进行初始化。 -
A类中定义一个构造函数
当A类中手动定义一个构造函数时,编译器识别到A类中存在构造函数,因此不会帮助A类生成一个默认构造函数,又因为日期类要调用A类的默认构造函数对A类的对象进行初始化,此时A类中的构造函数不是默认构造函数,因此就会报错。 -
下面举一个比较经典的栈和队列的例子
代码1:栈类中手动实现一个构造函数
class Stack
{
public:
// 手动实现一个构造函数
Stack(int capacity)
{
_a = (int*)malloc(sizeof(int) * capacity);
if (_a == nullptr)
{
return;
}
_capacity = capacity;
_size = _top = 0;
}
void Print()
{
cout << _capacity << endl;
cout << _size << endl;
cout << _top << endl;
}
private:
int* _a;
int _size;
int _capacity;
int _top;
};
class Queue
{
public:
// 对于队列类,类中的成员变量全部是自定义类型成员,所以可以不需要自己实现构造函数,编译器生成的默认构造函数就够用了
void Print()
{
_st1.Print();
_st2.Print();
}
private:
Stack _st1;
Stack _st2;
};
int main()
{
// 测试队列
Queue q;// q中有两个栈类的成员变量
q.Print();
return 0;
}
结果:
分析:上述的代码中,有一个栈类,一个队列类,在队列中包含两个栈,全是自定义类型,此时是可以不实现构造函数的,编译器生成的默认构造函数就够用了,在栈类中,全是自定义类型,显然是需要实现一个构造函数的,但是我们在栈类中实现了一个非默认构造函数的版本,所以编译器不会再帮助栈类重新生成一个默认构造函数,所以栈类中不存在默认构造函数,当创建队列对象的时候,首先会去调用队列类的默认构造函数,但是队列中是两个栈类成员,所以队列的默认构造函数会去调用栈类成员的默认构造函数,又因为,栈类中不存在相应的默认构造函数,因此,此时就调用失败,故报错。
- 代码2:栈类中不自己实现构造函数
class Stack
{
public:
void Print()
{
cout << _capacity << endl;
cout << _size << endl;
cout << _top << endl;
}
private:
int* _a;
int _size;
int _capacity;
int _top;
};
class Queue
{
public:
// 对于队列类,类中的成员变量全部是自定义类型成员,所以可以不需要自己实现构造函数,编译器生成的默认构造函数就够用了
void Print()
{
_st1.Print();
_st2.Print();
}
private:
Stack _st1;
Stack _st2;
};
int main()
{
// 测试队列
Queue q;// q中有两个栈类的成员变量
q.Print();
return 0;
}
结果:
分析:通过上面的结果,我们可以看到,此时队列中栈的成员全部是随机值。我们没有在队列中实现构造函数,所以编译器会生成一个队列的默认构造函数,队列中的成员变量是两个栈类型,所以当创建队列类的对象时,编译器会自动调用队列的默认构造函数,对于队列中的栈类型成员,队列的默认构造函数会去调用它们的默认构造函数,又因为我们没有在栈类中实现构造函数,所以编译器会在栈类中生成一个默认构造函数,因为编译器生成的默认构造函数对于类中的内置类型不会做处理,栈中的成员全部是内置类型,所以栈的默认构造函数不会对其进行处理,因此最终结果是随机值。
代码3:栈类中实现一个全缺省的默认构造函数
class Stack
{
public:
// 对于栈类,类中的成员变量全部是内置类型成员,所以我们必须自己实现构造函数
Stack(int capacity = 10, int size = 0)
{
_a = (int*)malloc(sizeof(int) * capacity);
if (_a == NULL)
{
return;
}
_capacity = capacity;
_size = size;
_top = 0;
}
void Print()
{
cout << _capacity << endl;
cout << _size << endl;
cout << _top << endl;
}
private:
int* _a;
int _size;
int _capacity;
int _top;
};
class Queue
{
public:
// 对于队列类,类中的成员变量全部是自定义类型成员,所以可以不需要自己实现构造函数,编译器生成的默认构造函数就够用了
void Print()
{
_st1.Print();
_st2.Print();
}
private:
Stack _st1;
Stack _st2;
};
int main()
{
// 测试队列
Queue q;// q中有两个栈类的成员变量
q.Print();
return 0;
}
结果:
分析:上述代码中显然队列中的栈成员中的成员变量不再是随机值。分析情况和上面两种情况是类似的,当创建队列类的对象时,编译器会去调用队列的默认构造函数,由于队列中没有实现构造函数,所以编译器会生成一个默认构造函数,这个默认构造函数又回去调用队列中栈成员的默认构造函数,我们在栈类中实现了一个全缺省的默认构造函数,所以编译器不会再生成栈类的默认函数,所以队列的默认构造函数就会调用我们实现的栈的默认构造函数对队列中的栈成员进行初始化,故最终结果不是随机值。
总结:一个类中的构造函数分为普通构造函数和默认构造函数,默认构造函数只有三种:无参构造函数,全缺省参数构造函数,不写编译器自动生成的默认构造函数,这三个默认构造函数在调用的时候是可以不进行传参的,其他在调用的时候需要传参的构造函数属于普通构造函数。默认构造函数在对对象中的成员变量进行初始化时会将对象中的成员变量分为内置类型的成员变量和自定义类型的成员变量,默认构造函数对于内置类型的成员变量不会进行任何处理,对于自定义类型的成员变量会去调用这个成员的默认构造函数,如果这个成员的类中没有默认构造函数(也就是手动实现了一个非默认构造函数),那么此时原来类中的默认构造函数就无法调用这个成员的默认构造函数,此时就会报错。
- 什么时候需要手动实现构造函数,什么时候不需要手动实现构造函数,编译器帮助我们生成的默认构造函数就够用了呢?
当一个类中的成员变量中只存在自定义类型时,此时编译器自动生成的默认构造函数就够使用了,当然,一定要确保,这些自定义类型中是存在默认构造函数的,并且能够对其自己的成员变量进行正确的初始化的。如果一个类中存在内置类型,因为编译器自动生成的默认构造函数是无法处理内置类型的成员变量的,所以此时我们就需要自己实现构造函数才能对这些内置类型的成员变量进行初始化。
三、析构函数
析构函数和前面我们实现的销毁函数的功能是类似的,其主要是完成类对象中资源的清理工作,并不会销毁对象。所谓的资源通常是指一个动态开辟的指针指向的空间。对于没有资源的类可以不需要实现析构函数。析构函数:与构造函数功能相反,析构函数不是完成对象的销毁,局部对象销毁工作是由编译器完成的。而对象在销毁时会自动调用析构函数,完成类的一些资源清理工作。
析构函数是特殊的成员函数。
其特征如下:
- 析构函数名是在类名前加上字符 ~。
- 无参数无返回值。一个类中只能有一个析构函数,不能构成重载。
- 一个类有且只有一个析构函数。若未显式定义,系统会自动生成默认的析构函数。
- 对象生命周期结束时,C++编译系统系统自动调用析构函数。
前面写的一个栈类就需要实现自己实现析构函数
class Stack
{
public:
// 对于栈类,类中的成员变量全部是内置类型成员,所以我们必须自己实现构造函数
Stack(int capacity = 10, int size = 0)
{
_a = (int*)malloc(sizeof(int) * capacity);
if (_a == NULL)
{
return;
}
_capacity = capacity;
_size = size;
_top = 0;
}
// 析构函数
~Stack()
{
free(_a);
_size = _capacity = _top = 0;
}
void Print()
{
cout << _capacity << endl;
cout << _size << endl;
cout << _top << endl;
}
private:
int* _a;
int _size;
int _capacity;
int _top;
};
- 当我们没有自己实现析构函数时,编译器也会自动生成一个析构函数,编译器生成的析构函数对于类中的内置类型不会做任何处理,对于自定义类型的成员会去调用这个成员的析构函数
class String
{
public:
String(const char* str = "jack")
{
_str = (char*)malloc(strlen(str) + 1);
strcpy(_str, str);
}
~String()
{
cout << "~String()" << endl;
free(_str);
}
private:
char* _str;
};
class Person
{
private:
String _name;
int _age;
};
int main()
{
Person p;
return 0;
}
分析:
在上述代码中,我们在Person类中没有实现析构函数,所以编译器会生成一个Person类的析构函数,Person类中的成员变量是名字和年龄,年龄是整型变量,是一个内置类型,所以编译器生成的析构函数不会处理,姓名是一个string类型,是一个自定义类型,所以析构函数会去调用这个成员的析构函数完成其中资源的清理工作。
四、拷贝构造函数
拷贝构造函数完成的工作主要是拷贝工作,通常是用一个同类型的对象去拷贝构造一个新的对象。构造函数:只有单个形参,该形参是对本类类型对象的引用(一般常用const修饰),在用已存在的类类型对象创建新对象时由编译器自动调用。
拷贝构造函数也是特殊的成员函数,其特征如下:
- 拷贝构造函数是构造函数的一个重载形式。
- 拷贝构造函数的参数只有一个且必须使用引用传参,使用传值方式会引发无穷递归调用。如果拷贝构造函数的参数不是采用引用传参,而是采用传值传参,当调用拷贝构造时,首先需要将拷贝对象传给函数的形参,而拷贝对象是一个自定义类型的对象,所以也是一次拷贝构造过程,所以会继续调用拷贝构造函数,所以就会出现源源不断地调用该类型的拷贝构造函数。
- 拷贝构造通常分为值拷贝和深拷贝。对于类型中没有动态开辟的指针,通常只需要实现浅拷贝即可,对于类型中存在动态开辟的指针,那么就需要实现成深拷贝。
- 如果我们没有在类型中实现拷贝构造函数,那么编译器会自动生成一个拷贝构造函数,编译器生成的拷贝构造函数是一个浅拷贝。
- 编译器生成的拷贝构造函数在处理类中成员时,同样将成员变量分成两类:内置类型和自定义成员。对于内置类型,实现浅拷贝,对于自定义类型,调用这个成员的拷贝构造函数。
下面我们写一个日期类的拷贝构造函数
代码1:自己实现拷贝构造函数
class Date
{
public:
// 对于日期类,因为其中的成员变量全部是内置类型,所以我们必须自己实现一个构造函数
Date(int year = 1, 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;
}
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2023,2,3);
Date d2(d1);
d1.Print();
d2.Print();
return 0;
}
结果:
在实现拷贝构造函数时,我们需要注意一些细节:拷贝构造函数的参数必须是传引用传参,防止出现无限递归传参,如果可以确定被拷贝的对象不会发生改变,我们可以在形参前面加上const关键字,从而可以对形参在函数中实行保护,防止被拷贝的对象在拷贝构造函数中被改变。
代码2:自己不实现拷贝构造函数
class Date
{
public:
// 对于日期类,因为其中的成员变量全部是内置类型,所以我们必须自己实现一个构造函数
Date(int year = 1, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2023, 2, 3);
Date d2(d1);
d1.Print();
d2.Print();
return 0;
}
结果:
通过上面的结果,显然对于日期类,就算我们不自己实现一个拷贝构造函数,仍然能够完成拷贝过程,这是因为,当我们没有在类中实现拷贝构造函数时,编译器会帮我们自动生成一个,编译器生成的拷贝构造函数可以帮助实现浅拷贝,对于日期类,类中的成员变量没有动态开辟的指针,因此浅拷贝就能够实现拷贝过程了。
代码3:栈类型不实现拷贝构造函数
class Stack
{
public:
// 对于栈类,类中的成员变量全部是内置类型成员,所以我们必须自己实现构造函数
Stack(int capacity = 10, int size = 0)
{
_a = (int*)malloc(sizeof(int) * capacity);
if (_a == NULL)
{
return;
}
_capacity = capacity;
_size = size;
_top = 0;
}
// 析构函数
~Stack()
{
free(_a);
_size = _capacity = _top = 0;
}
void Print()
{
cout << _capacity << endl;
cout << _size << endl;
cout << _top << endl;
}
private:
int* _a;
int _size;
int _capacity;
int _top;
};
int main()
{
Stack st1(10);
Stack st2(st1);
st1.Print();
st2.Print();
return 0;
}
结果:
通过上面结果我们可以看出程序发生崩溃,显然编译器生成的浅拷贝并不能够正确完成栈类型对象的拷贝过程,原因是栈类型中有一个动态开辟的指针,如果只是完成浅拷贝,那么最终就会导致两个栈中的指针指向了同一块空间,最后当对象要析构的时候,两个对象都需要调用析构函数,就会出现同一块空间被释放两次,所以程序会发生崩溃。除此之外,两个栈中的指针指向同一块空间,其中一个栈在修改数据时,显然也会影响到另一个栈的空间,显然是不正常的。
解决方法:对于栈类型的拷贝构造函数,需要自己实现深拷贝。
五、赋值运算符重载
赋值运算符重载需要使用一个新的关键字operator,并且重载之后其实是形成一个新的函数,所以运算符重载本身就是一种函数,其还可以增加代码的可读性,随着学习的深入,我们会慢慢体会到。赋值的运算符和拷贝构造有点类似,但是也有所不同。拷贝构造的过程是使用一个同类型的对象去拷贝构造一个新的对象,赋值是将一个同类型的对象去赋值另一个已经存在的对象,在实现赋值运算符重载的过程中需要注意几个细节:赋值给自己,支持连续赋值(考虑返回值的问题),下面以一个日期类的赋值运算符重载函数作为例子:
// 赋值运算符重载
Date& operator=(const Date& d)
{
if (this != &d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
return *this;
}
分析:其中的if条件判断就是可以解决对象自身赋值的情况,返回值是*this,就可以支持连续赋值了,并且,由于出了函数,*this还存在,所以这里可以考虑传引用返回,从而降低拷贝。