事の発端
たまたまElixir
でhttp responseのbinary
情報に対して、特定の日本語が含まれているかという判定式を記述していたところで、この現象に遭遇した。
iex> body = <<201, 202, 197, ...>>
iex> String.contains?(body, "いちご")
false
間違いなくbinary
の中には第2引数で渡している日本語ひらがな(内部データ的にはこいつもbinary)が含まれているのになぜかfalse
になる。この一件をtwitterにて投下したところ、KIKUCHI Yutaka 🌓 菊池 豊さんとこの動作について議論をしたが、なぜそうなるのかの答えにはたどり着けなかった
以降、時間が上手く確保できずで調査をする時間がなかったのだが、空いた時間を作れたのでなぜそうなるのかを調べてみた
問題の判定
iex> ?a
97
iex> ?b
98
iex> ?c
99
iex> ?あ
12354
iex> ?い
12356
iex> ?う
12358
なぜfalse
になるのか...
iex> String.contains?(<<97, 98, 99>>, "abc")
true
iex> String.contains?(<<12354, 12356, 12358>>, "あいう")
false
2byteずつ確保されているなら以下の処理はtrueになるのかどうか(ならない)
iex> String.contains?(<<12354, 12355, 12356, 12357, 12358, 12359>>, "あいう")
false
String.contains?の実装を見てみる
まずは公式ドキュメントから。この時点で解決出来るのがベスト
Checks if string contains any of the given contents.
contents can be either a string, a list of strings, or a compiled pattern.
文字列に与えられたコンテンツが含まれているかどうかを確認。 コンテンツは文字列、文字列リスト、もしくはコンパイルされたパターンのどれか。
なるほど、コードサンプルも確認しておこう。第2引数をリストにして複数からor検索で実行出来るのは知らなかった
String.contains?("elixir of life", "of")
true
String.contains?("elixir of life", ["life", "death"])
true
なにやら見慣れない使い方を発見。確かにcompile pattern
を引数に渡せると説明にあったけど、そもそもcompile pattern
が何か分からん
iex> pattern = :binary.compile_pattern(["life", "death"])
iex> String.contains?("elixir of life", pattern)
true
:binaryということはErlangのモジュールなので、Erlangのドキュメントを確認しに行く
about binary module
This module contains functions for manipulating byte-oriented binaries.
Although the majority of functions could be provided using bit-syntax,
the functions in this library are highly optimized and are expected to either execute faster or consume less memory,
or both, than a counterpart written in pure Erlang.
このモジュールには、バイト指向のバイナリを操作するための関数が含まれています。
ほとんどの関数はビット構文を使用して提供しており、このライブラリの関数は高度に最適化されており、
純粋なErlangで記述された関数よりも高速に実行される、もしくはメモリの消費量が少なくなります。
思った通り、バイナリ操作をするための関数群らしい。先ほど、登場した:binary.compile_pattern
について確認する。
少々長いので、部分的に公式ドキュメントを引用する。
Builds an internal structure representing a compilation of a search pattern
When a list of binaries is specified, it denotes a set of alternative binaries to search for
なるほど。本当に関数名のまんまでErlang
ではmatch/3, matches/3
などの関数で使用するための検索パターンをバイナリから作成するための関数のよう。
リストの場合も同様で、引数で渡すときはflatなデータを代入してくれなど諸注意についても記述されている。何となく意味と使われ方が分かったので次に進もう
ちなみに以下のコードも試したがダメだった
iex> pattern = :binary.compile_pattern(["あ", "い", "う"])
{:ac,
iex> String.contains?(<<12354, 12356, 12358>>, pattern)
内部実装を見に行こう
compile pattern
というものも試してみたがtrue
に判定されなかった。そもそもcompile pattern
についての知識が乏しいというのもあるが、一旦考えないこととする。実際にString.contains?
がどのように判定を行なっているかを確認するために、String
モジュールを見てみる
https://github.com/elixir-lang/elixir/blob/v1.10.0/lib/elixir/lib/string.ex#L2188
def contains?(string, []) when is_binary(string) do
false
end
def contains?(string, contents) when is_binary(string) and is_list(contents) do
"" in contents or :binary.match(string, contents) != :nomatch
end
def contains?(string, contents) when is_binary(string) do
"" == contents or :binary.match(string, contents) != :nomatch
end
まずcontains?
に関してはパターンマッチを使用して3種類の関数が実装されている。
共通の条件としては第1引数がバイナリであること。ドキュメントに記述があるように、第1引数が空文字の場合にAll matching
、true
を返すようなのでor条件式。それに加えた以下の条件によって3つの関数を使い分けしているようだ
- 1.第2引数が空のリストの場合に固定でfalseを返す
- 2.第2引数がリストであり、要素を持っている。
:binary.match(string, contentes) != :nomatch
ではない
- 2.第2引数がリストではなく(排反的に)
:binary.match(string, contentes) != :nomatch
ではない
ここで、再びErlang
のドキュメントの:binary.match
に戻る。おそらく、こちらでもパターンマッチを使用して複数の関数が定義されているだろう
内部で実装されているErlang
のbinary.match/2
を直接呼び出してもfalse
になる
iex> :binary.match(<<12354, 12356, 12358>>, "あいう")
:nomatch
binary.match/2 について
Erlang Official Document: binary.matchから引用
match(Subject, Pattern) -> Found | nomatch OTP R14B
Types
Subject = binary()
Pattern = binary() | [binary()] | cp() # 第2引数がbinaryかbinaryを要素に持つリスト、もしくはcompile pattern
Found = part()
Same as match(Subject, Pattern, []).
https://github.com/elixir-lang/elixir/blob/v1.10.0/lib/elixir/lib/string.ex#L2188
-spec match(Subject, Pattern) -> Found | nomatch when
Subject :: binary(),
Pattern :: binary() | [binary()] | cp(),
Found :: part().
match(_, _) ->
erlang:nif_error(undef).
え、どういうこと。これで関数として判定が成り立つってこと?? 単純にErlang
のsyntax
を理解出来ていないのか。
return
としてFound :: part()
もしくはnomatch
(atom)を返すのは分かるけど、判定をどこでしてるのかが全く分からない。判定をどうやっているかが分からないと今回一番見たい部分を見る事が出来ない。
とりあえず正常にmatchした時のresponseを落ち着いて1回、見てみる
iex> :binary.match("あいう", "あいう")
{0, 9}
このreponseが先ほど確認したFound :: part()
に当たるものだろう。part()
は内部でbinary()
を返している:
-spec part(Subject, PosLen) -> binary() when
Subject :: binary(),
PosLen :: part().
気を取り直して、binary
のドキュメントをよく見てると判定をどのようにしているかの旨が記述されているではないか。
part() = {Start :: integer() >= 0, Length :: integer()}
A representaion of a part (or range) in a binary. Start is a zero-based offset into a binary() and Length is the length of that part.
As input to functions in this module, a reverse part specification is allowed,
constructed with a negative Length, so that the part of the binary begins at Start + Length and is -Length long.
This is useful for referencing the last N bytes of a binary as {size(Binary), -N}. The functions in this module always return part()s with positive Length.
先ほど確認したresponseは{Start :: integer() >= 0, Length :: integer()}
は上記のように構成されており、Start()
はbinary()
という基準点からの距離?(offset...うーん、いまいち何を言ってるのか分からないが)を持つらしい。
こんな時は頭を空っぽにして、コードの実行結果を見てみよう。Erlang
のドキュメントを参考にpart()
関数を呼び出してみる
iex> bin = <<1,2,3,4,5,6,7,8,9,10>>
<<1, 2, 3, 4, 5, 6, 7, 8, 9, 10>>
iex> :binary.part(bin, {byte_size(bin), -5})
<<6, 7, 8, 9, 10>>
あーなるほど、offsetと言っているのはsliceを行うというような意味合いなのか。とすると内部でやっていることは大したことではないはず。今回はpart()
の第2引数を-5で固定で渡したけど、Elixir
のcontains?
からは何が渡っているのだろうか...(後日談: これあんまり関係なかった)
先ほどの戻り値は0番目の位置から9byte進んだところまで一致したということを表しているのか
iex> :binary.match("あいう", "あいう")
{0, 9}
つまり、「あ、い、う」それぞれが3byteずつ容量を持っているということか? そう思い<<12354, 12356, 12358>>をiex
に打ち込んでみたところ、「あいう」に変換されないことに気づく
iex> bin = <<12354, 12356, 12358>>
"BDF"
iex> :binary.part(bin, {byte_size(bin), -2})
"DF"
どういうことだ..?? byteの情報とcodepoints
の情報が一致しないものがあるということだとすれば辻褄が合うが...
iex(33)> String.to_charlist("あいう")
[12354, 12356, 12358]
そもそもErlang
にはcodepoints
という概念がないのかもしれないと思い、binary
をbinary list
に変換するbinary.bin_tolist
を試してみたところ、とんでもないことが分かった。やはり先ほどの予測通り、日本語ひらがなは3byte
の情報を持っているようだ
iex> :binary.bin_to_list("あ")
[227, 129, 130]
iex> :binary.bin_to_list("い")
[227, 129, 132]
iex> :binary.bin_to_list("う")
[227, 129, 134]
仮説が正しいのかを確認
iex> bin = <<227, 129, 130, 227, 129, 132, 227, 129, 134>>
"あいう"
素晴らしい。これならtrue
の判定を見る事が出来そうだ
iex> String.contains?(<<227, 129, 130, 227, 129, 132, 227, 129, 134>>, "あいう")
true
なるほど、やはりそうだった。内部でcallしているのがErlang
のモジュールに実装された関数であるため、byte情報の取り扱い方が異なるのが原因だと考えられる。Erlang
では日本語ひらがな1文字は3byteの情報で扱うのだが、Elixir
では1byteの情報として扱っている。この違いのせいで、思ったようにtrue
の判定にならなかったのだろう
残る疑念
Elixir
ではbinary
情報を1byteで扱っている、Erlang
では日本語ひらがなに関しては3byte
で扱っていると記述しているが、これは本当にbyte
なのか。単位が正確ではない気がする。1codepoint
が正しい??
参考文献