バグは重箱の隅ばかりつついてくる

2023/11/10 14:20:一部のコードを修正しました.

この記事は アカンクリスマスアドベントカレンダー2023 7日目 の記事です.

はじめに

私は普段からC++という言語をよく使用しており,基本的なことは理解しているつもりでいます.しかし,全く知らなかった仕様によるバグで詰まることがよくあります.普段は知らなくても動くため気にもしておらず,突然知らない仕様で詰まる.プログラミングをしている方はこのような経験がよくあるのではないでしょうか.
本記事では,そんな多くの人が気にしないようなものとして,C++size_tの仕様を取り上げ解説していこうと思います.

前置き

本記事内ではC++ 20を前提とします.また,断りがない限りコンパイラGCC 13.2.0を仮定します.規格書はC++ 20のワーキングドラフトを参照しています.
最新の情報や公式の国際規格(有料)を知りたい方は規格書についてまとめて頂けているサイトがあるため,こちらを参考にしてください.

size_t

size_tC言語C++で用いられる型の一つです.size_tはその名の通りサイズを表すための型で,非負整数として扱われます.配列などのサイズを表現したり,for文のloop indexとして使われることが多いです.

#include <array>
#include <iostream>

constexpr size_t HOGE_SIZE = 5;
int main() {
  std::array<int, HOGE_SIZE> ary{1, 2, 3, 4, 5};
  for (size_t i = 0; i < ary.size(); ++i) { std::cout << ary[i] << std::endl; }
}

size_tの仕様

size_tは非負整数として扱われはしますが,bit数は仕様として決定されていません.規格書には以下のように記載されています.十分な大きさであることは保証されていますが,具体的な大きさの記載はないです.つまり環境依存です.

The type size_t is an implementation-defined unsigned integer type that is large enough to contain the size in bytes of any object (7.6.2.4).
https://timsong-cpp.github.io/cppwp/n4861/support.types.layout#3

実際にGCCのコードを見ると,GCCの定義済みマクロである__SIZE_TYPE__typedefしてあり,GCCに依存していることが分かります(該当部).

  typedef __SIZE_TYPE__ 	size_t;

ちなみに,私の環境では__SIZE_TYPE__long unsigned intでした.longも結局環境依存(参考)なので環境依存のたらい回しが凄いです...

$ gcc -dM -E -c -xc /dev/null | grep __SIZE_TYPE__
#define __SIZE_TYPE__ long unsigned int

size_tstd::size_t

ここまでsize_tの話をしていましたが,実はsize_tは2種類存在します.1つはC言語から使われるsize_t,もう一つはC++STLに内包されるstd::size_tです.2種類あることだけを知っていると使い分けが難しそうに見えますが,規格書に以下の記載があるため問題ないです.

For each type T from the C standard library, the types ​::​T and std​::​T are reserved to the implementation and, when defined, ​::​T shall be identical to std​::​T.
https://timsong-cpp.github.io/cppwp/n4861/extern.types#1

要するにsize_tstd::size_tは全く同一のものを指すということです.そのためどちらを使っても問題ないです.
GCCでは以下のようにsize_tはそのままstd::size_tのことを指しています(該当部).

using std::size_t;

size_tsize_type

C++arrayvectorなどのコンテナをfor文で走査する場合,次のようにsize()というメンバ関数でサイズを取得すると思います.

#include <vector>

int main() {
  std::vector<int> v{1, 2, 3, 4, 5};
  for (std::size_t i = 0; i < v.size(); ++i) {}
}

ここで,i < v.size()に着目します.一見問題ないように見えますが,v.size()は実はsize_typeという型で返るため,iの型であるsize_tと異なる型での比較が起こってしまいます.
ではsize_typeとはどういう型なのでしょうか?規格書には以下のように記載されています.

size_­type can represent any non-negative value of difference_­type
https://timsong-cpp.github.io/cppwp/n4861/container.requirements.general#tab:container.req

非負整数であることは保証されていますが,サイズはコンテナ依存で規定されていません.そのため,やはり前述したfor文による走査は環境依存で壊れうると言えます.

ただし,GCCでは基本的にはsize_tsize_typeは同一のものとしており,例えばvectorの実装に以下のような記述があります(該当部).

      typedef size_t					size_type;

GCCに限らずほとんどの場合でsize_tsize_typeは同じと言って良さそうなので,神経質でなければ前述のfor文も問題ないと思います.

size_tを正しく使う

size_tの仕様について理解したはずなので,正しい使い方を学んでいきましょう.

size_tを使わない

これが一番です.使わなければ何も問題は起きないので仕様を理解する必要もありません.for文でindexを加算していくなんてやめて拡張for文を使いましょう.

#include <iostream>
#include <vector>

int main() {
  std::vector<int> v{1, 2, 3, 4, 5};
  for (const auto& x : v) { std::cout << x << std::endl; }
}

どうしてもindexが欲しい場合はrangesを使うと良いです.

#include <iostream>
#include <ranges>
#include <vector>

int main() {
  std::vector<int> v{1, 2, 3, 4, 5};
  // C++20
  for (auto i : std::ranges::views::iota(0ull, v.size())) {
    std::cout << i << " " << v[i] << std::endl;
  }
  // C++23以降
  for (const auto& [i, x] :
       std::ranges::zip_view(std::ranges::views::iota(0ull, v.size()), v)) {
    std::cout << i << " " << x << std::endl;
  }
}

明示的に型を合わせる

どうしてもsize_tを使いたい場合は,環境依存にならないように静的に型キャストしたり型を取得したりして明示的に合わせましょう.

#include <iostream>
#include <type_traits>
#include <vector>

int main() {
  std::vector<int> v{1, 2, 3, 4, 5};

  using T = std::common_type<size_t, decltype(v)::size_type>::type;
  for (T i = 0; i < static_cast<T>(v.size()); ++i) {
    std::cout << v[i] << std::endl;
  }

  for (decltype(v.size()) i = 0; i < v.size(); ++i) {
    std::cout << v[i] << std::endl;
  }
}

なお,間違っても次のようなコードを書いてはいけません.size_t(size_type)はintと同じサイズではなく,多くの場合でintよりも大きいです.コンテナサイズも同様にintより大きくなりうるので,このコードではコンテナサイズがintで表現できるサイズを超えた場合に無限ループします.

#include <iostream>
#include <vector>

int main() {
  std::vector<int> v{1, 2, 3, 4, 5};
  for (int i = 0; i < v.size(); ++i) { std::cout << v[i] << std::endl; }
  for (unsigned int i = 0; i < v.size(); ++i) { std::cout << v[i] << std::endl; }
}

GCCを信じる

前述したようにGCCではsize_tsize_typeは同じと言って良いので,明示的にキャストする必要はないです.ただし,仕様で規定されているわけではないため何が起こっても私は責任はとれません.

#include <iostream>
#include <vector>

int main() {
  std::vector<int> v{1, 2, 3, 4, 5};
  for (size_t i = 0; i < v.size(); ++i) { std::cout << v[i] << std::endl; }
}

おわりに

Rustなら全部解決なのになぁ...