目录
1 类的6个默认成员函数
2 构造函数
3 析构函数
3 拷贝构造函数
1 类的6个默认成员函数
class Date
{
public:
private:
};
这是一个空类,试问里面有什么?
可能你会觉得奇怪,明明是一个空类,却问里面有什么。其实一点也不奇怪,这就像文件操作章节,系统默认有三个流一样,标准输出流(stdout),标准输入流(stdin),标准错误流(stderr),类里面系统是有默认的函数的,一共有6个默认函数。
默认函数是指用户没有显式实现,系统会自己生成的函数,下面依次介绍。
2 构造函数
class Date
{
public:
void Init(int year = 2020,int month = 1,int day = 17)
{
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
当我们写了一个日期类之后,我们想要对它进行初始化,我们通常都会写一个函数叫做Init()函数,用来初始化里面的成员变量,这是一般写法。
那么有疑问了,我们介绍的不是构造函数吗,为什么会涉及到构造函数?
这是因为构造函数就是专门用来作为初始化函数的,至于为什么取名为构造函数呢?咱也不知道,咱也不敢问。
构造函数应遵行一下几个点:
1 函数名和类名应相同,并且没有返回值
class Date
{
public:
Date()
{
_year = 2020;
_month = 1;
_day = 17;
}
void Print()
{
cout << _year << '-' << _month << '-' << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1;
d1.Print();
return 0;
}
里面的Date()函数就是构造函数,因为没有返回值,所以不用加void,只有默认成员函数如果没有返回值就可以不用加上void,其他函数就不可以,可以用print函数试验一下。
2 类实例化的时候编译器自动调用构造函数
这里就这里结合调试:
是会自动跳到构造函数的,留个疑问,如果我们没有显式写默认构造函数会怎么样呢?
3 构造函数支持函数重载
这里就复习一下函数重载的概念,函数名相同,函数的参数不同,包括类型不同,个数不同,顺序不同,就构成函数重载:
class Date
{
public:
Date()
{
_year = 2020;
_month = 1;
_day = 17;
}
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 d1;
Date d2(2024,4,10);
d1.Print();
d2.Print();
return 0;
}
构造函数可以有多个,只要支持函数重载就行,并且不存在调用歧义:
class Date
{
public:
Date()
{
_year = 2020;
_month = 1;
_day = 17;
}
Date(int year = 1, int month = 1, int day = 1)
{
_year = year;
_month = month;
_day = day;
}
private:
int _year;
int _month;
int _day;
};
这种代码就会存在调用歧义,两个函数都构成构造函数的函数重载,但是调用的时候会出现问题,传参的时候如果是无参,则两个函数都行,就会存在调用歧义,所以编译器就会报错。
使用构造函数的时候一般有无参调用和带参调用:
Date d1;
Date d2(2024,4,10);
两种调用方式都可以,取决于带不带参数,都是没有问题的。
4 如果用户没有显示调用构造函数,编译器就会调用默认的构造函数,一旦用户显示定义构造函数,系统就不会生成默认构造函数。
class Date
{
public:
Date(int x)
{
_year = 2020;
_month = 1;
_day = 17;
}
void Print()
{
cout << _year << '-' << _month << '-' << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1;
d1.Print();
return 0;
}
这里定义了一个默认构造函数,系统就不会默认生成构造函数,所以这里编译器会报错,说没有合适的默认构造函数,主要就是因为我们已经显式定义了默认构造函数。
5 构造函数只会对自定义类型进行初始化,C++标准没有规定对内置类型要有所处理,初始化自定义类型的时候会调用该自定义类型自己的构造函数
这个点可能有点绕,我们分开来看,一是没有规定对内置类型有所处理, 如下:
class Date
{
public:
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1;
d1.Print();
return 0;
}
如上,打印出来都是些随机值,说明编译器对这三个内置类型没有进行处理,但是不乏有些编译器会将它们初始化为0,这也不用惊讶,因为对内置类型没有规定要处理,所以可处理可不处理,取决于编译器心情咯。
那么什么是调用自定义类型的构造函数呢?
class Time
{
public:
Time()
{
_hour = 0;
_minute = 0;
_second = 0;
}
private:
int _hour;
int _minute;
int _second;
};
class Date
{
public:
void Print()
{
cout << _year << '-' << _month << '-' << _day << endl;
}
private:
int _year;
int _month;
int _day;
Time _t;
};
当我们进行调试的时候,我们会发现编译器会自动进入到Time类的构造函数,随即初始化Time类的三个内置类型为0,但是如果Time类中我们没有显式定义构造函数呢?
那么就会:
那么Time类的内置类型的成员都会是随机值,有点类似无限套娃,只要我们没有显式定义构造函数,就会被定义为随机值,是不是看起来很鸡肋?
先不着急,C++11的标准中为了给内置成员初始化,添加了一个补丁,即可以在声明的时候给上缺省值:
class Date
{
public:
void Print()
{
cout << _year << '-' << _month << '-' << _day << endl;
}
private:
int _year = 1;
int _month = 1;
int _day = 1;
Time _t;
};
int main()
{
Date d1;
d1.Print();
return 0;
}
打印出来的时候即都是1,这就补上了不给内置成员初始化的缺陷。
那么构造函数是不是很鸡肋没有用处呢?
实际上并不是,如下:
class Stack
{
public:
private:
int* arr;
int _size;
int _capacity;
};
class MyQueue
{
public:
private:
Stack _st1;
Stack _st2;
};
在两个栈实现队列的时候,当我们调用MyQueue的时候,调用到MyQueue的构造函数的时候,我们不需要对队列进行初始化,因为使用的是栈,所以在栈里面初始化,队列类里面就不需要了,这个时候就不需要在Queue里面显式构造函数了。
默认构造函数有三种,无参构造函数,全缺省构造函数,系统自动生成的默认构造函数。
总结来说就是不需要传参的构造函数就是默认构造函数,而且默认构造函数只能有一个,不然存在调用歧义的问题。
3 析构函数
构造函数是用来初始化的,那么析构函数就是用来做类似销毁的工作的,但是不是对对象本身进行销毁,对象本身是局部变量,局部变量进行销毁是编译器完成的,析构函数是用来进行对象中的资源清理的。
析构函数应遵循如下特点:
函数名是类型前面加个~,没有返回值没有参数
class Date
{
public:
~Date()
{
_year = 0;
_month = 0;
_day = 0;
}
private:
int _year;
int _month;
int _day;
};
析构函数不能函数重载,如果用户显式定义了析构函数,系统就不会默认生成析构函数
当代码执行到这一步的时候,系统就会开始执行析构函数的代码,下一步语句就会跳转到~Date函数执行代码清理工作,因为析构函数没有参数,所以不支持函数重载,即只能有一个析构函数。
对象的声明周期结束的时候编译器会自己调用析构函数
也就是上图了,因为声明周期一结束,就会自己调用析构函数,如果没有显式定义析构函数的话,就会调用系统自己生成的析构函数。
当我们调用系统给的析构函数的时候就会发现:
内置类型并没有进行处理,这就是析构函数和构造函数相同的点:
对于内置类型没有要求要进行处理,处理自定义类型的时候会调用自定义类型自己的析构函数:
class Time
{
public:
~Time()
{
_hour = 0;
_minute = 0;
_second = 0;
}
private:
int _hour;
int _minute;
int _second;
};
class Date
{
public:
private:
int _year;
int _month;
int _day;
Time _t;
};
同构造函数一样。
那么总结起来也是,比如碰到两个栈实现一个队列的时候,就可以不用写析构函数,其他情况用户都是要显式定义析构函数的。
在类中,如果没有资源申请,那么就可以不用写析构函数,如果有资源申请,那么一定要写析构函数,不然就会导致内存泄露.
内存泄露是一件很恐怖的事,因为它不会报错,内存一点点的泄露,最后程序崩溃了,然后重启一下程序发现又好了,如此往复,就会导致用户的体验很不好
class Stack
{
public:
Stack(int capacity = 4)
{
int* tem = (int*)malloc(sizeof(int) * capacity);
if (tem == nullptr)
{
perror("malloc fail!");
exit(1);
}
arr = tem;
_capacity = capacity;
_size = 0;
}
~Stack()
{
free(arr);
arr = nullptr;
_capacity = _size = 0;
}
private:
int* arr;
int _size;
int _capacity;
};
像这种在堆上申请了空间的,就一定要写析构函数,不然就会导致内存泄露。
3 拷贝构造函数
拷贝构造函数,拷贝就是复制,像双胞胎一样,复制了许多特征,拷贝构造函数就是用来复制对象的,应遵行如下特点:
拷贝构造函数是构造函数的一个重载形式:
既然是构造函数的重载形式,那么拷贝构造函数的函数名也应该是类名,当然,也是没有返回值的。
拷贝构造函数的参数只有一个,是类类型的引用,如果采用传值调用就会触发无限递归,程序就会崩溃:
这个点的信息量有点大,我们一个一个解释
第一个,函数参数只有一个引用类型的参数,使用的时候如下:
class Date
{
public:
Date(int year,int month,int day)
{
_year = year;
_month = month;
_day = day;
}
Date(Date& dd)
{
_year = dd._year;
_month = dd._month;
_day = dd._day;
}
void Print()
{
cout << _year << '-' << _month << '-' << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main()
{
Date d1(2020, 1, 17);
d1.Print();
Date d2(d1);
d2.Print();
Date d3 = d1;
d3.Print();
return 0;
}
其中参数是Date& dd的就是拷贝构造函数,拷贝构造函数一共有两种拷贝方法:
一是Date d2 = d1,二是Date d3(d1),两种方式都可以的,最后打印出来的结果都是2020-1-17。
那么,为什么使用传值调用就会触发无限递归呢?
这是因为在传值调用的时候,形参也是一个对象,对象之间的赋值都会涉及到拷贝构造函数的调用,我们结合以下代码:
class Date
{
public:
Date(int year,int month,int day)
{
_year = year;
_month = month;
_day = day;
}
Date(const Date& dd)
{
_year = dd._year;
_month = dd._month;
_day = dd._day;
}
void Print()
{
cout << _year << '-' << _month << '-' << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
void Func(Date pd)
{
cout << "Date pd" << endl;
}
int main()
{
Date d1(2020, 1, 17);
Func(d1);
return 0;
}
当代码段执行到Func的时候,语句就会先跳到拷贝构造函数,赋值完了才会进入到函数Func里面,这时候我们监视形参pd,就会发现pd已经赋值了d1的数值。
也就是说,传值调用的时候,就会自动跳到拷贝函数,那么如果拷贝构造函数也是传值调用的话呢?就会造成拷贝构造函数的形参调用拷贝构造函数的形参,一直循环往复,从而导致了无限递归。
这就是为什么拷贝构造函数的参数必须是引用类型了,但是我们拷贝构造的时候,因为是引用类型,我们不希望引用类型被修改,所以常加一个const进行修饰。
如果用户没有显式定义拷贝构造函数,系统会默认生成拷贝构造函数,拷贝构造函数按字节序进行拷贝,这种拷贝被叫做浅拷贝,与之对应的是深拷贝
默认成员函数都有个特点,如果用户没有显式定义函数,系统都会默认生成该函数。
那么,什么是浅拷贝呢?对于日期类,无非就是赋值,我们不必太过在乎,但是对于Stack这种,我们就需要注意一下了,先看代码:
class Stack
{
public:
Stack(int capacity = 4)
{
int* tem = (int*)malloc(sizeof(int) * capacity);
if (tem == nullptr)
{
perror("malloc fail!");
exit(1);
}
arr = tem;
_capacity = capacity;
_size = 0;
}
~Stack()
{
free(arr);
arr = nullptr;
_capacity = _size = 0;
}
private:
int* arr;
int _size;
int _capacity;
};
int main()
{
Stack s1;
Stack s2(s1);
return 0;
}
对于Stack这种有资源申请的类,我们拷贝构造之后,生成解决方案的时候是成功的,但是当我们
运行程序的时候就会报错:
报错位置是在空指针那里,那么我们可以把重心放在空指针这里,既然是空指针报错,是我们越界访问了吗?还是说我们free了两次空指针?
看这个:
在拷贝构造完成之后,发现s1 和 s1的arr指向的空间居然是一样的:
因为拷贝构造函数内置类型是按照字节序拷贝的,所以拷贝的时候就会出现两个指针指向空间是同一个的情况,那么在析构函数,释放空间的时候,就会free掉空间两次,所以会报错。
浅拷贝对应的就是深拷贝,所以解决方法就是深拷贝,对于这种有空间申请的类,我们进行拷贝构造的时候都要深拷贝,不然析构的时候就会出现问题:
Stack(const Stack& ss)
{
arr = (int*)malloc(sizeof(int) * ss._capacity);
if (arr == nullptr)
{
perror("malloc fail!");
return;
}
memcpy(arr, ss.arr, sizeof(int) * ss._size);
_size = ss._size;
_capacity = ss._capacity;
}
深度拷贝构造无非就是两个指针指向不同的空间,但是里面的数据是一样的,那么拷贝数据我们就可以用到memcpy,然后自己开辟一块空间给s2,最后赋值相关的数据就可以了,这样就不会报错了。
总结:
如果是日期类的拷贝构造,是没有必要进行深拷贝的,用系统默认生成的拷贝构造函数就行。
拷贝构造函数报错常常因为析构函数,所以一般情况下拷贝构造函数不用写的话,析构函数也不用写。
如果内置成员都是自定义类型,如MyQueue,没有指向资源,默认的拷贝构造函数就可以。
如果内部资源有申请的话,如Stack类,就需要用户自己显式定义拷贝构造函数,防止空间多次释放。
感谢阅读!