在Linux系统管理和自动化脚本编写中,Shell脚本是最常用的工具之一。函数和数组作为Shell编程的两大核心特性,能显著提升脚本的可读性和复用性。我见过太多脚本因为缺乏良好的函数封装而变成难以维护的"面条代码",也见过不少开发者因为不了解数组特性而写出冗长低效的脚本。
函数允许我们将重复的逻辑封装起来,而数组则提供了处理批量数据的结构化方式。两者结合使用,可以写出既简洁又强大的Shell脚本。下面这张表格对比了函数和数组在Shell中的基本特性:
| 特性 | 函数 | 数组 |
|---|---|---|
| 定义方式 | function name() { ... } |
array=(elem1 elem2 elem3) |
| 引用方式 | name arg1 arg2 |
${array[index]} |
| 作用域 | 默认全局 | 默认全局 |
| 典型用途 | 代码复用,逻辑封装 | 批量数据处理 |
提示:虽然Shell中的函数和数组语法看起来简单,但实际使用时有很多细节需要注意,特别是在不同Shell解释器(Bash/Zsh等)中的行为差异。
Shell函数的定义有两种标准形式:
bash复制# 形式一:使用function关键字
function say_hello() {
echo "Hello, $1!"
}
# 形式二:省略function关键字
say_bye() {
echo "Goodbye, $1!"
}
调用这些函数时,直接写函数名加上参数即可:
bash复制say_hello "World" # 输出:Hello, World!
say_bye "Alice" # 输出:Goodbye, Alice!
函数参数通过位置变量$1, $2等访问,这和脚本参数的处理方式一致。但有几个特殊变量在函数中特别有用:
$#:传递给函数的参数个数$@:所有参数的列表$*:所有参数合并为单个字符串$?:函数退出状态码一个常见的误区是认为函数内的变量默认是局部的。实际上,Shell函数中的变量默认都是全局的!这意味着:
bash复制count=0
increment() {
count=$((count + 1))
}
increment
echo $count # 输出1,全局变量被修改
要创建局部变量,必须使用local关键字:
bash复制increment() {
local count=0
count=$((count + 1))
echo "Inside: $count"
}
increment # 输出:Inside: 1
echo $count # 输出空,全局count未被修改
Shell数组的索引从0开始(除非使用关联数组),定义数组最简单的方式是:
bash复制fruits=("Apple" "Banana" "Orange")
访问数组元素:
bash复制echo ${fruits[0]} # 输出:Apple
echo ${fruits[1]} # 输出:Banana
获取数组长度和所有元素:
bash复制echo ${#fruits[@]} # 输出数组长度:3
echo ${fruits[@]} # 输出所有元素:Apple Banana Orange
数组切片操作(Bash 4.0+):
bash复制echo ${fruits[@]:1:2} # 从索引1开始取2个元素:Banana Orange
添加元素到数组:
bash复制fruits+=("Grape") # 添加单个元素
fruits+=("Peach" "Pear") # 添加多个元素
删除数组元素:
bash复制unset fruits[1] # 删除索引为1的元素
unset fruits # 删除整个数组
注意:使用
unset删除数组元素后,索引不会自动重新排列。比如删除fruits[1]后,fruits数组将包含索引0和2的元素,但长度会相应减少。
Shell函数不像其他编程语言那样直接"返回"值,而是通过以下几种方式传递结果:
退出状态码:使用return返回0-255的整数,0表示成功
bash复制is_even() {
if (( $1 % 2 == 0 )); then
return 0 # 成功/真
else
return 1 # 失败/假
fi
}
is_even 4
echo $? # 输出0
输出到标准输出:通过echo/printf输出结果,调用者用命令替换捕获
bash复制add() {
echo $(( $1 + $2 ))
}
result=$(add 3 5)
echo $result # 输出8
全局变量:修改全局变量(不推荐,容易造成副作用)
bash复制result=""
compute() {
result=$(( $1 * $2 ))
}
compute 3 4
echo $result # 输出12
最佳实践是优先使用第二种方法(输出到标准输出),因为它最灵活且没有副作用。对于简单的真假判断,可以使用退出状态码。
处理可变数量参数时,$@特别有用:
bash复制sum() {
local total=0
for num in "$@"; do
total=$((total + num))
done
echo $total
}
echo $(sum 1 2 3 4) # 输出10
处理命名参数(模拟关键字参数):
bash复制create_user() {
while [[ $# -gt 0 ]]; do
case "$1" in
-u|--username)
username="$2"
shift 2
;;
-p|--password)
password="$2"
shift 2
;;
*)
echo "Unknown option: $1"
exit 1
;;
esac
done
echo "Creating user: $username with password: $password"
# 实际创建用户逻辑...
}
create_user -u alice -p secret123
随着脚本复杂度增加,将函数组织到单独的文件中是明智之举。创建函数库文件(如utils.sh):
bash复制#!/bin/bash
# utils.sh
log_info() {
echo "[INFO] $(date '+%Y-%m-%d %H:%M:%S') - $@"
}
log_error() {
echo "[ERROR] $(date '+%Y-%m-%d %H:%M:%S') - $@" >&2
}
validate_number() {
[[ "$1" =~ ^[0-9]+$ ]] && return 0 || return 1
}
在其他脚本中使用source命令加载函数库:
bash复制#!/bin/bash
source ./utils.sh
log_info "Script started"
if validate_number "123"; then
log_info "Valid number"
else
log_error "Invalid number"
fi
提示:使用
source或.命令加载函数库时,要注意路径问题。最佳实践是使用绝对路径,或者在脚本开头设置SCRIPT_DIR=$(dirname "$(readlink -f "$0")"),然后source "$SCRIPT_DIR/utils.sh"。
Bash 4.0+支持关联数组(类似其他语言中的字典/哈希表):
bash复制declare -A user # 必须声明
user=(
[name]="Alice"
[age]=25
[email]="alice@example.com"
)
echo "User name: ${user[name]}" # 输出:Alice
echo "User age: ${user[age]}" # 输出:25
遍历关联数组:
bash复制for key in "${!user[@]}"; do
echo "$key: ${user[$key]}"
done
注意:关联数组在Bash 4.0以下版本不可用。编写可移植脚本时要注意检查Bash版本:
if ((BASH_VERSINFO[0] < 4)); then echo "需要Bash 4.0+"; exit 1; fi
字符串转数组(按空格分割):
bash复制str="Apple Banana Orange"
fruits=($str) # 注意:如果字符串中有空格会出问题
更安全的方式(处理带空格的元素):
bash复制str="Apple 'Red Delicious' Banana"
eval "fruits=($str)" # 使用eval要特别小心安全问题
或者使用read命令:
bash复制str="Apple|Banana|Orange"
IFS='|' read -ra fruits <<< "$str"
数组转字符串:
bash复制fruits=("Apple" "Banana" "Orange")
str="${fruits[*]}" # 用第一个字符IFS连接
echo "$str" # 输出:Apple Banana Orange
# 自定义分隔符
IFS=',' str="${fruits[*]}"
echo "$str" # 输出:Apple,Banana,Orange
Shell本身不支持真正的多维数组,但可以通过以下方式模拟:
数组的数组(Bash 4.0+):
bash复制declare -A matrix
matrix[0,0]=1
matrix[0,1]=2
matrix[1,0]=3
matrix[1,1]=4
echo "${matrix[0,1]}" # 输出2
使用分隔符的一维数组:
bash复制matrix=("1,2" "3,4")
row=0
col=1
IFS=',' read -ra cells <<< "${matrix[$row]}"
echo "${cells[$col]}" # 输出2
使用关联数组:
bash复制declare -A matrix
matrix["0-0"]=1
matrix["0-1"]=2
matrix["1-0"]=3
matrix["1-1"]=4
echo "${matrix["0-1"]}" # 输出2
假设我们需要分析一个网站的访问日志,统计不同页面的访问次数。下面是一个结合函数和数组的实现:
bash复制#!/bin/bash
declare -A page_counts # 关联数组存储页面计数
# 初始化统计函数
init_stats() {
page_counts=() # 清空数组
}
# 分析单行日志
analyze_line() {
local line="$1"
local page
# 简单提取页面路径(实际应使用更健壮的解析方法)
page=$(echo "$line" | awk '{print $7}')
# 更新计数
((page_counts["$page"]++))
}
# 显示统计结果
show_stats() {
echo "Page Access Statistics:"
echo "----------------------"
for page in "${!page_counts[@]}"; do
printf "%-30s: %d\n" "$page" "${page_counts[$page]}"
done | sort -nr -k2 # 按访问量降序排序
}
# 主处理函数
process_log() {
local log_file="$1"
local line
init_stats
while IFS= read -r line; do
analyze_line "$line"
done < "$log_file"
show_stats
}
# 使用示例
process_log "/var/log/nginx/access.log"
解析INI风格的配置文件,将节(section)和键值对存入嵌套的关联数组:
bash复制#!/bin/bash
declare -A config # 主配置数组
# 解析配置文件
parse_config() {
local file="$1"
local current_section=""
local line
while IFS= read -r line; do
line=${line%%#*} # 移除注释
line=${line%%;*} # 移除行尾注释
line=${line//[$'\t\r\n']} # 移除空白字符
line=${line#"${line%%[![:space:]]*}"} # 移除前导空格
line=${line%"${line##*[![:space:]]}"} # 移除尾部空格
[[ -z "$line" ]] && continue # 跳过空行
# 检查节定义 [section]
if [[ "$line" =~ ^\[(.+)\]$ ]]; then
current_section="${BASH_REMATCH[1]}"
declare -g -A "config[$current_section]" # 声明子数组
elif [[ "$line" =~ ^([^=]+)=(.*)$ ]]; then
local key="${BASH_REMATCH[1]}"
local value="${BASH_REMATCH[2]}"
if [[ -z "$current_section" ]]; then
echo "Error: Key without section: $key" >&2
return 1
fi
# 存储键值对
eval "config[$current_section][$key]=\"$value\""
else
echo "Warning: Invalid line: $line" >&2
fi
done < "$file"
}
# 获取配置值
get_config() {
local section="$1"
local key="$2"
eval "echo \"\${config[$section][$key]}\""
}
# 使用示例
parse_config "app.conf"
echo "Database host: $(get_config "database" "host")"
echo "Server port: $(get_config "server" "port")"
处理目录中的多个文件,根据文件类型执行不同操作:
bash复制#!/bin/bash
declare -a text_files image_files other_files # 三个数组分别存储不同类型的文件
# 文件分类函数
classify_files() {
local dir="$1"
text_files=()
image_files=()
other_files=()
for file in "$dir"/*; do
if [[ -d "$file" ]]; then
continue # 跳过目录
fi
case "$(file -b --mime-type "$file")" in
text/*)
text_files+=("$file")
;;
image/*)
image_files+=("$file")
;;
*)
other_files+=("$file")
;;
esac
done
}
# 处理文本文件
process_text_files() {
for file in "${text_files[@]}"; do
echo "Processing text file: $file"
# 实际处理逻辑,如转换编码、查找替换等
done
}
# 处理图片文件
process_image_files() {
for file in "${image_files[@]}"; do
echo "Processing image file: $file"
# 实际处理逻辑,如调整大小、添加水印等
done
}
# 主函数
main() {
local target_dir="${1:-.}" # 默认为当前目录
if [[ ! -d "$target_dir" ]]; then
echo "Error: Directory not found: $target_dir" >&2
return 1
fi
classify_files "$target_dir"
echo "Found ${#text_files[@]} text files, ${#image_files[@]} images, and ${#other_files[@]} other files."
process_text_files
process_image_files
}
main "$@"
减少子shell调用:
bash复制# 慢:每次调用都创建子shell
sum=$(add 3 5)
# 快:使用全局变量(谨慎使用)
add() {
result=$(( $1 + $2 ))
}
add 3 5
echo $result
避免在循环中调用外部命令:
bash复制# 慢:每次循环都调用date
for i in {1..100}; do
timestamp=$(date +%s)
# ...
done
# 快:在循环外调用一次
timestamp=$(date +%s)
for i in {1..100}; do
# 使用$timestamp
done
使用shell内置功能代替外部命令:
bash复制# 慢:使用awk
count=$(echo "$var" | awk '{print length}')
# 快:使用shell内置字符串操作
count=${#var}
大数组的内存占用:
稀疏数组的处理:
bash复制# 创建稀疏数组
array=()
array[1000]="value"
# 遍历时跳过空元素
for i in "${!array[@]}"; do
echo "$i: ${array[$i]}"
done
数组复制的效率:
bash复制# 直接赋值是高效的
new_array=("${old_array[@]}")
# 关联数组复制
declare -A new_array
for key in "${!old_array[@]}"; do
new_array["$key"]="${old_array[$key]}"
done
使用set -x启用调试:
bash复制# 在脚本开头或函数内
set -x
# 要调试的代码
set +x
检查函数调用栈:
bash复制# 在函数中添加
echo "Call stack: ${FUNCNAME[@]}"
数组内容调试:
bash复制# 打印数组内容和结构
declare -p array_name
性能分析:
bash复制# 使用time命令测量执行时间
time my_function args
使用trap调试:
bash复制# 捕获EXIT信号打印变量状态
trap 'declare -p important_vars' EXIT
提示:在复杂脚本中,考虑使用专门的调试工具如bashdb或vscode的bash调试插件。对于生产脚本,实现详细的日志记录比交互式调试更实用。