g a Sample object:name = b
// Destoring a Sample object:name = a
可以看到,创建成员变量也很简单,关键在于第二步,这和Java又不一样。第二步中,初始化成员变量使用了特殊的语法,在构造函数小括号后面添加了:
,然后普通变量初始化的语法,称之为成员变量初始化。这样写的关键原因在于,对象创建需要先申请内存,内存申请后使用:
后面的初始化方式初始化成员变量,最后才调用构造函数完成对象的创建,每一步都有它对应的位置和作用。假如像Java一样写在构造函数里面,就相当于将第二步放到了第三步,打乱了它本来的顺序。
为了说明成员函数确实在对象的整个生命周期都可以使用,我们再个它添加一个成员函数吧。
class Sample{
void print() {
std::cout << "Invoke print name = " << name << std::endl;
}
//其余不变
}
int main() {
std::string str{ "a" };
Sample a{ str };
{
Sample b{ "b" };
b.print();
}
a.print();
}
// 输出
// Creating a Sample object:name = a
// Creating a Sample object:name = b
// Invoke print name = b
// Destoring a Sample object:name = b
// Invoke print name = a
// Destoring a Sample object:name = a
我们添加了一个成员函数print
它没有参数,但是它的函数体使用了成员变量name
,可以看到,它也能正常工作。
至此,对象的创建和销毁就说得差不多了。还没说到的是构造函数可以有很多个,在创建对象的时候可以选择使用哪种方式创建,编译器会根据传递的参数来推导出实际使用的构造函数,开发者需要考虑的是提供的构造函数都能完成成员函数的正确初始化,以便在调用成员函数时,成员函数都能按预期工作。如Sample
,我们还可以提供一个无参的构造函数,然后将name
初始化为空字符串,这样print
和析构函数也能正常工作。
总结一下,类是管理数据的容器,它的数据随着对象的创建而创建,并在对象存在的整个生命周期都可用。构造函数需要保证数据的初始化,并可以控制它构造的方式,成员函数可以随时使用,析构函数是对象销毁时最后一个调用,它需要保证数据到此都被清理。
数据的转移和共享
数据拷贝
数据创建之后,不仅可以供成员函数使用,还可能被转移到其他对象中去。或者和其他对象共享。复制构造函数可以控制数据以怎样的方式和其他对象共享。
class Sample {
private:
int value;
public:
Sample(const int value) :value{ value } {
std::cout << "Create value = " << value << std::endl;
}
// 以Sample命名,是构造函数,函数参数是自己的类型,说明是复制构造函数
// 这个复制构造函数选择用赋值的形式共享value数据
Sample(const Sample& sample) :value{ sample.value } {
std::cout << "Copy create object" << std::endl;
}
};
void use(Sample sample) {
//函数返回,sample对象被销毁
}
int main() {
Sample a{ 1 };
// a的数据被分享给一个临时对象了,此时出现了两个对象,它们的value都是1
use(a);
}
// 输出
// Create value = 1
// Copy create object
复制构造函数有以下几个特征
- 会出现至少两个同类型的对象。因为复制需要先有一个存在的对象,再用这个存在的对象数据初始化另一个正在创建的对象的成员变量。这也是复制构造函数参数是自己的原因。
- 存在变量从无到有初始化的情况都会调用复制构造函数。函数调用,形参需要初始化为实参,参数本来不存在,调用函数会传递一个已存在的对象,就会调用到复制构造函数。这也是为什么复制构造函数参数是引用的类型。假如是普通变量,调用复制构造的时候需要产生临时变量,临时变量又需要调用复制构造函数,程序就会陷入无限递归中。
- 除了函数调用,函数返回值,用对象初始化新变量的情况也会调用到复制构造函数。函数返回后,函数体中所有的局部变量都会被销毁,返回值也属于一种局部变量肯定也要被销毁,但是返回后的值却需要被 外部使用,它们的生命周期是不一样的,由此我们就知道肯定创建了一个新的对象,这个对象被局部返回值初始化,但是有着和外部一样的生命周期。用对象初始化变量就更直观了,初始化的对象是从无到有创建的。符合构造函数出现的特点。
我们可以来验证一下
//其余不变
Sample returnSample() {
// 用普通构造函数初始化的
Sample sample{ 2 };
return sample;
}
int main() {
Sample a{ 1 };
std::cout << "init local variable" << std::endl;
// b是新对象,用a初始化的,所以调用了复制构造函数
Sample b = a;
// use的形参被用来初始化
std::cout << "Use Sample as parameter" << std::endl;
use(a);
//返回的sample被用来初始化c
std::cout << "return sample" << std::endl;
Sample c = returnSample();
}
// 输出
// Create value = 1
// init local variable
// Copy create object
// Use Sample as parameter
// Copy create object
// return sample
// Create value = 2
// Copy create object
可以看到,这三种情况都会造成复制构造函数的调用。
数据移动
数据拷贝虽然简单易行,但是还是有个小瑕疵。考虑下面这种场景:
void swap(Object& left,Object& right){
// 有新对象产生,拷贝构造,目前内存中有两份一模一样的left
Object temp=left;
// 赋值操作,生成了一个right的临时对象
left=right;
// 赋值操作,生成了一个temp的临时对象
right=temp;
// 三个临时对象都被销毁
}
int main(){
Object a;
Object b;
swap(a,b);
return 0;
}
一个简单的交换逻辑,我们就生成了很多的临时对象,假如我们操作的是列表,大对象,短时间