2017/09/04

マルチバイト文字を指定した際の tr コマンドの置換処理について

(GNU) tr は、標準入力から読み込んだデータに対し、引数で指定された文字集合を置換して出力する、単純な置換コマンドである。

tr コマンドは、もっぱらASCII文字の置換に利用される。
また、以前に「 tr はマルチバイト文字に対応していない」という発言を、耳にしたことがある。

しかし、 @tamago_girai さんの下記ツイートに記載されたコマンドを実行した所、マルチバイト文字も置換することが出来た。
(ただし、このコマンドでは、「越谷市は趱趱だ」という、意図しない結果が出力される。)

そこで、 tr は一体何を変換しているのか疑問に感じ、調べてみることにした。

本記事では、 tr コマンドの置換処理の動作について、調べた内容を記す。


環境

  • GNU bash 4.4.12(1)-release

  • tr (GNU coreutils) 8.27

  • xxd V1.10 27oct98

  • Personal Tukubai 20170606 ( Open-usp-Tukubai で代用可能)

検証1

変換の実行

検証は、前述のツイートで使われている、「熊谷市は熱熱だ」という文字列を用いて、おこなった。

上記の文字列に対して、 tr コマンドを用いて、「熊」を「越」に置換する。
置換した結果、以下の文字列が表示される。

$ echo '熊谷市は熱熱だ' | tr '熊' '越'
越谷市は趱趱だ

出力結果では、「熊谷市」の「熊」だけでなく、「熱熱だ」の「熱」の文字も、置換されてしまった。
また、「熱」という文字は、「越」ではなく、「趱」に置換されている。
以降、この結果に至った原因を調べていく。

「熊」、「越」のバイト列の確認

まず、「熊」、「越」の文字が、それぞれどんなバイト列に該当するのかを調べる。
文字からバイト列への変換は、 xxd を用いておこなった。

$ echo -n '熊' | xxd -b | self 2/NF-1
11100111 10000110 10001010
$ echo -n '越' | xxd -b | self 2/NF-1
11101000 10110110 10001010

上記の出力結果より、「熊」、「越」は、それぞれ以下のようなバイト列であることが分かった。

11100111 10000110 10001010

11101000 10110110 10001010

元文字列および tr 実行後のバイト列の確認

次に、「熊谷市は熱熱だ」のバイト列と、 tr 実行後のバイト列を確認する。

$ echo '熊谷市は熱熱だ' | xxd -b -c 256 | self 2/NF-1
11100111 10000110 10001010 11101000 10110000 10110111 11100101 10111000 10000010 11100011 10000001 10101111 11100111 10000110 10110001 11100111 10000110 10110001 11100011 10000001 10100000 00001010
$ echo '熊谷市は熱熱だ' | tr '熊' '越' | xxd -b -c 256 | self 2/NF-1
11101000 10110110 10001010 11101000 10110000 10110111 11100101 10111000 10000010 11100011 10000001 10101111 11101000 10110110 10110001 11101000 10110110 10110001 11100011 10000001 10100000 00001010

上記の結果から、「熱」に該当するバイト列 11100111 10000110 10001010 が、 11101000 10110110 10001010 に、置き換えられている事が分かる。
バイト列 11101000 10110110 10110001 は、UTF-8では「 」であるため、結果として「越谷市は趱趱だ」という文字列が表示された。

$ echo -n '趱' | xxd -b | self 2/NF-1 | tr ' ' '\n' | sed 's/.*/echo "obase=16;ibase=2;&" |bc/' | sh | xargs
E8 B6 B1

差分の確認

変更箇所を分かりやすくするため、 tr 実行前・後のバイト列に対して、 diff を実行した結果を記す。

$ diff <(echo '熊谷市は熱熱だ' |xxd -b -c 256 |self 2/NF-1 |tr ' ' '\n') <(echo '熊谷市は熱熱だ' |tr '熊' '越' |xxd -b -c 256 |self 2/NF-1 |tr ' ' '\n')
1,2c1,2
< 11100111
< 10000110
---
> 11101000
> 10110110
13,14c13,14
< 11100111
< 10000110
---
> 11101000
> 10110110
16,17c16,17
< 11100111
< 10000110
---
> 11101000
> 10110110

検証2

(以下、2017年 9月 5日 火曜日 10:15:21 JST 追記)

置換範囲の確認

次に、置換処理はバイト列単位でおこなわれているのか、それとも1バイト毎におこなわれているのか、置換範囲を調べた。

元文字列は「 🍺 」、置換対象文字は「き」、置換先文字は「ん」とする。

バイト列の確認

まず、「🍺」、「き」、「ん」の文字が、それぞれどんなバイト列に該当するのかを調べる。

$ echo -n '🍺' | xxd -b | self 2/NF-1
11110000 10011111 10001101 10111010
$ echo -n 'き' | xxd -b | self 2/NF-1
11100011 10000001 10001101
$ echo -n 'ん' | xxd -b | self 2/NF-1
11100011 10000010 10010011

変換の実行

次に、「🍺」、に対して、 tr 'き' 'ん' を実行する。

$ echo -n '🍺' | tr 'き' 'ん'
📺

実行した結果、「📺」が表示された。

元文字列および tr 実行後のバイト列の確認

そして、 tr 'き' 'ん' 実行後のバイト列を確認する。

$ echo -n '🍺' | tr 'き' 'ん' | xxd -b | self 2/NF-1
11110000 10011111 10010011 10111010

上記の結果から、「き」に含まれるバイト列のうち、3バイト目の 10001101 が、元バイト列の一部分と一致し、「ん」の3バイト目の 10010011 に、置き換えられている事が分かる。
バイト列 11110000 10011111 10010011 10111010 は、UTF-8では「 📺」であるため、結果として「📺」が表示された。

$ echo -n '📺' | xxd -b | self 2/NF-1 | tr ' ' '\n' | sed 's/.*/echo "obase=16;ibase=2;&" |bc/' | sh | tarr | tr -d ' '
F09F93BA

また、上記の結果から、 tr での置換処理は、1バイト毎におこなわれている 事が分かった。

結論

tr コマンドは、文字を置換しているのではなく、バイト列を置換している。
よって、「 tr コマンドはマルチバイト文字に対応していない」は、正確ではない。
正しくは、「 tr コマンドは、文字ではなくバイト列を 1バイト毎に [1] 置換しているため、1文字が非1バイトの文字コードデータを置換させる場合、意図した通りに置換されない事がある」。

雑記

  • tr コマンドでも、マルチバイト文字を置換する事は可能である事は、分かった。
    → ただ、結論に書いてあるように、意図したとおりに変換出来ない可能性がある。
    → なので、マルチバイト文字の置換をしたい場合は、素直に GNU sed を使うのが良さそう。

  • 本来なら、ソースを読んで動作を直接確認するのが、一番正確なのだろうけれど…​
    GNU tr のソースを読んでも、イマイチ良く分からなかったので、コマンドの動作結果から、動作を確認することにしたorz


1. 2017年 9月 4日 月曜日 21:44:10 JST 追記
Tags: Shell *nix
このエントリーをはてなブックマークに追加