1. 为什么我非要盯着 declare -i 这个"冷门开关"不放
Shell 脚本写多了,总会遇到一类让人抓狂的 bug:明明每一步 echo 出来都是对的,脚本跑完结果却差了那么几个数。我自己最早栽的坑是在一个批量文件处理脚本里,用count变量统计处理成功的文件数,逻辑上每次成功后执行count=$count+1,跑了二十个文件,最后打印出来是++++++++++++++++++++开头的一串东西——因为变量从头到尾都是字符串,+被当成了普通字符拼接。后来有同事建议我把每一处都套上$(( )),代码确实能跑,但那个变量在七个函数里被改过十几处,每处都包一层算术展开,读起来像一堆括号打架。最后我改用了declare -i count,一次声明,后面所有赋值自动走算术求值,脚本短了一截,也再没出过类似的错。这就是Shell 里的 declare -i,它做的事情朴素到一句话就能说完:把变量声明为整数类型,之后赋值时 shell 会自动把右边的字符串当成算术表达式求值再存进去。它不是新东西,bash从很早就支持,但真正在脚本里用起来的人并不多。这篇内容面向的是已经能写基本 Shell 脚本、但被数值处理折腾过的人,也适合刚开始维护别人遗留脚本、看不懂declare -i到底是什么语义的读者。
1.1 从一次"字符串拼接事故"说起
那个批量脚本的原始写法大概是这样:
count=0 for f in "$@"; do process_file "$f" && count=$count+1 done echo "成功处理 $count 个文件"变量count在 bash 里没有类型,第一次赋0是字符串"0",第二次赋"0+1",第三次赋"0+1+1"。如果只循环两次,最后 echo 出来就是0+1+1,肉眼一看就知道错了;但如果循环里恰好有一次echo打印了中间值,输出会是0+1,看起来像 1,你可能就放过去了。这种 bug 的恶劣之处在于,错误不会立刻暴露,而是在某个边界条件下突然显形。改成declare -i count=0之后,count=$count+1会先做算术求值:count被展开为0,表达式变成0+1,结果是1存回变量。第二次循环时count是1,1+1得2。同一个写法,语义完全变了。
这件事让我意识到一个关键点:Shell 变量的默认类型是字符串,所有的"数字感"都是错觉。你以为你在做加法,其实你在拼字符串;你以为$num是个数,其实它是字符序列。declare -i的价值就在于把这个错觉变成事实,让变量从声明的那一刻起就带上了"这是个数"的标签。
1.2 declare -i 到底解决了什么问题
把它的作用拆开看,主要有三层。
第一层是消除显式算术展开的噪音。没有-i属性时,你写count=$((count + 1))或者((count++));有了-i属性,直接写count=count+1甚至count+=1就够了。在一个变量被频繁修改的脚本里,这能省掉大量括号,代码可读性的提升是实打实的,尤其是当表达式本身已经比较复杂的时候。
第二层是统一数值变量的行为。脚本里如果同时存在字符串变量和数值变量,维护者需要靠上下文猜哪个是哪个。加上了-i声明,等于在代码里留下了类型注释,后续接手的人一看declare -i就知道"这个变量只装整数,赋值请给表达式"。这在多人协作或者自己半年后回看代码时价值很高。
第三层是减少子进程和外部命令。很多老脚本用expr做加法,expr是外部命令,每次调用都要 fork 一个进程。declare -i的求值完全在 shell 内部完成,没有进程开销。这一点在循环里尤其明显,后面我会给一组实测对比。
不过它也有明确的边界,最大的边界就是:它只管整数,不管小数。任何涉及金额、比例、坐标的浮点计算,declare -i都帮不上忙,硬套上去只会得到一个被截断的错误结果。这一点必须在动手前就想清楚,否则后面全是坑。
1.3 这篇内容适合谁,能拿走什么
如果你平时写的脚本里出现了"变量赋值后数字变成了字符串"、"相加变成了拼接"、"前导零导致报错"这几类问题,那这篇内容就是为你准备的。我会从declare命令的属性机制讲起,把-i的行为边界、和let/$(( ))的区别、函数作用域里的陷阱、外部输入的安全风险,以及真实的排查过程都摊开讲。你能拿走的是:一套可以直接抄进项目的写法约定、一张遇到问题能对号入座的速查表,以及几个只有踩过才知道的细节。不需要你有很深的 shell 源码背景,能看懂基本的赋值语句和函数定义就够。
2. declare -i 的底层行为拆解
2.1 declare 的本质是给变量贴属性标签
很多人把declare当成"声明变量"的语法糖,其实它的核心动作是给变量附加属性。bash 里的变量除了名字和值,还挂着一组标志位,declare就是操作这些标志位的命令。常用的属性有这么几个:-i表示整数,-a表示索引数组,-A表示关联数组(bash 4 以上),-r表示只读,-x表示导出为环境变量,-l表示赋值时自动转小写,-u表示自动转大写。这些属性可以叠加,比如declare -ir MAX=100就是"整数且只读"。
理解了这个机制,很多行为就顺了。declare -i n如果n之前不存在,会创建一个空值的整数变量;如果n已经存在并且有值,那这个值会被放置到整数属性的约束下重新解释,但不会立刻重新求值——这一点很关键,很多人的误解就出在这里。你可以用declare -p n查看变量当前挂载的属性,输出形如declare -i n="5",这个命令我在排查任何"变量行为诡异"的问题时几乎都会先用一次,比盲猜快得多。要撤销属性,把减号换上去:declare +i n,之后n就退化成普通字符串变量了,但当前值会保留。
还有一个常被忽略的细节:declare在函数内部默认创建的是局部变量。这跟很多人的直觉相反。你在函数里写declare -i n=0,它不会去修改外层的同名变量,而是新建一个只在函数内可见的局部变量。这个设计本身是合理的,但它造成的后果是:如果你打算在函数里修改全局计数器,写了declare -i反而改不到外面去,脚本跑完数字纹丝不动,排查起来非常费劲。解决办法是用declare -g -i(bash 4.2 及以上),显式声明操作的是全局变量。
2.2 赋值即求值:-i 到底改了什么
带-i属性的变量,在赋值时会经历一次算术展开(arithmetic expansion)。这个过程的规则和你在$(( ))里写表达式时完全一致,所以理解了$(( ))的规则,就理解了declare -i的全部行为。
具体来说,赋值右边的字符串会被当成算术表达式解析:数字字面量直接取值,变量名会被递归展开成它的值,运算符按算术优先级计算,括号可以改变优先级。举几个能说明问题的例子:
declare -i a a=1+2 # a 是 3 a="3 * 4" # a 是 12,字符串里的空格不影响 b=10 a=b+5 # a 是 15,变量名被展开 a=a*2 # a 是 30,右边的 a 取的是当前值 30 之前的值 15最后一行值得单独说一下。a=a*2执行时,先取a的旧值15,算出30,再存回去。所以这种写法是安全的,不会出现"自己引用自己导致未定义"的问题。但如果你是a=a+a,取的也是同一个旧值,结果是30,不是60。这个和很多语言的+=语义一致,不用特别记。
再看几个容易出错的:
declare -i a a=hello # a 变成 0 a= # a 变成 0 a=" 42 " # a 是 42,前后空格被算术解析器忽略a=hello得到0的原因值得讲清楚:在算术表达式里,一个未被赋值的变量名的值是空字符串,空字符串在数值上下文中被当作0。所以hello作为一个变量名被求值,结果是0。如果你开着set -u(未定义变量报错),这一行会直接抛错退出,而不是静默变成 0。这个差异在不同环境的脚本里会造成很不一样的表现,迁移脚本时一定要留意。
还有一类行为是字符串拼接者最容易踩的:-i变量上的字符串拼接会变成数字解读。
declare -i a x=1; y=2 a=$x$y # a 是 12,不是 3,也不是 "12" 这个字符串$x$y展开成12,再作为算术表达式求值,得到数字12。这个结果碰巧"看起来对",所以很多人没意识到中间发生了什么。但如果x是1、y是+2,展开成1+2,结果是3。也就是说,变量的内容会被当代码执行,这一点在后面的安全章节我会展开讲。
2.3 和 let、$(( ))、expr 的横向对比
同样一个加法,Shell 里至少有四种写法。它们不是互相替代的关系,适用场景差别挺大,我做了一张对照表,方便你按场景选:
| 写法 | 是否持久改变量 | 是否走算术求值 | 是否有进程开销 | 典型适用场景 |
|---|---|---|---|---|
declare -i n+n=a+b | 是 | 是 | 无 | 变量在脚本中被反复修改 |
((n = a + b)) | 是 | 是 | 无 | 一次性计算,尤其配合条件判断 |
n=$((a + b)) | 是 | 是 | 无 | 需要把结果赋给普通字符串变量 |
n=$(expr $a + $b) | 是 | 否,靠外部程序 | 有,每次 fork | 兼容极老的 POSIX 环境 |
先说(( ))和declare -i的关系。两者都用同一套算术求值引擎,区别在于(( ))是"每次显式求值",declare -i是"变量自带求值属性"。前者更显式,后者更省字。我自己的习惯是:函数内的临时计算用(( )),跨函数共享的状态变量用declare -i。理由是临时计算写完就完了,显式写出来一目了然;而状态变量会在多处被改,如果每个地方都要求写$(( )),总有人会漏,一旦漏了就是字符串拼接的 bug,而且这种 bug 往往要到数据量变大之后才暴露。用属性来保证,比靠人记规矩可靠得多。
再说expr。这个命令现在基本只在对兼容性有极端要求的场景下才会见到,它的两个硬伤:一是每次调用都要 fork 一个进程,循环里跑几千次要几秒钟;二是它对参数格式很敏感,expr 1 + 2中间必须有空格,expr 1+2会把整个1+2当字符串原样输出。我在一个旧脚本里见过这种写法,因为少了一个空格,整个统计逻辑静默失效,跑了半年没人发现。所以除非你的运行环境连 bash 都没有,否则没有理由继续用它。
关于性能,我在同一台机器上做过一组粗略对比,循环 10000 次累加:
| 方式 | 耗时(约) |
|---|---|
declare -i n+n=n+1 | 0.03 秒 |
((n++)) | 0.03 秒 |
n=$((n+1)) | 0.04 秒 |
n=$(expr $n + 1) | 12 秒以上 |
这个差距不是量级问题,是数量级问题。400 倍的差距,在循环体里放大之后足以让一个本来能秒级完成的脚本跑到让人怀疑是不是卡死了。所以哪怕你不喜欢declare -i,也请用(( )),别再写expr。
3. 动手:把 declare -i 落到脚本里
3.1 最小示例与属性自查
先来个能直接跑的最小例子,把"声明前"和"声明后"的差异并排看清楚:
#!/usr/bin/env bash # 不声明,默认字符串 plain=1 plain=$plain+1 plain=$plain+1 echo "plain = $plain" # 输出 1+1+1 # 声明为整数 declare -i num=1 num=num+1 num=num+1 echo "num = $num" # 输出 3跑完之后用declare -p看一眼两个变量挂的属性,差异一目了然:
declare -p plain num # declare -- plain="1+1+1" # declare -i num="3"declare --后面的两个横线表示没有任何属性,就是普通字符串。这个小技巧我在排查"为什么我的变量没按数字算"时用得最多,因为它能直接告诉你属性到底有没有挂上。很多人写着declare -i n却写在了一个函数里,全局那个变量根本没属性,用declare -p一查立刻现形。
还有一点要提醒:declare -i的声明要放在第一次赋值之前。如果先写了n=1+2,再写declare -i n,那之前的赋值不会追溯重算,n的值仍然是字符串"1+2",只是从声明那一刻起,后续的赋值才会走算术求值。这个顺序问题在新手代码里很常见,尤其是变量分散在脚本各处赋值的时候。
3.2 计数器与循环累加:最主流的用法
declare -i出现频率最高的场景就是计数器。下面是我现在写脚本时的标准套路:
#!/usr/bin/env bash set -euo pipefail declare -i total=0 declare -i ok=0 declare -i fail=0 while IFS= read -r line; do total+=1 if is_valid "$line"; then ok+=1 else fail+=1 fi done < input.txt printf '总计 %d,成功 %d,失败 %d\n' "$total" "$ok" "$fail"这里有几个细节。total+=1在-i属性下等价于total=total+1,比写全更利落。printf里用了%d,这个和declare -i是配套的:既然变量确定是整数,格式化输出时就该用整型占位符,而不是%s。用%s虽然也能打印出来,但一旦变量因为某个分支没被正确处理而变成非数字内容,%d会立刻报错提醒你,%s则会静默输出错误结果。这算是一层额外的校验。
set -euo pipefail这三个选项和declare -i搭配时需要留意-u。前面提过,-i变量如果被赋了一个未定义的变量名,-u会让脚本直接退出。这在调试阶段是好事,能早发现逻辑错误;但在生产脚本里,如果某个变量在特定分支下本来就可能没赋值,就会变成意外中断。我的做法是:所有参与算术的变量都显式初始化,declare -i n=0一句解决,不要指望"没赋值就当 0"这种隐式行为。
还有个小技巧,涉及读取外部文件的数字。如果文件里存的是007这样的值,直接num=$line会有麻烦,原因在第四章详说。稳妥写法是num=10#$line,强制按十进制解析,前导零就被吃掉了。
3.3 函数内的局部整数:local -i 才是正解
一旦涉及函数,declare -i的作用域问题就必须讲清楚。看这段代码:
#!/usr/bin/env bash declare -i counter=0 bump() { declare -i counter=100 counter+=1 echo "函数内: $counter" } bump echo "函数外: $counter"输出是函数内: 101和函数外: 0。原因就是declare在函数内默认创建局部变量,外层那个counter从头到尾没被碰过。如果你的意图是"函数内临时用一下",这个行为正合适;如果意图是"修改全局计数",那就错了。
写函数内的临时整数变量,我更推荐用local -i,语义更明确:
calc() { local -i width=$1 local -i height=$2 local -i area=width*height echo "$area" }local本身就支持-i,和declare -i的行为完全一致,只是更清楚地表达了"这是局部变量"。在函数里用declare容易让读代码的人误以为在操作全局,用local就没有这个歧义。这条不是什么硬性规范,但是个能减少沟通成本的小习惯。
如果确实需要在函数里修改全局变量,两种写法。一是别加declare,直接counter+=1,前提是全局那个counter已经用declare -i声明过,属性会跟着变量走,函数内直接赋值一样走算术求值。二是用declare -g -i counter,显式声明全局。第一种更简洁,我一般用这种;第二种适合在函数里首次创建全局变量的场景,比如初始化函数。
注意:
declare -g需要 bash 4.2 及以上。一些系统自带的老版本 bash 不支持这个选项,写了会报invalid option。脚本里如果用了-g,最好在开头加一个版本检查,或者干脆改成"在顶层声明,函数内直接赋值"的写法,兼容性最好。
3.4 与 readonly、数组的组合拳
declare的属性可以叠加,这让它在定义常量时特别好用:
declare -ir MAX_RETRY=3 declare -ir TIMEOUT=30-i保证是整数,-r保证只读。之后再赋值会报readonly variable,脚本中断。这种方式定义的常量比单纯写MAX_RETRY=3强得多,因为后者在几百行之后被谁改了你根本发现不了。我在所有超过 200 行的脚本里都会这么定义配置常量,实际救过我好几次——有一次在一个分支里手滑写了TIMEOUT=0,脚本立刻报错退出,而不是带着错误的超时值跑了一整晚。
数组也能带整数属性:
declare -ai scores=(0 0 0) scores[0]=10+5 scores[1]=scores[0]*2 echo "${scores[0]} ${scores[1]}" # 15 30declare -ai等于-a(索引数组)加-i(元素为整数),数组的每个元素在赋值时都会走算术求值。这个在处理批量数值时很顺手,比如统计每个目录下的文件数:
declare -ai counts declare -i i=0 for d in */; do counts[i]=$(find "$d" -maxdepth 1 -type f | wc -l) i+=1 done注意counts[i]=...这里的i在数组下标位置会被自动求值,不需要写$i。而等号右边的$(...)结果是一个字符串,赋给整数元素时会被当成算术表达式求值。wc -l的输出通常带前导空格,在算术上下文里空格会被忽略,所以不用额外处理。但如果输出可能为空,-i求值会得到 0,这个行为在这里反而是便利的。
4. 踩坑实录:七类典型问题与排查
4.1 前导零的八进制陷阱
这是declare -i最容易让人栽跟头的地方,没有之一。看这段:
declare -i n n=010 echo "$n" # 输出 8,不是 10 n=08 echo "$n" # 直接报错原因在于 bash 的算术求值遵循 C 语言的整数常量规则:以0开头的数字按八进制解析。所以010是八进制的 10,等于十进制的 8;而08在八进制里是不合法的(八进制只有 0 到 7),解析器直接报value too great for base。
这个问题最阴险的触发方式是从外部读取数据。比如你从 CSV 里读日期字段20240315,从某个配置文件读端口号0080,从文件名里提取序号007,这些值一旦被赋给-i变量,就会按八进制解释,得出一个莫名其妙的数。我在一个处理月份字段的脚本里遇到过:读进来的08月直接让脚本挂了,前七个月都跑得好好的,到八月突然崩,排查了半天才反应过来是八进制。
解决办法很明确,用10#前缀强制指定进制:
declare -i n raw="08" n=10#$raw echo "$n" # 输出 8,正确10#告诉解析器"后面的数字按十进制读",前导零就没意义了。这个前缀写在算术表达式里也有效,比如n=10#$a + 10#$b。我的习惯是在所有"从外部读入数字再参与计算"的地方,一律加上10#,宁可多写几个字符,也不要留一个只有在特定月份才爆炸的定时炸弹。
提示:前导零问题不只出现在
declare -i,$(( ))、(( ))、let全都一样,因为它们用的是同一个算术求值器。但declare -i的问题在于它是隐式的——你写n=$raw的时候完全看不出这里会发生进制转换,而((n = $raw))至少还提醒了你"我在做算术"。这也是用declare -i时需要额外留心的原因。
4.2 盲目信任外部输入:算术求值会执行命令替换
这一条属于安全意识层面,但确实是我在代码评审里见过的问题。先说结论:不要把不受控的外部输入直接赋给-i变量。原因是算术求值支持命令替换和变量递归展开,右边的字符串会被当成表达式解析,而表达式里可以有$(...)。
看这个演示:
declare -i n=0 input='1+$(echo injected)' n=$input echo "$n"输出会是injected加1的求值过程,中间的命令替换被执行了。这还只是打印,如果输入是1+$(rm -rf some_dir),后果就不用我说了。同样的风险存在于$(( ))和(( )),不是declare -i独有的,但declare -i的隐式性让这个风险更隐蔽——你写n=$input,看起来只是"赋值",实际上"执行"了。
防御的办法有三条,按严格程度排序。第一,如果输入来源可控(脚本内部的常量、可信的配置),不必过度紧张,正常用就行。第二,如果输入来自外部但格式明确,先用case或正则校验格式,只允许纯数字通过:
if [[ $input =~ ^-?[0-9]+$ ]]; then n=$input else echo "非法输入" >&2 exit 1 fi第三,最稳妥的是不走算术求值,先清洗再赋值。比如用参数展开去掉非数字字符,或者用printf '%d'做一次转换。我的实际做法是:所有跨信任边界的数据,在进入-i变量之前必须过一遍格式校验,这条已经写进我们内部脚本的规范里了。校验的代码只有三行,但它拦掉的不只是安全问题,还有各种因为脏数据导致的诡异计算错误。
4.3 浮点数、除零与溢出
declare -i只处理整数,遇到小数会直接报错,不会静默截断:
declare -i n n=1.5 # bash: 1.5: syntax error: invalid arithmetic operator这个报错其实比静默截断好——至少你知道出错了。但如果你在set -e的脚本里,这一行会让整个脚本退出,所以涉及除法的场景要特别小心。比如计算平均值avg=total/count,bash 的整数除法是向零取整,7/2得3,-7/2得-3。如果你需要四舍五入或者保留小数,得自己处理:
declare -i total=7 count=2 declare -i avg=(total * 100 / count + 50) / 100 # 四舍五入到整数 echo "$avg" # 4这里的思路是"先放大再缩回",中间加 50 实现四舍五入。这个技巧在处理百分比、评分这类场景时很常用,虽然不如真正的浮点精确,但避免了引入外部计算工具。
除零会直接报错并中断:
declare -i n n=10/0 # bash: 10/0: division by 0需要注意的是报错之后变量的值。在我的测试里,赋值语句失败后变量保持原值不变,而不是清零。所以如果你的逻辑依赖"报错后变量会变成 0",那就是错的。正确做法是在除法之前显式检查分母:
if (( count > 0 )); then avg=total/count else avg=0 fi溢出是另一个容易被忽略的问题。bash 的整数是 64 位有符号的,范围大概在正负九乘十的十八次方。一般的脚本计数根本碰不到这个边界,但如果涉及字节数累计、大数乘法,是有可能越界的。越界后不会报错,而是回绕成负数,这种错误极难排查。我的应对是:涉及大数累计时,定期用printf打印中间值做核对,或者干脆改用awk处理,它有浮点和大数支持,比在 shell 里硬扛省心。
4.4 作用域、子 shell 与 set -u 的连锁反应
前面提过函数内declare默认局部的问题,这里补充另一个更隐蔽的场景:管道和子 shell。
declare -i count=0 cat file.txt | while read -r line; do count+=1 done echo "$count" # 输出 0这个现象让无数人困惑过。原因不在declare -i,而在管道:管道的每一段都在子 shell 里执行,while循环里的count+=1改的是子 shell 的副本,循环结束子 shell 退出,父 shell 的count还是 0。解决办法是用进程替换或者重定向:
while read -r line; do count+=1 done < file.txt echo "$count" # 正确这个坑和整数属性无关,但因为它经常和计数器一起出现,我把它放在这里一起说。判断方法很简单:如果你发现"循环里的计数最后是 0",先怀疑子 shell,用echo $$在循环内外各打一次,PID 不同就是子 shell 问题。
set -u的连锁反应前面提过一次,这里补一个具体例子:
set -u declare -i n echo "$n" # bash: n: unbound variabledeclare -i n创建了变量但没赋值,在set -u下引用它会报未绑定。所以我的写法统一是declare -i n=0,永远带初始值。多打两个字符,省掉一类问题。
4.5 兼容性:不是所有 shell 都认识 declare
declare是 bash 的内建命令,sh(很多系统上指向dash)里没有这个命令,写了会报declare: not found。同样地,local -i在部分精简 shell 里也不支持。所以如果你的脚本头写的是#!/bin/sh,就别用declare;反过来,如果脚本里用了declare,脚本头必须明确写成#!/usr/bin/env bash,不要写#!/bin/sh。
还有一个容易忽略的点:一些系统自带的 bash 版本比较老,不支持declare -g、不支持-A(关联数组),declare -i倒是基本都有。如果你要把脚本分发到不确定版本的环境,稳妥做法是在脚本开头做一次能力检测:
if ! declare -i __probe=0 2>/dev/null; then echo "需要 bash" >&2 exit 1 fi这行探测很便宜,但能避免脚本在错误的环境里跑出一堆莫名其妙的错误。
4.6 问题速查表
把上面这些整理成一张表,出问题时按症状对号入座:
| 症状 | 最可能的原因 | 排查方法 | 处理方式 |
|---|---|---|---|
变量值变成1+1+1 | 没加-i属性,走的是字符串拼接 | declare -p 变量名看有没有-i | 在首次赋值前加declare -i |
08报 value too great | 前导零被按八进制解析 | 打印原始输入看是否有前导零 | 用10#前缀强制十进制 |
| 函数里改了,外面没变 | declare在函数内默认创建局部变量 | 在函数内外各declare -p一次 | 改declare -g -i或函数内直接赋值 |
| 循环计数最后是 0 | 管道导致子 shell | 循环内外打印$$对比 | 改用重定向或进程替换 |
| 赋小数直接报错 | -i不支持浮点 | 看报错信息里的syntax error | 改用awk或放大整数倍 |
set -u下报 unbound | 声明了但没初始化 | 看报错变量名 | 声明时给初始值=0 |
脚本在某些机器上报declare: not found | 用的是 sh/dash | 看脚本首行 shebang | 改成#!/usr/bin/env bash |
| 赋值后变量莫名变了 | 右边表达式被算术解析 | 检查右边内容是否含运算符 | 加格式校验,或改用普通变量 |
这张表我建议直接存到自己的笔记里。实际排查的时候,先看症状落在哪一行,基本三步之内能定位。
5. 工程化取舍:什么时候该用,什么时候绕开
5.1 适用场景清单
declare -i不是万能的,我给它划了个适用范围,超过这个范围就换工具。
适合用的场景:跨函数共享的计数器(前面反复举的例子)、脚本顶层的配置常量(配合-r)、循环里的索引和统计累加、需要做位运算的掩码和标志位、从文件读取的确定性数字字段(配合10#和格式校验)。
不适合用的场景:任何涉及小数的计算,用awk或bc更合适;超大数运算(超过 64 位),shell 里硬扛没有意义;需要精确金额计算的场景,浮点误差和整数溢出都会咬人,应该换成"以分为单位存整数"的表示法;输入完全不可信的边界数据,除非有严格校验,否则不要直接进-i变量。
有一个场景我要特别说明:日期和时间的处理。很多人喜欢把日期存成20240315这样的整数格式,然后做加减。这样单个日期本身没问题,但一旦涉及"加一天"、"算间隔天数",就会遇到月末、闰年这些边界,靠整数加减兜不住。这类需求老老实实用date -d处理,别在declare -i上硬套。
5.2 代码规范:我在项目里定的三条约定
用得久了,自然形成了一些约定,写出来供参考。
第一条,声明集中在脚本头部。所有全局的整数变量在脚本开头一块声明完,函数里用local -i。这样做的好处是任何人打开脚本,前二十行就能看到全部状态变量,不用在几百行里翻找。我见过反过来的写法,变量在用到的地方随手声明,结果同一个名字在两处声明了不同类型,排查起来极其痛苦。
第二条,声明必带初始值。declare -i n=0而不是declare -i n。理由前面说过,主要是为了和set -u配合,顺带也避免了"未初始化被当成 0"这种隐式行为带来的歧义。
第三条,外部输入先校验再赋值。所有从文件、参数、命令输出拿到的数字,先过[[ =~ ^-?[0-9]+$ ]],通过了才进-i变量。这三行校验代码在脚本里出现几十次,看着有点啰嗦,但它拦下的问题比它能带来的麻烦多得多。
除了这三条,还有一个习惯值得一提:不要把-i变量当字符串用。有些人声明了整数变量,又用它存"N/A"这类占位符,结果被求值成 0,然后误判成"数量为零"。这属于属性滥用。整数变量就只装整数,需要表示"无值"时另设一个布尔标志位,或者用空字符串的普通变量,职责分清楚。
5.3 一个真实的重构过程
最后讲一个我最近做的重构,把上面这些串起来。原来有个日志清理脚本,逻辑是遍历目录、统计超过保留期的文件、按大小排序、删除。原始版本用的是纯字符串变量加expr,跑一次要十几秒,而且偶尔会删错。重构之后的骨架大概是这样:
#!/usr/bin/env bash set -euo pipefail declare -ir KEEP_DAYS=30 declare -ir MAX_DELETE=1000 declare -i total=0 declare -i expired=0 declare -i deleted=0 scan() { local -i days=$1 local -i cutoff cutoff=$(date -d "-${days} days" +%s) local f mtime while IFS= read -r -d '' f; do total+=1 mtime=$(stat -c %Y -- "$f") if (( mtime < cutoff )); then expired+=1 fi done < <(find . -type f -print0) } scan "$KEEP_DAYS" printf '扫描 %d 个文件,过期 %d 个\n' "$total" "$expired" if (( expired > MAX_DELETE )); then echo "过期文件数量超过阈值,中止" >&2 exit 1 fi这里几个细节值得说一下。cutoff用local -i声明,因为它是函数内的临时值;total和expired是全局状态,在顶层声明,函数内直接+=1,属性跟着走,不需要再写declare。find用进程替换< <(...)而不是管道,避开了子 shell 问题,否则计数永远是 0。mtime < cutoff写在(( ))里,这里用(( ))而不是赋值,是因为它是个条件判断,用if (( ))语义最清楚。
重构前的版本用的是expr和字符串比较,跑完还要sort | uniq去重,实际耗时主要花在 fork 上。改完之后同样的数据量,耗时从十几秒降到一秒以内,而且逻辑清楚了很多——每个变量的类型从声明就能看出来,不用读到使用处才猜。
心得:重构这类脚本时,我一般不会一次性全改。先把最热的循环体里的
expr换掉,跑一遍验证结果一致;再把状态变量加上-i声明,再跑一遍;最后处理边界情况(前导零、空输入、除零)。每一步都验证,出问题能立刻定位到是哪一步引入的。一次性大改看着痛快,但一旦结果不对,你面对的是一整个文件,排查成本高得多。
这套流程走下来,我对declare -i的定位也清晰了:它不是什么高级技巧,就是把"这个变量是整数"这件事写进代码,让 shell 替你守住。它的边界很明确,超出整数范围就该换工具;它的坑也很集中,前导零、作用域、外部输入这三类,记住就能避开。真正难的不是学会这个语法,而是在写脚本的时候,脑子里始终有一根弦:Shell 变量的默认身份是字符串,任何数字感都需要你主动去建立。想明白这一点,后面很多看起来莫名其妙的 bug 都能解释得通了。