7

A little context for the question first. The while IFS= read -r line; do ... done < input.txt is a well-known structure for reading a file line-by-line in shell scripting. But having been using c-style for loop ( in case some of the users here don't know, it is the for((i=0;i<=$val;i++))do;...done type used in bash and ksh), I recalled that while and for loops in C-like languages are interchangeable, and can emulate one another. So I came up with c-style loop that emulates the while IFS= read -r line structure mentioned above.

for((i=1;;i++))
do 
    IFS= read -r line || break
    # do something with line here
done < input.txt

I've tested it with multiple types of input - normal lines, lines with leading tabs/spaces, lines that don't end in \n (which is the case where read can't catch the last line) - and in all cases this works exactly the same as the while loop approach. This also technically has a "built-in" line counter with i variable.

So, the question is this: is there any reason (besides non-portability to other shells aside from ksh and bash ) to avoid using this approach ? Are there cases where this might fail ? While this works well, I want to know if there's any issues I've overlooked before starting to use this approach actively in my own scripts.

  • I can't think of any disadvantage of doing it this way. But then, I can't think of any reason not to do it portably, either. – Satō Katsura Nov 1 '17 at 5:37
  • @SatōKatsura yeah, portability is always great. I'm for the most part working only on Ubuntu, which always has bash, and I use ksh for fun & learning factor, so at least in my case bash is always available, so I figured portability in this context isn't a great factor. – Sergiy Kolodyazhnyy Nov 1 '17 at 5:42
  • 2
    Think of it like this: lack of portability is a flaw, not a quality. It may be justified to do things non-portably if, say, it saves you work. But in this particular case you don't gain anything out of it. It's just an exercise in bad practice, for no reason. – Satō Katsura Nov 1 '17 at 5:59
  • 2
    6 month down the road you might need to run the same script on *BSD. If it depends on bash features you'll need to either install bash, or fix the script. If it's portable you'll just head for a beer. If it costs nothing to do it portably, why not do it right from the beginning. Don't create future problems when you can avoid them for free. – Satō Katsura Nov 1 '17 at 6:24
  • 2
    I agree with Satō ... unless these scripts won't be shared with anyone. In that case if you want to use every bashism in the book go for it...only you know whether this will be a problem or not. Many of my personal use scripts are non-portable because I don't intend on using them anywhere but in Bash. – B Layer Nov 1 '17 at 8:48
5

Is there any reason to avoid using this approach?

Clarity, or lack of it.

A while loop like while sometest ; do ... could also be written as

while :; do 
    if ! sometest; then
        break
    fi
    ...

But we don't do that (in the shell, or in C) because it moves the loop condition away from where people are used to looking for it. Your construction is similar: you left the middle expression of the for (( ; ; )) construct empty.

Of course you had to do it, since for (( ; ; )) in the shell only evaluates arithmetic expressions, not commands like read. Even though for and while can be somewhat easily transformed in C, the same doesn't really apply in the shell because of this: they do different things.

(I would say that even in C, a for loop by its structure hints a bit at a counting loop, but of course it's not exactly clear-cut.)

As for this:

This also technically has a "built-in" line counter with i variable.

I don't think there's anything built-in in that. You initialized the line counter manually, and you increment it manually. You could do the same with the more traditional while loop

i=0
while IFS= read -r line; do 
    ... 
    let i++
done < input.txt

(or i=$((i + 1)) to be more portable)

3

I do fully agree with @ilkkachu here.

But FWIW, to be able to use that read command as part of the for condition, with ksh93 (where that for ((...)) syntax comes from), you could use disciplines:

function read.get {
  IFS= read -r line
  .sh.value=$(($? == 0))
}

for ((i = 0; read; i++)) {
  printf '%5d: %s\n' "$i" "$line"
}

We set the get discipline of the $read variable so that when expanded, the read command is run and $read is expanded to 1 if read was successful or 0 otherwise.

Or a variant using types:

typeset -T read_t=(
  typeset value
  function get {
    IFS= read -r _.value
    ((.sh.value = $? == 0))
  }
)

read_t line
for ((i = 0; line; i++)) {
  printf '%5d: %s\n' "$i" "${line.value}"
}

Where the read_t type is a kind of object which when expanded reads a line into theobject.value and expands to 1 if the read was successful, or 0 otherwise.

Or the ${ ...; } form of command substitution:

for ((i = 0; ${ IFS= read -r line; echo "$(($? == 0))";}; i++)) {
  printf '%5d: %s\n' "$i" "$line"
}

With zsh, hijacking the dynamic named directory feature:

set -o extendedglob
handle_-read:var()
  case $1:$2 in
    (d:-read:[a-zA-Z_][a-zA-Z0-9_]#)
      IFS= read -r ${2#*:} && reply=('' $#2);;
    (*) false;;
  esac

zsh_directory_name_functions+=(handle_-read:var)

for ((i = 0; ${#${(D):--read:line}} == 3; i++)) {
  printf '%5d: %s\n' "$i" "$line"
}

(not that I would recommend doing that).

That's the only ways I know of where you can have a command executed as part of an arithmetic expressions not in a subshell.

Normal command substitution ($(...) or `...`) can also be used to run commands in an arithmetic expression, but it's done in a subshell, so something like:

for ((i = 0; $(IFS= read -r line; echo "$((!$?))"); i++)) {
  printf '%5d: %s\n' "$i" "$line"
}

While it would be valid syntax for bash, it would fail to set the $line variable outside of the subshell.

With zsh, you could however do something like:

for ((i = 0; ${${line::=$(IFS= read -re)}+$?} == 0; i++)) {
  printf '%5d: %s\n' "$i" "$line"
}

Though that would be inefficient as it would fork a subshell for each line and feed the line through an extra pipe.

bash doesn't have the ${var::=value} unconditional assignment parameter expansion operator and its ability to nest parameter expansions is very limited. It does have the ${var=value} Bourne operator (assign if previously unset), and there are some operators that allow nesting like ${foo#${bar}}, so you could do something like:

unset line
for ((i = 0; 0*${?#"x${line=`IFS= read -r && printf %s "$REPLY"`}"}+$? == 0; i++)); do
  printf '%5d: %s\n' "$i" "$line"
  unset line
done

(here having to work around two bugs of bash).

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.