值语义与引用语义

值语义与引用语义:关键不在存在哪里,而在是否共享

在讨论对象、变量和内存模型时,经常会看到“值类型”和“引用类型”这两个说法。

这个说法在不同语言中的地位并不一样。例如 Java 语言规范 将 Java 类型分为 primitive typesreference types;而 C++ 标准并不把普通对象类型正式划分为“值类型”和“引用类型”。

所以,本文后续主要使用值语义引用语义这组说法。

简单说:

  • 值语义
    • 变量表示的是它自己的值
    • 复制后得到逻辑上独立的对象
    • 修改一个对象,不影响另一个对象
    • 常见例子:C++ 的 std::vectorstd::stringstd::array
  • 引用语义
    • 多个变量或句柄共同指向同一个对象
    • 复制后共享同一个逻辑值
    • 修改对象时,其他引用者也能观察到变化
    • 常见例子:Java 中的对象引用、C++ 中多个 std::shared_ptr<T> 指向同一个对象

如何区分值语义和引用语义?

复制之后,两个变量是否共享同一个逻辑值。

  • 如果复制后各自独立,修改不共享,认为是值语义。
  • 如果复制后共同指向同一个对象,修改可共享,认为是引用语义。

值语义和引用语义,不等于值传递和引用传递

值语义 / 引用语义,描述的是对象复制后是否共享同一个逻辑值。

值传递 / 引用传递,描述的是函数调用时参数如何传递。

这两组概念相关,但不是一回事。

例如 Java 的参数传递本质上是值传递:传递的是变量中保存的值。只是对于对象变量来说,这个值本身是一个引用。因此调用函数时,引用被复制了一份,但两个引用仍然指向同一个对象。

C++ 则同时支持值传递、指针传递和引用传递:

void f(std::vector<int> v);    // 值传递,复制 vector
void g(std::vector<int>& v);   // 引用传递,引用原 vector
void h(std::vector<int>* v);   // 指针传递,传递地址

但这些参数传递方式,并不直接决定 std::vector 这个类型本身是否按值语义设计。

std::vector 仍然是值语义类型,只是你可以选择通过引用或指针去访问同一个 vector 对象。

值语义和引用语义,也不等于是否使用堆内存

值语义可以使用堆内存。

std::vector 就是典型例子:对象本身可以在栈上,但元素存储通常在堆上。它仍然是值语义,因为它独占自己的元素序列。

引用语义也不要求对象一定在堆上。

C++ 可以对栈上的对象建立引用:

std::vector<int> v = {1, 2, 3};

std::vector<int>& r1 = v;
std::vector<int>& r2 = v;

r1[0] = 100;

// r2 也能看到变化

这里没有动态分配 vector 对象,但 r1r2 都引用同一个对象,因此访问上表现出引用语义。

值语义:复制后逻辑独立

值语义的对象,复制后会得到一个独立对象。

例如 C++ 的 std::vector

std::vector<int> a = {1, 2, 3};
std::vector<int> b = a;

b[0] = 100;

// a 仍然是 {1, 2, 3}
// b 是 {100, 2, 3}

这里 ab 是两个独立的 vector 对象。

即使 std::vector 内部通常会在堆上分配元素存储空间,它仍然是值语义。原因是:每个 vector 对象独占拥有自己的元素序列(发生深拷贝),复制后两个对象逻辑独立。

所以,值语义不等于“不使用指针”,也不等于“不使用堆内存”。

引用语义:复制后共享同一个对象

引用语义的核心是:多个变量或句柄共享同一个对象。

例如 Java 中的对象变量:

ArrayList<Integer> a = new ArrayList<>();
ArrayList<Integer> b = a;

b.add(1);

// a 和 b 指向同一个 ArrayList 对象

这里复制的是引用,而不是 ArrayList 对象本身。

ab 指向同一个对象,所以通过 b 修改对象后,a 也能观察到变化。

C++ 中也可以表达类似语义,例如使用 std::shared_ptr

auto a = std::make_shared<std::vector<int>>();
auto b = a;

b->push_back(1);

// a 和 b 指向同一个 vector 对象

需要注意的是:std::shared_ptr<T> 本身是一个可以按值复制的对象。复制 shared_ptr 会产生另一个句柄,但这些句柄共享同一个被管理对象。

所以更准确地说:

shared_ptr 这个句柄本身有值的一面;但它让被管理对象表现出引用语义。

结构相似,不代表语义相同

从实现结构上看,Go 的 slice 和 C++ 的 std::vector 都可以被粗略理解成类似:

  • len
  • cap
  • 指向元素存储的指针

但它们的语义并不相同。

std::vector 是值语义:复制一个 vector 会复制元素序列,两个 vector 逻辑独立。

Go 的 slice 更像引用语义:复制一个 slice 会复制 slice header,但底层数组仍然共享。修改元素时,其他指向同一底层数组的 slice 也可能观察到变化。

当然,Go 的 append 可能因为扩容而创建新的底层数组,所以 slice 的共享关系有时会发生变化。但在一般复制场景下,slice 表现出明显的引用语义特征。

所以,不能只看底层结构长什么样。关键仍然是:

复制之后,是否共享同一个逻辑值。

C++ 中如何表达引用语义?

C++ 的普通变量通常直接表示一个对象,而不是一个引用。

如果想在 C++ 中表达引用语义,常见方式有几种。

1. 使用引用或指针共享访问同一个对象

std::vector<int> v = {1, 2, 3};

std::vector<int>& r1 = v;
std::vector<int>& r2 = v;

r1[0] = 100;

// r2 也能看到变化

这里 r1r2 都绑定到同一个 vector 对象,所以修改是共享的。

不过这只是共享访问,不一定意味着共享所有权。

2. 使用 std::shared_ptr 表达共享所有权

auto a = std::make_shared<std::vector<int>>();
auto b = a;

b->push_back(1);

// a 和 b 共享同一个 vector 对象

std::shared_ptr 适合表达“多个句柄共同拥有同一个对象”的语义。

这和 Java / Go 中常见的引用语义比较接近,但仍然要注意:C++ 里共享所有权是显式写出来的,而不是所有对象变量默认如此。

3. 自己实现引用计数或共享控制块

也可以通过自定义引用计数、共享控制块等方式实现共享所有权。

本质仍然是:

多个变量共同指向同一个对象,并协调对象生命周期。

C++ 中如何表达值语义?

C++ 的普通对象变量天然适合表达值语义。

值语义的核心是:

对象自己拥有自己的状态,复制后两个对象逻辑独立。

常见实现方式包括:

  • 直接使用普通对象变量
  • 正确实现拷贝构造、拷贝赋值、移动构造、移动赋值和析构。如对资源进行深拷贝。
  • 使用 RAII 管理资源
  • 对不适合复制的资源禁用拷贝,只允许移动

例如 std::vector 就是典型的值语义类型。

它内部通过指针管理动态数组,但这个动态数组归当前 vector 对象拥有。复制 vector 时会复制元素,两个 vector 对象不会共享底层存储。

需要注意的是,std::unique_ptr<T> 表达的是独占所有权和移动语义。它不是传统的可复制值语义类型,因为它不能被复制。但它仍然体现了 C++ 中一个重要原则:

所有权应该由对象的拷贝、移动和析构规则明确表达。

总结

值语义和引用语义的关键区别,不在于:

  • 是否使用指针
  • 是否分配在堆上
  • 参数是值传递还是引用传递

真正的区别在于:

复制之后,两个变量是否共享同一个逻辑值。

复制后各自独立,修改不共享,就是值语义。

复制后共同指向同一个对象,修改可共享,就是引用语义。

C++ 的普通对象变量不是隐式引用,而是直接表示一个对象。一个 C++ 类型最终表现出什么语义,取决于它如何定义拷贝、移动、赋值和析构。

标准库中的大多数常用类型,例如 std::vectorstd::stringstd::array,都按值语义设计:拷贝之后,两个对象逻辑独立。

评论