值语义 VS 引用语义:深入理解C++对象赋值背后的奥秘

1,337 阅读8分钟

以下内容为本人的学习笔记,如需要转载,请声明原文链接 微信公众号「ENG八戒」mp.weixin.qq.com/s/64NjAjPYV…

_c9ebef05-a7fd-49cd-93b3-d5ae976a33bb.jpeg

编程的世界里,处理对象或者变量的关系,有两种基本的方式,「值语义」和「引用语义」。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 的话来总结一下:

那些使用最高频率的对象将采用值语义,因为我们仅在不影响性能的情况下保留灵活性,在最需要性能的时候进行优化。