news 2026/10/3 3:41:12

赋值运算符究竟在做什么?一文读懂值复制、引用与复合赋值

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
赋值运算符究竟在做什么?一文读懂值复制、引用与复合赋值

如果让我选一个"看起来最简单、实际上坑最多"的编程知识点,赋值运算符绝对排前三。它从你写第一行代码就跟在你身边,但直到你对着if (x = 3)这样的代码调了半天bug、想破脑袋都没发现问题在哪时,你可能才真正意识到——自己对这个天天见的符号,其实并没有想象中那么了解。

这个内容写出来,主要就是解答三个问题:赋值运算符到底是干什么的、为什么所有语言里都离不开它、以及不同语言之间它的表现为什么差这么多。适合刚学编程、被各种"赋值/比较"绕晕的新手,也适合写了两三年代码、想系统梳理一遍底层概念的朋友。说白了,搞懂这一个符号,等于把"变量、内存、引用、拷贝"这条线全都串起来了。

1. 赋值运算符的本质拆解:它不是在"算等于",而是在"写状态"

1.1 三个最容易搞错的直觉误区

先聊一个最基础的认知。我们从小接触数学里的等号,天然会把=理解成"两边相等"。但到了编程里,=的意思完全变了——它表示"把右边的值,写入左边变量对应的存储位置"。数学的等式是用来描述的,编程的赋值是用来操作的。

举个例子。x = 5在数学里的读法是"x等于5",但在程序里更准确的理解是"让x的值为5"。如果是x = x + 1,数学老师看到会直摇头,这等式根本不成立;但在程序里这行代码太正常了,它的含义是"读取x当前的值,加1之后,再写回x"。

这里就有三个特别容易踩的直觉误区。

第一个误区:把赋值当成可以双向推导的等式。数学里a = b和b = a在大部分场景下可以互换理解,但程序里完全不是一回事。赋值具有明确的方向性,永远是"右边算结果,左边接收结果"。我见过不少零基础学员写代码时把变量名放反,比如想用total累加,结果写了total = num,然后在下一行又用total去累加下一个数——最后发现total一直等于最后一个数,而不是总和。这就是没建立"方向感"。

第二个误区:混淆赋值运算符和比较运算符。赋值是=(或 R 语言里是<-),比较是==(全等是===)。这个坑经典到什么程度呢?在 C 语言和 Java 里,if (x = 3)这段代码不会报错,它会把 3 赋给 x,然后判断"赋完值之后的 x 是否为真"(非零即真),于是这个条件永远成立,循环永远出不来。这也是很多新手第一次接触"赋值表达式有返回值"这个特性时抓狂的原因——赋值竟然还能作为表达式参与运算。

第三个误区:认为每次赋值都在"复制"数据。这是最隐蔽的一个坑。对于整数、浮点数、布尔值这类基础类型,b = a确实是把 a 的值拷贝了一份给 b,之后两者互不影响。但一旦遇到字符串、列表、对象、数组这类复合类型,b = a在很多语言里并不是真的复制内容,而是让 b 和 a 指向同一份数据。改 b 就是改 a,改 a 就是改 b。这个展开讲是一大块内容,后面专门花一整节来说。

1.2 赋值表达式的值与副作用:不同语言的规矩差异

严格来说,赋值运算符做的事情有两部分:副作用和表达式值。副作用指"变量的值被改变了"这件事,表达式值指"这行代码本身算出来是什么结果"。

C 语言把两者都给你。在 C 里,a = b = c = 1这种链式赋值完全合法,因为c = 1这个表达式的值就是 1,然后再赋给 b,再赋给 a。很多系统级代码里喜欢这么写,图的是简洁。

但在 Python 里,赋值是语句而不是表达式,它没有值。你可以写a = b = 1(这是语法糖),但不能写x = (y = 2),因为(y = 2)不是一个表达式,没有可返回的值。这一点让不少从 C 或 Java 转过来的程序员不太适应。

JavaScript 则正好站在 C 这边,let a; a = b = 3;是可以的,if (a = 3)也不会报错——它和 C 一样继承了赋值表达式返回值的特性,所以写 JS 判断时如果不小心漏掉一个等号,同样会踩进"恒为真"的陷阱。

Go 语言又不一样,它规定赋值本身不产生可用的值,所以a = b = 1这种写法在 Go 里编译不过,必须分开写。

这个差异看起来只是语言细节,但它直接影响你写代码时的习惯。我个人的建议是:无论语言允不允许,都不要把赋值嵌入到判断条件里。即使 C 和 JS 支持,阅读代码的人也很容易看漏;现在的编译器也基本都会对这类写法给出警告。保持赋值和判断分离,能省掉大量不必要的排错时间。

语言赋值符号链式赋值赋值表达式有返回值比较符号
C=支持有,返回被赋的值==
Java=支持有,返回被赋的值==(对象比较需equals)
Python=支持(语法糖)无,赋值是语句==/is
JavaScript=支持有,返回被赋的值==/===
Go=不支持无,赋值是语句==
Rust=支持(部分场景)返回单元类型()==

2. 为什么所有编程语言都需要"赋值"这个动作

2.1 从计算器到程序:可变状态是编程的基石

你可能觉得这个问题很傻——语言当然要有赋值运算符啊,不然怎么写程序?但真的值得想一下"为什么"。

回到根本。计算机的模型是"存储 + 执行":内存里放数据,CPU 一条条读指令、改数据、再写回。程序运行的本质,就是内存中的数据不断发生变化。而赋值运算符,恰恰是所有"数据变化"的最小原语。

拿计算器对比就能看出区别。计算器按3 + 5 =,它算出 8 然后显示出来,完事了。你没法让计算器"记住"这个 8,并在下一步继续用它做运算。程序不一样,程序需要的是:把中间结果存起来 → 在下一次计算中读取 → 更新它 → 再存起来。这个"存起来"和"更新"的动作,就是赋值。

没有赋值运算符,你能写出来的代码就只有一堆常量表达式。理论上纯函数式语言(比如 Haskell)确实可以做到"不依赖赋值",但那是建立在一整套完全不同的抽象体系上的,普通程序员日常接触的命令式语言,全部建立在"变量可以被反复修改"这个前提上。

所以你看,赋值运算符不是什么可有可无的语法,它是命令式编程的地基。count = count + 1这种累加操作,背后是"读取 → 计算 → 写回"三个动作的组合;循环里的循环变量、递归里的参数传递、状态机里的状态切换,归根到底都是赋值在驱动。

2.2 没有赋值运算符,世界会变成什么样

做一个简单的思想实验:假设某个语言禁止一切赋值,你只能声明常量。那么你写一个计数器都费劲——数字没法变,循环怎么写?只能靠递归创建新的不可变值来模拟"变化"。

再比如用户交互。一个程序读取输入、根据输入做出响应,几乎必然需要把输入先存到一个变量里,然后判断、再赋给另一个变量。这些都是赋值的活。

而且赋值运算符还承担了一个在并发和异步场景里的关键职能:它定义了一个"状态变更点"。两个 goroutine 或两个线程之间,我们关心的是什么?是"谁在什么时候修改了共享数据"。如果没有明确的赋值动作,数据不会凭空变化;有了赋值,我们才能去谈锁、谈原子操作、谈互斥、谈数据竞争。换句话说,赋值不仅是语言的语法,还是程序行为同步协议的一部分。

这也是为什么很多语言里赋值和多线程安全总是被放在一起讨论。Java 的volatile关键字影响的是赋值的可见性,C++ 的std::atomic包装的也是赋值与读取的原子性。如果不理解赋值运算符背后"写入存储位置"这一层含义,这些并发概念就总隔着一层纱。

3. 主流语言中的赋值运算符形态:一张表看清"同门差异"

3.1 各大语言的赋值语法对比表

同为赋值,不同语言的语法细节差异很大,下面这张表浓缩了最常见的几个点。

语言普通赋值复合赋值示例解构赋值短变量声明特殊说明
Ca = 1;a += 2;无无变量需先声明
C++a = 1;a += 2;C++17 结构化绑定无移动语义
Javaa = 1;a += 2;无无对象是引用赋值
Pythona = 1a += 2a, b = b, a无需声明赋值是语句
JavaScriptlet a = 1;a += 2;[x, y] = arr;let/constvar提升坑
Goa = 1a += 2a, b = b, aa := 1:=只能在函数内
Rustlet mut a = 1; a = 2;a += 2;let (a, b) = (1, 2);let a = 1;默认不可变,需mut

这张表值得留意的是"解构赋值"这一列。它本质上是一种批量赋值语法:一次把多个值分别赋给多个变量。Python 里最经典的a, b = b, a交换两个变量,没有再引入临时变量,背后的原理是右边会先构建一个元组(b, a),左边再按位置解包赋值。Go 里同样支持这个写法,日常写交换逻辑非常顺手。

JavaScript 的解构则应用更广。const [head, ...rest] = list;可以直接从数组里取头元素和剩余部分;const {name, age} = obj;能从对象里按同名属性取值。写接口返回数据处理时,解构赋值能大幅度减少"取一个属性写一行 let"的啰嗦代码。

Rust 里的let语句比较特殊,它同时完成"声明 + 赋值"。变量默认不可变,如果要修改必须加mut。这背后的设计哲学是:默认不可写,当你想写的时候显式声明,以减少意外共享和并发风险。用惯了这种方式之后,再回到其他语言反而会觉得它们太随意了。

3.2 链式赋值与赋值的返回值:C语言风格的经典陷阱

链式赋值我一直觉得是个"甜中带刺"的特性。a = b = c = 1,写的确实爽,一行搞定三个变量初始化。但它隐藏的坑在于:你要清楚每一次赋值表达式的返回值是什么。

在 C 里,b = 1返回 1,然后a = (b = 1)把 1 赋给 a,所有变量都是 1,没问题。但如果换成a = b = c + 1,中间的细节就不那么直观了。更麻烦的是,一旦出现在条件里,就变成了开头说的经典 bug:

if (x = 3) { // 这里的代码永远会执行,因为 3 是非零值 }

正常的判断应该是if (x == 3)。多一个等号,程序行为完全改变,而且编译器多半不报错,甚至不会给警告(现在好一点的编译器和静态检查工具会提示"疑似笔误")。

JavaScript 同样继承了这个特性。if (user = getUserById(id))这种写法在 JS 里很常见——它利用赋值表达式的返回值来判断"有没有取到数据",然后顺便把 user 变量赋值了。一句话干两件事,看起来很高明,但可读性并不好。而且如果你本意是判断user == getUserById(id),少写一个等号就会让变量被悄悄改写,还难以察觉。

这里给个实用建议:在代码评审里,只要看到if条件里出现=,不管是不是故意的,都值得重点讨论一下。毕竟它的收益仅仅是少写几行,代价却是让读代码的人花额外的精力去判断"你到底是故意的还是笔误"。

4. 赋值到底是复制还是引用:所有引用类型踩坑的本质

4.1 值语义与引用语义:贴标签和替身的区别

现在聊这节课最重要的部分。很多 bug 归根到底只有一句话:你以为是复制,实际是共享。

先做个生活化的类比。值复制就像把一份文件复印一份,你手上的复印件和原件互不相干,你在复印件上涂改,原件不受影响。引用赋值则像是把一份文件的两个标签分别贴给两个人,看起来是两份"文件",其实背后是同一张纸,任何一个人在纸上画一笔,另一个人看到的也是改过的内容。

Python 里这个现象最直观:

a = [1, 2, 3] b = a # 这一步不是复制,是让 b 指向同一个列表 b.append(4) print(a) # [1, 2, 3, 4],a 也被改了!

很多人刚学的时候在这里摔过跤。为什么会这样设计?因为列表、对象这类复合数据在内存里可能很大,如果每次赋值都深挖一遍完整拷贝,内存和 CPU 开销都受不了;而且很多业务场景本来就希望"就是同一份数据,只是多一个名字方便操作"。

JavaScript 的对象也一样:

const obj1 = { score: 90 }; const obj2 = obj1; obj2.score = 100; console.log(obj1.score); // 100

Go 语言则有它独特的气质:struct 赋值默认是值复制(每个字段都拷一遍),而 slice、map、channel 赋值是引用语义(底层指向同一块内存)。C 里的指针赋值则是把地址复制一份,两个指针变量值相同,指向同一块内存,所以一旦通过其中一个指针修改数据,另一个指针看到的也是最新状态。

4.2 浅拷贝、深拷贝与复合赋值运算符的交互

理解了引用共享之后,下一个问题就是:我确实想要一份独立的副本,该怎么办?答案是显式拷贝。

Python 里list.copy()或者copy.copy()做的是浅拷贝:只复制最外层容器,里面的元素仍然是共享的。copy.deepcopy()做的是深拷贝:递归复制所有层。两者对嵌套列表的区别非常明显:

import copy old = [[1, 2], [3, 4]] shallow = copy.copy(old) deep = copy.deepcopy(old) shallow[0].append(999) deep[0].append(100) print(old) # [[1, 2, 999], [3, 4]] # 浅拷贝只保护了外层列表,内层子列表还是共享的;深拷贝则完全独立

JavaScript 里对应的工具是slice()、Array.from()、Object.assign()这些只做浅拷贝的 API,以及structuredClone()这个能做深拷贝的原生方法。很多 JS 开发者用过JSON.parse(JSON.stringify(obj))做深拷贝,这招对纯 JSON 数据可用,但遇到函数、undefined、Date、循环引用时会出各种奇奇怪怪的问题,生产环境要谨慎。

这个知识在工程实践里的直接应用是"函数参数默认值"。Python 里装饰器、默认参数可变对象的问题很出名:

def add_item(item, storage=[]): storage.append(item) return storage print(add_item(1)) # [1] print(add_item(2)) # [1, 2],这个默认列表一直在被复用!

原因就是storage=[]这个默认列表在函数定义时只被创建一次,后续每次调用没有传 storage 的话,拿到的都是同一个列表对象,append 操作等于在共享的同一个列表上叠加。这也是一次典型的"赋值/引用没搞清"引发的经典 bug。

处理方式很简单:默认值写None,函数体里显式新建列表。这背后的思维是——把"共享"变成"每次新建",你就掌握了对赋值行为的主动控制权。

5. 复合赋值运算符的真实语义:从 a=a+1 到 a+=1 的演进与风险

5.1 不可变类型下复合赋值的等价性

a = a + 1写多了总嫌啰嗦,于是几乎所有语言都提供了a += 1这种复合赋值运算符。除了+=,还有-=、*=、/=、%=、//=、**=、<<=、>>=、&=、|=、^=等等,对应算术和位运算的每种操作。

对不可变类型(整数、浮点数、字符串、元组等),a += b和a = a + b几乎是完全等价的。因为不可变类型不存在"原地修改"的可能,你能做的只有计算出一个新值,然后重新绑定到变量名上。这一点上,所有语言的行为基本一致。

比如 Python 里x = 5; x += 3,本质上是创建一个新整数 8,然后让 x 指向 8。原来的整数 5 如果没有其他引用,就会被垃圾回收。C 语言里稍有不同,它更接近x = x + 3的底层语义:读取 x 的值到寄存器,加 3,写回 x 所在的栈内存。C 没有"对象"这个概念(指针和 struct 另说),所以赋值就是一种纯粹的内存写操作。

5.2 可变类型下 += 的原地修改:Python 列表的经典案例

真正容易出问题的是可变类型配合+=。在 Python 里有一个著名的差异:

list1 = [1, 2, 3] list1 += [4] # 调用 __iadd__,原地扩展 print(list1) # [1, 2, 3, 4] list2 = [1, 2, 3] list2 = list2 + [4] # 创建一个新列表,然后重新绑定 print(list2) # [1, 2, 3, 4]

结果看起来一样,区别在内存。第一种是往原列表里追加元素,别的引用这个列表的变量也能看到变化;第二种是新建了一个列表,原来那个列表如果还被别的变量引用,则不受影响。

用id()函数可以验证:

a = [1, 2, 3] print(id(a)) # 某个地址 a += [4] print(id(a)) # 地址不变,说明是原地修改 b = [1, 2, 3] print(id(b)) b = b + [4] print(id(b)) # 地址变了,说明创建了新列表

为什么 Python 要区分这两种行为?因为list实现了__iadd__方法,它的默认行为是调用extend(),也就是原地操作。而+运算符调用的是__add__,语义是返回一个新对象。这个差异在写业务代码时很容易被忽略,但在涉及共享可变对象的设计里,可能会引发非常隐蔽的并发问题。

JavaScript 里的+=情况又不同,数组没有+=只能走concat或 push,字符串的+=则会产生一个新字符串,原字符串内容不变。

5.3 字符串拼接效率陷阱:复合赋值并非总是最优

说到字符串,就不得不提一个常见陷阱:在循环里用+=拼字符串。

因为字符串是不可变类型,每次s += token实际都是创建一个新的字符串,再把旧字符串内容复制一遍后拼接新内容。循环 N 次,总复制量是 O(N²),数据量一旦上去,性能立刻崩掉。

看这段代码:

s = "" for i in range(50000): s += "a"

在 Python 里跑一下会明显感觉到耗时。优化做法是改用列表收集再join():

parts = [] for i in range(50000): parts.append("a") s = "".join(parts)

join()会先计算总长度,再一次性分配内存完成拼接,复杂度降到 O(N)。Java 里的StringBuilder、C# 里的StringBuilder、JavaScript 里的Array.join都是同一个道理。复合赋值运算符不是不好,而是不加甄别地在循环里使用,一定会遇到性能瓶颈。

理解了+=在可变类型和不可变类型上的表现差异后,建议在写代码的时候多问一句:"这一行+=到底是修改原对象,还是创建新对象?" 凡是涉及共享对象、跨线程共享数据、缓存场景,这个问题的答案直接决定了程序的稳定性。

6. 进阶视角:解构赋值、短变量声明与所有权转移

6.1 解构赋值:一次性批量赋值的优雅写法

现代语言越来越喜欢"少写临时变量"。解构赋值就是这种趋势的代表。

Python 的交换写作已经提过了,它最优雅的地方在于不需要第三个临时变量:

a = 1 b = 2 a, b = b, a print(a, b) # 2 1

更复杂的用法可以从函数返回多个值:

def get_user(): return "Tom", 25 name, age = get_user() print(name, age) # Tom 25

JavaScript 的解构则更灵活,可以配合 rest 参数、默认值、嵌套结构:

const [first, second, ...rest] = [1, 2, 3, 4, 5]; // first = 1, second = 2, rest = [3, 4, 5] const { name, age = 18 } = { name: "Tom" }; // age 不传时默认 18

Go 语言里的短变量声明配合多返回值,写文件读取、矩阵运算这类代码时特别顺手:

x, err := someFunction() if err != nil { // 处理错误 }

解构赋值本质上仍然是赋值,只不过把"一个值赋给一个变量"扩展成了"一组值批量赋给一组变量"。它没有改变赋值运算符的语义,只是让批量赋值变得更加声明式。遇到需要交换、拆分、提取的代码,优先想想能不能用解构,代码往往一下子就清爽了。

6.2 Rust 的 move 语义:赋值运算符隐含的所有权交换

如果你写过 Rust,会发现它对赋值的理解和其他语言有本质差异。在其他语言里,let b = a要么复制值,要么创建引用;但在 Rust 里,对非Copy类型来说,这个赋值是移动——a 的所有权转交给 b,之后 a 就不可再用了。

let s1 = String::from("hello"); let s2 = s1; // println!("{}", s1); // 编译报错!s1 的所有权已经被移动走了

为什么 Rust 敢这么干?因为它的核心卖点就是"无垃圾回收的内存安全"。如果允许 s1 和 s2 同时指向同一块内存,那么作用域结束时会对同一块内存执行两次释放(double free)——这是 C++ 里最臭名昭著的 bug 之一。Rust 用编译期的所有权规则堵死了这条路:赋值即移动,旧变量失效,内存安全得到保证。

那如果我真的想要两个独立的字符串呢?用clone():

let s1 = String::from("hello"); let s2 = s1.clone(); // 现在 s1 和 s2 各自拥有一份数据

基础类型(整数、浮点、布尔)实现了Copytrait,赋值仍然按值复制。因为拷贝整数就是几个字节的复制,成本极低,不值得动用移动语义。从赋值运算符这个切口看 Rust 的设计,能很清晰地理解它"编译器帮你管内存"的思路——而这个思路的起点,正是对"赋值到底意味着什么"的重新定义。

这个知识点虽然偏底层,但对于写系统级代码、追求性能的开发者来说,理解移动 vs 复制 vs 引用这三者的界限,其实是通识。C++ 里的移动语义(std::move)同样在做类似的事,只是 Rust 把这个行为变成了默认规则,不需要程序员手动标记。

讲到这里我把赋值运算符这条线基本走完了:它是什么、为什么存在、各语言差异、复制引用陷阱、复合赋值的语义差异、以及现代语言的高级演进。如果让我说一个最重要的实践心得,那就是——每次写下赋值语句之前,先问一句:这行代码是复制了一份数据,还是让两个名字指向同一份数据?把这个问题想清楚,很多隐蔽的 bug 根本不会有机会出现。包括前面提过的循环拼接字符串、共享列表、默认参数这些坑,本质都是没想清楚这一层。保持这个习惯,踩坑率能降一大半。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/3 3:40:57

HER算法解析:用“事后经验回放”破解强化学习稀疏奖励难题

hindsight 这个词有意思。在日常生活里&#xff0c;它说的是“事后聪明”——事情都结束了才觉得“我早该想到”&#xff0c;一般带点贬义。但在强化学习里&#xff0c;这个词代表的是一个非常能打的技术&#xff1a;Hindsight Experience Replay&#xff0c;也就是事后经验回放…

作者头像 李华
网站建设 2026/10/3 3:40:19

Oracle的隐忧:技术债、成本与生态失守,去O之路如何走?

1. 曾经的必选项如今成了备选项&#xff1a;Oracle的霸权是怎么来的1.1 那个年代里&#xff0c;Oracle确实没有对手先交代一下背景&#xff0c;免得后文里的批评显得像情绪发泄。我是从Oracle 9i时代入行的&#xff0c;后来一路经手10g、11g、12c&#xff0c;到现在维护着几套1…

作者头像 李华
网站建设 2026/10/3 3:40:04

扫地机器人视觉+IMU融合导航与路径规划实战解析

我先说明一下&#xff0c;按照任务规范&#xff0c;我现在直接输出最终的博文内容&#xff0c;纯Markdown格式&#xff0c;不添加任何前后置说明。扫地机器人从随机碰撞进化到“哪里脏扫哪里&#xff0c;扫完自己回家充电”&#xff0c;背后靠的是一整套传感器融合与路径规划体…

作者头像 李华
网站建设 2026/10/3 3:39:34

FOC无刷电机电角度自动校准:磁编码器与极对数获取实战

搞过FOC的朋友应该都清楚&#xff0c;启动那一刻最难受的不是电流环没调好&#xff0c;而是你还不知道转子的电角度到底在哪。拿AS5047P这类磁编码器做位置反馈时&#xff0c;你读到的其实只是机械角度&#xff0c;而FOC的Park变换需要的是电角度。这个角度对不上&#xff0c;电…

作者头像 李华
网站建设 2026/10/3 3:39:04

MySQL从安装到调优:事务、索引与数据同步的实战笔记

前几天有个同事跑来找我&#xff0c;说他照着网上的教程装 MySQL&#xff0c;折腾了一整天&#xff0c;最后net start mysql弹出来的还是“服务无法启动”。我过去一看&#xff0c;他连my.ini里的basedir和datadir都写反了&#xff0c;数据目录是空的&#xff0c;服务当然起不来…

作者头像 李华
网站建设 2026/10/3 3:38:47

2bit反射型超表面设计:从单patch扫参到pin管偏置的完整流程

做2bit反射型超表面最磨人的地方&#xff0c;不是那些理论公式&#xff0c;而是你盯着仿真软件里那个patch单元&#xff0c;不知道该把参数往哪扫。扫出来相位有变化&#xff0c;但四个状态凑不齐90间隔&#xff1b;pin管加进去以后&#xff0c;相位曲线又整体漂移&#xff1b;…

作者头像 李华