👻

String.hashCode が同じ文字列なのに違う値になることがある

2020/10/01に公開

[追記] このバグはすでに修正済です

Flutter/Dart でアプリを開発していたところ、Map に「同じ文字列が別キーとして入る」という現象に遭遇しました。さらに調査すると、同じ文字列なのに String.hashCode が違うのが原因だということが分かりました。

これは、いろいろな要因が重なって Dart のエッジなバグを踏んでいたものですが upstream では解決されています。

せっかくですので、経緯を記録として残しておきます。

おおよその実装

文字列を加工して返す関数があり:

String foo(String name) {
  return "prefix-${name}";
}

Mapにデータを入れる処理があり:

Map data = new Map<String, String>();

String str1 = foo("test");

// ...

// str1 は "prefix-test" という文字列
data[str1] = "value";

Mapからデータを取り出す処理がありました:

String str2 = foo("test");

// str2 は "prefix-test" という文字列
// str1 と同じ
String value = data[str2];

// "value" を期待するが null になる
print(value);

上記のように Map に対して「同じ文字列」でアクセスしているのに、期待通りの処理になりませんでした。

hashCode

Map はオブジェクトの hashCode を使って格納位置をコントロールしている(いろいろな言語でそういう実装なので、Dart もそうだろうと思った)ので hashCode が怪しい……ということで以下のように確認したところ「同じ文字列で、別のhashCode」になっていました。

print("${str1 == str2}"); // true
print("${str1.hashCode == str2.hashCode}"); // false!!!

通常の Object.hashCode は「同一性を示すもの」をもとにして算出されます。この場合の同一とは、メモリ上のアドレスも同じであることです。

ただし String, Point, Rect のようなクラスでは、同値であれば同じ(==true を返す)ものであるとして扱えるように、hashCode== 演算子をオーバーライドしています。このため、別の箇所で生成した String オブジェクトであっても、同じキーとして Map でも使えます。今回は、何らかの理由で、同じ値の String で別の hashCode になってしまうのが問題の原因です。

なお、この現象は iOS のシミュレーターおよび実機でのみ発生し、Android のエミュレータおよび実機では発生しません。

identityHashCode

さて、前述のコードで省略されている箇所で、文字列 str1 に対して identityHashCode を呼び出しているコードがありました。この identityHashCode を呼び出していることが hashCode を変えてしまっている原因でした。

identityHashCode とは?

Returns the identity hash code of object.

Returns the same value as object.hashCode if [object] has not overridden
[Object.hashCode]. Returns the value that [Object.hashCode] would return
on this object, even if hashCode has been overridden.

This hash code is compatible with [identical].

つまり hashCode をオーバーライドしているクラスのオブジェクトに対しても、基底クラスである Object.hashCode の値を得ることのできる関数です。同値でイコールになるものでも、別オブジェクトであれば別のものとして扱いたい場合に、この identityHashCodeidentical といった関数を使います。

ところが、この関数によって内部的に呼び出された Object.hashCode の値が String.hashCode の結果としてもキャッシュされてしまう、という動作になっていました。

hashCode の計算にはある程度のコストがかかるので、算出したものをキャッシュするようにしているようですが String の場合はこれが問題になりました。

普通にコードを書いていると identityHashCode は使わないと思うので、ほとんどのアプリでは問題にならないと思います。

対策

identityHashCode を使わないようにしました。この関数を使わずとも実装できるコードだった、むしろ同一性よりは「同値かどうか」をベースにしたほうがよい処理だったので、そのように修正しました。

いずれ Flutter が利用している Dart のバージョンがあがり、該当の修正が含まれるようになりますが、それまでに対応が必要な場合は、コードを見直すとよいでしょう。

おまけ

原因究明までに試したこと

  • Flutterのバージョンをいろいろ変える(Dartのバージョンも変わるため)
  • 各種キャッシュをクリア(flutter/bin/cache, ~/.pub-cache, /path/to/app/build)
  • printをあちこちに埋め込み、どこで値が変わってしまうのか調査

この記事はQiitaの記事をエクスポートしたものです

Discussion