18

I have a bash function to set the $PATH like this --

assign-path()
{
    str=$1
    # if the $PATH is empty, assign it directly.
    if [ -z $PATH ]; then
        PATH=$str;
    # if the $PATH does not contain the substring, append it with ':'.
    elif [[ $PATH != *$str* ]]; then
        PATH=$PATH:$str;
    fi
}

But the problem is, I have to write different function for different variables (for example, another function for $CLASSPATH like assign-classpath() etc.). I could not find a way to pass argument to the bash function so that I can access it by reference.

It would be better if I had something like --

assign( bigstr, substr )
{
    if [ -z bigstr ]; then
        bigstr=substr;
    elif [[ bigstr != *str* ]]; then
        bigstr=bigstr:substr;
    fi
}

Any idea, how to achieve something like above in bash?

ramgorur
  • 431

9 Answers9

17

In bash you can use ${!varname} to expand the variable referenced by the contents of another. Eg:

$ var=hello
$ foo () { echo "${!1}"; }
$ foo var
hello

From the man page:

${!prefix*}
${!prefix@}
       Names matching prefix.  Expands to the names of variables whose names
       begin with prefix, separated by the first character of the IFS special
       variable.  When @ is used  and the expansion appears within double quotes,
       each variable name expands to a separate word.

Also, to set a variable referenced by the contents (without the dangers of eval), you can use declare. Eg:

$ var=target
$ declare "$var=hello"
$ echo "$target"
hello

Thus, you could write your function like this (take care because if you use declare in a function, you must give -g or the variable will be local):

shopt -s extglob

assign()
{
  target=$1
  bigstr=${!1}
  substr=$2

  if [ -z "$bigstr" ]; then
    declare -g -- "$target=$substr"
  elif [[ $bigstr != @(|*:)$substr@(|:*) ]]; then
    declare -g -- "$target=$bigstr:$substr"
  fi
}

And use it like:

assign PATH /path/to/binaries

Note that I have also corrected an bug where if substr is already a substring of one of the colon separated members of bigstr, but not its own member, then it wouldn't be added. For example, this would allow adding /bin to a PATH variable already containing /usr/bin. It uses the extglob sets to match either the beginning/end of the string or a colon then anything else. Without extglob, the alternative would be:

[[ $bigstr != $substr && $bigstr != *:$substr &&
   $bigstr != $substr:* && $bigstr != *:$substr:* ]]
Graeme
  • 34,027
  • -g in declare is not available in older version of bash, is there any way so that I can make this backward compatible? – ramgorur Jul 22 '15 at 05:57
  • 2
    @ramgorur, you could use export to put it in your environment (at the risk of overwriting something important) or eval (various issues including security if you are not careful). If using eval you should be ok if you do it like eval "$target=\$substr". If you forget the \ though, it will potentially execute a command if there is a space in the contents of substr. – Graeme Jul 22 '15 at 11:16
10

New in bash 4.3, is the -n option to declare & local:

func() {
    local -n ref="$1"
    ref="hello, world"
}

var='goodbye world'
func var
echo "$var"

That prints out hello, world.

derobert
  • 109,670
  • The only problem with namerefs in Bash is that you can't have a nameref (in a function for example) referencing a variable (outside the function) of the same name as the nameref itself. This may be fixed for release 4.5 though. – Kusalananda Jan 19 '17 at 09:18
2

You can use eval to set a parameter. A description of this command can be found here. The following usage of eval is wrong:

wrong(){
  eval $1=$2
}

With respect to the additional evaluation evaldoes you should use

assign(){
  eval $1='$2'
}

Check the results of using these functions:

$ X1='$X2'
$ X2='$X3'
$ X3='xxx'
$ 
$ echo :$X1:
:$X2:
$ echo :$X2:
:$X3:
$ echo :$X3:
:xxx:
$ 
$ wrong Y $X1
$ echo :$Y:
:$X3:
$ 
$ assign Y $X1
$ echo :$Y:
:$X2:
$ 
$ assign Y "hallo world"
$echo :$Y:
:hallo world:
$ # the following may be unexpected
$ assign Z $Y
$ echo ":$Z:"
:hallo:
$ # so you have to quote the second argument if its a variable
$ assign Z "$Y"
$ echo ":$Z:"
:hallo world:

But you can achieve your goal without the usage of eval. I prefer this way that is more simple.

The following function makes the substitution in the right way (I hope)

augment(){
  local CURRENT=$1
  local AUGMENT=$2
  local NEW
  if [[ -z $CURRENT ]]; then
    NEW=$AUGMENT
  elif [[ ! ( ( $CURRENT = $AUGMENT ) || ( $CURRENT = $AUGMENT:* ) || \
    ( $CURRENT = *:$AUGMENT ) || ( $CURRENT = *:$AUGMENT:* ) ) ]]; then
    NEW=$CURRENT:$AUGMENT
  else
    NEW=$CURRENT
    fi
  echo "$NEW"
}

Check the following output

augment /usr/bin /bin
/usr/bin:/bin

augment /usr/bin:/bin /bin
/usr/bin:/bin

augment /usr/bin:/bin:/usr/local/bin /bin
/usr/bin:/bin:/usr/local/bin

augment /bin:/usr/bin /bin
/bin:/usr/bin

augment /bin /bin
/bin


augment /usr/bin: /bin
/usr/bin::/bin

augment /usr/bin:/bin: /bin
/usr/bin:/bin:

augment /usr/bin:/bin:/usr/local/bin: /bin
/usr/bin:/bin:/usr/local/bin:

augment /bin:/usr/bin: /bin
/bin:/usr/bin:

augment /bin: /bin
/bin:


augment : /bin
::/bin


augment "/usr lib" "/usr bin"
/usr lib:/usr bin

augment "/usr lib:/usr bin" "/usr bin"
/usr lib:/usr bin

Now you can use the augment function in the following way to set a variable:

PATH=`augment PATH /bin`
CLASSPATH=`augment CLASSPATH /bin`
LD_LIBRARY_PATH=`augment LD_LIBRARY_PATH /usr/lib`
  • Even your eval statement is wrong. This, for example: v='echo "OHNO!" ; var' ; l=val ; eval $v='$l' - would echo "OHNO! before assigning var. You could "${v##*[;"$IFS"]}='$l'" to ensure that the string can't expand to anything that won't be evaluated with the =. – mikeserv Apr 02 '14 at 09:42
  • @mikeserv thank you for your comment but I think that is not a valid example. The first argument of the assign script should be a variable name or a variable that contains a variable name that is used on the left hand side of the = assignement statement. You can argue that my scrip does not check its argument. That is true. I do not even check if there is any argument or if the number of arguments is valid. But this was by intention. The OP can add such checks if he wants. – miracle173 Apr 02 '14 at 13:04
  • @mikeserv: I think that your proposal to transform the first argument silently to a valid variable name is not a good idea: 1) some variable is set/overwritten that was not intended by the user. 2) the error is hidden from the user of the function. That is never a good idea. One should simply raise an error if that happens. – miracle173 Apr 02 '14 at 13:06
  • @mikeserv: It is interesting when one wants to use your variable v (better its value) as second argument of the assign function. So its value should be on the right hand side of an assignement. It is necessaary to quote the argument of the function assign. I added this subtlety to my posting. – miracle173 Apr 02 '14 at 13:09
  • Probably true - and you're not actually using eval in your final example - which is wise - so it doesn't really matter. But what I'm saying is that any code that uses eval and takes user input is inherently risky - and if you did use your example, I could make the function designed to change my path to rm my path with little effort. – mikeserv Apr 02 '14 at 13:10
  • The eval example you present can be made to execute arbitrary code. Still. – mikeserv Apr 02 '14 at 13:12
  • @mikeserv: For what value of V the function assign PATH "$V" executes some malicious code? – miracle173 Apr 02 '14 at 21:19
2

With a few tricks you can actually pass named parameters to functions, along with arrays (tested in bash 3 and 4).

The method I developed allows you to access parameters passed to a function like this:

testPassingParams() {

    @var hello
    l=4 @array anArrayWithFourElements
    l=2 @array anotherArrayWithTwo
    @var anotherSingle
    @reference table   # references only work in bash >=4.3
    @params anArrayOfVariedSize

    test "$hello" = "$1" && echo correct
    #
    test "${anArrayWithFourElements[0]}" = "$2" && echo correct
    test "${anArrayWithFourElements[1]}" = "$3" && echo correct
    test "${anArrayWithFourElements[2]}" = "$4" && echo correct
    # etc...
    #
    test "${anotherArrayWithTwo[0]}" = "$6" && echo correct
    test "${anotherArrayWithTwo[1]}" = "$7" && echo correct
    #
    test "$anotherSingle" = "$8" && echo correct
    #
    test "${table[test]}" = "works"
    table[inside]="adding a new value"
    #
    # I'm using * just in this example:
    test "${anArrayOfVariedSize[*]}" = "${*:10}" && echo correct
}

fourElements=( a1 a2 "a3 with spaces" a4 )
twoElements=( b1 b2 )
declare -A assocArray
assocArray[test]="works"

testPassingParams "first" "${fourElements[@]}" "${twoElements[@]}" "single with spaces" assocArray "and more... " "even more..."

test "${assocArray[inside]}" = "adding a new value"

In other words, not only you can call your parameters by their names (which makes up for a more readable core), you can actually pass arrays (and references to variables - this feature works only in bash 4.3 though)! Plus, the mapped variables are all in the local scope, just as $1 (and others).

The code that makes this work is pretty light and works both in bash 3 and bash 4 (these are the only versions I've tested it with). If you're interested in more tricks like this that make developing with bash much nicer and easier, you can take a look at my Bash Infinity Framework, the code below was developed for that purpose.

Function.AssignParamLocally() {
    local commandWithArgs=( $1 )
    local command="${commandWithArgs[0]}"

    shift

    if [[ "$command" == "trap" || "$command" == "l="* || "$command" == "_type="* ]]
    then
        paramNo+=-1
        return 0
    fi

    if [[ "$command" != "local" ]]
    then
        assignNormalCodeStarted=true
    fi

    local varDeclaration="${commandWithArgs[1]}"
    if [[ $varDeclaration == '-n' ]]
    then
        varDeclaration="${commandWithArgs[2]}"
    fi
    local varName="${varDeclaration%%=*}"

    # var value is only important if making an object later on from it
    local varValue="${varDeclaration#*=}"

    if [[ ! -z $assignVarType ]]
    then
        local previousParamNo=$(expr $paramNo - 1)

        if [[ "$assignVarType" == "array" ]]
        then
            # passing array:
            execute="$assignVarName=( \"\${@:$previousParamNo:$assignArrLength}\" )"
            eval "$execute"
            paramNo+=$(expr $assignArrLength - 1)

            unset assignArrLength
        elif [[ "$assignVarType" == "params" ]]
        then
            execute="$assignVarName=( \"\${@:$previousParamNo}\" )"
            eval "$execute"
        elif [[ "$assignVarType" == "reference" ]]
        then
            execute="$assignVarName=\"\$$previousParamNo\""
            eval "$execute"
        elif [[ ! -z "${!previousParamNo}" ]]
        then
            execute="$assignVarName=\"\$$previousParamNo\""
            eval "$execute"
        fi
    fi

    assignVarType="$__capture_type"
    assignVarName="$varName"
    assignArrLength="$__capture_arrLength"
}

Function.CaptureParams() {
    __capture_type="$_type"
    __capture_arrLength="$l"
}

alias @trapAssign='Function.CaptureParams; trap "declare -i \"paramNo+=1\"; Function.AssignParamLocally \"\$BASH_COMMAND\" \"\$@\"; [[ \$assignNormalCodeStarted = true ]] && trap - DEBUG && unset assignVarType && unset assignVarName && unset assignNormalCodeStarted && unset paramNo" DEBUG; '
alias @param='@trapAssign local'
alias @reference='_type=reference @trapAssign local -n'
alias @var='_type=var @param'
alias @params='_type=params @param'
alias @array='_type=array @param'
niieani
  • 121
1
assign () 
{ 
    if [ -z ${!1} ]; then
        eval $1=$2
    else
        if [[ ${!1} != *$2* ]]; then
            eval $1=${!1}:$2
        fi
    fi
}

$ echo =$x=
==
$ assign x y
$ echo =$x=
=y=
$ assign x y
$ echo =$x=
=y=
$ assign x z
$ echo =$x=
=y:z=

Does this fit?

  • hi, I tried to do it like yours, but not working, sorry for being a newbie. Could you please tell me what is wrong with this script? – ramgorur Apr 01 '14 at 22:36
  • 1
    Your usage of eval is susceptible to arbitrary command execution. – Chris Down Apr 02 '14 at 03:24
  • You have an idea to make zhe eval lines safer? Normally, when I think I need eval, I decide not to use *sh and switch to a different language instead. On the other hand, using this in scrips to append entries to some PATH-like variables, it will run with string constants and not user input... –  Apr 02 '14 at 04:31
  • 1
    you can do eval safely - but it takes a lot of thought. If you're just trying to reference a parameter, you'd wanna do something like this: eval "$1=\"\$2\"" that way on eval's first pass it only evaluates $1 and on the second it does value="$2". But you need to do something else - this is unnecessary here. – mikeserv Apr 02 '14 at 05:20
  • Actually, my above comment is wrong, too. You've gotta do "${1##*[;"$IFS"]}=\"\$2\"" - and even that comes with no warranty. Or eval "$(set -- $1 ; shift $(($#-1)) ; echo $1)=\"\$2\"". It's not easy. – mikeserv Apr 02 '14 at 09:51
1

Named arguments simply aren't how Bash's syntax was designed. Bash was designed to be an iterative improvement upon the Bourne shell. As such it needs to ensure certain things work between the two shells as much as possible. So it isn't meant to be easier to script with overall, it's just meant to be better than Bourne while ensuring that if you take a script from a Bourne environment over to bash it's as easy as possible. That isn't trivial since a lot of shells still treat Bourne as a de facto standard. Since people write their scripts to be Bourne-compatible (for this portability) the need remains in force and is unlikely to ever change.

You're probably better off looking at a different shell script (like python or something) entirely if it's at all feasible. If you're running up against a language's limitations, you need to start using a new language.

Bratchley
  • 16,824
  • 14
  • 67
  • 103
  • Maybe early in bash's life this was true. But now specific provisions are made. Full reference variables are now available in bash 4.3 - see derobert's answer. – Graeme Apr 02 '14 at 01:38
  • And if you look here, you'll see you can do this kind of thing really easily even with just POSIX portable code: http://unix.stackexchange.com/a/120531/52934 – mikeserv Apr 02 '14 at 05:27
1

With standard sh syntax (would work in bash, and not only in bash), you could do:

assign() {
  eval '
    case :${'"$1"'}: in
      (::) '"$1"'=$2;;   # was empty, copy
      (*:"$2":*) ;;      # already there, do nothing
      (*) '"$1"'=$1:$2;; # otherwise, append with a :
    esac'
}

Like for solutions using bash's declare, it's safe as long as $1 contains a valid variable name.

0

ON NAMED ARGS:

This is very simply done and bash is not required at all - this is the basic POSIX specified behavior of assignment via parameter expansion:

: ${PATH:=this is only assigned to \$PATH if \$PATH is null or unset}

To demo in a similar vein to @Graeme, but in a portable way:

_fn() { echo "$1 ${2:-"$1"} $str" ; }

% str= ; _fn "${str:=hello}" > hello hello hello

And there I only do str= to ensure it has a null value, because parameter expansion has the built-in safeguard against reassigning shell environment inline if it's already set.

SOLUTION:

For your specific problem, I don't believe named arguments are necessary, though they're certainly possible. Use $IFS instead:

assign() { oFS=$IFS ; IFS=: ; add=$* 
    set -- $PATH ; for p in $add ; do { 
        for d ; do [ -z "${d%"$p"}" ] && break 
        done ; } || set -- $* $p ; done
    PATH= ; echo "${PATH:="$*"}" ; IFS=$oFS
}

Here's what I get when I run it:

% PATH=/usr/bin:/usr/yes/bin
% assign \
    /usr/bin \
    /usr/yes/bin \
    /usr/nope/bin \
    /usr/bin \
    /nope/usr/bin \
    /usr/nope/bin

> /usr/bin:/usr/yes/bin:/usr/nope/bin:/nope/usr/bin

% echo "$PATH" > /usr/bin:/usr/yes/bin:/usr/nope/bin:/nope/usr/bin

% dir="/some crazy/dir" % p=assign /usr/bin /usr/bin/new "$dir" % echo "$p" ; echo "$PATH" > /usr/bin:/usr/yes/bin:/usr/nope/bin:/nope/usr/bin:/some crazy/dir:/usr/bin/new > /usr/bin:/usr/yes/bin:/usr/nope/bin:/nope/usr/bin:/some crazy/dir:/usr/bin/new

Notice it only added the arguments that weren't already in $PATH or that came before? Or even that it took more than one argument at all? $IFS is handy.

mikeserv
  • 58,310
  • hi, I failed to follow, so could you please elaborate it a little bit more? thanks. – ramgorur Apr 01 '14 at 22:12
  • I'm already doing so... Just a few more moments please... – mikeserv Apr 01 '14 at 22:13
  • @ramgorur Any better? Sorry, but real life intruded and it took me a little longer than I expected to finish the write-up. – mikeserv Apr 02 '14 at 00:58
  • same here as well, succumbed to real life. Looks like lots of different approaches to code this thing, let me give me sometime to settle on the best one. – ramgorur Apr 02 '14 at 04:52
  • @ramgorur - of course, just wanted to make sure I didn't leave you hanging. About this - you choose whatever you want, man. I will say none of the other answers I can see offer as concise, portable, or as robust a solution as assign does here. If you have questions about how it works, I'd be happy to answer. And by the way, if you really want named arguments, you might wanna look at this other answer of mine in which I show how to declare a function named for another function's arguments: http://unix.stackexchange.com/a/120531/52934 – mikeserv Apr 02 '14 at 05:10
-2

I can't find anything like ruby, python, etc. but this feels closer to me

foo() {
  BAR="$1"; BAZ="$2"; QUUX="$3"; CORGE="$4"
  ...
}

Readability is better in my opinion, 4 lines is excessive for declaring your parameter names. Looks closer to modern languages as well.

  • (1) The question is about shell functions.  Your answer presents a skeleton shell function.  Beyond that, your answer has nothing to do with the question.  (2) You think it increases readability to take separate lines and concatenate them into one line, with semicolons as separators?  I believe that you’ve got it backwards; that your one-line style is *less* readable than the multi-line style. – Scott - Слава Україні Jul 12 '19 at 19:29
  • If reducing unnecessary parts is the point, then why the quotes and semicolons? a=$1 b=$2 ... works just as well. – ilkkachu Jul 12 '19 at 20:27