範囲
範囲は、要素アクセスを抽象化したものだ。この抽象化により、多数のコンテナ型に対して多数のアルゴリズムを使用することが可能になる。範囲は、コンテナの実装方法ではなく、コンテナの要素へのアクセス方法を強調する。
範囲は、型が特定のメンバー関数のセットを定義しているかどうかに基づく、非常に単純な概念だ。この概念は、構造体とクラスに関するforeachの章で既に紹介している。メンバー関数empty、front、およびpopFront()を提供する型は、foreachループで使用することができる。これら3つのメンバー関数のセットは、範囲型InputRangeの要件だ。
まず、すべての範囲型の中で最も単純なInputRangeから、範囲の紹介を始めよう。他の範囲では、InputRangeよりも多くのメンバー関数が必要になる。
先に進む前に、コンテナとアルゴリズムの定義を説明しておく。
コンテナ (データ構造):コンテナは、ほとんどすべてのプログラムに登場する非常に便利な概念だ。変数は、ある目的のためにまとめられ、コンテナの要素として一緒に使われる。Dのコンテナは、そのコア機能である配列と連想配列、および モジュールで定義されている特別なコンテナ型だ。すべてのコンテナは、特定のデータ構造として実装されている。例えば、連想配列はstd.containerハッシュテーブルの実装だ。
すべてのデータ構造は、その要素を格納し、そのデータ構造特有の方法でそれらへのアクセスを提供する。例えば、配列データ構造では、要素は隣り合って格納され、要素インデックスによってアクセスされる。リンクリストデータ構造では、要素はノードに格納され、それらのノードを1つずつたどってアクセスされる。ソートされた二分木データ構造では、ノードは、別々の分岐を通じて、前と後の要素へのアクセスを提供する。
この章では、コンテナと データ構造という用語を同じ意味で使用する。
アルゴリズム(関数):特定の目的のために特定の方法でデータ構造を処理することをアルゴリズムと呼ぶ。例えば、線形探索は、コンテナを最初から最後まで繰り返し処理して検索するアルゴリズムであり、二分探索は、毎回候補の半分を排除して要素を検索するアルゴリズムである。
この章では、アルゴリズム と関数を同じ意味で使用する。
以下の例のほとんどでは、要素型としてint、コンテナ型としてint[]を使用する。実際には、範囲はテンプレート化されたコンテナやアルゴリズムと併用するとより強力になる。実際、範囲が結びつくコンテナやアルゴリズムのほとんどはテンプレート化されている。テンプレート化された範囲の例は、次の章で説明する。
歴史
アルゴリズムとデータ構造を互いに抽象化する非常に成功したライブラリとして、C++標準ライブラリの一部としても登場するStandard Template Library (STL)がある。STLは、C++のテンプレートで実装されているイテレータ概念により、この抽象化を提供している。
イテレータは非常に有用な抽象化だが、いくつかの弱点もある。Dの範囲は、これらの弱点を克服するために設計された。
アンドレイ・アレクサンドレスクは、論文"On Iteration"で範囲を紹介し、イテレータよりも優れている点を示している。
範囲はDの不可欠な部分である
Dのスライスは、最も強力な範囲RandomAccessRangeの実装であり、Phobosには多くの範囲機能がある。Phobosで範囲がどのように使用されるかを理解することは不可欠だ。
多くのPhobosアルゴリズムは、一時的な範囲オブジェクトを返す。例えば、次のコードで10より大きい要素を選択するfilter()は、実際には配列ではなく範囲オブジェクトを返す。
writelnは、その範囲オブジェクトを遅延評価し、必要に応じて要素にアクセスする:
[20, 11]
この出力は、filter()がint[]を返すように見えるが、そうではない。次の代入がコンパイルエラーになることから、これがわかる:
エラーメッセージには、rangeオブジェクトの型が含まれている。
注釈:使用しているPhobosのバージョンによって、型は異なる場合がある。
この一時オブジェクトを実際の配列に変換することは、この章の後半で説明する。
アルゴリズムの伝統的な実装
従来のアルゴリズムの実装では、アルゴリズムは、それが操作するデータ構造がどのように実装されているかを認識している。例えば、リンクリストの要素を出力する次の関数は、リンクリストのノードにはelementおよびnextというメンバーがあることを認識している必要がある。
同様に、配列の要素を出力する関数は、配列にはlengthプロパティがあり、その要素は[]演算子でアクセスされることを知っておく必要がある。
注釈: foreachは、配列を反復処理する場合に有用であることはご存じだろう。従来のアルゴリズムがデータ構造とどのように関連しているかを説明するために、forの使用が妥当であると仮定しよう。
アルゴリズムがデータ構造に結びついていると、型ごとに特別にアルゴリズムを記述する必要がある。例えば、find()、sort()、swap()などの関数は、配列、リンクリスト、連想配列、二分木、ヒープなどに対して個別に記述しなければならない。その結果、M個のデータ構造をサポートするN個のアルゴリズムをNxM回記述しなければならない。(注釈: 実際には、すべてのアルゴリズムがすべてのデータ構造に適用できるわけではないため、その数はNxM未満になる。例えば、連想配列はソートできない。)
一方、範囲はアルゴリズムをデータ構造から抽象化するため、N個のアルゴリズムとM個のデータ構造を実装するだけで十分だ。新しく実装されたデータ構造は、そのデータ構造が提供する範囲の型をサポートする既存のアルゴリズムすべてで動作し、新しく実装されたアルゴリズムは、そのアルゴリズムが要求する範囲の型をサポートする既存のデータ構造すべてで動作する。
Phobos範囲
この章で扱う範囲は、begin..endの形式で書かれる数値範囲とは異なる。数値範囲がforeachループやスライスでどのように使用されるかは既に説明した。
この章でrangeと表記する場合は、Phobosの範囲を意味する。
範囲は範囲階層を形成する。この階層の最下位には、最も単純な範囲InputRangeがある。他の範囲は、基となる範囲の上に追加の要件を課す。以下は、最も単純なものから機能の豊富なものへと並べた、すべての範囲とその要件である:
InputRange(入力範囲):empty、front、popFront()メンバー関数を必要とするForwardRange(前方の範囲):saveメンバー関数を追加で必要とするBidirectionalRange(双方向の範囲): さらに、backおよびpopBack()メンバー関数が必要RandomAccessRange(ランダムアクセスの範囲):[]演算子 (および、範囲が有限か無限かによって別のプロパティ) も追加で必要
この階層構造は、以下のグラフのように示すことができる。RandomAccessRangeには有限版と無限版がある。
InputRange
↑
ForwardRange
↗ ↖
BidirectionalRange RandomAccessRange (無限)
↑
RandomAccessRange (有限)
上記のグラフは、最下位の型が最上位にあるクラス階層形式で表示している。
これらの範囲は、要素へのアクセスを提供するものだ。要素の出力に関するもう1つの範囲がある:
OutputRange:put(range, element)操作のサポートが必要
これらの5つの範囲型があれば、データ構造からアルゴリズムを抽象化するには十分だ。
範囲を短縮して反復する
通常、コンテナの要素を反復処理しても、コンテナ自体は変更されない。例えば、foreachまたはforを使用してスライスを反復処理しても、スライスには影響がない。
別の反復方法では、異なる考え方が必要だ。反復は、範囲を最初から短縮することで実現できる。この方法では、要素へのアクセスには常に最初の要素が使用され、次の要素にアクセスするために最初の要素が最初からポップされる:
slice = slice[1..$]式によって最初の要素が削除されることで、反復が実現される。上のスライスは、次の段階を経て完全に消費される。
[ 10, 11, 12 ]
[ 11, 12 ]
[ 12 ]
[ ]
Phobosの範囲の反復の概念は、範囲を最初から短縮するというこの新しい考え方に基づいている。(BidirectionalRangeおよび有限RandomAccessRange型は、最後から短縮することもできる。)
上記のコードは、このタイプの反復を説明するためのものなので、この例のように反復を行うことが通常であるとは考えないようにしよう。
ほとんどのケースでは、範囲を反復処理するために要素を失うことは望ましくないため、代わりに代理範囲を使用することがある。以下のコードでは、元の範囲の要素を保持するために別のスライスを使用している:
これは、ほとんどのPhobos範囲関数で使用されている方法である。元のコンテナを保持するために、消費される特別な範囲オブジェクトを返す。
InputRange
このタイプの範囲は、上記のprint()関数で見たように、要素が順番にアクセスされる反復の型をモデルしている。ほとんどのアルゴリズムでは、すでに反復処理された要素を無視して、要素を順方向に反復処理するだけで十分だ。InputRangeは、要素が読み込まれるとストリームから削除される、プログラムの標準入力ストリームもモデルしている。
完全を期すために、InputRangeで必要な3つの関数を以下に示す。
-
empty: 範囲が空かどうかを指定する。範囲が空であると判断された場合はtrueを返し、そうでない場合はfalseを返す -
front: 範囲の先頭にある要素へのアクセスを提供する -
popFront(): 範囲の先頭から最初の要素を削除して範囲を短縮する
注釈: emptyおよびfrontは、範囲のプロパティとみなされるため、括弧は付けずに記述している。popFront()は、副作用のある関数であるため、括弧を付けて記述している。
これらの範囲関数を使用して、print()を実装する方法を以下に示す。
また、範囲の型を任意に制限しないように、print()は関数テンプレートになっていることに注意。print()は、3つのInputRange関数を提供する任意の型で動作するようになった。
InputRangeの例
先ほど見たSchool型を、今回はInputRangeとして再設計しよう。SchoolはStudentコンテナとして想像することができる。そのため、範囲として設計すると、Studentの範囲として見ることができる。
例を短くするため、いくつかの重要な設計上の側面は無視しよう。
- このセクションに関連するメンバーのみを実装する
- すべての型を構造体として設計する
private、publicなどの指定子や修飾子を無視し、const- 契約プログラミングやユニットテストの利点を活用しない
SchoolをInputRangeとして受け入れるには、3つのInputRangeメンバー関数を定義する必要がある。
emptyが範囲が空の場合にtrueを返すようにするには、students配列の長さを使用できる。その配列の長さが0の場合、範囲は空とみなされる:
frontが範囲の最初の要素を返すようにするには、配列の最初の要素を返すことができる:
注釈:実際の要素のコピーではなく、実際の要素へのアクセスを提供するために、refキーワードを使用している。そうしないと、Studentは構造体であるため、要素がコピーされてしまう。
popFront()で範囲を先頭から短縮するには、students配列を先頭から短縮する:
注釈:上で述べたように、単に要素を反復処理するためにコンテナから元の要素を削除することは通常ではない。この問題については、後で特別な範囲型を導入して対処する。
この3つの関数があれば、SchoolをInputRangeとして使用することができる。例として、上記のmain()の最後に次の行を追加して、新しいprint()関数テンプレートがschoolを範囲として使用するようにしよう。
print()そのオブジェクトをInputRangeとして使用し、その要素を出力に表示する:
| Ebru(1) | Derya(2) | Damla(3) |
ユーザー型をInputRangeとして定義するという目標を達成した。この型を、InputRange型を操作するアルゴリズムに送った。Schoolは、Phobosや、InputRange型を扱う他のライブラリのアルゴリズムで使用できる状態になっている。これについては、以下で例を見ていく。
スライスを範囲として使用するstd.arrayモジュール
std.arrayモジュールをインポートするだけで、最も一般的なコンテナ型が最も機能的な範囲型に準拠するようになる。スライスは、RandomAccessRangeオブジェクトとしてシームレスに使用できる。
std.arrayモジュールは、スライス用の関数empty、front、popFront()、およびその他の範囲関数を提供している。その結果、スライスは、print()など、あらゆる範囲関数で使用できるようになる。
std.rangeモジュールがすでにインポートされている場合は、std.arrayをインポートする必要はない。
固定長配列からは要素を削除できないため、popFront()はそれらに対して定義できない。そのため、固定長配列は範囲自体として使用できない:
print()が呼び出される行でコンパイルエラーが表示される方が望ましい。これは、print()にテンプレート制約を追加することで実現できる。次のテンプレート制約は、次の章で説明するisInputRangeを利用している。テンプレート制約により、コンパイルエラーはprint()が定義されている行ではなく、print()が呼び出されている行で発生するようになった:
固定長配列の要素は、range関数で引き続きアクセスできる。必要なのは、配列自体ではなく、配列全体のスライスを使用することだ。
スライスは範囲として使用できるが、すべての範囲型が配列として使用できるわけではない。必要に応じて、すべての要素を1つずつ配列にコピーすることができる。std.array.arrayは、この作業を簡略化するヘルパー関数である。array()は、InputRangeの範囲を繰り返し、要素をコピーして、新しい配列を返す。
出力:
[Ebru(1), Derya(2), Damla(3)]
また、上記のコードでUFCSが使用されていることにも注目。UFCSは、コードを式の実行順序と自然に一致させるため、範囲アルゴリズムと非常に相性が良い。
文字列を範囲として自動的にデコードするdchar
文字配列である文字列は、std.arrayをインポートするだけで、範囲として使用できる。ただし、charおよびwcharの文字列は、RandomAccessRangeとして使用することはできない。
std.arrayは、すべての型の文字列に対して特別な機能を提供する。文字列を反復処理すると、UTFコード単位ではなく、Unicodeコードポイントの反復処理になる。その結果、文字列はUnicode文字の範囲として表示される。
次の文字列には、単一のcharでは表現できないçおよびé、および単一のwcharでは表現できない𝔸(数学の二重打点大文字A)が含まれている(この章を読んでいる環境では、これらの文字が正しく表示されない場合があることに注意)。
プログラムの出力は、文字の範囲から通常期待されるものと同じだ:
| a | b | c | ç | d | e | é | 𝔸 |
| a | b | c | ç | d | e | é | 𝔸 |
| a | b | c | ç | d | e | é | 𝔸 |
ご覧のとおり、この出力は"文字と文字列"の章で見たものと一致しない。それらの章で説明したように、stringはimmutable(char)の配列の別名であり、wstringはimmutable(wchar)の配列の別名である。したがって、以前の出力には、適切にデコードされたUnicode文字ではなく、UTFコード単位が表示されることが予想される。
文字が正しく表示されるのは、範囲として使用すると、文字列要素が自動的にデコードされるためである。以下で見るように、デコードされたdcharの値は、文字列の実際の要素ではなく、r値だ。
念のため、文字列をコード単位の配列として扱う次の関数を考えてみよう。
文字をインデックスで直接アクセスすると、配列の要素はデコードされない:
| a | b | c | � | � | d | e | � | � | � | � | � | � |
| a | b | c | ç | d | e | é | ��� | ��� | ||||
| a | b | c | ç | d | e | é | 𝔸 |
自動デコードは、必ずしも望ましい動作とは限らない。例えば、文字列の最初の要素に代入しようとしている次のプログラムは、.frontの戻り値がr値であるため、コンパイルできない。
範囲アルゴリズムが文字列の実際のコード単位を変更する必要がある場合(その変更がUTFエンコーディングを無効にしない場合)、文字列はstd.string.representionを使用してubyte要素の範囲として使用できる。
representationは、char、wchar、およびdchar文字列の実際の要素を、それぞれubyte、ushortおよびuintの範囲として表示する。
実際の要素を含まない範囲
Schoolオブジェクトの要素は、実際にはstudentsメンバーのスライスに格納されていた。したがって、School.frontは既存のStudentオブジェクトへの参照を返した。
範囲の強力な機能のひとつは、要素を実際に所有しないという柔軟性だ。frontは、実際のコンテナの実際の要素を返す必要はない。返される要素は、popFront()が呼び出されるたびに計算され、frontが返す値として使用できる。
実際の要素を持たない範囲は既に上で見た通りだ:charとwcharはすべてのUnicode文字を表現できないため、範囲要素として現れるUnicode文字は、charまたはwcharの配列の実際の要素にはなれない。文字列の場合、frontは、配列に対応するUTFコード単位から構築された dcharを返す:
配列の要素型はcharだが、上記のfrontの戻り値の型はdcharである。このdcharは、配列の要素ではなく、配列の要素からUnicode文字としてデコードされたr値だ。
同様に、一部の範囲は要素を所有していないが、他の範囲の要素へのアクセスを提供するために使用される。これは、上記のSchoolオブジェクトを反復処理する際に要素が失われる問題を解決するための解決策だ。実際のSchoolオブジェクトの要素を保持するために、特別なInputRangeを使用することができる。
その方法を見るために、StudentRangeという新しい構造体を定義し、Schoolからこの新しい構造体にすべての範囲メンバー関数を移動しよう。School自体はもはや範囲ではないことに注意。
新しい範囲は、Schoolの学生にアクセスするためのメンバースライスで始まり、popFront()でそのメンバースライスを消費する。その結果、School内の実際のスライスは保持される:
注釈:すべての処理はメンバースライスにディスパッチされるため、StudentRangeは範囲の良い例とは言い難い。実際、studentsがSchoolのアクセス可能なメンバーであると仮定すると、ユーザーコードはSchool.studentsのスライスを直接作成し、そのスライスを範囲として使用することができたはずだ。
無限の範囲
要素を実際のメンバーとして格納しないもう1つの利点は、無限の範囲を作成できることだ。
無限の範囲を作成するには、emptyが常にfalseを返すようにすればよい。これは定数であるため、emptyは関数である必要はなく、enum値として定義することができる。
別のオプションとして、不変のstaticメンバーを使用することもできる:
この例として、フィボナッチ数列を表す範囲を設計しよう。intメンバーは2つしかないが、次の範囲は無限のフィボナッチ数列として使用できる。
注釈:無限だが、メンバーはint型であるため、int.maxを超えると、このフィボナッチ数列の要素は間違った値になる。
emptyはFibonacciSeriesオブジェクトに対して常にfalseであるため、print()のforループはそれらに対して終了しない:
無限の範囲は、範囲をすぐに完全に消費する必要がない場合に便利である。以下では、FibonacciSeriesの要素の一部のみを使用する方法について説明する。
範囲を返す関数
先ほど、明示的にStudentRange(school)と記述して、StudentRangeオブジェクトを作成した。
ほとんどの場合、このような範囲のオブジェクトを返す便利な関数が代わりに使用される。例えば、StudentRangeを返すことを唯一の目的とする関数を使用すると、コードが簡略化される。
これは、実際には非常に複雑になることがある範囲型の名前を覚えて、明示的に記述するよりも便利だ。
この例としては、単純なstd.range.take関数がある。take()は、範囲の先頭から指定した数の要素にアクセスする関数だ。実際には、この機能はtake()関数自体によって実現されているのではなく、この関数が返す特別な範囲オブジェクトによって実現されている。take()を使用する場合、この事実を明示的に記述する必要はない。
take()は、schoolの最初の2要素にアクセスできる一時的な範囲オブジェクトを返す。次に、print()はそのオブジェクトを使用し、次の出力を生成する:
| Ebru(1) | Derya(2) |
上記の操作では、schoolに変更は加わらない。依然として3つの要素を含む。
take()のような関数によって返される範囲オブジェクトの具体的な型は重要ではない。これらの型は、エラーメッセージで表示されることがある。あるいは、typeofおよびstringofを使って、自分で出力することもできる。
出力によると、take()はTakeという名前のテンプレートのインスタンスを返している:
Take!(StudentRange)
std.rangeおよびstd.algorithmモジュール
型を範囲として定義する大きな利点は、独自の関数だけでなく、Phobosや他のライブラリでも使用できることだ。
std.rangeには、多数のrange関数、構造体、およびクラスが含まれている。std.algorithmには、他の言語の標準ライブラリにもよく見られるアルゴリズムが数多く含まれている。
標準モジュールで型を使用する方法の例を見るために、std.algorithm.swapFrontアルゴリズムでSchoolを使ってみよう。swapFront()は、2つのInputRange範囲の先頭要素を交換する。 (2つの範囲の先頭要素が交換可能であることが条件だ。配列は、この条件を満たしている。)
2つの学校の最初の要素が入れ替わる。
| Mary(10) | Derya(2) | Damla(3) |
| Ebru(1) | Jane(20) |
別の例として、std.algorithm.filterアルゴリズムを見てみよう。filter()は、特定の条件(述語)を満たさない要素をフィルタリングした特別な範囲を返す。要素のフィルタリング操作は、要素へのアクセスにのみ影響し、元の範囲は保持される。
述語は、条件を満たすとみなされる要素に対してはtrue、条件を満たさない要素に対してはfalseと評価される式だ。filter()が使用する述語を指定するには、いくつかの方法がある。これまでの例で見たように、その1つの方法はラムダ式を使用することである。以下のパラメータaは、各学生を表している。
上記の述語は、school.studentsOfの範囲内の奇数を持つ要素を選択する。
take()と同様に、filter()も特別な範囲オブジェクトを返す。この範囲オブジェクトは、他の範囲関数に渡すことができる。例えば、print()に渡すことができる。
この式は、範囲school.studentsOfから始まり、その初期範囲の要素をフィルタリングする範囲オブジェクトを構築し、新しい範囲オブジェクトをprint()に渡す、と説明できる。
出力は奇数番号の学生で構成される:
| Ebru(1) | Damla(3) |
条件を満たす要素に対してtrueを返す限り、述語は関数として指定することもできる。
上記の述語関数は、名前がDで始まる学生に対してはtrueを返し、それ以外の学生に対してはfalseを返す。
注釈: student.name[0]を使用すると、最初の文字ではなく、最初のUTF-8コードユニットが返される。前述のように、frontはnameを範囲として使用し、常に最初のUnicode文字を返す。
今回は、名前がDで始まる学生を選択して表示する:
| Derya(2) | Damla(3) |
generate()は、std.rangeモジュールにある便利な関数テンプレートで、関数から返された値をInputRangeの要素として簡単に表示できる。これは、呼び出し可能なエンティティ(関数ポインタ、デリゲートなど)を受け取り、その呼び出し可能なエンティティから返される値で構成されるInputRangeオブジェクトを返す。
返されるrangeオブジェクトは無限である。そのrangeオブジェクトのfrontプロパティにアクセスするたびに、元の呼び出し可能エンティティが呼び出されて、そこから新しい要素が取得される。rangeオブジェクトのpopFront()関数は、何もしない。
例えば、次の範囲オブジェクトdiceThrowerは、無限の範囲として使用できる。
[1, 0, 3, 5, 5, 1, 5, 1, 0, 4]
遅延評価
関数が範囲オブジェクトを返すもう1つの利点は、それらのオブジェクトを遅延評価できることだ。遅延評価された範囲は、必要なときに1つずつ要素を生成する。これは、実行速度やメモリ消費の点で不可欠な場合がある。実際、無限の範囲が存在できるのは、範囲が遅延評価されるからだ。
遅延範囲は、要素を1つずつ、必要なときにのみ生成する。この例を、FibonacciSeries範囲で見てみよう。要素は、必要なときにのみpopFront()によって計算される。FibonacciSeriesが熱心な範囲であり、すべての要素を前もって生成しようとした場合、生成は終了せず、生成した要素を格納するスペースも確保できない。
熱心な範囲のもう一つの問題は、おそらく使用されない要素のために時間とメモリを消費してしまう点である。
Phobosのほとんどのアルゴリズムと同様に、take()およびfilter()も遅延評価の恩恵を受けている。例えば、FibonacciSeriesをtake()に渡して、有限個の要素を生成させることができる。
FibonacciSeriesは無限だが、出力には最初の10個の数字のみが含まれる:
| 0 | 1 | 1 | 2 | 3 | 5 | 8 | 13 | 21 | 34 |
ForwardRange
InputRangeは、反復処理されるたびに範囲から要素が取り出される範囲をモデル化している。
一部の範囲は、その状態を保存するだけでなく、InputRangeとして動作することもできる。例えば、FibonacciSeriesオブジェクトは、自由にコピーでき、2つのコピーは互いに独立して存続するため、その状態を保存することができる。
ForwardRangeは、範囲のコピーを返すsaveメンバー関数を提供している。saveが返すコピーは、コピー元の範囲オブジェクトとは独立して動作しなければならない。つまり、一方のコピーを反復処理しても、もう一方のコピーには影響があってはならない。
std.arrayをインポートすると、スライスは自動的にForwardRange範囲になる。
FibonacciSeriesでsaveを実装するには、単にオブジェクトのコピーを返すだけで済む:
返されたコピーは、コピー元のポイントから継続する別の範囲になる。
次のプログラムでは、コピーされたオブジェクトが実際の範囲から独立していることを示す。次のコードのアルゴリズムstd.range.popFrontN()は、指定された範囲から指定された数の要素を削除する:
プログラムの出力は、範囲から要素を削除しても、保存されたコピーには影響がないことを示している:
| 元の範囲 | [0, 1, 1, 2, 3] |
|---|---|
| 2つの要素を削除した後 | [1, 2, 3, 5, 8] |
| コピー | [1, 2, 3, 5, 8] |
| さらに3つの要素を削除した後 | [5, 8, 13, 21, 34] |
| コピー | [1, 2, 3, 5, 8] |
また、report()では、範囲がwriteflnに直接渡されていることにも注意。print()関数と同様に、stdioモジュールの出力関数は、InputRangeオブジェクトを受け取ることができる。これからは、stdioの出力関数を使用する。
ForwardRangeと連携するアルゴリズムは、std.range.cycleだ。cycle()は、範囲の要素を最初から最後まで繰り返し反復処理する。最初からやり直すためには、範囲の初期状態のコピーを保存できる必要があるため、ForwardRangeが必要だ。
FibonacciSeriesがForwardRangeになったため、FibonacciSeriesオブジェクトを使用してcycle()を試すことができる。ただし、cycle()が無限の範囲を反復処理し、結果としてその終わりを見つけることができないのを避けるため、まずFibonacciSeriesをtake()に渡し、有限の範囲を作成する必要がある:
結果の範囲も有限にするため、cycleが返す範囲もtake()を通過させる。出力は、FibonacciSeriesの最初の5要素を循環して最初の20要素で構成される:
[0, 1, 1, 2, 3, 0, 1, 1, 2, 3, 0, 1, 1, 2, 3, 0, 1, 1, 2, 3]
中間変数を定義することもできた。以下のコードは、上記の1行のコードと同等だ:
怠惰の重要性をもう一度指摘しておこう。上記の最初の4行は、最終的に要素を生成する範囲オブジェクトを構築しているだけだ。結果の一部である数値は、必要に応じてFibonacciSeries.popFront()によって計算される。
注釈:ここでは、FibonacciSeriesをForwardRangeとして使用しているが、実際には、FibonacciSeries().take(5)の結果をcycle()に渡している。take()は適応性がある。つまり、そのパラメータがForwardRangeである場合、返す範囲はForwardRangeになる。isForwardRangeでこれがどのように実現されているかは、次の章で説明する。
BidirectionalRange
BidirectionalRangeは、ForwardRangeのメンバ関数に対して2つのメンバ関数を提供する。backは、frontと似ている。範囲の最後の要素へのアクセスを提供する。popBack()は、popFront()と似ている。範囲から最後の要素を削除する。
std.arrayをインポートすると、スライスは自動的にBidirectionalRangeの範囲になる。
BidirectionalRangeの良い例は、std.range.retro関数だ。retro()は、BidirectionalRangeを受け取り、そのfrontをbackに、popFront()をpopBack()に結びつける。その結果、元の範囲が逆順で反復される。
出力:
[3, 2, 1]
retro()が返す特別な範囲と同様に動作する範囲を定義しよう。次の範囲は機能が制限されているが、範囲の強力な機能を示す例だ。
出力はretro()と同じだ:
[3, 2, 1]
RandomAccessRange
RandomAccessRangeは、[]演算子によって要素にアクセスできる範囲を表す。演算子オーバーロードの章で見たように、[]演算子は、opIndex()メンバー関数によって定義されている。
std.arrayモジュールをインポートすると、スライスは可能な場合にのみRandomAccessRange範囲になる。例えば、UTF-8およびUTF-16エンコーディングでは、インデックスによってUnicode文字にアクセスできないため、charおよびwchar配列は、Unicode文字のRandomAccessRange範囲として使用できない。一方、UTF-32エンコーディングのコードはUnicode文字コードと1対1で対応しているため、dchar配列は、Unicode文字のRandomAccessRange範囲として使用できる。
すべての型が、その機能に応じて メンバー関数を定義するのは当然のことだ。しかし、コンピュータサイエンスでは、そのアルゴリズムの複雑さについて、ランダムアクセスはopIndex()定数時間で実行されなければならないという期待がある。定数時間アクセスとは、要素へのアクセスに要する時間が、コンテナ内の要素の数に依存しないことを意味する。したがって、範囲がどんなに大きくても、要素へのアクセスは範囲の長さに依存してはならない。
RandomAccessRangeとみなされるためには、次の条件の少なくとも1つを満たす必要がある:
- 無限であること
ForwardRange
または
条件を満たすかどうかによって、範囲は無限か有限かのいずれかになる。
無限のRandomAccessRange
以下のものは、無限のForwardRangeに基づくRandomAccessRangeのすべての要件である。
empty、front、およびInputRangeが要求するpopFront()saveForwardRangeが要求するものopIndex()RandomAccessRangeが要求するものemptyの値がコンパイル時にfalseとして既知であること
FibonacciSeriesをForwardRangeとして定義することができた。しかし、opIndex()は、要素にアクセスするにはまずその前の要素すべてにアクセスする必要があるため、FibonacciSeriesに対して定数時間で動作するように実装することはできない。
opIndex()が定数時間で動作する例として、整数の2乗で構成される無限の範囲を定義しよう。次の範囲は無限だが、その要素のいずれかにアクセスするのは定数時間で実行できる。
注釈: SquaresRangeをstructとして定義するほうがより理にかなっている。
この範囲の要素にはメモリが割り当てられていないが、[]演算子で要素にアクセスできる:
出力には、インデックス5と10の要素が含まれる。
25
100
インデックス0の要素は、常に範囲の最初の要素を表す必要がある。これが実際にそうであるかどうかをテストするには、popFrontN()を利用できる:
範囲の最初の5要素は0、1、4、9、16で、0、1、2、3、4の2乗である。これらを削除すると、次の値の2乗が範囲の最初の要素になる。
25
RandomAccessRange(最も機能的な範囲)であるため、SquaresRangeは他のタイプの範囲としても使用できる。例えば、filter()に渡す場合、InputRangeとして使用できる。
出力は、最後の2桁が同じ最初の50個の要素で構成される:
[100, 144, 400, 900, 1444, 1600]
有限RandomAccessRange
有限BidirectionalRangeに基づくRandomAccessRangeの要件は、以下の通りだ。
emptypopFront()、front、およびInputRangeが要求するすべての要件saveForwardRangeが要求するものbackおよび、popBack()がBidirectionalRangeを必要とするopIndex()RandomAccessRangeが要求するものlength、これは範囲の長さを提供する
有限のRandomAccessRangeの例として、std.range.chainと同様に機能する範囲を定義しよう。chain()は、複数の個別の範囲の要素を、1つの大きな範囲の要素であるかのように表現する。chain()は、あらゆる型の要素およびあらゆる型の範囲で機能するが、例を短くするため、intスライスでのみ機能する範囲を実装しよう。
この範囲をTogetherと名付け、次のような動作を期待する。
上記の2つの別々の配列で構築された場合、rangeはこれらの要素をすべて1つの範囲として表示する必要がある。例えば、どちらの配列にもインデックス4の要素はないが、要素102は集合範囲のインデックス4に対応する要素である必要がある。
102
予想通り、範囲全体を出力すると、すべての要素が含まれるはずだ:
出力:
[1, 2, 3, 101, 102, 103]
Togetherは遅延評価で動作する: 要素は新しい大きな配列にコピーされず、元の slice からアクセスされる。
可変長引数の章で紹介した可変長引数関数を利用して、任意の数の元のスライスで範囲を初期化することができる。
要素の型はconst(int)であり、このstructは範囲の要素を変更しないことを示していることに注意。ただし、反復を実装するために、popFront()によってスライスは必ず変更される。
コンストラクタが呼び出すclearFront()とclearBack()は、元のスライスの先頭と末尾から空のスライスを削除するためのものである。このような空のスライスはTogetherの動作を変更しないため、事前に削除することで実装を簡素化できる:
これらの関数は、後でpopFront()およびpopBack()からも呼び出す。
clearFront()とclearBack()は先頭と末尾の空のスライスをすべて削除するため、スライスが残っている場合は集合範囲がまだ空ではないことを意味する。つまり、スライスが残っていない場合のみ、範囲を空とみなすべきだ:
最初のスライスの最初の要素は、このTogether範囲の最初の要素だ:
最初のスライスの最初の要素を削除すると、この範囲の最初の要素も削除される。この操作により最初のスライスが空になる可能性があるため、clearFront()を呼び出して、その空のスライスとその後のスライスを削除する必要がある:
この範囲のコピーは、slicesメンバーのコピーから構築できる:
この場合、.dupは、slicesのみをコピーし、その中に含まれるスライス要素はコピーしないことに注意しよう。
範囲の末尾の操作は、範囲の先頭での操作と類似している:
範囲の長さは、スライスの長さの合計として計算できる:
または、std.algorithm.foldを使用することで、より少ないコードで長さを計算することもできる。fold()は、テンプレートパラメータとして操作を受け取り、その操作を範囲内のすべての要素に適用する。
テンプレートパラメータのaは現在の結果(この場合は合計)を表し、bは現在の要素を表す。最初の関数パラメータは要素を含む範囲であり、2番目の関数パラメータは結果の初期値(size_t.initは0)である。(UFCSを利用して、slicesがfoldの前に記述されていることに注意。)
注釈:さらに、lengthが呼び出されるたびに長さを計算する代わりに、length_という名前のメンバー変数を維持すると、測定可能なほど高速になる可能性がある。このメンバーは、コンストラクタで1回計算し、popFront()およびpopBack()によって要素が削除されるたびに、それに応じて調整する。
特定のインデックスに対応する要素を返す1つの方法は、すべてのスライスを確認して、その要素がそのスライスの要素に含まれるかどうかを判断することだ。
注釈:このopIndex()は、前述の定数時間の要件を満たしていない。この実装を許容できる速度にするためには、slicesメンバーがあまり長くないことが必要だ。
この新しい範囲は、任意の数のintスライスで使用する準備ができた。take()およびarray()を使用すると、この章で先に定義した範囲型も使用することができる。
3つのスライスの要素は、単一の大きな配列の要素としてアクセスできる。
[0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 777, 888, 0, 1, 4, 9, 16]
この範囲を他の範囲アルゴリズムに渡すこともできる。例えば、BidirectionalRangeが必要なretro()
出力:
[16, 9, 4, 1, 0, 888, 777, 34, 21, 13, 8, 5, 3, 2, 1, 1, 0]
もちろん、プログラムではTogetherではなく、より機能的なstd.range.chainを使用すべきだ。
OutputRange
これまで見てきた範囲型は、すべて要素へのアクセスに関するものだった。OutputRangeは、stdoutに文字を送信するのと同じように、ストリーム化された要素の出力を表す。
先ほど、OutputRangeはput(range, element)操作のサポートが必要であると述べた。put()は、std.rangeモジュールで定義されている関数だ。これは、コンパイル時に範囲と要素の機能を判断し、要素を出力するのに最も適切な方法を使用する。
put()は、以下のケースを記載順に検討し、最初に一致したケースの方法を適用する。Rは範囲の型、rangeは範囲オブジェクト、Eは要素の型、eは範囲の要素を表す。
| 検討対象ケース | 適用されるメソッド |
|---|---|
R putというメンバー関数があるputは、Eを引数として受け取ることができる | range.put(e); |
R putというメンバー関数がある、putはE[]を引数として取ることができる | range.put([ e ]); |
Rは、InputRangeであり、eは、range.front | range.front = e;range.popFront(); |
Eは、InputRangeであり、コピー可能R | for (; !e.empty; e.popFront())put(range, e.front); |
R 引数としてEを受け取ることができる (例: Rがデリゲートとなる可能性がある) | range(e); |
R E[]を引数として受け取ることができる (例: Rはデリゲートである可能性がある) | range([ e ]); |
最初のケースに一致する範囲を定義しよう。この範囲には、出力範囲の型と一致するパラメータを取るput()というメンバー関数が含まれる。
この出力範囲は、stdoutを含む複数のファイルに要素を出力するために使用される。put()で要素が出力されると、その要素はすべてのファイルに書き込まれる。追加機能として、各要素の後に書き込む区切り文字を指定する機能を追加しよう。
任意の型の要素の出力範囲として使用できるように、put()も要素型でテンプレート化されている。
OutputRangeを使用するPhobosのアルゴリズムは、std.algorithm.copyである。copy()は、InputRangeの要素をOutputRangeにコピーする、非常に単純なアルゴリズムだ。
このコードは、入力範囲の要素をstdoutと"output_0"および"output_1"という名前のファイルの両方に出力する。
1
2
3
赤
青
緑
スライスを使用するOutputRange
std.rangeモジュールは、スライスをOutputRangeオブジェクトとしても作成する。(対照的に、std.arrayはそれらを入力範囲としてのみ作成する。)残念ながら、スライスをOutputRangeオブジェクトとして使用すると、混乱を招く効果がある:スライスは、それに対してput()操作を実行するたびに要素を1つ失う。そして、その要素は直前に出力された要素である!
この動作の原因は、スライスにput()メンバー関数が存在しないことにある。その結果、前の表の3番目のケースがスライスに一致し、次のメソッドが適用される。
上記のコードは、put()が実行されるたびに実行されるため、スライスの先頭要素が出力された要素の値に割り当てられ、その後、popFront()によってスライスから削除される。
その結果、スライスはOutputRangeとして使用されているにもかかわらず、意外にも要素が失われる:
[2, 3]
これを回避するには、OutputRangeとして別のスライスを使用する必要がある:
今回は2番目のスライスが消費され、元のスライスには期待通りの要素が含まれている:
[2, 3]
[100, 2, 3] ← 予想される結果
もう1つの重要な点は、OutputRangeとして使用しても、スライスの長さは長くなることはないということだ。スライスに十分な容量があるかどうかを確認するのは、プログラマーの責任である。
間接参照popFront()呼び出しによりスライスが完全に空になると、プログラムは例外で終了する:
std.array.Appender その便利な関数appenderを使用すると、要素が追加されるOutputRangeとしてスライスを使用することができる。appender()が返す特別な範囲オブジェクトのput()関数は、実際には要素を元のスライスに追加する。
上記のコードでは、appenderが配列で呼び出され、特別な範囲オブジェクトを返す。その範囲オブジェクトは、そのメンバー関数put()を呼び出すことで、OutputRangeとして使用される。結果の要素は、そのプロパティ.dataでアクセスできる。
出力:
[1, 2, 3, 0, 100, 200, 300]
Appender ~=演算子もサポートしている:
出力:
[1, 2, 3, 0, 100, 200, 300, 1000]
toString() OutputRangeパラメーター付き
toStringメンバー関数を delegateパラメータを受け取るように定義できるのと同様に、OutputRangeを受け取る関数を定義することもできる。format、writefln、writelnなどの関数は、出力範囲の出力バッファに出力文字を直接配置することで、より効率的に動作する。
任意のOutputRange型で動作させるには、toStringのような定義は、オプションでテンプレート制約付き関数テンプレートである必要がある。
main()内のコードは、OutputRangeオブジェクトを定義していないことに注意。このオブジェクトは、文字を表示する前に文字を格納するために、writelnによって定義されている。
こんにちは
範囲テンプレート
この章では主にintの範囲を使用してきたが、範囲と範囲アルゴリズムはテンプレートとして定義された方がはるかに有用だ。
std.rangeモジュールには、多くの範囲テンプレートが含まれている。これらのテンプレートについては、次の章で説明する。
要約
- 範囲は、アルゴリズムからデータ構造を抽象化し、アルゴリズムとシームレスに連携できるようにする。
- 範囲はDの概念であり、Phobosの多くの機能の基礎となっている。
- 多くのPhobosアルゴリズムは、特別なタスクを実行するために遅延評価の範囲オブジェクトを返す。
- UFCSは範囲アルゴリズムとよく連携する。
InputRangeオブジェクトとして使用される場合、文字列の要素はUnicode文字になる。InputRangeempty、front、popFront()が必要だ。ForwardRangesaveも追加で必要。BidirectionalRangebackとpopBack()も必要だ。- Infinite
RandomAccessRangeには、opIndex()がForwardRange上で必要だ。 - 有限の
RandomAccessRangeは、opIndex()およびlengthoverBidirectionalRangeが必要。 std.array.appenderスライスに追加するOutputRangeを返す。- スライスは有限の
RandomAccessRange - 固定長配列は範囲ではない。