LoginSignup
113
113

More than 1 year has passed since last update.

シェルスクリプトの変数はダブルクォートしなければいけない!という話

Last updated at Posted at 2021-08-07

TL; DR

  • 変数をダブルクォートしない使い方は上級者向けの危険な機能です!
  • $@$*(または配列の全要素)をダブルクォートしない使い方は知る必要すらありません!
  • ShellCheck を導入すれば誰でも簡単に正しい書き方がわかります!!

2021-08-21 補足 この記事は dash や bash などの POSIX シェルの一般的な動作を解説しており zsh のデフォルトとは異なります。記事の中でも説明していますが zsh の場合はシェルのオプションを変更することで POSIX 準拠の動作に変更することができます。zsh に関しては後半の「zsh に関する注意点」も参照してください。

はじめに

プログラミング言語は、言語によって記号の意味が異なることがよくあります。クォーテーションマークはその一つです。C 言語ではシングルクォートは文字リテラル(一文字)を意味し文字列はダブルクォートします。JavaScript や Python ではシングルクォートとダブルクォートの意味は同じでエスケープシーケンスが使えます。Ruby ではシングルクォートはただの文字列('\ を使う時だけエスケープが必要)ですがダブルクォートはバックスラッシュ記法や式展開が使えます。クォートは多くの言語で文字列を表現する時に使いますが微妙な違いがあります。シェルスクリプトも他の言語とは違う意味があります。それを正しく理解していないと大きなミスを引き起こしかねません。

前提知識 シェルスクリプトのクォートの一般的な話

特別な意味を持つ文字がなければクォートしなくてよい

シェルスクリプトの他の言語との大きな違いの一つは文字列のクォートが必須ではないという点です。これはシェル上で快適に単語を入力をできるように操作性の観点から設計した結果です。もしクォートが必須だと入力がとても面倒になってしまいます。

find /var/log -type f -name "*.log" -exec ls -l {} \;

# もしクォートが必須だったら?
find "/var/log" "-type" "f" "-name" "*.log" "-exec" "ls" "-l" "{}" ";"

# おまけ もしカッコとカンマも必須だったら?
find("/var/log", "-type", "f", "-name", "*.log", "-exec", "ls", "-l", "{}", ";")

# おまけ2 cp *.txt files の glob 展開と文字列の区別をどう表現しますか?
cp(glob("*.txt"), "files") # glob データ型の導入?

余談ですがシェルスクリプトの代わりに別の言語を使おうとか新しいライブラリが登場したりしますが、シェル(スクリプト)はこのように無駄を極限まで削ぎ落とした優れた文法を実現しています。この点において他の言語はシェルに敵いません。この文法の差に気づいてそれを超えない限り他の言語がシェルスクリプトの代わりとして置き換わることはまずないでしょう。汎用プログラミング言語用の REPL をシェルとして使う人はいないということです。

なお、クォートせずに特別な意味がある文字を使いたい場合はバックスラッシュでエスケープすると使うことができます。

echo \"\<\>\' # => "<>'

特別な意味を持つ文字があればクォートする

シェルスクリプトの文法として特別な意味を持つ文字(&|<, >, * 等)をただの文字として使う場合にはクォートが必要です。その時シングルクォートとダブルクォートで文字列中の記号の意味が変わるものがあります。これに関しては他の言語でもよくあることなのでここでつまづく人は少ないと思います。

echo "Hello World" # スペースを使いたい場合はクォートする
echo "Hello <World>" # シェルのリダイレクト記号として解釈されなようにクォートする
echo "Hello $USER" # ダブルクォートの中では変数($ は特別な意味を持つ)が使える
echo 'Hello $USER' # シングルクォートの中では $ は特別な意味を持たない

シングルクォート ('...'

シングルクォートの場合はその中ですべての文字が特別な意味を持たなくなります。唯一の例外は文字列を終了させるシングルクォーテーションでシングルクォートの中にシングルクォーテーションを含めることはできません。もしそういうことをしたければ次のように書きます。(シェルスクリプトでは文字列と文字列をつなげる + のようなものはなく、つなげて書くことができます。)

# What’s New
echo 'What'"’"'s New' # ダブルクォーテーションで括っている ('What' "’" 's New')
echo 'What'\’'s New' # バックスペースでエスケープしている ('What' \’ 's New')

ダブルクォート ("..."

ダブルクォートでも多くの文字が特別な意味を持たなくなり $`\ と文字列終了の " だけが特別な意味を持ちます。

echo "$VAR" # 変数展開 の $
echo "$(uname -a)" # コマンド置換の $
echo "`uname -a`" # コマンド置換の `
echo " \$ \` \" \\ " # 特殊な文字を埋め込む場合のエスケープ文字の \

たまに勘違されますがダブルクォートの中で \ でエスケープできる文字は $`\" の四文字だけです(それに加えて行末で \ を用いると続く改行文字が無かったことになるので改行文字もエスケープできるとみなすことができます)。それ以外の文字、つまりダブルクォートの中の \t\n はタブや改行の意味にはなりません。あれ?でも \n で改行されてるよ?って思った方、それは echo コマンドが解釈しているだけです。

echo "foo\nbar" # zsh の echo は \n という文字列で改行する
echo 'foo\nbar' # だからシングルクォートしていても改行する
printf '%s' "foo\nbar" # echo を使わず出力すれば foo\nbar と表示される

ドルシングルクォート ($'...'

なお、シェルスクリプトにはシングルクォート、ダブルクォートの他に、$'...'Dollar-Single Quotes - ドルシングルクォートってカタカナで書いてる人いなさそうだけど・・・ダラーのほうが良い?)も多くのシェルで利用可能です(POSIX への追加が検討されています)。これを使うと \t\n などのエスケープシーケンスが使えるようになります。ただしドルシングルクォートの中で変数などを使うことはできません。バックスラッシュが使えるシングルクォートという扱いです。

printf '%s' $'foo\nbar' # \n で 改行される
printf '%s' $'foo\nbar'"$VAR" # 変数を使いたければ後ろにつなげればいいだけ

つまりシェルスクリプトには三種類のクォートがあるということです。

  • シングルクォート('...'
  • ダブルクォート("..."
  • ドルシングルクォート($'...') 一部のシェルを除く

ドルダブルクォート ($"...") 補足 メッセージの国際化

2021-08-26 追記 私がこれをクォート機能として認識しておらず一部のシェルにしか対応してないので漏れていました。適切にメッセージカタログを作っておくと文字列を日本語などに翻訳することができます。

echo $"Hello World" # => こんにちは世界
# 対応してないシェルでは $Hello World と出力される

変数展開などが行われるので翻訳機能付きのダブルクォートという扱いであるようです。対応シェルは bash と ksh93 だけだと思います。(mksh でも R40 で $"..." という構文に対応しましたが国際化機能は実装されていません。)

メッセージの国際化方法に興味がある方は以下を参照してください。私はやったことがありません。

余談ですが、私はこのどちらの方法も気に入っていません。$"..." は 対応シェルが限られるし、gettext.sh は外部コマンド呼び出しでパフォーマンスが低下する可能性があるからです。

シェルスクリプトの国際化をしたいと思ったことはないのですが、もしやるなら、$"..." をマーカーとして扱い "$MSGID0001" みたいな翻訳されたメッセージが入った変数に置き換えるビルドスクリプトを作る方式にするでしょう。(ビルドせずに実行した時に $ が出力されないように """..." みたいにした方が良いだろうか?)

ダブルクォートの有無による違い

さてここからが本題なのですが変数をダブルクォートせずにそのまま書いた場合とダブルクォートの中に書いた場合で(特別な意味を持つ文字以外の)違いがあります。それが「単語分割(フィールド分割)」と「パス名展開」です。変数をダブルクォートしない場合にはこれらの機能が働きます。逆に言えばこれらの機能を意図せずに使ってしまうことを防ぐためにダブルクォートしなければいけないということです。

注意 zsh では変数の「単語分割」と「パス名展開」がデフォルトで無効になっておりダブルクォートしなくてもダブルクォートした場合と同じ動きをします。POSIX シェルに準拠した動作にするにはそれぞれ setopt shwordsplitsetopt globsubst で有効にする必要があります。なお無効になっている理由は、この記事で説明している通りダブルクォートしないことが危険だからです。(参考

単語分割(フィールド分割)

args_length() {
  echo $# # 引数の長さを出力
}

VAR=""
args_length $VAR # => 0
args_length "$VAR" # => 1

VAR="foo bar baz"
args_length $VAR # => 3
args_length "$VAR" # => 1

このような動作になる理由はシェルはコマンド・シェル関数(この例では args_length 関数)を呼び出す前に変数の中身を展開するからという説明で理解できると思います。

VAR=""
args_length # 引数がない
args_length "" # 引数がある

VAR="foo bar baz"
args_length foo bar baz # 3 つの引数に分割される
args_length "foo bar baz" # 1 つの引数

単語分割というのは、変数をダブルクォートしない場合に(特定のルールで)分割してから変数の中身を展開する機能です。この特定のルールというのは IFS 変数に含まれてる文字のいずれかで分割するというルールです。デフォルトでは IFS 変数にはスペース、タブ、改行(、zshの場合は追加で \0)が設定されているのでこれらの文字で分割して展開されます。

IFS 変数を変更することで別の文字で区切ることができます。

IFS=":"
VAR="foo:bar:baz"
args_length foo bar baz # : で分割してから変数の中身が展開される

この機能をうまく利用すると、カンマ区切りデータを分割したりすることができます。

IFS=","
VAR="foo,bar,baz"

set -- $VAR # 位置パラーメータへ分割して設定
# set -- foo bar baz

echo "1:$1 2:$2 3:$3" # => 1:foo 2:bar 3:baz

ただ単語分割と IFS 変数 を使った分割はいろいろと罠があるので注意が必要です。詳しくは「シェルスクリプトの単語分割 (IFS) は罠だらけ」を参照してください。

パス名展開

これは正確には「変数のダブルクォート」とは直接関係なくダブルクォートされてない特殊な文字列(glob パターン、いわゆるワイルドカード)がある場合に行われる展開で、単語分割の後のタイミングで(単語分割自体がなくても)行われる処理です。

echo * # カレントディレクトリのファイルに展開される(ドットで始まるのを除く)
echo .* # カレントディレクトリのファイルに展開される(ドットで始まるファイルのみ)

echo *.no-such-extension # => *.no-such-extension
# パターンにマッチするファイルが見つからなければ、パターン文字列そのものになるので注意
# ただし zsh のデフォルト設定や一部のシェルでは設定でエラーにすることもできる

GLOB="*"
echo $GLOB # echo * と解釈されカレントディレクトリのファイルに展開される
echo "$GLOB" # echo "*" と解釈されるので "*" が出力されるだけ

# 単語分割が行われる = ダブルクォートしてないから、必然的にパス名展開も行われる
GLOB="foo bar *"
echo $GLOB # echo foo bar * と解釈される

なおパス名展開は set -f (set -o noglob) で無効にすることができます。

set -f
GLOB="foo bar *"
echo $GLOB # => foo bar * という文字列が出力される

変数の単語分割やパス名展開を使うことはほぼない

変数に対して単語分割やパス名展開が必要になる場合はほとんどありません。カンマ区切りデータを分割したい時などまったくないわけではありませんが、必要になるケースの方が圧倒的に少なく特別な理由がなければ変数は常にダブルクォートをしておくべきです。そうしなければ次のような問題が発生してしまいます。

file="data 2021-08-06.txt" # スペースが含まれるファイル名
rm $file # rm data 2021-08-06.txt
# data と 2021-08-06.txt の 2 つのファイルを削除しようとしてしまう
# 正しくは rm "$file" => rm "data 2021-08-06.txt" であるべき

file="data[2021-12].txt" # 日付を[]で追加したファイル名
rm $file # rm data[2021-12].txt
# data1.txt というファイルにマッチするので、それが削除されてしまう
# 正しくは rm "$file" => rm "data[2021-12].txt" であるべき

コマンド置換にもダブルクォートは必要!

話を単純にするために、ここまでは変数だけに絞って話をしましたが、コマンド置換でもダブルクォートは必要です。

args_length() {
  echo $# # 引数の長さを出力
}

args_length $(echo "foo bar baz") # => 3 単語分割されている
args_length "$(echo "foo bar baz")" # => 1

echo $(echo "*") # echo * が実行される
echo "$(echo "*")" # echo "*" が実行される

コマンド置換も基本的にはダブルクォートしていればよいのですが、よく見かけるダブルクォートしてはいけない例としてこのようなものがあります。以下のコードを実行すると単語分割が行われないためにループは一回しか実行されません。

for i in "$(seq 3)"; do
  echo "@ $i"
done

# 実行結果(改行も含めて一つとして扱われる)
@ 1
2
3

以下のようにダブルクォーテーションを外せばよいのですが、実行するコマンドによっては意図しない単語分割やパス名展開が行われないように十分気をつける必要があります。

for i in $(seq 3); do
  echo "@ $i"
done

# 実行結果
@ 1
@ 2
@ 3

ちなみに一般的には forin でコマンド置換を使うのは避けパイプを使ったほうが良いでしょう。

seq 3 | while IFS= read -r line; do
  echo "@ $line"
done

また別件ですがコマンド置換は変数に入れないとエラー処理ができないという問題があります。それについては「シェルスクリプトのコマンド置換 $(...) の出力は変数に入れないとエラー処理ができないという話」を参照してください。

位置パラメータと配列

"$@""${arr[@]}" の話

ここまでの話で「ダブルクォートした方が良いのは分かった。ダブルクォートしたら引数が分割されることなく一つになるんだな」と思ってしまった人、ちょっと待ってください。例外があります。それは全位置パラメータ($@)と配列の全要素(${arr[@]})です。

位置パラメータと配列もダブルクォートしなければいけないものです(ダブルクォートなしで使う例はまったくないとは言いませんが、変数の場合よりも使うことはないでしょう)。しかしこれらは一つの引数になるわけではありません。ダブルクォートしても引数の数は変わりません。

args() {
  echo "length: $#"
  printf '%s\n' "$@" # 引数を一行ごとに表示する
}

set -- # 位置パラメータをなしにする
args "$@" # args として実行される
# length: 0
#

set -- "foo 1" "bar 2" "baz 3"
args "$@" # args "foo 1" "bar 2" "baz 3" として実行される
# length: 3
# foo 1
# bar 2
# baz 3

最初の例の set --args "$@" に注意してください。args の引数はあるように書いているのに位置パラメータがなにもない場合は引数は 0 個です。また二番目の例では args "$@" の引数の一つに見えるのに、実際にシェル関数に渡される引数の数は 3 個です。このようにシェルスクリプトのダブルクォートは他の言語のように単一の文字列を作るものではなく変数の展開のルールを変えるのものなのです。

さてここで少し面白い実験をしてみましょう。$@ の前後に文字を付けて args "1$@2" と呼び出したらどうなるでしょうか?答えは以下のようになります。

set -- foo bar baz
args "1$@2" # args "1foo" "bar" "baz2" として実行される
# length: 3
# 1foo
# bar
# baz2

引数の数は変わらず $@ の前につけた文字列は最初の引数の前に $@ の後ろにつけた文字列は最後の引数の後に結合されるのです。この仕様が役に立つことは少ないのですが知っていると意外な場面で使えるかもしれません。

ここまで位置パラメータ($@)の話しかしてきませんでしたが、配列でも同じことが当てはまります。こちらも要素数は変わりません。

arr=("foo 1" "bar 2" "baz 3")
args "${arr[@]}"
# length: 3
# foo 1
# bar 2
# baz 3

arr2=("${arr[@]}") # 別の配列にコピーする際もダブルクォートは必要

ところで配列の文法をよく見ると位置パラメータは無名配列のように見えてきませんか?("${arr[@]}""${[@]}""${@}""$@") この関係性に気づくと配列と位置パラメータは同じような使い方ができるように設計されているということにも気づくと思います。

"$*""${arr[*]}" の話

"$@""${arr[@]}" の話をしたならば "$*""${arr[*]}" の話 もしなければいけないでしょう。これらも同じくダブルクォートしなければいけないものです。ダブルクォートなしで使うことが正しいという例は正直思いつきません。さて、こちらですが "$@" と違って一つの引数にするためのものです。

args() {
  echo "length: $#"
  printf '%s\n' "$@" # 引数を一行ごとに表示する
}

set -- # 位置パラメータをなしにする
args "$*" # args "" として実行される
# length: 1
#

set -- "foo 1" "bar 2" "baz 3"
args "$*" # args "foo 1 bar 2 baz 3" として実行される
# length: 1
# foo 1 bar 2 baz 3

この時、位置パラメータのすべては IFS 変数の最初の一文字(デフォルトではスペース)を区切り記号として結合されます。もし IFS 変数が空文字であれば区切り記号なしで結合されます。単語分割のほぼ逆の処理が行われると思って良いでしょう(単語分割では区切り文字を複数指定できるので厳密には異なる)。説明が同じになるだけなので省略しますが配列の場合("${arr[*]}")も同じように動作します。

"$@""$*" の使い分け

"$@""${arr[@]}")と "$*""${arr[*]}")の使い分けですが、複数の引数(要素)として扱いたい場合は前者(こちらが一般的)を使い、一つの引数(要素)に結合したいなら後者を使います。またどちらもダブルクォートしなければいけません。もししなければ両方とも単語分割とパス名展開が行われてしまいます(結果として $@$* も同じ意味になります)。位置パラメータまたは配列で単語分割やパス名展開をしたいということはあまり考えられないのでダブルクォートしない使い方は忘れていいと思います。

ダブルクォートしなくても良い箇所

実はダブルクォートしなくても単語分割やパス名展開が行われない箇所がいくつかあります。基本的に変数はどんな場合でもダブルクォートしていればよいのでこの項目の内容は雑学程度に思ってください。(と言いつつ個人的には省けるものは省きたいスタイルなので私はこれらの箇所でダブルクォートしていません。)

var=$VAR # 変数代入
var=$(echo "foo bar") # コマンド置換結果の変数代入も OK

# case の対象は問題なし
case $var in
  ...
esac

# [[ ]] は OK。ただし [ ] はだめ
# この違いは [[ ]] はコマンドではなくシェルの予約語だから
if [[ $var = "test" ]]; then
  ...
fi

# 必ず数値だったり特別な文字が入ることがない特殊変数
echo $# # 位置パラメータの数 
echo $? # 終了ステータス
echo $! # バックグラウンド起動したプロセスID
echo $- # シェルのオプション(set -e した時の e など)
echo $PPID # プロセスID
echo $OPTIND # getopts で shift すべき引数の数

上記の変数代入と似たような形で export var="$VAR" 等のコマンドを使った変数代入があるのですが、こちらに関しては(現状は)ダブルクォートしなければいけません。なぜ "現状は" と書いたかというと、次期 POSIX の Issue 8 ではこのケースで単語分割やパス名展開が行われないと規定されるからです(詳細は「次期POSIXシェル仕様の「宣言ユーティリティ」とシェルスクリプトの互換性問題」参照)。とは言っても特定のシェル限定で開発するならともなく、汎用的なシェルスクリプトの場合は古いシェルへの対応をすぐに打ち切ることはできないと思うのでしばらくはダブルクォートしなければいけません。

ところで [ ] ではなく [[ ]] を使うべき理由の一つに「ダブルクォートしなくていいから」というのがあるようなんですが、ほとんどの場所でダブルクォートが必要なのだから逆に常に使っていた方が間違わなくて良いと思うのは私だけでしょうか?[[ ]]の変数をダブルクォートしない人は、変数代入や case でもダブルクォートしないんですよね?(私と同じですね!w)

zsh に関する注意点

この記事は POSIX シェル準拠を前提としていたため「変数はダブルクォートをしなければいけない!」という結論でしたが、zsh の場合は単語分割とパス名展開がデフォルトで無効であるためダブルクォートは必須ではありません。ダブルクォートをしてもしなくても一部の例外を除き同じように動作します。

一部の例外の話の前に、zsh の変数の扱いが他と違う点を説明しておきます。zsh では配列の [ ] を使用する時に { } は不要です。他のシェルでは配列を使う時に ${arr[@]}${arr[10]} のように { } が必要になりますが zsh では $arr[@]$arr[10] と書くことができます。また 10 以上の位置パラメータを参照する際にも { } は不要で $10 で参照することができます。とは言え他のシェルと互換性がないので明示的に { } を使うことをお勧めします。

zsh のデフォルトではダブルクォートが不要であるため printf "%s\n" $var のように書くことができます。これは printf "%s\n" "$var" と書くのと全く同じです。また printf "%s\n" $@printf "%s\n" "$@" も同じです。殆どの場合ダブルクォートをしてもしなくても同じ動きなのですが配列を使う時に少し違いがあります。printf "%s\n" ${ary[@]}printf "%s\n" "${ary[@]}" も同じなのですが配列名だけを使用した場合が異なります。つまり printf "%s\n" $aryprintf "%s\n" "$ary" の動きが異なります。

zsh (と yash) では配列名のみを使用した場合は配列全体を意味します(他のシェルでは 0 番目の要素を意味します)。そのため zsh では変数をダブルクォートで括らない場合に [@] を書く必要はありません。しかし配列の変数名のみを使用した場合にダブルクォートで括ると(IFS 区切り文字で) で 1 つに結合されてしまいます。これを回避するには "${ary[@]}""${(@)ary}" のように書いて明示的に配列として扱うようにしなければいけません。

$ ary=("a a" "b b" "c c")

$ printf "%s\n" $ary
a a
b b 
c c

$ printf "%s\n" "$ary"
a a b b c c

$ printf "%s\n" "${ary[@]}"
a a
b b 
c c

zsh 専用スクリプトであれば、ダブルクォートをしなくてよく配列名だけで配列全体を表現できたりと短く書くことができますが、それでもダブルクォートの有無による違いはあるので注意が必要です。

ShellCheck を使おう!

さて変数は必要ない限りダブルクォートするという方針にするとして、ミスしないように気を張り詰めて書くというのは無駄な労力です。コンピュータがやってくれることはコンピューターに任せましょう。ということで ShellCheck を導入してください。ダブルクォートが必要な場所を教えてくれますし、スペースが必要な場所や必要ない場所も教えてくれます。この件に限らずシェルスクリプト以外でも同じですが生産性とコードの品質を考えてるなら lint ツールを使わないなんてありえません。

まとめ

  • シェルスクリプトはただの文字列はクォートしなくても良い
  • 変数を使う場合は単語分割やパス名展開が行われないように必ずダブルクォートする
  • もし本当に単語分割やパス名展開が必要なら理解してからダブルクォートを外す
  • そんなことよりさっさと ShellCheck を導入しよう!
113
113
2

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
113
113