コンピュータサイエンスにおいて、抽象データ型(ADT)とは、データ型の数学的モデルであり、データの利用者の視点から、具体的には取り得る値、この型のデータに対する可能な操作、およびこれらの操作の動作によって定義される(意味論)。この数学的モデルは、データの具体的な表現であり、利用者ではなく実装者の視点を表すデータ構造とは対照的である。例えば、スタックは後入れ先出し(LIFO)ルールに従うプッシュ/ポップ操作を持ち、連結リストまたは配列のいずれかを使用して具体的に実装できる。別の例として、値を特定の順序なしに、また重複値なしで格納するセットがある。セットから値自体を取り出すのではなく、値がセットに属しているかどうかをテストして、ブール値の「in」または「not in」を取得する。
ADT は理論的な概念であり、形式意味論やプログラム検証、より広義にはアルゴリズム、データ構造、ソフトウェアシステムの設計と分析に使用されます。主流のコンピュータ言語のほとんどは、ADT を形式的に指定することを直接サポートしていません。しかし、さまざまなプログラミング言語の機能は、ADT の実装の特定の側面に対応しており、ADT 自体と混同されることがよくあります。これには、抽象型、不透明データ型、プロトコル、契約による設計などが含まれます。たとえば、モジュール型プログラミングでは、モジュールは ADT 操作に対応する手順を宣言し、多くの場合、制約を説明するコメントが付けられます。この情報隠蔽戦略により、クライアントプログラムに影響を与えることなくモジュールの実装を変更できますが、モジュールは ADT を非公式に定義するだけです。抽象データ型の概念は、オブジェクト指向プログラミングやソフトウェア エンジニアリングの契約による設計手法において重要なデータ抽象化の概念に関連しています。[ 1 ]
ADTは、1974年にBarbara LiskovとStephen N. Zillesによって、 CLU言語の開発の一環として初めて提案されました。[ 2 ]代数仕様は、1980年頃のコンピュータサイエンスにおける重要な研究テーマであり、当時は抽象データ型とほぼ同義でした。[ 3 ]普遍代数に数学的な基礎があります。[ 4 ]
形式的には、ADT は数学における代数構造に類似しており、 [ 5 ]ドメイン、演算の集合、および演算が満たさなければならない制約の集合から構成されます。[ 6 ]ドメインは、たとえばADT 演算の集合上の自由オブジェクトのように、暗黙的に定義されることがよくあります。ADT のインターフェースは通常、ドメインと演算、およびおそらく事前条件や事後条件などの演算に対する制約の一部のみを参照しますが、動作とみなされる演算間の関係などの他の制約は参照しません。動作の形式仕様には、公理的意味論と操作的意味論という 2 つの主要なスタイルがあります。[ 7 ]
制約はインターフェースの一部ではありませんが、ADTの定義にとって依然として重要です。たとえば、スタックとキューは要素の追加/削除インターフェースが似ていますが、後入れ先出しと先入れ先出しの動作を区別するのは制約です。制約は、などの等式だけでなく、論理式fetch(store(S,v))=vも含まれます。
関数型プログラミングの精神に基づき、抽象データ構造の各状態は独立した実体または値として扱われます。この考え方では、各操作は副作用のない数学関数としてモデル化されます。抽象データ型を変更する操作は、古い状態を引数として受け取り、新しい状態を結果の一部として返す関数としてモデル化されます。操作の評価順序は重要ではなく、同じ引数(同じ入力状態を含む)に同じ操作を適用すると、常に同じ結果(および出力状態)が返されます。制約は、操作が満たさなければならない公理または代数法則として指定されます。
命令型プログラミングの精神に基づき、抽象データ構造は可変な実体として捉えられます。つまり、時間の概念が存在し、抽象データ構造は時間によって異なる状態をとる可能性があるということです。操作は時間の経過とともに抽象データ構造の状態を変化させるため、操作の評価順序が重要となり、同じ実体に対する同じ操作でも、実行されるタイミングによって異なる結果をもたらす可能性があります。これは、コンピュータの命令や命令型言語のコマンドとプロシージャに類似しています。この考え方を強調するために、抽象アルゴリズムを記述する際によく用いられる命令型スタイルと同様に、操作は評価されるのではなく、実行される、あるいは適用されると表現するのが一般的です。制約は通常、散文で記述されます。
抽象データ型(ADT)の説明は、多くの場合、主要な操作のみに限定されています。より詳細な説明では、ADTに対する補助的な操作が具体的に示されることが多く、例えば以下のようなものがあります。
create()それによって、ADTの新しいインスタンスが生成されます。compare(s, t)これは、2つのインスタンスの状態が何らかの意味で同等であるかどうかをテストするものです。hash(s)インスタンスの状態から標準的なハッシュ関数を計算する。print(s)またはshow(s)、インスタンスの状態を人間が読みやすい形で表現する。これらの名称は例示であり、著者によって異なる場合があります。命令型ADTの定義では、次のような記述もよく見られます。
initialize(s)新しく作成されたインスタンスをs後続の操作のために準備するか、何らかの「初期状態」にリセットします。copy(s)、インスタンスを とs同等の状態にしますt。clone(t)s←を実行しcreate()、copy(s, t)を返しますs。free(s)またはdestroy(s)、によって使用されるメモリやその他のリソースを解放しますs。ADTは理論上の実体であり「メモリを使用しない」ため、このfree操作は通常は関連性も意味もありません。ただし、ADTを使用するアルゴリズムが使用するストレージを分析する必要がある場合は、この操作が必要になることがあります。その場合、各ADTインスタンスがその状態に応じてどれだけのメモリを使用するか、そしてそのうちどれだけがプールに戻されるかを指定する追加の公理が必要になりますfree。
抽象データ型(ADT)の定義では、そのインスタンスに格納される値が、変数範囲と呼ばれる特定の集合Xの要素に限定されることがよくあります。例えば、抽象変数は整数のみを格納するように制約される場合があります。プログラミング言語と同様に、このような制約はアルゴリズムの記述と分析を簡素化し、可読性を向上させる可能性があります。
操作的なスタイルでは、複数のインスタンスがどのように処理されるか、また、あるインスタンスを変更すると他のインスタンスに影響するかどうかが不明確な場合が多い。ADT を定義する一般的なスタイルでは、アルゴリズムの実行中にインスタンスが 1 つだけ存在し、すべての操作がそのインスタンスに適用されるかのように操作を記述する。たとえば、スタックには、唯一の既存のスタックに対して操作する操作 ( x ) と () がある。pushこのスタイルのpopADT定義は、暗黙のインスタンスを使用または変更するすべての操作に明示的なインスタンス パラメータ (以下のスタックの例のSなど) を追加することで、ADT の複数の共存インスタンスを許容するように簡単に書き換えることができる。セットに対する操作やリストに対する操作のように、単一の操作が ADT の 2 つの異なるインスタンスをパラメータとして受け取る場合など、複数のインスタンスを許可しないと意味のある ADT を定義できない場合もある。unioncompare
複数インスタンス方式は、エイリアシング公理と組み合わされることがあります。つまり、() の結果は、createアルゴリズムによって既に使用されているインスタンスとは異なるということです。ADT の実装では、メモリを再利用し、create() の実装によって以前に作成されたインスタンスが生成される場合があります。ただし、そのようなインスタンスが「再利用」されていると定義することは、ADT の形式体系では困難です。
より一般的には、この公理は他のインスタンスとの部分的なエイリアシングも除外するように強化することができ、複合ADT(ツリーやレコードなど)と参照型ADT(ポインタなど)は完全に互いに排他的であると想定できます。たとえば、抽象変数の定義を拡張して抽象レコードを含める場合、レコード変数RのフィールドFに対する操作は、明らかにFに関係しますが、FはRとは別個のものであり、 Rの一部でもあります。部分的なエイリアシングの公理は、1つのレコード変数のフィールドを変更しても、他のレコードには影響しないことを示します。
アルゴリズムの分析を支援するために、各操作の計算複雑度(「コスト」)を、時間(演算の計算)と空間(値の表現)の両面から含める著者もいます。たとえば、ADTの状態に関係なく、各操作は同じ時間を要し、各値は同じ空間を要したり、ADTの「サイズ」があり、操作はADTのサイズに対して線形、二次などとなることを指定できます。C ++標準テンプレートライブラリの設計者であるAlexander Stepanovは、STL仕様に複雑度保証を含め、次のように主張しました。
抽象データ型の概念を導入した理由は、ソフトウェアモジュールの互換性を確保するためです。モジュールが同様の複雑性特性を共有していない限り、互換性のあるモジュールは実現できません。機能的には同じでも複雑性のトレードオフが異なるモジュールを別のモジュールに置き換えた場合、そのコードの利用者は不快な驚きを覚えるでしょう。データ抽象化についてどんなに詳しく説明しても、利用者はそのコードを使いたがらないでしょう。複雑性に関する記述は、インターフェースの一部として必ず含める必要があります。
—アレクサンダー・ステパノフ[ 8 ]
他の著者たちはこれに異議を唱え、スタックADTは、操作コストの違いに関わらず、リンクリストで実装されても配列で実装されても同じであり、ADTの仕様は実装に依存しないべきだと主張している。
抽象変数は、命令型変数の意味論を持つ、最も単純な非自明な抽象データ型(ADT)とみなすことができる。抽象変数は、2つの演算、fetchおよびを許容するstore。演算定義は、抽象変数を用いて記述されることが多い。公理的意味論では、抽象変数の型であり、その内容の型はfetch関数である。そして、store型は関数です主な制約は、常に同じ変数Vに対する直近の操作で使用されたfetch値xを返すことです。つまり、。また、値を完全に上書きすることも要求できます。storefetch(store(V,x)) = xstorestore(store(V,x1),x2) = store(V,x2)
操作的意味論では、fetch( V ) は位置Vの現在の値を返す手続きであり、store( V , xvoid ) は値xを位置Vに格納する戻り値の型を持つ手続きです。制約は、読み取りと書き込みが整合しているという形で非公式に説明されます。多くのプログラミング言語と同様に、操作store( V , x ) はV ← x (または同様の表記) と書かれることが多く、変数V がfetch値を必要とするコンテキストで使用される場合は常に( V ) が暗黙的に使用されます。したがって、たとえば、V ← V + 1 は、 ( V , ( V ) + 1)の省略形として一般的に理解されています。storefetch
この定義では、名前は常に異なることが暗黙のうちに前提とされています。つまり、変数Uに値を格納しても、別の変数Vの状態には影響しません。この前提を明示的にするために、次の制約を追加することができます。
store( U , x ); store( V , ystore ) } は { ( V , y ); store( U , x ) }と同等です。この定義では、 Vが初期化されていないとき、つまりVに対して何らかの操作を実行する前にfetch( V )を評価した結果については何も述べていません。格納前にフェッチすることは、禁止することも、特定の結果となるように定義することも、未指定のままにすることもできます。このような操作が正当であるという前提に基づいて効率が決まるアルゴリズムもあり、変数の範囲内の任意の値を返します。storefetch
抽象スタックは後入れ先出し構造であり、一般的に次の3つの主要な操作によって定義されます。pushスタックにデータ項目を挿入する操作、popスタックからデータ項目を削除する操作、およびpeekスタックの最上位にあるデータ項目を削除せずにアクセスする操作です。完全な抽象スタック定義には、ブール値の関数(S)と、初期スタックインスタンスを返す操作()topも含まれます。emptycreate
公理的意味論では、スタック状態のタイプであり、スタックに含まれる値の型は、次の型を持つことができます。、、、、 そして公理的意味論では、初期スタックの作成は「自明な」操作であり、常に同じ識別状態を返します。そのため、Λや"()"のような特別な記号で表されることがよくあります。empty操作述語は、次のように簡単に記述できます。または。
制約はpop(push(S,v))=(S,v)、 、top(push(S,v))=v、[ 9 ]empty ( create) = T (新しく作成されたスタックは空である)、empty( push( S、x )) = F (スタックに何かをプッシュすると、スタックは空でなくなる) です。 これらの公理は、sが によって返されるスタックの状態でない限り、 top( s ) またはpop( s )の効果を定義しません。 はスタックを空にしないため、 s = Λの場合、これら 2 つの操作は無効であると定義できます。 これらの公理 (および副作用の欠如) から、(Λ、x ) ≠ Λ であることが推論できます。 また、( s、x ) = ( t、y )は、 x = yかつs = tの場合に限ります。pushpushpushpushpush
他の数学分野と同様に、スタックの状態は、公理から有限ステップで存在が証明できるものだけであると仮定するのが一般的です。この場合、すべてのスタックは有限個の値のシーケンスであり、有限回のpopsの後、空のスタック(Λ)になります。上記の公理だけでは、無限スタック(pop毎回異なる状態を生成する、永遠に実行可能なスタック)や循環スタック(有限回のsの後、同じ状態に戻るスタック)の存在を排除するものではありません。特に、 ( s ) = sまたは( s , x ) = sとなるような状態spopを排除するものではありません。しかし、与えられた操作では初期スタック状態からそのようなスタック状態を得ることができないため、それらは「存在しない」と仮定されます。poppush
抽象スタックの操作的定義では、push( S , x ) は何も返さず、pop( S ) は結果として値を返しますが、スタックの新しい状態は返しません。したがって、任意の値xと任意の抽象変数Vに対して、操作のシーケンス { push( S , x ); V ← pop( S )} はV ← xと同等であるという制約があります。定義により、代入V ← xはSの状態を変更できないため、この条件は、V ← pop( S )がS をpush( S , x )の前の状態に復元することを意味します。この条件と抽象変数の特性から、例えば、次のシーケンスが成り立ちます。
push( S , x ); push( S , y ); U ← pop( S ); push( S , z ); V ← pop( S ); W ← pop( S ) }ここで、x、y、zは任意の値であり、U、V、Wは互いに異なる変数である。これは以下と同等である。
公理的意味論とは異なり、操作的意味論ではエイリアシングが発生する可能性があります。ここでは、スタックインスタンスに対する操作は、他のスタックを含む他のADTインスタンスの状態を変更しないことが暗黙のうちに想定されています。つまり、次のようになります。
push( S、x ); push( T、ypush ) } は { ( T、y ); push( S、x ) }と同等です。より複雑な例としては、Boom のバイナリ ツリー、リスト、バッグ、セットの抽象データ型の階層構造が挙げられます。 [ 10 ]これらのデータ型はすべて、空のコンテナを構築するnull 、単一の要素からコンテナを構築するsingle 、同じ型の 2 つのコンテナを結合するappendという 3 つの操作によって宣言できます。これらの操作に対して次のルールを順次追加することで、4 つのデータ型の完全な仕様を与えることができます。
データへのアクセスは、3つの操作に対するパターンマッチングによって指定できます。たとえば、これらのコンテナのメンバ関数は次のようになります。
関数がデータ型に関する関連規則の下で不変であることを確認するために、注意を払う必要があります。選択された方程式のサブセットによって示される各同値類において、関数はそのすべての要素に対して同じ結果を返す必要があります。
さまざまなアプリケーションで有用であることが証明されている一般的なADTには、次のようなものがあります。
これらの抽象データ型(ADT)はそれぞれ、必ずしも同等ではない、さまざまな方法やバリエーションで定義できます。たとえば、抽象スタックには、countプッシュされたがまだポップされていない項目の数を示す操作がある場合とない場合があります。この選択は、クライアントだけでなく実装にも影響を与えます。
コンピュータグラフィックス用のADTの拡張として、1979年に抽象グラフィカルデータ型(AGDT)が提案されました[ 11 ]。これはNadia Magnenat ThalmannとDaniel Thalmannによって導入されました。AGDTは、 ADTの利点に加えて、グラフィカルオブジェクトを構造的に構築する機能を提供します。
抽象データ型は理論上の実体であり、(とりわけ)抽象アルゴリズムの記述を簡略化したり、データ構造を分類・評価したり、プログラミング言語の型システムを形式的に記述したりするために使用されます。しかし、抽象データ型は実装されることがあります。これは、各抽象データ型のインスタンスまたは状態が具体的なデータ型またはデータ構造によって表現され、各抽象操作に対応する手続きまたは関数が存在し、これらの実装された手続きが一定の基準まで抽象データ型の仕様と公理を満たすことを意味します。実際には、実装は完璧ではなく、ユーザーは表現や実装された手続きの制限による問題点を認識しておく必要があります。
例えば、整数は、0と1という区別された値、加算、減算、乗算、除算(ゼロ除算に注意)、比較などの演算によって定義される抽象データ型(ADT)として指定できます。ADTは、結合法則や交換法則など、抽象代数におけるおなじみの数学的公理に従って動作します。しかし、コンピュータでは、整数は一般的に固定幅の32ビットまたは64ビットの2進数として表現されます。ユーザーは、この表現における問題、例えば算術オーバーフロー(ADTが有効な結果を指定しているにもかかわらず、表現がその値を格納できない状態)に注意する必要があります。とはいえ、多くの場合、ユーザーはこれらの不備を無視して、抽象データ型であるかのように実装を使用できます。
通常、同じ抽象データ型(ADT)を実装する方法は複数あり、それぞれ異なる具体的なデータ構造を使用します。例えば、抽象スタックはリンクリストや配列で実装できます。同じ特性と機能を持つADTの異なる実装は、意味的に同等とみなすことができ、ADTを使用するコード内ではある程度互換的に使用できます。これは一種の抽象化またはカプセル化を提供し、さまざまな状況でADTオブジェクトを使用する際に大きな柔軟性をもたらします。例えば、ADTの異なる実装は、状況によって効率が異なる場合があります。それぞれの実装が望ましい状況で使用すれば、全体的な効率が向上します。インターフェースに従ってADT実装を使用するコードは、ADTの実装が変更されても引き続き動作します。
クライアントが実装に依存しないようにするため、ADTは多くの場合、不透明なデータ型または 何らかのハンドルとして、 1つ以上のモジュールにパッケージ化されます[ 12 ]。これらのモジュールのインターフェースには、操作のシグネチャ(パラメータと結果の数と型)のみが含まれます。モジュールの実装、つまりプロシージャの本体と使用される具体的なデータ構造は、モジュールのほとんどのクライアントから隠蔽できます。これにより、クライアントに影響を与えることなく実装を変更することが可能になります。実装が公開されている場合は、透過的なデータ型と呼ばれます。
C++やJavaなどの現代のオブジェクト指向言語は、抽象データ型の一種をサポートしています。クラスが型として使用されるとき、それは隠された表現を参照する抽象型です。このモデルでは、ADTは通常クラスとして実装され、ADTの各インスタンスは通常そのクラスのオブジェクトです。モジュールのインターフェースは通常、コンストラクタを通常のプロシージャとして宣言し、その他のほとんどのADT操作をそのクラスのメソッドとして宣言します。C++やJavaなどの多くの現代のプログラミング言語には、このスタイルで多数のADTを実装する標準ライブラリが付属しています。しかし、このようなアプローチでは、ADTに見られる複数の表現バリアントを容易にカプセル化することはできません。また、オブジェクト指向プログラムの拡張性を損なう可能性もあります。インターフェースを型として使用する純粋なオブジェクト指向プログラムでは、型は表現ではなく振る舞いを参照します。
一部のプログラミング言語の仕様では、特定の組み込みデータ型の表現方法について意図的に曖昧な表現を用い、それらに対して実行可能な操作のみを定義しています。そのため、これらの型は「組み込み抽象データ型(ADT)」とみなすことができます。例としては、Awk、Lua、Perlなどの多くのスクリプト言語における配列が挙げられ、これらは抽象リストの実装とみなすことができます。
形式仕様記述言語では、抽象データ型(ADT)を公理的に定義することができ、その言語はこれらのADTの値を操作することを可能にするため、簡潔かつ即時的な実装が実現されます。例えば、OBJ系のプログラミング言語では、仕様記述のための数式を定義し、それを書き換えて実行することができます。ただし、このような自動実装は、専用の実装ほど効率的ではない場合がほとんどです。
例として、上記の抽象スタックをC言語で実装した例を以下に示します。
命令型インターフェースの例としては、次のようなものが考えられます。
// 型: スタックインスタンス表現 (不透明なレコード) typedef struct { // ここに実装} Stack ;// 型: スタックインスタンスに格納される値 (任意のアドレス) typedef void * Item ;// 新しい空のスタックインスタンスを作成しますStack * stack_create ( void );// スタックの先頭にアイテムを追加しますvoid stack_push ( Stack * s , Item x );// スタックから最上位の項目を削除して返しますItem stack_pop ( Stack * s );// スタックが空かどうかをチェックしますbool stack_is_empty ( Stack * s );このインターフェースは、以下のように使用できます。
#include <stack.h>int main () { Stack * s = stack_create (); // 新しい空のスタックインスタンスを作成しますint x = 17 ;// x のアドレスをスタックの先頭に追加しますstack_push ( s , & x ); // x のアドレスをスタックから削除して返しますItem y = stack_pop ( s );if ( stack_is_empty ( s )) { // スタックが空の場合に何らかの処理を行うprintf ( "スタックは空です!" ); } }このインターフェースはさまざまな方法で実装できます。上記のADTの正式な定義では、スタックがどれだけのスペースを使用できるか、また各操作にどれだけの時間がかかるかが指定されていないため、実装は任意に非効率になる可能性があります。また、←sの呼び出し後にスタックの状態が継続するかどうかも指定されていません。xpop(s)
実際には、正式な定義では、スペースはプッシュされてまだポップされていないアイテムの数に比例すること、そして上記のすべての操作は、その数に関係なく一定の時間で完了する必要があることを明記する必要があります。これらの追加仕様を満たすために、実装ではリンクリスト、または(動的にサイズ変更可能な)配列と2つの整数(アイテム数と配列サイズ)を使用できます。
関数型プログラミング言語には関数型ADTの定義がより適しており、その逆もまた然りです。しかし、Cのような命令型言語でも関数型インターフェースを提供することは可能です。例えば、次のようになります。
// 型: スタックインスタンス表現 (不透明なレコード) typedef struct { // ここに実装} Stack ;// 型: スタックインスタンスに格納される値 (任意のアドレス) typedef void * Item ;// 空のスタック状態を返しますStack * stack_is_empty ( void );// スタック状態の先頭に項目を追加し、結果として得られるスタック状態を返しますStack * stack_push ( Stack * s , Item x );// スタック状態から最上位の項目を削除し、結果として得られるスタック状態を返しますStack * stack_pop ( Stack * s );// スタック状態の最上位項目を返しますItem stack_top ( Stack * s );