以下内容为本人的学习笔记,如需要转载,请声明原文链接 微信公众号「ENG八戒」mp.weixin.qq.com/s/64NjAjPYV…
编程的世界里,处理对象或者变量的关系,有两种基本的方式,「值语义」和「引用语义」。C++ 这种性能怪兽对这两种语义都支持,它们各有优势和最佳使用场合,利用好它们是性能优化的必备技能。
关于「值语义」和「引用语义」的区别,通过对象的赋值运算可以比较直观理解。
值语义
赋值时,如果拷贝的是值,对象的完整状态被直接复制,拷贝前后,副本和原数据之间相互独立不干扰,修改其中一个而不会影响另一个的状态。这属于「值语义」,也叫「拷贝语义」。
看下面的示例:
#include <iostream>
class MyClass {
public:
int value;
MyClass(int v) : value(v) {}
void output(const std::string &str) {
std::cout << str << " Value: "
<< *value << std::endl;
}
};
int main() {
MyClass obj1(1);
MyClass obj2 = obj1;
obj2.value = 2;
obj1.output("obj1");
obj2.output("obj2");
return 0;
}
创建 MyClass 对象 obj1 后,将其赋值给新的同类对象 obj2,再对 obj2 的属性值进行修改,最后输出各对象的最终属性值。
编译跑一下程序:
obj1 Value: 1
obj2 Value: 2
从输出来看,obj1 的属性还是初始值,obj2 被修改后属性值已改变,两对象的属性值不一致,修改其中一个不会影响另一个,也就是拷贝前后的对象是独立的。
MyClass obj2 = obj1; 这条语句中,MyClass 类中实际负责赋值的是拷贝构造函数,而该构造函数并没有具体实现,因而采用系统生成的默认实现。可见,默认情况下,赋值操作会直接拷贝属性值,实现值语义。如果换成赋值运算符也同理。
C++ 在执行赋值操作时,如果左操作数是新键对象,那么系统会调用拷贝构造函数,否则系统调用的是赋值操作符函数。
引用语义
如果属性通过指针实现动态绑定,对象被复制时,拷贝的是指针,也就是拷贝资源或数据的地址。拷贝完成后,副本和原对象之间共享状态数据,这属于「引用语义」。
执行引用语义后,可能有多个对象指向同一个状态,修改其中任一个对象会影响其它所有共享对象的状态。
来看看简单的例子:
#include <iostream>
class MyClass {
public:
int *value;
MyClass(int data) : value(new int(data)) {}
~MyClass() { delete value; }
void output(const std::string &str) {
std::cout << str << " Value: "
<< *value << std::endl;
}
};
int main() {
MyClass obj1(1);
MyClass obj2 = obj1;
*(obj2.value) = 2;
obj1.output("obj1");
obj2.output("obj2");
return 0;
}
这个例子同样创建 MyClass 对象 obj1 后,将其赋值给新的同类对象 obj2,再对 obj2 的属性值进行修改,最后输出各对象的最终属性值。和前面的值语义例程不同的地方是 MyClass 对象的属性变量是数据指针,属性被动态绑定,直接拷贝对象只会拷贝指向属性的地址。
编译跑一下程序看看结果:
obj1 Value: 2
obj2 Value: 2
free(): double free detected in tcache 2
Aborted (core dumped)
从输出来看,obj1 的属性不再是初始值,obj2 被修改后属性值也已改变,两对象的属性值维持一致,可见两对象的属性值是共享的,这就是属性被动态绑定的效果。
MyClass 类中实际负责赋值的仍然是拷贝构造函数,采用系统生成的默认实现,就是直接拷贝了指向属性的地址指针。
但最后程序报错并崩溃了,正是由于两对象的属性是共享的,当其中一个对象被释放时会同步释放属性指针,另一个对象被释放时也会同步释放属性指针,这就导致同一个地址指针被多次释放,崩溃触发了。
程序在退出时才引发崩溃,实验的目标也已演示清楚,但如果需要避免最后的崩溃,可以基于 RAII 的思想结合智能指针稍作改动即可:
#include <iostream>
#include <memory>
class MyClass {
public:
std::shared_ptr<int> value;
MyClass(int data) : value(new int(data)) {}
void output(const std::string &str) {
std::cout << str << " Value: "
<< *value << std::endl;
}
};
int main() {
MyClass obj1(1);
MyClass obj2 = obj1;
*(obj2.value) = 2;
obj1.output("obj1");
obj2.output("obj2");
return 0;
}
编译运行程序:
obj1 Value: 2
obj2 Value: 2
动态绑定同样可以通过引用实现。
翻手作云覆手雨
上面说引用语义时,举的例子中,使用指针指向最终的属性数据,那么是不是只要包含指针成员,就表明这个对象体现的是引用语义呢?
要强调的是,语义作用的是对象之间的关系,包括赋值、拷贝等,所以对象会体现何种语义,应该从具体的赋值操作符函数、拷贝构造函数等拷贝实现来看。
对于包含指针成员的对象,不同的拷贝实现也会体现不同语义,包括值语义和引用语义,不同的实现比如深浅拷贝两种。
指针的值具备值语义,正如拷贝指针,对于不同的指针变量而言,仍然是相互独立的。但是,针对指针指向的对象或者资源,一旦拷贝指针,对象或者资源就是共享的了,从这个角度可认为指针却是引用语义。
上面实现引用语义的 MyClass,我们把它改造一下用以体现值语义:
#include <iostream>
#include <memory>
class MyClass {
public:
std::shared_ptr<int> value;
MyClass(int data) : value(new int(data)) {}
// 拷贝构造
MyClass(const MyClass &val)
: value(new int(*(val.value))) {}
// 拷贝赋值
MyClass &operator=(const MyClass &val)
{
*value = *(val.value);
return *this;
}
void output(const std::string &str) {
std::cout << str << " Value: "
<< *value << std::endl;
}
};
int main() {
MyClass obj1(1);
MyClass obj2 = obj1; // 拷贝构造
*(obj2.value) = 2;
obj1.output("obj1");
obj2.output("obj2");
obj2 = obj1; // 拷贝赋值操作
*(obj2.value) = 3;
obj1.output("obj1");
obj2.output("obj2");
return 0;
}
前面实现引用语义的 MyClass 中,拷贝构造函数和拷贝赋值操作符都采用默认实现。我们这里自己提供实现,按照深拷贝的写法,将指针成员指向的数据一并完整拷贝。
编译运行:
obj1 Value: 1
obj2 Value: 2
obj1 Value: 1
obj2 Value: 3
从输出结果来看,与引用语义的例程相比,obj1 和 obj2 数据保持独立,不再相互影响,因而对 MyClass 对象赋值时体现的是值语义。
孰优孰劣
拷贝的负担
值语义和引用语义体验在对象之间的赋值时,不同点在于拷贝的深浅。浅如只是拷贝指针,深如拷贝动态绑定的内存资源。
拷贝的内容越多,自然越是对系统的负担越重,从这点来看,深拷贝就比较不受待见了。
如果对效率要求高,又需要频繁赋值的场合下,就避免触发值语义的执行吧,这是引用语义的优势。
指针的负担
各位看官,虽然笔者在前面的诸多文章里,不止一次提及深度拷贝大型数据集是效率的大忌。
但是,这里想问一句,你是否留意到有个反直觉的地方?平常业务逻辑中,访问对象的频率,其实是远超过拷贝这个对象的频率?
也就是说,访问对象成员要比拷贝资源要频繁得多。那么哪个更慢呢?
再说通过指针访问目标对象,中间其实多了一层寻址,这可是实打实的消耗吧?每次访问都增加了一层消耗,大量访问的加持下,总消耗到底是谁更优?
来做个实验:
#include <iostream>
#include <chrono>
class Val {
public:
Val(int v) : val(v) {}
int val;
};
std::time_t get_nowtime_nano_second() {
auto now = std::chrono::system_clock::now();
auto epoch = now.time_since_epoch();
return std::chrono::duration_cast<std::chrono::nanoseconds>(epoch).
count();
}
int main() {
// 直接访问对象
Val obj1(0);
std::time_t timestamp_begin = get_nowtime_nano_second();
for (int i = 0; i < 1000; ++ i) {
obj1.val += 1;
}
std::time_t timestamp_end = get_nowtime_nano_second();
std::cout << "access obj immediately,time use(nano second):"
<< timestamp_end - timestamp_begin << "\n";
// 通过指针访问对象
Val *obj2 = new Val(0);
timestamp_begin = get_nowtime_nano_second();
for (int i = 0; i < 1000; ++ i) {
obj2->val += 1;
}
timestamp_end = get_nowtime_nano_second();
std::cout << "access obj via pointer,time use(nano second):"
<< timestamp_end - timestamp_begin << "\n";
return 0;
}
上面的代码对直接访问对象和通过指针访问对象分别循环执行 1000 次,累计总共耗时,时间单位是纳秒。
编译执行,打印输出:
access obj immediately,time use(nano second):1803
access obj via pointer,time use(nano second):2409
结果很明显,通过指针访问对象的确会比直接访问对象要慢,而且随着次数累积,差距会越来越大。
于是我们可以说,如果对一个对象的完整拷贝是低频率事件,直接访问这个对象以及包含的资源,会比通过指针访问这个对象要更加高效。
动态绑定对应的引用语义是有隐含负担的,所以值语义的应用是大部分场景的默认选项。
最后,我们用 C++ 作者 Bjarne Stroustrup 的话来总结一下:
那些使用最高频率的对象将采用值语义,因为我们仅在不影响性能的情况下保留灵活性,在最需要性能的时候进行优化。