Edited at

シェルスクリプトのechoの移植性の問題に本気で対応する

なんで一番基本のコマンドなのに移植性がないんだろう?

この話については以下が詳しいです。

さて「僕らが本当に欲しかったもの」それはエスケープシーケンスを解釈せずに文字列をそのまま出力する関数ではないでしょうか?

ではどうするか? 簡単な答えは echo の代わりに printf を使用します。printfを使用したechoの代替関数の例です。

puts() { # 改行無し

printf '%s' "$*"
}
putsn() { # 改行あり
printf '%s\n' "$*"
}

ほとんど方はこれで必要十分でしょう。おつかれさまでした。

以降はもう少し踏み込んだ話です。


puts & putsn 関数の改良

まず簡単なところから。実は前出の puts & puts 関数は、IFSが設定されている場合にechoとは違ってスペースではなくIFSの最初の文字で結合されてしまうという違いがあります。対応は簡単でIFS(の一番最初の文字)にスペースを入れてあげればよいです。使用変数をむやみに増やしたくないので、IFSの最初にスペースを加えてます。

puts() {

IFS=" $IFS" # 最初にスペースを追加
printf '%s' "$*"
IFS=${IFS# } # 最初のスペースを削除
}

さて話はここからです。実はprintfは一部のシェル(mksh, posh, 古いksh, 古いzsh)でビルトインではありません。外部コマンドのprintfを呼び出しています。つまり遅いのです。

Linux上のdashで10万回の呼び出しを行った所、私の環境でビルトインだと 0.212秒 の所が外部コマンドだと 89.270秒 でした。WSL上だとさらに遅くなり 874.169秒 です。ここまで遅いとわずか100回程度の呼び出しでも 0.8秒 となるので体感で遅いのがわかってしまいます。

そんなマイナーなシェルや古いシェルやWSLなんて使わないよって方には関係ない話です。おつかれさまでした。

さてどうすれば高速化できるでしょうか? 実は zsh, ksh, mksh には printf に似た print コマンドが実装されているのでそれが使えます。

puts() {

IFS=" $IFS"; print -nr -- "${*:-}"; IFS=${IFS# }
}

残る問題はposhです。

poshなんて使わn(略)

poshのprintfはビルトインではなくprintもありません。そのためechoを駆使してがんばります。poshのechoはエスケープ文字の \ を解釈します。つまりこの \\\ に置き換えてしまえばよいのです。(動き的には \ で分割し \\ をつけて出力しています。)

そしてもう一つ、poshのechoでは -n をオプションとみなすため、この文字列をそのまま出力する方法が見つかりませんでした。そのため -n の場合のみ printf を使用しています。 一文字ずつ出力しています。(2019/04/10訂正)

puts() {

[ $# -eq 0 ] && return 0
IFS=" $IFS"; set -- "$*"; IFS=${IFS# }
[ "$1" = -n ] && echo -n - && echo -n n && return 0
local IFS='\'; set -- $1
[ $# -gt 0 ] && echo -n "$1" && shift
while [ $# -gt 0 ]; do echo -n "\\\\$1" && shift; done
}

あとは、これらを組み合わせるだけです。


puts & putsn 関数 完成版

if [ "${ZSH_VERSION:-}" ] || [ "${KSH_VERSION:-}" ]; then

puts() { IFS=" $IFS"; print -nr -- "${*:-}"; IFS=${IFS# }; }
elif [ "${POSH_VERSION:-}" ]; then
puts() {
[ $# -eq 0 ] && return 0
IFS=" $IFS"; set -- "$*"; IFS=${IFS# }
[ "$1" = -n ] && echo -n - && echo -n n && return 0
local IFS='\'; set -- $1
[ $# -gt 0 ] && echo -n "$1" && shift
while [ $# -gt 0 ]; do echo -n "\\\\$1" && shift; done
}
else
puts() { printf '%s' "$*"; }
fi
putsn() { IFS=" $IFS"; puts "${*:-}$LF"; IFS=${IFS# }; }

Q. 色を付けたり文字列の途中で改行したりしたいときはどうするの?

A. エスケープコードや改行コードを変数に入れて使えば良いです。

こんな感じですね。

putsn "${ESC}[31mR${LF}E${LF}D${ESC}[m"

ついでに ESC, LF に加えてすべての制御コードを変数に入れるコードです。

# printf の呼び出し回数を1回にするためにこのようなコードにしています。

eval "$(printf "
SOH='
\\001' STX='\\002' ETX='\\003' EOT='\\004'
ENQ='
\\005' ACK='\\006' BEL='\\007' BS='\\010'
HT='
\\011' TAB='\\011' LF='\\012' VT='\\013'
FF='
\\014' CR='\\015' SO='\\016' SI='\\017'
DLE='
\\020' DC1='\\021' DC2='\\022' DC3='\\023'
DC4='
\\024' NAK='\\025' SYN='\\026' ETB='\\027'
CAN='
\\030' EM='\\031' SUB='\\032' ESC='\\033'
FS='
\\034' GS='\\035' RS='\\036' US='\\037' DEL='\\177'
"
)"

ライセンス?

このコードに権利を主張するつもりはありませんのでご自由にお使いください。明記が必要ならば The MIT License とします。