如何在Bash中返回一个数组而不使用全局变量?

131

我有一个创建数组的函数,我想将该数组返回给调用者:

create_array() {
  local my_list=("a", "b", "c")
  echo "${my_list[@]}"
}

my_algorithm() {
  local result=$(create_array)
}

通过这种方法,我只能得到一个扩展过的字符串。如何在不使用任何全局变量的情况下“返回”我的列表(my_list)?


这不是唯一的问题:虽然 result=( $(create_array) ) 可以添加数组项,但它无法处理包含空格的项;这才是真正的挑战! - U. Windl
22个回答

118

在Bash 4.3及以上版本中,您可以使用nameref,以便调用者可以传递数组名称,被调用者可以使用nameref间接地填充已命名数组。

#!/usr/bin/env bash

create_array() {
    local -n arr=$1             # use nameref for indirection
    arr=(one "two three" four)
}

use_array() {
    local my_array
    create_array my_array       # call function to populate the array
    echo "inside use_array"
    declare -p my_array         # test the array
}

use_array                       # call the main function

生成输出:

inside use_array
declare -a my_array=([0]="one" [1]="two three" [2]="four")

您还可以使函数更新现有数组:

update_array() {
    local -n arr=$1             # use nameref for indirection
    arr+=("two three" four)     # update the array
}

use_array() {
    local my_array=(one)
    update_array my_array       # call function to update the array
}

这种方法更加优雅高效,因为我们不需要使用命令替换$()来获取调用函数的标准输出。如果函数返回多个输出,使用与输出数量相同的nameref即可。


以下是Bash手册关于nameref的说明:

使用declare或local内置命令(参见Bash内置命令)并使用-n选项将变量赋予nameref属性,可以创建一个nameref,或引用另一个变量。这允许间接地操作变量。每当引用、赋值、取消设置nameref变量或更改其属性(除了使用或更改nameref属性本身之外),操作实际上是在nameref变量的值所指定的变量上执行的。通常在shell函数中使用nameref来引用作为参数传递给函数的变量的名称。例如,如果将变量名作为其第一个参数传递给shell函数,则在函数内部运行

declare -n ref=$1会创建一个nameref变量ref,其值为作为第一个参数传递的变量名称。对ref的引用和赋值以及其属性的更改被视为对传递的$1名称的变量的引用、赋值和属性修改。


4
已点赞。请注意可能存在名称冲突的潜在风险(参见https://mywiki.wooledge.org/BashProgramming?highlight=%28nameref%29#Functions)。此外,请注意所引用的数组仍然是全局的。 - Dennis Williamson
1
你是指 arr 还是 my_array?两者都只在各自的函数中是局部变量,因此在函数外不可见。 - codeforester
2
关于局部变量,你是正确的。抱歉,我错过了你同时使用一个函数的事实。 - Dennis Williamson
请注意 https://dev59.com/qpHea4cB1Zd3GeqPmDMO。 - Kanagavelu Sugumar
谢谢!每天都会学到新东西。我将广泛利用它。 - Mark
显示剩余2条评论

44

全局变量有什么问题?

返回数组实际上不太实用,存在许多陷阱。

话虽如此,如果变量有相同的名称,则以下技术是可行的:

$ f () { local a; a=(abc 'def ghi' jkl); declare -p a; }
$ g () { local a; eval $(f); declare -p a; }
$ f; declare -p a; echo; g; declare -p a
declare -a a='([0]="abc" [1]="def ghi" [2]="jkl")'
-bash: declare: a: not found

declare -a a='([0]="abc" [1]="def ghi" [2]="jkl")'
-bash: declare: a: not found

declare -p 命令(除了 f() 中的命令外)用于演示数组状态。在 f() 中,它被用作返回数组的机制。

如果需要给数组取不同的名称,可以这样做:

$ g () { local b r; r=$(f); r="declare -a b=${r#*=}"; eval "$r"; declare -p a; declare -p b; }
$ f; declare -p a; echo; g; declare -p a
declare -a a='([0]="abc" [1]="def ghi" [2]="jkl")'
-bash: declare: a: not found

-bash: declare: a: not found
declare -a b='([0]="abc" [1]="def ghi" [2]="jkl")'
-bash: declare: a: not found

7
+1 很好的回答,但是你提到返回数组时有哪些陷阱?cdarke的答案似乎很合理。 - helpermethod
7
例如,cdarke所提供的技巧可以展平数组。f () { local a=($(g)); declare -p a; }; g () { local a=(a 'b c' d); echo "${a[@]}"; }; f 输出结果为"declare -a a='([0]="a" [1]="b" [2]="c" [3]="d")'"。你会注意到,现在有4个元素而不是3个。 - Dennis Williamson
1
经过多次尝试,我终于明白了Dennis在他的评论中所说的“cdarke的答案可以展平数组”的意思。 ${array[@]} 语法确实会适当地引用数组项,但是 echo 不会打印未转义的引号。因此,任何使用 echo 的解决方案只有在没有数组项包含空格时才能正常工作。我抽象出了Dennis的示例,并使其更加健壮,以获得一个实用的、可重用的实现 - Stephen M. Harris
2
参见此解决方案,它使用namedrefs而不是globals。需要Bash版本4.3或更高版本。 - codeforester
1
同意@codeforester的解决方案。如果你要花费精力进行复杂的declare逻辑,那么最好以一种更简洁/更用户友好的方式来完成它。 - yuyu5
显示剩余5条评论

19
Bash 无法作为返回值传递数据结构。返回值必须是 0-255 之间的数字退出状态。但是,如果您愿意,可以使用命令或进程替换将命令传递给 eval 语句。
在我看来,这很少有必要。如果您必须在 Bash 中传递数据结构,请使用全局变量-那就是它们的用途所在。如果出于某些原因不想这样做,请考虑使用位置参数。
您的示例可以轻松地重写为使用位置参数而不是全局变量:
use_array () {
    for idx in "$@"; do
        echo "$idx"
    done
}

create_array () {
    local array=("a" "b" "c")
    use_array "${array[@]}"
}

尽管如此,所有这些都创造了一定数量的不必要复杂性。Bash函数通常在您将它们视为具有副作用的过程,并按顺序调用它们时效果最佳。

# Gather values and store them in FOO.
get_values_for_array () { :; }

# Do something with the values in FOO.
process_global_array_variable () { :; }

# Call your functions.
get_values_for_array
process_global_array_variable
如果你只是担心污染全局命名空间,你也可以使用unset内置命令在使用完全局变量后删除它。使用原始示例中的local关键字来让my_list成为全局变量,并在my_algorithm结尾处添加unset my_list以清理变量。

2
你的第一个结构只有当生产者(create_array)可以“调用”消费者(use_array)时才能起作用,反过来则不行。 - musiphil

15

你的原始解决方案并不是很遥远。你有几个问题:你使用逗号作为分隔符,并且没有将返回的项捕获到列表中。试试这个:

my_algorithm() {
  local result=( $(create_array) )
}

create_array() {
  local my_list=("a" "b" "c")  
  echo "${my_list[@]}" 
}

考虑到有关嵌入空格的评论,对 IFS 进行一些微调即可解决这个问题:

my_algorithm() {
  oldIFS="$IFS"
  IFS=','
  local result=( $(create_array) )
  IFS="$oldIFS"
  echo "Should be 'c d': ${result[1]}"
}

create_array() {
  IFS=','
  local my_list=("a b" "c d" "e f") 
  echo "${my_list[*]}" 
}

4
这段代码不会返回一个数组,它会使用空格作为分隔符返回一个字符串。这种方法无法正确处理数组元素内部的空格,因此不能用于例如路径数组的处理。 - Andrey Tarantsov
4
@AndreyTarantsov: 如果我使用[@]create_array将会返回一个列表;如果我使用[*],它将会返回一个单独的字符串(除了0-255之间的数字外,它不能返回任何东西)。在my_algorithm中,数组是通过将函数调用括在括号中创建的。因此,在my_algorithm中,变量result是一个数组。我明白嵌入值中的空格总是会引起问题。 - cdarke
这个很好用,我建议将旧的IFS移动到全局外部,并创建一个数组IFS设置的全局变量,最好设置为不可打印字符。例如:arrayIFS=$'\001' - user4401178

12

使用由Matt McClure开发的技术:http://notes-matthewlmcclure.blogspot.com/2009/12/return-array-from-bash-function-v-2.html

避免全局变量意味着您可以在管道中使用该函数。以下是一个示例:

#!/bin/bash

makeJunk()
{
   echo 'this is junk'
   echo '#more junk and "b@d" characters!'
   echo '!#$^%^&(*)_^&% ^$#@:"<>?/.,\\"'"'"
}

processJunk()
{
    local -a arr=()    
    # read each input and add it to arr
    while read -r line
    do 
       arr+=('"'"$line"'" is junk') 
    done;

    # output the array as a string in the "declare" representation
    declare -p arr | sed -e 's/^declare -a [^=]*=//'
}

# processJunk returns the array in a flattened string ready for "declare"
# Note that because of the pipe processJunk cannot return anything using
# a global variable
returned_string="$(makeJunk | processJunk)"

# convert the returned string to an array named returned_array
# declare correctly manages spaces and bad characters
eval "declare -a returned_array=${returned_string}"

for junk in "${returned_array[@]}"
do
   echo "$junk"
done

输出为:

"this is junk" is junk
"#more junk and "b@d" characters!" is junk
"!#$^%^&(*)_^&% ^$#@:"<>?/.,\\"'" is junk

2
使用arr+=("value")代替${#arr[@]}进行索引。请参考此链接了解原因。反引号已经过时,难以阅读和嵌套。请使用$()代替。如果makeJunk中的字符串包含换行符,则您的函数将无法正常工作。 - Dennis Williamson
我的变体(下面的链接)可以使用多行字符串。 - TomRoche
修正了建议的改进。谢谢。 - Steve Zobell

11

一个基于“declare -p”内置命令的纯Bash、最小化和强大的解决方案——不需要疯狂的全局变量

这种方法包括以下三个步骤:

  1. 使用“declare -p”将数组转换并将输出保存在变量中。
    myVar="$( declare -p myArray )"
    declare -p语句的输出可以用来重新创建数组。例如,declare -p myVar的输出可能如下所示:
    declare -a myVar='([0]="1st field" [1]="2nd field" [2]="3rd field")'
  2. 使用echo内置命令将变量传递给函数或从函数传递回来。
    • 为了在回显变量时保留数组字段中的空格,IFS被临时设置为控制字符(例如垂直制表符)。
    • 只需回显变量中声明语句的右侧即可——这可以通过参数展开形式${parameter#word}实现。对于上面的例子:${myVar#*=}
  3. 最后,在传递到该处时使用eval和“declare -a”内置命令重新创建数组。

示例1——从函数返回一个数组

#!/bin/bash

# Example 1 - return an array from a function

function my-fun () {
 # set up a new array with 3 fields - note the whitespaces in the
 # 2nd (2 spaces) and 3rd (2 tabs) field
 local myFunArray=( "1st field" "2nd  field" "3rd       field" )

 # show its contents on stderr (must not be output to stdout!)
 echo "now in $FUNCNAME () - showing contents of myFunArray" >&2
 echo "by the help of the 'declare -p' builtin:" >&2
 declare -p myFunArray >&2

 # return the array
 local myVar="$( declare -p myFunArray )"
 local IFS=$'\v';
 echo "${myVar#*=}"

 # if the function would continue at this point, then IFS should be
 # restored to its default value: <space><tab><newline>
 IFS=' '$'\t'$'\n';
}

# main

# call the function and recreate the array that was originally
# set up in the function
eval declare -a myMainArray="$( my-fun )"

# show the array contents
echo ""
echo "now in main part of the script - showing contents of myMainArray"
echo "by the help of the 'declare -p' builtin:"
declare -p myMainArray

# end-of-file

示例1的输出:

now in my-fun () - showing contents of myFunArray
by the help of the 'declare -p' builtin:
declare -a myFunArray='([0]="1st field" [1]="2nd  field" [2]="3rd       field")'

now in main part of the script - showing contents of myMainArray
by the help of the 'declare -p' builtin:
declare -a myMainArray='([0]="1st field" [1]="2nd  field" [2]="3rd      field")'

示例2 - 将数组传递给函数
#!/bin/bash

# Example 2 - pass an array to a function

function my-fun () {
 # recreate the array that was originally set up in the main part of
 # the script
 eval declare -a myFunArray="$( echo "$1" )"

 # note that myFunArray is local - from the bash(1) man page: when used
 # in a function, declare makes each name local, as with the local
 # command, unless the ‘-g’ option is used.

 # IFS has been changed in the main part of this script - now that we
 # have recreated the array it's better to restore it to the its (local)
 # default value: <space><tab><newline>
 local IFS=' '$'\t'$'\n';

 # show contents of the array
 echo ""
 echo "now in $FUNCNAME () - showing contents of myFunArray"
 echo "by the help of the 'declare -p' builtin:"
 declare -p myFunArray
}

# main

# set up a new array with 3 fields - note the whitespaces in the
# 2nd (2 spaces) and 3rd (2 tabs) field
myMainArray=( "1st field" "2nd  field" "3rd     field" )

# show the array contents
echo "now in the main part of the script - showing contents of myMainArray"
echo "by the help of the 'declare -p' builtin:"
declare -p myMainArray

# call the function and pass the array to it
myVar="$( declare -p myMainArray )"
IFS=$'\v';
my-fun $( echo "${myVar#*=}" )

# if the script would continue at this point, then IFS should be restored
# to its default value: <space><tab><newline>
IFS=' '$'\t'$'\n';

# end-of-file

示例2的输出:

now in the main part of the script - showing contents of myMainArray
by the help of the 'declare -p' builtin:
declare -a myMainArray='([0]="1st field" [1]="2nd  field" [2]="3rd      field")'

now in my-fun () - showing contents of myFunArray
by the help of the 'declare -p' builtin:
declare -a myFunArray='([0]="1st field" [1]="2nd  field" [2]="3rd       field")'

在示例2中,我认为我们应尽可能使用位置参数。 - mcoolive
我真的很喜欢这种方法——考虑到bash的限制,它非常优雅。然而,在示例1中,改变IFS是否真的有任何影响?由于一切都是双引号,所以我看不出它如何有所帮助。当我设置IFS=$'\n'并发出包含换行符的字符串数组时,一切都正常。也许我错过了某些边缘情况吗? - bland328
请不要推广昂贵的 shell 分叉。 - konsolebox

4

有用的例子:从函数返回数组

function Query() {
  local _tmp=`echo -n "$*" | mysql 2>> zz.err`;
  echo -e "$_tmp";
}

function StrToArray() {
  IFS=$'\t'; set $1; for item; do echo $item; done; IFS=$oIFS;
}

sql="SELECT codi, bloc, requisit FROM requisits ORDER BY codi";
qry=$(Query $sql0);
IFS=$'\n';
for row in $qry; do
  r=( $(StrToArray $row) );
  echo ${r[0]} - ${r[1]} - ${r[2]};
done

4
我最近发现了BASH的一个怪癖,即函数可以直接访问调用栈中较高级别函数中声明的变量。我刚刚开始思考如何利用这个特性(它既有好处也有危险),但一个明显的应用是解决这个问题的精神。

当委托创建数组时,我更喜欢获得返回值而不是使用全局变量。我偏爱的原因有几个,其中之一是避免可能干扰预先存在的值,并避免在以后访问时留下无效的值。虽然有解决这些问题的方法,但最简单的方法是在代码完成后使变量超出作用域。

我的解决方案确保数组在需要时可用,并在函数返回时丢弃它,并保留具有相同名称的全局变量。

#!/bin/bash

myarr=(global array elements)

get_an_array()
{
   myarr=( $( date +"%Y %m %d" ) )
}

request_array()
{
   declare -a myarr
   get_an_array "myarr"
   echo "New contents of local variable myarr:"
   printf "%s\n" "${myarr[@]}"
}

echo "Original contents of global variable myarr:"
printf "%s\n" "${myarr[@]}"
echo

request_array 

echo
echo "Confirm the global myarr was not touched:"
printf "%s\n" "${myarr[@]}"

这是此代码的输出结果: 程序输出 当函数 request_array 调用 get_an_array 时,get_an_array 可以直接设置 myarr 变量,该变量是局部于 request_array 的。由于 myarr 是使用 declare 创建的,因此它仅在 request_array 中有效,并且在 request_array 返回时超出范围。
尽管这个解决方案并不字面上返回一个值,但我建议作为一个整体看,它满足真正函数返回值的承诺。

3

最近我需要类似的功能,下面是 RashaMattSteve Zobell 的建议混合而成。

  1. echo 每个数组/列表元素作为函数内的单独行
  2. 使用 mapfile 读取由函数回显的所有数组/列表元素。

据我所见,字符串被保持完整,空格被保留。

#!bin/bash

function create-array() {
  local somearray=("aaa" "bbb ccc" "d" "e f g h")
  for elem in "${somearray[@]}"
  do
    echo "${elem}"
  done
}

mapfile -t resa <<< "$(create-array)"

# quick output check
declare -p resa

一些更多的变化...
#!/bin/bash

function create-array-from-ls() {
  local somearray=("$(ls -1)")
  for elem in "${somearray[@]}"
  do
    echo "${elem}"
  done
}

function create-array-from-args() {
  local somearray=("$@")
  for elem in "${somearray[@]}"
  do
    echo "${elem}"
  done
}


mapfile -t resb <<< "$(create-array-from-ls)"
mapfile -t resc <<< "$(create-array-from-args 'xxx' 'yy zz' 't s u' )"

sentenceA="create array from this sentence"
sentenceB="keep this sentence"

mapfile -t resd <<< "$(create-array-from-args ${sentenceA} )"
mapfile -t rese <<< "$(create-array-from-args "$sentenceB" )"
mapfile -t resf <<< "$(create-array-from-args "$sentenceB" "and" "this words" )"

# quick output check
declare -p resb
declare -p resc
declare -p resd
declare -p rese
declare -p resf

3

[注意: 以下内容因为我无法理解的原因而被拒绝作为 此答案 的编辑(因为该编辑并非旨在回应帖子的作者!),因此我接受了这个建议,将其作为一个单独的答案。]

Steve Zobell's adaptation of Matt McClure's technique 的更简单实现使用bash内置函数(自版本==4以来)readarray正如RastaMatt所建议的那样,创建一个可以在运行时转换为数组的数组表示形式。(请注意,readarraymapfile都命名为相同的代码。)它仍然避免全局变量(允许在管道中使用该函数),并且仍然处理不良字符。

如果你想查看一些更加完整的例子(例如更好的模块化),但仍然是玩具级别的,请参见bash_pass_arrays_between_functions。以下是一些易于执行的例子,这里提供是为了避免版主抱怨外部链接。

剪切以下代码块并将其粘贴到bash终端中以创建/tmp/source.sh/tmp/junk1.sh

FP='/tmp/source.sh'     # path to file to be created for `source`ing
cat << 'EOF' > "${FP}"  # suppress interpretation of variables in heredoc
function make_junk {
   echo 'this is junk'
   echo '#more junk and "b@d" characters!'
   echo '!#$^%^&(*)_^&% ^$#@:"<>?/.,\\"'"'"
}

### Use 'readarray' (aka 'mapfile', bash built-in) to read lines into an array.
### Handles blank lines, whitespace and even nastier characters.
function lines_to_array_representation {
    local -a arr=()
    readarray -t arr
    # output array as string using 'declare's representation (minus header)
    declare -p arr | sed -e 's/^declare -a [^=]*=//'
}
EOF

FP1='/tmp/junk1.sh'      # path to script to run
cat << 'EOF' > "${FP1}"  # suppress interpretation of variables in heredoc
#!/usr/bin/env bash

source '/tmp/source.sh'  # to reuse its functions

returned_string="$(make_junk | lines_to_array_representation)"
eval "declare -a returned_array=${returned_string}"
for elem in "${returned_array[@]}" ; do
    echo "${elem}"
done
EOF
chmod u+x "${FP1}"
# newline here ... just hit Enter ...

运行/tmp/junk1.sh:输出应为

this is junk
#more junk and "b@d" characters!
!#$^%^&(*)_^&% ^$#@:"<>?/.,\\"'

请注意,lines_to_array_representation 还处理空行。尝试将以下代码块粘贴到您的 bash 终端中:

FP2='/tmp/junk2.sh'      # path to script to run
cat << 'EOF' > "${FP2}"  # suppress interpretation of variables in heredoc
#!/usr/bin/env bash

source '/tmp/source.sh'  # to reuse its functions

echo '`bash --version` the normal way:'
echo '--------------------------------'
bash --version
echo # newline

echo '`bash --version` via `lines_to_array_representation`:'
echo '-----------------------------------------------------'
bash_version="$(bash --version | lines_to_array_representation)"
eval "declare -a returned_array=${bash_version}"
for elem in "${returned_array[@]}" ; do
    echo "${elem}"
done
echo # newline

echo 'But are they *really* the same? Ask `diff`:'
echo '-------------------------------------------'

echo 'You already know how to capture normal output (from `bash --version`):'
declare -r PATH_TO_NORMAL_OUTPUT="$(mktemp)"
bash --version > "${PATH_TO_NORMAL_OUTPUT}"
echo "normal output captured to file @ ${PATH_TO_NORMAL_OUTPUT}"
ls -al "${PATH_TO_NORMAL_OUTPUT}"
echo # newline

echo 'Capturing L2AR takes a bit more work, but is not onerous.'
echo "Look @ contents of the file you're about to run to see how it's done."

declare -r RAW_L2AR_OUTPUT="$(bash --version | lines_to_array_representation)"
declare -r PATH_TO_COOKED_L2AR_OUTPUT="$(mktemp)"
eval "declare -a returned_array=${RAW_L2AR_OUTPUT}"
for elem in "${returned_array[@]}" ; do
    echo "${elem}" >> "${PATH_TO_COOKED_L2AR_OUTPUT}"
done
echo "output from lines_to_array_representation captured to file @ ${PATH_TO_COOKED_L2AR_OUTPUT}"
ls -al "${PATH_TO_COOKED_L2AR_OUTPUT}"
echo # newline

echo 'So are they really the same? Per'
echo "\`diff -uwB "${PATH_TO_NORMAL_OUTPUT}" "${PATH_TO_COOKED_L2AR_OUTPUT}" | wc -l\`"
diff -uwB "${PATH_TO_NORMAL_OUTPUT}" "${PATH_TO_COOKED_L2AR_OUTPUT}" | wc -l
echo '... they are the same!'
EOF
chmod u+x "${FP2}"
# newline here ... just hit Enter ...

在命令行中运行/tmp/junk2.sh。您的输出应与我的类似:

`bash --version` the normal way:
--------------------------------
GNU bash, version 4.3.30(1)-release (x86_64-pc-linux-gnu)
Copyright (C) 2013 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>

This is free software; you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.

`bash --version` via `lines_to_array_representation`:
-----------------------------------------------------
GNU bash, version 4.3.30(1)-release (x86_64-pc-linux-gnu)
Copyright (C) 2013 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>

This is free software; you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.

But are they *really* the same? Ask `diff`:
-------------------------------------------
You already know how to capture normal output (from `bash --version`):
normal output captured to file @ /tmp/tmp.Ni1bgyPPEw
-rw------- 1 me me 308 Jun 18 16:27 /tmp/tmp.Ni1bgyPPEw

Capturing L2AR takes a bit more work, but is not onerous.
Look @ contents of the file you're about to run to see how it's done.
output from lines_to_array_representation captured to file @ /tmp/tmp.1D6O2vckGz
-rw------- 1 me me 308 Jun 18 16:27 /tmp/tmp.1D6O2vckGz

So are they really the same? Per
`diff -uwB /tmp/tmp.Ni1bgyPPEw /tmp/tmp.1D6O2vckGz | wc -l`
0
... they are the same!

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