Edited at

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

More than 3 years have passed since last update.

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