シェルスクリプトで空白入りのファイル名が壊れる原因と対処|変数のクォート忘れ・ワイルドカード展開・”$@”と$*の違い・unary operator expected(bash)

真っ赤な紅葉の下で、キツネ耳としっぽをつけたななみが、大人っぽく微笑んでいる。

こんにちは、おうどんです🍜

おうどん

ななみさん、注文一覧を読むスクリプト、動かしたよ。ファイルは1つなのに、cat が2回怒った

ななみ

ファイル名を教えてください

おうどん

udon list.txt

ななみ

その真ん中の空白で、bash さんが切りましたね

(会話は説明用に作ったものです)

切った、とは何か。
bash(Linux や Git Bash で動くシェル)は、クォートのない変数を展開したあと、空白のところで単語に切り分けます。しかも、値の中の * はファイル名に広げます。

たとえるなら、bash は包丁を持った張り切りアルバイトです。

bash「注文票に空白があったので、2枚に切っておきました!」

頼んでいません。
でも、ダブルクォートという「切るな」の札を貼っておけば、1枚のまま運んでくれます。

この記事では、札の貼り方と、貼り忘れで起きるエラー、札を貼っても防げない落とし穴、札を貼らなくていい場所までまとめます。

検証は、今回の検証環境(Windows の Git Bash、bash 5.2.26)で、本文のコードをすべて実行しました。

まず覚えるのは、この3つだけです

  • 変数は $file ではなく "$file" と書く
  • 引数をまとめて渡すときは $* ではなく "$@" と書く
  • ファイルの一覧は $(ls) ではなく *.txt のようなワイルドカードで受ける
目次

クォートなしの変数は空白で切られる:症状を再現する

udon list.txt というファイルを、変数に入れて読みます。

file="udon list.txt"
cat $file
cat: udon: No such file or directory
cat: list.txt: No such file or directory

エラーが2行。udon と list.txt という2つのファイルを探しに行っています。
ダブルクォートを付けると、ちゃんと読めます。

cat "$file"
きつね 1杯

本当に切られているのか、コマンドが受け取った引数を数えて確かめましょう。次の showargs.sh は、受け取った引数を1つずつ [ ] で囲んで出すだけのスクリプトです。この記事の確認で何度も使います。

#!/usr/bin/env bash
echo "引数の数: $#"
i=1
for a in "$@"; do
  printf '%s: [%s]\n' "$i" "$a"
  i=$((i + 1))
done
./showargs.sh $file
./showargs.sh "$file"
引数の数: 2
1: [udon]
2: [list.txt]
引数の数: 1
1: [udon list.txt]

切っているのは cat ではなく bash です。cat は、切られたあとの2枚を渡されただけ。

新人

じゃあ、cat の設定で空白を無視してもらえばいいですね

cat のところに届いたときには、もう2枚です。切られた注文票をあとから貼り合わせる係は、この店にいません。

Windows の Git Bash なら、C:\Program Files でも同じことが起きます。

ls $PROGRAMFILES
ls: cannot access 'C:\Program': No such file or directory
ls: cannot access 'Files': No such file or directory

bash「Program さんと Files さんの席ですね!」

そんな2人組は来店していません。

ダブルクォートは「切るな」の札

bash が切る場所は、変数 IFS(単語の区切り文字を決める変数)で決まります。Bash リファレンスマニュアルによると、未設定のときの区切りは空白・タブ・改行の3つです。

そして、切られるのは「ダブルクォートの中にない」展開の結果だけ。つまり "$file" と書けば、値の中に空白があっても1語のままです。

ダブルクォートの中で特別な意味が残るのは $ やバッククォートなど限られた文字だけなので、変数の展開はちゃんと効きます。シングルクォートは全部の文字をそのまま扱うので、変数も展開されません。

新人

じゃあ、シングルのほうが強い札ですね。全部シングルにします

強すぎて、変数まで冷凍保存されます。

f="udon list.txt"
echo "$(basename "$f" .txt)"
x="a   b"
echo '$x'
echo "\$x は $x"
udon list
$x
$x は a   b

1行目の "$(basename "$f" .txt)" は、クォートの中にクォートがあるように見えますが、$( ) の中は別の区画です。中の変数にも、外の結果にも、それぞれ札が要ります。

おうどん

札を貼る手間で、1日が終わる

全部の変数に2文字足すだけです。一杯のうどんより早く終わります。

値の中の*も広がる:空白より静かな事故

札を忘れて起きるのは、切られることだけではありません。

msg="本日の * おすすめ"
echo $msg
echo "$msg"
本日の kake.txt kitsune.txt tanuki.txt おすすめ
本日の * おすすめ

* がカレントフォルダのファイル名に化けました。マニュアルの Filename Expansion の節のとおり、クォートされていない語に *・?・[ があると、パターンとして扱われてファイル名に置き換わります。

bash「アスタリスクとあったので、店の品物を全部お持ちしました!」

本日のおすすめが、店の在庫一覧になりました。

もう1つ、連続した空白や改行もつぶれます。切ったあとで、1つの空白でつなぎ直して表示されるからです。

line="かけ     300円"
echo $line
echo "$line"
lines=$(printf '1行目\n2行目\n3行目')
echo $lines
echo "$lines"
かけ 300円
かけ     300円
1行目 2行目 3行目
1行目
2行目
3行目

「echo で見たら1行だった」は、値が1行とは限りません。切られたものを見ているだけのことがあります。

新人

ログの改行が消えたので、ログのほうを疑ってました

ログに謝りに行きましょう。

空の変数で[が怒る、-nがうそをつく

値が空のときは、切られるどころか、語が丸ごと消えます。マニュアルにも、クォートのない展開が値を持たないと取り除かれる、とあります。

empty=""
./showargs.sh a $empty b
./showargs.sh a "$empty" b
引数の数: 2
1: [a]
2: [b]
引数の数: 3
1: [a]
2: []
3: [b]

これが [(条件を判定する test コマンドの別名)で起きると、エラーになります。

x=""
[ $x = "kake" ]
y="きつね うどん"
[ $y = "きつね うどん" ]
n=""
[ $n -eq 0 ]

e2.sh という名前で保存して実行した結果です。

./e2.sh: line 2: [: =: unary operator expected
./e2.sh: line 4: [: too many arguments
./e2.sh: line 6: [: -eq: unary operator expected
新人

unary って、うどんの仲間ですか

一文字も関係ありません。単項、つまり相手が1つの演算子のことで、= の左の相手が消えたので [ が困っています。

もっと怖いのは、エラーが出ないほうです。

x=""
if [ -n $x ]; then echo "入ってます!"; else echo "空です"; fi
if [ -n "$x" ]; then echo "入ってます!"; else echo "空です"; fi
入ってます!
空です

-n は「文字列が空でなければ真」のはず。空の変数で「入ってます!」と言いました。
語が消えて [ -n ] になり、-n という2文字の文字列そのものを「空じゃない」と判定したからです。理由は上級者向けのところで説明します。

bash「伝票の欄が空だったので、欄ごと捨てて、残った紙に何か書いてあるから、入ってます!」

検品の基準が、紙の有無まで下がっています。

クォート忘れで出る、検索されやすいメッセージをまとめます。

出たメッセージ今回の再現起きていること
No such file or directory が2回cat $file1つのファイル名が2語に切られた
unary operator expected[ $x = "kake" ](x が空)左の語が消えた
too many arguments[ $y = "きつね うどん" ]左が2語になり、語が多すぎる
binary operator expected[ -f $file ]-f のあとが2語になった
ambiguous redirectecho "1杯" > $file書き出し先が2語になった

下の2つは、次のスクリプトを e1.sh として実行した結果です。

file="udon list.txt"
echo "1杯" > $file
[ -f $file ] && echo ある
./e1.sh: line 2: $file: ambiguous redirect
./e1.sh: line 3: [: udon: binary operator expected

🔰 ここまで読めば今日から困らない
変数は "$file" のようにダブルクォートで囲む。これだけで、空白入りのファイル名、値の中の *、空の変数によるエラーを防げます。

ここから先は中級者向け:札を貼る場所と、札だけでは足りない場面

ここからは、実務のスクリプトでハマるところです。

引数の渡し方は$@と$*で4通りある

スクリプトが受け取った引数をそのまま別のコマンドへ渡す場面です。4つの書き方を比べます。

echo '--- $*';   ./showargs.sh $*
echo '--- $@';   ./showargs.sh $@
echo '--- "$*"'; ./showargs.sh "$*"
echo '--- "$@"'; ./showargs.sh "$@"

args.sh として保存し、bash args.sh "きつね うどん" "たぬき そば" かけ で実行しました。

--- $*
引数の数: 5
1: [きつね]
2: [うどん]
3: [たぬき]
4: [そば]
5: [かけ]
--- $@
引数の数: 5
1: [きつね]
2: [うどん]
3: [たぬき]
4: [そば]
5: [かけ]
--- "$*"
引数の数: 1
1: [きつね うどん たぬき そば かけ]
--- "$@"
引数の数: 3
1: [きつね うどん]
2: [たぬき そば]
3: [かけ]

お客さんは3人です。受け取ったときの3人のまま渡せるのは "$@" だけ。

書き方動き店でいうと
$*・$@全員分をいったん並べて、空白で切り直す3枚の注文票を細切れにする
"$*"全員分を1語につなぐ3人の注文を1枚に書き写す
"$@"1人ずつ別の語のまま3枚のまま運ぶ
新人

じゃあ、$* はいらない子ですか

いらない子ではありません。"$*" は、ログに引数全部を1行で書くときや、区切り文字でつなぐときに活躍します(上級者向けで使います)。

引数が0個のときも差が出ます。

echo '--- "$@"'; ./showargs.sh "$@"
echo '--- "$*"'; ./showargs.sh "$*"

引数なしで実行した結果です。

--- "$@"
引数の数: 0
--- "$*"
引数の数: 1
1: []

"$@" は何も渡さず、"$*" は空の引数を1つ渡します。

forで$(ls)を回さない、0件のガードを入れる

ファイルを1つずつ処理するとき、$(ls) の結果は札を貼れません。貼ると全部で1語になり、貼らないと切られます。

for f in $(ls); do echo "[$f]"; done
echo "--"
for f in *; do echo "[$f]"; done
[soba.txt]
[udon]
[list.txt]
--
[soba.txt]
[udon list.txt]

ワイルドカードは、ファイル名を1つずつの語として返します。あとから切られないので、空白があっても崩れません。

ただし、一致するファイルが0件のときは別の問題が出ます。

for f in *.csv; do echo "[$f]"; done
[*.csv]

CSV が1件もないのに、*.csv という文字列のまま1回ループが回りました。マニュアルのとおり、一致がなければ単語はそのまま残るからです。
ループの中で処理を書いていると、*.csv というファイルはない、というエラーになります。

直し方は2つあります。ループの先頭で確かめるか、nullglob で0件を本当に0件にするかです。

for f in *.csv; do
  [ -e "$f" ] || continue
  echo "[$f]"
done
echo "(終わり)"

shopt -s nullglob
for f in *.csv; do echo "[$f]"; done
files=(*.csv)
echo "件数: ${#files[@]}"
shopt -u nullglob
(終わり)
件数: 0

nullglob にも落とし穴があります。コマンドの引数に直接ワイルドカードを書いていると、語が消えて引数なしになります。

touch memo.txt
shopt -s nullglob
ls *.csv
echo "ls の終了コード: $?"
memo.txt
ng.sh
ls の終了コード: 0

ls は引数なしと同じ動きになり、CSV でないファイルを全部表示しました(ng.sh は、このコードを保存したファイルです)。
nullglob を使うなら、上の files=(*.csv) のように配列に受けて件数を確かめてから使いましょう。shopt -s failglob にすると、一致がないときにエラーでコマンドを止められます。今回の検証環境でも no match: *.csv のエラーで止まりました。

おうどん

0件なら0回。当たり前のことを言わせるために、設定が要る

当たり前を設定で買うのが、シェルです。

クォートの位置:ワイルドカードは札の外に出す

札は全部に貼ればいい、というわけでもありません。

src="my data"
./showargs.sh "$src"/*.csv
./showargs.sh "$src/*.csv"
./showargs.sh $src/*.csv
引数の数: 2
1: [my data/a.csv]
2: [my data/b c.csv]
引数の数: 1
1: [my data/*.csv]
引数の数: 2
1: [my]
2: [data/*.csv]

札を貼るのは変数だけ。* は札の外に出します。2行目は * まで「ただの文字」になり、3行目はフォルダ名が切られました。

値にもワイルドカードの文字が入っている

ファイル名に [ が入っていると、札を忘れたときに別のファイルを読むことがあります。

echo "きつね" > '[1].csv'
echo "たぬき" > '1.csv'
f='[1].csv'
cat $f
cat "$f"
たぬき
きつね

$f は [1].csv というパターンになり、1.csv に一致しました。エラーは出ません。別の人の注文を、黙って運んできます。
いちばん怖いのは、このエラーが出ない事故です。

bash「似たお客さんがいたので、お届けしました!」

配達先を、雰囲気で決めないでください。

ハイフンで始まるファイル名は、オプションに化ける

札を貼っても防げない例です。* の展開結果が -n だと、cat はオプションだと思います。

echo "かけ 1杯" > udon.txt
: > ./-n
cat *
echo "--"
cat -- *
echo "--"
cat ./*
     1	かけ 1杯
--
かけ 1杯
--
かけ 1杯

-n という空のファイルが、行番号を付けるオプションとして読まれました。--(ここから先はオプションではない、という印)を付けるか、./* と書けば防げます。-- が使えるかはコマンドしだいなので、./* のほうが広く効きます。

rmは、札を貼っても止まらない

ここは rm を実行せず、echo で「どんなコマンドになるか」だけを見ます。真似をするときも、まず echo を付けてください。

dir=""
echo rm -rf "$dir/"
echo rm -rf "$dir"/tmp
rm -rf /
rm -rf /tmp

札はきちんと貼ってあります。でも値が空なので、ルートフォルダを指す形になりました。
クォートは切られるのを防ぐだけで、空を防ぎません。${変数:?メッセージ} なら、変数が空か未設定のときにそこで止まります。

dir=""
echo rm -rf "${dir:?dir が空です}"/tmp
echo "ここには来ない"

rm_guard.sh として実行した結果です。

./rm_guard.sh: line 2: dir: dir が空です

終了コードは1で、次の行には進みませんでした。消す系のコマンドに変数を渡すときは、この書き方にしておくと安心です。

おうどん

このスクリプト、札を貼ってあるから消しても大丈夫

札は包丁を止めるだけで、爆弾は止めません。

変数の中にクォートを入れても効かない

オプションを変数にまとめたいときの、定番のハマりです。

opts='--name "udon list"'
./showargs.sh $opts
./showargs.sh "$opts"
引数の数: 3
1: [--name]
2: ["udon]
3: [list"]
引数の数: 1
1: [--name "udon list"]

クォートが文字のまま残って、"udon と list" に切られました。全体に札を貼ると、今度は1語です。
複数の引数を変数にしたいときは、配列にします。

arr=(--name "udon list")
./showargs.sh "${arr[@]}"
引数の数: 2
1: [--name]
2: [udon list]

マニュアルの Arrays の節のとおり、"${arr[@]}" は要素ごとに別の語になり、"${arr[*]}" は1語になります。"$@" と "$*" の関係と同じです。要素が0個なら "${arr[@]}" は何も渡しません。

オプションを付けたり付けなかったりするときも、配列が便利です。

verbose=1
opts=()
./showargs.sh "${opts[@]}" a
[ -n "$verbose" ] && opts+=(--verbose)
opts+=(--title "udon list")
./showargs.sh "${opts[@]}" a
引数の数: 1
1: [a]
引数の数: 4
1: [--verbose]
2: [--title]
3: [udon list]
4: [a]

チルダは札の中では広がらない

d="~/logs"
echo "$d"
~/logs

~(ホームフォルダの省略)は、クォートされていないときだけ展開されます。札を貼るなら "$HOME/logs" と書きましょう。
札を貼ったのに ~ という名前のフォルダを探しに行くのは、この店では「札の貼りすぎ」と呼びます。

完成スクリプト:空白も0件も失敗も数える

ここまでを入れた、CSV をコピーするスクリプトです。

#!/usr/bin/env bash
# copy_csv.sh 入力フォルダ 出力フォルダ [--dry-run]
# 入力フォルダの *.csv を出力フォルダへコピーする
set -u

src="${1:?使い方: copy_csv.sh 入力フォルダ 出力フォルダ [--dry-run]}"
dst="${2:?出力フォルダを指定してください}"

dry_run=""
if [ "${3:-}" = "--dry-run" ]; then
  dry_run=1
fi

if [ ! -d "$src" ]; then
  echo "入力フォルダがありません: $src" >&2
  exit 2
fi

shopt -s nullglob
files=("$src"/*.csv)
shopt -u nullglob

if [ "${#files[@]}" -eq 0 ]; then
  echo "対象の CSV がありません: $src"
  exit 0
fi

if [ -n "$dry_run" ]; then
  printf 'コピー予定: %s\n' "${files[@]}"
  exit 0
fi

mkdir -p -- "$dst" || exit 2

cp_opts=(-p)
ok=0
ng=0
for f in "${files[@]}"; do
  if cp "${cp_opts[@]}" -- "$f" "$dst/"; then
    ok=$((ok + 1))
  else
    ng=$((ng + 1))
    echo "失敗: $f" >&2
  fi
done

echo "成功: $ok 件 / 失敗: $ng 件"
[ "$ng" -eq 0 ]
  • set -u で、書き間違えた変数名をエラーにする(マニュアルの set の説明。$@ と $* は対象外)
  • "$src"/*.csv は札を変数だけに貼る
  • nullglob は配列に受ける行だけで使い、すぐ戻す
  • 0件は失敗にせず、メッセージを出して終了コード0
  • cp は -- のあとにファイル名を渡す
  • 最後の [ "$ng" -eq 0 ] の結果が、スクリプトの終了コードになる

わざと意地悪な名前のファイルを4つ置いて、確かめました。空白、- 始まり、[1] と 1 の組み合わせです。

$ bash copy_csv.sh "in put" "out put" --dry-run
コピー予定: in put/-n.csv
コピー予定: in put/1.csv
コピー予定: in put/[1].csv
コピー予定: in put/かけ 1杯.csv
$ bash copy_csv.sh "in put" "out put"
成功: 4 件 / 失敗: 0 件

out put の中身です。

-n.csv
1.csv
[1].csv
かけ 1杯.csv

ほかの場合の結果も並べます(読みやすいように、1回ずつの結果を続けて書いています)。

$ 空のフォルダを指定
対象の CSV がありません: empty
終了コード 0

$ 引数なし
./copy_csv.sh: line 6: 1: 使い方: copy_csv.sh 入力フォルダ 出力フォルダ [--dry-run]
終了コード 1

$ ないフォルダを指定
入力フォルダがありません: nothing
終了コード 2

$ 1件だけコピーに失敗(出力先に同じ名前のフォルダを作っておいた)
cp: cannot overwrite directory 'out2/1.csv' with non-directory
失敗: in put/1.csv
成功: 3 件 / 失敗: 1 件
終了コード 1

クォート忘れは機械に見つけてもらう

人間は札を貼り忘れます。ShellCheck の SC2086 は、クォートのない変数を指摘する検査項目です。for i in $@ のような書き方も指摘の対象で、"$@" に直すよう案内しています。
今回の検証環境には ShellCheck が入っていないので実行はしていません。

わざと切らせたい場面(空白区切りの値を単語に分けたい場面など)も、SC2086 のページでは、bash なら配列を使う、としています。

ここから先は上級者向け:なぜ切られるのか。読み飛ばしてもOKです

ここからは、bash の中で何が起きているかの話です。

展開には順番がある

マニュアルの Shell Expansions の節にある順番です。

順番処理クォートなしの $x の値に効くか
1ブレース展開({a,b})効かない(値が入る前に終わる)
2チルダ展開・変数展開・算術展開・コマンド置換ここで値が入る
3単語分割効く
4ファイル名展開効く
5クォートの除去効かない(展開で生まれたクォートは対象外)

変数の値が入るのは2番です。そのあとに走る3番と4番だけが、値に働きます。
これで、ここまでの謎がほとんど解けます。

  • 値の中の空白と * が効くのは、2番のあとに3番・4番があるから
  • 値の中のクォートが文字のまま残るのは、5番が元の入力にあったクォートしか取らないから
  • 値の中の {a,b} が広がらないのは、1番がもう終わっているから
b='{a,b}'
echo $b
{a,b}

"$x" は、3番と4番を丸ごと飛ばす札、というわけです。

おうどん

包丁の工程だけ飛ばす札。厨房の設計としては、札のほうが本体だ

札がなくても切られない場所

マニュアルでは、次の場所で単語分割とファイル名展開が行われないとされています。

  • 変数代入の右辺(a=$x)
  • export・local など宣言コマンドの引数の代入
  • [[ ]] の中
  • case の語
x="きつね   うどん *"
a=$x
echo "$a"
export b=$x
echo "$b"
f() { local c=$x; echo "$c"; }
f
if [[ $x == きつね* ]]; then echo "[[ で一致"; fi
case $x in
  きつね*) echo "case で一致" ;;
esac
list=($x)
echo "配列にすると ${#list[@]} 個"
printf '[%s]\n' "${list[@]}"
きつね   うどん *
きつね   うどん *
きつね   うどん *
[[ で一致
case で一致
配列にすると 4 個
[きつね]
[うどん]
[noq.sh]
[showargs.sh]

最後の配列だけは切られて、* も広がりました。代入の見た目でも、配列の ( ) の中は別の話です。
export や local の扱いは bash のマニュアルの範囲で、今回の実行も bash だけです。ほかのシェルで同じかは確かめていません。迷ったら札を貼っておけば、この場所でも害はありません。

ただし、札の有無で意味が変わる場所が1つあります。[[ ]] の == の右辺です。

order='きつね'
pat='き*'
[[ $order == $pat ]] && echo "クォートなし: 一致(パターン)"
[[ $order == "$pat" ]] || echo "クォートあり: 不一致(文字どおり)"
クォートなし: 一致(パターン)
クォートあり: 不一致(文字どおり)

右辺はパターンとして扱われ、クォートした部分は文字どおりに比べられます。ユーザーの入力と完全一致で比べたいなら、右辺に札を貼ります。
「貼らなくていい場所」のはずが、ここだけは貼るかどうかで別の料理が出てきます。

testコマンドの判定は引数の数で決まる

-n がうそをついた理由です。マニュアルの test の項によると、[ は引数の数で動きを変えます。

引数の数判定
0個偽
1個その引数が空でなければ真
2個1つ目が単項の演算子なら、その判定
3個2つ目が二項の演算子なら、その判定

[ -n $x ] で x が空だと、語が消えて引数は -n の1個だけ。
すると -n は演算子ではなく「空でない文字列」として見られ、真になります。

"$x" なら引数は2個(-n と空文字)で、正しく偽です。[[ -n $x ]] は語が消えないので、札がなくても正しく動きます。

新人

演算子として雇ったのに、紙として評価されてる

人事の話みたいですが、仕様です。

IFSは包丁の刃を決める

IFS を変えると、切る場所が変わります。さらに、空白とそれ以外で切り方が違います。

IFS=,
x='かけ,,たぬき,'
./showargs.sh $x
y='きつね うどん,月見'
./showargs.sh $y
IFS=' '
z='  かけ    たぬき  '
./showargs.sh $z
引数の数: 3
1: [かけ]
2: []
3: [たぬき]
引数の数: 2
1: [きつね うどん]
2: [月見]
引数の数: 2
1: [かけ]
2: [たぬき]

カンマのような空白以外の文字は、並ぶと間に空の語ができます。空白は、いくつ並んでも1つの区切りで、前後の空白は無視されます。マニュアルの Word Splitting の節のとおりです。
今回の実行では、末尾のカンマの後ろに空の語はできませんでした。

IFS を空にすると単語分割は止まりますが、ファイル名展開は止まりません。別の工程だからです。止めるには set -f です。

mkdir -p menu; touch menu/a.txt menu/b.txt
cd menu
x='*'
IFS=''
../showargs.sh $x
set -f
../showargs.sh $x
引数の数: 2
1: [a.txt]
2: [b.txt]
引数の数: 1
1: [*]

この2つを組み合わせると、1行1要素の値を空白で切らずに回せます。

list=$'きつね うどん\nたぬき そば\n*'
IFS=$'\n'
set -f
for item in $list; do
  echo "[$item]"
done
[きつね うどん]
[たぬき そば]
[*]

IFS を書き換えるとスクリプト全体に効くので、関数の中で local IFS にすると安全です。"$*" が IFS の最初の文字でつなぐ性質と合わせると、こう書けます。

join_by_comma() {
  local IFS=,
  echo "$*"
}
join_by_comma かけ きつね たぬき
echo "外の IFS は無事: [${IFS@Q}]"
かけ,きつね,たぬき
外の IFS は無事: [$' \t\n']

さっき「いらない子ですか」と言われた "$*" の、見せ場です。

引数の前後に文字をつなぐと、端だけくっつく

set -- かけ きつね たぬき
./showargs.sh "x$@y"
./showargs.sh "${@:2}"
引数の数: 3
1: [xかけ]
2: [きつね]
3: [たぬきy]
引数の数: 2
1: [きつね]
2: [たぬき]

全員に x と y が付くわけではなく、最初の人の前と最後の人の後ろにだけ付きました。"${@:2}" は2番目以降だけを渡す書き方です。shift せずに先頭を飛ばせます。

何が渡ったかを見る:set -xと@Q

切られたかどうかは、set -x(実行前に、展開後のコマンドを表示する設定)で見えます。

set -x
cat $file
cat "$file"
+ cat udon list.txt
cat: udon: No such file or directory
cat: list.txt: No such file or directory
+ cat 'udon list.txt'
きつね 1杯

+ の行に 'udon list.txt' とシングルクォートが見えたら、1語のまま渡っています。
値そのものを確かめたいときは、${変数@Q} か printf %q です。

echo "${file@Q}"
printf '%q\n' "$file"
'udon list.txt'
udon\ list.txt

シェルが変わると話も変わる

今回の話は bash のものです。ほかのシェルについて、資料で確かめた範囲を書きます(どちらも今回は実行していません)。

  • POSIX のシェルの仕様でも、$@ は位置パラメータごとに1つのフィールドを作り、位置パラメータがなければ、ダブルクォートの中でもフィールドを作りません。
  • zsh のオプションの説明によると、クォートのない変数を分割する SH_WORD_SPLIT は、zsh の標準の動きでは有効ではありません(ksh・sh のエミュレーションでは有効)。

つまり、zsh で動いたスクリプトを bash に持っていくと、切られることがあります。

おうどん

手元で動いたので大丈夫です

厨房が違うと、包丁の持ち方も違います。

札を貼るのは2文字、貼らないと全部切られる

冒頭のスクリプトに戻ります。

おうどん

ななみさん、全部の変数に札を貼ったよ。ファイル名に空白があっても、[1] があっても通った

ななみ

0件のときのガードも入りましたね。意地悪な名前で試したのは、とてもいいと思います

おうどん

bash さんの包丁も、札の前では止まった

止まったのは包丁だけで、おうどんの日報は今日も止まったままです。

bash は悪いアルバイトではありません。空白で切るのが仕事で、切らないでほしいときに札を貼るのがこちらの仕事です。

bash「札がないので、切っておきました!」

今日も元気です。

変数はダブルクォートで囲み、切りたいときだけ配列を使う。
手元のスクリプトで、まず $ の前に " がない行を1つ探してみてください。その1行が、いつか空白入りのファイル名に出会う前に🍜

参考資料

  • Bash Reference Manual(Bash の保守担当者のサイト) — 展開の順番、Word Splitting(IFS の既定値・連続する区切り・空の語)、Filename Expansion(一致なしの扱い・nullglob・failglob・-f)、Quote Removal、Double Quotes、Special Parameters($* と $@)、Arrays、Shell Parameters の代入、[[ と case、test の引数の数、${parameter:?word}、Tilde Expansion、set -u・-x・-f、@Q
  • ShellCheck:SC2086 — クォートのない変数の指摘、$@ の直し方、分割したいときは配列を使う例外
  • POSIX:Shell Command Language — $@ と $* の規定、Field Splitting
  • zsh:Options — SH_WORD_SPLIT の既定

2026年11月時点の内容です。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

おうどん|癒しと創作をたのしむ雑食クリエイター
写経アプリやクレイセラピー、優しい和風デザインがすき。
ZARDと刀剣と文字に癒されて、今はアプリ作ってます。

コメント

コメントする

目次