LoginSignup
989
1009

More than 5 years have passed since last update.

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

Last updated at Posted at 2015-12-03

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 さんです!

989
1009
5

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
989
1009