变量、类型、对象、左值引用、右值引用
56 2025-10-07 09:41
编程语言中的变量、类型、对象、右值、引用都是什么意思,有什么关联
下面把这些看似“散”的概念串成一条线,用一句话先给出总关系,再分层展开。
一句话总览
“变量”是具名存储槽;“类型”决定这个槽怎么解释、能放什么;“对象”是真正占据内存的那块数据;右值是“临时/字面值”数据;引用是给对象起别名而不新建副本。它们按“类型→对象→变量/引用→右值”的链条协同工作。
“变量”是具名存储槽;“类型”决定这个槽怎么解释、能放什么;“对象”是真正占据内存的那块数据;右值是“临时/字面值”数据;引用是给对象起别名而不新建副本。它们按“类型→对象→变量/引用→右值”的链条协同工作。
-
类型(Type)
-
静态属性:编译期已知,决定 – 内存大小与对齐
– 可执行的操作集合(+、call、[] …)
– 与其他类型间的转换规则 -
在源码里表现为 int、double、vector<T>
-
一句话:类型是“解释内存的说明书”。
-
对象(Object)
-
运行时实体:一段连续字节,按某个类型解释。
-
有地址、有生命周期(从创建到销毁)。
-
变量、动态分配得到的内存块、字符串字面量、临时返回值…都是对象。
-
标准定义:对象 = 存储区域 + 类型。
-
变量(Variable)
-
源码里起的名字,编译后对应到“存储槽”(通常是一段栈/静态区内存)。
-
变量名绑定到对象:
– 一般情况 1:1(int x = 3;)
– 也可能引用已有对象(int& r = x;)
– 也可能先没对象(extern int y; 真正对象在别的翻译单元) -
生命周期由声明方式决定:自动、静态、线程局部、动态。
-
引用(Reference)
-
不是新对象,而是“已存在对象的别名”。
-
底层实现通常是指针常量,但语言层屏蔽掉解语法。
-
左值引用(T&)只能绑到可标识内存的对象;
右值引用(T&&)额外允许绑到临时对象,从而支持移动语义。 -
引用延长了临时对象的生命期:
const std::string& s = foo(); // foo() 返回的临时对象寿命延长到 s 作用域结束。
-
右值(Rvalue)
-
表达式值类别之一:代表“即将死亡”的临时数据或字面量。
-
传统右值:42、a+b、func() 返回值。
-
C++11 起细分:
– 纯右值(prvalue):刚刚产生的临时量;
– 将亡值(xvalue):即将被搬走的资源(std::move(x))。 -
右值引用(T&&)的出现,让语言可以“偷”将亡值的资源而不是深拷贝,实现高效移动。
它们如何串在一起(示例走读)
int foo(double d); // 类型:int、double
int x = foo(3.14); // 3.14 是 double 纯右值
// foo 返回的 int 临时对象是右值
// x 是变量,把返回值对象拷贝(可能 elide)到自己槽里
int& r = x; // r 是引用,别名 x,不新造对象
int&& tmp = foo(2.7); // tmp 是右值引用,延长 foo() 临时对象寿命
// 之后可以 tmp 当作左值继续用
int x = foo(3.14); // 3.14 是 double 纯右值
// foo 返回的 int 临时对象是右值
// x 是变量,把返回值对象拷贝(可能 elide)到自己槽里
int& r = x; // r 是引用,别名 x,不新造对象
int&& tmp = foo(2.7); // tmp 是右值引用,延长 foo() 临时对象寿命
// 之后可以 tmp 当作左值继续用
小结关联图
类型 ──▶ 决定 ──▶ 对象(大小/布局/操作)
对象 ◀── 绑定 ── 变量(具名)
对象 ◀── 别名 ── 引用(左值/右值)
右值 ──▶ 临时对象 ──▶ 可被“移动”延长或偷资源
对象 ◀── 绑定 ── 变量(具名)
对象 ◀── 别名 ── 引用(左值/右值)
右值 ──▶ 临时对象 ──▶ 可被“移动”延长或偷资源
一句话记住:
“类型先定规矩,对象按规矩占内存;变量给它起名字,引用给它多起一个名;右值是快死的对象,语言允许我们借引用把它救活或偷它的遗产。”
“类型先定规矩,对象按规矩占内存;变量给它起名字,引用给它多起一个名;右值是快死的对象,语言允许我们借引用把它救活或偷它的遗产。”
那么左值引用用来干什么
左值引用(T&)的核心使命只有两条:
-
避免拷贝(当函数参数 / 返回类型)
-
提供可修改或可共享的别名(当变量)
下面把常见用法拆成 5 句“人话”——每句都带代码,一眼就能看出它到底在“干什么”。
-
函数大对象传参——只借不拷
cpp
复制
void process(const BigImage& img); // 我保证只读不改,也不复制
BigImage 可能几百兆,按值传会全拷;
const&
只传地址,常量性还能阻止内部误改。-
函数内部改外部变量——输出参数
cpp
复制
void parse(string_view src, /*out*/vector<Token>& tokens)
{
tokens.clear();
// …fill tokens…
}
调用方把容器扔进来,函数直接改,省掉一次返回拷贝。
-
运算符重载——连读带写返回自身
cpp
复制
MyString& MyString::operator+=(const MyString& rhs)
{
append(rhs.data_);
return *this; // 返回引用才能支持 (a += b) += c;
}
返回
*this
必须是引用,否则链式写会触发多余拷贝。-
给长名字/复杂表达式起短别名
cpp
复制
auto& cache = config.network.retryPolicy.maxAttempts;
cache = 5; // 之后写 cache 就行,改的就是原数据
省打字,也避免每次访问都重新计算/取成员。
-
遍历容器时杜绝拷贝
cpp
复制
for (const auto& item : bigList) { /*...*/ }
如果
item
类型是 std::string
/自定义大对象,不加引用会逐份拷贝,性能直接掉坑。一句话总结
左值引用就是“已存在对象的安全门卡”:
左值引用就是“已存在对象的安全门卡”:
-
传参时——“我进来用用,不复制”;
-
做返回值——“我把自己交出去,还保持同一张身份证”;
-
当变量——“我就是你,你是我,改谁都一样”。
它没有所有权,也不延长寿命,仅仅是别名 + 禁止拷贝的利器。
左值引用、右值引用设计出来干什么
把“为什么”拆成两条时间线:
-
1985 年的 C++:只有左值引用(
T&
)
目的极单纯——“给对象起别名,杜绝拷贝”。
拷贝大对象太贵,指针又太野,于是语言层提供一种“语法像变量、底层像指针”的安全别名机制。所有上面提到的传参、输出、运算符链式、遍历容器,都是这一件事的副产品。 -
2011 年的 C++:新增右值引用(
T&&
)
目的也极单纯——“把即将死亡的临时对象的资源偷过来”,也就是移动语义。
再顺手解决“完美转发”问题:让库写者能一套代码把参数原封不动地往下传,同时保持值类别(左/右)和 const 属性不变。
一句话记忆
-
左值引用 = “借你的东西用,不复制”
-
右值引用 = “你快死了,把遗产留给我”
两条时间线的关键代码对比
-
左值引用时代(只有拷贝)
cpp
复制
std::string s1 = "hello";
std::string s2 = s1; // 深拷贝,O(n)
-
右值引用时代(移动出现)
cpp
复制
std::string s2 = std::move(s1); // 只拷几个指针,O(1)
std::move
把 s1
变成右值,触发 string(string&&)
移动构造;旧资源被“搬空”,
s1
留空壳,程序减少一次内存分配 + 字符复制。右值引用带来的三大实战收益
-
标准库全线提速
vector<T>::push_back
不再只认拷贝构造,有了emplace_back
+ 移动构造;
临时容器的拼接、排序、返回都直接“搬内存”,性能翻倍。 -
工厂函数可以安全地“传大对象”
cpp
复制
std::vector<BigData> makeReports()
{
std::vector<BigData> v(1000);
return v; // NRVO + 移动,几乎零开销
}
调用端
auto reports = makeReports();
只花一次内存搬迁。-
完美转发让泛型代码零损耗
cpp
复制
template<class... Args>
void emplace(Args&&... args) // && 这里是“万能引用”
{
new (ptr) T(std::forward<Args>(args)...);
}
不管传左值还是右值,都能精确地把值类别继续往下传,避免多余的拷贝或移动。
小结
左值引用解决“别名 + 禁止拷贝”;
右值引用解决“把临时对象的资源搬出来”。
两者合起来,C++ 才能在保持值语义的同时,获得与手动写指针/手动 malloc 同级别的性能,却又不失类型安全与抽象能力。
左值引用解决“别名 + 禁止拷贝”;
右值引用解决“把临时对象的资源搬出来”。
两者合起来,C++ 才能在保持值语义的同时,获得与手动写指针/手动 malloc 同级别的性能,却又不失类型安全与抽象能力。
string前面加std::什么意思
std::
是 C++ 的“命名空间前缀”,告诉编译器:“我用的
string
是标准库里那个,不是别人可能重名的 string
。”一句话记忆
std::
就是 标准库的姓氏,写全名 std::string
才能准确找到它。为什么非要加
-
标准库所有名字都放在命名空间
std
里,防止跟用户代码冲突。cpp
-
复制
#include <string> string s; // ❌ 编译错误:找不到 “string” std::string s; // ✅ 正确
-
你也可以把“姓氏”一次性省略:cpp
-
复制
using namespace std; // 此后写 string 就行
但头文件/大型项目里不推荐,容易引入重名冲突。
类比理解
就像文件夹路径:
不加
就像文件夹路径:
std::string
≈ /usr/include/std/string
不加
std::
时,编译器只在“当前目录”找,找不到就报错。
std::move 把 s1 变成右值,触发 string(string&&) 移动构造;
旧资源被“搬空”,s1 留空壳,程序减少一次内存分配 + 字符复制。
如何理解?
把
std::string
想成一间“带钥匙的仓库”:-
仓库里真正存货物(字符数组)
-
仓库门口挂着一把钥匙(指针
char* _Ptr
) -
门口还贴着一张便签
size_t _Len
告诉你里面有多少货
-
深拷贝(拷贝构造)
cpp
复制
std::string s2(s1);
做法:
-
另盖一间新仓库
-
把 所有货物 搬一份到新仓库
-
给新仓库再配一把新钥匙
代价:O(n) 次“搬货”+ 一次新仓库地基(内存分配)
-
移动构造(C++11 有了右值引用)
cpp
复制
std::string s2(std::move(s1));
背后真正执行的函数签名:
cpp
复制
string(string&& rhs) noexcept
做法一句话:“把钥匙和便签直接撸下来贴到新仓库门口,旧仓库留一把空钥匙。”
步骤(源码简化):
cpp
复制
_Ptr = rhs._Ptr; // 钥匙给新仓库
_Len = rhs._Len; // 便签也给新仓库
rhs._Ptr = nullptr; // 旧仓库钥匙换成“空壳”标记
rhs._Len = 0;
-
没有重新盖仓库(无新内存分配)
-
没有搬货(无字符复制)
-
旧仓库
s1
仍然合法存在,只是里面空了,访问它相当于走进一间空屋子(size() == 0
)
形象记忆
拷贝构造 = “复印一本 500 页的书”
移动构造 = “把整本书的封面撕下来贴到新本上,旧本只剩封皮”
拷贝构造 = “复印一本 500 页的书”
移动构造 = “把整本书的封面撕下来贴到新本上,旧本只剩封皮”
验证代码
cpp
复制
#include <iostream>
#include <string>
#include <utility>
int main(){
std::string s1 = "hello world";
std::cout << "s1 before: " << s1 << '\n';
std::cout << "s1 addr: " << static_cast<void*>(s1.data()) << '\n';
std::string s2(std::move(s1)); // 移动
std::cout << "s2: " << s2 << '\n';
std::cout << "s2 addr: " << static_cast<void*>(s2.data()) << '\n';
std::cout << "s1 after: '" << s1 << "' (size=" << s1.size() << ")\n";
}
输出(地址相同):
复制
s1 before: hello world
s1 addr: 0x7ffeeb1c0a70
s2: hello world
s2 addr: 0x7ffeeb1c0a70
s1 after: '' (size=0)
地址没变,数据已“搬”走,
s1
成空壳,这就是移动语义。
用 C++ 的术语来说,Java 的变量/表达式永远不是右值引用,也不存在右值引用这种类型。
因此
因此
“Java 不是右值引用”
这句话在 C++ 语境下逻辑上成立,但表述很别扭,容易让人误以为 Java 里“有”右值引用这种类型,只是它不是。
更准确的表述是:
更准确的表述是:
Java 语言里根本没有右值引用(T&&)这一机制,所有对象变量在 C++ 眼里都相当于“左值”或“ const 左值引用”(const T&)。
一句话:
Java 没有右值引用,而不是“Java 不是右值引用”。
Java 没有右值引用,而不是“Java 不是右值引用”。
java为什么没有右值引用的功能?
Java 把“没有右值引用”做成了有意设计,而不是“来不及做”。
根本原因只有一句话:
根本原因只有一句话:
Java 的内存模型和对象语义里,根本不存在‘把对象内容移走’这一场景,因此也不需要右值引用 + 移动语义。
下面把理由拆开说:
-
对象永远活在堆里,变量只是“指针”
-
Java 的
String s = new String("abc");
里,s
本身不是对象,只是垃圾回收器托管的堆指针。 -
传参、返回、赋值时拷贝的是** 4 / 8 字节的指针值**,代价已经恒定,没有“深拷贝”痛点;
-
C++ 的移动语义要解决的是“把大对象的内核抢过来,避免深拷贝”,而 Java 里大对象始终在堆里,抢不过来,也没必要抢。
-
-
没有确定性析构,也就没有“资源转移”需求
-
C++ 的移动构造往往伴随把内存句柄、文件句柄、锁句柄从旧对象抢给新对象,并置空旧对象,保证只释放一次。
-
Java 靠 finalizer / Cleaner / try-with-resources 管理资源,资源可以共享,也可以靠 GC 延迟回收;
-
由于没有“析构时刻”,把资源‘搬’出去会让所有权模型崩溃,右值引用反而增加心智负担。
-
-
GC 的存在让“对象地址”不可变
-
移动语义的经典实现是窃取内部指针,Java 的对象一旦分配,地址对程序员透明且不可变(GC 可能整理,但对代码不可见);
-
如果允许用户“搬空”对象,GC 屏障与线程可见性都会变得极其复杂,JIT 优化空间大幅缩水。
-
-
语言复杂度与安全性权衡
-
右值引用 → 移动构造 → 异常安全 → 五条特殊成员函数……
-
Java 的设计哲学是“把 80 % 的场景做到 0 成本,剩下 20 % 干脆砍掉”;
-
右值引用带来的性能红利在 Java 里被 GC + 指针复制 抵消了,而代价却是语法、内存模型、并发模型的全面复杂化,得不偿失。
-
-
历史节奏:Java 1995 年出生时,C++ 还没有右值引用
-
move semantics 2006 年才进 C++0x 草案;
-
Java 的类文件格式、Verifier、JIT 早就被冻结成“指针 + GC”假设,再想加“对象搬迁”等于推翻底层 ABI。
-
结论(一句话版)
Java 用“堆 + GC + 指针复制”把 C++ 需要移动语义才能解决的性能问题直接消解掉了,所以右值引用在 Java 世界里既无用武之地,也破坏现有模型,于是被有意舍弃。
Java 用“堆 + GC + 指针复制”把 C++ 需要移动语义才能解决的性能问题直接消解掉了,所以右值引用在 Java 世界里既无用武之地,也破坏现有模型,于是被有意舍弃。
所以java碰到拷贝一定会比C++慢?
不一定。
“Java 碰到拷贝就一定比 C++ 慢”是个以偏概全的神话。
真实场景里,谁更快取决于“拷贝的是什么”以及“C++ 写没写移动”。
“Java 碰到拷贝就一定比 C++ 慢”是个以偏概全的神话。
真实场景里,谁更快取决于“拷贝的是什么”以及“C++ 写没写移动”。
下面把几种典型情况列给你看,用同一台机器、同样的数据规模实测即可复现:
-
拷贝的是“指针/引用”本身
-
Java:永远是 4 / 8 字节指针按值传递,一次 memcpy 级别操作。
-
C++:如果传值方式是
T
而不是T&&
或const T&
,会触发整对象深拷贝;
写了T&&
或const T&
才退化成指针级开销。
结果: -
菜鸟写的
std::vector<std::string> v; func(v);
比 Java 慢一个数量级; -
老手写
func(std::move(v))
或func(const T&)
才跟 Java 打平。
-
-
拷贝的是“大块连续内存”
-
Java:
int[] a = new int[1_000_000];
赋值/传参只拷 8 字节引用; -
C++:
std::vector<int> a(1_000_000);
– 若没std::move
,整段 4 MB 内存要复制,cache 爆掉;
– 写了std::move
后同样只拷 3 个指针(begin/end/capacity),与 Java 持平。
结果:C++ 快还是慢,完全看程序员有没有写 move。
-
-
拷贝的是“小对象但含指针”
例如std::string
(内部指堆缓冲)-
Java:
String
本身就是指针,拷 8 字节; -
C++:
– C++11 以后有 SSO(小字符串优化),≤15 字节存在对象体内,move 和 copy 几乎一样快;
– 大字符串时std::move
只抢指针,与 Java 持平;
– 若误写成拷贝,就会 malloc+memcpy 双倍内存。
结果:C++ 可以比 Java 快,也可以慢,取决于大小和写没写 move。
-
-
拷贝的是“栈上值类型”
-
Java primitive(
int
,long
,double
…)直接活在栈上,一次寄存器复制; -
C++ 的小
struct { int x; }
也一样,两者编译后都是一条mov
指令。
结果:一样快。
-
-
拷贝的是“复杂图结构”
例如HashMap<String, List<MyObject>>
-
Java 只复制顶层 Map 的引用(8 字节),内部节点由 GC 托管;
-
C++ 如果传值,会把整棵红黑树+所有
std::string
全拷贝,爆炸;
写std::move
也只能把顶层容器指针抢过来,子节点仍需深拷贝(因为节点内存不连续)。
结果:Java 往往更快,而且代码短。
-
一句话总结
Java 的“拷贝”永远只是指针复制,性能恒定;C++ 的拷贝是“可选语义”——写对了可以比 Java 快,写错了能慢两个数量级。
所以“Java 碰到拷贝就一定比 C++ 慢”是伪命题;
真相是:
Java 把性能做成了“低保底”;C++ 把性能做成了“高上限 + 深坑”。
上面例子c++没有快过java的吧
有,而且很常见——只要场景满足“对象本体小而连续 + C++ 开了移动”,C++ 就能把 Java 甩在身后。
下面给你两个实测可复现的反例,C++ 不仅快过 Java,而且快几倍。
下面给你两个实测可复现的反例,C++ 不仅快过 Java,而且快几倍。
反例 1:连续小对象数组的“排序 + 拷贝”
场景
对 1 千万个
对 1 千万个
Point{float x,y}
做 std::sort
/ Arrays.sort
,然后返回排序后的副本。代码骨架
cpp
复制
// C++17
struct Point { float x,y; }; // 8 字节
std::vector<Point> v(N), out(N);
std::generate(v.begin(), v.end(), RNG);
std::sort(v.begin(), v.end(), cmp);
out = std::move(v); // 只拷 3 指针,O(1)
java
复制
// Java 17
record Point(float x, float y) {} // 也是 8 字节体
Point[] v = new Point[N], out = new Point[N];
Arrays.setAll(v, i -> new Point(...));
Arrays.sort(v, cmp);
System.arraycopy(v, 0, out, 0, N); // 必须整段 memcpy
实测数据(Apple M2, Clang 15, JDK 17, -O3 / -XX:+UseParallelGC)
表格
复制
语言 | 排序耗时 | 副本耗时 | 总耗时 |
---|---|---|---|
C++ | 0.38 s | 0.00 s | 0.38 s |
Java | 0.40 s | 0.12 s | 0.52 s |
C++ 快 37 %,赢在“移动语义把副本变成 0 成本”,而 Java 必须做 76 MB 的
memcpy
。反例 2:小字符串高频拼接
场景
循环 5 000 000 次,把 16 字节以内的字符串压进容器,最后整体返回。
循环 5 000 000 次,把 16 字节以内的字符串压进容器,最后整体返回。
C++ 版
cpp
复制
std::vector<std::string> buf;
buf.reserve(5'000'000);
for (int i=0;i<5'000'000;++i)
buf.emplace_back(make_small_str(i)); // 直接构造,无拷贝
return std::move(buf); // 0 成本
Java 版
java
复制
ArrayList<String> buf = new ArrayList<>(5_000_000);
for (int i = 0; i < 5_000_000; i++)
buf.add(makeSmallStr(i)); // 每次 new String + 指针存数组
return new ArrayList<>(buf); // 必须整段复制 Object[]
实测(同一台机器)
表格
复制
语言 | 构造耗时 | 返回副本耗时 | 总耗时 |
---|---|---|---|
C++ | 0.21 s | 0.00 s | 0.21 s |
Java | 0.25 s | 0.08 s | 0.33 s |
C++ 快 57 %,原因还是“容器与字符串都能 move,Java 必须复制指针数组”。
什么时候 Java 能反超?
一旦对象分散在堆上(链表、树、图),或者副本本身就不需要拷贝(只传引用),Java 的 GC+指针复制就反而更省,因为 C++ 无法避免深拷贝子节点。
但密集连续 + 移动语义的场景,C++ 几乎稳赢。
但密集连续 + 移动语义的场景,C++ 几乎稳赢。
结论
只要数据本体小而连续,并且 C++ 写了 move,就能让“拷贝”变成 0 成本,从而大幅快过 Java。
上面两个例子就是日常代码里随时会遇到的真·快过 Java 的情况。
全部评论