如何在Bash中的函数内修改全局变量?

163

我正在处理这个:

GNU bash, version 4.1.2(1)-release (x86_64-redhat-linux-gnu)

我有一个如下的脚本:

#!/bin/bash

e=2

function test1() {
  e=4
  echo "hello"
}

test1 
echo "$e"

返回:

hello
4

但如果我将函数的结果赋值给一个变量,全局变量e不会被修改:

#!/bin/bash

e=2

function test1() {
  e=4
  echo "hello"
}

ret=$(test1)

echo "$ret"
echo "$e"

返回:

hello
2

我听说过在这种情况下使用 eval,所以我在 test1 中这样做:

eval 'e=4'

但是结果相同。

你可以解释一下为什么它没有被修改吗?我怎样才能将test1函数的回声保存到ret中并同时修改全局变量呢?


你需要返回“hello”吗?你可以只使用echo $e来返回它。或者你可以echo想要的所有内容,然后解析结果吗? - user3442743
11个回答

157

当您使用命令替换(即$(...)构造)时,您正在创建一个子shell。子shell从其父shell继承变量,但这只能单向工作:子shell不能修改其父shell的环境。

您的变量e在子shell中设置,但并未在父shell中设置。有两种方法可以将值从子shell传递到其父级。首先,您可以将输出发送到stdout,然后使用命令替换捕获它:

myfunc() {
    echo "Hello"
}

var="$(myfunc)"

echo "$var"

以上为输出结果:

Hello

对于介于0到255之间的数值,您可以使用return将该数字作为退出状态传递:

mysecondfunc() {
    echo "Hello"
    return 4
}

var="$(mysecondfunc)"
num_var=$?

echo "$var - num is $num_var"

这会输出:

Hello - num is 4

谢谢你的建议,但我必须返回一个字符串数组,并且在函数内部我必须向两个全局字符串数组添加元素。 - mllamazares
4
你意识到,如果你只是运行函数而不将其赋值给变量,函数内的所有全局变量都将被更新。不妨在函数中更新字符串数组,然后在函数结束后将其赋值给另一个变量,而不是返回一个字符串数组。 - user3442743
@JohnDoe:你不能从一个函数中返回一个“字符串数组”。你只能打印一个字符串。但是,你可以像这样做:setarray() { declare -ag "$1=(a b c)"; } - rici

62

如果您使用 {fd}local -n,则需要 bash 4.1。

其余部分应该可以在 bash 3.x 中工作。由于 printf %q 可能是 bash 4 的功能,我不完全确定。

概要

您的示例可以修改如下以达到所需效果:

# Add following 4 lines:
_passback() { while [ 1 -lt $# ]; do printf '%q=%q;' "$1" "${!1}"; shift; done; return $1; }
passback() { _passback "$@" "$?"; }
_capture() { { out="$("${@:2}" 3<&-; "$2_" >&3)"; ret=$?; printf "%q=%q;" "$1" "$out"; } 3>&1; echo "(exit $ret)"; }
capture() { eval "$(_capture "$@")"; }

e=2

# Add following line, called "Annotation"
function test1_() { passback e; }
function test1() {
  e=4
  echo "hello"
}

# Change following line to:
capture ret test1 

echo "$ret"
echo "$e"

按预期打印:

hello
4

请注意此解决方案:

  • e=1000也适用。
  • 如果需要$?,则保留$?

唯一的副作用是:

  • 它需要现代的bash
  • 它会更频繁地创建子进程。
  • 它需要注释(以您的函数命名,并添加_
  • 它牺牲了文件描述符3。
    • 如果需要,可以将其更改为另一个FD。
      • _capture中,只需将所有出现的3替换为另一个(更高的)数字。

以下内容(可能很长,抱歉)希望解释如何将此方法应用于其他脚本。

问题

d() { let x++; date +%Y%m%d-%H%M%S; }

x=0
d1=$(d)
d2=$(d)
d3=$(d)
d4=$(d)
echo $x $d1 $d2 $d3 $d4

输出

0 20171129-123521 20171129-123521 20171129-123521 20171129-123521

期望输出为

4 20171129-123521 20171129-123521 20171129-123521 20171129-123521

问题的原因

Shell变量(或者一般来说是环境变量)被从父进程传递到子进程,但反过来不行。

如果你进行输出捕获,这通常在一个子shell中运行,所以将变量传回可能会很困难。

有些人甚至告诉你,这是无法解决的。这是错误的,但这是一个众所周知的难题。

有几种最佳解决方法,这取决于您的需求。

下面是一个逐步指南,介绍如何操作。

将变量传回到父进程的shell

有一种方法可以将变量传回到父进程的shell。但是这是一条危险的路,因为这使用了eval。 如果做得不好,你会冒很多风险。但如果正确地完成,只要bash没有bug,就是完全安全的。

_passback() { while [ 0 -lt $# ]; do printf '%q=%q;' "$1" "${!1}"; shift; done; }

d() { let x++; d=$(date +%Y%m%d-%H%M%S); _passback x d; }

x=0
eval `d`
d1=$d
eval `d`
d2=$d
eval `d`
d3=$d
eval `d`
d4=$d
echo $x $d1 $d2 $d3 $d4

打印

4 20171129-124945 20171129-124945 20171129-124945 20171129-124945

请注意,这也适用于危险的事物:

danger() { danger="$*"; passback danger; }
eval `danger '; /bin/echo *'`
echo "$danger"

打印

; /bin/echo *

这是由于 printf '%q',它会将所有内容都引用起来,以便您可以在shell环境中安全地重用它。

但是这太过繁琐了

这不仅看起来丑陋,而且打字也很费劲,因此容易出错。只要有一个小错误,你就会失败,对吧?

好吧,我们处于shell级别,所以你可以改进它。只需要思考你想要看到的界面,然后你就可以实现它。

增强shell的处理方式

让我们退一步,并思考一些API,使我们能够轻松地表达我们想做的事情。

那么,我们想用 d() 函数做什么呢?

我们想将输出捕获到一个变量中。好的,那么让我们为此实现一个API:

# This needs a modern bash 4.3 (see "help declare" if "-n" is present,
# we get rid of it below anyway).
: capture VARIABLE command args..
capture()
{
local -n output="$1"
shift
output="$("$@")"
}
现在,不再需要编写
d1=$(d)

我们可以编写

capture d1 d

好的,看起来我们没有改变太多,因为这些变量仍然没有从 d 传递回父 shell,并且我们需要再打一些字。

但是现在我们可以将 shell 的全部功能用于它,因为它已经很好地包装在一个函数中了。

考虑一个易于重用的接口

第二点是,我们希望保持DRY(不要重复自己)。 因此,我们绝对不想打一些像这样的东西:

x=0
capture1 x d1 d
capture1 x d2 d
capture1 x d3 d
capture1 x d4 d
echo $x $d1 $d2 $d3 $d4

这里的x不仅是冗余的,而且在始终重复使用时容易出错。如果您在脚本中使用了1000次然后添加一个变量,您绝对不想更改涉及到d调用的所有1000个位置。

因此,去掉x,这样我们可以写成:

_passback() { while [ 0 -lt $# ]; do printf '%q=%q;' "$1" "${!1}"; shift; done; }

d() { let x++; output=$(date +%Y%m%d-%H%M%S); _passback output x; }

xcapture() { local -n output="$1"; eval "$("${@:2}")"; }

x=0
xcapture d1 d
xcapture d2 d
xcapture d3 d
xcapture d4 d
echo $x $d1 $d2 $d3 $d4

输出

4 20171129-132414 20171129-132414 20171129-132414 20171129-132414

这看起来已经非常不错了。(但是还有一个“local -n”在其他普通的bash 3.x中无法工作)

避免更改d()

最后的解决方案存在一些严重的缺陷:

  • d()需要被修改
  • 它需要使用xcapture的一些内部细节来传递输出。
    • 请注意,这会遮蔽(烧毁)一个名为output的变量,所以我们永远不能将其传回去。
  • 它需要与_passback合作

我们能否也摆脱这个呢?

当然可以!我们在shell中,所以我们有一切必要的东西来完成这项工作。

如果你更仔细地查看对eval的调用,你会发现,在这个位置上,我们拥有100%的控制权。在“eval”内部,我们处于一个子shell中,因此我们可以做任何想做的事情,而不必担心对父shell造成任何不良影响。

太好了,让我们再添加一个包装器,现在直接在eval中:

_passback() { while [ 0 -lt $# ]; do printf '%q=%q;' "$1" "${!1}"; shift; done; }
# !DO NOT USE!
_xcapture() { "${@:2}" > >(printf "%q=%q;" "$1" "$(cat)"); _passback x; }  # !DO NOT USE!
# !DO NOT USE!
xcapture() { eval "$(_xcapture "$@")"; }

d() { let x++; date +%Y%m%d-%H%M%S; }

x=0
xcapture d1 d
xcapture d2 d
xcapture d3 d
xcapture d4 d
echo $x $d1 $d2 $d3 $d4

打印

4 20171129-132414 20171129-132414 20171129-132414 20171129-132414                                                    

然而,这个方法也存在一些严重的缺陷:

  • 存在!DO NOT USE!标记,因为在这种情况下有一个非常糟糕的竞态条件, 这很难被发现:
    • >(printf ..)是后台作业。 因此,在运行_passback x时,它仍可能执行。
    • 如果在printf_passback之前添加sleep 1;,则可以自己验证此情况。 然后,_xcapture a d; echo首先输出xa
  • _passback x不应该是_xcapture的一部分, 因为这会使重用该命令变得困难。
  • 此处还有一些不必要的fork($(cat)), 但由于这种解决方案是!DO NOT USE!,所以我选择了最短的路线。

但是,这表明我们可以做到这一点,而不需要修改d()(也不需要使用local -n)!

请注意,我们不一定需要_xcapture, 因为我们可以直接在eval中编写所有内容。

但是,这样做通常并不容易阅读。 如果您几年后回到脚本,请确保不会有太多麻烦。

解决竞态条件

现在让我们解决竞态条件。

技巧是等待printf关闭其STDOUT,然后输出x

有很多方法可以实现这一点:

  • 不能使用shell管道,因为管道在不同的进程中运行。
  • 可以使用临时文件、锁文件或fifo之类的东西。这允许等待锁或fifo, 或不同的通道来输出信息,然后按正确顺序组装输出。

遵循最后一个路径可能如下所示(请注意,在这里执行printf最后会更好):

_passback() { while [ 0 -lt $# ]; do printf '%q=%q;' "$1" "${!1}"; shift; done; }

_xcapture() { { printf "%q=%q;" "$1" "$("${@:2}" 3<&-; _passback x >&3)"; } 3>&1; }

xcapture() { eval "$(_xcapture "$@")"; }

d() { let x++; date +%Y%m%d-%H%M%S; }

x=0
xcapture d1 d
xcapture d2 d
xcapture d3 d
xcapture d4 d
echo $x $d1 $d2 $d3 $d4

输出

4 20171129-144845 20171129-144845 20171129-144845 20171129-144845

为什么这是正确的?

  • _passback x 直接与 STDOUT 通信。
  • 然而,由于 STDOUT 需要在内部命令中捕获, 我们首先使用 '3>&1' 将其“保存”到 FD3 中(当然您可以使用其他方式), 然后使用 >&3 重新使用它。
  • $("${@:2}" 3<&-; _passback x >&3) 完成后,在 _passback 之后, 当子 shell 关闭 STDOUT 时。
  • 因此,无论 _passback 花费多长时间, printf 都不能在 _passback 之前发生。
  • 请注意,printf 命令在完整的命令行组装之前不会执行, 因此,无论如何实现 printf,我们都无法看到来自 printf 的伪影。

因此,首先执行 _passback,然后执行 printf

这样解决了竞争问题,牺牲了一个固定的文件描述符 3。 当然,在您的 shell 脚本中,如果 FD3 不空闲,则可以选择另一个文件描述符。

请注意 3<&-,它保护 FD3 不被传递给函数。

使其更通用

_capture 包含属于 d() 的部分,从可重用性的角度来看很糟糕。如何解决?

好吧,以一种绝望的方式引入一个附加功能, 一个额外的函数,必须返回正确的内容,命名为原始函数加上 _

此函数在实际函数之后调用,并可以增加内容。 这样,这可以被视为一些注释,因此非常易读:

_passback() { while [ 0 -lt $# ]; do printf '%q=%q;' "$1" "${!1}"; shift; done; }
_capture() { { printf "%q=%q;" "$1" "$("${@:2}" 3<&-; "$2_" >&3)"; } 3>&1; }
capture() { eval "$(_capture "$@")"; }

d_() { _passback x; }
d() { let x++; date +%Y%m%d-%H%M%S; }

x=0
capture d1 d
capture d2 d
capture d3 d
capture d4 d
echo $x $d1 $d2 $d3 $d4

仍然打印

4 20171129-151954 20171129-151954 20171129-151954 20171129-151954

允许访问返回码

只有一个位被遗漏了:

v=$(fn) 设置$?fn返回的内容。所以你可能也需要这个。

但是它需要进行一些大的调整:

# This is all the interface you need.
# Remember, that this burns FD=3!
_passback() { while [ 1 -lt $# ]; do printf '%q=%q;' "$1" "${!1}"; shift; done; return $1; }
passback() { _passback "$@" "$?"; }
_capture() { { out="$("${@:2}" 3<&-; "$2_" >&3)"; ret=$?; printf "%q=%q;" "$1" "$out"; } 3>&1; echo "(exit $ret)"; }
capture() { eval "$(_capture "$@")"; }

# Here is your function, annotated with which sideffects it has.
fails_() { passback x y; }
fails() { x=$1; y=69; echo FAIL; return 23; }

# And now the code which uses it all
x=0
y=0
capture wtf fails 42
echo $? $x $y $wtf

打印

23 42 69 FAIL

仍有很多改进的空间

  • 使用passback() { set -- "$@" "$?"; while [ 1 -lt $# ]; do printf '%q=%q;' "$1" "${!1}"; shift; done; return $1; }可以消除_passback()

  • 使用capture() { eval "$({ out="$("${@:2}" 3<&-; "$2_" >&3)"; ret=$?; printf "%q=%q;" "$1" "$out"; } 3>&1; echo "(exit $ret)")"; }可以消除_capture()

  • 该解决方案通过在内部使用文件描述符(此处为3)来污染它。如果您恰好传递FD,则需要牢记这一点。
    请注意,bash 4.1及以上版本具有{fd}以使用某些未使用的FD。
    (也许我会在回头时添加一个解决方案。)
    请注意,这就是我之所以喜欢将其放入类似于_capture的单独函数中的原因,因为将所有内容塞入一行中是可能的,但会使其越来越难以阅读和理解。

  • 也许您还想捕获调用函数的STDERR,或者甚至想从变量中传递进出更多的文件描述符。
    我还没有解决方案,不过这里有一种方法可以捕获多个FD,因此我们也可以通过这种方式将变量传回。

还要记住:

这必须调用一个shell函数,而不是外部命令。

没有简单的方法将环境变量从外部命令传递出去。 (使用LD_PRELOAD=应该是可能的!) 但这完全是另一回事。

最后的话

这不是唯一的解决方案。它是一个解决方案的例子。

在shell中,通常有很多表达方式。 所以请放心改进并找到更好的东西。

这里提出的解决方案相当远离完美:

  • 几乎没有测试,所以请原谅打字错误。
  • 仍有很多改进的空间,请参见上文。
  • 它使用了许多现代bash的功能,因此可能难以移植到其他shell。
  • 还可能有一些我没想到的怪癖。

然而,我认为它很容易使用:

  • 只需添加4行“库”。
  • 只需要为您的shell函数添加1行“注释”。
  • 暂时牺牲一个文件描述符。
  • 每个步骤应该易于理解,甚至数年后也是如此。

11
你很棒。 - Eliran Malka
3
在我的一生中,我从未见过如此广泛的回复从如此多的角度进行。我向您致敬,@Tino - bocian85

16

也许您可以使用文件,在函数内部写入文件,然后在读取文件后再读取它。我已将e更改为数组。在此示例中,读回数组时使用空格作为分隔符。

#!/bin/bash

declare -a e
e[0]="first"
e[1]="secondddd"

function test1 () {
 e[2]="third"
 e[1]="second"
 echo "${e[@]}" > /tmp/tempout
 echo hi
}

ret=$(test1)

echo "$ret"

read -r -a e < /tmp/tempout
echo "${e[@]}"
echo "${e[0]}"
echo "${e[1]}"
echo "${e[2]}"

输出:

hi
first second third
first
second
third

哇,好多信息。我得到了:passback: 命令未找到。:( - Brian Patterson

15

你正在执行test1,是在子shell(child shell)中执行的,子shell无法修改父shell中的任何内容

你可以在bash 手册中找到相关信息。

请查看:导致子shell的操作 此处


12

当我想要自动删除临时文件时,遇到了类似的问题。我想出的解决方案不是使用命令替换,而是将应该接收最终结果的变量名称传递给函数。例如:

#!/usr/bin/env bash

# array that keeps track of tmp-files
remove_later=()

# function that manages tmp-files
new_tmp_file() {
  file=$(mktemp)
  remove_later+=( "$file" )
  # assign value (safe form of `eval "$1=$file"`)
  printf -v "$1" -- "$file"
}

# function to remove all tmp-files
remove_tmp_files() { rm -- "${remove_later[@]}"; }

# define trap to remove all tmp-files upon EXIT
trap remove_tmp_files EXIT

# generate tmp-files
new_tmp_file tmpfile1
new_tmp_file tmpfile2

因此,应用于本文中,可以这么说:
#!/usr/bin/env bash
    
e=2
    
function test1() {
  e=4
  printf -v "$1" -- "hello"
}
    
test1 ret
    
echo "$ret"
echo "$e"

功能正常,对“返回值”没有限制。


1
这是一个被低估的解决方案。 - kvantour
我会实现以下几点修改:**(1)** 避免使用 eval,改用 printf -v "$1" -- "%s" "$file"。**(2)** 将 remove_later 定义为一个数组 declare -a remove_later 并相应地使用它。 - kvantour
2
非常感谢@kvantour!我完全同意您的建议,特别是数组解决方案,更加简洁。您想进行更改吗?我目前无法处理它... - Elmar Zander

4
假设已经存在 local -n,下面的脚本可以让函数 test1 修改一个全局变量:
#!/bin/bash

e=2

function test1() {
  local -n var=$1
  var=4
  echo "hello"
}

test1 e
echo "$e"

这将产生以下输出:

hello
4

2

我不确定这个在你的终端上是否有效,但是我发现如果你不提供任何输出,它会被自然地视为一个无返回值函数,并且可以改变全局变量。以下是我使用的代码:

let ran1=$(( (1<<63)-1)/3 ))
let ran2=$(( (1<<63)-1)/5 ))
let c=0
function randomize {
    c=$(( ran1+ran2 ))
    ran2=$ran1
    ran1=$c
    c=$(( c > 0 ))
}

这是一个简单的游戏随机器,可以有效地修改所需的变量。


1
不必引入复杂函数或对原始函数进行大量修改,解决这个问题的方法是将值存储在临时文件中,并在需要时读取/写入它。这种方法在我需要模拟多次调用bash函数的情况下非常有用,例如您可以创建:
# Usage read_value path_to_tmp_file
function read_value {
  cat "${1}"
}

# Usage: set_value path_to_tmp_file the_value
function set_value {
  echo "${2}" > "${1}"
}
#----

# Original code:

function test1() {
  e=4
  set_value "${tmp_file}" "${e}"
  echo "hello"
}


# Create the temp file
# Note that tmp_file is available in test1 as well
tmp_file=$(mktemp)

# Your logic
e=2
# Store the value
set_value "${tmp_file}" "${e}"

# Run test1
test1

# Read the value modified by test1
e=$(read_value "${tmp_file}")
echo "$e"

缺点是您可能需要多个临时文件来存储不同的变量。而且,您可能需要发出“sync”命令,在写入和读取操作之间将内容持久化到磁盘上。

1
由于命令替换是在子shell中执行的,因此虽然子shell继承了变量,但当子shell结束时对它们的更改将会丢失。 参考资料
引用: 命令替换、括号分组的命令和异步命令都在子shell环境中调用,该环境是 shell 环境的副本。

@JohnDoe 我不确定这是可能的。你可能需要重新考虑脚本的设计。 - Some programmer dude
哦,但我需要在函数内分配一个全局数组,否则我将不得不重复很多代码(重复函数的代码-30行-15次-每次调用一次)。没有其他办法,是吗? - mllamazares

0
不要试图通过与bash争斗来使一个函数修改一个全局变量并返回另一个值,而是创建两个函数:一个用于修改全局变量,另一个用于返回一个值。
#!/bin/bash

e=2

function test1() {
  echo "hello"
}

function test2() {
  e2=4
  echo $e2
}

ret=$(test1)
e=$(test2)

echo "$ret"
echo "$e"

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接