ElixirのString.contains?で第1引数にbinaryを第2引数に日本語ひらがなを与えるとfalseになる理由についての調査報告

事の発端

たまたま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, #Reference<0.2806410432.2574385153.63561>}

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 matchingtrueを返すようなのでor条件式。それに加えた以下の条件によって3つの関数を使い分けしているようだ

  • 1.第2引数が空のリストの場合に固定でfalseを返す
  • 2.第2引数がリストであり、要素を持っている。:binary.match(string, contentes) != :nomatchではない
  • 2.第2引数がリストではなく(排反的に):binary.match(string, contentes) != :nomatchではない

ここで、再びErlangのドキュメントの:binary.matchに戻る。おそらく、こちらでもパターンマッチを使用して複数の関数が定義されているだろう
内部で実装されているErlangbinary.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).

え、どういうこと。これで関数として判定が成り立つってこと?? 単純にErlangsyntaxを理解出来ていないのか。
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で固定で渡したけど、Elixircontains?からは何が渡っているのだろうか...(後日談: これあんまり関係なかった)
先ほどの戻り値は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という概念がないのかもしれないと思い、binarybinary listに変換するbinary.bin_tolistを試してみたところ、とんでもないことが分かった。やはり先ほどの予測通り、日本語ひらがなは3byteの情報を持っているようだ

# よく見ると配列の一番最後の要素の値が2ずつインクリメントされている
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が正しい??

参考文献