LoginSignup
8
4

More than 1 year has passed since last update.

シェルスクリプトにはコマンド出力を変数に入れると末尾の改行が全部消えてしまう罠がある!

Last updated at Posted at 2021-09-02

はじめに

「コマンド出力を変数に入れる」というのはコマンド置換 ret=$(cmd)(または ret=`cmd`)のことです。この機能の最大の罠は末尾の改行が全部消えてしまうことです。と個人的に思っているのですがあんまり話題にならないですね。みんな知っていて気にしてないのか、単に気づいていないのか、わたし、気になります。さてこの記事ではコマンド置換の「末尾改行消失問題」と「その解決方法」ついて色々解説したいと思います。

末尾改行消失問題とは?

具体例で示したほうが簡単だと思います。改行を画面に沢山出力しようと思ったらどのようなコマンドを使いますか?echo? いや printf ですね。\n を繰り返すことで簡単に改行をいくつも出力できます。

$ printf "test\n\n\n\n"
test



ではこれを変数に入れるとしましょう。そのために使うのはコマンド置換です。そしてその変数の中身を表示すると・・・?

$ ret=$(printf "test\n\n\n\n")
$ echo "[$ret]" # わかりやすいように [ ] をつけて出力
[test]

はい、あれほどたくさんあった改行が全て消えてしまいました。これが末尾改行消失問題です。

注意 「末尾改行消失問題」は私が今考えた独自用語ですのでこんな用語で検索しても何も見つからないでしょう。

末尾の改行を削除するという発想について

一見出力した文字列を勝手に変えてしまうというのは、ありえない設計に思えるのですが、実際それはうまく機能します。例えば echo はターミナルに出力すると改行を出力します。(-n をつけて)改行を出力しないようにするとコマンドプロンプトがずれてしまいますよね?

$ echo "test"
test
$ echo -n "test" # 改行を出力しないとプロンプト($)がずれる
test$

だから出力の最後に改行があったほうが良いのです。

その一方文字列の比較では、末尾の改行のことなんかみんな気にしません。

if [ "$(echo "test")" = "test" ]; then
  echo "末尾の改行が消えるので文字列は一致します" 
fi

この場合は改行が消えたほうが良いのです。

よくよく考えると不自然なのに自然に受け入れられてしまう。こんな設計ができる発想は一体どこから出てくるのでしょうか?(注意 普通に素晴らしいと思っています。)

それでもやっぱり困ることがある

例えば標準出力に出力していたテキストをちょっと加工するために変数に入れてから再出力なんてことをするとその違いに気づき一体どこで改行が消えているんだ?とハマることになります。私にとっては ShellSpec (シェルスクリプト用のユニットテストフレームワーク)の開発で出力を厳密にテストできるようにしたかったため開発の初期段階で DSL や実装法に関して色々と試行錯誤することになりました。

解決方法

単純に末尾の改行を保持したいというだけなら簡単です。

1. 余計な文字を追加してから除去する

一般的にはこちらの方が簡単で推奨される方法です。

ret=$(printf "test\n\n\n\n"; echo _) # 末尾に _ を 1 文字追加する
ret=${ret%_} # パラメータ展開を用いて末尾一文字の _ を削除する
echo "[$ret]"

2. ファイルに出力してから読み取る

使える例は少ないと思うのですが、もともとファイルに出力したかった等の場合に使うことができます。

LF="
"
printf "test\n\n\n\n" > /tmp/work.tmp
ret=''
while IFS= read -r line; do
  ret="${ret}${line}${LF}"
done < /tmp/work.tmp
ret="${ret}${line}" # 末尾が改行で終わらない行でも読み取るために必要
echo "[$ret]"

補足ですが LF 変数には改行を入れていますが、他のやり方として LF=$(printf '\n_') && LF=${LF%_} のような書き方があります。しかしこの方法はコマンド置換でサブシェルが必要となりわずかに遅くなるので、私は少々不格好ですが上記のコードを最近では使っています(書くのは簡単ですし)。 POSIX にドルシングルクォート(LF=$'\n')が採用されてすべてのシェルで使えるようになれば良いのですが。

注意 POSIX シェル以前(= Bourne シェル)は切り捨てています

これらの解決方法は POSIX 準拠のシェルで使える方法ですが、POSIX シェル以前 = Bourne シェルでは使用できません。1. の方法は末尾一文字を削除するためのパラメータ展開が使えません。2.の方法は read-r オプションに対応していません。(ループがサブシェルで実行されるという別の問題がありますがそれは { ... } を使えば解決可能です。)

Bourne シェルへの対応に興味がないため、詳しく検証していませんが Bourne シェルの制限上、末尾改行消失問題の完全な解決策はなさそうに思えます。Bourne シェルにはこういった制限がある上に、古いシェルで事実上ほぼ使われていないので私は切り捨てています。(どこまでも古いシェルに対応したらきりがないので、私は POSIX にほぼ準拠しているシェルのみに対応することにしています。)

もし Bourne シェルと POSIX シェルの違いに興味がある方は「BourneシェルとBourneシェル系(bash等のPOSIXシェル)の違いについて」を参照してください。

解決方法はあるけれど・・・

さて、末尾に余計な一文字を追加することで解決は可能ですが、コマンド置換を使うたびにこんなコードを毎回書くのは嫌ですよね?

ret=$(printf "test\n\n\n\n"; echo _)
ret=${ret%_}
echo "[$ret]"

printf だけではありません。sedawk いろんな外部コマンド呼び出しの末尾の改行を厳密に扱おうとするとこのようなコードが必要になるのです。とは言え現実問題ではこのような処理が必要になる場合は少ないので該当の箇所だけやれば十分ですが。

一応この問題の解決方法はあるので紹介します。

# コマンドを実行して改行を保持しながら変数に入れる汎用関数
assign() {
  eval "$1=\$(shift; \"\$@\"; echo _) && $1=\${$1%_}"
}

assign ret printf "test\n\n\n\n"
echo "[$ret]"

その気になれば、なんとでもなるもんです。

さいごに

ということで「末尾改行消失問題」と「その解決方法」でした。

実のところ、私はあまりコマンド置換を使用していません。どうしても使わなければいけない所(例えば date コマンド等)はあるのですが大抵はアンチパターンではないかと思っているぐらいです。

通常(外部)コマンドを使う場合はパイプでつないでその先は標準出力へ直接出力です。変数に戻すことはしません。変数に戻したい場合は大抵が小さなデータであるため、可能な場合はコマンドを使わずにパラメータ展開だけで実装します。小さなデータの場合は外部コマンド呼び出し(fork & exec)のコストが相対的に大きくなるためシェルで実装されているパラメータ展開の方が圧倒的に速いです。そしてパラメータ展開を使った場合は末尾改行消失問題は発生しません。

こういった理由から私は小さなデータの場合にはシェル関数とパラメータ展開を使って実装し、大きなデータの場合のみ外部コマンドを使うようにしています。

8
4
0

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
8
4