深入篇【C++】类与对象:拷贝构造函数详解
- ①.拷贝构造函数
- Ⅰ.概念
- Ⅱ.特征
- 1.重载形式之一
- 2.参数唯一
- 3.形参必须传引用
- 4.编译器的拷贝函数
- 5.典型调用场景
- ②.总结:
①.拷贝构造函数
Ⅰ.概念
在创建对象时,能否创建一个与已存在对象一模一样的新对象呢?
拷贝构造函数:只有单个形参,该形参是对本类型相同的对象的引用,一般是用const修饰,在用已存在的对象创建一个同类型的新对象时由编译器自动调用。
为什么要用const修饰?
防止将拷贝对象修改,我们只是要将拷贝对象,并不能将对象修改了。所以加上const来修饰拷贝的对象,防止错误修改。
Ⅱ.特征
拷贝构造函数也是特殊的成员函数。它的特征如下:
1.重载形式之一
拷贝构造函数是构造函数的一个重载形式。
函数名字跟类名是一样的,只是参数列表不同
Data(int year =2023, int month = 5, int day=4)//构造函数
{
_year = year;
_month = month;
_day = day;
}
//两个函数构成重载
Data(const Data& d)//拷贝构造函数
{
_year = d._year;
_month = d._month;
_day = d._day;
}
2.参数唯一
拷贝构造的函数参数只有一个,理论上是两个的,一个是已存在的要被拷贝的对象,一个是新创建的要拷贝的对象,但新创建的对象传给了隐藏的this指针了。所以显示的只有一个参数。
3.形参必须传引用
构造函数的参数只有一个且必须是类类型对象的引用,如果使用传值方式编译器会直接报错,因为这样会引发无穷递归调用。
class Data
{
public:
Data(int year =2023, int month = 5, int day=4)
{
_year = year;
_month = month;
_day = day;
}
Data (const Data d)//这种形式是错误的,不可以这样写,不能传值过去
{
_year = d._year;
_month = d._month;
_day = d._day;
}
Data(const Data& 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()
{
Data d1(2023, 5, 3);
Data d2(d1);
d2.Print();
}
我们知道调用函数需要先传参,而对应内置类型,传参是直接以字节形式拷贝过去,但自定义类型就必须要用拷贝构造形式去完成。
也就是C++规定:在传参过程中
1.内置类型是直接拷贝过去的。
2.自定义类型必须调用拷贝构造完成拷贝。
所以对于自定义类型传参,如果使用传值形式,则传参就相当于又形参了一个新的拷贝构造函数,为什么呢?
因为采用传值形式的话,那么形参就是实参的一份拷贝。
将实参传过去,那么就必须调用一次拷贝构造函数形成形参。
所以如果传值过去,编译器会强制检查发现这样会引发无穷递归调用拷贝构造函数,然后报错。
所以形参必须给该类对象的引用。
当调用拷贝构造函数时,传参就不需要再调用拷贝函数了,因为传参使用的是引用传参,参数不是实参的一份临时拷贝,而就是实参本身,只不过是别名。
因为拷贝构造函数也是特殊的成员函数,是由编译器自动调用的,所以我们可以不去显示的去调用,编译器会帮我们调用,这是在拷贝构造函数已经写的情况下。
4.编译器的拷贝函数
如果没有显式的定义拷贝函数,那么编译器会自动生成一个默认的拷贝构造函数。默认的拷贝构造函数对象按照内存存储按照字节序完成拷贝,这种拷贝叫做浅拷贝,或者值拷贝。
默认生成的拷贝函数:
1.内置类型完成值拷贝/浅拷贝。
2.自定义类型会调用相对应的拷贝构造。
对于不需要申请动态资源的对象,浅拷贝就可以完成工作。
class Time
{
public:
Time()
{
_hour = 1;
_minute = 1;
_second = 1;
}
Time(const Time& s)
{
_hour = s._hour;
_minute = s._minute;
_second = s._second;
cout << "Time(const Time& s)" << endl;
}
private:
int _hour;
int _minute;
int _second;
};
class Data
{
public:
void Print()
{
cout << _year << "-" << _month << "-" << _day << endl;
}
private://内置类型
int _year=1;
int _month=1;
int _day=1;
//自定义类型
Time _t;
};
int main()
{
Data d1;
//用已存在的d1拷贝构造d2,这里会调用Data类的拷贝构造
//但Data类没有显式的定义,所以编译器会生成一个默认的拷贝构造。
//默认生成拷贝构造能否完成拷贝工作呢?
Data d2(d1);
d2.Print();
}
默认生成的拷贝构造是可以完成上面的拷贝任务的,因为默认的拷贝构造会对对象进行浅拷贝,而该场景就适合浅拷贝,因为没有动态资源的开辟,虽然Time定义的是自定义类型,但是浅拷贝可以完成任务就行了。
编译器生成的默认拷贝构造函数已经可以完成字节序的值拷贝了,那还需要自己显式的写拷贝构造函数吗?当然对于Data日期类的是没有写的必要,但并不是每个类都是像日期类一样的。
当对象的拷贝需要深度拷贝时,就不能单单使用浅拷贝,这样会出问题的。
比如下面这个栈:
typedef int DataType;
struct stack//class可以定义一个类
{
public://访问限定符
stack(int capacity = 4)//缺省值
{
cout << "stack(int capacipty=4)" << endl;
_array = (DataType*)malloc(sizeof(DataType) * capacity);
_capacity = capacity;
_size = 0;
}
void Push(DataType data)
{
_array[_size] = data;
_size++;
}
void Pop()
{
if (Empty())
{
return;
}
--_size;
}
int Empty()
{
return _size == 0;
}
~stack()
{
cout << "~stack()" << endl;
if (_array)
{
free(_array);
_array = NULL;
_capacity = 0;
_size = 0;
}
}
private://访问限定符
DataType* _array;
int _capacity;
int _size;
};
int main()
{
stack s1;//定义一个对象
stack s2(s1);
根据调试可以发现d1初始化,然后将已存在的d1拷贝给新创建的d2。看起来都完美,但其实有一个致命的错误。
我们知道默认生成的拷贝函数是按照浅拷贝进行拷贝的,浅拷贝就是完全一样,全部复制过来。
这样是存在危险的,因为可能存在这样的情况:拷贝对象与被拷贝对象的成员指向了同一块空间。
这种情况会存在这样的问题:
1.同一块空间会析构两次,会报错。
当s2对象生命周期结束时,系统自动调用析构函数来清理数据,那么*a指向的空间就被销毁了。
而当s1对象生命周期结束时,又析构一次相同的空间,这样同一块空间就析构两次了。
编译器会出错的。
2.一个变量修改会影响另一个变量,因为两个变量都存在同一块空间里。
【注意:】
=类中如果没有涉及资源的申请时,拷贝构造函数是否写都是可以的;一旦涉及资源的申请时,则拷贝构造函数是一定要写的,否则就是浅拷贝了。
编译器默认生成的拷贝构造只能完成浅拷贝,而需要深度拷贝时还必须用我们自己写的拷贝构造函数。
其实自己写的拷贝构造函数就是为自定义类型的深度拷贝准备的。
所以总结一下,适合深度拷贝和浅拷贝的场景:
1.对于Data和MyQueue这样的类我们不需要自己写拷贝构造,因为浅拷贝就可以完成任务。
(MyQueue就是用栈来实现队列,而栈里面是要用自己写的拷贝函数,但实现队列时就不需要了)
class MyQueue
{
private:
stack pushst;
stack popst;
};
2.对于Stack这样的类,我们是需要自己写拷贝构造的,因为里面涉及要深度拷贝,有动态资源的开辟。
5.典型调用场景
1.使用已存在的对象创建新对象。
2.函数参数类型为类类型对象。
3.函数的返回值类型为类类型对象。
class Data
{
public:
Data(int year =2023, int month = 5, int day=4)
{
_year = year;
_month = month;
_day = day;
}
Data(const Data& d)//正确的写法是这样,传引用过去
{
_year = d._year;
_month = d._month;
_day = d._day;
}
void Print()
{
cout << _year <<"-"<< _month <<"-"<< _day << endl;
}
private:
int _year;
int _month;
int _day;
};
//典型调用场景:返回值是类类型对象,参数是类类型对象
Data Test(Data d)
{
Data tmp(d);//用已存在的d(其实是d2)来创建新对象tmp
return tmp;//返回对象tmp
}
int main()
{
Data d1(2023, 5, 3);
Data d2(d1);
Data tmp=Test(d2);
d2.Print();
}
注意:
为了提高此程序效率,一般对象传参时,尽量使用引用类型,返回时根据实际场景,能用引用尽量使用引用。
就比如上面的Test函数,对象传参我们最好使用引用类型,那样就减少了一次调用拷贝函数的工作,提高了效率
Data Test(Data& d)
{
Data tmp(d);//用已存在的d(其实是d2)来创建新对象tmp
return tmp;//返回对象tmp
}
那能不能给返回值也使用引用呢?
答案是不能,要根据实际场景来对返回值使用引用,这样是对局部对象返回,不能使用引用,因为局部对象返回后,这个对象就销毁了,使用引用取别名那就对已经销毁的空间的非法访问了。所以不可以。
②.总结:
- 1.拷贝构造函数是构造函数的一个重载形式。
- 2.拷贝构造函数的参数只有一个且必须是类类型对象的引用,使用传值方式编译器直接报错,
因为会引发无穷递归调用。 - 3.若未显式定义,编译器会生成默认的拷贝构造函数。 默认的拷贝构造函数对象按内存存储按
字节序完成拷贝,这种拷贝叫做浅拷贝,或者值拷贝。 - 4.在编译器生成的默认拷贝构造函数中,内置类型是按照字节方式直接拷贝的,而自定
义类型是调用其拷贝构造函数完成拷贝的。 - 5.类中如果没有涉及资源申请时,拷贝构造函数是否写都可以;一旦涉及到资源申请
时,则拷贝构造函数是一定要写的,否则就是浅拷贝。 - 6.拷贝构造函数典型调用场景:
使用已存在对象创建新对象
函数参数类型为类类型对象
函数返回值类型为类类型对象