そのシェルスクリプトもうちょっとシンプルに書けそう Tips集(Golf/シェル芸ではない)

  • 889
    いいね
  • 6
    コメント
この記事は最終更新日から1年以上が経過しています。

Shell Script Advent Calendar 2015 4日目 の投稿です。

以前から自分用にメモしていたものを文字起こししました。

:zero: はじめに

仕事でシェルを使い始めて3年くらい経ちました。
途中、python や ruby でスクリプト作ったり、ちょっと zsh に浮気したりしましたが、なんだかんだで今も Bash を使うことが多いです。

この3年間、スーパーシェル芸人(@ebanさん)にご教授頂いたり、Golfしたり(@ebanの影響)、シェル芸勉強会に参加したり(@ebanの影響)してきました。
そんな3年間のまとめとして、シェルスクリプト初めましてだった3年前の私に向けたTips集を書いてみました。

:exclamation: 趣旨

各項目ごとに、まず初心者(過去の私がやってた)あるある実装を例示して、その次に、より良さげな実装を例示する構成としています。

:computer: 実行環境

  • OS
    • Mac OS X Yosemite
    • Mac OS X El Capitan (途中から)
  • Shell
    • bash 4.3
      • brew で bash 入れたので 4.3 になってます。
      • 特に手入れしていなければ、お手元の Mac OS X は、3.2 あたりと思いますのでご注意を。

他の環境では検証できていません。だいたい動くと思いますが...。

:notebook: Bashの変数であるある

変数の初期化

シェルと触れ合ったばかりの私は、いつもこんな感じで実装してましたね。

分かりやすいあるある例
# 引数があればそれを、無ければデフォルト値で初期化
if [ -z "$1" ]; then
    hoge="default"
else
    hoge=$1
fi

いえ、悪いわけではないです。他の言語と似た形なので、誰にでも分かりやすいと思います。

ただですね、Bashの変数には便利な機能があります。
そんな便利機能の一つ、:- を使うと、このif文は不要です。

初期化なんて簡単
hoge=${1:-default}

一発ですね。え、暗号めいていて初見さんお断りじゃないかって?
でも:- 以外に、:+:? など、本当に便利なんですよ。たくさんあるんです。使いたいんです。
覚えておいて損のない便利さだと思いますよ。

参考) http://qiita.com/bsdhack/items/597eb7daee4a8b3276ba

補足) " は必要?

@magicant さんから
クォート漏れ、細かいミスの指摘・修正の編集リクエストをいただきました。ありがとうございます!
(チェック不足でした...:sweat:)

例えばクォート漏れの指摘の一つは、
[ -z $1 ][ -z "$1" ]
というものでした。

$1 が、"hoge" とか 123 なら、"" がなくても動きます。
ただ、空白のみや、-r hoge のような文字列だと厄介なことになります。

$1と"$1"
[ -z $1 ] && echo '1) $1 is zero length'
[ -z "$1" ] && echo '2) $1 is zero length'
↑スクリプトを色んな引数で試してみる
# 空文字
$ bash a.sh ""
1) $1 is zero length
2) $1 is zero length

# 空白のみ
$ bash a.sh " "
1) $1 is zero length

# 空白たくさんでも?
$ bash a.sh "       "
1) $1 is zero length

# -から始まると?
$ bash a.sh "-b hoge"
a.sh: 2 行: [: -r: 二項演算子が予期されます

最後の例だと [ -z -r hoge ] と展開されてから実行されることになります。
" で囲んでいれば [ -z '-r hoge' ] と、文字列として扱われるので問題ないのです。

面倒がらず "" でちゃんと囲まないとですね。

変数に値があった場合のみオプションを追加する

分かりやすいあるある例2
if [ -n "${a_val}" ]; then
    a_opt_val="-a ${a_val}"
fi
something_cli ${a_opt_val} sub_cmd

${a_val} に何か文字が入っていたら、a_opt_val="-a ${a_val}" という変数を定義してるんですね。とても分かりやすいです。

でも、:+ を使えばこのif文は不要です。

変数に値があったらオプションにする
a_opt_val="hoge"
something_cli ${a_opt_val:+-a "${a_opt_val}"} sub_cmd
# この時、${a_opt_val:+-a "${a_opt_val}"}は -a hoge となる。

a_opt_val="-a hoge" ではなく、a_opt_val="hoge" にしている理由ですか?
変数 a_opt_val の値を気軽に使い回せるからです。

変数の未定義とTypoに気付きたい

変数とTypoは人類永遠の課題ではないでしょうか。
Typoをしないプログラマは、神の手(とキーボード)を持っているか、あるいはTypoしないようプログラムされた存在(ロボット)以外に存在しないのではと思います

そのくせ、Typoは恐怖の処理を招きます。

恐怖のTypo
tmp_dir="myTmp"
rm -rf ${tmo_dir}/* # => Don't pleeeeeeeease!!

そんなのエディターやIDEに任せるべき?
それも一つの道でしょう。私達が真に戦うべき相手はTypoなんかじゃないはずです。

とはいっても、Typoじゃなくても、実装ミスや勘違いで変数の未定義・空文字となってしまうことはありますよね。

変数の未定義チェック

if文じゃないと不安だったあの頃
if [ -z "${forgetful_var}" ]; then
  echo "forgetful_var is not defined!!"
  exit 1
fi

やっぱり if [ -z ${var} ] の構成は分かりやすいですからね。
でも、変数が出てくる度に全部 if でチェックするつもりですか?

関数を覚えたあの頃
function check_defined() {
  if [ -z "${1}" ]; then
    echo "Not defined!!"
    exit 1
  fi
}

check_defined ${forgetful_var_1}
check_defined ${fargetful_var_2}
check_defined ${forgetful_var_3}

なるほど、関数化すればif文は1つになりますね。
ただ、これって、どの変数が未定義だったのか分かりにくいですよね。
だってどの変数が未定義でも "Not defined!!" って出力されるんですから。

ともあれ、おもむろに set -u しましょうか。

未定義な変数を使った段階でエラー
set -u
forgetful_var_1="hoge"
forgetful_var_2="hoge"
forgetful_var_3="hoge"

echo ${forgetful_var_1}
echo ${fargetful_var_2}
echo ${forgetful_var_3}
実行結果
hoge
a.sh: 行 7: fargetful_var_2: 未割り当ての変数です

単純明快ですね。
参考) http://qiita.com/m-yamashita/items/889c116b92dc0bf4ea7d#-u

え、例えば変数 forgettableは、未定義でも問題ないようにしたい?
一旦 set +u するか、${forgettable:+${forgettable}} なら、forgettableが未定義でもエラーになりません。

未定義検知を一時的に無効にする
set -u
str="hoge"
echo ${str}

set +u
echo ${forgettable}
set -u
検知されない書き方にする
set -u
str="hoge"
echo ${str}
echo ${forgettable:+$forgettable}

こんな感じです。

おっと、未定義だけでなく空文字も許したくない、ですか?
set -u は未定義しか検知しませんからね。
そんな時は :? を使うとお手軽です。

特定の変数のみチェック
should_be_defined=
: ${should_be_defined:?} #=> 空文字も許さない
実行結果
a.sh: 行 2: should_be_defined: パラメータが null または設定されていません

: コマンドという何もしないコマンドと組み合わせれば、もはやこれ以上ないシンプルさですね。

補足) shellcheck

シェルスクリプトにおける、いわゆるLintのようなツール shellcheck というものがあります。
brew install shellcheck でさくっとインストールできます。
これを使うと、未定義だけでなく、未使用な変数の検出をしてくれます。

shellcheck流してみる
$ cat a.sh
#!/bin/bash
echo "${no_defined_var}"
no_use="var"

$ shellcheck a.sh 

In a.sh line 2:
echo "${no_defined_var}"
      ^-- SC2154: no_defined_var is referenced but not assigned.


In a.sh line 3:
no_use="var"
^-- SC2034: no_use appears unused. Verify it or export it.

他にも色々検出してくれますが、あまり詳しくないので紹介のみに留めます。

2015/12/06 追記

@b4b4r07 さんから
typoの指摘・修正いただきました。ありがとうございます。
(shellcheckのtypoがちらほら...)

変数の変数

変数に慣れてくると、こんな事したくなりますよね。

変数の変数やりたい(失敗)
mark_base="@(^ ^)@"
markA=mark_base #=> markA="mark_base" と同じ意味です
echo ${markA}
 #=> @(^ ^)@ と出力したい(実際は mark_base と出力される)

そういえば、bashの変数って ${parameter} でしか値を取得できないんでしたね。

でも他の言語なら、変数を変数に代入するのって普通のことじゃないですか。

例えばRubyなら?
mark_base = "@(^ ^)@"
markA = mark_base
p markA
 #=> "@(^ ^)@"

ググったら eval で頑張る方法が出てきましたか?
ああー、なるほど確かにできますね。

変数の変数eval版
mark_base="@(^ ^)@"
markA=mark_base
echo `eval echo '$'$markA`
 #=> "@(^ ^)@"

なんとか出来ました。でも、あまり気持ちのいい感じしませんね。それに eval は難しいです。
いっそ declare -A で連想配列を作った方が分かりやすいでしょう。

連想配列でがんばる版
declare -A marks
marks=(
["mark_base"]="@(^ ^)@"
)
markA=mark_base
echo ${marks[${markA}]}
 #=> "@(^ ^)@"

シンプルさからは遠く、ちょっと大げさな感じですね。
しかし、やっぱり変数の変数って難しいのでしょうか。
と思ったら、${!var} というのがあるんですよ。

eval使わなくていい版
mark_base="@(^ ^)@"
markA=mark_base
echo ${!markA}
 #=> "@(^ ^)@"

ちなみに man には次の説明しか書いてないです。

${parameter}
(中略)
If the first character of parameter is an exclamation point (!), it introduces a level of variable indirection.

${!parameter} と分かりやすく区切られていないんです。これは気付きませんね。
man ですら探し辛い書き方なので、使う時はコメントアウトを添えておくと(数カ月後の自分に)親切かもしれませんね。

grep あるある

特定のプロセスを確認

コンソールで試すがままなgrep

初心者あるある例
% ps aux | grep jenkins | grep -v grep | grep java
yasuhiroki      24501   0.0  3.9  6421828 654108   ??  S    11:22PM   1:16.61 /usr/bin/java -Dmail.smtp.starttls.enable=true -jar /usr/local/opt/jenkins/libexec/jenkins.war
  1. jenkinsでgrepしたら
  2. grep自身のプロセスが出てきたので-vで取り除き
  3. jenkinsのつくプロセスが他にもあったから
  4. とりあえず java でもう一回 grep

そんな感じで気付いたらパイプの連続です。
いえ気持ちは分かります。試しながらパイプで繋いでいれば、こうなりますよね。
でも、さすがに余計な処理が多すぎますね。

まず、grep自身のプロセスの取り除くには、ちょっと正規表現を使うだけで達成できます。
↓は良く見かける例です。

grep自身のプロセスを取り除くサンプル
ps aux | grep "ps au[x]"
実行例
# grep "ps aux" も検出しちゃう
$ ps aux | grep "ps aux"
yasuhiroki       5948   0.0  0.0  2457428    460 s000  U+   11:49PM   0:00.00 grep ps aux
root             5947   0.0  0.0  2437344   1008 s000  R+   11:49PM   0:00.00 ps aux

# []付けるだけで取り除ける
$ ps aux | grep "ps au[x]"
root             5949   0.0  0.0  2436320   1000 s000  R+   11:50PM   0:00.01 ps aux

grep "ps au[x]" という文字列は、正規表現 "ps au[x]" ではマッチしないですからね。

さて、今回欲しいのは、javaコマンドで jenkins.warを引数にしているプロセスなのでしたね。
では、そのように書きましょう。

正規表現
% ps aux | grep "java.*jenkins\.war"              
yasuhiroki      24501   0.0  3.9  6421828 654112   ??  S    11:22PM   1:16.72 /usr/bin/java -Dmail.smtp.starttls.enable=true -jar /usr/local/opt/jenkins/libexec/jenkins.war

java の grep も、grep -v も不要になりました。
jenkins\.war が地味にミソです。jenkins.war としてしまうと、grep 自身のプロセスも引っかかってしまいます。
考えるのが面倒なら、java.*jenkins\.wa[r] と、とりあえず最後の文字を [] で囲んでしまいましょうか。

そういえば、どうして jenkins のプロセスを探していたのです?

特定のコマンドのプロセス番号が知りたい

初心者よくあるやつ
% ps aux | grep "java.*jenkins\.war" | awk '{print $2}'
24501

pgrep を使いましょう。

pgrepで一発
% pgrep -f "java.*jenkins\.war"
24501

ああ、シンプル。

特定のコマンドの引数を知りたい

これはまあ、grep で良いような気がしますね。
ちょっと正規表現修正して -o すれば済みます。

grepのオプション使おう
# コマンド名 java を残して良いなら
$ ps aux | grep -o "java.*jenkins\.war.*"
java -Dmail.smtp.starttls.enable=true -jar -Xms512m -Xmx2048m -XX:MaxPermSize=128m -Dfile.encoding=utf-8 /usr/local/opt/jenkins/libexec/jenkins.war --httpPort=8080

# コマンド名 java を残したくないなら(引数だけにしたいなら)
$ ps aux | grep -o "java.*jenkins\.war.*" | cut -d' ' -f 2-
-Dmail.smtp.starttls.enable=true -jar -Xms512m -Xmx2048m -XX:MaxPermSize=128m -Dfile.encoding=utf-8 /usr/local/opt/jenkins/libexec/jenkins.war --httpPort=8080

一応、代替案も考えてみましたが、これだ! というものがありませんでした。

うーん
# Linuxなら取れるんだけど、OSXでは取れなくて...
cat /proc/$(pgrep -f 'java.*jenkins')/cmdline

2015/12/13 追記

@heliac2000 さんから、pgrep -l を使う手もあると教えていただきました。

ls -l と同じような意味(Long Output, Long Format)なので覚えやすいですね。

pgrepで取れる
$ pgrep -fl 'java.*jenkins' | cut -d' ' -f3-
-Dmail.smtp.starttls.enable=true -jar -Xms512m -Xmx2048m -XX:MaxPermSize=128m -Dfile.encoding=utf-8 /usr/local/opt/jenkins/libexec/jenkins.war --httpPort=8080

特定のファイル名を探す

要するにfindを使いこなせていなかったあの日
# カレントディレクトリを捜索
ls -l | grep 999

# もっと深い階層にあったかもと思って再帰的に検索
ls -lR | grep 999

lsgrep の組み合わせ。よくやってました。
確かに grep は何にでも応用できますからね。最強です。
しかして、ls -lR だと、どの階層のファイルか分かり辛くないですか?

どのディレクトリにあるのやら?
$ ls -lR | grep 999
-rw-r--r--  1 yasuhiroki  staff  4 12  2 02:20 1999.txt
-rw-r--r--  1 yasuhiroki  staff  4 12  2 02:20 2999.txt
-rw-r--r--  1 yasuhiroki  staff  4 12  2 02:20 3999.txt
-rw-r--r--  1 yasuhiroki  staff  7 12  2 02:20 4999.txt

ファイルの検索は find の仕事ですよ。

findの仕事
find . -name "*999*"
実行結果例
$ find . -name "*999*"
./tmp_dir/1999.txt
./tmp_dir/2999.txt
./tmp_dir/3999.txt
./tmp_dir/4999.txt

これでファイルの一覧がパス付きで取れました。

お目当てのファイルを探し出しましたけど、何か、続けてやりたいことがあったのでは?

特定の名前を持つファイルサイズ取得

やっぱりfindの仕事
$ find . -name "*999*" -ls
2368802        8 -rw-r--r--    1 yasuhiroki       staff                   4 12  2 02:20 ./tmp_dir/1999.txt
2369802        8 -rw-r--r--    1 yasuhiroki       staff                   4 12  2 02:20 ./tmp_dir/2999.txt
2370802        8 -rw-r--r--    1 yasuhiroki       staff                   4 12  2 02:20 ./tmp_dir/3999.txt
2371802        8 -rw-r--r--    1 yasuhiroki       staff                   7 12  2 02:20 ./tmp_dir/4999.txt

4,7 のある行がサイズです。
ううん...。分かりにくいですね。

findでexec使う
$ find . -name "*999*" -exec du -h {} \;
4.0K    ./tmp_dir/1999.txt
4.0K    ./tmp_dir/2999.txt
4.0K    ./tmp_dir/3999.txt
4.0K    ./tmp_dir/4999.txt

du コマンドで知りたい情報だけを得るようにしてみました。
ちょっとコマンドが長くなってしまいましたが。単位付きで分かりやすいですね。

でも気を付けてください。du コマンドのサイズって、ブロック単位なんですよ。
良く考えてくださいね。
知りたいのはファイルが消費する容量のサイズ?
それともファイルに含まれるバイト数?

バイト数なら wc を使えば良いですね。

findでexec使う(wc版)
$ find . -name "*999*" -exec wc -c {} \;
4   ./tmp_dir/1999.txt
4   ./tmp_dir/2999.txt
4   ./tmp_dir/3999.txt
7   ./tmp_dir/4999.txt

補足 find -exec + の方が適切なシーン

@fumiyasさんから、find -exec +教えていただきました

知らなかったので慌てて man を読んだところ、可能な限り引数にまとめて渡してコマンドを実行する、とのことです。

つまり今回の例だと、

find . -name "*999*" -exec wc -c {} + とすれば、
wc -c ./tmp_dir/1999.txt ./tmp_dir/2999.txt ./tmp_dir/3999.txt ./tmp_dir/4999.txt

を実行することになり、起動するコマンドが1つになります。ようするに xargs と似たような感じですね。(man にも、xargs と似たような振る舞いをすると書いてありました)

また、このケースだと、 wc を使うより stat コマンド、もしくは find -printf を使うほうがより効率的とのことです。

statを使う場合
# BSD版 stat
$ find . -name '*999*' -exec stat -f '%z %N' {} +
4 ./tmp_dir/1999.txt
4 ./tmp_dir/2999.txt
4 ./tmp_dir/3999.txt
7 ./tmp_dir/4999.txt

# GNU版 stat
$ find . -name '*999*' -exec gstat -c '%s %n' {} +
4 ./tmp_dir/1999.txt
4 ./tmp_dir/2999.txt
4 ./tmp_dir/3999.txt
7 ./tmp_dir/4999.txt

特定の名前を持つファイルのパーミッションを変更したい

項目を分けましたが、実装方法はサイズを調べる時と変わりないですね。

find便利
find . -name "*999*" -exec chmod 755 {} \;

ファイル内に hogehoge が含まれていたら条件分岐したい

初心者あるあるgrepとifその1
if [ "`grep 'hogehoge' hoge.txt`" ]; then
  echo "found!"
fi

grep して検索文字が含まれる行があれば、確かに条件式は真になりますね。
grep で抽出した行が複数あっても、"" で囲んでいるから大丈夫ですね。

でも、grepしたい検索文字が空白だったらどうしましょう。
検索したい文字が ' とか " とか含まれていたら?

いえ、実はそのまま空白や"を検索文字列に書けば動きます。
でも気になりますよね。あれっ大丈夫なんだっけ、って。
それに ' を検索したい時は流石に、考えないといけませんね。

エスケープの参考)
http://qiita.com/cocodrips/items/bb3640a9834c8978d48a

なるべく、クォートは減らすに越したことはないですよ。

初心者あるあるgrepとifその2
grep 'hogehoge' hoge.txt
if [ $? = 0 ]; then
  echo "found!"
fi

終了コードを使ったのですね。
これなら、grepgrep の記述のみに集中すれば良いので、余計な心配しなくて良いですね。クォートも必要最小限で済んでます(実は ' も不要です)。

でもこれって、[ $? = 0 ] をあちこちに書く未来が見えますね。
大したことないですって? [ $? = 0] としてしまって、syntaxエラーになったことありませんか?

ところで if の後は [] が続くなんて決まりありましたっけ?

ifとgrepをまとめてしまう
if grep 'hogehoge' hoge.txt; then
  echo "found!"
fi

if が条件分岐に使用するのは終了コードなのです。だから、素直にコマンドを書いてしまえば良いんです。

この記述なら、
if grepコマンドの終了コードが0なら echo "found!" する
と、読んだ通りの条件分岐になりますね。

grepの出力が邪魔になる? /dev/null に突っこんでもいいですけど、-s-q オプションを使った方が良いですよ。エラー出力も隠せますからね。

ついでに、この if ってそもそも if にする必要あるんでしょうか?

&&使って一行に
grep -sq 'hogehoge' hoge.txt && echo "found!"

&& は 左辺のコマンドの終了コードが 0 だった時に右辺を評価します。
if[] も消えて、 本当に処理したいコマンド grepecho だけに集中できますね。

hogehoge が無かった時の処理ですか?

hogehogeが無かった時
grep -sqv 'hogehoge' hoge.txt && echo "not found!"
grep -sq 'hogehoge' hoge.txt || echo "not found!"

grep -v|| で良いですね。
もし || を使う場合は、 ファイル hoge.txt が存在しない時も echo "not found!" が実行されることに気を付けてくださいね。
どちらを使うべきかは、実装したい処理によりますから。

あー、やっぱり三項演算したいですか。ちょっと工夫が必要なんです。
grep -sq 'hogehoge' hoge.txt && echo "found!" || echo "not found!"
って、if文で表すと↓になるんですよ。。

三項演算できてるようでちょっと違う
if grep -sq 'hogehoge' hoge.txt; then
  if ! echo "found!"; then
    echo "not found!"
  fi
else
  echo "not found!"
fi

echo "found!" が失敗した時も(Typoもあり得ますね)、echo "not found!" が出力されてしまうんです。

変数の中に特定の文字があるか調べたい

echo+grep
having_hoge_var="@(^ ^)@ hoge!"
echo ${having_hoge_var} | grep -sq "hoge" && echo "OK!"

grep で調べるために、echo で変数の値を標準出力にはきだしているんですね。

でも、echo ${having_hoge_var} | grep -sq "hoge" って長くないですか?
[[ ]] という [ ] の拡張を使うと、すっきりします。

[[]]でマッチ比較
having_hoge_var="@(^ ^)@ hoge!"
[[ ${having_hoge_var} =~ "hoge" ]] && echo "OK!"
[[ ${having_hoge_var} == *hoge* ]] && echo "OK"

どちらでも "OK" です。

整数の計算あるある

expr コマンドを使ってしまう

だってググると出てくるんだもの
i=`expr $i + 1`

「シェルスクリプト 計算」でググると出てきますものね。あー、bc コマンドも出てきますね。
なんだか、Bashってコマンド使わないと四則計算できないなんて不便ですね。

でも本当に不便なのは、もっと便利な方法があるのにゆるい検索では知ることができないWebの世界だと思いますよ。

ループでインクリメントしたい

exprが見つかったから...
i=0
while [ $i -lt 10 ]
do
  i=`expr $i + 1`
  echo $i
done

1~10を、インクリメントしながら表示しようとしたらこんな感じですか。
何度見ても、expr $i + 1 って何だか格好悪いですよね。

(()) を使えばすっきりです。
インクリメントしたいなら、見た目もパフォーマンスも圧倒的にこちらが良いです。

変数iをインクリメント
((i++))

Rubyですら使えない i++ ができるんです。

さっそく使う
i=0
while [ $((i++)) -lt 10 ]
do
  echo $i
done

(()) だけだと、何も出力されずただ実行されるのみです。
$(()) とすれば、計算結果の数値を得られるので、そのまま比較に使えるわけです。
やりたいのって、こういう事でしたよね?

でも、もうちょっと見覚えのある書式にしたいですよね。
それに (()) は、足し算しかできないわけじゃないんですよ?

もっとすっきりしたループ
i=0
while (( i++ < 10 ))
do
  echo $i
done

[ ] の中で使うとリダイレクトになってしまう比較演算子 < も、(()) なら使えます。

for では、もっと見覚えのある形にできますよ。

CやJavaっぽいforになる
for ((i=1; i <= 10; i++))
do
  echo $i
done

さらに for であれば do ~ done すら使わずに済むんです。

もっとCやJavaっぽいforになる
max_cnt=10
for ((i=1; i <= max_cnt; i++))
{
  echo $i
}

見慣れた書式で安心できますね。
ちなみに 1~10を一行ごとに出力するなら、forを使う以外にも echotrgrep で(シェル芸の範疇になりそうなので省略)

while read あるある

ファイルから一行ずつ読み込んで、あれやこれやってしますよね?
そんな時に使うといったら、while read でしたね。

csvを読み込む

泥臭くがんばりました
cat data.csv | while read line
do
  _1=`echo ${line} | cut -d',' -f1`
  _2=`echo ${line} | cut -d',' -f2`
  _3=`echo ${line} | cut -d',' -f3`

  echo "Column 1: $_1"
  echo "Column 2: $_2"
  echo "Column 3: $_3"
done

cut, 区切りにして、1つずつフィールドを読み取って...
大変ですね。列が20とか100とかなっても、このまま頑張りますか?
頑張りたくないですよね。

ようするに,でsplitすれば良いんですよね?

read時の区切り文字変えてみる?
cat data.csv | while IFS=',' read _1 _2 _3
do  
  echo "Column 1: $_1"
  echo "Column 2: $_2"
  echo "Column 3: $_3"
done

デフォルトの区切り文字は空白文字なので、',' では区切ってくれません。
なので↑のようにすれば、read してる時だけ、区切り文字を , した上、各フィールドの値を 各変数に代入できます。
色々と省略できてますね。

IFSを変更する時は、元の値を保持しておいて(IFS_ORI=$IFSとかして)、あとでIFSを元に戻さないと大変な目にあいますけど、この書き方なら不要です。

ただ、列数が増れば増えるほど辛くなることに変わりはないですね。
配列にすればもう少し扱いやすいでしょう。

splitして配列に
cat data.csv | while read line
do
  cols=(`echo $line | tr ',' ' '`)
  for ((i=0; i < ${#cols[@]}; i++)) {
    echo "Column $((i+1)): ${cols[$i]}"
  }
done

read -a で配列化はもっとシンプルに

2015/12/07 追記
@eban さんのブログで、read -a を使えばもっとシンプルになると指摘されました。

http://jarp.does.notwork.org/diary/201512a.html#20151204

readのaオプション使えば配列化が一発で
cat data.csv | while IFS=, read -a cols
do
  for ((i=0; i < ${#cols[@]}; i++)) {
    echo "Column $((i+1)): ${cols[$i]}"
  }
done

, 区切りの配列にできるので、空白が入っていても安心ですね。


えっ、こんな csv だったらどうするか?

こんなCSVは
2015/12/04 00:00:00,"A,B",c,d\ne
こうなっちゃう
Column 1: 2015/12/04
Column 2: 00:00:00
Column 3: "A
Column 4: B"
Column 5: c
Column 6: dne

ぐっ、こ、これは。。。

Bashでやり切るのは大変だから...
$ cat data.csv | ruby -r 'csv' -e 'CSV.parse($<).each{|row| row.each_with_index{|c,i| puts "Column #{i}: #{c}"}}'
Column 0: 2015/12/04 00:00:00
Column 1: A,B
Column 2: c
Column 3: d\ne

つまり、CSVのパースを真剣にやるなら、シェルスクリプトは止めたほうが良いという話でした。

最後に

Greg's Wiki を読みながら書いてみるのが一番の勉強だと思います。

参考

明日は @yasuto777 さんです!