ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Shell变量与字符串深度解析:从原理到实战避坑指南

Shell变量与字符串深度解析:从原理到实战避坑指南 1. 为什么值得花时间啃透变量和字符串很多人学 shell 脚本的经历都差不多网上找一段能跑的代码改改变量名能用就行。直到某天脚本在生产环境里把文件删错了、路径里带了个空格整个循环就崩了、变量没加引号导致参数被拆得七零八落才开始回头补基础。我见过太多人卡在这一步——不是不会写for循环而是不知道$var和$var之间那道引号到底差在哪。变量和字符串就是 shell 脚本的地基。这两个东西没搞明白后面学函数、学数组、学流程控制全是空中楼阁。你搜shell脚本for循环、shell中常见坑、grep在shell脚本中的常见用法翻来覆去遇到的问题根子上八成都能追溯到变量展开和字符串处理上。这篇内容适合谁看写过几行脚本但总踩坑的运维、测试、后端同学从 Python、Java 转过来觉得 shell 语法反直觉的开发者还有那些被adb shell、vcenter server 进入shell这类场景逼着要写脚本、但没系统学过的人。我会把变量和字符串这两块从原理到实操掰开揉碎讲一遍重点放在为什么这么设计和实际会踩什么坑上而不是罗列语法手册。先给个心理预期shell 的变量模型和大多数编程语言不一样它本质上是字符串的容器没有类型系统至少 POSIX shell 没有所有东西都是文本。理解这一点后面很多奇怪的行为就都说得通了。比如python变量可以随便赋值数字、列表、字典但 shell 里a1和a1在存储层面没区别只有在参与运算时才会被临时解释成数字。这个设计哲学贯穿始终记住它。2. 变量从赋值到作用域的完整拆解2.1 赋值的三条铁律和背后的原因shell 变量赋值看起来简单namevalue就完事了但这里有三条铁律违反了就报错或者行为诡异。第一条等号两边绝对不能有空格。a 1会报command not found因为 shell 把a当成了命令名和1当成了参数。为什么这么设计因为 shell 的解析器是先做词法分割再判断语法的a 1被切成三个 token第一个 token 是ashell 就去找有没有叫a的命令。这是 shell 作为命令解释器而非编程语言的历史遗留它首先是个命令行工具其次才是脚本语言。第二条变量名只能包含字母、数字、下划线且不能以数字开头。1var1是非法的。这个和 C 语言、Python 的规则一致没什么好说的但要注意 shell 里大小写敏感Var和var是两个不同的变量别混用。第三条赋值时不做单词分割和通配符展开。a*不会把*展开成当前目录的文件列表a就是字面的星号。但一旦你echo $a展开就发生了。这个区别极其重要后面讲引号的时候会反复用到。# 正确 namehello world count42 path/usr/local/bin # 错误示范 name hello # command not found 1count42 # 非法变量名2.2 环境变量、局部变量和 export 的真实作用很多人搞不清export到底干了什么。简单说不加 export 的变量只在当前 shell 进程里可见加了 export 的变量会传递给子进程。举个例子你写了个脚本parent.sh#!/bin/bash foobar export bazqux ./child.shchild.sh里echo $foo是空的echo $baz能打印出qux。原因就是foo没有导出子进程继承不到。这个机制在写多脚本协作、调用外部命令时特别关键。比如你设了JAVA_HOME但忘了 export然后调用mvnmvn 就找不到 Java——因为它是个子进程看不到你没导出的变量。提示export VARvalue和VARvalue; export VAR效果一样前者更简洁。已经赋值的变量可以用export VAR单独导出。还有个容易忽略的点子进程修改环境变量不会影响父进程。这是进程隔离的基本规则但在脚本里经常有人期望我在函数里改了全局变量外面应该能看到——如果这个函数是在子 shell 里跑的比如$(...)或管道那就看不到。这个坑后面讲作用域时会展开。2.3 变量展开的几种形态和实战选择shell 的变量展开远不止$var一种。${var}、${var:-default}、${var:default}、${var:?error}、${#var}、${var#pattern}这一套是 shell 脚本区别于其他语言的核心竞争力。用好了能省掉大量 if 判断。语法含义典型场景${var}基本展开界定变量名边界echo ${var}s避免把 s 当成变量名一部分${var:-default}var 未设置或为空时用 default读取可选配置项${var:default}同上但会把 default 赋给 var初始化默认值${var:?msg}var 未设置时报错退出强制要求必填参数${#var}字符串长度校验输入长度${var#pattern}从头删除最短匹配去掉路径前缀${var##pattern}从头删除最长匹配取文件名${var%pattern}从尾删除最短匹配去掉扩展名${var%%pattern}从尾删除最长匹配取目录名这套语法我第一次见的时候觉得反人类#和%记不住。后来发现有个记忆窍门键盘上#在$左边%在$右边所以#管开头、%管结尾。单符号是最短匹配双符号是最长匹配。实战里最常用的是${var:-default}和${#var}。比如写个部署脚本允许用户通过环境变量覆盖默认端口PORT${DEPLOY_PORT:-8080} echo 使用端口: $PORT如果用户设了DEPLOY_PORT9090就用 9090没设就用 8080。比写if [ -z $DEPLOY_PORT ]; then ...干净多了。2.4 作用域为什么你的函数改了变量外面没变shell 的变量作用域规则和主流语言差异很大这是新手最容易翻车的地方。默认情况下shell 变量是全局的。你在脚本任何地方赋值整个脚本都能看到。但有两个例外函数里用local声明的变量以及子 shell 里的修改。#!/bin/bash counter0 increment() { local counter10 # 局部变量不影响外面 echo 函数内: $counter } increment echo 函数外: $counter # 还是 0如果去掉local函数内的counter10会改掉全局的。这个行为在写复杂脚本时很危险所以函数内的变量尽量都加 local除非你明确想改全局。更隐蔽的坑是子 shell。管道、命令替换$(...)、(...)都会创建子 shell子 shell 里的变量修改不会传回父 shellcount0 echo a b c | while read -r item; do count$((count 1)) done echo count $count # 输出 0不是 3这是 shell 脚本里最经典的坑之一。解决办法有几种用here-string 代替管道、用进程替换 (...)、或者把结果通过标准输出传出来再赋值。我一般推荐最后一种逻辑最清晰count$(echo a b c | tr \n | wc -l)注意while read配合管道是重灾区写循环统计的时候一定要留意变量作用域。这个坑我在生产脚本里踩过至少三次。3. 字符串引号、拼接与那些反直觉的行为3.1 单引号、双引号、无引号的三国演义shell 里字符串的处理核心就一句话引号决定了哪些字符有特殊含义。三种写法对应三种完全不同的行为必须刻进肌肉记忆。无引号最危险。变量会被单词分割按 IFS 拆分通配符会展开连续空格会被压缩。echo $var如果 var 是a b两个空格输出会变成一个空格。双引号保留字面值但允许变量展开、命令替换、转义。$var是最常用的形式能防止单词分割和通配符展开同时保留变量替换。单引号完全字面值里面的一切都不解释。$var就是字面的$var不会替换。nameworld echo hello $name # hello world echo hello $name # hello $name echo hello $name # hello world但如果有空格就出问题为什么无引号这么危险看这个例子filemy document.txt rm $file # 等价于 rm my document.txt删两个文件 rm $file # 正确删一个文件文件名带空格是运维日常不加引号就是灾难。我的习惯是只要变量可能包含空格或特殊字符一律加双引号。宁可多打两个字符也不要半夜被叫起来恢复数据。3.2 字符串拼接的三种姿势shell 没有专门的拼接运算符拼接靠相邻即连接firsthello lastworld # 方式一直接相邻 full$first $last # hello world # 方式二花括号界定边界 full${first}${last} # helloworld # 方式三printf 格式化 full$(printf %s-%s $first $last) # hello-world方式一最直观但要注意变量名边界。$firstworld会被解析成变量firstworld而不是first加world。这时候必须用${first}world。这个坑在拼接路径、生成文件名时特别常见。方式三printf适合需要格式化控制的场景比如补零、对齐。printf %05d 42输出00042比手写循环补零优雅得多。3.3 字符串长度、截取和替换的实战用法${#var}取长度前面提过了这里补充几个高频操作。截取子串${var:offset:length}。offset 从 0 开始length 可省略表示到结尾。负数 offset 表示从末尾算起注意冒号后要有空格否则和默认值语法冲突。strabcdefgh echo ${str:2:3} # cde echo ${str: -3} # fgh注意空格替换${var/old/new}替换第一个匹配${var//old/new}替换全部。path/usr/local/bin:/usr/bin:/bin echo ${path//:/ } # 把冒号全换成空格这个在解析 PATH、处理分隔符列表时特别好用比tr或sed轻量。大小写转换bash 4.0${var^^}转大写${var,,}转小写。inputHello World echo ${input,,} # hello world处理用户输入、做不区分大小写的比较时很方便。但要注意 macOS 自带的 bash 是 3.2 版本不支持这个语法跨平台脚本要小心。3.4 字符串比较为什么和都能用但推荐后者字符串比较用[ ]或[[ ]]。POSIX 标准里字符串相等用是 bash 扩展。在[[ ]]里两者都行但在[ ]里用可能报错。为了可移植性统一用。if [ $a $b ]; then echo 相等 fi注意变量一定要加引号。如果$a是空的[ $a $b ]会变成[ $b ]报语法错误。加引号后变成[ $b ]安全。[[ ]]比[ ]强的地方在于支持模式匹配和正则if [[ $file *.txt ]]; then echo 是文本文件 fi if [[ $input ~ ^[0-9]$ ]]; then echo 纯数字 fi~正则匹配是处理输入校验的利器。判断字符串是否是数字、是否是合法 IP、是否符合某种格式一行搞定。但要注意正则不要加引号加了就变成字面匹配了。提示[[ ]]是 bash 和 zsh 的关键字不是命令所以里面的变量不加引号也不会单词分割。但为了习惯统一我还是建议加引号。4. 实操从零写一个健壮的参数处理脚本4.1 需求拆解和设计思路光讲语法没意思我们写个实际能用的脚本一个文件批量重命名工具接收目录、匹配模式、替换规则三个参数把匹配的文件重命名。这个需求覆盖了变量默认值、字符串截取、模式匹配、循环、错误处理是个很好的综合练习。设计要点参数用${var:-default}提供默认值目录默认当前目录用${var:?msg}强制要求替换规则必填用[[ ]]做参数校验用for循环遍历文件注意处理带空格的文件名用${var//old/new}做字符串替换重命名前检查目标是否存在避免覆盖4.2 完整脚本和逐段解析#!/bin/bash set -euo pipefail # 参数解析 target_dir${1:-.} pattern${2:?用法: $0 目录 匹配模式 替换规则} replacement${3:?缺少替换规则} # 校验目录存在 if [[ ! -d $target_dir ]]; then echo 错误: $target_dir 不是目录 2 exit 1 fi # 统计变量 renamed0 skipped0 # 遍历匹配文件 shopt -s nullglob for file in $target_dir/$pattern; do basename$(basename $file) newname${basename//$pattern/$replacement} if [[ $basename $newname ]]; then ((skipped)) continue fi newpath$target_dir/$newname if [[ -e $newpath ]]; then echo 跳过: $newname 已存在 2 ((skipped)) continue fi mv -- $file $newpath echo 重命名: $basename - $newname ((renamed)) done echo 完成: 重命名 $renamed 个, 跳过 $skipped 个逐段说几个关键点。set -euo pipefail是脚本健壮性的第一道防线。-e让命令失败时立即退出-u让使用未定义变量时报错-o pipefail让管道中任一命令失败就返回失败。这三个选项能挡掉大量隐蔽 bug。但要注意-e在((...))算术表达式返回 0 时会误判所以((renamed))这种写法在set -e下第一次执行会退出——因为renamed从 0 变 1 时((0))返回的是旧值 0被当成失败。解决办法是写成((renamed)) || true或者用renamed$((renamed 1))。我在上面脚本里用了后者更安全。shopt -s nullglob让没有匹配文件时 glob 展开为空而不是保留字面模式。不加这个for file in *.txt在没有 txt 文件时会循环一次file 的值是字面的*.txt然后 mv 就报错了。basename和${var//old/new}配合先取文件名再做替换。注意替换用的是$pattern作为匹配串这里假设 pattern 是简单字符串而非正则。如果要支持正则得用sed或rename命令。mv --里的--是防止文件名以-开头被当成选项。这个细节很多人不知道但处理用户提供的文件名时很重要。4.3 测试和边界情况验证写完脚本必须测边界。我一般会准备这几类测试用例测试场景输入预期行为正常重命名目录含 a.txt b.txt模式 txt替换 md都改成 .md文件名带空格my file.txt正确处理不拆分目标已存在a.txt 和 a.md 都在跳过 a.txt提示已存在无匹配文件空目录不报错输出 0 个目录不存在/nonexist报错退出缺参数只给目录报错提示用法带空格的文件名是最容易翻车的。上面脚本里for file in $target_dir/$pattern中$pattern故意不加引号是为了让 glob 展开但$target_dir加了引号防止目录名带空格出问题。这个部分加引号的写法是 shell 里处理 glob 的标准姿势需要理解每个引号的作用。5. 常见坑速查和排查思路5.1 变量相关的经典翻车现场坑一变量未定义导致脚本行为异常。set -u能挡住大部分但有些场景你确实想用空值。这时候用${var:-}显式给空默认值。坑二命令替换丢失换行。$(cat file)会把末尾的换行去掉如果文件内容对换行敏感比如做校验和要用$(cat file; echo x)再截掉最后那个 x或者直接读文件。坑三算术运算里的前导零。$((08 1))会报错因为08被当成八进制。解决办法是用10#$var强制十进制$((10#$var 1))。处理日期、编号时特别容易遇到。坑四$?被覆盖。$?保存上一条命令的退出码但任何命令都会覆盖它包括echo。想检查某条命令的结果必须紧接着检查中间不能插其他命令。5.2 字符串处理的排查清单遇到字符串行为不符合预期按这个顺序排查变量加引号了吗先加双引号试试是单引号还是双引号单引号里变量不展开变量名边界清楚吗用${var}明确界定有特殊字符吗*、?、[在无引号时会展开是子 shell 吗管道和$()里的修改不传回父 shellbash 版本支持这个语法吗${var^^}需要 4.0这个清单我贴在显示器边上好几年了排查字符串问题基本能覆盖九成情况。5.3 调试技巧让脚本自己说话bash -x script.sh是最有用的调试手段会打印每条命令展开后的实际执行形式。变量到底展开成了什么、引号有没有生效一目了然。更精细的做法是在脚本里临时插set -x和set x只对可疑段落开启追踪set -x result${input//foo/bar} set x还有个技巧是用${varQ}bash 4.4打印变量的引号转义形式能看清变量里到底有什么不可见字符var$a\tb echo ${varQ} # 输出 $a\tb能看出制表符处理从文件读入、从命令输出捕获的字符串时这个特别有用能发现隐藏的\r、\t等字符。6. 我个人在实际操作中的几点体会写了这么多年 shell关于变量和字符串有几个体会是文档里不会写的。第一引号不是可选项是默认项。我现在的习惯是写完变量先加引号再考虑要不要去掉。这个顺序能避免 90% 的单词分割问题。刚开始觉得麻烦形成肌肉记忆后就自然了。第二[[ ]]优先于[ ]。除非要兼容 POSIX sh比如某些精简的容器环境否则一律用[[ ]]。它更安全、功能更强、语法更宽容。判断脚本要不要用[[ ]]看 shebang 是#!/bin/bash还是#!/bin/sh就行。第三字符串操作优先用 shell 内置语法而不是外部命令。${var//old/new}比echo $var | sed s/old/new/g快一个数量级因为不用 fork 子进程。在循环里处理大量字符串时这个差异非常明显。我做过测试处理一万行文本内置替换比 sed 快 5 到 10 倍。第四变量命名要有意义但别太长。shell 脚本通常不长cfg、dst、src这种缩写足够清晰。但d、x、tmp1这种就过分了过一个月自己都看不懂。第五善用local和readonly。函数内变量加local常量加readonly能挡掉很多意外修改。readonly CONFIG_FILE/etc/app.conf之后任何试图改它的操作都会报错相当于给自己上了个保险。这套东西学下来你会发现 shell 脚本的坑其实都有规律本质上是它作为命令解释器的历史包袱和字符串模型的特殊性造成的。理解了这两点剩下的就是熟练度问题。变量和字符串吃透了后面学数组、函数、流程控制会顺很多因为那些东西的底层还是字符串在支撑。
返回列表