我之前调试一个批量重命名的脚本时,遇到一段看起来毫无问题的代码:
let "n = n + 1"跟我想的一样吗?不,它彻底打乱了我的脚本。原因很简单:let不是“赋值语句”,它是一门独立的算术求值器。这个名字容易让人联想到其他语言里的let关键字,也容易让人把它和 Bash 里最朴素的n=1搞混。结果就是,等号周围加不加空格、表达式结果是否为 0、有没有开set -e,都会直接影响脚本行为。
这篇文章从头到尾拆一遍let和普通赋值的区别。你不光能搞明白let a=1和a=1为什么不相等,还能顺手解决几个常见问题:let i++为什么退出码总是不对、((...))和$((...))到底选哪个、为什么很多 Shell 规范都建议你少用let。所有例子都在 Bash 5.x 下验证过,zsh 和 ksh 的细节会有些差异,但核心逻辑一致。
1. 等号周围的一空之差:let 的本质是算术求值器
1.1 let 和其他语言的 let 关键字没有关系
很多从 JavaScript、Rust 转过来写 Bash 的朋友,第一次看到let会以为它是“声明变量用的语句”。这完全是被名字误导了。
Bash 里的let是系统内置命令,你可以把它理解成一个小型的 C 风格算术解释器。它接收一个或多个表达式,把表达式算完,然后允许你把结果通过=写进变量。它解决的核心问题很简单:Bash 本身不擅长做整数运算,你写x=1+2,得到的变量内容是字符串1+2,不是数字3。let就是为了把“字符串存储”变成“算术求值”而存在的。
举个最直接的例子:
x=1+2 echo "$x"输出结果是1+2。Shell 不负责做加法,它只负责把=右边的字符原封不动存进变量。注意,普通赋值语句也会做变量展开,但不会做算术展开。x=1+2里的1+2就是三个字符拼起来,没有任何计算发生。
换用let:
let x=1+2 echo "$x"这次输出的是3。let把1+2放进了算术语境里,按 C 语言规则先算后存。我经常跟别人说:let是一个带着赋值功能的计算命令,而不是一个“升级版赋值语句”。这个定位决定了后面所有细节。
1.2 普通赋值和算术赋值的真实差异
普通赋值的完整规则是:变量名、等号、值三个要素之间不能有空格,值部分按字符串处理。比如:
name="hello world"这里的hello world有空格也没关系,Shell 会把它看作一个整体赋给name。但如果你写:
let x = 1那就出事了。因为 Bash 解析命令行的时候,会按空白把参数拆开。let x = 1实际传给了let三个参数:第一个是x,第二个是=,第三个是1。let会把第一个参数x当作一个表达式去求值,在一个没有定义过的变量上算术求值,结果就是 0;第二个参数=单独作为表达式,直接语法错误,于是你会在终端看到:
let: =: syntax error: operand expected (error token is "=")这是所有新手第一次接触let时踩的坑。普通赋值要求等号两边不能有空格,但let的语法并不是“变量名=值”,而是“参数是表达式”。你可以把等号当作表达式内部的一个运算符,所以等号两侧有没有空格完全由你控制,真正决定它是否合法的是:整体是不是一个参数。
比如下面三种写法都是合法的:
let x=1 let x = 1 let "x = 1"前两种之所以能用,是因为 Bash 在执行let之前先把x = 1拆成了x、=、1三个独立参数。你没看错,let x = 1实际上等价于let x = 1的三个参数,Shell 会分别求值。这里第一个参数x单独求值,得到旧值 0;第三个参数1单独求值,得到 1。中间那个=本来会报错,但为什么有时候看起来又不报错?
其实 Bash 对算术表达式里的=处理非常灵活。单独拿=出来,确实可能报出刚才那个 syntax error。可如果你写的是let x = 1,实际在多数 Bash 版本里你会看到的反而是另一个错误,或者是x被设置成了某个奇怪值。这种含糊不清最坑人。最好的习惯就是加引号,让表达式彻底变成一个参数:
let "x = 1"引号把空格变成了表达式的一部分,语义清晰,不会触发分词。这也是我后来在脚本里必须写let时,一律带引号的原因。
1.3 赋值不做扩展?不对,扩展顺序才是关键
还有一个容易混淆的点:let到底会不会做变量扩展?会,但时机在算术求值之前。
x=5 let "y = x * 2"这里x在算术求值时会先被展开成 5,然后计算5 * 2,最后把 10 存进y。所以你可以放心在let表达式里写变量名。需要注意的一点是:如果你给表达式加了单引号,变量的展开会被推迟到算术求值阶段;如果你用双引号,Shell 先扩展再计算。绝大多数情况下效果一样,但如果变量本身不可信,比如包含空字符串,就会引发算术错误。
比如:
unset x let "y = x + 1"未定义的变量在算术语境里默认是 0,所以这里y会被赋成 1。这反而比普通赋值更省心,普通赋值会把空值原样写进去。
2. 退出码才是 let 的隐藏条款:结果正好是 0 的陷阱
2.1 退出码不是“执行成功/失败”,而是“结果是否为 0”
普通赋值命令的退出码很好理解:赋值成功返回 0,失败返回非 0。比如a=1的退出码是 0,a=0的退出码也是 0。
但let不这样。Bash 手册里写得很清楚:let返回 1,当且仅当最后一个算术表达式的结果为 0;如果最后一个表达式的结果非 0,返回 0。
这句话要拆开看。它不是说“let 认为算出了 0 就是失败”,而是 Bash 有意把let设计成和test、((...))一样,可以当作条件判断使用。在算术语境里,0 表示假,非 0 表示真,所以let的退出码直接反映了表达式真伪。
看几个例子:
let "a = 3" echo $?a被赋成 3,最后一个表达式是a = 3,它的求值结果是 3,非 0,所以退出码是 0。
let "b = 0" echo $?b被赋成 0,最后一个表达式结果是 0,退出码是 1。但在普通赋值b=0里,退出码是 0。这就是一个反直觉的地方:赋值成功了,退出码却表示失败。
这个设计其实很像 C 语言里的赋值表达式a = 0,整个表达式的值就是 0,放在条件语句里当然就是“假”。Bash 把这一套 C 语义原封不动搬进来了。
2.2 set -e 下的意外退出
最危险的情况出现在脚本开头写了set -e。这个选项的意思是:任何一个简单命令执行失败,脚本立刻退出。而let "b = 0"的退出码恰好是 1,在set -e环境里,它就变成了“致命错误”。
我之前见过一个非常经典的线上问题,脚本里是这样写的:
set -e count=3 let "count = count - 3" echo "done"看起来只是让count减 3,减完变成 0,然后继续执行。但因为count = count - 3的结果正好是 0,let退出码为 1,脚本在set -e下直接终止,后面的echo done永远没机会跑。
这类问题很难排查,因为日志里完全看不出来有异常,脚本就是莫名其妙断掉。重新检查退出码才发现let把“结果为 0”当成“假”返回了。
如果你要在set -e的环境里给变量做普通算术更新,我强烈建议改用命令替换:
count=$((count - 3))$((...))是算术展开,它只负责把算式转成字符串“0”,赋值操作本身是普通赋值,退出码永远是 0,除非语法错误。这就绕开了let的退出码陷阱。
2.3 利用退出码做条件判断:if let 的正确用法
let的退出码虽然坑人,但它也意味着你可以把它直接用在if、while里,当算术条件用。
比如判断变量是否是偶数:
n=4 if let "n % 2 == 0"; then echo "偶数" fin % 2 == 0结果为 1,非 0,退出码为 0,条件成立。如果n是 3,表达式结果为 0,退出码为 1,条件不成立。
这种写法在语法上完全合法,但实际项目里我更推荐用((...)):
if (( n % 2 == 0 )); then echo "偶数" fi二者的退出码规则一模一样,但((...))的写法更直观,不用加引号,也没有等号空格陷阱。后面我会专门对比它们。
3. 运算符与多表达式:let 的细节地图
3.1 一条 let 里同时算多个表达式
let命令可以接收多个参数,也可以在一个表达式里用逗号分隔多个子表达式。
先看参数写法:
let "a=1" "b=2"这里let有两个参数,每个参数都是一个算术表达式,Bash 从左到右依次求值,最后a是 1,b是 2。整个命令的退出码看最后一个表达式的值:如果b=2的结果非 0,退出码为 0。
再看逗号写法:
let "c=1, d=c+1, e=d*2"一个参数内用逗号分隔,同样是从左到右逐步求值。后面的表达式可以看到前面刚赋过的变量。这里c是 1,d是 2,e是 4。这种写法在需要一次性更新多个相关变量时很紧凑,比如滑动窗口或者递推计算。
需要注意,逗号表达式的整体值是最右边那个子表达式的值。上面例子里整体值是e的新值 4,非 0,所以退出码是 0。如果最右边的结果是 0,退出码就会变成 1。
3.2 自增自减与后置返回值的反直觉
let支持++和--,既有前置也有后置,语义和 C 语言一致:
i=0 let "i++" echo $i执行完i变成 1。但这个命令的退出码是 1,因为后置i++表达式的值是旧值 0,而 0 代表假。
这一点太容易踩了。很多人写循环:
i=0 while ((i < 3)); do let i++ echo "$i" done脚本会像预期那样输出 1、2、3。但是一旦有set -e,第一次执行let i++时,表达式结果是旧值 0,退出码为 1,如果这里没有进入if/while这类条件保护,脚本可能直接中断。
改成前置自增可以避免一部分问题:
let "++i"因为表达式的值是自增后的新值,第一次执行时表达式值为 1,退出码为 0。但如果 i 从 -1 开始,++i的结果是 0,退出码仍为 1。所以本质上无法完全靠前置自增规避“结果为 0”。
我的建议是:在必须保证退出码稳定的场景,不要用独立let做自增,改用:
i=$((i + 1))或者用循环 C 风格写法for ((i=0; i<3; i++)),把自增放进条件头里,语义和安全性都更好。
3.3 进制、幂运算与位运算
let的算术能力比很多人想象中强,它支持十六进制、八进制,也支持位运算和幂运算。
let "hex = 0x1F" let "oct = 010" let "power = 2 ** 10" let "shift = 1 << 4" let "bit = 0xFF & 0x0F"这些表达式和 C 语言几乎一一对应。0x1F是 31,010是八进制的 8,2 ** 10是 1024,1 << 4是 16,0xFF & 0x0F是 15。
平时在脚本里解析十六进制值,let是很趁手的工具:
let "value = 0xFF" echo "$value"输出 255。如果要反过来把数字格式化成十六进制,那还得用printf "%x",let只负责算,不做格式化。
括号和优先级也是细节。表达式里有括号时,Shell 本身会把括号当作语法结构,所以必须用引号包住整个表达式:
let "result = (3 + 5) * 2"如果不加引号,Bash 可能会把括号解析成子 shell,整行命令直接报语法错误。记住一句话:凡是在let后面写复杂表达式,一律用单引号或双引号包起来,这样空格、括号、重定向符号都只属于表达式,不会被 Shell 提前解释。
4. 横向对照 let、((...))、$((...)) 和 declare -i
4.1 它们背后是同一台算术引擎
Bash 内部有一套独立的算术求值器,let、((...))、$((...))最终都调用它。区别只在于它们如何和命令行语法相处。
((...))是一个复合命令,它和let的退出码规则一致:最后一个表达式为 0 时返回 1,否则返回 0。但它有几个优点:不需要引号,内部空格不影响解析,可读性更好。
(( a = 3 )) (( b = a + 1 ))$((...))是一个展开语法,它会把计算结果作为字符串替换出来。它自己没有退出码,退出码属于外层命令。
c=$(( 3 + 5 ))这里的退出码是普通赋值命令的退出码,几乎永远是 0,除非算术表达式本身语法错误。
declare -i则是给变量加上“整数属性”:
declare -i d d=1+2 echo "$d"输出 3。因为d被声明成整数变量之后,赋值的右侧会强制走算术求值。这个写法的优点是看起来就像普通赋值,缺点是它依赖 Bash 扩展,而且隐式求值有时候很让人困惑,读代码的人一眼看不出来右边是不是算式。所以我只在极少数局部场景用它,不太喜欢把它写进正式脚本。
4.2 退出码和可移植性的差异
| 写法 | 是否需要引号 | 退出码含义 | 是否 POSIX | 适合场景 |
|---|---|---|---|---|
let "a=a+1" | 建议加引号 | 随表达式真假 | 否 | 老式 ksh 风格 |
a=$((a+1)) | 不需要 | 随赋值命令,通常为 0 | 是 | 通用推荐 |
((a=a+1)) | 不需要 | 随表达式真假 | 否 | 条件判断、循环 |
declare -i a; a=a+1 | 不需要 | 随赋值命令,通常为 0 | 否 | 局部简单算术 |
如果你写的脚本要兼容/bin/sh,那let和((...))都用不了,$((...))是唯一能保证算术求值的可移植写法。这也是很多规范建议默认使用$((...))的原因。
从可读性出发,我也更推荐$((...))。它把“计算”和“赋值”两个动作分得清清楚楚,表达式的部分看起来非常接近数学记法。相比之下let是一个命令名加一堆参数,阅读时总要花一点时间辨认哪个是表达式。
4.3 我的实际选择顺序
在维护真实脚本时,我会按优先级做选择:
- 只要涉及变量赋值,一律
a=$((...)),保证退出码稳定。 - 只要涉及条件判断,一律
((...)),语义直观。 - 只有写 ksh 风格的老旧脚本、或者要刻意追求 C 语言表达式的紧凑感时,才用
let。 - 几乎不用
declare -i,因为它会改变普通赋值语义,容易让后来维护的人误判代码。
这个顺序不是绝对的,但能避开我踩过的绝大部分坑。let不是不能用,而是它的退出码和参数规则太容易在大型脚本里埋雷。小范围、几行的脚本里用一下无妨,几百行、多人协作的脚本里尽量控制使用范围。
5. 脚本实战:踩坑过程与三条实用修改
5.1 踩坑:set -e 下 let 让脚本神隐退出
先说一个我实际遇到的故障。脚本大概长这样:
set -e total=0 for file in *.txt; do size=$(wc -c < "$file") let "total = total + size" done echo "total: $total"第一轮循环里,如果$size是 0,total = total + size的结果也是 0,let退出码为 1,set -e直接把脚本掐断。表现就是“处理到某个空文件直接停掉”,而日志里看不到任何报错,只有循环里后续的echo没输出。
排查过程其实不长。我先怀疑是wc -c读取异常,又怀疑是文件通配符问题,最后加了一行echo $?才定位到let。真正的根因就是“表达式结果为 0 时let返回失败”。
修复也简单:
total=$((total + size))改动一行,退出码立刻恢复正常。从此我在set -e的脚本里彻底禁用了独立let做累加。
5.2 踩坑:变量变成了字符串“1+2”
另一个常见错误是拿普通赋值写加法:
n=1 n=n+1 echo "$n"输出是n+1三个字符,不是 2。如果写成n=$n+1,输出是1+1,也不是 2。只有用let或者$((...))才会得到 2。
有个典型场景:批量重命名文件时,计数器写成了count=$count+1,最后生成的文件名变成了file_1+1.txt。问题不在语法,而在“赋值没有算术”这个核心认知。要保存数值变化,要么显式展开后计算,要么走算术引擎。我的习惯是:看到需要自增、累加的代码,第一反应就写$((...)),不给自己留误用普通赋值的机会。
5.3 合理使用 let 的场景:条件判断与多变量更新
虽然我说了很多let的坑,但它并非一无是处。在不需要考虑set -e、并且表达式本身偏 C 风格的小脚本里,let和((...))依然很顺手。
一个常见的合理场景是加标志位:
let "flags |= 0x08"这一行把flags的第 3 位置为 1,写法紧凑,一眼能看出是位操作。
另一个是多变量递推更新:
let "old = value, value = value * 2 + offset"这种一次写多个相关变量的场景,比拆成两行更不容易出现中间状态混乱。但记得外部用引号包住整个表达式,避免被 Shell 分词。
还有一个实用技巧是,把let当作一个不输出任何内容的算术条件。例如判断一个数是否是 2 的幂:
if let "value > 0 && (value & (value - 1)) == 0"; then echo "是 2 的幂" fi这种写法非常 C 语言,看懂的人会觉得精妙,看不懂的人会想骂人。如果你和团队协作,我更推荐写一个带注释的函数封装一下,可维护性高得多。
最后分享一个我在项目里固定使用的检查清单:
- 赋值并需要稳定退出码:用
$((...))。 - 判断真伪或循环条件:用
((...))。 - 必须兼容
/bin/sh:只用$((...))。 - 必须兼容 ksh 风格旧代码:用
let,但所有表达式加引号,并且检查结果可能为 0 的路径。 - 不要在
set -e下用独立let做普通变量更新。
还有一个很多人忽略的小细节:调试时可以执行help let,它会列出let支持的全部运算符。遇到不确定的优先级,直接跑一句let "x = ..."; echo $?验证退出码,比反复读文档快得多。我在实际运维中就是靠这个小习惯少踩了很多暗坑。