跳到主要内容

Shell 扩展

Shell 扩展(Shell Expansions)是 Bash 处理命令的核心机制。当你输入一条命令后,Shell 会按照特定的顺序对命令进行一系列的扩展和替换,最终生成实际执行的命令。理解 Shell 扩展的执行顺序和各种扩展类型,对于编写正确、高效的脚本至关重要。

扩展执行顺序

Bash 按照以下顺序执行扩展:

  1. 大括号扩展(Brace Expansion)
  2. 波浪号扩展(Tilde Expansion)
  3. 参数和变量扩展(Parameter and Variable Expansion)
  4. 命令替换(Command Substitution)
  5. 算术扩展(Arithmetic Expansion)
  6. 进程替换(Process Substitution)
  7. 词分割(Word Splitting)
  8. 文件名扩展(Filename Expansion)

理解这个顺序非常重要,因为前面扩展的结果可能会影响后面的扩展。例如,变量扩展的结果会被词分割处理,而大括号扩展的结果则不会。

# 例子:理解扩展顺序
var="a b c"
echo {$var} # 输出:{a b c}(大括号扩展先执行,此时 $var 还未展开)
echo $var # 输出:a b c

# 大括号扩展不会递归执行
files="*.txt"
echo {files} # 输出:{files},不是 *.txt 的文件列表

大括号扩展

大括号扩展是一种生成任意字符串的机制。它在所有扩展中最先执行,结果不会被词分割。

枚举扩展

使用逗号分隔的列表生成多个字符串:

# 基本用法
echo {a,b,c} # 输出:a b c
echo {red,green,blue} # 输出:red green blue

# 带前缀和后缀
echo file.{txt,md,sh} # 输出:file.txt file.md file.sh
echo {a,b,c}.txt # 输出:a.txt b.txt c.txt
echo /usr/{bin,lib,src} # 输出:/usr/bin /usr/lib /usr/src

范围扩展

使用 .. 生成连续序列:

# 数字范围
echo {1..5} # 输出:1 2 3 4 5
echo {10..1} # 输出:10 9 8 7 6 5 4 3 2 1(倒序)

# 字母范围
echo {a..e} # 输出:a b c d e
echo {A..E} # 输出:A B C D E

# 带步长(Bash 4.0+)
echo {1..10..2} # 输出:1 3 5 7 9(奇数)
echo {10..1..2} # 输出:10 8 6 4 2(偶数)
echo {a..z..3} # 输出:a d g j m p s v y

零填充

当范围以 0 开头时,Bash 会自动填充零:

echo {01..10}             # 输出:01 02 03 04 05 06 07 08 09 10
echo {001..010} # 输出:001 002 003 004 005 006 007 008 009 010

嵌套扩展

大括号扩展可以嵌套:

echo {a,b}{1,2}           # 输出:a1 a2 b1 b2
echo {a,b,c}{1..3} # 输出:a1 a2 a3 b1 b2 b3 c1 c2 c3

# 复杂嵌套
echo {{a,b},{1,2}} # 输出:a b 1 2
echo {a,b}{c,d}{e,f} # 输出:ace acf ade adf bce bcf bdf

实际应用

# 创建目录结构
mkdir -p project/{src,lib,tests,docs}/{main,utils}

# 批量创建文件
touch file{1..5}.txt

# 备份文件
cp script.sh{,.bak} # 复制为 script.sh.bak

# 批量重命名
for file in img{1..10}.png; do
mv "$file" "photo_${file#img}"
done

# 批量下载
curl -O https://example.com/file{1..5}.tar.gz

# 批量测试
for port in {8080..8085}; do
(echo > /dev/tcp/localhost/$port) 2>/dev/null && echo "Port $port is open"
done

注意事项

# 大括号扩展不会匹配文件
echo {a,b,c} # 即使 a, b, c 文件不存在也会输出
echo *.txt # 如果没有匹配文件,输出 *.txt(取决于 shopt -s nullglob)

# 避免与参数扩展冲突
echo ${var} # 这是参数扩展
echo {var} # 这不是大括号扩展(没有逗号或 ..)
echo {a,b}$var # 大括号扩展和参数扩展结合

# 引号会阻止大括号扩展
echo "{a,b,c}" # 输出:{a,b,c}
echo '{a,b,c}' # 输出:{a,b,c}

波浪号扩展

波浪号 ~ 在 Shell 中有特殊含义,主要用于表示用户主目录。

基本用法

# 当前用户的主目录
echo ~ # 输出:/home/username(或 /Users/username on macOS)
cd ~ # 切换到主目录

# 指定用户的主目录
echo ~root # 输出:/root
echo ~daemon # 输出:/usr/sbin(取决于系统)

特殊变量形式

# 当前工作目录
echo ~+ # 等同于 $PWD
cd /tmp && echo ~+ # 输出:/tmp

# 上一个工作目录
echo ~- # 等同于 $OLDPWD
cd /var && cd /tmp && echo ~- # 输出:/var

目录栈操作

如果启用了 autocd 或使用 pushd/popd,波浪号可以访问目录栈:

# 启用目录栈
shopt -s cdable_vars

# 目录栈中的第 N 个目录
echo ~+0 # 当前目录
echo ~+1 # 目录栈中的下一个目录
echo ~-1 # 目录栈中的上一个目录

实际应用

# 快速访问配置文件
vim ~/.bashrc
vim ~/.config/nvim/init.vim

# 操作用户目录
cp file.txt ~/Documents/
mv file.txt ~backup/ # 移动到 backup 用户的主目录

# 在脚本中使用
backup_dir=~/backups
mkdir -p "$backup_dir"

参数和变量扩展

参数扩展是 Shell 中最常用的扩展形式,用于访问变量的值。

基本形式

# 获取变量值
name="John"
echo $name # 输出:John
echo ${name} # 输出:John(推荐,更清晰)

# 变量名边界
echo "${name}s" # 输出:Johns
echo "$names" # 输出为空(查找 names 变量)

默认值

# ${var:-default}:如果 var 未定义或为空,返回 default
echo "${name:-World}" # 输出:John
echo "${unset_var:-World}" # 输出:World

# ${var:=default}:如果 var 未定义或为空,赋值并返回
unset var
echo "${var:=default}" # 输出:default
echo "$var" # 输出:default

# ${var:+alternative}:如果 var 已定义且非空,返回 alternative
name="John"
echo "${name:+Hello}" # 输出:Hello
echo "${unset:+Hello}" # 输出为空

# ${var:?message}:如果 var 未定义或为空,打印错误并退出
echo "${required:?变量必须设置}" # 如果未设置,脚本退出

字符串操作

str="Hello, World!"

# 获取长度
echo ${#str} # 输出:13

# 子字符串(从 0 开始,注意:这是字符索引,不是字节)
echo ${str:0:5} # 输出:Hello
echo ${str:7} # 输出:World!
echo ${str: -6} # 输出:World!(注意空格,负数表示从末尾开始)

# 删除前缀(# 最短,## 最长)
path="/usr/local/bin/script.sh"
echo ${path#*/} # 输出:usr/local/bin/script.sh
echo ${path##*/} # 输出:script.sh

# 删除后缀(% 最短,%% 最长)
filename="backup.tar.gz"
echo ${filename%.*} # 输出:backup.tar
echo ${filename%%.*} # 输出:backup

# 替换(/ 第一个,// 全部)
str="Hello, World! World!"
echo ${str/World/Shell} # 输出:Hello, Shell! World!
echo ${str//World/Shell} # 输出:Hello, Shell! Shell!

# 替换开头和结尾
str="Hello World"
echo ${str/#Hello/Hi} # 输出:Hi World
echo ${str/%World/Shell} # 输出:Hello Shell

大小写转换

str="Hello, World!"

# 转大写
echo ${str^^} # 输出:HELLO, WORLD!
echo ${str^} # 输出:Hello, World!(首字母大写)

# 转小写
echo ${str,,} # 输出:hello, world!
echo ${str,} # 输出:hello, World!(首字母小写)

# 模式匹配转换
str="hello world"
echo ${str^h} # 输出:Hello world(匹配 h 则大写)
echo ${str^^[aeiou]} # 输出:hEllO wOrld(只大写元音)

变量间接引用

# 使用 ${!var} 进行间接引用
name="John"
var="name"
echo ${!var} # 输出:John

# 获取以特定前缀开头的变量名
prefix="BASH"
echo ${!prefix*} # 输出:BASH BASHOPTS BASHPID ...
echo ${!prefix@} # 同上

数组操作

arr=(a b c d e)

# 访问元素
echo ${arr[0]} # 输出:a
echo ${arr[-1]} # 输出:e(最后一个元素)

# 所有元素
echo ${arr[@]} # 输出:a b c d e
echo ${arr[*]} # 输出:a b c d e

# 数组长度
echo ${#arr[@]} # 输出:5
echo ${#arr[0]} # 输出:1(第一个元素的长度)

# 所有索引
echo ${!arr[@]} # 输出:0 1 2 3 4

# 子数组
echo ${arr[@]:1:3} # 输出:b c d(从索引 1 开始,取 3 个)

命令替换

命令替换将命令的输出作为值使用。

语法

# 推荐语法
output=$(command)

# 旧语法(不推荐)
output=`command`

基本用法

# 获取命令输出
current_dir=$(pwd)
echo "当前目录: $current_dir"

# 获取日期
today=$(date +%Y-%m-%d)
echo "今天是: $today"

# 获取文件数量
count=$(ls | wc -l)
echo "文件数量: $count"

嵌套使用

# $() 可以嵌套
file_count=$(find . -name "*.txt" | wc -l)
echo "txt 文件数量: $file_count"

# 深层嵌套
result=$(echo "Count: $(ls | wc -l)")

注意事项

# 命令替换会移除末尾换行符
output=$(echo -e "line1\nline2\n")
echo "$output" # 输出两行,没有末尾空行

# 保留换行符
output=$(echo -e "line1\nline2\n"; echo x)
output=${output%x}
echo "$output" # 保留所有换行符

# 命令替换中的引号
find . -name "*.txt" # 正确
files=$(find . -name "*.txt") # 正确

算术扩展

算术扩展用于执行整数运算。

基本语法

# 推荐语法
result=$((expression))

# 使用 expr(旧式)
result=$(expr 1 + 1)

# 使用 let
let result=1+1

运算符

# 算术运算
echo $((1 + 2)) # 输出:3
echo $((5 - 3)) # 输出:2
echo $((4 * 3)) # 输出:12
echo $((10 / 3)) # 输出:3(整数除法)
echo $((10 % 3)) # 输出:1(取余)
echo $((2 ** 10)) # 输出:1024(幂运算)

# 比较运算(返回 0 或 1)
echo $((5 > 3)) # 输出:1
echo $((5 < 3)) # 输出:0
echo $((5 == 5)) # 输出:1

# 位运算
echo $((5 & 3)) # 输出:1(按位与)
echo $((5 | 3)) # 输出:7(按位或)
echo $((5 ^ 3)) # 输出:6(按位异或)
echo $((5 << 1)) # 输出:10(左移)
echo $((5 >> 1)) # 输出:2(右移)

变量使用

a=10
b=3

# 直接使用变量名(不需要 $)
echo $((a + b)) # 输出:13
echo $((a * b)) # 输出:30

# 自增和自减
echo $((a++)) # 输出:10,然后 a 变为 11
echo $((++a)) # a 先变为 12,然后输出 12

# 复合赋值
((a += 5)) # a = a + 5
((a *= 2)) # a = a * 2

条件表达式

# 三元运算符
a=10
b=20
echo $((a > b ? a : b)) # 输出:20(较大值)

# 逗号运算符
echo $((a=5, b=10, a+b)) # 输出:15

进程替换

进程替换将命令的输入或输出表现为文件路径,用于需要文件参数的场景。

语法

# 输入替换:将命令输出作为文件
<(command)

# 输出替换:将文件作为命令输入
>(command)

输入替换 <(...)

# 比较两个命令的输出
diff <(ls dir1) <(ls dir2)

# 比较排序后的文件
diff <(sort file1.txt) <(sort file2.txt)

# 使用 join 合并文件
join <(sort file1.txt) <(sort file2.txt)

# 将命令输出作为另一个命令的输入文件
grep "pattern" <(cat large_file.txt)

输出替换 >(...)

# 同时写入多个文件
echo "Hello" | tee >(grep -o "H" > h.txt) >(grep -o "e" > e.txt)

# 复制到多个目标
tar -cf >(gzip > file1.tar.gz) >(bzip2 > file2.tar.bz2) files/

# 计算多种校验和
cat file.txt | tee >(md5sum > file.md5) >(sha256sum > file.sha256) > /dev/null

实际应用

# 处理多个数据源
while read -r line; do
echo "Processing: $line"
done < <(grep "error" /var/log/*.log)

# 合并和去重
sort -u <(cat file1.txt) <(cat file2.txt)

# 并行处理
process() {
echo "Processing $1..."
sleep 1
echo "Done: $1"
}
export -f process

# 使用进程替换配合 xargs
find . -name "*.txt" | xargs -I {} bash -c 'process "$@"' _ {}

词分割

词分割(Word Splitting)是将扩展结果分割成多个词的过程。

触发条件

词分割只在以下扩展中发生:

  • 参数扩展 $var${var}
  • 命令替换 $(command)
  • 算术扩展 $((expression))

且不在双引号内。

分割规则

分割使用 IFS(Internal Field Separator)变量中的字符作为分隔符:

# 默认 IFS 是空格、制表符、换行符
echo "$IFS" | od -c # 输出:空格、制表符、换行符

# 修改 IFS
IFS=:
echo "$PATH" # 按 : 分割显示 PATH

示例

# 词分割的影响
files="file1.txt file2.txt file3.txt"
ls $files # 分别 ls 三个文件(词分割)

files="file with spaces.txt"
ls $files # 错误:ls file with spaces.txt(分成三个参数)
ls "$files" # 正确:ls "file with spaces.txt"

# 遍历文件名(危险)
for file in $(ls); do # 词分割会破坏带空格的文件名
echo "$file"
done

# 安全遍历
for file in *; do # 不触发词分割
echo "$file"
done

使用双引号避免词分割

# 总是用双引号包围变量
name="John Doe"
echo $name # 输出:John Doe(但可能有问题)
echo "$name" # 输出:John Doe(推荐)

# 数组展开
arr=("a b" "c d")
echo ${arr[@]} # 输出:a b c d(词分割)
echo "${arr[@]}" # 输出:a b c d(保持数组元素独立)

# "$@" 保持参数独立
function show_args() {
for arg in "$@"; do
echo "Arg: $arg"
done
}
show_args "hello world" "foo bar"

文件名扩展

文件名扩展(也称 globbing)将模式匹配为文件名列表。

基本通配符

通配符含义示例
*匹配任意字符(包括空)*.txt 匹配所有 txt 文件
?匹配单个字符file?.txt 匹配 file1.txt 等
[...]匹配括号内任意字符[abc].txt 匹配 a.txt, b.txt, c.txt
[!...][^...]匹配不在括号内的字符[!0-9].txt 匹配非数字开头
# 基本用法
ls *.txt # 所有 txt 文件
ls file?.txt # file1.txt, file2.txt 等
ls [abc].txt # a.txt, b.txt, c.txt
ls [!0-9]*.txt # 非数字开头的 txt 文件

# 字符范围
ls [a-z].txt # 小写字母开头的文件
ls [A-Z].txt # 大写字母开头的文件
ls [0-9].txt # 数字开头的文件

扩展通配符

Bash 支持扩展通配符(需要启用 extglob):

# 启用扩展通配符
shopt -s extglob

# 扩展通配符语法
?(pattern) # 匹配 0 或 1 次
*(pattern) # 匹配 0 次或多次
+(pattern) # 匹配 1 次或多次
@(pattern) # 匹配恰好 1 次
!(pattern) # 匹配不匹配 pattern 的任何内容
# 示例
ls *(.txt|.md) # 所有 txt 或 md 文件
ls !(backup*) # 非 backup 开头的文件
ls +([0-9]).txt # 纯数字命名的 txt 文件
ls ?([0-9]).txt # 可选数字开头的 txt 文件

点文件处理

# 默认不匹配点文件(以 . 开头)
ls * # 不包含 .hidden
ls .* # 只匹配点文件

# 启用 dotglob 匹配点文件
shopt -s dotglob
ls * # 包含 .hidden

# 匹配特定点文件
ls .[!.]* # 匹配 .file 但不匹配 . 和 ..

其他选项

# nullglob:无匹配时返回空
shopt -s nullglob
ls *.nonexistent # 返回空,而不是 *.nonexistent

# failglob:无匹配时报错
shopt -s failglob
ls *.nonexistent # 报错:no match: *.nonexistent

# globstar:递归匹配子目录
shopt -s globstar
ls **/*.txt # 匹配当前目录及子目录中的所有 txt 文件

# nocaseglob:忽略大小写
shopt -s nocaseglob
ls *.TXT # 匹配 .txt 和 .TXT

实际应用

# 查找文件
for file in **/*.py; do
echo "Python file: $file"
done

# 批量处理
for dir in */; do # 遍历所有目录
echo "Directory: $dir"
done

# 安全处理(处理无匹配情况)
shopt -s nullglob
files=(*.txt)
if [[ ${#files[@]} -gt 0 ]]; then
for file in "${files[@]}"; do
echo "Processing: $file"
done
fi

模式匹配

模式匹配在 [[ ]] 条件测试和 case 语句中使用,与文件名扩展类似但有区别。

基本模式

# 在 [[ ]] 中使用 == 或 !=
str="hello.txt"

# 通配符匹配
if [[ $str == *.txt ]]; then
echo "是 txt 文件"
fi

# 多个模式
if [[ $str == h*.{txt,md} ]]; then
echo "匹配成功"
fi

正则表达式匹配

# 在 [[ ]] 中使用 =~ 进行正则匹配
str="hello123"

if [[ $str =~ ^[a-z]+[0-9]+$ ]]; then
echo "匹配字母+数字格式"
fi

# 捕获组
if [[ $str =~ ^([a-z]+)([0-9]+)$ ]]; then
echo "字母部分: ${BASH_REMATCH[1]}"
echo "数字部分: ${BASH_REMATCH[2]}"
fi

case 语句中的模式

case $var in
*.txt) echo "文本文件" ;;
*.jpg|*.png) echo "图片文件" ;;
[0-9]*) echo "数字开头" ;;
[a-z]|[A-Z]) echo "单个字母" ;;
*) echo "其他" ;;
esac

扩展总结

理解 Shell 扩展的执行顺序和各种类型,是编写正确脚本的关键:

扩展类型说明示例
大括号扩展生成字符串序列{a,b,c}
波浪号扩展用户目录~, ~user
参数扩展变量值$var, ${var}
命令替换命令输出$(cmd)
算术扩展整数运算$((1+2))
进程替换进程作为文件<(cmd)
词分割分割字符串$var(无引号时)
文件名扩展匹配文件*.txt

最佳实践

  1. 变量使用双引号包围,避免意外的词分割
  2. 使用 ${var} 语法,明确变量边界
  3. 理解扩展顺序,避免意外行为
  4. 文件名处理时考虑空格和特殊字符
  5. 使用 shopt 选项调整 globbing 行为