printfコマンドが\xHHに対応してないなら自力で対応させれば良い

## はじめに

多くのシェルでビルトインコマンドのになっている `printf` コマンドは、シェルによって対応しているフォーマット指定子が異なり、16 進数で文字を表現する `\xHH` は dash (ash 系のシェル)では対応してないということをご存知でしょうか？ 手元の bash で動作確認して、いざ本番環境で動かしてみたら表示されない、といったことをしばしば見かけます。移植性がないため [POSIX (ls)](https://pubs.opengroup.org/onlinepubs/9699919799/utilities/printf.html)でも標準化できていないフォーマット指定子です。

8 進数で表現する `\NNN` であれば、どのシェルでも対応しています。したがって移植性がある方法は `\xHH` ではなく慣れない`\NNN` を使う方法です。せっかく書いたコードを書き直さなければなりません。……というのが多くの人の発想ではないかと思いますが、シェルスクリプトプログラミングを学んだ人の発想は違います。この記事では、この問題をシェルスクリプトプログラミングを使ってどのように解決するのかを紹介します。

ちなみに、なぜ `\xHH` に対応していないのかは、きちんと調べていませんが、昔は 8 進数の方がよく使われていた（16 進数表記でA〜Fを使うという発想は誕生してないか広く使われていなかった）からだと思われます。いい加減対応しろって感じですよね。POSIX が生まれたせいで、標準化されてないものには対応しなくても良いという風潮が生まれているように思えます。

## 状況確認

どのような問題かは想像できると思いますが、念の為に状況を確認しておきましょう。何事も問題を正しく把握することは重要なことです。

```sh:printf.sh
#!/bin/sh

printf '[\x41]\n'
```

上記のようなシェルスクリプトを、macOS で実行すると以下のように出力されます。なお macOS の `/bin/sh` は（POSIX 準拠モードの）bash 3.2.57です。

```console
$ ./printf.sh
[A]
```

一方、このシェルスクリプトを Ubuntu で実行するとこのようになります。なお Ubuntu の `/bin/sh` は dash です。

```console
$ ./printf.sh
[\x41]
```

さらに、FreeBSD では以下のように表示されます。FreeBSD の `/bin/sh` は ash です。

```console
$ ./printf.sh
[x41]
```

同じ `\xHH` に対応していないという状況であっても出力に差があることがわかると思います。

## 外部コマンドの `printf` を使う

簡単な対応策は、外部コマンドの `printf` を使うことです。シェルスクリプトから外部コマンドの `printf` を使うには `env printf` で実行するのが簡単です。

```sh:printf.sh
#!/bin/sh

env printf '[\x41]\n'
```

上記のコードは正しく動作します。しかし全ての `printf` コマンドの前に `env` をつけるのは面倒でしょう。これを回避する方法は簡単です。以下のようにするだけです。

```sh:printf.sh
#!/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 進数表記に変更することで対応しています。

```sh:printf.sh
#!/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 進数で書かないとだめなんだよ」というバッドノウハウはドヤ顔で語るようなありがたい知識ではありません。なくしていくべきものです。