「速通Shell」聚砖成墙,Shell函数封装之道

为保持连贯性,学习shell循环和遍历之前,先了解下shell函数。在前面的篇章中已经讲述过Shell变量引用,如果说变量是一块块零散的砖,那么Shell函数就是把这些砖头垒成一面墙的工艺。

函数就是给脚本"砌墙"的过程,一段完成特定功能的代码,被一个名字装起来,需要的时候喊它一声,它就替你把那件事办了。一来二去,脚本的主体变成了一个清晰的调用清单,可读性一下子就上来了。

更重要的是,函数是可以被复用的。今天写一个打印日志的函数,明天另一个脚本里还能继续用,再不用重复造轮子。

这一篇,我们就简单聊一聊Shell里的函数,包括怎么定义、怎么调用、怎么传参、怎么返回,以及一个很多人都会踩的坑------变量作用域。

什么是Shell函数

什么是函数?从编程的角度,函数就是一段被命名的代码块。

我们日常写Shell脚本,本质上是在按顺序书写一条条Shell命令,让它们按部就班地执行。这种模式有一个专业一点的说法,叫"顺序结构"。但顺序结构解决不了所有问题------有些逻辑会重复出现,有些逻辑需要根据外部输入做不同反应,这时候我们就需要一种"打包"的机制,把一段代码起个名字收拢起来,留出一个口子让外部传数据进来。

这个口子就是参数,这个名字就是函数名,这段被收拢的代码就是函数体。函数在需要的时候被调用执行,执行完之后,控制权交还给调用它的地方继续往下走,这个过程叫"调用与返回"。

很多高级编程语言里,函数是一等公民,复杂到可以写出一本厚厚的书。但Shell里的函数,相对来说要朴素得多。它没有返回值类型,没有访问修饰符,甚至不能像C语言那样返回复杂的数据结构。这种"简单"既是一种局限,也是一种优势------它让Shell函数非常容易上手,几乎看一遍示例就能写出来。

函数定义

Shell里定义函数有两种写法,这两种写法在功能上完全等价,只是风格略有差异。

第一种写法使用function关键字,这种写法显得比较"正式",关键字摆在那里,一眼就能看出这是一个函数:

复制代码
function greet() {
    echo "Hello, World!"
}

第二种写法更简洁,去掉function关键字,直接用函数名加括号的形式定义,这种写法是Bash官方推荐的风格,也是绝大多数生产脚本里看到的写法:

复制代码
greet() {
    echo "Hello, World!"
}

我个人更倾向于第二种写法,因为更短。function关键字更多是历史遗留的兼容写法,在POSIX标准里其实并不是必须出现的。括号()在函数定义里其实是可有可无的,写不写都行,但加上括号是个不错的习惯,它能让函数定义和普通的命令在视觉上区分开。

需要特别注意的是,函数体一定要用大括号{}包裹起来,而且左大括号和函数名之间需要有一个空格或者换行。这个细节非常容易踩坑,例如:

复制代码
greet() { echo "Hello"; }   # 错误:左大括号前没有空格
greet(){ echo "Hello"; }    # 正确

另外,函数体里的命令可以分号结尾,也可以换行结尾,两种方式都被允许。

函数调用

函数定义好之后,并不会自动执行。要让函数跑起来,必须显式地调用它。调用的方式很简单------直接写函数名即可:

复制代码
#!/bin/bash
​
# 定义函数
greet() {
    echo "Hello, World!"
}
​
# 调用函数
greet

执行这个脚本,屏幕上会打印出"Hello, World!"。就这么简单,没有复杂的语法,没有额外的修饰词,写个名字就完事了。

Shell函数调用和执行一条普通的命令几乎没有区别。函数名就相当于一个自定义的命令,调用函数和执行lsecho这些系统命令,在语法层面是一回事。这种统一性,是Shell脚本非常优雅的一个地方。

需要注意的是,函数必须先定义后调用。Shell解释器是从上到下顺序读取脚本的,当它读到函数名时,必须已经知道这个函数是干什么的,否则会报"command not found"的错误。所以在实际开发中,我们通常会把所有函数定义放在脚本的开头部分,把可执行逻辑(也就是调用函数的代码)放在最后,这样结构会非常清晰:

复制代码
#!/bin/bash
​
# 这里是函数定义区
greet() {
    echo "Hello, World!"
}
​
# 这里是函数定义区
say_goodbye() {
    echo "Goodbye, World!"
}
​
# 这里是脚本主体,调用函数
echo "脚本开始执行"
greet
say_goodbye
echo "脚本执行结束"

函数参数

函数定义好之后,它就是一个独立的"黑盒"了。但纯粹的黑盒没什么用,我们需要让外部的数据能够流进函数里,让它根据不同的输入产生不同的输出。这就需要用到参数。

Shell函数的参数机制和脚本参数机制是一模一样的,都是通过位置参数来传递。在函数体内部,通过$1$2$3这样的位置参数变量来获取调用者传入的参数,$#表示参数个数,$*$@表示所有参数。

来看一个具体的例子。我们写一个问候函数,让它能根据传入的名字打印出不同的问候语:

复制代码
#!/bin/bash
​
greet() {
    echo "Hello, $1! You are $2 years old."
}
​
# 调用函数,传入两个参数
greet "Tom" 25
greet "Jerry" 30

执行这个脚本,会得到下面的输出:

复制代码
Hello, Tom! You are 25 years old.
Hello, Jerry! You are 30 years old.

可以看到,$1对应第一个参数"Tom",$2对应第二个参数25。这种参数传递方式和Shell脚本的传参方式完全一致,对于熟悉Shell脚本的朋友来说,几乎不需要额外学习。

这里有一个非常重要的细节:$1$2这些位置参数在函数内部是"局部"的,也就是说它们只属于当前这个函数调用上下文,不会和脚本主体的位置参数混淆。假设我们脚本本身也有一个$1,函数内部访问的$1是函数调用时传入的那个参数,而不是脚本的$1。这种"局部化"的语义让函数具有了很好的隔离性,函数可以被独立地使用,而不用担心和外部发生命名冲突。

如果调用函数时没有传入足够的参数,那么对应的位置参数就是空字符串。比如greet "Tom"只传一个参数,那么函数内部的$2就是空的。这在编程中是一个常见的问题,所以好的函数习惯在开头先检查参数是否正确:

复制代码
greet() {
    if [ $# -lt 2 ]; then
        echo "Usage: greet <name> <age>"
        return 1
    fi
    echo "Hello, $1! You are $2 years old."
}

return 1表示函数异常退出,返回值1会作为这个函数的退出状态码。这种"用返回值表示错误"的约定,是Shell编程中非常常见的模式。

函数返回值

函数返回值这个问题,是Shell函数和很多高级语言函数差异最大的地方之一。

在C语言或者Java里,函数可以返回各种类型的数据------整数、字符串、对象、数组。但是在Shell里,函数的返回值只能是0到255之间的一个整数。这个限制乍一看很苛刻,但仔细想想,Shell函数的返回值并不是用来返回"数据"的,而是用来返回"状态"的------告诉调用者,这个函数是成功执行了还是出错了。

约定上,返回值0表示"成功",非0表示"失败"。这种约定和Shell命令本身的退出状态码是完全一致的。前面我们讲过的$?特殊变量,就是用来获取上一个命令或者上一个函数的退出状态码。

看一个具体的例子:

复制代码
#!/bin/bash
​
check_file() {
    if [ -f "$1" ]; then
        echo "文件 $1 存在"
        return 0
    else
        echo "文件 $1 不存在"
        return 1
    fi
}
​
# 调用函数
check_file "/etc/passwd"
echo "函数返回值: $?"
​
check_file "/tmp/notexist.txt"
echo "函数返回值: $?"

执行这个脚本,会得到类似下面的输出:

复制代码
文件 /etc/passwd 存在
函数返回值: 0
文件 /tmp/notexist.txt 不存在
函数返回值: 1

可以看到,$?准确地反映了函数的执行状态。这种"返回值就是状态码"的设计哲学,是Shell脚本里非常基础也非常重要的一个概念。

但是问题来了------如果函数需要把计算结果"传"出去怎么办?比如我们写一个加法函数,希望它把两个数的和返回给调用者。

这种情况下,我们通常不用return语句,而是用echo把结果"打印"出来,然后在调用处用$()或者反引号捕获这个输出:

复制代码
#!/bin/bash
​
add() {
    local result=$(($1 + $2))
    echo $result
}
​
# 用 $() 捕获函数的输出
sum=$(add 3 5)
echo "3 + 5 = $sum"

执行结果是3 + 5 = 8。注意看,这里我们用了三种技术:local声明局部变量(这个后面会详细讲)、$(())进行算术运算(前两篇变量引用里讲过)、$()捕获命令输出。

这种"用echo输出,用()捕获"的模式,弥补了return只能返回整数的限制,让函数可以"返回"任意字符串、浮点数、甚至多行文本。但是要注意,这种方式返回的不是"函数返回值",而是"函数输出"。调用者访问输出用'()捕获"的模式,弥补了return只能返回整数的限制,让函数可以"返回"任意字符串、浮点数、甚至多行文本。但是要注意,这种方式返回的不是"函数返回值",而是"函数输出"。调用者访问输出用`()捕获"的模式,弥补了return只能返回整数的限制,让函数可以"返回"任意字符串、浮点数、甚至多行文本。但是要注意,这种方式返回的不是"函数返回值",而是"函数输出"。调用者访问输出用'(),访问返回值用$?`,这两者千万不能混淆。

变量作用域

变量作用域这个问题,是Shell函数里最容易出错的地方之一。理解了它,你就能避免90%的Shell脚本调试难题。

默认情况下,Shell里定义的变量是全局的。这意味着在一个函数内部定义的变量,在函数外部依然可以访问。这点和很多高级语言(比如Python、Java)很不一样,那些语言里函数内部定义的变量默认都是局部的,函数执行完就销毁了。

看一个具体的例子:

复制代码
#!/bin/bash
​
my_func() {
    message="Hello from inside function"
    echo "函数内部: $message"
}
​
my_func
echo "函数外部: $message"

执行这个脚本,会得到下面的输出:

复制代码
函数内部: Hello from inside function
函数外部: Hello from inside function

可以看到,在函数内部定义的message变量,在函数外部依然存在。这就是一个典型的"变量泄漏"问题。如果我们有多个函数都定义了同名的变量,它们会相互覆盖,导致脚本的行为难以预测。

解决这个问题的方法是使用local关键字。local声明的变量只在当前函数内部有效,函数执行完毕就自动销毁:

复制代码
#!/bin/bash
​
my_func() {
    local message="Hello from inside function"
    echo "函数内部: $message"
}
​
my_func
echo "函数外部: $message"

执行这个脚本,输出会变成:

复制代码
函数内部: Hello from inside function
函数外部: 

这次,函数内部的message变量没有泄漏到外部,外部访问它时是空字符串。

这是一个非常重要的编程习惯:在函数内部定义的所有变量,都应该使用local声明为局部变量。这个习惯可以避免大量的隐藏bug,让函数真正成为一个"独立"的模块。

变量作用域还有一个隐藏的坑------local声明的变量会"屏蔽"外层的同名变量。看下面的例子:

复制代码
#!/bin/bash
​
name="outer"
​
my_func() {
    local name="inner"
    echo "函数内部: $name"
}
​
my_func
echo "函数外部: $name"

执行结果是:

复制代码
函数内部: inner
函数外部: outer

可以看到,函数内部的local name并没有影响外部的name。这就是local的"屏蔽"机制------在函数作用域内,局部变量会覆盖外层的同名变量,但这种覆盖只在该函数内有效,函数执行完毕,外层的变量依然保持原值。

递归函数

递归是函数式编程里的一个重要概念,Shell函数虽然简单,但也支持递归调用。

所谓递归,就是函数自己调用自己。一个经典的例子是阶乘计算。n! = n * (n-1)!,这就是一个递归定义。我们用Shell函数来实现它:

复制代码
#!/bin/bash
​
factorial() {
    local n=$1
    if [ $n -le 1 ]; then
        echo 1
    else
        local sub=$(factorial $((n-1)))
        echo $((n * sub))
    fi
}
​
result=$(factorial 5)
echo "5! = $result"

执行结果是5! = 120。可以看到,递归函数的基本结构是:先写终止条件(这里是n <= 1时返回1),再写递归调用(这里是用n-1的结果乘以n)。

但是需要特别提醒的是,Shell函数的递归深度是有限制的,因为每次递归都会在调用栈里保存一份函数上下文,太深了会爆栈。在生产环境里,我个人不太推荐在Shell里写深度递归------Shell不是为这种场景设计的,效率也不高。如果真的需要复杂的递归逻辑,建议用Python或者其他更合适的语言。

函数库

当我们积累的函数越来越多时,把所有函数都堆在一个脚本里会变得难以维护。一个常见的做法是把常用的函数单独放到一个文件里,形成"函数库",然后在需要的脚本里引入。

我们创建一个名为utils.sh的文件,里面放一些常用的工具函数:

复制代码
# utils.sh
log_info() {
    echo "[INFO] $(date '+%Y-%m-%d %H:%M:%S') $1"
}
​
log_error() {
    echo "[ERROR] $(date '+%Y-%m-%d %H:%M:%S') $1" >&2
}
​
check_root() {
    if [ "$(id -u)" -ne 0 ]; then
        log_error "需要 root 权限执行此脚本"
        exit 1
    fi
}

然后在主脚本里通过source命令(或者.点号)引入这个文件:

复制代码
#!/bin/bash
​
# 引入函数库
source ./utils.sh
​
# 使用函数库中的函数
log_info "脚本开始执行"
check_root
log_info "脚本执行结束"

注意这里用的是source命令,而不是直接执行。source会让被引入的脚本在当前Shell进程中执行,所以里面的函数定义才会被当前Shell"看到"。如果用./utils.sh或者bash utils.sh这种方式,被引入的脚本会在子Shell中执行,子Shell里定义的函数在父Shell里是看不到的(前面变量引用里我们讲过这个知识点)。

这种"函数库 + source引入"的模式,是Shell脚本工程化非常重要的一步。当你发现自己的脚本超过500行时,就应该考虑把其中通用的部分抽出来做函数库了。我自己维护的一个服务器部署脚本,就是用这种模式组织的------log.sh负责日志输出,config.sh负责配置加载,db.sh负责数据库操作,主体脚本只负责"调度"这些函数,结构非常清晰。

总结

Shell函数是Shell编程从"会写"到"写好"的分水岭。它不是什么高深莫测的特性,而是一种朴素的代码组织方式。把重复的代码封装成函数,把零散的逻辑拆分成模块,让脚本的主体变成清晰的"调用清单"------这就是函数带给我们的核心价值。

这一篇我们系统地讲了Shell函数,包括函数定义(两种写法)、函数调用、函数参数(位置参数机制)、函数返回值(return和echo两种方式)、变量作用域(local关键字)、递归函数以及函数库。每一个知识点都不算复杂,但组合起来,就能让我们写出结构清晰、易于维护的Shell脚本。

实际开发中,养成"所有函数内部变量都用local声明"和"复杂脚本拆分成函数库"这两个习惯,能让你的Shell脚本水平提升一个台阶。

如果这篇文章对您有帮助,欢迎点赞、收藏。下一篇将进入Shell循环和遍历。

相关推荐
程序员与背包客_CoderZ1 小时前
Linux D-Bus通信协议详解:从入门到C/C++编码实战
linux·服务器·c语言·c++·嵌入式硬件·嵌入式软件·dbus
Android系统攻城狮1 小时前
Linux PipeWire深度解析之pw_properties_iterate调用流程与实战(六十五)
linux·运维·服务器·音频进阶·pipewire音频进阶
jason.zeng@15022074 小时前
linux的top命令
java·linux·服务器
Huangjin007_4 小时前
【Linux 系统篇(十五)】进程 (三) :进程状态深度详解
linux·运维
RisunJan4 小时前
Linux命令-talk(终端实时对话)
linux·运维·服务器
NeilYuen4 小时前
【vemory】高性能KV存储AOF方案设计
linux·c++·redis·缓存
整点bug5 小时前
AI 运维该不该自动执行命令?
运维·ssh·openai
三言老师5 小时前
Rocky Linux 8.6 整机系统备份与迁移方案文档文档用途
linux·运维·服务器·网络
雾时之林6 小时前
Linux----防火墙
linux·运维·网络