R / Richie全部文章 ↑

Linux · 5 分钟阅读

Shell 字符串截取

${var#pattern} 不是"删除文件前缀"——它是 Shell 参数展开(parameter expansion)里的一类:去掉字符串开头/结尾匹配的内容。这条语法在处理文件路径、日志轮转、批量重命名时高频出现,看懂一次就省无数次 cut / sed。

一句话解释

${var#pattern} — 去掉 var 开头最短匹配 pattern 的部分。
${var##pattern} — 去掉 var 开头最长匹配 pattern 的部分。
${var%suffix} — 去掉 var 结尾最短匹配。
${var%%suffix} — 去掉 var 结尾最长匹配。

符号 位置 匹配方式
# 头部 最短
## 头部 最长
% 尾部 最短
%% 尾部 最长

一个 # 是单字符去掉;两个 ## 是贪婪去掉。% 同理,方向相反。

跑一遍例子

f="/usr/local/bin/app.tar.gz"

echo "${f#*/}"        # usr/local/bin/app.tar.gz
echo "${f##*/}"       # app.tar.gz
echo "${f%.*}"        # /usr/local/bin/app.tar
echo "${f%%.*}"       # /usr/local/bin/app

四个搭配把"路径名 / 文件名 / 去掉扩展名 / 只留目录"全拆出来了,不用 basename / dirname 也能写。

文件名处理的四件套

path="/srv/web/main.py"

dir="${path%/*}"             # /srv/web
file="${path##*/}"           # main.py
base="${file%.*}"            # main
ext="${file##*.}"            # py

dir 用了 %/*(去掉最后一个 / 及之后),file 用了 ##*/(去掉最后一个 / 及之前)。这是 basename/dirname 思路的 shell 版。

批量改后缀

for f in *.md; do
    mv "$f" "${f%.md}.markdown"
done

${f%.md} 去掉结尾的 .md,再拼上新后缀。比 sed 简单一截。

去前缀:去掉 UUID / 时间戳

# 文件名是 20260804_report.csv,想拿 "report.csv"
name="20260804_report.csv"
echo "${name#*_}"            # report.csv

# 想去掉所有数字前缀
echo "${name##*[[:alpha:]]}" # report.csv

* 是通配符——这里它匹配"任意字符任意长度",不是正则。

模式里能用通配符

# 和 % 的 pattern 用的是 shell 通配符(* ? [...]),不是正则。

v="abc123def456"

echo "${v#*[0-9]}"     # 23def456    去掉到第一个数字为止
echo "${v##*[0-9]}"    # 456         去掉到最后一个数字为止
echo "${v%[a-z]*}"     # abc123      去掉末尾字母

字符类 [0-9] 跟正则写法一致。[[:digit:]] 这类 POSIX 字符类也支持。

跟 sed 比一比

需求 参数展开 sed
去掉路径前缀 ${p#*/} `echo “$p” | sed 's
去掉扩展名 ${f%.*} `echo “$f” | sed 's
改后缀 ${f%.old}.new `echo “$f” | sed 's

参数展开是 shell 内置,没有 fork 子进程,比 sed 快 10–100 倍——批量处理上千个文件时差距明显。

一些容易踩的坑

1. 模式没匹配 → 原样返回

x="hello"
echo "${x#xyz}"   # hello(没匹配上,原样输出)

2. 模式里别加引号

f="app.tar.gz"
echo "${f%".gz"}"  # 错误!把 ".gz" 当字面量,引号反而让 % 不工作
echo "${f%.gz}"    # 正确

3. 数组也能用

arr=("/a/b/c" "/d/e/f")
echo "${arr[@]##*/}"   # c f

4. 默认值(容易和 # 混淆)

${var:-default} 是默认值,和 ${var#pattern} 长得像但完全两回事。- 和 + 操作符的列表:

写法 含义
${var:-x} 为空或未定义时用 x
${var:=x} 为空或未定义时赋值为 x
${var:+x} 非空时返回 x,否则空
${var:?msg} 为空时打印 msg 并退出
${var#x} / ${var##x} 头部截短
${var%x} / ${var%%x} 尾部截短

- = + ? 是"默认值族";# ## % %% 是"截短族"。看符号位置分清。

实战:日志归档

# 把 access.log.YYYYMMDD 滚动成 .gz
for f in /var/log/nginx/access.log.*; do
    [[ "$f" =~ \.log\.[0-9]{8}$ ]] || continue

    date_part="${f##*.log.}"          # 取日期部分
    gz="/var/log/nginx/archive/access-${date_part}.log.gz"

    gzip -c "$f" > "$gz"
    rm "$f"
done

实战:批量改 Makefile 里的旧路径

for f in Makefile */Makefile; do
    sed -i.bak "s|/opt/old|/opt/new|g" "$f"
    [[ -f "$f.bak" ]] && rm "$f.bak"
done

这个例子用 sed 因为要做"全文件替换 + 原地改写"——参数展开的定位是"取一个值出来",不是"全文改"。

调试小技巧

# 在脚本里看展开结果
set -x
f="/a/b/c.tar.gz"
echo "${f##*/}"
# + echo c.tar.gz

set -x 把每条命令展开后的样子打出来。觉得展开不符合预期时这样对照最快。

一些经验

  • 处理"已知结构"的字符串优先用参数展开:零 fork,可读
  • 全文替换、模式很复杂才上 sed / awk
  • 写 ## %% 时手画一遍匹配范围,贪婪和最短别弄反
  • 在 for f in *.log 这类循环里配合 ${f%.*} 做扩展名操作非常顺手

参考