はじめに
多くのシェルでビルトインコマンドのになっている printf
コマンドは、シェルによって対応しているフォーマット指定子が異なり、16 進数で文字を表現する \xHH
は dash (ash 系のシェル)では対応してないということをご存知でしょうか? 手元の bash で動作確認して、いざ本番環境で動かしてみたら表示されない、といったことをしばしば見かけます。移植性がないため POSIX (ls)でも標準化できていないフォーマット指定子です。
8 進数で表現する \NNN
であれば、どのシェルでも対応しています。したがって移植性がある方法は \xHH
ではなく慣れない\NNN
を使う方法です。せっかく書いたコードを書き直さなければなりません。……というのが多くの人の発想ではないかと思いますが、シェルスクリプトプログラミングを学んだ人の発想は違います。この記事では、この問題をシェルスクリプトプログラミングを使ってどのように解決するのかを紹介します。
ちなみに、なぜ \xHH
に対応していないのかは、きちんと調べていませんが、昔は 8 進数の方がよく使われていた(16 進数表記でA〜Fを使うという発想は誕生してないか広く使われていなかった)からだと思われます。いい加減対応しろって感じですよね。POSIX が生まれたせいで、標準化されてないものには対応しなくても良いという風潮が生まれているように思えます。
状況確認
どのような問題かは想像できると思いますが、念の為に状況を確認しておきましょう。何事も問題を正しく把握することは重要なことです。
#!/bin/sh
printf '[\x41]\n'
上記のようなシェルスクリプトを、macOS で実行すると以下のように出力されます。なお macOS の /bin/sh
は(POSIX 準拠モードの)bash 3.2.57です。
$ ./printf.sh
[A]
一方、このシェルスクリプトを Ubuntu で実行するとこのようになります。なお Ubuntu の /bin/sh
は dash です。
$ ./printf.sh
[\x41]
さらに、FreeBSD では以下のように表示されます。FreeBSD の /bin/sh
は ash です。
$ ./printf.sh
[x41]
同じ \xHH
に対応していないという状況であっても出力に差があることがわかると思います。
外部コマンドの printf
を使う
簡単な対応策は、外部コマンドの printf
を使うことです。シェルスクリプトから外部コマンドの printf
を使うには env printf
で実行するのが簡単です。
#!/bin/sh
env printf '[\x41]\n'
上記のコードは正しく動作します。しかし全ての printf
コマンドの前に env
をつけるのは面倒でしょう。これを回避する方法は簡単です。以下のようにするだけです。
#!/bin/sh
printf() {
env printf "$@"
}
printf '[\x41]\n'
シェルビルトインコマンドの printf
コマンドを、ユーザー定義の printf
関数で置き換え、外部コマンド版の printf
コマンドを呼び出す。ただこれだけです。
ただし、この方法は欠点があります。FreeBSD では外部コマンド版の printf
も \xHH
に対応していません。macOS も /bin/sh
は \xHH
に対応しているのですが、外部コマンド版の printf
も同様に対応していません。ただし GNU 版の Coreutils をインストールするだけで gprintf
というコマンド名で使えるようになるので、あとは適当に条件分岐して呼び出すコマンドを変更するだけです。
また外部コマンドの printf
コマンドを呼び出しは、シェルビルトイン版のコマンドよりも遅いという問題もあります。
別解 自力で \xHH
をデコードする
GNU 版 Coreutils をインストールすれば解決できるとは言え、これだけのためにインストールするのは面倒だと思うかもしれません。もっとも移植性が高いは自力で \xHH
をデコードすることです。自力でデコードすることも可能ですが少し面倒なので、以下の例では \xHH
を \NNN
の 8 進数表記に変更することで対応しています。
#!/bin/sh
printf() {
decode_format decoded_format "$1"
shift
set -- "$decoded_format" "$@"
unset decoded_format
command printf "$@"
}
decode_format() {
set -- "$1" "${2#*\\}\\" "${2%%\\*}"
while [ "$2" ]; do
set -- "$1" "${2#*\\}" "$3" "${2%%\\*}"
case $4 in
x[0-9a-fA-F][0-9a-fA-F]*)
set -- "$1" "$2" "$3" "0${4%"${4#???}"}" "${4#???}"
set -- "$1" "$2" "$3\\$(($4>>6 & 0x3))$(($4>>3 & 0x7))$(($4 & 0x7))$5"
;;
x[0-9a-fA-F]*)
set -- "$1" "$2" "$3" "0${4%"${4#??}"}" "${4#??}"
set -- "$1" "$2" "$3\\$(($4>>3 & 0x1))$(($4 & 0x7))$5"
;;
*) set -- "$1" "$2" "$3\\$4" ;;
esac
done
eval "$1=\$3"
}
printf '[\x41]\n' # => [A]
コードはごちゃごちゃいていますが、大したことはしていません。単に \xHH
の HH 部分を 16 進数から 8 進数に変換しているだけ、単純なビット演算で事足ります。説明が必要なのは command printf "$@"
の部分でしょう。command
コマンドを使うとユーザー定義のシェル関数を無視してコマンド(外部コマンド or ビルトインコマンド)を呼び出します。したがってこのコードは printf
関数を再帰呼び出しすることはなく、シェルビルトインの printf
コマンドが呼び出されます。このテクニックは完全に POSIX シェルの言語仕様の範囲だけで実現されています。
なお、この記事はこのようなテクニックを紹介するのが目的なので、FreeBSD やその他の環境で動くかのテストはしていません。あしからず。(動くはずですが)
さいごに
たまにシェルスクリプトはプログラミング言語なのか?などと言う人がいますが、それは言っている人がプログラミング言語の定義を知らず、またシェルスクリプトのプログラミング能力を知らないからです。シェルスクリプトのプログラミングを知れば、このようにシェルスクリプト自身でシェルスクリプトの機能を拡張することが出来ます。
このようなテクニックを利用してシェルの互換性問題を解決しつつ、より高度な機能を提供するライブラリが、シェルスクリプトの世界にもあればよいのですが、残念ながらそのようなよく知られたライブラリは(おそらくまだ)存在していません。シェルスクリプトプログラミング技術が広まれば「printf
コマンドは 16 進数表記は移植性がないから 8 進数表記を使わなければいけない」と言った無駄なバッドノウハウを減らしていくことが出来るでしょう。「8 進数で書かないとだめなんだよ」というバッドノウハウはドヤ顔で語るようなありがたい知識ではありません。なくしていくべきものです。