7

Recently I decided to read a bit more on the bash built-ins declare, local, and readonly, which led me to switch from:

local variable_name
variable_name='value'
readonly variable_name

To:

variable_name='value'
declare -r variable_name

This change cut down the number of lines written and allowed me to set a few attributes, like telling bash that the value of a variable is an integer, which was nice. However, while creating a function that will serve as an alias for cURL, I noticed that variables inside an array never expand if I use declare, but expand just fine with local and readonly.

Here is an example:

#!/usr/bin/env bash

set -o errexit -o errtrace -o pipefail -o nounset
IFS=$'\n\t'

curl() {

  curl_version="$(command curl --version | awk 'NR==1 {print $2}')"
  declare -r curl_version

  curl_args=(
    --user-agent "curl/${curl_version}"
    --silent
    --fail
  )

  command curl "${curl_args[@]}" \
    "${@}"

}

curl --url 'https://httpbin.org/get'

Because the variables do not expand for whatever reason, the --user-agent part of the array makes the script exit with an error, since as far as bash knows, this is an unbound variable, and those are not allowed because of set -o nounset.

I have been trying to get this to work for a few days now, so I guess it is time to throw the towel and ask for help. Can anyone point me in the right direction to understand what I am doing wrong, please?

EDIT:

Forgot to mention, but the variable does expand if I declare it in the same line, like declare -r variable_name. The problem is, if I do that, I hit SC2155 from ShellCheck, hence why I am trying to declare after the value is set.

3
  • There's too much unnecessary code to read through. Please read How to create a Minimal, Reproducible Example over on stackoverflow. Jun 28, 2021 at 16:08
  • @glennjackman Fixed, thank you for the heads up!
    – James Pond
    Jun 28, 2021 at 16:17
  • Why do you think this is specific to arrays? Did you try simply doing echo $curl_version; there?
    – Barmar
    Jun 29, 2021 at 19:57

2 Answers 2

12

With:

curl_version="$(command curl --version | awk 'NR==1 {print $2}')"
declare -r curl_version

within a function, you're setting the $curl_version global variable to some value and then creating a separate local and readonly variable which is initially unset.

It looks like you want:

# instantiate a new local variable (but in bash it inherits the "export"
# attribute if any of the variable with same name in the parent scope)
local curl_version

# unset to remove that export attribute if any. Though you could
# also change the above to local +x curl_version
unset -v curl_version

# give a value:
curl_version="$(command curl --version | awk 'NR==1 {print $2}')"

# make that local variable read only
local -r curl_version

(here using local instead of declare to make it clearer that you want to make the variable local¹).

Or do all at the same time with:

local +x -r curl_version="$(command curl --version | awk '{print $2; exit}')"

(though as noted by shellcheck, you then lose the exit status of the pipeline²).

In any case, I wouldn't use readonly / typeset -r in shells like you would use const in C especially in bash. Shells (other than ksh93) don't have static scoping like in C. And in bash (contrary to zsh for instance), you can't create a variable local to a function if it has been made readonly in the global scope.

For instance:

count() {
  local n
  for (( n = 0; n < $1; n++ )) { echo "$n"; }
}

readonly n=5
count "$n"

would work in zsh but not in bash. It may be OK if you only use local -r and never readonly.


¹ in any case typeset / declare / local are all the same in bash, the only difference being that if you try to use local outside of a function, it reports an error. The difference between typeset -r and readonly (same as between typeset -x and export) being that the latter doesn't instantiate a new variable if called within a function.

² See how with that exit in awk in that version for awk to stop processing the input after the first line, curl could be killed with a SIGPIPE (very unlikely in practice as curl would send its output in one go and it would fit in the pipe) and because of pipefail, the pipeline could end up failing with 141 exit status, but local itself would still succeed as long as it can assign a value to the variable.

1
  • Thanks! As I mentioned above, it seems like my understanding of declare was incorrect—it is always something obvious, isn't it?. Thank you for the help.
    – James Pond
    Jun 28, 2021 at 16:34
7

The first line of the function creates a global variable.

  curl_version="$(command curl --version | awk 'NR==1 {print $2}')"

The second line of the function creates a readonly, empty, LOCAL variable

  declare -r curl_version

This local variable overrides the value of the global variable.

Note this exerpt from help declare:

When used in a function, `declare' makes NAMEs local, as with the `local' command. The `-g' option suppresses this behavior.

I'd recommend this:

curl() {

  local -r curl_version="$(command curl --version | awk 'NR==1 {print $2}')"

  local curl_args=(
    --user-agent "curl/${curl_version}"
    --silent
    --fail
  )

  command curl "${curl_args[@]}" \
    "${@}"
}

For inspecting the variables, add declare -p curl_version curl_args into the function before the command call.

2
  • My understanding of declare was incorrect, then. From help declare, I got the impression that it worked like local, while also setting attributes. Thank you for the clarification!
    – James Pond
    Jun 28, 2021 at 16:31
  • 3
    @JamesPond, it does work like local which is the point here. Jun 28, 2021 at 16:39

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .