16
13

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.

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

Last updated at Posted at 2019-03-05

ビルトインの echo を置き換えることで各シェルの非互換性を問題を解決する、移植性のある(ポータブルな)echo シェル関数を作りました。(リポジトリPortable echo) 解説は後半の方でしています。

はじめに

echo はシェルスクリプトにおいて一番基本となるコマンドなのになぜ移植性がないのでしょう? この話については以下が詳しいです。

さて「僕らが本当に欲しかったもの」それはエスケープシーケンスを解釈せずに文字列をそのまま出力する関数ではないでしょうか? 文字をそのまま出力したい時にどうするか? 簡単な答えは echo の代わりに printf を使用します。以下は printf を使用した echo の代替関数の例です。

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

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

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

puts & putsn 関数の改良

まず簡単なところから。実は前出の puts & putsn 関数は echo とは違って IFS の最初の文字で結合されてしまうという違いがあります。通常は IFS の最初の文字はスペースに設定されているのですが、変更できるので必ずしもそうなっているとは限りません。対応は簡単で IFS(の一番最初の文字)にスペースを入れてあげればよいです。IFS 変数を書き換えたままにするのは良くないので OLDIFS 変数などに一旦退避して作業後に戻すというのをよく見かけますが(POSIX準拠では)シェルにはグローバル変数しかなく使用する変数をむやみに増やしたくないので、IFS の最初にスペースを加えて結合後に削除します。

puts() { IFS=" $IFS"; printf '%s' "${*:-}"; IFS=${IFS#?}; }
putsn() { IFS=" $IFS"; printf '%s\n' "${*:-}"; IFS=${IFS#?}; }

また "$*" ではなく "${*:-}" としているのは ksh の 93r や 93s で set -u を有効にしている時に位置パラメーター(引数)がない場合に *: parameter not set というエラーになることへのワークアラウンドです。

printf がビルトインでないシェルへの対応

さて話はここからです。実は printf は一部のシェル(古い zsh, 古い ksh, OpenBSD ksh, mksh, posh 等)でビルトインではありません。そのため外部コマンドの printf が呼び出されます。つまり遅いのです。(そのため上記のコードは printf がビルトインの場合のみ使用します。)

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

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

さてどうすれば高速化できるでしょうか? 実は zsh, ksh, mksh には printf に似た print コマンドがシェルビルトインとして実装されているのでそれを使うことが出来ます。

# zsh 4 系以前版
puts() { builtin print -nr -- "${@:-}"; }
putsn() { builtin print -r -- "${@:-}"; }
# ksh 88, mksh, OpenBSD ksh, pdksh 版
puts() { command print -nr -- "${@:-}"; }
putsn() { command print -r -- "${@:-}"; }

builtincommandprint という名前のシェル関数が定義されていてもビルトインの print を使用するようにするためのものです。めったに必要になることのない特殊な状況への対応です。また "${@:-}" は上の "${*:-}" と同じで位置パラメーターがない場合のワークアラウンドです。(おそらく zsh 版では必要ないと思うのですが合わせています。)

残る問題は posh です。

poshなんて使わn(略)

posh の printf はビルトインではなく print もありません。そのため echo を使ってがんばります。posh の echo はエスケープ文字の \ を解釈します。つまりこの \\\ に置き換えてしまえばよいのです。ただし古いバージョンの posh ではパラメータ展開にバグあるのでその対策で2バージョン作っています。そしてもう一つ、posh の echo では -n をオプションとみなすため -n の場合だけ例外的に一文字ずつ出力しています。

# posh 0.5.4 あたり以前
puts() {
  if [ $# -eq 1 ] && [ "$1" = "-n" ]; then
    builtin echo -n -; builtin echo -n n; return 0
  fi
  IFS=" $IFS"; set -- "${*:-}\\" ""; IFS=${IFS#?}
  while [ "$1" ]; do set -- "${1#*\\\\}" "$2${2:+\\\\}${1%%\\\\*}"; done
  builtin echo -n "$2"
}
putsn() { [ $# -gt 0 ] && puts "$@"; builtin echo; }

# posh 上記以降
puts() {
  if [ $# -eq 1 ] && [ "$1" = "-n" ]; then
    builtin echo -n -; builtin echo -n n; return 0
  fi
  IFS=" $IFS"; set -- "${*:-}\\" ""; IFS=${IFS#?}
  while [ "$1" ]; do set -- "${1#*\\}" "$2${2:+\\\\}${1%%\\*}"; done
  builtin echo -n "$2"
 }
putsn() { [ $# -gt 0 ] && puts "$@"; builtin echo; }

builtinecho という名前のシェル関数を定義されていてもビルトインの echo を使用して問題なく動くようにするためのものです。

最後に(printf がビルトインではなく)未知のシェルのために外部コマンドの printf を使用したフォールバックコードを追加します。まためったに無い状況ですが PATH 環境変数が空に設定されている場合でも動くように対策を加えます。

puts() {
  PATH="${PATH:-}:/usr/bin:/bin"
  IFS=" $IFS"; printf '%s' "$*"; IFS=${IFS#?}
  PATH=${PATH%:/usr/bin:/bin}
}
putsn() {
  PATH="${PATH:-}:/usr/bin:/bin"
  IFS=" $IFS"; printf '%s\n' "$*"; IFS=${IFS#?}
  PATH=${PATH%:/usr/bin:/bin}
}

シェル判定コード

さて各シェル毎の関数はできましたが、シェルを判定して適切な関数を定義しなければいけません。その判定方法です。今回はシェルの判定には機能チェック(どのコマンドが使用できるか?)と変数の参照によるバージョンチェックの両方を組み合わせています。どちらか一方では適切に判断する難しかったです。

puts() {
  printf '' && return 0 # ビルトイン printf がある場合
  if print -nr -- ''; then # ビルトイン print がある場合
    [ "${ZSH_VERSION:-}" ] && return 1 || return 2
  fi
  if [ "${POSH_VERSION:-}" ]; then
    [ "${1#*\\}" ] && return 3 || return 4
  fi
  return 9
}
( PATH=""; puts "\\" ) 2>/dev/null &&:

シェル判定の関数ですが、関数名は puts という名前を再利用しています。これは後の本物の puts 関数によって上書きされます。一般的にはよくないコーディングスタイルですが、むやみに関数を増やしたくないのでこうしています。もちろん別の名前でも構いません。

ビルトインの関数が呼べるかどうかは、環境変数 PATH を空にして実際に呼び出すことで確認しています。typecommand -v を使えば?と思うかもしれませんが、posh で typecommand -v が、zsh 4系以前で command -v が実装されていません。(-v オプションなしの command であればどのシェルでも実装されているのですが)他にも未知のシェルが存在するかもしれないのでより安全な方法をとっています。

puts & putsn 関数 完成版

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

# シェル判定関数
puts() {
  printf '' && return 0
  if print -nr -- ''; then
    [ "${ZSH_VERSION:-}" ] && return 1 || return 2
  fi
  if [ "${POSH_VERSION:-}" ]; then
    [ "${1#*\\}" ] && return 3 || return 4
  fi
  return 9
}
( PATH=""; puts "\\" ) 2>/dev/null &&:
case $? in
  0) # printf がビルトインの場合
    puts() { IFS=" $IFS"; printf '%s' "${*:-}"; IFS=${IFS#?}; }
    putsn() { IFS=" $IFS"; printf '%s\n' "${*:-}"; IFS=${IFS#?}; }
    ;;
  1) # 古 zsh 用
    puts() { builtin print -nr -- "${@:-}"; }
    putsn() { builtin print -r -- "${@:-}"; }
    ;;
  2) # ksh88, mksh, OpenBSD ksh, pdksh 用
    puts() { command print -nr -- "${@:-}"; }
    putsn() { command print -r -- "${@:-}"; }
    ;;
  3) # posh 用(バグ対応版)
    puts() {
      if [ $# -eq 1 ] && [ "$1" = "-n" ]; then
        builtin echo -n -; builtin echo -n n; return 0
      fi
      IFS=" $IFS"; set -- "${*:-}\\" ""; IFS=${IFS#?}
      while [ "$1" ]; do set -- "${1#*\\\\}" "$2${2:+\\\\}${1%%\\\\*}"; done
      builtin echo -n "$2"
    }
    putsn() { [ $# -gt 0 ] && puts "$@"; builtin echo; }
    ;;
  4) # posh用
    puts() {
      if [ $# -eq 1 ] && [ "$1" = "-n" ]; then
        builtin echo -n -; builtin echo -n n; return 0
      fi
      IFS=" $IFS"; set -- "${*:-}\\" ""; IFS=${IFS#?}
      while [ "$1" ]; do set -- "${1#*\\}" "$2${2:+\\\\}${1%%\\*}"; done
      builtin echo -n "$2"
    }
    putsn() { [ $# -gt 0 ] && puts "$@"; builtin echo; }
    ;;
  9) # 未知のシェルのためのフォールバック
    puts() {
      # shellcheck disable=SC2031
      PATH="${PATH:-}:/usr/bin:/bin"
      IFS=" $IFS"; printf '%s' "$*"; IFS=${IFS#?}
      PATH=${PATH%:/usr/bin:/bin}
    }
    putsn() {
      PATH="${PATH:-}:/usr/bin:/bin"
      IFS=" $IFS"; printf '%s\n' "$*"; IFS=${IFS#?}
      PATH=${PATH%:/usr/bin:/bin}
    }
    ;;
esac

ビルトイン echo の置き換え

よくみるとわかりますが、このコードで echo を使用しているのは posh だけです。その posh でも builtin によって必ずビルトインの echo が呼び出されるようになっています。つまり echo シェル関数を定義することでビルトインの echo の挙動を全て同じに変更することが出来るということです。putsn の名前を変更しても良いですし putsn を呼び出す echo シェル関数を定義しても良いです。サンプルとして -n だけ対応している(エスケープシーケンスには対応していない) echo シェル関数の実装例です

echo() {
  if [ "${1:-}" = "-n" ] && shift; then
    puts "${@:-}"
  else
    putsn "${@:-}"
  fi
}

色を付けたい場合

Q. 色をつけたい場合はどうするの?またタブ (\t) などの制御文字を出力したい場合はどうするの?
A. エスケープ文字やタブを変数に入れて使えば良いです。その他の制御文字も同じやり方で出力できます。

ESC=$(printf '\033') TAB=$(printf '\011')
putsn "${ESC}[31m${TAB}RED${TAB}${ESC}[m"

おまけですべての制御コードを変数に入れるコードです。

# 遅いかもしれない 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'
")"

ライセンス

ライセンスはCreative Commons Zero v1.0 Universalにしています。つまり(可能な限り)著作権を放棄していますので、ライブラリとして使うなり、自分のスクリプトに組み込んで使うなり、必要ない部分を削除したりして改変して使うなり、ご自由にお使いください。著作権表示も不要です。

16
13
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
16
13

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?