Shell编程必会:函数与数组的实战技巧与避坑指南
发布时间:2026/10/20 9:28:25 锦皓数字建站

接到一个“只要几分钟”的小需求某台机器上跑着一段老旧的部署脚本整整五百多行里面光日志清理的逻辑就重复写了八遍参数一改就得全局替换。我当时一边往下翻一边想这玩意儿是怎么活到今天的后来花了半小时用函数加数组重构完核心逻辑两百行不到新增一个清理目录只需要加一行配置。从那之后我就一直认为Shell 这门语言写出来的代码最容易区分新手和熟练工的不是语法炫技而是函数和数组这两个基础特性用没用明白。这篇文章就围绕“Shell 编程之函数与数组”这个主题把定义语法、参数陷阱、返回值设计、数组批量操作、函数与数组配合的实战案例以及我踩过的兼容性和性能坑一次性说清楚。适合刚写完几十个脚本、开始觉得代码重复得难受的开发者也适合写了几年脚本但一直没系统整理过函数和数组用法的老手。1. 函数与数组在真实脚本中的价值定位先说个反直觉的结论Shell 脚本里函数和数组用得好不好跟脚本跑得快不快关系不大跟脚本能不能维护、能不能扩展关系极大。尤其是团队协作或者三个月后再翻开自己写的脚本这段价值会体现得淋漓尽致。1.1 从500行重复脚本说起模块化之前的代码灾难很多人写 Shell 脚本是从“一条命令搞定一件事”开始的。今天加一行 crontab明天补一段grep加awk后天把整个部署流程串成一个巨型脚本。等到逻辑越来越长就发现改一处配置要牵扯七八个段落复制粘贴出来的代码块长得一模一样只有路径不同。更难受的是调试——出错信息指向的行号跟实际逻辑根本对不上因为同一段逻辑被复制到了不同行。这就是模块化缺失的典型症状。函数解决的就是“同一段逻辑多处复用”的问题把日志清理、配置校验、进程检查这类动作抽成独立的函数每个函数只干一件事入参和返回值说清楚调用处自然就干净了。数组解决的是“同构数据批量承载”的问题一堆服务器 IP、一批日志文件、一组环境变量值如果不放进数组就只能用一长串变量名或者不断拼接字符串处理起来又慢又容易出错。1.2 函数和数组分别解决了哪两类问题简单归纳我会把这两个特性的分工说得尽量直白函数 动作的封装。它是把“怎么做”收拢到一个命名单元里外面调用时不需要关心内部步骤。比如backup_dir /var/www /backup/20240601调用者只需要知道“传源目录和目标目录返回是否成功”至于内部用的是tar、rsync还是cp -a那是函数自己的事。数组 数据的集合。它是把“一批相关数据”放在一个变量名下通过下标或键名访问。比如SERVERS(web01 web02 web03)配合for s in ${SERVERS[]}就能一次遍历完所有服务器。函数负责“流程分块”数组负责“数据分组”两者合在一起才是完整的分而治之思想。只学函数不学数组你会发现参数传起来束手束脚只学数组不学函数数据是组织好了处理逻辑还是一大坨。1.3 什么时候该用什么时候没必要也不能一上来就鼓吹“万物皆函数”。写一行ls -l也套个函数那就过度设计了。我自己的判断标准有三个同一段逻辑在脚本里出现两次以上且未来大概率还会变果断提函数。要处理的同类数据超过三个且长度不固定用数组。单个函数超过四十行或者函数里还要拆子函数先停下来想一想是不是逻辑切得太细了。小脚本怎么舒服怎么来但脚本一旦超过一百行函数和数组的引入几乎是必然的。别等到脚本坏了才去救火重构永远是在代码还能看懂的时候做最划算。2. 函数定义与调用的隐藏细节Shell 函数的语法看起来简单得让人放松警惕但真的写出有质量的函数需要掌握不少隐藏细节。这一节把定义、调用、返回值、作用域这些基础概念背后容易踩的坑逐一说明。2.1 两种定义语法以及兼容性差异Shell 函数有两种写法# 写法一POSIX 风格兼容性最好 my_func() { echo hello } # 写法二function 关键字bash 扩展语法 function my_func { echo hello }两种写法在当前主流 Linux 发行版自带的 Bash 里都能跑但差异在于可移植性。my_func()这种写法是 POSIX 标准sh和dash也认function my_func是 Bash 扩展换到纯 POSIX shell 环境可能直接报错。我以前在某个精简容器镜像里调试脚本默认sh是dash用function关键字写的函数全部解析失败排查了半天才想起来是兼容性问题。我的建议是写脚本一律用my_func()的格式既能跑在 Bash 下也能跑在dash、sh下不需要额外成本就获得了更好的兼容性。另外注意function my_func()这种写法在 Bash 里居然也不报错但千万别依赖这种奇奇怪怪的形式它不属于任何规范。2.2 函数必须“先定义后调用”Shell 脚本是顺序解析执行的函数定义本质上就是执行一条“把函数体绑定到函数名”的命令。所以函数必须定义在调用之前否则会报“command not found”#!/bin/bash # 错误示范先调用后定义 do_work do_work() { echo working; }这个错误在脚本开发期经常出现特别是把函数调用放在脚本靠前位置、定义放在末尾时。正确做法是把所有函数定义集中在脚本头部或者单独 source 一个函数库文件再写主流程。我习惯的文件结构是这样的#!/bin/bash # 1. 配置变量区 # 2. 函数定义区 # 3. 主流程区只要主流程在最后面函数定义不管在前还是在后都无所谓但统一放在中段最清晰。2.3 return 与 exit 的边界为什么 $? 最大只能到 255函数里的return N会结束函数并返回退出码调用方通过$?拿到这个值。但很多人忽略了退出码的范围限制——return只能返回 0 到 255 的整数超过 255 会自动取模。比如return 300调用方拿到的是 44test_ret() { return 300 } test_ret echo $? # 输出 44这一点对函数设计的影响很大函数的状态码只适合表示“成功/失败/少数枚举状态”不适合返回真正的数值数据。比如你想写一个函数返回文件行数直接return $(wc -l file)超过 255 就会出错。正确做法是用echo输出结果调用方用$(...)捕获get_line_count() { wc -l $1 } lines$(get_line_count /etc/passwd)exit和return的区别也要拎清楚。exit是结束整个脚本进程return只是结束当前函数。在函数里想直接终止整个脚本用exit 1想返回一个状态交给调用方继续处理用return 1。我见过有人把这两个用反了导致脚本中途退出排查时发现居然是在函数里写了exit设计意图只是提示一个子任务失败。2.4 local 变量的作用域与全局变量污染问题Shell 函数里不声明局部变量的后果比很多人想象的严重。默认情况下函数体内赋值的变量是全局的函数调用结束后变量仍然存在可能覆盖外部同名变量。counter10 increment() { counter$((counter 1)) } increment echo $counter # 11外部变量被改了如果函数内部声明local变量生命周期就被限制在函数及其子调用范围内increment() { local counter$1 counter$((counter 1)) echo $counter }在函数里加local是强制性的好习惯我在自己的脚本规范里直接写死这一条函数内所有临时变量必须用 local 声明。这不只是为了隔离命名冲突还有一个实际好处函数递归调用时没有 local 的变量会导致递归层之间互相污染调几层就全乱了。2.5 删除函数与函数内联的边界已经定义的函数可以删除用unset -f 函数名。这个命令在调试和脚本热加载场景下有用日常写脚本基本用不上了解即可。更值得了解的是函数定义可以被同名新定义覆盖这既是危险也是灵活——比如在不同分支里定义不同版本的deploy()就能实现简单的策略切换if [[ $env prod ]]; then deploy() { echo 生产部署流程; } else deploy() { echo 测试部署流程; } fi deploy这种方式比到处写if判断要干净得多也是函数式重构里很实用的思路。3. 函数参数传递与返回值设计的三类方案Shell 函数的参数传递机制特别朴素没有命名参数没有默认参数全靠位置参数。正因为如此返回值的设计才更需要花心思。这里我把三种常见方案和各自适合的场景讲透。3.1 位置参数与 $#、$、$* 的行为函数收到参数后通过$1、$2、$3依次访问。注意第十个参数开始必须用${10}包裹否则会被解析成${1}0。$#表示参数个数$表示所有参数列表$*表面上也是所有参数但它和$在带引号时行为完全不同这个坑放到数组章节再展开因为机制一模一样。一个容易忽略的点是函数内的$0依然指向脚本本身不是函数名。很多新手以为在函数里$0能拿到函数名用来打日志结果所有函数都打印出脚本路径。想要函数名可以用FUNCNAME[0]Bash 有这个数组变量log_func() { echo 当前函数${FUNCNAME[0]} }3.2 返回值的三种方案退出码、echo 捕获、全局变量函数想要“带数据回来”实际工作中就三种方案选型逻辑非常清晰方案写法适用场景注意事项退出码return 0/1只关心成功失败范围 0-255失败不一定是 1建议语义化echo 捕获echo 结果ret$(func)返回单个文本数据函数内其他 echo 会污染结果需用2输出日志全局变量FUNC_RESULTxxx返回多值或结构化数据调用后立即使用否则可能被其他函数覆盖第三种方案写起来像这样parse_config() { CONFIG_NAME$1 CONFIG_VALUE$2 } parse_config timeout 30 echo $CONFIG_NAME$CONFIG_VALUE # timeout30全局变量的缺点是作用域开放容易互相覆盖。折中做法是约定一个前缀比如_ret_开头减少冲突概率。我在大型脚本里会用这种方案返回关联数组的键值对比让函数输出一大段文本再解析要直观得多。3.3 典型反例echo 污染导致的结果错乱echo捕获方案最经典的坑就是函数内部既有真正的输出又用echo打日志。举例get_size() { echo 计算文件大小... echo $(stat -c%s $1) } size$(get_size /etc/passwd) echo 得到大小$size结果$size是两行一行日志一行数值。后续如果对size做算术运算直接报错。解决办法是约定函数内所有调试信息一律写到标准错误输出也就是追加2get_size() { echo 计算文件大小... 2 echo $(stat -c%s $1) } size$(get_size /etc/passwd)这样日志会直接打到屏幕上而$(...)捕获到的只有标准输出里的数值。这个约定非常朴素但极其有效我几乎所有脚本里都在用。3.4 递归函数与资源控制Shell 函数当然支持递归比如遍历目录树可以用递归函数实现。但递归在 Shell 里的成本远高于 Python 或 C——每次函数调用都是一次内部子进程或者至少一次上下文切换深度超过几百层就可能栈溢出或拖垮性能。这里给一个经验值别让 Shell 递归深度超过 100。如果真的需要深度遍历优先考虑用find配合while read而不是自己写递归。还有经典的反模式“fork 炸弹”——函数里同时调用两个自身副本瞬间耗尽系统进程资源dangerous() { dangerous | dangerous }这种代码练习归练习绝对不要在任何真实机器上体验任何机器都会被拖垮。4. 数组的创建、遍历与批量构造技巧数组是 Shell 数据处理的承重墙。这个章节从最基本的定义语法开始一路讲到关联数组和mapfile批量读文件把增删改查和那些藏在引号里的坑逐一拆透。4.1 索引数组定义与常见坑空格分隔、通配符展开索引数组最常见的定义方式是用圆括号加空格分隔元素FRUITS(apple banana cherry) echo ${FRUITS[0]} # apple echo ${FRUITS[2]} # cherry echo ${#FRUITS[]} # 3元素个数一个新手常踩的坑是元素内容里如果包含空格定义时不做处理就会变成两个元素。想要元素本身带空格必须给元素加引号# 错误user1 和 user2 被拆成两个元素 USERS(zhang san li si) # 正确两个元素明确包裹 USERS(zhang san li si)另一个坑是通配符展开。arr(*.log)如果当前目录正好有多个 .log 文件数组就会包含所有文件名而不是字面量*.log。这既是坑也是特性借助这个特性可以把一组匹配文件直接装入数组比如ALL_CONFIG(/etc/myapp/*.conf)脚本立刻就能遍历所有配置文件。4.2 数组增删改查操作清单索引数组的操作在日常脚本里离不开这些整理成清单方便查阅# 定义空数组 arr() # 追加单个元素 arr(new_item) # 追加多个元素 arr(item1 item2) # 按下标赋值覆盖 arr[2]replacement # 读取指定下标 echo ${arr[2]} # 读取全部元素两个引号版本的区别见 4.5 echo ${arr[]} echo ${arr[*]} # 切片从下标 1 开始取 2 个元素 slice(${arr[]:1:2}) # 删除一个下标元素 unset arr[2] # 删除整个数组 unset arr # 数组长度 echo ${#arr[]} # 重新构造数组删除后下标会重新编号 arr(${arr[]})注意unset arr[2]一定要给下标加引号否则 Bash 可能做路径名展开把arr[2]当成一个文件模式处理。删除元素之后数组原有下标不会自动重排${arr[2]}会变成空但${arr[]}依然能列全所有剩余元素。想重新连续编号用最后一行重新赋值。4.3 关联数组bash 4的应用场景索引数组用数字当下标关联数组用字符串键值对当下标类似 Python 的字典。定义方式# 必须先声明为关联数组 declare -A server_roles server_roles[web01]frontend server_roles[db01]backend echo ${server_roles[db01]} # backend echo ${!server_roles[]} # 所有键web01 db01关联数组最实用的场景就是做统计计数。比如统计日志里各状态码出现次数以前是用grep | sort | uniq -c现在可以在内存里直接维护declare -A count while read -r code; do ((count[$code])) done access.log这个用法比外部命令组合更直观循环结束后打印键值对就成了统计报告。注意关联数组要求 Bash 4.0 及以上macOS 自带的 Bash 3.2 用不了可以用brew install bash解决或者改用 awk 处理。这个兼容性问题后面专开一节细说。4.4 mapfile 批量读取文件到数组的高效用法把整个文件按行读进数组最朴素的方式是while read循环但效率一般。Bash 4 起提供了mapfile别名readarray一条命令搞定mapfile -t lines /etc/passwd echo ${#lines[]} # 行数 echo ${lines[0]} # 第一行-t表示去掉每行末尾的换行符。还有一个高级用法结合进程替换读多行命令的输出mapfile -t large_files (find /var/log -name *.log -size 100M)这段把超过 100M 的日志文件列表装进数组后续逐条处理。比arr($(find ...))的写法安全得多因为后者会做路径名展开和单词拆分遇到带空格的文件名就全乱了。4.5 ${arr[]} 与 ${arr[*]} 的区别引号的艺术这是数组操作里最经典的一个区别理解透了以后写循环基本不会出错${arr[]}每个元素作为独立字段即使元素里有空格也会被完整保留。遍历用这个。${arr[*]}所有元素合并成一个以空格分隔的字符串。转字符串用这个。不带引号的${arr[]}在循环里元素会被再次拆分带空格的文件名直接碎成多段。实际写循环时一定要记牢# 安全遍历元素内空格不乱拆 for item in ${arr[]}; do echo $item done # 错误示范文件名带空格会碎 for item in ${arr[]}; do echo $item done这个坑我踩过不止一次。有次处理一批路径中带空格的任务文件循环里丢了好几个任务排查到最后才发现是不带引号导致的字段拆分。从那以后我写遍历循环不加引号都会下意识停下来检查。5. 函数 数组的综合实战日志聚合统计理论讲再多不如把函数和数组放进一个真实需求里揉一遍。这一节用一个访问日志分析的例子把前面的知识点全部串起来。需求背景很简单某台机器每天产生大量访问日志运维同学想知道当天状态码分布、哪些 URL 访问量最大、哪些请求响应时间异常用于快速摸排线上问题。5.1 需求拆分为什么这个场景天然适合函数 数组如果不用函数和数组直接写流水线命令也能得到答案但每次想看不同维度的数据就得重新敲一遍命令或者说改动一大段逻辑。用函数拆的好处是每个分析步骤变成独立的可复用模块用数组承载的好处是原始日志读到内存后多维度统计不需要反复读文件。我把它拆成三个函数加两个数组。#!/bin/bash # 配置文件路径 LOG_FILE${1:-/var/log/nginx/access.log} # 定义原始数据数组和统计计数数组 declare -a raw_lines declare -A status_count declare -A url_count declare -A slow_requests5.2 模块拆分parse_log、count_status、url_rank第一个函数负责读取日志到数组这一步用mapfile替代手写循环parse_log() { if [[ ! -r $LOG_FILE ]]; then echo 错误日志文件不可读或不存在 2 return 1 fi mapfile -t raw_lines $LOG_FILE echo 日志总行数${#raw_lines[]} 2 }第二个函数统计状态码分布。这里用关联数组计数配合${line}按空格拆取字段。注意read -ra fields会自动按 IFS 拆分count_status() { local line for line in ${raw_lines[]}; do # 假设访问日志格式里第 9 个字段是状态码 local code code$(echo $line | awk {print $9}) [[ -z $code ]] continue ((status_count[$code])) done echo ---- 状态码分布 ---- for code in ${!status_count[]}; do echo $code ${status_count[$code]} done | sort -rn -k2 }第三个函数统计 Top URL。同样遍历数组把第 7 个字段取出来做键url_rank() { local line for line in ${raw_lines[]}; do local url url$(echo $line | awk {print $7}) [[ -z $url ]] continue ((url_count[$url])) done echo ---- 访问量 Top10 URL ---- for url in ${!url_count[]}; do echo ${url_count[$url]} $url done | sort -rn -k1 | head -10 }最后在主流程里依次调用parse_log || exit 1 count_status url_rank这样写的好处是如果临时想看特定 IP 的请求情况只需要再写一个类似结构的函数复用 raw_lines 数组即可完全不需要重读文件。5.3 数组传参给函数的正确姿势上面的函数直接用全局数组变量没走参数传递。实际工作中函数之间传递数组通常有两种姿势各有适用场景。第一种把数组按元素全部展开作为位置参数传进去。函数内用$重建数组print_array() { local items($) for item in ${items[]}; do echo $item done } arr(a b c) print_array ${arr[]}这种方式的优点是显式、调用关系清楚缺点是数组元素很多时参数列表会变得很臃肿而且 Bash 对参数总数有上限极端场景可能出问题。第二种直接使用全局数组变量函数内不声明同名局部数组。这种方式更常见代价是函数对全局有依赖可复用性稍差。我的经验是中小型脚本优先用全局数组变量函数之间默认共享数据开发效率高数组需要被多处调用且内容变化大、需要隔离时才用参数展开传递。两者结合也能用比如函数接收一个数组名作为参数内部用间接引用访问。5.4 实战结果解读与后续扩展脚本跑完输出类似这样日志总行数204801 ---- 状态码分布 ---- 200 185302 404 11876 301 4521 500 5102 ---- 访问量 Top10 URL ---- 4250 /api/v1/order/query 3192 /index.html ...从这个结果能快速判断500 占比约 2.5%在可接受范围内但需要关注404 多为爬虫或老链接。想进一步扩展可以加慢请求统计函数把响应时间超过阈值比如第 10 个字段超过 3000 毫秒的日志行单独收集进另一个数组再做详细分析。这个结构还能扩展成“多文件日志分析”把多个日志文件名放进一个数组循环调用 parse_log 读取并聚合最后统一输出。核心逻辑一点不用改只是入口层的配置变丰富这正是函数和数组分层设计的意义。6. 性能与兼容性的个人经验谈Shell 脚本跑起来快不快往往不是语法问题而是设计问题。这一节分享我在实际生产环境中踩过的性能坑和兼容性问题以及配合这些经验的代码规范。6.1 bash 3.2 和 bash 4 的差异关联数组只是一面最典型的分水岭是 Bash 4 带来的declare -A关联数组和mapfile。很多老系统尤其 macOS 自带版本、某些精简企业镜像默认 Bash 是 3.2一跑关联数组直接报“declare: -A: invalid option”。我在某次给客户机器部署脚本时本地一切正常生产机器一跑就崩最后发现生产机器 Bash 版本是 3.2。遇到这种情况有几个选择升级 Bash然后脚本第一行写#!/usr/bin/env bash确保用的是新路径的 Bash。不用关联数组改用 awk 实现统计逻辑。用索引数组配合一个辅助 key 数组模拟键值映射代码会丑一些但兼容性立马上来了。如果脚本要跨机器分发我的建议是在脚本开头做一次版本检查if (( BASH_VERSINFO[0] 4 )); then echo 需要 Bash 4当前版本$BASH_VERSION 2 exit 1 fi6.2 循环中调用外部命令的开销不可低估Shell 内置命令和外部命令的性能差距在循环场景里会被无限放大。比如逐行解析时如果对每一行都调用一次echo | awk处理二十万行日志等于启动了二十万个进程慢到怀疑人生。更好的做法是尽量用内置语法替代外部命令字符串截取用${var:offset:length}、正则匹配用[[ $line ~ pattern ]]、数值计算用(( ... ))。真的绕不开awk就把awk整体处理一次调用完成整个文件的分析而不要放在逐行循环里。我之前用awk {print $9}在循环里逐行跑处理三十万行耗时四分钟改成一次性awk {count[$9]} END {for (c in count) print c, count[c]}后全程不到三秒。这个数量级的差距足以让人改变写脚本的习惯。6.3 每天写脚本的规范set -euo pipefail 与 shellcheck函数和数组引入后脚本复杂度上来错误会更隐蔽。我现在的脚本默认头三行#!/bin/bash set -euo pipefail-e命令失败立即退出防止错误继续蔓延。-u使用未定义变量直接报错救过我无数次——变量名拼错不用等到运行到那一步才发现。o pipefail管道中任一命令失败整体返回失败避免grep 没有匹配时管道链还认为自己成功。配合shellcheck静态检查工具能在运行前发现大量低级问题比如该加引号没加、变量拼写错误、某个命令可能不存在。我几乎每写完一个小函数就会跑一次 shellcheck这已经成了肌肉记忆。6.4 大数组场景内存与临时文件的取舍数组把所有数据加载进内存好处是处理快、随机访问方便代价是内存占用随数据线性增长。一个二十万行的日志数组每个元素几十字节整体也就几十 MB完全可接受。但如果是上千万行的超大文件全部装进数组就不划算了。我的分界线是日志文件超过 50 万行基本不再全量读入数组改成分块处理或流式读取。mapfile也能配上限比如mapfile -n 10000 -t block file只读前一万行处理完再续读用循环实现流式处理while mapfile -t -n 10000 block [[ ${#block[]} -gt 0 ]]; do process_block ${block[]} # 把一万行作为一个批处理量 done huge_file.log这样内存占用稳定可控又保住了数组带来的便利。最后再分享一个连运维同学都不一定注意的体验Shell 脚本里的函数和数组最大的收益其实不是“省内存”或“跑得快”而是把原来只可意会的运维操作沉淀成了可读、可测、可交接的代码。我后来每次写脚本都会先画一画动作清单把反复出现的动作提成函数把批量数据装进数组等骨架立起来再填实现。坚持这个习惯之后我基本告别了改一行动全局、加一个功能动半个脚本的日子。做脚本和做工程一样先把结构立稳再谈优化这条经验放之四海而皆准。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。