範囲
範囲は、要素アクセスを抽象化したものだ。この抽象化により、多数のコンテナ型に対して多数のアルゴリズムを使用することが可能になる。範囲は、コンテナの実装方法ではなく、コンテナの要素へのアクセス方法を強調する。
範囲は、型が特定のメンバー関数のセットを定義しているかどうかに基づく、非常に単純な概念だ。この概念は、構造体とクラスに関する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()
save
ForwardRange
が要求するもの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
の要件は、以下の通りだ。
empty
popFront()
、front
、およびInputRange
が要求するすべての要件save
ForwardRange
が要求するもの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文字になる。InputRange
empty
、front
、popFront()
が必要だ。ForwardRange
save
も追加で必要。BidirectionalRange
back
とpopBack()
も必要だ。- Infinite
RandomAccessRange
には、opIndex()
がForwardRange
上で必要だ。 - 有限の
RandomAccessRange
は、opIndex()
およびlength
overBidirectionalRange
が必要。 std.array.appender
スライスに追加するOutputRange
を返す。- スライスは有限の
RandomAccessRange
- 固定長配列は範囲ではない。