ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Linux bash let命令详解:算术运算、坑点与性能对比

Linux bash let命令详解:算术运算、坑点与性能对比 提到Linux命令里的算术运算let绝对是最容易被人忽略却又值得好好聊的一个。它不是 ls、grep 那种每天都要用的命令但在bash脚本里写计数器、累加器、字节偏移量这些逻辑时let 的 C 语言风格能让你少敲很多字符。我刚入行那会儿一直用 $(( )) 和 expr直到接手一个老项目脚本里全是 let i 这样的写法才被迫把这命令从头到尾啃了一遍。这篇文章我会从语法、运算符、返回值这几个最容易出错的地方讲起再拿 let 和 (())、expr、bc 做一组对比最后把我的踩坑记录和几个面试常问点一并整理出来。无论你是刚学Linux的新手还是写过一阵子脚本但没细究过这条命令的开发者这篇应该都能给你省点时间。1. 先搞清楚let到底是什么1.1 let不是外部命令而是shell内建命令第一次查 let 的根底多数人会疑惑为什么which let什么都找不到其实是因为 let 根本不在 /bin 或 /usr/bin 下它是 bash、zsh、ksh 这类 shell 自己内置的命令。想知道当前 shell 认不认它直接执行type -a let如果输出let is a shell builtin说明当前 bash 支持。如果输出command not found那就要么换了 shell要么用了 dash 这类极简环境。这个身份很重要。内建命令意味着执行时不会 fork 出一个子进程只是 shell 自己算一笔整数账性能开销可以忽略。相比之下expr 1 2是外部程序每执行一次都要启动一个进程循环里跑几万次慢得能明显感受到差距。所以哪怕 let 的出场率不算高在需要密集数值计算的脚本里它的性价比反而比 expr 好很多。1.2 为什么脚本里离不开算术运算很多 Linux 新手学了 ls、cd、grep 就觉得脚本差不多了直到写循环、统计、批处理才发现数值计算是绕不过去的硬需求。比如统计目录里有多少个文件、循环产生 001 到 100 的序号、计算超时时间、判断某个数字是奇数还是偶数这些都是典型场景。let 的定位就是专门处理整数算术。它和$(( ))、(( ))一样共用 bash 的算术求值引擎但写法上更接近 C 语言。换句话说你可以在 let 后面直接写a 2、i、flag | 0x01省去$和一堆括号。理解这一点再看老脚本里密密麻麻的 let 表达式就不会犯怵了。2. let的语法与基础用法拆解2.1 基本格式与第一个例子let 的基本格式非常简单let 表达式表达式会直接在变量上做修改不会打印任何结果。想要看结果得自己echo $变量。例如a10 let aa5 echo $a输出是 15。这里注意我不写$a也完全没问题因为 let 的算术引擎会自动把裸变量名当成“取这个变量的值”这是 C 风格写法的来源之一。当然你写let a$a5也正确只是冗余。let 还支持一次传多个表达式按顺序依次执行let a1 b2 cab echo $a $b $c输出1 2 3。每个参数被当成独立表达式一个一个算过去。这种写法适合把相关联的多个累加操作放在同一行脚本看起来更紧凑。2.2 let支持哪些运算符let 支持的运算符比很多人想象中要多。除了常见的加减乘除取余还有幂运算、位运算、逻辑运算和各种复合赋值运算符。我把常用的一份整理在下面运算符类型运算符示例基本四则 - * / %let r7%3结果 1幂运算**let r2**10结果 1024自增自减 --let i等价于 ii1复合赋值 - * / %let n*3等价于 nn*3位运算 | ^ ~let r52结果 20逻辑运算 || !let r(53)(24)结果 1比较运算 !let r53结果 0这里有两个细节要注意。一是/是整除5/2得到 2不会出现 2.5。二是**幂运算需要 bash 4.0 及以上版本绝大多数现代发行版都满足但如果你在非常老的嵌入式系统上干活最好先确认 bash 版本。复合赋值是 let 最顺手的功能。以前写count$((count1))现在直接let count1是不是清爽很多同理let totalsize、let offset2也都是合法写法。2.3 空格与引号新手必看let 的语法坑集中在一个地方空格。如果按习惯写let a 1几乎必挂报错长这样-bash: let: a: syntax error in expression (error token is a)原因是 shell 会先把命令行按空格拆词a、、1成了三个独立参数。let 拿到第一个参数a会对a做算术求值但后面又跟着这种不能独立成表达式的 token于是炸了。正确写法有三种let a1 let a 1 let a 1第一种最常用第二种加了双引号可读性最好第三种单引号也能跑因为 let 的算术引擎自己会解析变量名。我个人的建议是既然是写脚本尽量别用容易出错的空格风格要么干脆不空格要么整体用双引号包住。尤其在let a a 1这种场景加引号后代码迁移到任何 bash 版本都稳。另外补充一点如果你更习惯用(( ))其实它和 let 是同一套东西。(( a a 1 ))不需要引号因为复合命令自身有独立的解析规则。这也是为什么很多现代脚本更推荐(( ))后面细说。3. 实操过程从自增计数到综合脚本3.1 计数器与累加器let i 和 let sumn最经典的用法就是循环计数。比如从 1 加到 100#!/bin/bash sum0 for ((i1; i100; i)); do let sumi done echo 1 到 100 的和$sum这里 let 出现在循环体内部比起sum$((sumi))读起来更接近自然语言把 i 累加到 sum 上。如果你负责过老项目一定见过更简练的写法count0 for file in /etc/*; do [ -f $file ] let count done echo 普通文件数量$countlet count等价于count1。它没有表达式输出也不适合写在$(...)里但在“只管把变量加一”的场景下非常干脆。还有一个容易混淆的点let b a和let b a不一样。前者先把 a 的旧值赋给 b再自增 a后者先自增 a再把新值赋给 b。遇到这种表达式先脑内模拟一遍 C 语言的执行顺序再上机验证别凭直觉猜。3.2 进制与位运算超出基础四则运算的用法let 的算术引擎支持多种进制写法这个很多人没用过。八进制用0开头十六进制用0x开头二进制可以用2#1010这种进制#数值的通用格式。例如let octal 0777 let hex 0xff let binary 2#1010 echo $octal $hex $binary输出为511 255 10。你可能会问八进制 0777 转出来 511这不是权限掩码吗没错处理 chmod 权限值、网络掩码、颜色值这类数据时进制转换非常实用。位运算在脚本里也有用武之地。判断一个数是奇数还是偶数不用取模直接看最低位n7 let odd n 1 echo $odd输出 1说明 7 是奇数。再比如给一个标志变量设置某些位flags0 let flags | 0x01 # 打开第1个开关 let flags | 0x04 # 打开第3个开关 let flags ~0x04 # 关闭第3个开关 echo $flags这类技巧在处理配置开关、文件权限、协议字段时很常见。虽然普通脚本不一定会用到但当你真遇到时let 这种简洁的位运算写法比一堆$(( ))嵌套舒服得多。3.3 真实案例写一个文件大小统计脚本光讲语法没意思我写一个能直接跑的小脚本。目标统计指定目录下所有普通文件的总字节数并计算平均大小整数。#!/bin/bash # 用法./sizestat.sh [目录] dir${1:-.} total0 count0 for f in $dir/*; do if [ -f $f ]; then size$(stat -c %s $f) let totalsize let count1 fi done echo 文件数$count if [ $count -gt 0 ]; then let avgtotal/count echo 总字节数$total echo 平均字节数$avg fi打个比方你有三千个配置文件想快速知道平均每个多大这脚本跑一下就有整数结果。要注意的是size$(stat -c %s $f)取出来的字节数已经是整数后续 let 累加不会有浮点问题。但平均数是整除如果统计出来是 3.7 万字节的数小数部分会被直接截断这不算 bug是脚本的设计取舍。3.4 结合循环与条件判断批量重命名另一个接地气的场景是批量重命名。比如把一堆 png 文件改成 img_1.png、img_2.png 这种带序号的格式#!/bin/bash i0 for f in *.png; do let new i 1 mv $f img_$new.png let i1 done这个脚本有几个细节值得注意。let new i 1这种写法放在 mv 命令前是为了生成从 1 开始的序号。最后let i1再递增计数器。如果你把递增放在 mv 前面序号就会从 2 开始容易踩错。实际执行前最好先在当前目录放几个测试文件跑一遍确认再对真实目录动手。4. let、(())、expr、bc的对比与选型4.1 一张表看清差异很多人的疑问是既然有 let为什么还有(( ))、$(( ))、expr、bc它们到底谁替代谁我整理了一张对比表方便你直接看差异方式类型是否支持浮点典型用法退出状态说明let 表达式bash 内建否let count1最后一个表达式值为 0 时返回 1(( 表达式 ))bash 内建否(( count 1 ))与 let 相同表达式值为 0 时返回 1$(( 表达式 ))bash 内建否sum$((ab))用在赋值中一般不看退出状态expr 表达式外部命令否sum$(expr $a $b)结果为 0 或错误时返回非 0bc 表达式外部命令是avg$(echo scale2; $a/$b | bc)正常计算返回 0注意(( ))和$(( ))的区别一个是算术命令一个叫算术展开。(( ))可以直接修改变量也能当条件判断$(( ))的主要作用是“把计算结果作为一个字符串返回”所以经常出现在变量赋值和 echo 里a5 b7 sum$((a b)) # 算术展开 (( total a * b )) # 算术命令let 和(( ))几乎等价硬要说差异就是 let 后面写表达式更接近普通命令语法(( ))则把整个表达式框起来规避了空格陷阱。4.2 性能对比内建命令为什么快如果只在脚本里算个位数性能差异你根本感觉不到。但批量循环时外部命令的 fork 开销会迅速放大。我试过一个小测试# 用 let time for i in {1..50000}; do let c1; done # 用 expr time for i in {1..50000}; do c$(expr $c 1); done在我自己的机器上let 版本一瞬间跑完expr 版本要好几秒。原因很简单let 只是 shell 内部一次算术求值expr 每次都要启动一个新进程。进程创建、初始化、返回结果一套流程走下来性能差距自然就出来了。这不是说 expr 一无是处。在 POSIX 场景或某些精简 shell 环境中expr 可能是唯一的选择之一。但只要能确定脚本跑在 bash 下能内建就内建没必要为了写个加法去启动几十个进程。4.3 选型原则我什么时候该用let我现在的经验是这样临时在命令行里快速算一个数或者写一次性脚本let 很顺手。尤其是let i、let x2这种短写法几乎不用动脑。但如果脚本要长期维护、给别人看我会优先用(( ))或$(( ))因为空格和引号的坑更少可读性更好。需要浮点计算时let 和(( ))都不能用考虑 bc 或 awk。需要校验返回值、处理复杂逻辑时(( ))更自然。需要跨 shell 兼容时老老实实写$(( ))。这些原则组合起来基本能应对绝大多数场景。5. 踩坑实录与脚本调试技巧5.1 最隐蔽的坑表达式结果为0时let返回1这是我刚开始用 let 时踩得最狠的坑。看一下这段代码let a 0 echo $?你猜返回什么我猜很多人以为是 0但实际是 1。这是 let 和(( ))的共同行为如果最后一个表达式的算术结果为 0命令返回状态码 1只有结果非 0才返回 0。这会导致什么后果如果你写let a 0 echo 成功echo 永远不执行。更危险的是在脚本里如果把let a0当作正常赋值来用后面又用$?判断是否成功了就会逻辑错乱。解决办法很简单。如果只是赋值不关心返回值可以追加|| truelet a 0 || true或者直接改用(( a 0 ))它的逻辑一样同样要留意返回值。如果你真的想用 let 做条件判断比如判断一个数是否大于 10if let a 10; then echo a大于10 fi这是合理用法因为表达式的值就是比较结果非 0 即真。要分清赋值表达式和条件表达式let 的返回逻辑是不同的。5.2 空格引发的syntax error与排查前面提到let a 1会报错实际报错信息可能带各种 token。比如-bash: let: a: syntax error in expression (error token is a) -bash: let: : syntax error in expression (error token is )排查方法很简单先看错误里的 token 是什么如果是、、*这类符号十有八九是空格把表达式拆碎了。确认一下有没有在表达式内部加无引号空格。另外*在 shell 里是通配符如果写let a3*5没引号*会被展开成当前目录的文件列表错误更诡异。所以只要表达式里出现特殊字符引号就别省。5.3 前导零的经典坑08和09不是合法数字bash 算术表达式里以 0 开头的数字会被当成八进制。问题来了08、09里的 8 和 9 不是合法八进制数字直接用会报错。最常踩这坑的场景是处理日期month08 let month1会报let: month1: value too great for base (error token is 08)。这是因为08被当成八进制但 8 这个数字不行。解决办法是强制按十进制解析month08 let month 10#$month 110#number表示把 number 按十进制读。如果变量是0810#08就是 8再加 1 得到 9。类似地从 date 命令拿到的月份、日期如果带前导零都要考虑这个问题。5.4 浮点数、空变量与整数溢出let 只支持整数遇到小数直接报语法错误let r 5 / 2 echo $r # 2整除 let r 5.0 / 2 # 报错需要小数结果用 bc 或 awk。另外未定义变量在算术表达式里会被当成 0这一点有时会掩盖 bugunset b let a b 1 echo $a # 输出 1b 被当作 0如果你希望脚本对未定义变量保持警觉可以在脚本开头加set -u。这样再遇到裸变量shell 会直接报错而不是默默当成 0。还有整数溢出。bash 的算术运算基于机器字长64 位系统上最大整数是 9223372036854775807。超过这个数会回绕成负数let x 9223372036854775807 1 echo $x # 输出 -9223372036854775808大多数脚本不会处理这么大的数但如果你在写涉及时间戳、字节量的逻辑最好心里有数。5.5 写脚本时的调试技巧调试 let 相关脚本我的习惯是三步。第一步在脚本开头加set -x让 shell 打印每条实际执行的命令这样你能清楚看到 let 表达式展开成了什么。第二步在关键位置加declare -p 变量名直接输出变量的类型和值比echo $变量更直观。第三步用echo $?检查 let 的返回码特别是前面说的“表达式结果为 0 返回 1”的坑一眼就能定位。举个调试示例set -x a08 let a 10#$a 1 declare -p aset -x模式下输出会显示实际执行的let a 10#08 1你立刻能看到 10#08 这样的写法。如果不去查资料光看报错也能想到可能是进制问题。5.6 面试高频小知识顺便总结几个我见过面试常提的点。第一个let 和 expr 的区别是什么核心是内建命令对外部命令性能、功能和可移植性的差异。第二个(( ))和$(( ))的区别是什么一个是算术命令一个是算术展开前者能当条件用后者主要用于取结果。第三个为什么let a0返回 1这就涉及到退出状态与表达式值之间的关系回答的时候提到“结果为 0 返回 1”即可。第四个如何在 POSIX sh 中实现算术运算用$(( ))因为它是 POSIX 标准的一部分dash 也支持。6. 兼容性补充let在dash和POSIX环境下的命运6.1 bash专属dash会报command not foundlet 是 bash 提供的功能不代表所有 shell 都有。Ubuntu、Debian 等系统把/bin/sh指向 dashdash 设计得比 bash 精简得多不支持 let。你拿sh -c let a1跑会直接看到sh: 1: let: not found所以脚本的 shebang 很关键。如果第一行写的是#!/bin/sh脚本里出现 let 就是不兼容的写法。很多人在 Ubuntu 上写脚本习惯性用#!/bin/sh跑起来才意识到问题。遇到这种情况要么把 shebang 改成#!/bin/bash要么把 let 改成 POSIX 支持的$(( ))。6.2 POSIX算术展开是最稳妥的替代跨 shell 最没有争议的写法就是$(( ))。它被 POSIX 标准收录bash、dash、ksh、zsh 基本都支持。举例i0 while [ $i -lt 10 ]; do echo $i i$((i 1)) done这种写法放到任何符合 POSIX 的 shell 里都能跑。如果你的脚本需要在多种系统间迁移优先用$(( ))而不是 let 或(( ))因为后两个虽然好用却依赖 bash 或 ksh 这类具备完整算术命令的环境。6.3 zsh、ksh的兼容情况除了 bashzsh 和 ksh 也支持 let 命令语法基本一致。所以如果你日常用的是 zsh写let a12也能跑。但从可移植性角度除非你确定目标环境我不想推荐把 let 当成默认方案。它更像一个“熟悉 bash 的人在交互式环境下的便利工具”而不是一套跨平台标准。维护长期项目时用更通用的写法能少很多不必要的麻烦。最后补一句我自己的习惯。写一次性命令或是临时调试let 确实顺手但凡是会进版本库、要长期维护的脚本我会优先写成(( ))或$(( ))。原因很简单团队里新人不一定熟悉 let 的空格规则而$(( ))在几乎所有 shell 里都能跑。let 不是不能用而是要知道它的边界。如果你在维护老脚本时遇到 let别急着改成别的先按这个文章的思路确认退出状态和引号再决定动刀。这是我踩过坑之后最想告诉你的。
返回列表