豪鬼メモ

MT車練習中

std::string_viewの間違った使い方

みんな大好きstd::string_viewは、任意の文字列やバイナリバイト列への「ビュー」を表すために使われる。DBMライブラリTkrzwではstd::string_viewの間違った使い方をしていたために、最新の処理系でビルドすると全く動かなくなるという不具合があった。その対処に苦労したという話。


std::string_viewとは、単純化するとポインタとサイズの構造体だ。以下のように表現しても良い。

struct string_view {
  const char* data;
  size_t size;
};

dataが指し示す記憶領域への所有権を持たないのがstd::stringとの明白な違いだ。std::string_viewは単なるビューであり、生成コストが非常に小さく、関数のパラメータとして実体渡しする際のコストも小さい。よって、実行速度を気にするプログラムではstd::stringではなくstd::string_viewをできる限り多用する。

当然ながらTkrzwでもstd::string_viewを使いまくっているわけだが、その中で特殊な使用法をしているところがある。ファイル上のデータをあたかもメモリ上にあるように見せかけるために、ファイル上のオフセットとstd::string_viewのペアを保持する構造体を使っている。単純化するとこんな感じだ。

struct Record {
  off_t off_;
  std::string_view data_;

  std::string_view Get() const;
};

レコードにアクセスする関数を呼ぶと、該当の領域がメモリに読み込まれている場合には、std::string_viewにポインタとサイズを与えて作って返す。該当の領域がメモリに読み込まれていない場合には、std::string_view(nullptr, size) として、nullptrとサイズだけを与えて作って返し、実際にデータを参照する際に遅延読み込みを行う。この後者のnullptrでstd::string_viewを構築する用法が仕様違反なのだ。CPlusPlus Referenceからそのコンストラクタの仕様を抜粋する。

  • constexpr basic_string_view( const CharT* s, size_type count );
    • Constructs a view of the first count characters of the character array starting with the element pointed by s. s can contain null characters. The behavior is undefined if [s, s + count) is not a valid range (even though the constructor may not access any of the elements of this range). After construction, data() is equal to s, and size() is equal to count.

デフォルトコンストラクタではnullptrと0のペアとしてオブジェクトが作られるので、パラメータ付きのコンストラクタでもnullptrを与えられると私は勘違いしていた。std::string_viewはビューなのだから、生成した時点ではポインタの先が何であるかを感知しないと思っていたのだ。そして多くの処理系では実際にnullptrを与えても普通に動いていた。しかし、仕様としてはポインタの先の領域が妥当なメモリ領域であることを要求している。どんな目的でそのような制約を設けているのかはわからないが、とにかく仕様上はそうなのだ。そのせいか、最近の一部の処理系では、コンストラクタの中にパラメータがnullptrでないことを調べるassertを埋め込んでいることがある。その場合、nullptrを与えれば当然プログラムはabortしてしまう。うぬぬ。自分の環境でテストに通るだけじゃだめで、依存するAPIの全ての仕様はちゃんと読まないといけない。

小手先の対策としては、nullptrの代わりにreinterpret_cast< const char*>(1)とかを使う手がある。1はnullptrじゃないのでassert(data != nullptr) みたいな典型的な検査には引っかからないし、1を正常なポインタとして使う処理系もないので区別できる。しかし、正常なポインタじゃないものを与えるのは依然として仕様違反だ。何らかの方法で参照先の妥当性検証をする処理系があればクラッシュしてしまうだろう。

仕方ないので、nullptrを扱うかもしれない場所ではstd::string_viewを使うのを止めて、ほぼ同じ機能のtkrzw::NullableStringViewというクラスを書いて代用した。会社でこんな車輪の再発明をしたらコードレビューでめっちゃ詰問されそうだけれども、しょうがない。私に言わせれば、std::string_viewのコンストラクタに変な制約をつけている方がおかしい。ビューに過ぎないんだから勝手に参照先を観測するなよと言いたい。どうでもいいことだが、観測するだけで結果が変わってしまうってのは量子力学っぽくてちょっと浪漫を感じた。

あとは、必要な場所でこのtkrzw::NullableStringViewを使うように既存のコードを書き換えていけばよいことになる。しかし、どこを書き換えるべきかに完全な自信を持つのは難しい。そこで、まずは報告された問題が自分の環境でも再現するようにしたい。といっても処理系を入れ替えるのは手間なので、ちょっとしたハックで乗り切ることにした。string_viewのコンストラクタにnullptrを渡すと落ちるという挙動を任意の処理系で強制するには、標準ライブラリのヘッダを書き換えてしまえば良い。/usr/include/c++/11/string_view をエディタで開いて、コンストラクタにassertモドキを追加する。

      constexpr
      basic_string_view(const _CharT* __str, size_type __len) noexcept
      : _M_len{__len}, _M_str{__str}
      {
        // Assures that the given pointer is not nullptr.                                                        
        if (__str == nullptr) {
          abort();
	}
      }

この状態でビルドし直せば、報告された問題が再現するようになった。あとは、テストケースを動かしてクラッシュさせてスタックトレースを見ながら、落ちた場所のstd::string_viewをtkrzw::NullableStringViewに置き換えていけばよい。テストケースのカバレッジが十分であれば、それだけで問題を解決できる。もちろん関連するコードを読み直して意図的な動作であるかの確認もする。

という経緯で、潜在的にどの処理系でも基本機能でクラッシュしかねないという重大バグを解決し、Tkrzw 1.0.32をリリースした。久しぶりに趣味のコードを書くと楽しいものだ。