27
15

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

シェルスクリプトで変数に改行文字を入れる方法の細かすぎる解説

Last updated at Posted at 2021-09-06

はじめに

これも何番煎じかわからないようなネタですが「変数に改行を入れる方法」ではなく関連する話を含めての解説がメインです。

解説がいらない人用

全 POSIX シェル対応 (Bourne シェルも対応)

LF='
'

bash、ksh、mksh、zsh は以下も使用可能

LF=$'\n'

解説

LF='改行'

LF='
'

すべての POSIX シェルで使用可能なので私はこれを推奨します。シェルスクリプトの改行コードは Windows の CR+LF ではなく LF を使用している前提です。いくつかの環境ではシェルスクリプトの改行コードに CR+LF を使用していても動くようなのですが(未調査)、遅かれ早かれ問題になるはずです。また需要は少ないと思いますが、**POSIX シェル以前の Bourne シェルでも使える(おそらく)**唯一の方法です。Solaris 10 などの古い環境にも対応させるのであればこの方法しかありません。

メリットはコードの量が最も少なく最も速いということです。デメリットは 2 行になるため少し不格好でインデントをする場合に困るだけですが、インデントに関してはこのような定数はスクリプトの上部の関数の外で定義するので通常は問題にならないはずです。

LF=$'\n'

LF=$'\n'

bash、ksh、mksh、zsh で使えるドルシングルクォート($'...')を使った方法です。dash や yash など純粋な POSIX シェルに近いシェルでは使うことができませんが、一行で書くことができるためインデントする場合でも困りません。現在は POSIX で規定されていませんが、採用が検討されており将来はすべての POSIX シェルで使えるようになるかもしれません。

ちなみにドルシングルクォートというのはクォートの一種でエスケープシーケンスが使えるシングルクォートです。頭に $ がついたシングルクォートで $'foo\tbar\nbaz' のように長い文字列を表現することができます。(1 文字の特殊文字を表現するのに使われていることが多いため、私は最初 C 言語の文字リテラルのように 1 文字限定だと勘違いしていましたがそうではありません。)

その他の方法との比較

シェル変数に改行文字を入れる方法は他にもありますが、これらはいくつかの理由により候補にするのをやめました。

LF=$(printf '\n_') && LF=${LF%_}

# 非推奨
LF=$(printf '\n_') && LF=${LF%_}

比較的よく見かけるのがこのコードです。私も一時期使っていたのですが、長くてパフォーマンスが悪くて POSIX シェル対応版に比べて一行で書けるぐらいの意味しかないので、今は使わないほうが良いと思っています。(私の古い解説記事でこのコードが残ってるかもしれませんが、この記事の内容の方が最新です。)

注意 ただし LF 以外の文字、例えば TABCR なども同時に変数に入れる場合にはこれに近い方法を使うことがあります。詳しくは例外を参照してください。

printf '\n' の意味は「改行文字を出力する」です。ターミナルから実行すると改行が出力されることが分かると思います。それなら LF=$(printf '\n') で良いのでは?と思うはずです。しかしそれではうまくいきません。なぜならコマンドの出力を変数に入れる(コマンド置換を使う)と、末尾にある連続した改行文字が消えてしまうからです。改行文字一つではありません。複数の改行文字が消えます。つまり LF=$(printf '\n\n\n\n\n\n\') と実行しても LF 変数は空文字なのです。そのため \n の後に余計な文字を追加しています。この例では _ を使用していますが別の文字でも構いません。(参照 シェルスクリプトにはコマンド出力を変数に入れると末尾の改行が全部消えてしまう罠がある!

そうすると LF 変数には [改行]_ という 2 文字になるため LF=${LF%_} を使って末尾の _ を 1 文字削除しています。${parameter%word} というパラメータ展開(変数展開)は変数の末尾から指定した word を削除するというものですが、このパラメータ展開は Bourne シェルでは使えないので注意してください。もっとも Bourne シェルは Solaris 10 など(Solaris 11 でも /usr/sunos/bin/sh として残ってるらしい)の 古い Unix で使われていたシェルなので気にする必要はないと思いますが、もし Bourne シェルとの互換性が必要な場合は、この手法は使えないということになります。

私がこの手法を使わない理由は Bourne シェルとの互換性ではなくパフォーマンス上の理由です。LF=$(printf '\n_') を使うと LF='<改行>' を使うよりも 300 倍遅くなってしまいます。(環境によって異なりますが大幅な速度低下は同じです。なおこの記事の検証は macOS 上で行っています)。

bench.sh
# (1) LF='<改行>' を使う方法
for i in $(seq 100000); do
LF='
'
done

# (2) LF=$(printf '\n_') を使う方法
for i in $(seq 100000); do
  LF=$(printf '\n_') && LF=${LF%_}
done
# (1) LF='<改行>' を使う方法 (bash)
$ time /bin/bash ./bench.sh
real	0m0.430s
user	0m0.419s
sys 	0m0.015s

# (2) LF=$(printf '\n_') を使う方法 (bash)
$ time /bin/bash ./bench.sh
real	2m11.605s
user	0m34.770s
sys 	1m33.722s

# (2) LF=$(printf '\n_') を使う方法 (ksh)
$ time /bin/ksh ./bench.sh
real	0m2.781s
user	0m1.199s
sys 	0m1.558s

# (2) LF=$(printf '\n_') を使う方法 (mksh)
time mksh ./bench.sh
real	5m55.038s
user	1m48.605s
sys 	3m20.259s

もっとも 300 倍以上遅くなったと言っても、普通は 1 回しか実行しないので、この例では 1.316 ミリ秒にすぎず気にする必要はないのですが、無駄に遅くする必要もありません。

なぜここまで遅くなるかというと、コマンド置換を使うとサブシェルが生成されるからです。サブシェルは現在のシェルと同じメモリ内容をもった別のプロセスで、殆どのシェルでは内部的には fork を使って子プロセスを生成しています。これはとても遅い処理です。上のベンチマーク結果で ksh がかなりマシになってるのは(条件を満たせば)サブシェルを子プロセスを生成せずに実現する最適化が行われているからです。そのためサブシェルを多用するようなシェルスクリプトは ksh で実行するととても速く動作します。また逆に mksh では bash の 2 倍以上遅くなっています。それは mksh では printf コマンドがシェルビルトインではなく外部コマンドだからです。外部コマンドは fork + exec を使って子プロセスを生成するため更に遅くなります。

LF=$(printf '\n_') && LF=${LF%_} のメリットは 1 行で書くことができインデントが自然に出来るという点です。しかしながら LF のような定数はコードのトップでインデントしない状態で定義することが多いため、さほどメリットがありません。一行で書けるのは良いのですが、バイト数で考えると逆に 20 バイト以上増えます。

LF=${IFS#??}

# 注意点あり
LF=${IFS#??}

実はこの方法を採用している解説記事やコードは見たことがありません。しかし可能であるため、もしこの方法を思いついてしまった人へ、このコードの罠を解説しておきます。

まずこの方法で改行コードを入れることができる理由ですが、IFS 変数はシェルを起動した時に、スペース、タブ、改行の 3 文字初期化されることが POSIX で規定されているからです。パラメータ展開 ${IFS#??} を用いて頭 2 文字を削除すれば LF 変数に改行文字が代入されます。1 行で書けインデントも自然にできサブシェルも使用しないので一見良さそうに見えますが、2 つの罠があります。

1 つは zsh では IFS はスペース、タブ、改行、NULL 文字 (\0) の 4 文字で初期化されることです。zsh は他のシェルと異なりシェル変数に NULL 文字を入れることが出来ます。そして NULL 文字も空白の一つとして考えています。とはいえ IFS が 4 文字だったら最後の NULL 文字を削除すればいいだけなので大きな問題ではありません。

それよりも大きな問題は dash (や少し古い FreeBSD sh、NetBSD sh、Busybox ash)のバグです。dash では親プロセスで IFS がエクスポートされている場合に IFS 変数を初期化せずに値を引き継ぎます。このバグは現時点での最新版である dash 0.5.11 で修正されていますが、修正されているシェルは現時点でまだ十分に使われていないためこの方法を使うのは時期尚早でしょう。詳細は「dashでシェルスクリプトを動かす場合はIFS変数を初期化した方が良い(親プロセスの値を引き継ぐ不具合)」を参照してください。

現時点ではこの方法は推奨できません。もちろん dash のことを考えなければ問題ないのですが、その場合は代わりに LF=$'\n' が使えることでしょう。

LF="\n"

時折見かけますが、これは変数に改行文字を入れているのではありません。\n という文字列を入れているだけです。意味としては LF='\n' と全く同じです。なぜこのような勘違いが起きるのかと言うと、変数の中身の確認方法にあります。

# 間違い
$ LF="\n" # LF='\n' でも同様
$ echo "[$LF]"
[
]
# ↑改行が出力される環境がある!

echo コマンドは POSIX で規定されたコマンドですが、POSIX が標準化を始めるよりも前から Unix / Linux で存在するコマンドです。既存の実装との互換性を考慮しなければならなかったからだと思いますが POSIX では動作を一つに定めることをせず複数の異なる動作を許容する仕様となっています(POSIX の仕様が曖昧だったからバラバラに実装されたのではなく、すでにバラバラだったため POSIX は仕様を統一するのを諦めた)。

そのため echo コマンドを使って変数の中身を確認すると bash であれば \n と表示されるのですが、dash や zsh を使って確認している場合には改行として表示されるためLF="\n" は改行文字を入れる方法だという勘違いが起きてしまいます。

このようなエスケープシーケンスが含まれている文字列の確認をする場合には printf コマンドを使用して確認するようにしましょう。

$ LF="\n"
$ printf '[%s]\n' "$LF"
[\n]

例外

printf コマンドを使って LF 変数に改行を代入する方法は使わないと書きましたが、同時に TABCR なども変数に代入したい場合には、それらのついでとして LFprintf で代入することもあります。これらの制御文字は印刷不可能文字であるためソースコードに直接書いても区別がつかないため間違いを防止するために printf を使います。ただしパフォーマンスの問題を避けるため以下のような書き方はしていません。

TAB=$(printf '\t')
CR=$(printf '\r')
LF=$(printf '\n_') && LF=${LF%_}

この場合、3 回のコマンド置換(サブシェル)が必要となってしまいます。3 回ぐらい大したことないんじゃ?というのはそのとおりなのですが、必要ないサブシェルをわざわざ使うこともありませんしコードが長くなるのも嫌ですね。そこで私が使っているのは以下のような書き方です。

eval "$(printf 'TAB="\011" LF="\012" CR="\015"')"

上と同様のベンチマークを行うと、この書き方によって 2.93 倍に高速化しました。サブシェルの回数が 1/3 になるので計算通りです。印刷不可能な ASCII 文字は 33 個ありますので、全てを変数に入れた場合は およそ 33 倍に高速化します。

ところで上の方で 1 回あたり 1.316 ミリ秒かかると書きましたが、33 個だと 43.43 ミリ秒になります。格闘ゲームの上級者は 1 フレームレベルでの戦いをしていると言われますが、この 1 フレームが 16.66 ミリ秒なので約 2.6 フレームの長さです。また printf が外部コマンドの mksh だと 100 ミリ秒にもなります。これは大抵の人が体感で違いが分かるレベルです。シェルスクリプトで実行するのは変数設定だけではありません。他にも時間がかかる処理をすることもあるでしょう。そう考えるとこのこのチューニングの効果に意味がないとまでは言えないのではないでしょうか?塵も積もればというやつです。

さいごに

ということで、変数に改行文字を入れる方法の解説でした。途中、細かすぎるパフォーマンスの話を入れましたが実際にはさほど重視しておらず、どれが一番楽な書き方か?で選んでいます。私が推奨する楽な書き方は冒頭の 2 つです。

27
15
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
27
15

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?