C++98でmapを再実装する

42Tokyo の課題でSTLコンテナの1つ、mapを再実装しました。

この記事では、mapの再実装で学んだことを書きます。

課題の概要、コンテナ・イテレータアルゴリズムの基礎的知識について説明した後、mapの実装について説明します。

課題の概要

  • 規格
    • C++98。後の規格で非推奨になっても実装が必要。
  • 実装する内容
    • STLコンテナ:vector、map、stack(+ set)
      • カッコ内のコンテナを実装するとクリア時にボーナスが付きます。
      • イテレータも実装が必要になります。
    • STLコンテナ以外:iterators_traits、reverse_iterator、equal、pairなど
      • この記事ではSTLコンテナ以外の詳細な実装は取り上げません。
  • リファレンス

基礎知識

C++では以下の機能を使ってデータの扱いを共通化しています。

コンテナ

コンテナは動的配列、連結リスト、辞書などのデータ構造を使ってオブジェクトを保持します。

コンテナには次の分類があります。

  • シーケンスコンテナ
    • 配列のような直線的なデータ構造。例:list、vector
  • 連想コンテナ
    • キーに基づいて探索可能なデータ構造。例:map、set
  • コンテナアダプタ
    • 他のコンテナを元にインターフェースを提供。例:stack

今回実装するのは連想コンテナの1つ、mapです。

mapはkeyとvalueのpairを持ち、keyからvalueを取り出すことができます。

イテレータ

コンテナの各要素には添字演算子イテレータを使ってアクセスします。

イテレータがあることで、コンテナを使う側はコンテナの内部実装を知ることなくコンテナの各要素を操作できます。

操作方法

  • ポインタのように *iterator と、 * をつけるとその要素を参照できます。
  • ++iterator とすると、現在の要素から次の要素に進みます。
  • 後述するカテゴリによって、使える操作は異なります。

イテレータには5つのカテゴリがあり、それぞれで使える演算子が異なります。

mapは双方向イテレータが提供されます。

双方向イテレータは以下の操作を行えます。

操作
次の要素に進む ++iter, iter++
前の要素に戻る --iter, iter--
要素の読み込み、(非constなら)書き込み *iter
クラスメンバの参照 iter->m
イテレータの比較 iter==iter, iter!=iter

アルゴリズム

アルゴリズムイテレータを介してコンテナを操作できる関数を提供します。

例えば、以下の関数があります。

やったこと

データ構造

データ構造は課題で特に指定がありません。

今回はClang、GCCと同じく赤黒木にしました。

実装の詳細はスライドにまとめましたので、こちらを参照ください。
アルゴリズムイントロダクション』を参考に実装しています。

赤黒木は左と右を間違えると簡単にバグります。
bashの再実装の課題 の時と同じくdotファイルを生成して、ノードの色と位置関係を確認するようにしました。

f:id:nafuka11:20211015213032p:plain

mapを実装するには単に赤黒木を実装するだけでなく、イテレータにも対応する必要があります。

イテレータ

実装方法

イテレータiterator を継承して実装しました(iteratorC++17から非推奨)。

リファレンスを見ても、イテレータ実装依存なため詳しく書かれていません。このため、reverse_iterator を参考に実装しました。

インクリメントとデクリメント

++ で次の要素、-- で前の要素に進みます。

mapは要素を順序づけており、先頭の要素はキーが最小、末尾の要素はキーが最大になります。

次の要素は今よりもキーが大きい要素、前の要素は今よりもキーが小さい要素です。

次の要素の探索( ++ )は、今の要素に右の子があるかどうかで処理が異なります。

  • 右の子がある場合:右の子から最小(左端)の要素が次の要素です。

    f:id:nafuka11:20211015212859p:plain

  • 右の子がない場合:今の要素が親の右の子である限り、親をたどります。たどった要素の親が次の要素です。

    f:id:nafuka11:20211015212922p:plain

-- は、 ++ の左右を逆にした処理です。

beginとend

mapは begin()end()で先頭の要素、末尾 + 1の要素を参照するイテレータを返します。

begin()end() の計算量は定数時間である必要があります。このため木がbeginとendに相当するノードを覚えておく必要があります。

ClangとGCCで実現方法が異なります。

Clang

f:id:nafuka11:20211015212948p:plain

Clangでは、endはダミーノードで、ダミーノードの左の子がrootになります。

end-11 からイテレータ++ すると、右の子がないので、親をたどってendに到達します。

GCC

f:id:nafuka11:20211015213003p:plain

GCCでは、headerというダミーノードを持ち、親がroot、左の子がbegin、右の子がend-1を指します。headerがendに相当します。

Clangのように、rootの親をendにすることで、end-1から ++ するとendにたどり着きます。

面白いのはendの親はrootであることです。
これにより、ClangとGCCでは end()++ した時の挙動が異なります。

Clangでは、endの親はNULLなのでSegmentation Faultになります。

GCCでは、endから右の子end-1へ行き、そこから最小(左端)の要素になります。

ハマったこと

allocator::rebind

mapのテンプレート引数で指定できるallocatorは pair<const Key, T> を確保するために使われます。

木のノードではありません。木のノードを確保するためには、allocator::rebind を使って異なる型を確保するクラスを取り出す必要があります。

実装するために以下のことをしました。

  1. 赤黒木のクラスに、 allocator<pair> から allocator<赤黒木のノード> にallocatorをrebindする型を宣言する
    以下のように定義しました。

    typedef typename Allocator::template rebind<rb_node<T> >::other node_allocator_type;
    

    rb_node は赤黒木のノードのクラスです。

    typedeftypenametemplate について説明します。

    • typedef:typedef を使うと型の別名(エイリアス)を宣言することができます。
    • typename:テンプレート引数に依存する型は、コンパイル時に型どうか判断できません。typename を使って型であることを明示します。
    • template:allocator::rebind がテンプレートであることを明示するため、 template を使います。
  2. 赤黒木のコンストラクタで allocator<pair> を受け取って allocator<赤黒木のノード> にrebindした型のオブジェクトをメンバ変数にセットする。

insert with hint

insert with hintは map::insertオーバーロードです。

cplusplus.com によると以下のように定義されています。

iterator insert (iterator position, const value_type& val);

C++98とC++11以降の動作

insert with hintはC++98とC++11以降で動作が異なります。

C++98では、positionが挿入する要素の前の場合、挿入時間が最適化されます。

C++11以降では、挿入要素に続く要素をpositionが指している場合、挿入時間が最適化されます。

何故そのような変更がされたかは以下のページが分かりやすいです。

www.open-std.org

C++98の実装だと、multimapのような重複要素を許すコンテナの場合、先頭要素の前に要素を挿入できない問題があったそうです。

実装方法

GCCのソース を参考に実装しました。

valとposition及びposition前後の要素の大小関係を比較し、挿入できるなら挿入します。挿入できない場合、通常のinsertになります。
おそらくこれはC++11な実装なのかなと思います。

C++98な実装が見つけられたら良かったのですが、今回見つけられませんでした。

keyとvalueの比較

lower_boundなどkeyからiteratorを取り出す際、keyとvalue(pair)の比較が必要になります。

ClangとGCCで対処方法が異なります。

  • Clangは、keyとvalueを比較できる関数オブジェクト( __map_value_compare )を使って比較。

  • GCCは、pairからkeyを取り出す関数オブジェクト( _Select1st )を使い、pairからkeyを取り出してkey_compare。

GCCの実装だとset等、別のコンテナを実装するときに新たな関数オブジェクトを実装する必要があるため、Clangを参考にした実装をすることにしました。

参考URL・書籍

github.com

github.com

赤黒木

GCCの赤黒木の実装について

stackoverflow.com

allocator::rebind

in-neuro.hatenablog.com

stackoverflow.com


  1. 双方向イテレータでは iter-1 は実装されてないので、あまり良い表現ではないのですが、キーの最大値の要素です。