Bash数组与字符串处理:参数展开、截取、替换
Bash 的数组(索引数组、关联数组)和字符串参数展开是 Shell 脚本中最实用的两项特性——样本列表管理、路径操作、批量参数传递都能用它们处理。本文覆盖数组操作、字符串截取替换和参数展开,附生信场景模板。
实测环境:Debian 12,Bash 5.2。
1. 索引数组——最常用的数组类型
1.1 创建与访问
# 创建samples=(WT_1 WT_2 KO_1 KO_2)chroms=("chr1" "chr2" "chr3")files=(*.bam) # 通配符展开
# 访问echo "${samples[0]}" # 第一个元素:WT_1echo "${samples[@]}" # 所有元素(作为独立单词)echo "${#samples[@]}" # 数组长度:4echo "${!samples[@]}" # 所有索引:0 1 2 3
# 遍历for s in "${samples[@]}"; do echo "Sample: ${s}"done[@] 和 [*] 的区别是 Bash 数组最容易搞混的地方:
arr=("a b" "c d")
# [@] 保持元素独立(推荐)for i in "${arr[@]}"; do echo "$i"; done# 输出:# a b# c d
# [*] 把所有元素拼成一个字符串(通常不是你想要的)for i in "${arr[*]}"; do echo "$i"; done# 输出:# a b c d注意:遍历数组永远用 "${arr[@]}"。
1.2 添加、删除、切片
# 追加samples+=(WT_3 KO_3) # 直接拼接samples=("${samples[@]}" "extra")
# 按索引赋值samples[0]="WT_CONTROL"
# 删除某个元素(实际是置空,不改变索引)unset "samples[1]"
# 真正的删除+重新索引samples=("${samples[@]}") # 重建数组,跳过空位
# 切片echo "${samples[@]:1:2}" # 从索引1开始取2个
# 查找for i in "${!samples[@]}"; do if [[ "${samples[$i]}" == "KO_2" ]]; then echo "Found KO_2 at index ${i}" fidone1.3 生信场景:样本列表管理
#!/bin/bashset -euo pipefail
# 从文件读取样本列表到数组mapfile -t SAMPLES < sample_list.txt# mapfile(或 readarray)把文件的每一行读入数组# -t 去掉行尾换行符
echo "Total samples: ${#SAMPLES[@]}"
# 跳过空行和注释行CLEAN_SAMPLES=()for sample in "${SAMPLES[@]}"; do [[ -z "${sample}" || "${sample}" == \#* ]] && continue CLEAN_SAMPLES+=("${sample}")done
echo "Valid samples: ${#CLEAN_SAMPLES[@]}"
# 批量生成命令CMDS=()for sample in "${CLEAN_SAMPLES[@]}"; do CMDS+=("fastp -i ${sample}_R1.fq.gz -I ${sample}_R2.fq.gz -o clean/")done
# 用GNU parallel执行printf '%s\n' "${CMDS[@]}" | parallel -j 81.4 数组去重
生信中经常重复拿到样本名,去重:
# 简单的去重(保持顺序)declare -A seenUNIQUE_SAMPLES=()
for sample in "${SAMPLES[@]}"; do if [[ -z "${seen[$sample]:-}" ]]; then UNIQUE_SAMPLES+=("${sample}") seen[$sample]=1 fidone
echo "Unique: ${#UNIQUE_SAMPLES[@]}"2. 关联数组——键值对的威力
关联数组用字符串做下标,等于 Bash 内置的字典。
# 声明(必须!)declare -A mapping
# 赋值mapping=( [WT_1]="wild type replicate 1" [WT_2]="wild type replicate 2" [KO_1]="knockout replicate 1" [KO_2]="knockout replicate 2")
# 访问echo "${mapping[KO_1]}" # knockout replicate 1echo "${!mapping[@]}" # 所有键:WT_1 WT_2 KO_1 KO_2echo "${mapping[@]}" # 所有值
# 遍历for key in "${!mapping[@]}"; do echo "${key} -> ${mapping[$key]}"done
# 检查键是否存在if [[ -v mapping[WT_1] ]]; then # -v 测试变量/数组键存在 echo "WT_1 exists"fi生信场景:样本名→条件映射
declare -A CONDITIONSCONDITIONS=( [SRR100001]=treated [SRR100002]=treated [SRR100003]=control [SRR100004]=control [SRR100005]=treated)
# 按条件分组declare -A GROUPSfor sra in "${!CONDITIONS[@]}"; do cond="${CONDITIONS[$sra]}" GROUPS[$cond]="${GROUPS[$cond]:-} ${sra}"done
# 输出分组for cond in "${!GROUPS[@]}"; do echo "=== ${cond} ===" for sra in ${GROUPS[$cond]}; do echo " ${sra}" donedone生信场景:计数器
declare -A GENE_COUNTS
# 从GTF中统计每个基因类型的出现次数while IFS=$'\t' read -r chrom source feature start end score strand frame attrs; do if [[ "${feature}" == "gene" ]]; then # 从属性字段提取gene_type if [[ "${attrs}" =~ gene_type\ \"([^\"]+)\" ]]; then gene_type="${BASH_REMATCH[1]}" ((GENE_COUNTS[$gene_type]++)) # 关联数组做计数器 fi fidone < genes.gtf
# 输出统计for gene_type in "${!GENE_COUNTS[@]}"; do echo "${gene_type}: ${GENE_COUNTS[$gene_type]}"done | sort -t: -k2 -rnBASH_REMATCH 是 Bash 正则匹配后自动填充的数组,${BASH_REMATCH[1]} 是第一个捕获组。这个技巧在解析 GTF/GFF/SAM 等结构化文本时极其好用。
3. 字符串处理五大类操作
3.1 长度和提取
s="SRR12345678_S1_L001_R1_001.fastq.gz"
echo "${#s}" # 长度echo "${s:0:3}" # 前3个字符:SRRecho "${s: -9}" # 后9个字符:R1_001.fa.. (注意空格)echo "${s:4}" # 从第4字符开始到最后3.2 前后缀删除(生信最高频!)
path="/data/projects/RNASeq/results/sample1.bam"filename="sample_WT_rep1_R1.fastq.gz"
# 删前缀 —— # 最短匹配,## 最长匹配echo "${path#*/}" # data/projects/RNASeq/results/sample1.bamecho "${path##*/}" # sample1.bam(basename的效果)
# 删后缀 —— % 最短匹配,%% 最长匹配echo "${filename%.*}" # sample_WT_rep1_R1.fastqecho "${filename%%.*}" # sample_WT_rep1_R1echo "${filename%.fastq.gz}" # sample_WT_rep1_R1
# 生信实战:提取样本IDsample_id="${filename%%_R1*}" # sample_WT_rep1echo "Sample ID: ${sample_id}"这些操作的 mnemonic:
3.3 替换
s="sample_WT_rep1_R1.fastq.gz"
# 首次替换echo "${s/R1/R2}" # sample_WT_rep1_R2.fastq.gz
# 全部替换echo "${s//r/R}" # sample_WT_Rep1_R1.fastq.gz
# 行首/行尾替换echo "${s/#sample/SAMPLE}" # SAMPLE_WT_rep1_R1.fastq.gzecho "${s/%.gz/.bgz}" # sample_WT_rep1_R1.fastq.bgz
# 生信实战:R1↔R2 配对r1="sample_S1_L001_R1_001.fastq.gz"r2="${r1/_R1_/_R2_}" # 最安全的替换方式echo "${r2}" # sample_S1_L001_R2_001.fastq.gz3.4 默认值和条件展开
# 如果变量未设置或为空,用默认值THREADS="${1:-8}"
# 如果变量未设置或为空,赋值默认值并返回: "${OUTPUT_DIR:=./results}" # : 是空命令,效果等于赋值
# 如果未设置则报错退出INPUT="${2:?Error: input file required}"
# 如果变量已设置则用替代值echo "${DEBUG:+Debug mode ON}" # DEBUG有值时才输出3.5 大小写转换
s="ATGCTAGCTAG"
echo "${s,,}" # 全小写:atgctagctagecho "${s,}" # 首字母小写:aTGCTAGCTAGecho "${s^^}" # 全大写:ATGCTAGCTAGecho "${s^}" # 首字母大写:ATGCTAGCTAG(本来已大写)
# 生信场景:统一序列大小写seq="atcgATCG"echo "${seq^^}" # ATCGATCG4. 生信全流程实战:Bash数组+字符串驱动RNA-seq批量比对
#!/bin/bashset -euo pipefail
# ========== 1. 用数组管理所有样本 ==========mapfile -t RAW_SAMPLES < sample_list.txt
# 清洗SAMPLES=()for s in "${RAW_SAMPLES[@]}"; do [[ -z "${s}" || "${s}" == \#* ]] && continue SAMPLES+=("${s}")done
echo "Total samples: ${#SAMPLES[@]}"
# ========== 2. 关联数组存储元信息 ==========declare -A METADATAwhile IFS=$'\t' read -r sample condition replicate; do METADATA["${sample}_cond"]="${condition}" METADATA["${sample}_rep"]="${replicate}"done < metadata.tsv
# ========== 3. 字符串处理提取配对关系 ==========declare -A R1_FILES R2_FILES
for f in raw_data/*.fastq.gz; do basename=$(basename "${f}")
if [[ "${basename}" == *_R1_* ]]; then sample_id="${basename%%_R1*}" R1_FILES["${sample_id}"]="${f}" elif [[ "${basename}" == *_R2_* ]]; then sample_id="${basename%%_R2*}" R2_FILES["${sample_id}"]="${f}" fidone
# ========== 4. 主循环 ==========SUCCESS=()FAILED=()
for sample in "${SAMPLES[@]}"; do r1="${R1_FILES[$sample]:-}" r2="${R2_FILES[$sample]:-}"
if [[ -z "${r1}" || -z "${r2}" ]]; then echo "WARNING: Missing files for ${sample}, skipping" FAILED+=("${sample}") continue fi
condition="${METADATA["${sample}_cond"]:-unknown}" echo "Processing ${sample} (${condition})..."
# fastp QC fastp -i "${r1}" -I "${r2}" \ -o "clean/${sample}_R1.fq.gz" \ -O "clean/${sample}_R2.fq.gz" \ -j "qc/${sample}.json" -h "qc/${sample}.html" -w 8
# 根据条件选择参考基因组路径 ref_index="/opt/refs/${condition}_index" hisat2 -p 16 -x "${ref_index}" \ -1 "clean/${sample}_R1.fq.gz" \ -2 "clean/${sample}_R2.fq.gz" \ | samtools sort -@ 8 -o "bam/${sample}.bam" -
samtools index "bam/${sample}.bam" SUCCESS+=("${sample}")done
# ========== 5. 结果汇总 ==========echo ""echo "============================="echo "Pipeline complete!"echo "Success: ${#SUCCESS[@]}"echo " ${SUCCESS[@]}"echo "Failed: ${#FAILED[@]}"echo " ${FAILED[@]:-None}"echo "============================="
# 按条件统计declare -A COND_COUNTfor sample in "${SUCCESS[@]}"; do cond="${METADATA["${sample}_cond"]:-unknown}" ((COND_COUNT[$cond]++))done
for cond in "${!COND_COUNT[@]}"; do echo " ${cond}: ${COND_COUNT[$cond]} samples"done这个脚本展示了:数组去重、关联数组元信息管理、字符串前后缀删除提取样本ID、默认值处理缺失数据、成功/失败分组汇总。
5. Bash数组 vs 临时文件
很多生信新人习惯用临时文件处理中间数据:
# ✗ 用文件的写法ls *.bam > bam_list.txtwc -l bam_list.txtgrep "sample1" bam_list.txt# ...后面还要 rm bam_list.txt
# ✓ 用数组的写法bams=(*.bam)echo "${#bams[@]}"for b in "${bams[@]}"; do [[ "${b}" == *sample1* ]] && echo "Found: ${b}"done数组的优缺点:
当文件数量 N 很大时,内存操作(数组)比磁盘 I/O(临时文件)快了三个数量级。但数组也有硬伤:不能跨进程共享、大量数据(10万+元素)会拖慢Bash。遇到这种情况还是用文件或换 Python。
6. 踩坑记录
坑1:"${array[@]}" 忘了双引号
arr=("a b" "c d")# ✗ 没有引号——元素被单词分割for i in ${arr[@]}; do echo "$i"; done# 输出四个独立单词:a, b, c, d
# ✓ 有引号for i in "${arr[@]}"; do echo "$i"; done# 输出两个元素:a b, c d坑2:关联数组必须 declare -A
# ✗ 不声明就当索引数组mapping=([key1]="val1" [key2]="val2")echo "${mapping[0]}" # 空的!key1被当作变量名展开
# ✓declare -A mappingmapping=([key1]="val1" [key2]="val2")echo "${mapping[key1]}" # val1坑3:unset 数组元素产生空洞
arr=(a b c d)unset "arr[1]"echo "${#arr[@]}" # 3 ——没毛病echo "${arr[1]}" # 空 ——有毛病echo "${!arr[@]}" # 0 2 3 ——索引不连续了!
# 如果你后面用索引遍历会出问题for i in 0 1 2 3; do echo "${arr[$i]}" # 索引1是空的done
# ✓ 删除后用 "${arr[@]}" 重新索引arr=("${arr[@]}")坑4:关联数组键含空格
declare -A mapmap["a key"]="value"echo "${map[a key]}" # ✓ 可以但别扭
# 最好避免键中有空格坑5:大数组性能崩塌
Bash 数组在元素超过 10 万时操作明显变慢。我测过一个 50 万元素的数组,for 遍历耗时是 Python 的 50 倍。
# 如果数据量大,切分或换 Python:python3 -c "data = [line.strip() for line in open('big_list.txt')]print(f'Total: {len(data)}')# 10倍快的处理..."经验判断:<1000 元素随便用 Bash 数组;1000-10000 还行;>10000 换 Python。
坑6:${#array[@]} 和 ${#array} 的区别
arr=(a bb ccc)echo "${#arr[@]}" # 3 ——元素个数echo "${#arr}" # 1 —— ${arr} = ${arr[0]} = "a",长度是1# 容易搞混!永远用 ${#array[@]} 取长度坑7:${string##*/} 中 */ 是通配符不是正则
path="/data/results/sample.bam"# ##*/ 的意思是:删除最长的能匹配 "*/" 的前缀# 即删到最后一个斜杠之前echo "${path##*/}" # sample.bam ✓
# 但如果你以为是 regex 写了 \/ 就毁了echo "${path##*\/}" # 什么都不删(\/字面量通常不匹配/)Bash 的 # % ## %% 用的全是 glob 通配符(* ? [a-z]),不是正则,不能用 \d、.、+ 这些正则符号。
坑8:mapfile 在旧版Bash不存在
mapfile(也叫 readarray)是 Bash 4.0+ 才有的。macOS 自带的 Bash 3.2 不支持。
# 兼容方案:IFS=$'\n' read -r -d '' -a SAMPLES < sample_list.txt# 或者用传统的 while readSAMPLES=()while IFS= read -r line; do SAMPLES+=("${line}")done < sample_list.txt7. 总结
| 操作 | 语法 | 记忆诀窍 |
|---|---|---|
| 取数组长度 | ${#arr[@]} | # 号在数学里就是”个数” |
| 遍历数组 | for i in "${arr[@]}" | 双引号+[@]是建议 |
| 删前缀(最短) | ${var#pattern} | # 在 $ 前 = 删前面 |
| 删后缀(最短) | ${var%pattern} | % 在 $ 后 = 删后面 |
| 删前缀(最长) | ${var##pattern} | 两个# |
| 删后缀(最长) | ${var%%pattern} | 两个% |
| 首次替换 | ${var/old/new} | 一个斜杠 |
| 全局替换 | ${var//old/new} | 两个斜杠 |
| 默认值 | ${var:-default} | :- |
| 关联数组 | declare -A arr | -A = Associative |
Bash 的数组和字符串操作,学到就是赚到。一个 %% 就能省掉一次 sed 调用,一个关联数组就能替代 Python 字典。把这张速查表贴在显示器旁边,写脚本时瞟一眼,效率翻倍。
本文于 2025-07-22 在 Debian 12(Bash 5.2.15)上实测完成。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!