☰
Shell函数与数组:从传参机制到作用域避坑指南
2026/10/14 7:51:40 网站建设 项目流程

写Shell脚本的人大概都经历过这个阶段:脚本越写越长,最后变成一坨从上到下顺序执行的命令,改一处牵动全身。等你想把重复逻辑抽出来复用的时候,发现Shell的函数和数组跟其他语言相比有不少反直觉的地方——函数定义里居然不能写参数列表、函数返回值居然不是靠return拿的、数组传参居然会被"压扁"成一个个独立的参数。这篇博客就是来把这层窗户纸捅破的。

我这篇内容主要围绕Shell编程中函数和数组这两个核心主题展开,覆盖从定义语法、传参机制、返回值处理,到数组的初始化、遍历、切片、关联数组,再到二者组合使用时容易踩的坑。适合两类人看:一类是刚接触Shell脚本没几天、想系统搞清楚函数和数组用法的新手;另一类是已经写了一阵子脚本,但经常在自己的脚本里遇到"变量值传不进去""数组遍历出来不对""管道后面变量丢失"这类问题,想一次性搞明白背后原因的人。

1. 函数定义与调用:为什么Shell函数写起来和别的语言不太一样

1.1 四种定义方式的语法差异

Shell里函数的定义方式有四种,按兼容性和习惯排列如下:

# 方式一:function关键字 + 函数名 + 括号 function myfunc { echo "hello from myfunc" } # 方式二:function关键字 + 函数名 + 括号,再加大括号 function myfunc() { echo "hello from myfunc" } # 方式三:直接函数名加括号(最常用,类似别的语言) myfunc() { echo "hello from myfunc" } # 方式四:不用function关键字,也不带括号(极少见) myfunc { echo "hello from myfunc" }

四种方式在bash里都能跑,但方式一和方式三是最推荐的。方式二是历史遗留的混合写法,虽然bash支持,但有些严格的语法检查工具(比如shellcheck)会提示兼容性问题。方式四只在极少数场景和特殊shell环境下能跑,不建议用,因为可读性差,也容易让人误解。

这里有一个必须说清楚的点:函数名后面那一对括号,只是语法标记,里面绝对不能写参数列表。这点和Python、Java那些语言完全不一样,Shell函数的参数是在调用时通过位置参数隐式传入的,后面细讲。

另外一个细节是函数命名的规则:函数名本质上等同于一个命令名,所以不能用普通命令的名字去命名,比如别写ls() {}这种,会覆盖掉系统的ls命令。也别和脚本里的变量名冲突,因为Shell的函数名和变量名共享同一个命名空间,定义了一个叫config的函数又定义一个叫config的变量,后定义的那个会把前一个覆盖掉,这个坑我在生产脚本里踩过一次,排查了很久才发现是命名冲突。

1.2 传参机制:调用时不带括号,参数靠位置匹配

函数调用的语法是:

myfunc arg1 arg2 arg3

注意,没有括号。参数之间用空格分隔,这和函数定义时那对括号一点关系都没有。

函数内部通过$1、$2、$3……去访问这些参数,$0在函数内部依然指向当前脚本名而不是函数名,$#表示参数个数,$@表示所有参数列表,$*也表示所有参数,但表现略有差异(后面数组章节会说)。

看一下这个例子:

greet() { echo "第一个参数: $1" echo "第二个参数: $2" echo "参数总数: $#" echo "所有参数: $@" } greet hello world

输出:

第一个参数: hello 第二个参数: world 参数总数: 2 所有参数: hello world

这里有一个新手特别容易搞混的点:函数里的$1和脚本顶层的$1不是同一个东西。脚本顶层的$1是执行脚本时传入的命令行参数,函数里的$1是函数被调用时传入的参数。两者互不干扰,作用域是分开的。

举个例子就明白了:

#!/bin/bash # 脚本接收命令行参数:./test.sh apple echo "脚本的参数: $1" # 输出 apple show_func_args() { echo "函数接收到的参数: $1" # 输出 banana } show_func_args banana

1.3 返回值机制:return和echo是完全不同的东西

这应该是Shell函数里最容易踩坑的一个机制了。其他语言里函数一般通过return把计算结果返回给调用方,但在Shell里,return只能返回0~255之间的整数,而且这个整数的语义是退出状态码,不是计算结果。

add() { local sum=$(( $1 + $2 )) return $sum } add 5 3 echo "返回码: $?" # 输出 8

这段代码虽然能在数值小于256时跑出"正确"结果,但它是错的。因为return返回的应该是对错状态(0表示成功、非0表示失败),而不是数据。一旦数值超过255会被截断,比如add 200 100,期望300,实际返回44。这个坑非常隐蔽。

那计算结果怎么拿回来?答案是用echo输出,再用命令替换$(...)去捕获:

add() { echo $(( $1 + $2 )) } result=$(add 5 3) echo $result # 输出 8

这种方式可以返回任意长度的字符串。这是Shell函数甚至是Shell命令之间传递数据最核心的通道。

理解了这一点,你就能看懂那些看起来"莫名其妙"的写法了——比如用grep的返回状态码做流程控制:

check_config() { grep -q "timeout=30" /etc/myapp/config.conf return $? # grep自己就是0或1,这里甚至可以省略直接return } if check_config; then echo "配置项存在" fi

这里函数里执行的最后一条命令的退出码,本身就是函数的退出状态,所以很多老手写函数根本不会显式写return,直接让最后一条命令的退出码作为函数返回值。只有当函数里有多步操作、又希望暴露特定退出状态时,才需要return。

2. 变量作用域:Shell函数里最容易忽略的全局污染问题

2.1 不写local的代价

Shell函数里的变量,默认是全局的。这大概是所有从Python、Java转到Shell写脚本的人最不适应的一点。

看这个例子:

set_counter() { count=$1 } count=100 set_counter 5 echo $count # 输出5,而不是100

函数内部的count=$1直接改掉了外部的全局变量。函数体里任何一个变量赋值,都会影响函数外部同名的变量。

要在函数内部创建独立于外界的变量,用local关键字:

set_counter() { local count=$1 count=$(( count + 1 )) } count=100 set_counter 5 echo $count # 输出100,外部变量的值没有被改变

local只对当前函数体生效,如果函数里还调用了别的函数,被调用函数里声明的local变量也不是当前函数能访问的——作用域是一层层隔离的。这一点和JavaScript里的var/let有点类似,但又没有块级作用域那么细,Shell的local是函数级作用域。

2.2 命令替换和管道:那个看不见的子Shell

就算你每次都坚持写local,还有一个比全局污染更阴险的坑——子Shell变量隔离。

先看命令替换:

get_count() { local n=10 echo $n } result=$(get_count) echo $result # 输出10,这个没问题

这里的$(get_count)会在一个子Shell进程里执行get_count,函数里做的所有操作(包括对全局变量的修改)都不会影响到当前Shell。

真正阴险的是管道。看这段:

count=0 cat access.log | grep "ERROR" | while read line; do count=$(( count + 1 )) done echo $count # 输出0,但你可能期望的是错误行数

管道|会把后面那部分命令放到子Shell里执行,while循环里对count的修改发生在子Shell里,循环结束,子Shell退出,修改也随之消失。外面那个count还是0。

这个问题有几种解决方法:

  1. 用进程替换(process substitution)替代管道:
count=0 while read line; do count=$(( count + 1 )) done < <(grep "ERROR" access.log) echo $count # 正确输出错误行数
  1. 用临时文件替代管道。

  2. 把处理逻辑拆到一个单独函数里,函数最终用echo把结果打出来,再通过命令替换捕获。

2.3 全局配置变量的读写习惯

实战当中,写较长脚本时,我习惯把所有配置集中放在脚本顶部:

#!/bin/bash readonly BACKUP_DIR="/data/backup" readonly DB_NAME="mydb" readonly RETENTION_DAYS=7

函数里以读取方式使用这些常量,这是没问题的。但如果某个函数需要修改一个全局变量,比如维护一个计数器或者状态标志,我不推荐靠函数内部直接改全局变量来传递状态。因为代码量一增加,你很难跟踪是哪个函数在什么时候改了这个值。

我的习惯是:这类可变状态尽量用返回值(echo)显式传,比如:

process_lines() { local total=0 while read -r line; do [[ "$line" =~ ^valid ]] && total=$(( total + 1 )) done echo $total } valid_count=$(process_lines < input.txt)

这种写法读起来和调试起来都舒服得多。

3. 数组的基本操作:定义、取值、遍历与边界细节

3.1 定义和取值:索引从0开始,空格天然分行

Shell数组的定义方法主要有几种:

# 直接用小括号,空格分隔各项 fruits=(apple banana orange) # 显式指定索引 fruits[0]=apple fruits[1]=banana fruits[5]=orange # 允许跳跃索引 # 从命令结果生成数组(按行或按空格切分) users=($(cat /etc/passwd | cut -d: -f1))

取值的方法:

echo $fruits # 输出 apple,不加下标默认取第0个 echo ${fruits[1]} # 输出 banana echo ${fruits[@]} # 输出所有项 echo ${#fruits[@]} # 输出数组长度

这里最常见的错误是漏写大括号,比如写$fruits[1],Shell会先把$fruits解析成数组第0个元素apple,然后拼接上[1],最终输出apple[1]。所有对数组下标和长度的操作,都必须带${}。

3.2 遍历数组:为什么"${arr[@]}"要加引号

遍历数组最常见的写法是:

arr=("item one" "item two" "item three") for item in "${arr[@]}"; do echo "$item" done

注意看双引号包裹。"${arr[@]}"在展开时,数组的每个元素作为一个独立的词,哪怕元素内部有空格也不会被拆分。如果去掉双引号写成${arr[@]},数组里带空格的元素会被按空格二次拆分,"item one"就变成item和one两个词了。

做个实验:

arr=("hello world" "foo bar") for item in ${arr[@]}; do echo "[$item]" done # 输出: # [hello] # [world] # [foo] # [bar] # 而使用"${arr[@]}"则输出: # [hello world] # [foo bar]

类似的还有"${arr[*]}"。@和*的区别在于:加引号时,"${arr[@]}"把每个元素作为独立字段,"${arr[*]}"把所有元素合并成一个字符串,用第一个字符(通常是空格)连接。这条规则在函数传参场景里也非常重要,后面会提到。

3.3 追加、删除、切片和关联数组

追加元素:

arr=(apple banana) arr+=(orange) # 用+=追加 arr[2]=grape # 或者直接指定新下标

删除元素:

unset arr[1] # 删除下标为1的元素

这里有个细节:unset arr[1]删除之后,数组的长度${#arr[@]}会减1,但剩下元素的下标不会自动重排,原来下标2的元素依然在下标2。如果你想要一个紧凑的数组,需要重新构建。很多人在循环里用for i in "${!arr[@]}"遍历下标时就会踩到这个空位坑。

切片操作:

arr=(a b c d e) echo "${arr[@]:1:3}" # 输出 b c d,从下标1开始取3个

关联数组(字典)也是Shell能用的数据结构,但需要先用declare -A声明:

declare -A server_role server_role[web01]="nginx" server_role[db01]="mysql" server_role[cache01]="redis" echo ${server_role[web01]} # 输出 nginx for key in "${!server_role[@]}"; do echo "$key -> ${server_role[$key]}" done

注意关联数组的遍历用"${!server_role[@]}"(感叹号取出所有键),普通数组也能用这个语法取出所有下标。关联数组的常见用途是做配置映射,比如把服务名映射到端口、把主机名映射到IP。

3.4 把字符串拆成数组:不是所有切割都要靠awk

经常需要处理的是按分隔符拆分字符串。最常用的是用IFS:

str="192.168.1.1,192.168.1.2,192.168.1.3" IFS=',' read -r -a ip_list <<< "$str" for ip in "${ip_list[@]}"; do echo "$ip" done

这里IFS=','是指定逗号作为字段分隔符,-a表示把切出来的结果存入数组。<<<是here-string,把$str作为输入传给read。值得注意IFS=','这个写法只在当前命令执行时临时生效,不会污染外部环境,如果你用export IFS=','去改全局的IFS,后面所有脚本行为都可能受影响,比如for循环的默认分词逻辑会变,所以不建议全局动IFS。

如果不限定必须是数组,直接用cut或者awk也行,但痛点在于它们输出的是按行组织的文本,再往数组里塞时还得考虑换行转空格的问题。所以做数组拆分时,IFS+read -a是最顺手的方案。

4. 函数与数组的组合使用:参数传递的边界与最常用的批处理模式

4.1 数组作为函数参数:会被展开成一串参数

先说结论:Shell函数不能直接接收一个数组变量。当你尝试把数组作为参数传进去时,它实际上被展开成了一系列独立的参数,函数内部拿到的是这些参数,而不是一个整体数组。

show_arr() { echo "函数内 \$1: $1" echo "函数内 \$2: $2" echo "函数内参数总数: $#" } arr=("aaa" "bbb" "ccc") show_arr "$arr"

运行后$arr等价于${arr[0]},传入的只有一个元素aaa,函数内$#是1。想要传整个数组,必须在调用时展开:

show_arr "${arr[@]}"

这样四个参数就传进去了,函数内部用"$@"可以拿回完整的参数列表。但是如果想在函数里把参数重新拼回一个数组,可以这样:

process_arr() { local items=("$@") for item in "${items[@]}"; do echo "处理: $item" done }

关键就是local items=("$@")这一行,把函数收到的所有位置参数重新聚合成一个数组。这条模式可以用来写"接受数组的通用函数"。

4.2shift的正确用法:处理位置参数队列

shift是一个经常被忽略但非常实用的内置命令。作用是把函数或脚本的位置参数整体左移一位,$1变成原来的$2,$#自动减1。

一个典型场景是脚本需要循环读取剩余参数:

process_all() { while [ $# -gt 0 ]; do echo "当前参数: $1" shift # 丢弃第一个参数,后面的补上来 done } process_all a b c d # 输出: # 当前参数: a # 当前参数: b # 当前参数: c # 当前参数: d

shift还可以带数字,比如shift 2一次跳过两个参数。在解析命令行选项时,这个命令特别常用:遇到-f就取$2作为文件名然后shift 2,遇到普通参数就shift 1。

4.3 一个可以直接抄作业的批处理实例

把前面这些知识串起来,写一个比较实用的例子:批量检查一组服务器列表,把每个服务器的状态打印出来,并统计成功和失败的数量。

#!/bin/bash check_server() { local host=$1 if ping -c 1 -W 1 "$host" >/dev/null 2>&1; then echo "OK: $host" return 0 else echo "FAIL: $host" return 1 fi } stats() { local success=0 local fail=0 while [ $# -gt 0 ]; do if check_server "$1"; then success=$(( success + 1 )) else fail=$(( fail + 1 )) fi shift done echo "成功: $success, 失败: $fail" } hosts=(192.168.1.1 192.168.1.2 192.168.1.3) stats "${hosts[@]}"

这里check_server返回的是退出状态码(0/1),在if check_server中Shell会直接根据退出码判断真假,不需要显式拿$?去比较。而stats里通过shift逐个消费参数,最终统计结果。这个函数+数组的配合模式,几乎可以覆盖运维批处理脚本的大部分需求。

5. 实战中踩过的高频坑:空数组、管道、read循环和性能陷阱

5.1set -u状态下的空数组

很多脚本为了严谨会在开头加set -eu,其中-u表示遇到未定义变量就报错退出。这个设置对数组有个隐藏影响:

set -u arr=() echo "${arr[@]}" # 在旧版本bash中会报错:arr[@]: unbound variable

空数组${arr[@]}展开后是"零个字段",在部分bash版本结合set -u会判定为未绑定变量而报错。解决方法是先判断长度:

if [ "${#arr[@]}" -gt 0 ]; then echo "${arr[@]}" fi

或者用${arr[@]+"${arr[@]}"}这种兼容写法,不过读起来比较绕。实际写脚本时我更推荐先做长度判断,逻辑也更清晰。

5.2 管道和while read丢失变量:最常见的一个谜之bug

前面提到过管道和子Shell的问题,这里专门展开一个高频场景。很多新手这样统计日志里Error出现的次数:

count=0 cat app.log | grep "Error" | while read -r line; do count=$(( count + 1 )) done echo "Error次数: $count"

运行之后count永远是0。原因就是管道后面的while在子Shell里执行,所有变量修改都带不出来。不要用管道去喂while read循环,改一下输入方式:

count=0 while read -r line; do count=$(( count + 1 )) done < <(grep "Error" app.log) echo "Error次数: $count"

或者直接把两个步骤放到同一条管道里也输出结果:

count=$(grep -c "Error" app.log) echo "Error次数: $count"

能用一个现成命令解决的事,真没必要造个循环上去。

5.3 大文件场景下:while read优于for循环

如果数据来自文件,很多人会写:

for line in $(cat bigfile.txt); do ... done

这种写法在文件较大时会遇到两个问题:一是命令替换$(cat ...)会先一次性把整个文件读入内存,内存占用很高;二是句柄和空格分词的坑——文件里每行的空格会被当成分隔符,行内容被拆得七零八落。

正确做法是用while read -r line配合输入重定向:

while IFS= read -r line; do echo "$line" done < bigfile.txt

IFS=设为空是为了不让行首行尾的空白被裁剪掉,-r是防止反斜杠被解释。逐行读取,边读边处理,对内存友好得多。

5.4 一个容易忽略的杂项清单:echo转义、浮点运算和子进程开销

还有一些杂项问题我平时经常遇到,挑几个一起说了:

  • echo在不同发行版对转义处理不一样。echo "$line"如果line里有\n,有的会输出两个字符,有的会转义成换行。写脚本时遇到这类问题,统一改用printf '%s\n' "$line",行为完全可控。

  • Shell的算术运算不支持浮点数。$(( 5 / 2 ))结果是2,不是2.5。如果需要浮点计算,用awk或bc。千万别试图用$(( ))去算平均耗时,结果会让你怀疑人生。

  • 函数调用是有进程开销的(bash里函数调用通常不启新进程,但命令替换会),在循环里调用函数、又在函数里用命令替换,层层嵌套,跑大数据集时会明显变慢。性能敏感的场景,尽量把函数设计成批处理模式(接收数组、循环内部只做纯算术或内置命令),避免为每一条数据频繁启动外部命令。

5.5 调试Shell脚本的一点狠招

最后分享一个调试经验。很多人调试Shell脚本只靠echo "here"和echo "value=$var",效率很低。我建议直接两条路走:

一是**bash -x script.sh**,一行一行地展开执行过程,每条命令和变量展开后的实际值都打在屏幕上。排查数组下标问题、引号展开问题有奇效。觉得输出太密集的时候,可以在脚本特定区域临时加set -x和set +x,只打印感兴趣的那段。

二是给每个函数加一个调试开关:

DEBUG=false log() { $DEBUG && echo "[DEBUG] $*" }

需要调试时把脚本顶部的DEBUG=true改一下,所有关键位置埋的log就会输出,不用反复改脚本和删脚本。

写Shell最大的幻觉就是"反正就是几行命令拼一拼,没什么可学的"。实际当脚本超过两百行、开始涉及数组传参和函数复用后,Shell特有的是非观——阉割的返回值机制、默认全局的变量、来着不拒的管道子Shell——会比很多语言更折腾人。但恰恰是这些东西决定了Shell在运维领域不可替代的生态位:它不追求工程上的优雅,追求的是用最少的代码操纵操作系统命令的组合。这套函数与数组的组合写法,配合shift、while read以及local的作用域管理,大概就是"少踩坑"的核心基本功了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询