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

ビルトインの `echo` を置き換えることで各シェルの非互換性を問題を解決する、移植性のある（ポータブルな）`echo` シェル関数を作りました。（リポジトリ[Portable echo](https://github.com/ko1nksm/portable-echo.sh)） 解説は後半の方でしています。

## はじめに

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

* [echo コマンドの違いと移植性の問題 - 拡張 POSIX シェルスクリプト Advent Calendar 2013](https://fumiyas.github.io/2013/12/08/echo.sh-advent-calendar.html)
* [どの環境でも使えるシェルスクリプトを書くためのメモ ver4.60](https://qiita.com/richmikan@github/items/bd4b21cf1fe503ab2e5c#echo%E3%82%B3%E3%83%9E%E3%83%B3%E3%83%89)

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

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

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

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

## puts & putsn 関数の改良

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

```sh
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` コマンドがシェルビルトインとして実装されているのでそれを使うことが出来ます。

```sh
# zsh 4 系以前版
puts() { builtin print -nr -- "${@:-}"; }
putsn() { builtin print -r -- "${@:-}"; }
```

```sh
# ksh 88, mksh, OpenBSD ksh, pdksh 版
puts() { command print -nr -- "${@:-}"; }
putsn() { command print -r -- "${@:-}"; }
```

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

残る問題は posh です。

**poshなんて使わｎ（略）**

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

```sh
# 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; }
```

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

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

```sh
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}
}
```

### シェル判定コード

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

```sh
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` を空にして実際に呼び出すことで確認しています。`type` や `command -v` を使えば？と思うかもしれませんが、posh で `type` と `command -v` が、zsh 4系以前で `command -v` が実装されていません。（`-v` オプションなしの `command` であればどのシェルでも実装されているのですが）他にも未知のシェルが存在するかもしれないのでより安全な方法をとっています。

## puts & putsn 関数 完成版

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

```sh
# シェル判定関数
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` シェル関数の実装例です

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

## 色を付けたい場合

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

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

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

```sh
# 遅いかもしれない 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](https://github.com/ko1nksm/portable-echo.sh/blob/master/LICENSE)にしています。つまり（可能な限り）著作権を放棄していますので、ライブラリとして使うなり、自分のスクリプトに組み込んで使うなり、必要ない部分を削除したりして改変して使うなり、ご自由にお使いください。著作権表示も不要です。