基本型(intなど)をクラスとして実装する場合の注意点は何ですか?


27

オブジェクト指向プログラミング言語を設計し、implentingすると、いくつかの点で1が基本型(のような実装の選択をする必要がありintfloatdoubleクラスまたは何か他のものとして、または同等の)。明らかに、Cファミリーの言語はそれらをクラスとして定義しない傾向があります(Javaには特別なプリミティブ型があり、C#はそれらを不変の構造体として実装します)。

基本型が(統一された階層を持つ型システムで)クラスとして実装される場合、非常に重要な利点を考えることができます。これらの型は、ルート型の適切なLiskovサブタイプになります。したがって、ボクシング/アンボクシング(明示的または暗黙的)、ラッパータイプ、特別な分散ルール、特別な動作などで言語を複雑にすることは避けます。

もちろん、言語デザイナーがその方法を決定する理由を部分的に理解することができます:クラスインスタンスは、空間のオーバーヘッドを持っている傾向がある(インスタンスのメモリレイアウトにvtableまたはその他のメタデータが含まれている可能性があるため) have(言語がそれらの継承を許可しない場合)

基本型がクラスではないことが多いのは、空間効率(および特に大きな配列での空間的局所性の向上)だけですか?

私は一般的に答えがイエスであると仮定しましたが、コンパイラにはエスケープ分析アルゴリズムがあり、インスタンス(基本タイプだけでなく任意のインスタンス)が厳密に証明されたときに空間的オーバーヘッドを(選択的に)省略できるかどうかを推測できます地元。

上記は間違っていますか、それとも私が見逃しているものがありますか?


回答:


19

はい、それはほとんど効率に帰着します。しかし、その影響を過小評価しているようです(または、さまざまな最適化の効果を過大評価しているようです)。

まず、それは単なる「空間オーバーヘッド」ではありません。プリミティブをボックス化/ヒープ割り当てすると、パフォーマンスコストもかかります。これらのオブジェクトを割り当てて収集するには、GCに追加のプレッシャーがあります。「プリミティブオブジェクト」が不変である場合、これは二重になります。次に、キャッシュミスが多くなります(間接性のため、および指定された量のキャッシュに収まるデータが少ないためです)。さらに、「オブジェクトのアドレスをロードしてから、そのアドレスから実際の値をロードする」という事実は、「値を直接ロードする」よりも多くの指示を必要とします。

第二に、脱出分析は、妖精の塵よりも速くはありません。これは、エスケープしない値にのみ適用されます。ローカル計算(ループカウンターや計算の中間結果など)を最適化することは確かに素晴らしいことであり、測定可能なメリットが得られます。しかし、はるかに多くの値がオブジェクトと配列のフィールドに住んでいます。確かに、これらはエスケープ分析自体の対象になる可能性がありますが、それらは通常可変の参照型であるため、それらのエイリアスはエスケープ分析に大きな課題を提示し、今ではそれらのエイリアス(1)もエスケープしないことを証明する必要があります、および(2)割り当てを削除する目的で違いを生じさせません。

(ゲッターを含む)任意のメソッドを呼び出すかにオブジェクトを引数として渡すことを考えると任意のオブジェクトの脱出を助けることができる他の方法は、あなたがすべての中間分析が、ほとんど些細な例が必要になります。これははるかに高価で複雑です。

そして、物事が本当に逃げ出し、合理的に最適化できない場合があります。実際、Cプログラマーがヒープ割り当ての問題をどのくらいの頻度で経験するかを考えると、かなり多くの人がそうです。intを含むオブジェクトがエスケープすると、エスケープ分析はintにも適用されなくなります。効率的なプリミティブフィールドに別れを告げます

これは別のポイントにつながります。必要な分析と最適化は非常に複雑で、研究の活発な分野です。言語実装があなたが提案する最適化の程度を達成したかどうかは議論の余地がありますが、たとえそうであっても、それはまれで非常に困難な努力でした。確かにこれらの巨人の肩の上に立つことは、自分で巨人になるよりも簡単ですが、それでも些細なことからはほど遠いです。最初の数年間は、競争力のあるパフォーマンスを期待しないでください。

それは、そのような言語が実行可能でないということではありません。明らかにそうです。専用のプリミティブを備えた言語のように、行ごとに高速になるとは限りません。言い換えれば、十分に賢いコンパイラーのビジョンに惑わされないでください。


エスケープ分析について話すとき、私は自動ストレージに割り当てることも意味しました(すべてを解決するわけではありませんが、あなたが言うように、それはいくつかの問題を解決します)。また、フィールドとエイリアシングによってエスケープ分析が失敗する頻度を過小評価していたことも認めています。キャッシュミスは、空間効率について話しているときに私が最も懸念していたことです。
セオドロスチャツィジアンナキス

@TheodorosChatzigiannakis私はエスケープ分析に割り当て戦略の変更を含めています(正直なところ、それが今までに使用された唯一のものだと思われるため)。

2番目の段落について:オブジェクトは、必ずしもヒープに割り当てられている必要も、参照型である必要もありません。実際、そうでない場合、これにより必要な最適化が比較的簡単になります。初期の例についてはC ++のスタックに割り当てられたオブジェクトを、言語にエスケープ分析を直接ベイクする方法についてはRustの所有者システムを参照してください。
アモン

@amon私は知っていますが、おそらくそれをもっと明確にすべきだったかもしれませんが、OPはJavaおよびC#のような言語にのみ興味があり、参照セマンティクスとサブタイプ間のロスレスキャストのためにヒープ割り当てがほぼ必須(および暗黙的)であるようです。しかし、分析を逃れる量を使用するRustについての良い点!

@delnanストレージの詳細を抽象化する言語に主に興味があるのは事実ですが、それらの言語に適用されない場合でも、関連すると思われるものを自由に含めてください。
セオドロスチャツィジアンナキス

27

基本型がクラスではないことが多いのは、空間効率(および特に大きな配列での空間的局所性の向上)だけですか?

いや

もう1つの問題は、基本型が基本操作で使用される傾向があることです。コンパイラはint + int、関数呼び出しにコンパイルされるのではなく、基本的なCPU命令(または同等のバイトコード)にコンパイルされることを知る必要があります。その時点でint、通常のオブジェクトとして持っている場合は、とにかく効果的に箱を開ける必要があります。

これらの種類の操作も、サブタイプ化とうまく機能しません。CPU命令にディスパッチすることはできません。CPU命令からディスパッチすることはできません。サブタイプの全体的なポイントは、aを使用Dできる場所を使用できることBです。CPU命令はポリモーフィックではありません。そのためのプリミティブを取得するには、単純な追加(またはその他)として操作の数倍のコストがかかるディスパッチロジックで操作をラップする必要があります。int型階層の一部であることの利点は、封印された/最終的なものである場合に少し意味がなくなります。そして、それは二項演算子のディスパッチロジックに関するすべての頭痛を無視しています...

基本的に、プリミティブ型には、コンパイラがそれらを処理する方法と、とにかくユーザーがそれらの型で何ができるかに関する多くの特別なルールが必要になるので、それらを完全に別個のものとして扱うほうがしばしば簡単です。


4
オブジェクトなどの整数を扱う動的に型付けされた言語の実装をチェックしてください。最終的なプリミティブCPU命令は、ランタイムライブラリのわずかに特権のあるクラス実装のメソッド(演算子オーバーロード)に非常によく隠れています。静的型システムとコンパイラでは詳細が異なって見えますが、根本的な問題ではありません。最悪の場合、事態はさらに遅くなります。

3
int + intネイティブCPU整数加算opへのコンパイル(または動作)が保証されている組み込み命令を呼び出す通常の言語レベルの演算子にすることができます。intから継承することの利点は、からobject別の型を継承する可能性だけでなく、ボクシングなしで動作intする可能性もあります。C#ジェネリックを検討してください:共分散と反分散を使用できますが、それらはクラス型にのみ適用可能です-構造型は(暗黙的、コンパイラ生成)ボクシングのみを行うため、自動的に除外されます。intobjectobject
セオドロスチャツィジアンナキス

3
@delnan-静的に型付けされた実装の私の経験では、確かに、すべての非システムコールはプリミティブ操作に集約されるため、オーバーヘッドがあるとパフォーマンスに劇的な影響があり、これが採用にさらに劇的な影響を及ぼします。
テラスティン

@TheodorosChatzigiannakis-素晴らしいので、有用なサブ/スーパータイプを持たないタイプで分散と反分散を得ることができます...そして、CPU命令を呼び出すためにその特別な演算子を実装することはまだ特別です。私はこの考えに異議を唱えていません-私は自分のおもちゃの言語で非常に似たようなことをしましたが、実装中にあなたが期待するほどきれいにしない実用的な落とし穴があることがわかりました。
テラスティン

1
@TheodorosChatzigiannakisライブラリの境界を越えてインライン展開することは確かに可能ですが、それは「私が持ちたいハイエンドの最適化」ショッピングリストのさらに別のアイテムです。役に立たないほど保守的でなくても完全に正しいことをすることは悪名高いことで悪名高いことを指摘する義務があります。

4

完全なオブジェクトになるために「基本型」が必要な場合はほとんどありません(ここで、オブジェクトとは、ディスパッチメカニズムへのポインタを含むデータ、またはディスパッチメカニズムで使用できるタイプのタグが付けられたデータです)。

  • ユーザー定義型が基本型から継承できるようにする必要があります。これは、パフォーマンスやセキュリティに関連する頭痛の種になるため、通常は望ましくありません。コンパイルがint特定の固定サイズを持つことやメソッドがオーバーライドされていないことをコンパイルが想定できないため、パフォーマンスの問題であり、intsのセマンティクスが破壊される可能性があるため、セキュリティ上の問題です不変ではなく、その値を変更します)。

  • プリミティブ型にはスーパータイプがあり、プリミティブ型のスーパータイプの型を持つ変数が必要です。たとえば、intHashableであり、Hashable通常のオブジェクトだけでなくを受信する可能性のあるパラメーターを受け取る関数を宣言するとしますint

    これは、そのような型を違法にすることで「解決」できます。サブタイピングを取り除き、インターフェースが型ではなく型制約であることを決定します。明らかにそれはあなたのタイプシステムの表現力を低下させ、そのようなタイプシステムはもはやオブジェクト指向と呼ばれなくなります。この戦略を使用する言語については、Haskellを参照してください。プリミティブ型にはスーパータイプがないため、C ++はその中間にあります。

    別の方法は、基本型の完全または部分的なボクシングです。ボクシングの種類は、ユーザーに表示される必要はありません。基本的に、基本型ごとに内部ボックス型を定義し、ボックス型と基本型の間の暗黙的な変換を行います。ボックス化された型のセマンティクスが異なる場合、これは厄介になる可能性があります。Javaには2つの問題があります。ボックス化された型には同一性の概念がありますが、プリミティブには値の等価性の概念しかありません。これらの問題は、値の型にIDの概念を提供しないこと、オペレーターのオーバーロードを提供すること、およびデフォルトですべてのオブジェクトをヌル可能にしないことによって完全に回避できます。

  • 静的型付けは機能しません。変数は、プリミティブ型またはオブジェクトを含む任意の値を保持できます。したがって、厳密な型指定を保証するには、すべてのプリミティブ型を常にボックス化する必要があります。

静的型付けを行う言語は、可能な限りプリミティブ型を使用し、最後の手段としてボックス化された型にのみフォールバックするようにします。多くのプログラムはパフォーマンスにそれほど敏感ではありませんが、プリミティブ型のサイズと構成が非常に重要な場合があります。数十億のデータポイントをメモリに収める必要がある大規模な数値計算を考えてください。からdoubleへの切り替えfloatCで実行可能なスペース最適化戦略かもしれませんが、すべての数値型が常にボックス化されている場合はほとんど効果がありません(したがって、ディスパッチメカニズムポインターに少なくとも半分のメモリを浪費します)。ボックス化されたプリミティブ型がローカルで使用される場合、コンパイラ組み込み関数を使用してボックス化を削除することはかなり簡単ですが、「十分に高度なコンパイラ」で言語の全体的なパフォーマンスを賭けることは近視眼的です。


intすべての言語でan は不変ではありません。
スコットホイットロック

6
@ScottWhitlockなぜそう思うのかはわかりますが、一般にプリミティブ型は不変の値型です。健全な言語では、7の値を変更できません。ただし、多くの言語では、プリミティブ型の値を保持する変数を別の値に再割り当てできます。Cのような言語では、変数は名前付きのメモリ位置であり、ポインターのように機能します。変数は、それが指す値とは異なります。int値は不変ですが、int変数ではありません。
アモン

1
@amon:健全な言語はありません。Javaのみ:thedailywtf.com/articles/Disgruntled-Bomb-Java-Edition
Mason Wheeler

get rid of subtyping and decide that interfaces aren't types but type constraints.... such a type system wouldn't be called object-oriented any longer しかし、これはプロトタイプベースのプログラミングのように聞こえますが、これは間違いなくOOPです。
マイケル

1
@ScottWhitlock質問は、int b = aを持っている場合、aの値を変更する何かをbにできるかどうかです。これが可能な言語の実装がいくつかありましたが、配列に対して同じことをするのとは異なり、一般に病理学的で望ましくないと考えられています。
Random832

2

私が知っているほとんどの実装は、そのようなクラスに3つの制限を課しています。これにより、ほとんどの場合、コンパイラは基本型としてプリミティブ型を効率的に使用できます。これらの制限は次のとおりです。

  • 不変性
  • 最終性(派生することはできません)
  • 静的型付け

コンパイラプリミティブを基本表現のオブジェクトにボックス化する必要がある状況は、Object参照がそれを指している場合など、比較的まれです。

これにより、コンパイラーに特別なケース処理がかなり追加されますが、神話上の超高度なコンパイラーだけに限定されません。その最適化は、主要言語の実際の実動コンパイラーで行われます。Scalaでは、独自の値クラスを定義することもできます。


1

Smalltalkでは、それらすべて(int、floatなど)はファーストクラスオブジェクトです。唯一の特殊なケースはSmallIntegersが成文化と効率のために仮想マシンによって異なって処理され、従ってSmallIntegerクラスはサブクラスを認めないだろうしていることである(実用的で限定されない。)、これは特別な配慮を必要としないことに注意してくださいプログラマー側では、区別はコード生成やガベージコレクションなどの自動ルーチンに限定されます。

Smalltalk Compiler(ソースコード-> VMバイトコード)とVM nativizer(バイトコード->マシンコード)は、生成されたコード(JIT)を最適化し、これらの基本オブジェクトでの基本操作のペナルティを軽減します。


1

私はオブジェクト指向言語とランタイムを設計していました(これはまったく異なる理由で失敗しました)。

intの真のクラスのようなものを作成することに本質的に問題はありません。実際、これにより、3(クラス、配列、およびプリミティブ)ではなく2種類のヒープヘッダー(クラスおよび配列)しか存在しないため、GCの設計が容易になります[この後、クラスと配列をマージできるという事実は関係ありません]。

プリミティブ型のほとんどの重要なケースは、ほとんどがfinal / sealedメソッドである必要があります(実際、ToStringはそれほど重要ではありません)。これにより、コンパイラは、関数自体のほとんどすべての呼び出しを静的に解決し、インライン化できます。ほとんどの場合、これはコピー動作として重要ではありません(言語レベルで埋め込みを使用可能にすることを選択しました(.NETも同様))が、メソッドがシールされていない場合、コンパイラはint + intの実装に使用される関数。

弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.