コンパイラが「複雑な」式を静的に型チェックするときに使用される一般的な手順は何ですか?


23

注:タイトルで「複雑」を使用したとき、式には多くの演算子とオペランドがあることを意味します。式自体が複雑なわけではありません。


私は最近、x86-64アセンブリの単純なコンパイラに取り組んでいます。コンパイラのメインフロントエンド(レクサーとパーサー)を終了し、プログラムの抽象構文ツリー表現を生成できるようになりました。そして、私の言語は静的に型付けされるので、次のフェーズ、つまりソースコードの型チェックを実行しています。しかし、私は問題に遭遇し、自分で合理的に解決することができませんでした。

次の例を考えてみましょう。

私のコンパイラのパーサーは、次のコード行を読み取りました。

int a = 1 + 2 - 3 * 4 - 5

そして、それを次のASTに変換しました:

       =
     /   \
  a(int)  \
           -
         /   \
        -     5
      /   \
     +     *
    / \   / \
   1   2 3   4

次に、ASTを入力する必要があります。最初に=演算子をチェックするタイプから始まります。まず、オペレーターの左側をチェックします。変数aが整数として宣言されていることがわかります。そのため、右側の式が整数に評価されることを確認する必要があります。

式が1or などの単一の値である場合、これがどのように行われるかを理解しています'a'。しかし、複数の値とオペランドを持つ -上記のような複雑な式 -では、これはどのように行われますか?式の値を正しく判断するには、型チェッカーが実際に式自体を実行し、結果を記録する必要があるようです。しかし、これは明らかにコンパイル段階と実行段階を分離する目的を無効にしているようです。

私がこれを行うことができると思う他の唯一の方法は、ASTの各部分式のリーフを再帰的にチェックし、リーフのすべてのタイプが期待される演算子タイプと一致することを確認することです。そのため、=演算子から始めて、型チェッカーは左側のすべてのASTをスキャンし、リーフがすべて整数であることを確認します。次に、部分式の各演算子に対してこれを繰り返します。

「The Dragon Book」のコピーでトピックを調査しようとしましたが、あまり詳細に説明されていないようで、すでに知っていることを繰り返します。

コンパイラが多くの演算子とオペランドを含む式を型チェックするときに使用される通常の方法は何ですか?上記の方法のいずれかが使用されていますか?そうでない場合、メソッドは何であり、どのように正確に機能しますか?


8
式の型を確認する明白で簡単な方法があります。「不快」と呼ぶものを教えてください。
gnasher729

12
通常の方法は「2番目の方法」です。コンパイラは、その部分式のタイプから複雑な式のタイプを推測します。これが表示的意味論の主要なポイントであり、今日まで作成されたほとんどのタイプシステムです。
Joker_vD

5
2つのアプローチでは異なる動作が生じる可能性があります。トップダウンアプローチでdouble a = 7/2 は、右側をdoubleとして解釈しようとするため、分子と分母をdoubleとして解釈し、必要に応じて変換しようとします。結果としてa = 3.5。ボトムアップは整数除算を実行し、最後のステップ(割り当て)でのみ変換しa = 3.0ます。
ハーゲンフォンアイゼン

3
あなたのASTの絵は、あなたの表現に対応していないことに注意してくださいint a = 1 + 2 - 3 * 4 - 5それにint a = 5 - ((4*3) - (1+2))
バジーレStarynkevitch

22
値ではなく型の式を「実行」できます。例えばにint + intなりintます。

回答:


14

再帰が答えですが、操作を処理する前に各サブツリーに降ります:

int a = 1 + 2 - 3 * 4 - 5

ツリー形式へ:

(assign (a) (sub (sub (add (1) (2)) (mul (3) (4))) (5))

型の推測は、最初に左側、次に右側を歩いて、オペランドの型が推測されるとすぐに演算子を処理することによって行われます。

(assign*(a) (sub (sub (add (1) (2)) (mul (3) (4))) (5))

-> lhsに降りる

(assign (a*) (sub (sub (add (1) (2)) (mul (3) (4))) (5))

->推測しaます。aであることが知られていますintassignノードに戻りました。

(assign (int:a)*(sub (sub (add (1) (2)) (mul (3) (4))) (5))

-> rhsに降りてから、何か面白いものが見つかるまで、内部演算子のlhsに入ります。

(assign (int:a) (sub*(sub (add (1) (2)) (mul (3) (4))) (5))
(assign (int:a) (sub (sub*(add (1) (2)) (mul (3) (4))) (5))
(assign (int:a) (sub (sub (add*(1) (2)) (mul (3) (4))) (5))
(assign (int:a) (sub (sub (add (1*) (2)) (mul (3) (4))) (5))

->のタイプを推測します1。これはint、親に戻ります

(assign (int:a) (sub (sub (add (int:1)*(2)) (mul (3) (4))) (5))

-> rhsに入る

(assign (int:a) (sub (sub (add (int:1) (2*)) (mul (3) (4))) (5))

->のタイプを推測します2。これはint、親に戻ります

(assign (int:a) (sub (sub (add (int:1) (int:2)*) (mul (3) (4))) (5))

->のタイプを推測しますadd(int, int)。これはint、親に戻ります

(assign (int:a) (sub (sub (int:add (int:1) (int:2))*(mul (3) (4))) (5))

-> rhsに降りる

(assign (int:a) (sub (sub (int:add (int:1) (int:2)) (mul*(3) (4))) (5))

など、あなたが終わるまで

(assign (int:a) (int:sub (int:sub (int:add (int:1) (int:2)) (int:mul (int:3) (int:4))) (int:5))*

割り当て自体も型を持つ式であるかどうかは、言語によって異なります。

重要なポイント:ツリー内の演算子ノードの型を決定するには、その直接の子だけを見る必要があります。子は既に型に割り当てられている必要があります。


43

コンパイラが多くの演算子とオペランドを持つ式を型チェックするときに通常使用される方法は何ですか。

型システム型推論、および統一を使用するHindley-Milner型システムに関するwikiページを読んでください。表示的意味論操作的意味論についても読んでください。

次の場合、型チェックはより簡単になります。

  • などのすべての変数aは、型で明示的に宣言されます。これは、C、Pascal、またはC ++ 98に似ていますが、と型推論されているC ++ 11には似ていませんauto
  • すべてのリテラル値が好む12または'c'固有のタイプがあります。intリテラル常にタイプがありint、文字リテラル、常にタイプがありchar、...。
  • 関数と演算子はオーバーロードされません。たとえば、+演算子の型は常にtype (int, int) -> intです。Cには、演算子のオーバーロードがあります(+符号付きおよび符号なし整数型とdoubleで機能します)が、関数のオーバーロードはありません。

これらの制約の下では、ボトムアップの再帰的なAST型装飾アルゴリズムで十分です(具体的な値ではなくのみを対象とするため、コンパイル時のアプローチです)。

  • スコープごとに、表示されるすべての変数のタイプ(環境と呼ばれる)のテーブルを保持します。宣言の後、int aエントリa: intをテーブルに追加します。

  • 葉の入力は簡単な再帰の基本ケースです。たとえば、リテラルのタイプは1既知であり、変数のタイプはa環境で検索できます。

  • (ネストされた部分式)オペランドの以前に計算されたタイプに従って、いくつかの演算子とオペランドを使用して式を入力するには、オペランドで再帰を使用し(したがって、これらの部分式を最初に入力します)、演算子に関連する入力規則に従います。

だからあなたの例では、 4 * 31 + 2入力されますintので、4312以前に入力されていますintし、あなたの型付け規則は、2つの和や積と言うint-sがありint、そしてそのために(4 * 3) - (1 + 2)

次に、Pierceの型とプログラミング言語の本を読んでください。Ocamlλ-calculusを少し学ぶことをお勧めします

より動的に型付けされた言語(Lispなど)については、QueennecのLisp In Small Piecesも参照してください。

スコットのプログラミング言語の語用論書も読んでください

ところで、型システムは言語のセマンティクスの重要な部分であるため、言語に依存しないタイピングコードを持つことはできません。


2
C ++ 11の方がauto簡単ではないのはどうしてですか?それがなければ、右側の型を把握し、左側の型と一致または変換があるかどうかを確認する必要があります。ではauto、あなただけの右側の種類を把握し、設定は完了です。
-nwp

3
@nwp C ++ auto、C#var、およびGo :=変数定義の一般的な考え方は非常に簡単です。定義の右側を型チェックします。結果の型は、左側の変数の型です。しかし、悪魔は詳細にあります。たとえば、C ++定義は自己参照型であるため、rhsで宣言されている変数を参照できます(例:)int i = f(&i)。のタイプiが推測されると、上記のアルゴリズムは失敗します:のタイプiを推測するには、のタイプを知る必要がありますi。代わりに、型変数を使用した完全なHMスタイルの型推論が必要になります。
アモン

13

C(および率直に言って、Cに基づいて最も静的に型付けされた言語)では、すべての演算子は関数呼び出しの構文糖衣と見なすことができます。

したがって、式は次のように書き換えることができます。

int a{operator-(operator-(operator+(1,2),operator*(3,4)),5)};

次に、オーバーロード解決が開始され、すべての関数が(int, int)or (const int&, const int&)型であると決定されます。

この方法により、型解決が理解しやすくなり、(さらに重要なことですが)実装しやすくなります。型に関する情報は、1つの方法でのみ流れます(内側の表現から外側へ)。

これがint式として評価されるため、double x = 1/2;結果となるx == 0理由1/2です。


6
Cについてほぼ真+(それがために、異なるタイプ有するので関数呼び出しのように処理されないdoubleとのためのintオペランド)
バジーレStarynkevitch

2
@BasileStarynkevitch:オーバーロードされた一連の機能のように実装されます:operator+(int,int)operator+(double,double)operator+(char*,size_t)、などパーサはただのトラック保つために持っているものがどの選択されていることを。
Mooingダック

3
@ascheplerソースレベルと仕様レベルで、Cが実際にオーバーロードされた関数または演算子関数を持っていることを示唆している人はいませんでした
cat

1
もちろん違います。Cパーサーの場合、「関数呼び出し」は他に対処する必要があるものであり、実際にはここで説明する「関数呼び出しとしての演算子」とあまり共通点がないことを指摘してください。実際、Cではのタイプをf(a,b)理解することはのタイプを理解するよりもかなり簡単ですa+b
アシェプラー

2
妥当なCコンパイラには複数のフェーズがあります。フロントの近く(プリプロセッサの後)に、ASTを構築するパーサーがあります。ここで、演算子が関数呼び出しではないことはかなり明らかです。ただし、コード生成では、どの言語構造がASTノードを作成したかは気にしなくなりました。ノード自体のプロパティによって、ノードの処理方法が決まります。特に、+は関数呼び出しになる可能性があります-これは、エミュレートされた浮動小数点演算を備えたプラットフォームでよく発生します。エミュレートされたFP数学を使用する決定は、コード生成で行われます。以前のASTの違いは必要ありません。
–MSalters

6

アルゴリズムに焦点を合わせて、ボトムアップに変更してみてください。pf変数と定数のタイプを知っています。演算子を持つノードに結果タイプのタグを付けます。リーフがオペレーターのタイプを決定するようにします。これもあなたの考えの反対です。


6

+単一の概念ではなく、さまざまな機能であると考える限り、実際には非常に簡単です。

    int operator=(int)
     /   \
  a(int)  \
        int operator-(int,int)
         /                  \
    int operator-(int,int)    5
         /              \
int operator+(int,int) int operator*(int,int)
    / \                      / \
   1   2                    3   4

右側の構文解析段階で、パーサーはそれを取得し1、それを知りint、それから+「未解決の関数名」として保存し、それを解析し2intそれがわかって、それをスタックに返します。+機能ノードは現在、両方のパラメータの型を知っているので、解決することができる+にはint operator+(int, int)、今、それはこのサブ式の型を知っているし、パーサはそれの陽気な方法で続けています。

ご覧のとおり、ツリーが完全に構築されると、関数呼び出しを含む各ノードはそのタイプを認識します。これは、パラメーターとは異なる型を返す関数を許可するため、重要です。

char* ptr = itoa(3);

ここで、ツリーは次のとおりです。

    char* itoa(int)
     /           \
  ptr(char*)      3

4

型チェックの基本は、コンパイラが行うことではなく、言語が定義することです。

C言語では、すべてのオペランドに型があります。「abc」の型は「const charの配列」です。1のタイプは「int」です。1Lのタイプは「long」です。xとyが式である場合、x + yなどのタイプに関するルールがあります。そのため、コンパイラは明らかに言語のルールに従う必要があります。

Swiftのような現代の言語では、ルールははるかに複雑です。Cのように単純な場合もあります。他の場合、コンパイラは式を見て、式にどの型が必要かを事前に通知され、それに基づいて部分式の型を決定します。xとyが異なるタイプの変数であり、同一の式が割り当てられている場合、その式は異なる方法で評価される可能性があります。たとえば、12 *(2/3)を割り当てると、Doubleに8.0が、Intに0が割り当てられます。また、2つのタイプが関連していることをコンパイラが認識しており、それらに基づいてどのタイプであるかを把握する場合があります。

迅速な例:

var x: Double
var y: Int

x = 12 * (2 / 3)
y = 12 * (2 / 3)

print (x, y)

「8.0、0」を出力します。

割り当てx = 12 *(2/3)の場合:左側の型は既知のDoubleであるため、右側の型はDoubleでなければなりません。Doubleを返す「*」演算子のオーバーロードは1つだけで、これはDouble * Double-> Doubleです。したがって、12は2/3と同様にDouble型でなければなりません。12は「IntegerLiteralConvertible」プロトコルをサポートします。Doubleには、「IntegerLiteralConvertible」型の引数を取る初期化子があるため、12はDoubleに変換されます。2/3にはDouble型が必要です。Doubleを返す「/」演算子のオーバーロードは1つだけで、これはDouble / Double-> Doubleです。2と3はDoubleに変換されます。2/3の結果は0.6666666です。12 *(2/3)の結果は8.0です。8.0がxに割り当てられます。

割り当てy = 12 *(2/3)では、左側のyの型はIntであるため、右側の型はIntである必要があります。したがって、12、2、3は結果2/3 =でIntに変換されます。 0、12 *(2/3)= 0

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