数组与函数进阶

系统讲解 Bash 索引数组与关联数组的声明、遍历与常用操作,函数的参数传递、返回值捕获、作用域与递归,以及如何用函数封装数组操作写出声明式风格的脚本。

1. 数组基础与索引数组

一句话总结: 索引数组用数字作下标,${arr[0]} 取单个元素,${arr[@]} 取全部,${#arr[@]} 取长度,声明用 arr=(...)。

Bash 的索引数组用整数下标访问,下标从 0 开始。定义数组最常用的方式是圆括号空格分隔列表。

# 定义与读取
fruits=("apple" "banana" "cherry")
echo "${fruits[0]}"       # apple
echo "${fruits[2]}"       # cherry

# 追加元素
fruits+=("date")
echo "${#fruits[@]}"      # 4,元素个数

# 一次读取全部
echo "${fruits[@]}"       # apple banana cherry date

# 带下标打印
echo "${fruits[*]}"       # 等价,但作为单个字符串

1.1 空数组与未定义下标

${arr[@]} 在数组为空或未定义时,在 set -u 下会直接报错,这是新手最容易踩的坑。

# 空数组安全展开:加默认值
empty=()
echo "${empty[@]-}"        # 空数组安全,输出空

# set -u 下直接展开空数组会报错
set -u
echo "${empty[@]}"         # bash: empty[@]: unbound variable

一句话总结: set -u 下展开未定义或空数组会报 unbound variable,写成 "${arr[@]-}" 可安全兜底。

1.2 从命令输出构建数组

mapfile(别名 readarray)把命令或文件输出按行读入数组,比 arr=$(...) 逐字更稳。

# 按行读入数组
mapfile -t lines < /etc/hosts
echo "共 ${#lines[@]} 行"

# 从管道读入
mapfile -t ips <<< "$(awk '{print $1}' access.log)"
printf '%s\n' "${ips[@]}"

# 指定起始下标与行数
mapfile -t -s 1 -n 3 header < file.txt   # 跳过首行,只读 3 行

2. 关联数组

一句话总结: 关联数组用字符串作下标,声明必须 declare -A,适合按 key 做聚合统计、缓存查找与配置映射。

索引数组的整数下标写不进"用户名→邮箱"这类映射,关联数组正是为此而生。

# 声明关联数组并赋值
declare -A users
users[alice]="alice@example.com"
users[bob]="bob@example.com"

echo "${users[alice]}"      # alice@example.com

# 遍历 key 与 value
for name in "${!users[@]}"; do
  echo "$name → ${users[$name]}"
done

2.1 关联数组做计数统计

awk 能按字段聚合,纯 Bash 场景下关联数组是同样的角色——统计每个状态码出现次数。

declare -A code_count
while read -r _ _ _ code; do
  ((code_count[$code]++))
done < access.log

for c in "${!code_count[@]}"; do
  printf '%s %d\n' "$c" "${code_count[$c]}"
done | sort -rn | head -10

一句话总结: 关联数组把「状态码、IP、用户名」这类离散 key 变成可聚合的桶,((map[$key]++)) 一行完成计数。

2.2 合并与删除

declare -A a=( [x]=1 [y]=2 ) b=( [y]=3 [z]=4 )

# 合并:遍历 b 覆盖进 a
for k in "${!b[@]}"; do a[$k]=${b[$k]}; done

# 删除单个 key 或清空
unset a[y]
unset a           # 删除整个数组

3. 数组遍历与常用操作

一句话总结: 遍历用 for x in "${arr[@]}",下标遍历用 "${!arr[@]}";切片、拼接、去重、排序是高频操作。

# 遍历元素(推荐加引号防词分裂)
for f in "${files[@]}"; do
  stat -c '%n %s' "$f"
done

# 遍历下标
for i in "${!files[@]}"; do
  echo "index=$i value=${files[$i]}"
done

3.1 切片与拼接

arr=(a b c d e)

# 切片:从下标 1 取 2 个
sub=("${arr[@]:1:2}")
echo "${sub[@]}"          # b c

# 拼接成字符串
joined=$(IFS=,; echo "${arr[*]}")
echo "$joined"            # a,b,c,d,e

一句话总结: ${arr[@]:off:len} 切片、"${arr[*]}" 配合 IFS 拼接成单字符串,是数组转 CSV 的两行写法。

4. 函数定义与参数

一句话总结: 函数用 name() { ... } 定义,$1 $2 是位置参数,local 声明局部变量,函数体内还能嵌套定义函数。

函数让脚本从"一串命令"进化为"可复用的程序单元"。

#!/usr/bin/env bash

greet() {
  local name="${1:-world}"
  echo "Hello, $name"
}

greet "shell"          # Hello, shell
greet                  # Hello, world

4.1 参数处理模式

与主脚本的参数解析共用同一套技巧:$# 个数、$@ 全部、shift 逐位消费。

log() {
  local level="$1"; shift
  printf '[%s] %s\n' "$level" "$*"
}

log INFO "build start"
log ERROR "compile failed"

一句话总结: 函数参数与脚本参数同构,local + shift + 默认值 ${1:-} 是函数参数处理的标准三件套。

4.2 传引用:让函数修改外部变量

Bash 没有指针,但可以用 nameref(declare -n)让函数直接读写外部数组或变量。

append_to() {
  local -n _arr="$1"
  _arr+=("${@:2}")
}

items=(a b)
append_to items c d
echo "${items[@]}"     # a b c d

5. 返回值与输出捕获

一句话总结: return N 返回 0~255 的整数状态码,文本输出靠 echo 打印后用 $(...) 捕获;两者混用时小心 stdout 被污染。

函数有两种"返回值":return 的状态码,和打印到 stdout 的文本。

# 状态码返回
is_root() { [[ "$EUID" -eq 0 ]]; }
if is_root; then
  echo "管理员"
fi

# 文本捕获
get_name() { echo "Alice"; }
name=$(get_name)

5.1 stdout 污染陷阱

函数内部的所有 echo 都会进入捕获结果,连调试输出也不例外——把调试打印改到 stderr。

compute() {
  echo "debug: 开始计算" >&2    # 调试信息走 stderr
  echo $(( ${1:-0} * 2 ))
}

result=$(compute 21)
echo "result=$result"          # result=42,不含调试行

一句话总结: 需要捕获文本的函数,内部普通输出一律 echo,调试/日志一律 >&2,否则 $(...) 会捞到脏数据。

5.2 返回值与输出并用

lookup() {
  local key="$1"
  declare -A map=( [a]=1 [b]=2 )
  [[ -n "${map[$key]}" ]] || return 1
  echo "${map[$key]}"
}

if val=$(lookup a); then
  echo "命中: $val"
else
  echo "未命中"
fi

6. 作用域与递归

一句话总结: 函数内 local 变量是局部的,未声明即全局;递归需要小心全局状态污染,常用 local 保存中间结果。

#!/usr/bin/env bash

counter=0
bump() {
  local x=10
  counter=$((counter + 1))   # 修改全局
  x=$((x + 1))
}
bump
echo "$counter"   # 1

6.1 递归阶乘

fact() {
  local n="$1"
  if (( n <= 1 )); then
    echo 1
  else
    echo $(( n * $(fact $((n - 1))) ))
  fi
}

echo "$(fact 5)"   # 120

一句话总结: 递归靠 local 保住每一层的 n 不被覆盖;递归深度别太大,Bash 函数栈有限,超过几千层会爆。

6.2 目录递归遍历

walk() {
  local dir="$1"
  for entry in "$dir"/*; do
    if [[ -d "$entry" ]]; then
      walk "$entry"
    else
      echo "$entry"
    fi
  done
}

walk /tmp/project | head

7. 声明式风格实战

一句话总结: 把「数据放数组、逻辑放函数」,脚本就从命令堆砌变成「配置驱动」——加一个元素等于加一个功能。

命令式脚本把数据和流程揉在一起;声明式把数据分离出来,逻辑复用同一套函数。

#!/usr/bin/env bash
set -euo pipefail

# 数据:任务清单
tasks=(
  "build:make build"
  "test:make test"
  "deploy:./deploy.sh prod"
)

run_task() {
  local name="$1" cmd="$2"
  echo "==> $name"
  if $cmd >/dev/null 2>&1; then
    echo "    OK"
  else
    echo "    FAIL"
    return 1
  fi
}

for t in "${tasks[@]}"; do
  run_task "${t%%:*}" "${t#*:}"
done

7.1 函数式 map 与 filter

把"对每个元素做什么"提成函数,循环只剩一行。

# 对数组每个元素执行函数(map)
map() {
  local fn="$1"; shift
  local out=()
  for x in "$@"; do
    out+=("$($fn "$x")")
  done
  echo "${out[@]}"
}

shout() { echo "${1^^}"; }
map shout hello world      # HELLO WORLD

# 过滤(filter)
filter_even() {
  local out=()
  for x in "$@"; do
    (( x % 2 == 0 )) && out+=("$x")
  done
  echo "${out[@]}"
}

filter_even 1 2 3 4 5      # 2 4

一句话总结: map 与 filter 函数把循环细节封装一次,之后改逻辑只改传入的函数,代码面大幅收窄。

7.2 用关联数组做配置表

declare -A PORT=( [web]=8080 [api]=9000 [db]=5432 )

start_service() {
  local svc="$1"
  local port="${PORT[$svc]:-0}"
  [[ "$port" != 0 ]] || { echo "未知服务 $svc"; return 1; }
  echo "启动 $svc @ $port"
}

for s in web api db; do start_service "$s"; done

8. 总结

环节要点
索引数组arr=(...) 声明,${arr[i]} 取值,${#arr[@]} 长度
关联数组declare -A 声明,字符串下标,适合聚合与映射
遍历"${arr[@]}" 元素遍历,"${!arr[@]}" 下标遍历
切片拼接"${arr[@]:off:len}" 切片,IFS + "${arr[*]}" 拼接
函数参数$1~$9 位置参数,local 局部变量,${1:-default} 默认值
返回值return N 状态码,echo + $(...) 捕获文本,日志走 stderr
作用域递归未 local 即全局,递归靠 local 保现场,深度有限
声明式数据放数组、逻辑放函数,map/filter 封装循环

数组和函数是 Bash 从"脚本语言"走向"编程语言"的两块基石。数组管数据、函数管逻辑,再用关联数组做配置表,中等复杂度的自动化任务都能写得结构清晰。下一步进入 xargs 与并行处理,把单机脚本扩展到并发批量任务。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「shell」更多文章

  1. 脚本性能优化实战
  2. 网络请求与诊断实战
  3. 定时任务调度实战