C++ イテレーター入門:なぜ使うのか、どう使うのか

はい、承知いたしました。C++のイテレーターについての詳細な入門記事を約5000語で記述し、直接表示します。


C++ イテレーター入門:なぜ使うのか、どう使うのか

はじめに

C++言語は、高性能なシステム開発から大規模なアプリケーションまで、幅広い分野で利用されています。その強力さの一因として、C++標準ライブラリ(Standard Template Library, STL)の存在が挙げられます。STLは、データ構造(コンテナ)、アルゴリズム、およびそれらを結びつけるための重要な要素を提供しています。

STLが提供する主要なコンテナには、要素を動的に管理するstd::vector、双方向リンクリストとして機能するstd::list、ソートされた一意な要素を格納するstd::set、キーと値のペアを管理するstd::mapなど、様々な種類があります。これらのコンテナはそれぞれ異なる内部構造と特性を持っています。

さて、もしあなたがこれらの異なるコンテナに格納された要素にアクセスし、操作したいと考えた場合、どうすれば良いでしょうか? もしvectorlistmapそれぞれに対して、全く異なる専用のアクセス方法や操作方法が用意されていたとしたら、非常に非効率的で、学習コストも高く、コードの再利用性も低くなってしまいます。

ここで登場するのが、「イテレーター (Iterator)」です。イテレーターは、コンテナの要素を指し示し、要素間を移動するための抽象化された概念です。C++標準ライブラリでは、コンテナの種類にかかわらず、イテレーターを介して統一的な方法で要素にアクセス・操作することができます。これが、STLのコンテナとアルゴリズムが組み合わせて使える、その汎用性の基盤となっています。

本記事では、C++のイテレーターについて、その基本的な概念から、「なぜそれが必要なのか」、そして「具体的にどのように使うのか」までを、詳細な説明と豊富なコード例を交えながら徹底的に解説します。イテレーターはC++、特にSTLを使いこなす上で避けては通れない重要なトピックです。この記事を通じて、イテレーターへの深い理解を得ていただければ幸いです。

イテレーターとは何か?

イテレーターは、コンテナ内の要素を「指し示す」ためのオブジェクトです。そして、その指し示す対象を「次の要素」や「前の要素」といった形で移動させる手段を提供します。これにより、コンテナの要素を順番にたどる、いわゆる「走査(Traversal)」を行うことが可能になります。

最も身近な類推として、C言語やC++におけるポインターを考えてみましょう。ポインターはメモリ上の特定のアドレスを指し示し、*演算子でそのアドレスに格納されている値にアクセスし、++--演算子でメモリ上の隣接する位置に移動することができます。イテレーターは、このポインターの概念をより抽象的かつ汎用的なものに拡張したものです。

例えば、配列とポインターの関係を見てみましょう。

“`cpp

include

int main() {
int arr[] = {10, 20, 30, 40, 50};
int* ptr = arr; // 配列の最初の要素を指すポインター (arr は &arr[0] と同じ)

std::cout << *ptr << std::endl; // 10 (ポインターが指す要素の値を取得)
ptr++; // ポインターを次の要素(メモリ上の次の int の位置)に進める
std::cout << *ptr << std::endl; // 20
ptr += 2; // ポインターを2要素分進める
std::cout << *ptr << std::endl; // 40

return 0;

}
“`

イテレーターもこれとよく似た操作を提供します。コンテナの最初の要素を指すイテレーターを取得し、*演算子で要素にアクセスし、++演算子で次の要素へ移動する、という基本的なパターンは共通しています。

しかし、イテレーターとポインターには決定的な違いがあります。ポインターは「生のメモリ上のアドレス」を直接扱いますが、イテレーターはコンテナの「抽象的な要素の位置」を扱います。std::vectorのように要素がメモリ上で連続して配置されているコンテナの場合、イテレーターの実体は内部的にポインターそのものであることが多いです。しかし、std::listのように要素が不連続なメモリに配置され、ノードがポインターで互いを指し示すリンクリストとして管理されている場合、イテレーターは内部的に現在のノードを指すポインターなど、リスト構造をたどるために必要な情報を保持しています。イテレーターは、これらのコンテナ固有の内部構造に関する詳細をユーザーから隠蔽し、統一されたインターフェースを提供します。

イテレーターが提供する基本的な操作は以下の通りです。

  • *iterator: イテレーターが現在指している要素への参照を取得します。これにより、要素の読み取りや書き込みが可能です。
  • ++iterator: イテレーターを次の要素に進めます(前置インクリメント)。
  • iterator++: イテレーターを次の要素に進めます(後置インクリメント)。
  • iterator1 == iterator2: 2つのイテレーターが同じ要素を指しているかどうかを判定します。
  • iterator1 != iterator2: 2つのイテレーターが異なる要素を指しているかどうかを判定します。

これらの基本的な操作に加えて、イテレーターの種類によっては、--(前の要素へ戻る)、+, -, +=, -=(複数要素の移動)、<, >, <=, >=(位置の比較)、[](オフセット指定でのアクセス)などの操作も可能です。これらの操作がどのイテレーターで使えるかは、後述する「イテレーターカテゴリ」によって決まります。

なぜイテレーターを使うのか?

イテレーターを使うことには、C++プログラミング、特に標準ライブラリを活用する上で、以下のようないくつもの重要なメリットがあります。

1. 統一的なコンテナアクセス

C++標準ライブラリは、std::vector, std::list, std::deque, std::set, std::mapなど、様々な特性を持つコンテナを提供しています。これらのコンテナは、それぞれが最適な用途を持つ一方で、内部的には全く異なるデータ構造(動的配列、リンクリスト、バランス木など)で実装されています。

もしイテレーターという抽象化の層がなければ、これらのコンテナに含まれる要素を操作するためには、それぞれのコンテナの内部構造に合わせた専用のコードを書く必要が出てきます。例えば、vectorならインデックスアクセス (vec[i]) やポインター演算、listならノードポインターをたどる、mapなら木の構造をたどるといった具合です。これは、コンテナの種類を変更するたびに、要素にアクセス・走査する部分のコード全体を書き換えなければならないことを意味します。結果として、コードの記述量が膨大になり、再利用性や保守性が著しく低下します。

しかし、イテレーターを使うことで、これらのコンテナに対して、その内部構造を意識することなく、統一されたインターフェースで要素を操作できるようになります。ほとんどの標準コンテナは、コンテナの最初の要素を指すイテレーターを取得するためのbegin()メソッドと、最後の要素の「次」を指すイテレーターを取得するためのend()メソッドを提供しています。

以下のコード例を見てください。std::vectorstd::liststd::setという全く異なる3種類のコンテナに対して、要素を順に表示するためのループ構造が、イテレーターを使うことでほぼ同じになっていることがわかります。

“`cpp

include

include

include

include

include

int main() {
// std::vector の要素をイテレーターで走査
std::vector vec = {10, 20, 30, 40, 50};
std::cout << “vector elements: “;
for (auto it = vec.begin(); it != vec.end(); ++it) {
std::cout << *it << ” “;
}
std::cout << std::endl;

// std::list の要素をイテレーターで走査
std::list<std::string> lst = {"apple", "banana", "cherry"};
std::cout << "list elements: ";
for (auto it = lst.begin(); it != lst.end(); ++it) {
    std::cout << *it << " ";
}
std::cout << std::endl;

// std::set の要素をイテレーターで走査
std::set<double> s = {3.14, 1.414, 2.718};
std::cout << "set elements: ";
for (auto it = s.begin(); it != s.end(); ++it) {
    // setの要素はconstなので、*it は const double& になる
    std::cout << *it << " ";
}
std::cout << std::endl;

return 0;

}
“`

この例のように、for (auto it = container.begin(); it != container.end(); ++it) というイテレーターを使った基本的なループ構造は、コンテナの種類が変わってもほとんど変わりません。イテレーターのおかげで、コンテナの内部実装の詳細に煩わされることなく、高レベルで統一的な要素アクセスが可能になります。

2. 標準アルゴリズムとの連携

C++標準ライブラリには、要素の検索 (std::find), ソート (std::sort), コピー (std::copy), 要素ごとの処理 (std::for_each), 要素の変換 (std::transform) など、コンテナに含まれる要素に対して様々な一般的な処理を行うための汎用的なアルゴリズムが多数用意されています。これらのアルゴリズムの多くは、操作の対象となる要素の範囲を、開始位置と終了位置を示す2つのイテレーターのペアで指定します。

アルゴリズムがイテレーターを介してコンテナにアクセスすることにより、アルゴリズム自体は特定のコンテナ型に依存しない汎用的な実装が可能になります。つまり、「このアルゴリズムはvector専用」「あのアルゴリズムはlist専用」ではなく、「このアルゴリズムは、操作に必要なイテレーターの能力を提供するどんなコンテナにも使える」という設計になっています。

例えば、std::sortアルゴリズムは、要素を効率的にランダムアクセスできる(つまり、ランダムアクセスイテレーターを提供する)コンテナに対して適用できます。そのため、std::vectorstd::dequestd::sortで直接ソートできますが、std::listのように要素が連続しておらずランダムアクセスが非効率なコンテナは、std::sortを直接適用できません(listは独自のsort()メンバ関数を持っています)。一方、std::findアルゴリズムは、要素を先頭から順にたどるだけでよく、要素の読み取りと次の要素への移動、そして等価性の比較ができれば機能します。これはイテレーターカテゴリでいうところの「入力イテレーター」の能力があれば十分です。したがって、std::vectorstd::liststd::setなど、入力イテレーター以上の能力を持つイテレーターを提供する全てのコンテナに対してstd::findを使用できます。

“`cpp

include

include

include // std::sort, std::find, std::for_each

include

int main() {
std::vector vec = {50, 20, 40, 10, 30};

// std::sort はイテレーターのペアを受け取る(Random Access Iterator が必要)
std::sort(vec.begin(), vec.end()); // vectorはRandom Access Iteratorを提供

std::cout << "Sorted vector: ";
for (auto it = vec.begin(); it != vec.end(); ++it) {
    std::cout << *it << " ";
}
std::cout << std::endl;

// std::find もイテレーターのペアと値を検索する(Input Iterator があれば使える)
auto find_it = std::find(vec.begin(), vec.end(), 30);
if (find_it != vec.end()) {
    std::cout << "Found 30 at position (distance from begin): "
              << std::distance(vec.begin(), find_it) << std::endl;
    // std::distance は Random Access Iterator なら高速、そうでなければイテレーターを進めて数を数える
} else {
    std::cout << "30 not found." << std::endl;
}

// std::for_each はイテレーターのペアと関数オブジェクトを受け取る(Input Iterator があれば使える)
std::cout << "Elements using for_each: ";
std::for_each(vec.begin(), vec.end(), [](int value) {
    std::cout << value << " ";
});
std::cout << std::endl;

std::list<int> lst = {50, 20, 40, 10, 30};
// std::sort(lst.begin(), lst.end()); // これはコンパイルエラーまたは実行時エラーになる(listのイテレーターはRandom Accessではないため)
lst.sort(); // list::sort() メンバ関数を使う

std::cout << "Sorted list: ";
 // std::find や std::for_each は list のイテレーター(Bidirectional Iterator)でも使える
std::for_each(lst.begin(), lst.end(), [](int value) {
    std::cout << value << " ";
});
std::cout << std::endl;

return 0;

}
“`
このように、イテレーターはC++標準ライブラリのコンテナとアルゴリズムの連携において、非常に中心的な役割を担っています。イテレーターを理解することで、これらの強力なツールを最大限に活用できるようになります。

3. 抽象化とカプセル化

イテレーターは、コンテナの内部実装の詳細をユーザーから隠蔽(カプセル化)します。ユーザーはイテレーターが提供する抽象的なインターフェース(*, ++, ==など)のみを使って要素を操作すればよく、コンテナが動的配列なのか、リンクリストなのか、あるいは木構造なのかといった具体的なデータ構造を知る必要はありません。

この抽象化により、コードの記述がより高レベルで行えるようになり、コンテナの実装変更がコード全体に与える影響を最小限に抑えることができます。例えば、最初はvectorを使っていたが、パフォーマンス要件から途中でlistに変更することになった場合でも、イテレーターを使って要素を走査・操作している部分のコードは、ほとんど変更せずに済む可能性があります(ただし、使用しているイテレーター操作が変更後のコンテナのイテレーターカテゴリでサポートされている範囲に限ります)。

ポインターを使った生データへの直接的なアクセスと比較すると、イテレーターはより安全な抽象化を提供します。例えば、イテレーターは通常、コンテナの境界を越えた不正なアクセスに対して、ある程度の防御機構を提供します(end()イテレーターを越えて++した場合の未定義動作などはありますが、ポインターで任意のアドレスにアクセスするよりも限定的です)。また、コンテナが提供する正規のイテレーターのみを使用することで、コンテナの整合性を保ちやすくなります。

4. 柔軟性 – イテレーターカテゴリ

すべてのコンテナが全く同じ能力を持つイテレーターを提供するわけではありません。コンテナの内部構造によっては、特定の操作が効率的に行えなかったり、あるいはそもそも構造的に不可能だったりします。例えば、双方向リンクリストであるstd::listでは、要素がメモリ上に連続して配置されていないため、「最初の要素から数えてN番目の要素に直接ジャンプする」(ランダムアクセス)といった操作は、先頭からN個要素をたどる必要があり、要素数Nに比例する時間(O(N))がかかります。一方、動的配列であるstd::vectorでは、要素が連続しているため、この操作は一定時間(O(1))で行えます。

イテレーターは、その提供する機能のレベルに応じて「イテレーターカテゴリ」に分類されます。これにより、アルゴリズムは自身が必要とする最小限のイテレーター能力を持つコンテナに対してのみ適用されるように設計されています。例えば、std::sortは要素の比較と位置の交換を効率的に行うためにランダムアクセスが必要なので、Random Access Iteratorを要求します。std::listはBidirectional Iteratorしか提供しないため、std::sortは適用できません。このカテゴリ分けによる柔軟性があるからこそ、C++標準ライブラリは多様なコンテナとアルゴリズムを提供し、それらを組み合わせて利用できるのです。

どうやってイテレーターを使うのか?

イテレーターの使い方は、主に以下のステップに分けられます。これは、古典的なC++03スタイルのイテレーターを使ったループの基本的なパターンです。

  1. イテレーターの取得: 走査を開始したい位置を指すイテレーターを取得します。通常はコンテナの最初の要素を指すイテレーター(begin()が返すもの)から始めます。
  2. 終了条件の判定: 走査を終了するかどうかを判定します。通常は、現在のイテレーターがコンテナの終端を示すイテレーター(end()が返すもの)と等しくなったかで判定します。
  3. 要素へのアクセス: 現在のイテレーターが指している要素の値を取得したり、必要であれば変更したりします。
  4. イテレーターの移動: 次の要素や必要に応じて前の要素へ、イテレーターを移動させます。

これらのステップを具体的なコードで見ていきましょう。

1. イテレーターの取得

ほとんどの標準コンテナは、以下のメンバ関数を提供しています。

  • iterator begin();: コンテナの最初の要素を指すイテレーターを返します。コンテナが空の場合、begin()end()と同じイテレーターを返します。
  • const_iterator begin() const;: constコンテナの場合や、constメンバ関数から呼び出された場合に、最初の要素を指すconst_iteratorを返します。
  • iterator end();: コンテナの最後の要素の「次」を指すイテレーターを返します。このイテレーターは、コンテナ内の有効な要素を指してはいません。走査ループの終了条件として使用されます。
  • const_iterator end() const;: constコンテナの場合や、constメンバ関数から呼び出された場合に、終端を示すconst_iteratorを返します。

C++11以降では、これらの他に以下のメンバ関数も利用可能です。

  • const_iterator cbegin() const;: コンテナの最初の要素を指すconst_iteratorを返します。コンテナがconstでなくても利用でき、常にconst_iteratorを返します。
  • const_iterator cend() const;: コンテナの終端を示すconst_iteratorを返します。コンテナがconstでなくても利用でき、常にconst_iteratorを返します。

イテレーターの具体的な型名は、コンテナの種類と格納している要素の型によって決まります。例えば、std::vector<int>の通常のイテレーター型はstd::vector<int>::iteratorconst_iterator型はstd::vector<int>::const_iteratorです。しかし、C++11以降ではautoキーワードを使うことで、これらの冗長な型名を省略して記述できます。これは非常に便利で、現代のC++プログラミングでは広く推奨されています。

“`cpp

include

include

include

include

int main() {
std::vector vec = {1, 2, 3};
auto vec_it = vec.begin(); // vec が non-const なので、std::vector::iterator 型

std::list<std::string> lst = {"a", "b"};
auto lst_it = lst.begin(); // lst が non-const なので、std::list<std::string>::iterator 型

std::map<char, int> mp = {{'x', 100}};
auto map_it = mp.begin(); // mp が non-const なので、std::map<char, int>::iterator 型

// const コンテナや const 参照から取得する場合は const_iterator になる
const std::vector<int>& const_vec = vec;
auto const_vec_it = const_vec.begin(); // std::vector<int>::const_iterator 型

// C++11以降では cbegin(), cend() も利用可能。常に const_iterator を返す。
auto cbegin_it = vec.cbegin(); // std::vector<int>::const_iterator 型 (vec は non-const でも)

return 0;

}
“`

要素を読み取るだけで変更しない場合は、安全のためにconst_iteratorを使用するか、cbegin()/cend()を使うことが推奨されます。

2. イテレーターの操作

取得したイテレーターに対して、要素アクセスや移動などの操作を行います。繰り返しになりますが、使用できる操作はイテレーターの「カテゴリ」によって異なります。

  • 要素へのアクセス (*it):
    イテレーターが指す要素への参照を取得します。これにより、要素の値を読み取ったり、非const_iteratorであれば値を変更したりできます。

    “`cpp

    include

    include

    int main() {
    std::vector vec = {10, 20, 30};
    auto it = vec.begin(); // 10 を指すイテレーターを取得

    std::cout << *it << std::endl; // 出力: 10 (イテレーターが指す要素の値を取得)
    
    *it = 100; // イテレーターが指す要素の値を変更
    std::cout << *it << std::endl; // 出力: 100
    std::cout << vec[0] << std::endl; // vec[0] も 100 になっている
    
    return 0;
    

    }
    ``mapsetのように、要素がキーと値のペアや、要素自体が不変であるコンテナの場合、*itで得られる参照の型に注意が必要です。例えば、std::mapのイテレーターは、std::pair型の要素への参照を返します。キー (first) は変更できませんが、値 (second`) は変更可能です(要素のキー自体を変更することは、その要素のマップ内の位置が変わることを意味するため、マップの構造を壊す可能性があります)。

    “`cpp

    include

    include

    include

    int main() {
    std::map ages = {{“Alice”, 30}, {“Bob”, 25}};
    auto it = ages.begin(); // {“Alice”, 30} を指すイテレーターを取得

    // マップの要素は pair<const Key, Value> なので、it は pair<const string, int> を指す
    std::cout << it->first << ": " << it->second << std::endl; // 出力: Alice: 30
    
    // it->first = "Alicia"; // エラー: mapのキーは変更できない (const)
    it->second = 31; // 値は変更できる
    std::cout << it->first << ": " << it->second << std::endl; // 出力: Alice: 31
    
    return 0;
    

    }
    ``->演算子は、イテレーターが指す要素がクラスや構造体である場合に、そのメンバーにアクセスするために使用できます。これは実際には(*it).memberの糖衣構文です。std::mapstd::set`のイテレーターが指す要素はペアや要素そのものであり、それらが構造体のように扱えるためこの演算子が使えます。

  • 次の要素への移動 (++it, it++):
    イテレーターを次の要素に進めます。ほとんどのイテレーターカテゴリ(Input, Forward, Bidirectional, Random Access)で利用可能です。前置インクリメント (++it) の方が、後置インクリメント (it++) よりも一般的に効率が良いとされています。特にポインター以外のイテレーター(オブジェクトとして実装されているイテレーター)の場合、後置インクリメントは現在のイテレーターの値を保持するため、元のイテレーターのコピーを作成する必要があるためです。特別な理由がない限り、前置インクリメントを使用することが推奨されます。

    “`cpp

    include

    include

    int main() {
    std::vector vec = {10, 20, 30};
    auto it = vec.begin(); // 10 を指す

    ++it; // イテレーターを次の要素(20)に進める
    std::cout << *it << std::endl; // 出力: 20
    
    it++; // イテレーターを次の要素(30)に進める
    std::cout << *it << std::endl; // 出力: 30
    
    return 0;
    

    }
    “`

  • 前の要素への移動 (--it, it--):
    イテレーターを前の要素に戻します。これは双方向イテレーター以上のカテゴリ(Bidirectional, Random Access)でのみ利用可能です (std::vector, std::list, std::map, std::set など)。入力イテレーターや前方イテレーター (std::istream_iterator, std::forward_list など) では使用できません。

    “`cpp

    include

    include

    int main() {
    std::vector vec = {10, 20, 30};
    auto it = vec.end(); // end() を指す (最後の要素 30 の「次」)

    --it; // イテレーターを前の要素(30)に戻す
    std::cout << *it << std::endl; // 出力: 30
    
    it--; // イテレーターを前の要素(20)に戻す
    std::cout << *it << std::endl; // 出力: 20
    
    return 0;
    

    }
    ``end()イテレーターは有効な要素を指しませんが、Bidirectional Iterator以上のカテゴリでは、演算子で最後の有効な要素を指すイテレーターにすることができます。ただし、コンテナが空の場合(begin() == end()の場合)、end()イテレーターに対して–`を行うと未定義動作になりますので注意が必要です。

  • 複数要素の移動 (it += n, it -= n):
    イテレーターを指定された要素数 n だけ進めたり (+=)、戻したり (-=) します。これはランダムアクセスイテレーターでのみ利用可能です (std::vector, std::deque, std::array, 生のポインター)。この操作はO(1)で実行されます。std::liststd::mapなどのBidirectional Iteratorではこの操作は使用できません(これらのコンテナで複数要素移動するには、ループで++--を繰り返すか、std::advanceアルゴリズムを使う必要があります)。

    “`cpp

    include

    include

    int main() {
    std::vector vec = {10, 20, 30, 40, 50};
    auto it = vec.begin(); // 10 を指す

    it += 2; // イテレーターを2要素分進める -> 30 を指す
    std::cout << *it << std::endl; // 出力: 30
    
    it -= 1; // イテレーターを1要素分戻す -> 20 を指す
    std::cout << *it << std::endl; // 出力: 20
    
    return 0;
    

    }
    “`

  • オフセットでのアクセス (it[n]):
    イテレーター it が指す位置から指定されたオフセット n にある要素にアクセスします。これもランダムアクセスイテレーターでのみ利用可能です。*(it + n) と同じ意味になります。

    “`cpp

    include

    include

    int main() {
    std::vector vec = {10, 20, 30, 40, 50};
    auto it = vec.begin(); // 10 を指す

    // it[2] は it + 2 が指す要素 (30) にアクセス
    std::cout << it[2] << std::endl; // 出力: 30
    
    // it[4] は it + 4 が指す要素 (50) にアクセス
    std::cout << it[4] << std::endl; // 出力: 50
    
    return 0;
    

    }
    “`

  • イテレーター間の距離 (it2 - it1):
    同じコンテナの2つのイテレーター it1it2 の間の要素数を取得します(it2it1 より後にある場合)。これはランダムアクセスイテレーターでのみ利用可能です。結果は符号付き整数型(通常はstd::iterator_traits<Iterator>::difference_type、多くの場合はptrdiff_t)になります。

    “`cpp

    include

    include

    include // std::distance

    int main() {
    std::vector vec = {10, 20, 30, 40, 50};
    auto it1 = vec.begin(); // 10 を指す
    auto it2 = vec.begin() + 3; // 40 を指す
    auto it_end = vec.end(); // end() を指す

    // イテレーター間の距離を計算 (Random Access Iterator の operator- を利用)
    std::cout << "Distance from it1 to it2: " << (it2 - it1) << std::endl; // 出力: 3
    std::cout << "Distance from begin to end: " << (it_end - vec.begin()) << std::endl; // 出力: 5 (要素数)
    
    // Random Access Iterator 以外のカテゴリの場合、std::distance アルゴリズムを使う
    // std::distance(it1, it2) は内部で it1 を it2 まで ++ してカウントする
    std::list<int> lst = {10, 20, 30, 40, 50};
    auto lst_it1 = lst.begin();
    auto lst_it2 = lst.begin();
    std::advance(lst_it2, 3); // list は operator+ がないので std::advance で進める
    
    std::cout << "Distance in list: " << std::distance(lst_it1, lst_it2) << std::endl; // 出力: 3
    
    return 0;
    

    }
    “`

  • イテレーターの位置比較 (==, !=, <, >, <=, >=):
    2つのイテレーターが同じ要素(または終端位置)を指しているか、あるいは相対的な位置関係を比較します。==!=はすべてのイテレーターカテゴリで利用可能ですが、入力イテレーターと出力イテレーターの比較は制限があり、一度通過したイテレーターとの比較は保証されない場合があります。<, >, <=, >=といった順序比較は、要素の位置が連続的で明確な順序を持つランダムアクセスイテレーターでのみ利用可能です。

    “`cpp

    include

    include

    int main() {
    std::vector vec = {10, 20, 30};
    auto it1 = vec.begin(); // 10 を指す
    auto it2 = vec.begin() + 1; // 20 を指す
    auto it_end = vec.end(); // end() を指す

    if (it1 == vec.begin()) {
        std::cout << "it1 points to the beginning." << std::endl;
    }
    
    if (it2 != it_end) { // 終端チェックとして最も一般的
        std::cout << "it2 is not the end iterator." << std::endl;
    }
    
    // Random Access Iterator なら位置の比較も可能
    if (it1 < it2) {
        std::cout << "it1 is before it2." << std::endl;
    }
    
    return 0;
    

    }
    “`

3. イテレーターカテゴリ (Iterator Categories)

イテレーターは、提供する操作や機能のレベルに応じて、以下の5つの主要なカテゴリに分類されます(C++11以降)。これは能力の高い順に並んでいます。下位カテゴリの機能は上位カテゴリに含まれます。

  1. Input Iterator (入力イテレーター)

    • コンテナから要素を一度だけ順方向 (++) に読み取ることができます。
    • 複数回走査したり、一度通過した要素に戻ったりすることは保証されません。
    • 要素の比較 (==, !=) が可能ですが、特定のイテレーター(コピーや過去のイテレーター)との比較は限定的です。
    • サポートされる操作: * (読み取り専用), ++ (前置・後置), ==, !=
    • 用途例: ストリームからの読み込み (std::istream_iterator)、一度きりのデータソースの読み込み
    • 提供コンテナ: 基本的な走査を行うアルゴリズム(std::find, std::for_eachなど)が必要とする最小要件。
  2. Output Iterator (出力イテレーター)

    • コンテナに要素を一度だけ順方向 (++) に書き込むことができます。
    • 一度書き込んだ要素を読み取ったり、書き込み済みの位置に戻ったりすることは保証されません。
    • サポートされる操作: * (書き込み専用), ++ (前置・後置)
    • 用途例: ストリームへの書き込み (std::ostream_iterator)、一度きりのデータシンクへの書き込み
    • 提供コンテナ: 基本的な出力を行うアルゴリズム(std::copyの出力先、std::transformの出力先など)が必要とする最小要件。挿入イテレーター (std::back_inserterなど) もこのカテゴリ。
  3. Forward Iterator (前方イテレーター)

    • コンテナの要素を順方向 (++) に複数回走査できます。一度通過した要素に戻る機能はありません。
    • 読み取りと書き込みの両方が可能です(ただし、setmapのように要素がconstの場合は読み取り専用)。
    • サポートされる操作: * (読み書き可能), ++ (前置・後置), ==, !=
    • 提供コンテナ: std::forward_list
  4. Bidirectional Iterator (双方向イテレーター)

    • コンテナの要素を順方向 (++) および逆方向 (--) の両方に移動できます。複数回走査可能です。
    • サポートされる操作: * (読み書き可能), ++, -- (前置・後置), ==, !=
    • 提供コンテナ: std::list, std::map, std::set, std::multimap, std::multiset
  5. Random Access Iterator (ランダムアクセスイテレーター)

    • コンテナ内の任意の位置へO(1)の時間でジャンプできます。ポインターとほぼ同等の機能を提供します。
    • サポートされる操作: 上記全ての操作に加えて、+, -, +=, -=, [], <, >, <=, >=
    • 提供コンテナ: std::vector, std::deque, std::array, 生のポインター

標準アルゴリズムは、自身が必要とする最小のイテレーターカテゴリを要求するように設計されています。例えば、std::sortは要素を比較して並べ替えるために任意の位置へのアクセスと要素間の距離計算が必要なので、Random Access Iteratorを要求します。std::copyは要素を順に読み取って順に書き出すだけで十分なので、Input IteratorとOutput Iteratorを要求します。std::reverseは要素を逆順にたどる必要があるため、Bidirectional Iteratorを要求します。

イテレーターカテゴリを理解することは、特定のアルゴリズムがなぜ特定のコンテナに適用できるのか(あるいはできないのか)を理解する上で非常に重要です。

4. コード例による具体的な使用方法

様々なコンテナとイテレーターを使ったコード例をもう少し見てみましょう。

例1: std::vector の要素を範囲ベースforループとイテレーターで比較

最も一般的なコンテナであるstd::vectorを使った走査方法を比較します。

“`cpp

include

include

int main() {
std::vector vec = {10, 20, 30, 40, 50};

std::cout << "Using range-based for loop:" << std::endl;
// 読み取り専用走査ならこれが最も簡潔
for (int x : vec) {
    std::cout << x << " ";
}
std::cout << std::endl;

std::cout << "Using range-based for loop with reference (for modification):" << std::endl;
// 要素を変更する場合は参照を使う
for (int& x : vec) {
    x += 1; // 各要素に1を加える
}
for (int x : vec) {
    std::cout << x << " "; // 出力: 11 21 31 41 51
}
std::cout << std::endl;


std::cout << "Using classical iterator loop (read only):" << std::endl;
// イテレーターを使った古典的なループ (読み取り専用、const_iterator)
for (auto it = vec.cbegin(); it != vec.cend(); ++it) {
    std::cout << *it << " ";
}
std::cout << std::endl;

std::cout << "Using classical iterator loop (for modification):" << std::endl;
// イテレーターを使った古典的なループ (変更可能、iterator)
for (auto it = vec.begin(); it != vec.end(); ++it) {
    *it -= 1; // 各要素から1を引く (元の値に戻す)
    std::cout << *it << " ";
}
std::cout << std::endl;

return 0;

}
“`
範囲ベースforループは内部でイテレーターを使っていますが、単純な要素の走査(特に最初から最後まで)においては、記述が非常に簡潔になるため推奨されます。しかし、特定の条件でループを中断したり、要素を削除/挿入したり、逆順に走査したりといった複雑な操作が必要な場合は、イテレーターを直接使う必要があります。

例2: std::list でイテレーターを使って要素を削除する

std::listは双方向イテレーターを提供し、要素の挿入や削除が高速です。ただし、eraseメンバ関数を使用すると、削除された要素を指していたイテレーターは無効になります。eraseは削除された要素の次の要素を指す有効なイテレーターを返すので、ループ内で要素を削除しながら走査する場合はその戻り値を利用するのが安全かつ効率的です。

“`cpp

include

include

include

int main() {
std::list lst = {“apple”, “banana”, “cherry”, “date”, “banana”, “elderberry”};

std::cout << "Original list: ";
for (const auto& s : lst) {
    std::cout << s << " ";
}
std::cout << std::endl;

// 要素 "banana" をリストから全て削除する
// ループ内の要素削除では、erase()の戻り値を利用してイテレーターを更新することが重要
for (auto it = lst.begin(); it != lst.end(); /* ++it は erase() の戻り値で*/) {
    if (*it == "banana") {
        // erase() は削除された要素を指すイテレーターを無効化し、
        // 削除された要素の次を指すイテレーターを返す
        it = lst.erase(it);
        // 次のループのイテレーションでは、返された有効なイテレーターから開始
    } else {
        // 要素を削除しなかった場合は、手動でイテレーターを次へ進める
        ++it;
    }
}

std::cout << "List after erasing 'banana': ";
for (const auto& s : lst) {
    std::cout << s << " ";
}
std::cout << std::endl;

return 0;

}
``std::vectorで同じようにループ内で要素を削除する場合も、vector::eraseが削除した要素の次を指すイテレーターを返すため、同様のパターンで記述できます。ただし、vectoreraseは後続の要素を全て前に移動させるため、要素数が多い場合や削除頻度が高い場合はlist`よりも低速になる可能性があります。

例3: std::map の要素へのアクセスと削除

std::mapのイテレーターは、キーと値のペア (std::pair<const Key, Value>) を指します。->firstでキーに、->secondで値にアクセスできます。マップもイテレーターの無効化ルールに注意が必要ですが、要素の削除は削除した要素のイテレーターのみを無効にし、他のイテレーターは有効なままです。map::eraseメンバ関数も削除した要素の次を指すイテレーターを返します。

“`cpp

include

include

include

int main() {
std::map ages = {
{“Alice”, 30},
{“Bob”, 25},
{“Charlie”, 35},
{“David”, 28}
};

std::cout << "Original map elements:" << std::endl;
// mapの要素を走査 (キーでソートされた順序で取得される)
for (auto it = ages.begin(); it != ages.end(); ++it) {
    // it->first がキー (const string&)、it->second が値 (int&)
    std::cout << it->first << ": " << it->second << std::endl;
}

// 年齢が 30 以上の要素を削除する
for (auto it = ages.begin(); it != ages.end(); /* ++it は erase() の戻り値で*/) {
    if (it->second >= 30) {
        std::cout << "Erasing: " << it->first << std::endl;
        it = ages.erase(it); // 要素を削除し、次のイテレーターを取得
    } else {
        ++it; // 条件に合わない場合は次へ進む
    }
}

std::cout << "\nMap elements after erasing older than 30:" << std::endl;
for (const auto& pair : ages) { // 範囲ベースforループで表示
    std::cout << pair.first << ": " << pair.second << std::endl;
}

return 0;

}
“`

5. 注意点と落とし穴

イテレーターを使う上で、特に注意が必要な点をいくつか挙げます。

  • イテレーターの無効化 (Iterator Invalidation)
    コンテナに対して、要素の挿入、削除、あるいはメモリ再配置(vectordequeのリサイズなど)を伴う操作を行うと、そのコンテナを指していたイテレーター、参照、ポインターが無効になることがあります。無効になったイテレーターを使用すると、未定義動作 (Undefined Behavior) となり、プログラムがクラッシュしたり、予期しない動作を引き起こしたりする可能性が非常に高いです。

    イテレーターの無効化ルールはコンテナの種類によって異なります。これはイテレーターを使う上で最も重要な注意点の一つです。

    • std::vector:

      • 末尾以外の位置での挿入や削除は、その位置以降の全てのイテレーター、参照、ポインターを無効にします。
      • 末尾への挿入 (push_back) でメモリ再配置が発生した場合、全てのイテレーター、参照、ポインターが無効になります。再配置が発生しない場合は、end()イテレーターのみが無効になります。
      • 末尾からの削除 (pop_back) は、end()イテレーターと削除された要素へのイテレーター/参照/ポインターのみを無効にします。
      • erase() メンバ関数は、削除された要素とその後の要素を指していたイテレーター、参照、ポインターを無効にしますが、削除された要素の次を指す有効なイテレーターを返します。ループで連続して要素を削除する場合は、この戻り値を利用する必要があります(前述の例参照)。
    • std::deque:

      • 末尾以外の位置での挿入や削除は、その位置以降の全てのイテレーター、参照、ポインターを無効にします。
      • 先頭または末尾での挿入/削除は、他のイテレーター、参照、ポインターを無効にしません(end()イテレーターは無効になる可能性があります)。
    • std::list, std::forward_list:

      • 要素の挿入や削除は、基本的に挿入/削除された要素に関係するイテレーターのみを無効化します。他のイテレーターは通常有効なままです。
      • erase() メンバ関数は、削除された要素の次を指す有効なイテレーターを返します。
    • std::map, std::set, std::multimap, std::multiset (連想コンテナ):

      • 要素の挿入は、既存のどのイテレーターも無効化しません。
      • 要素の削除は、削除された要素を指していたイテレーターのみを無効化します。他のイテレーターは有効なままです。
      • erase() メンバ関数は、削除された要素の次を指す有効なイテレーターを返します。

    イテレーターの無効化は、特にループ内でコンテナを変更する際に非常に注意が必要です。コンテナの変更操作を行う際は、必ずその操作がどのイテレーターを無効化するかを確認し、無効になったイテレーターを再利用しないようにコードを記述する必要があります。特にvectorのようなコンテナでループ内で要素を削除する場合は、eraseの戻り値を適切に利用するパターンを必ず守ってください。

  • end() イテレーターは「番兵」である:
    container.end()が返すイテレーターは、コンテナの最後の要素を指すのではなく、最後の要素の「次」の仮想的な位置を指します。これは、コンテナの全ての要素を走査し終えた状態を表すための「番兵(sentinel)」として機能します。したがって、end()イテレーターに対して*演算子を使って要素にアクセスしようとすると、コンテナの範囲外アクセスとなり、未定義動作になります。イテレーターを使った標準的なループは、it != container.end()という条件で終了することが極めて重要です。

  • iteratorconst_iterator の使い分け:
    コンテナのbegin()end()メソッドは、コンテナがconstオブジェクトであるか、あるいはcbegin()cend()といったconstバージョンを呼び出した場合に、要素の変更ができないconst_iteratorを返します。要素を読み取るだけで変更しない場合は、安全性の観点からconst_iteratorを使用することが推奨されます。これにより、誤って要素を変更してしまうことを防ぐことができます。

    “`cpp

    include

    include

    int main() {
    std::vector vec = {10, 20, 30};

    // const_iterator を使う例 (cbegin(), cend() は常に const_iterator を返す)
    for (auto it = vec.cbegin(); it != vec.cend(); ++it) {
        std::cout << *it << " ";
        // *it = 100; // エラー: const_iterator は要素を変更できない
    }
    std::cout << std::endl;
    
    // iterator を使う例 (要素の変更が可能)
    for (auto it = vec.begin(); it != vec.end(); ++it) {
        *it *= 2;
    }
    for (int x : vec) {
        std::cout << x << " "; // 出力: 20 40 60
    }
    std::cout << std::endl;
    
    return 0;
    

    }
    “`

  • イテレーターとポインターの違いを理解する:
    イテレーターはポインターと似た操作を提供しますが、あくまで抽象化された概念です。ポインターのように任意のアドレスを指すことはできませんし、ポインター演算子(+, -, []など)が使えるのはランダムアクセスイテレーターに限られます。また、イテレーターはそれが取得された特定のコンテナインスタンスの内部構造に依存しています。あるコンテナインスタンスから取得したイテレーターを、別のコンテナインスタンス(たとえ同じ型であっても)に対して使用することはできません。イテレーターは、対応するコンテナの生存期間内にのみ有効です。

イテレーターアダプターとその他の関連概念

C++標準ライブラリには、イテレーターの振る舞いを変更したり、特定の目的に特化したイテレーターを提供する「イテレーターアダプター」や関連する概念も存在します。これらを活用することで、より柔軟で表現力豊かなコードを書くことができます。いくつか簡単に紹介します。

  • Reverse Iterator (リバースイテレーター):
    コンテナを逆順に走査するためのイテレーターです。std::vector, std::deque, std::list, std::map, std::setなど、Bidirectional Iterator以上のカテゴリを提供するコンテナは、rbegin()rend()メソッドを提供しており、これらからリバースイテレーターを取得できます。リバースイテレーターは、++演算子でコンテナの先頭方向へ移動し、--演算子でコンテナの末尾方向へ移動するという、通常のイテレーターとは逆の振る舞いをします。

    “`cpp

    include

    include

    include // std::reverse_iterator

    int main() {
    std::vector vec = {10, 20, 30, 40, 50};

    std::cout << "Using reverse iterators: ";
    // vec.rbegin() は 50 を指すリバースイテレーター
    // vec.rend() は 10 の「前」を指すリバースイテレーター
    for (auto it = vec.rbegin(); it != vec.rend(); ++it) {
        std::cout << *it << " "; // 出力: 50 40 30 20 10
    }
    std::cout << std::endl;
    return 0;
    

    }
    “`

  • Insert Iterator (挿入イテレーター):
    要素をコンテナに挿入する際に使用するイテレーターアダプターです。これらのイテレーターに値を代入すると、実際にはコンテナの対応するメンバ関数(push_back, push_front, insert)が呼び出され、要素がコンテナに追加されます。主にstd::copyなどのアルゴリズムの出力イテレーターとして使用されます。

    • std::back_inserter(container): コンテナの末尾に要素を挿入します(container.push_back(value)のように動作)。vector, list, dequeなどで使用可能。
    • std::front_inserter(container): コンテナの先頭に要素を挿入します(container.push_front(value)のように動作)。list, dequeなどで使用可能。
    • std::inserter(container, iter): 指定された位置 iter の前に要素を挿入します(container.insert(iter, value)のように動作)。全ての標準コンテナで使用可能。

    “`cpp

    include

    include

    include // std::copy

    include // std::back_inserter, std::inserter

    include

    int main() {
    std::vector vec = {1, 2, 3};
    std::list lst;
    std::vector vec2 = {10, 20, 30};

    // vec の要素を lst の末尾にコピー (back_inserter を使用)
    // back_inserter が返すイテレーターに代入すると list::push_back が呼ばれる
    std::copy(vec.begin(), vec.end(), std::back_inserter(lst));
    // lst は {1, 2, 3}
    
    // vec2 の要素を lst の先頭に挿入 (inserter を使用)
    // lst.begin() の位置に挿入するので、挿入された要素は先頭に追加される
    std::copy(vec2.begin(), vec2.end(), std::inserter(lst, lst.begin()));
    // lst は {10, 20, 30, 1, 2, 3}
    
    std::cout << "List elements after copy and insert: ";
    for (int x : lst) {
        std::cout << x << " ";
    }
    std::cout << std::endl;
    return 0;
    

    }
    “`

  • Stream Iterator (ストリームイテレーター):
    標準入出力ストリーム (std::cin, std::cout) やファイルストリームをイテレーターとして扱うためのものです。これにより、ストリームとコンテナの間で、標準アルゴリズムを使ってデータをやり取りできます。

    • std::istream_iterator<T>(stream): 入力ストリームから型 T の値を読み込む入力イテレーター。デフォルトコンストラクタで作成したイテレーターは終端を表します。
    • std::ostream_iterator<T>(stream, delimiter): 出力ストリームに型 T の値を書き込む出力イテレーター。delimiter文字列は各要素の後に書き出されます。

    “`cpp

    include

    include

    include // std::istream_iterator, std::ostream_iterator

    include // std::copy

    include // std::accumulate

    int main() {
    std::vector vec;

    std::cout << "Enter integers (Ctrl+D or Ctrl+Z to end):" << std::endl;
    // 標準入力から整数を読み込み、vector に追加
    // istream_iterator<int>(std::cin) は最初の要素を指す
    // istream_iterator<int>() は入力の終端を表すイテレーター
    std::copy(std::istream_iterator<int>(std::cin),
              std::istream_iterator<int>(),
              std::back_inserter(vec)); // vector の末尾に挿入
    
    std::cout << "You entered: ";
    // vector の要素を標準出力にスペース区切りで出力
    // ostream_iterator<int>(std::cout, " ") は標準出力にスペースを区切り文字として書き出す
    std::copy(vec.begin(),
              vec.end(),
              std::ostream_iterator<int>(std::cout, " "));
    std::cout << std::endl;
    
    // 入力された数値の合計を計算(イテレーターとアルゴリズムの組み合わせ)
    long long sum = std::accumulate(vec.begin(), vec.end(), 0LL);
    std::cout << "Sum: " << sum << std::endl;
    
    return 0;
    

    }
    “`

  • Range-based for loop (C++11以降):
    前述の通り、C++11で導入された範囲ベースforループ (for (element : range)) は、イテレーターを使った走査の糖衣構文です。これは、走査対象のオブジェクト (range) から begin()end() (あるいは、生の配列の場合は先頭と末尾のポインター)を取得し、!= で終端をチェックし、++ で次へ進むという、イテレーターを使った典型的なループパターンをコンパイラが自動的に展開してくれます。単純な走査には非常に便利ですが、イテレーターの全ての機能を提供するわけではないため、必要に応じてイテレーターを直接使用する必要があります。

まとめ

C++のイテレーターは、標準ライブラリのコンテナとアルゴリズムを繋ぐ、なくてはならない存在です。その重要性は、以下の点に集約されます。

  • 統一性: 異なる内部構造を持つ様々なコンテナに対して、共通のインターフェースで要素アクセスを可能にします。これにより、コードの記述量が減り、再利用性が向上します。
  • 汎用性: std::sortstd::findといった標準アルゴリズムを、コンテナの種類に依存せず適用できる基盤となります。アルゴリズムは、必要とするイテレーターの能力(カテゴリ)に基づいて設計されています。
  • 抽象化: コンテナの内部実装の詳細を隠蔽し、より高レベルで安全な要素操作を提供します。
  • 柔軟性: イテレーターカテゴリによって、コンテナの特性に応じた適切な操作セットを提供します。

イテレーターを使うことは、主に begin()end() で範囲を指定し、* で要素にアクセスし、++-- で移動し、!= で終了を判定するというパターンに従います。使用できる操作は、イテレーターのカテゴリ(Input, Output, Forward, Bidirectional, Random Access)によって異なります。

イテレーターを扱う上で最も注意すべき点は、コンテナの変更によるイテレーターの無効化です。特にループ内での要素の挿入や削除は、無効化ルールを正しく理解し、eraseメンバ関数の戻り値を適切に利用するなどの対策を講じないと、容易に未定義動作を引き起こします。また、end()イテレーターが決して有効な要素を指さないこと、読み取り専用アクセスにはconst_iteratorを使うことなども重要な注意点です。

範囲ベースforループは、多くの単純な走査においてイテレーターを直接使うよりも簡潔な記述を提供しますが、イテレーターを直接使うことで、より複雑な操作や標準アルゴリズムとの連携が可能になります。

イテレーターはC++プログラミング、特にSTLを効果的に利用する上で不可欠なツールです。最初は概念や無効化ルールが難しく感じられるかもしれませんが、実際に様々なコンテナやアルゴリズムを使いながら慣れていくことで、その強力さと便利さを実感できるはずです。

この記事が、あなたがC++のイテレーターの世界への第一歩を踏み出す助けとなれば幸いです。


コメントする

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

上部へスクロール