
直接写不需要客套话。咱们来看看 Linux 下这个总是被人忽略、但在脚本里又特别好用的内建命令let。很多刚接触 shell 编程的朋友在做算术运算的时候第一反应是去装 bc要么就是用 expr等到别人拿出 let 三下五除二解决了问题才发现自己绕了远路。这篇就把 let 命令从用法到原理、再到实际脚本里的典型场景一次讲透。1. 先搞清楚 let 到底是什么、为什么需要它1.1 它不是外部程序而是 shell 自带的计算器let 是 bash、zsh、ksh 这类 shell 的内建命令不是一个独立的二进制文件。这一点特别关键因为内建命令意味着它不需要 fork 一个子进程来执行在脚本里大量使用的时候性能开销比外部命令小得多。你可以做个简单验证type -a let在 bash 里会输出类似let is a shell builtin的结果。如果你用的是 dash比如 Ubuntu 系统默认的 /bin/sh 在某些版本里就是 dash直接执行 let 会告诉你 command not found。这是后面要专门说的兼容性问题先在心里记一笔。let 从功能上讲就是一个整数算术表达式求值器支持变量赋值、自增自减、复合赋值、位运算、进制转换、逻辑运算等等。它不处理浮点数这是它的边界也是后续选型时要考虑的点。1.2 用计算器的类比理解它的工作方式你可以把 let 想象成一个历史悠久的计算器但它的按键规则很特别输入表达式时不允许有空格除非你把整个表达式用引号包起来。let a53 echo $a # 8如果你写成let a 5 3不加引号shell 会把参数拆成 a、、5、、3 五个部分let 拿到第一个参数 a 去求值结果就是变量 a 原本的值后面的参数被忽略了而且整个过程不会报错。这是我当年踩过的第一个坑后面会详细展开。let 能干的事远不止加减乘除它还能直接给变量赋值。比如let b10 echo $b # 10这里不需要在 b 前面加$符号let 会自动识别变量名并赋值如果变量之前不存在它会直接创建。这个特性和很多语言完全不一样刚接触会觉得有点反直觉但真正理解了之后会发现非常顺手。1.3 let 和 $(()) 双括号的关系let 还有一个好兄弟算术扩展$(())和复合命令(())。它们底层用的是同一套算术求值机制区别在于使用形式。let x 5 3 y$((5 3)) ((z 5 3))三个写法结果一样但细节不同。$(())会返回表达式的结果值可以直接赋值给变量(())是命令形式不输出结果但它的退出状态码表示表达式是否非零let 本身不输出结果要么通过变量赋值体现要么通过退出状态码判断。理解了三者的关系你在写脚本时就能根据场景自由选择不需要死记硬背。2. let 的语法规则与底层原理2.1 空格问题为什么我写的 let 总是不按预期执行这是 let 命令最容易踩的坑。let 的参数可以是多个每个参数都会被当作表达式的一部分但关键是加号、减号、乘号、除号这些符号在没有引号的普通参数里会被 shell 解析器拆分成独立的参数。let a5 3这里的5和是两个参数let 只会对第一个参数5做求值结果就变成了a5后面的和3被忽略。没有任何错误提示但结果就是不对这就是 shell 脚本里最瘆人的隐形 bug。排查起来还特别费劲因为你不看参数解析过程根本发现不了。解决办法很简单就两个方向整个表达式加引号let a 5 3不加引号但代码紧凑let a53我个人的习惯是只要表达式稍微长一点就一律用双引号包起来。好处有三个允许在等号两边加空格提升可读性可以安全地在表达式中使用*、(、)这些在命令行里容易被通配符展开的符号如果不小心写了 shell 关键字比如let a index引号也能避免被词法解析器干扰。特别提醒一下*的使用。在 shell 中裸的*是文件通配符如果你写let a5*3而当前目录下有文件叫3那5*3会被展开成文件名的形式结果就是语法错误或者计算错误。在脚本里必须写成let a 5 * 3或者let a5\*3转义。很多新手在这个问题上折腾半天其实是没搞清楚 shell 的通配符展开优先级。2.2 变量引用什么时候加 $ 什么时候不加let 命令有个很有意思的设计表达式里的变量加不加$都行。比如x10 let yx5 echo $y # 15 let y$x5 echo $y # 15两种写法结果一样。为什么因为 let 的底层会先对表达式做变量名解析没加$的纯变量名会被当作变量引用加了$的会被 shell 展开成值然后参与算术运算。但在某些场景下两者是有区别的。看个例子str34 let astr1 echo $a # 错误str 不是整数找不到变量 str let b$str1 echo $b # 8str这个变量名本身不存在所以let astr1报错但$str展开成值34然后得到3418。反过来如果变量名存在但值是字符串情况又不一样。这里不展开太深核心结论是当你拿不准这个变量到底存不存在、值是什么时老老实实加$更稳妥至少语义和 shell 其他位置一致。另外还有一层let 可以直接给变量赋值也就是把这个变量当作左值。这个场景下变量名不加$是必须的因为加$之后展开成值就变成let 1055这种非法语法了。let result (a b) * c这种写法在脚本里非常常用尤其是一连串计算之后把结果保存在一个新变量里。它本质上把 let 当成了 shell 里的一个迷你赋值语言。2.3 返回值状态码一个不能忽视的细节shell 命令的退出状态码 0 表示成功非 0 表示失败let 的规则有点特殊它根据最后一个算术表达式的结果来判断。如果结果是非 0 的返回 0成功如果结果是 0返回 1失败。let a 0 echo $? # 1因为表达式结果是 0 let b 10 echo $? # 0因为表达式结果是 10 let c -1 echo $? # 0非 0 即成功这个特性容易被忽略但在条件判断里特别有用。比如你写let remainder n % 2 if [ $remainder -eq 0 ]; then echo 偶数 fi这是常规写法还可以利用 let 的状态码直接简写成if ((n % 2 0)); then echo 偶数 fi或者更直接的if let n % 2 0; then echo 偶数 filet 表达式求值结果为真非 0则 if 成立。这就能写出非常紧凑的脚本。但要注意表达式结果恰好为 0 时if 分支不会走进去别把逻辑搞反了。我见过不少人在这里踩坑写判断条件时没注意到表达式结果为 0 的语义导致条件恒真或恒假。2.4 进制与位运算支持let 支持多种进制表示这一点很多人在排查 bug 时才会注意到。十进制let a10八进制以 0 开头的数字。let a077值是 63十六进制以 0x 开头。let a0x1F值是 31任意进制base#number的写法比如let a2#1011是二进制值是 11这个base#number语法非常强大在处理二进制、八进制、十六进制输入时不需要额外调用外部命令。比如写一个脚本把二进制字符串转十进制bin101101 let dec 2#$bin echo $dec # 45这里变量 $bin 展开成 101101然后2#101101按二进制解析成十进制 45。这个特性在处理网络权限、文件权限、位掩码时特别有用。比如解析一个八进制的文件权限perm755 let value 8#$perm echo $value # 493这在写一些处理 chmod 数据的辅助脚本时能节省大量字符串解析工作。let 也支持全套位运算左移右移、按位与或异或、按位取反。位运算在一些高性能脚本里作用很大比如做权限掩码判断mask0b111000 let p 0b101000 let r p mask echo $r # 40let 对二进制的0b前缀在较高版本的 bash 里也支持不过为了老版本系统兼容我一般用2#写法更稳妥。3. 实际场景里 let 的高频用法3.1 计数器循环里最常见的应用模式let 在循环计数器里的地位几乎是不可撼动的。最典型的场景count0 while [ $count -lt 5 ]; do echo 第 $count 次循环 let countcount1 done后面这句let countcount1是标准的自增写法也可以简写成let count let count1 let countcount1三种写法等价。let count是后置自增表达式的结果是加 1 之前的值但变量确实加了 1如果你需要先加后用可以写let count或者直接let count1。更现代的脚本通常用 for 循环for ((i0; i5; i)); do echo 第 $i 次循环 done这里的(( ))和 let 完全等价可以理解为let i0; i5; i的便捷形式。for 循环的三个表达式之间用分号分隔这种写法在 C 语言里很常见shell 用户看到会觉得格外亲切。计数器场景有一个实际经验在循环里频繁执行 let 时性能相差无几但如果循环规模特别大比如上百万次外部命令 expr 会明显慢因为每次 expr 都要 fork 一个子进程。let 是内建命令没有这个开销。我之前跑一个 100 万次的循环用 expr 要几十秒换 let 一瞬间跑完完全不在一个量级。3.2 复合赋值可以让脚本更简洁let 支持、-、*、/、%、**一整套复合赋值运算符这几乎照搬了 C 语言的运算符体系。比如let x 10 let x 5 echo $x # 15 let x * 2 echo $x # 30 let x % 7 echo $x # 2注意**是幂运算比如let x 2然后let x ** 10得到 1024。shell 里幂运算支持得不多let 有这个能力算是意外之喜。在脚本里写计算里平方、立方时不需要调外部命令。复合赋值在处理累加累乘时非常有用。比如计算一个目录下所有文件的总行数total0 for file in *.txt; do lines$(wc -l $file) let total lines done echo 总行数: $total累加逻辑就一行清晰直白。如果把let total lines换成total$((total lines))也完全等价看个人习惯。我写代码时更倾向 let 的复合赋值形式因为可读性好一眼就能看出是在累加。3.3 多表达式的逗号运算符let 支持在同一行执行多个算术表达式用逗号分隔。这在命令行里操作临时变量时特别方便。let i 1, j 2, k i j echo $i $j $k # 1 2 3逗号运算符的特性是表达式从左到右依次求值最后一个表达式的值作为整体结果影响退出状态码。这个特性在一次给多个变量赋值的场景下非常好用。还有一个实用场景在单行循环里同时更新多个变量。比如斐波那契数列a0 b1 for ((n0; n10; n)); do echo $a let tmp a, a b, b tmp b done这里let tmp a, a b, b tmp b一行完成了三个变量的更新而且因为逗号按顺序求值不会出现变量被提前覆盖的问题。这个写法精简且高效。但要注意一点逗号运算符不是所有 shell 版本的 let 都支持完全一致的行为在 bash 里很可靠zsh 里也行但有些精简过的 shell 可能不支持。写项目脚本时如果只是针对 bash放心用如果要移植到别的 shell先做个兼容性测试。3.4 配合脚本参数与退出码let 经常被用来处理命令行传进来的数值参数比如calc() { let result $1 * $2 echo $result } calc 6 7 # 42这里$1和$2是外部传入的参数展开后参与运算。如果参数不是一个合法的数值shell 会报语法错误所以在交互式脚本里最好先做参数校验if ! [[ $1 ~ ^[0-9]$ ]]; then echo 参数必须是数字 2 exit 1 fi另外可以用 let 的退出码来做一个数值条件的判断分支。比如检查一个数是否在某个区间n15 if let n 10 n 20; then echo n 在 10 到 20 之间 fi和||逻辑运算在 let 里正常支持表达式求值结果符合预期退出码也对应真假。这个写法比[ $n -gt 10 -a $n -lt 20 ]看着舒服多了。4. let 与其他算术方法的对比选型指南4.1 一张表看懂差异工具类型浮点支持性能可移植性适合场景letshell 内建不支持极高bash/zsh/kshdash 不支持脚本内循环、计数、变量赋值$(())算术扩展不支持极高bash/zshPOSIX 可用需要取得计算结果时expr外部命令不支持写法繁琐低几乎所有 Unix老脚本兼容bc外部命令支持中低几乎所有 Unix浮点数、高精度运算declare -ishell 内建不支持高bash需要自动整数化的变量这张表基本覆盖了我在实际项目里见过的所有场景。连 POSIX 都没有要求 shell 必须支持 let所以 let 只能“能用但别指望所有环境都有”。4.2 场景化的工具选择建议在 for 循环或 while 循环里我几乎不用 expr原因前面说了性能差太多。如果是简单的整数算术并需要拿到结果推荐$(( ))total$((count 10))如果表达式不需要返回值只想修改变量或者做条件判断let 和(())都好。写成let x 1和((x 1))是等价的后者在某些人看来更像 shell 原生的写法。如果需要浮点数运算比如计算平均分、折扣价、百分比let 干不了直接上 bcresult$(echo scale2; 5 / 3 | bc) echo $result # 1.66如果你有变量已经是数字字符串又想省去类型转换declare -i也是一种思路。但它的使用范围窄我这里只是提醒有这个东西不推荐过度使用。4.3 一个场景两种实现谁优谁劣拿一个具体需求来比较统计 1 加到 100 的总和。用 expr 的写法sum0 i1 while [ $i -le 100 ]; do sum$(expr $sum $i) i$(expr $i 1) done echo $sum用 let 的写法sum0 i1 while let i 100; do let sum i let i done echo $sum这是两个很有代表性的写法。第一个里面expr 每次算出结果还要通过命令替换拿回来每个循环迭代至少 fork 两个子进程第二个全程内建命令不产生任何额外进程。规模小的时候没感觉循环次数到一万以上差距就开始明显了到百万级几乎就是十秒级和毫秒级的差异。写脚本的人要注意这种细节尤其日志分析类脚本经常要处理大量文本。5. 实际踩坑记录与问题排查5.1 诡异结果不报错的空参数问题有一次帮同事排查一个 flaky 的部署脚本现象是每次计算的并发数都是 0但逻辑上明明传了参数。最后定位到他那行写的let threadsMAX_THREADS而 MAX_THREADS 是一个字符串变量值是16带引号吗并不是是环境变量没有正确导出结果这个变量是空的。let 拿到一个空变量名直接给它赋了个空值输出就成了 0没有任何报错。这里的核心陷阱是let 对未定义变量的处理不像其他语言会抛异常它默认当成 0。所以如果你在脚本里依赖某个外部传入的变量先做一次变量存在性检查if [ -z $MAX_THREADS ]; then MAX_THREADS4 fi let threads MAX_THREADS这种问题最恶心的地方是不报错只在结果里体现。排查时要善用set -u未定义变量时报错和set -x打印执行过程能极大缩短定位时间。5.2 浮点数进 let一个报错的例子let 只支持整型。你输入let a 3.14bash 会直接报语法错误syntax error: invalid arithmetic operator。这不算 bug是设计边界。但更隐蔽的是变量里的浮点数pi3.14 let r pi * 2这里 pi 展开成 3.14let 尝试解析3.14 * 2同样报语法错误。如果你在写一个通用的脚本需要同时处理整数和浮点数建议提前判断整型运算走 let浮点运算走 bc。或者在脚本入口统一转换成整数比如把兆字节换算成字节数这种场景用整数乘法就够了。专门做单位换算时let 其实很合适。比如let bytes size_mb * 1024 * 1024因为是整数乘法完全不涉及浮点问题而且计算超快。5.3 bash 之外的 shelldash 不支持 let如果你在写一个以#!/bin/sh开头的脚本而这个 sh 在 Ubuntu 或者 Debian 上指向 dash那么 let 命令直接不存在。执行会报let: not found。解决方案有三个哈希行改#!/bin/bash显式声明用 bash不用 let全部用$(( ))替代用一个函数封装兼容逻辑我自己的经验是如果你不确定目标机器上有没有 bash用$(( ))是最保险的它属于 POSIX 标准语法几乎所有现代系统都支持。如果你确认系统有 bash那 let 写起来确实舒服两者不冲突。这里有个令人哭笑不得的历史细节bash 的作者在早期写 let 的时候是从 C shell 的命令借鉴来的思路后来 bash 又自己加了(( ))结果(( ))后来居上成了主流let 反而被一些人遗忘了。但这不代表 let 过时很多老练的运维脚本里依然大量用 let尤其是处理大量循环计算的场景。5.4 引号里面再引号的问题let 表达式里如果嵌套了字符串变量比如从一个文本文件里读取一个数这个数周围有空白字符let 解析时可能出错。num$(cat number.txt) # 假设内容是 42 带了空格 let result num * 2这里 num 展开后是42有前后的空格let 解析42 * 2时到 42 后面没直接接运算符可能报语法错误。稳妥的做法是先把变量转成整数num$(cat number.txt) num$((num)) # 利用算术扩展的强转能力 let result num * 2$((num))会对值做一次算术解析如果 num 是纯整数字符串带空白扩展后还是能正常解析但如果 num 本身包含非数字字符会直接报错。另一个办法是直接用num${num//[^0-9]/}把非数字字符剥掉再交给 let。这类问题的根源在于 shell 的变量展开发生在 let 解析之前所以你不能假设 let 能像其他语言编译器那样做类型推断。写健壮脚本的要点是凡是来自外部输入的数先清洗、再参与运算。5.5 条件判断表达式与关键字冲突let 表达式里如果出现 shell 关键字比如let echo 1这种在没有引号的时候会先被 shell 解析把 echo 当命令执行非常危险。所以养成好习惯let 表达式一律引号包裹。万一变量名真的叫 time、type 之类引号也是必须的。另外如果脚本里定义了函数与 let 交互注意变量作用域。函数内用 let 定义的变量默认是全局的除非你用local关键字声明。这个坑比较容易出现在写库函数时my_func() { local count let count 10 echo $count }这里的local count只是声明了变量let 里赋值才能确保函数内局部。如果不写 local函数外部如果有同名变量let 会直接修改它带来隐形的副作用。5.6 调试技巧用 set -vx 观察表达式解析如果你实在找不到 let 表达式哪里出了问题最直接的办法是开启 shell 的调试模式set -vx let r a b * (c - d) set vx运行后bash 会显示命令展开过程、参数拆分结果以及每一步的计算值。这能看到*是否被通配符展开、变量展开后的值是什么、最终退出码是多少。在脚本里这种调试方式比到处加 echo 高效得多我基本每次都靠它定位空格类诡异问题。6. 几个可以直接拿去用的实战脚本片段6.1 下载进度计算模拟一个下载进度条百分比是整数计算total1000 current320 let percent current * 100 / total echo 已下载 $current / $total进度 $percent%注意current * 100可能超过整数范围在 bash 里默认整数是 64 位一般不用担心溢出。但如果你在 32 位机器上跑老系统乘法结果超过 21 亿就可能出问题这种情况建议先除后乘牺牲精度换取范围。6.2 批量文件重命名把一堆文件名里的数字加 1 再重命名for file in file_*.txt; do num$(echo $file | grep -o [0-9]*) let newnum num 1 mv $file file_${newnum}.txt done这里的核心就是let newnum num 1一行完成类型转换和加法。其他地方都是文本处理。6.3 计算质数的朴素筛用 let 写一个 100 以内的质数判断is_prime() { local n$1 if let n 2; then return 1 fi local i2 while let i * i n; do if let n % i 0; then return 1 fi let i done return 0 } for ((num2; num100; num)); do if is_prime $num; then echo -n $num fi done echo这个脚本用到了 let 的双重身份一面是条件判断一面是计数更新。读起来其实有点像 C 语言的语句习惯了之后写 shell 脚本的效率很高。6.4 模拟 dice 骰子游戏一个简单但有意思的例子。利用时间戳模拟随机数max6 min1 let range max - min 1 let rand ($(date %s) % range) min echo 骰子点数: $rand这种写法不适合生产环境但用来演示 let 的表达式组合能力非常直观。重点是let rand ($(date %s) % range) min命令替换、算术取模、变量赋值一层层套进去let 都能处理。7. 我对 let 的个人经验总结用 let 写脚本这么多年我很少在项目里把 let 当作唯一工具它更像一把趁手的扳手单线程场景很顺手但你别指望用它拧所有的螺丝。比较合理的策略是循环计数器、累加器、简单整数逻辑直接 let需要把计算结果嵌入到更长的字符串里时用$(( ))更直观浮点运算上 bc大数字高精度计算上 python 或者 awklet 最大的优点就是快、简洁、符合嵌入式脚本的极简精神。它最大的坑则是语法暗坑多不报错的诡异行为容易让新手困惑。我的习惯是把所有 let 表达式统一都用双引号包起来在脚本开头声明#!/bin/bash然后每次修改完脚本先用bash -n做语法检查再跑一行set -euo pipefail提升整体防御性。这样踩到暗坑的概率大幅下降。如果手头有老旧的 bash 版本比如 CentOS 6 默认的 bash 4.1也别担心let 的绝大部分能力在那个年代都已经具备了除了个别 bitwise 写法在极端老版本里可能有细微差异。总的来说只要规避了空格、变量空值、引号展开这几个硬伤let 是非常可靠的一个内建命令。最后分享一个我经常用的排查小技巧写完任意一条 let 表达式立刻接着写一行echo $?或者在一个变量后面打印它的值。利用 let 退出码判断执行结果同时看变量值是否符合预期双保险能省掉大量后期调试时间。这个习惯帮我处理过不少线上脚本的诡异问题算是最值得推荐的个人经验。