C#の足し算は評価スタックでどう処理されるのか

広告

次のC#コードを考えてみましょう。

int a = 2;
int b = 3;
int c = a + b;

Console.WriteLine(c);

実行結果は次のとおりです。

5

C#のコードだけを見ると、処理はとても単純です。

aに2を入れる
bに3を入れる
aとbを足す
結果をcに入れる
cを画面に表示する

しかし、C#がILという中間言語に変換されると、値は評価スタックを使って受け渡されます。

この記事では、変数を単なる箱として説明するのではなく、評価スタックの中で値がどのように動くかを順番に確認します。

ILは逆ポーランド記法と同じスタックベースの評価方式を使っています

評価スタックとは

評価スタックは、ILの命令が計算途中の値を一時的に置く場所です。

スタックには、最後に積んだ値から先に取り出すという特徴があります。

この仕組みを、LIFOといいます。

Last In, First Out
最後に入れたものを最初に取り出す

例えば、スタックへ先に 2 を積み、その後に 3 を積むと、次の状態になります。

スタックの上

┌─────┐
│  3  │ ← 最後に積んだ値
├─────┤
│  2  │
└─────┘

スタックの下

値を取り出すときは、上にある 3 から取り出されます。

評価スタックとローカル変数は別に考える

今回のプログラムには、3つのローカル変数があります。

int a = 2;
int b = 3;
int c = a + b;

ILでは、概念的に次の番号で管理されます。

ローカル変数番号
a0
b1
c2

この記事では、次の2つを区別して考えます。

ローカル変数
    値を保存しておく場所

評価スタック
    計算途中の値を一時的に置く場所

ローカル変数の値を計算に使うときは、その値をいったん評価スタックへ積みます。

計算結果を変数へ保存するときは、評価スタックから値を取り出します。

今回のコードをILで見る

最適化されていない場合、今回のコードは概念的に次のようなILになります。

ldc.i4.2
stloc.0

ldc.i4.3
stloc.1

ldloc.0
ldloc.1
add
stloc.2

ldloc.2
call void System.Console::WriteLine(int32)

それぞれの命令には、次の意味があります。

IL命令意味
ldc.i4.2整数2を評価スタックへ積む
ldc.i4.3整数3を評価スタックへ積む
stloc.0スタックの値をローカル変数0番へ保存する
stloc.1スタックの値をローカル変数1番へ保存する
ldloc.0ローカル変数0番の値をスタックへ積む
ldloc.1ローカル変数1番の値をスタックへ積む
addスタック上の2つの値を加算する
stloc.2スタックの値をローカル変数2番へ保存する
ldloc.2ローカル変数2番の値をスタックへ積む
callメソッドを呼び出す

たとえば今回のILでは、次のように考えると分かりやすいです。

IL命令元の意味日本語のイメージ
ldc.i4.2load constant int32 232ビット整数の定数2を積む
stloc.0store local 0ローカル変数0番へ保存する
ldloc.0load local 0ローカル変数0番を読み込む
addadd加算する
callcallメソッドを呼び出す

ldc.i4.2 の分解

ldc.i4.2

は、次のように分けて考えられます。

ldc = load constant
i4  = 4バイト整数、つまり32ビット整数
2   = 値2

つまり、

32ビット整数の定数2を評価スタックへ積む

という命令です。

C#の int は通常32ビット整数なので、ILでは i4 と表現されます。

ldloc.0 の分解

ldloc.0

は次の意味です。

ld    = load
loc   = local
0     = ローカル変数0番

つまり、

ローカル変数0番の値を評価スタックへ積む

という命令です。

今回のコードでは、ローカル変数0番が a なので、

ldloc.0

は、

a の値を評価スタックへ積む

という意味になります。

stloc.0 の分解

stloc.0

は次の意味です。

st    = store
loc   = local
0     = ローカル変数0番

つまり、

評価スタックの一番上の値をローカル変数0番へ保存する

という命令です。

add は略語というより英単語

add

は、英語の「加える」という意味の単語そのものです。

評価スタックの上にある2つの値を取り出し、加算して、その結果を再びスタックへ積みます。

call も英単語そのもの

call

も略語ではなく、英語の「呼び出す」です。

メソッドを呼び出す命令です。

覚え方

IL命令は、次のような部品に分けて読むと理解しやすくなります。

ld = load      読み込む
st = store     保存する
loc = local    ローカル変数
arg = argument 引数
c = constant   定数
i4 = int32     32ビット整数

たとえば、

ldarg.0

なら、

load argument 0

つまり、

引数0番を評価スタックへ積む

という意味です。

IL命令は、完全にランダムな記号ではなく、英語の動作名を短く組み合わせた表記になっています。

実際のILには、環境やビルド構成によって別の命令が加わることがあります。

ここでは、評価スタックの動きを理解するために必要な命令に絞って説明します。

手順1:整数2を評価スタックへ積む

最初のC#コードは次の行です。

int a = 2;

まず、次のIL命令が実行されます。

ldc.i4.2

ldc.i4.2 は、整数の 2 を評価スタックへ積む命令です。

実行前

評価スタック
┌─────┐
│ 空  │
└─────┘
ldc.i4.2 実行後

評価スタック
┌─────┐
│  2  │
└─────┘

この時点では、まだ 2 はローカル変数 a に保存されていません。

整数 2 が、評価スタックに一時的に置かれただけです。

手順2:スタックの2を変数aへ保存する

次に、次の命令が実行されます。

stloc.0

stloc.0 は、評価スタックの一番上にある値をローカル変数0番へ保存する命令です。

ローカル変数0番は a です。

実行前

評価スタック        ローカル変数
┌─────┐             a = 未設定
│  2  │             b = 未設定
└─────┘             c = 未設定
stloc.0 実行後

評価スタック        ローカル変数
┌─────┐             a = 2
│ 空  │             b = 未設定
└─────┘             c = 未設定

stloc は、スタックの値を取り出して変数へ保存します。

そのため、保存後の評価スタックは空になります。

手順3:整数3を評価スタックへ積む

次のC#コードは次の行です。

int b = 3;

まず、次のIL命令が実行されます。

ldc.i4.3

整数 3 が評価スタックへ積まれます。

評価スタック        ローカル変数
┌─────┐             a = 2
│  3  │             b = 未設定
└─────┘             c = 未設定

手順4:スタックの3を変数bへ保存する

次の命令は次のとおりです。

stloc.1

評価スタックの 3 を、ローカル変数1番へ保存します。

ローカル変数1番は b です。

評価スタック        ローカル変数
┌─────┐             a = 2
│ 空  │             b = 3
└─────┘             c = 未設定

ここまでで、ローカル変数は次の状態になります。

a = 2
b = 3
c = 未設定

評価スタックは空です。

手順5:変数aの値をスタックへ積む

次のC#コードでは、足し算を行います。

int c = a + b;

足し算を行うためには、まず a と b の値を評価スタックへ積む必要があります。

最初に、次の命令が実行されます。

ldloc.0

ldloc.0 は、ローカル変数0番の値を評価スタックへ積む命令です。

ローカル変数0番は a で、その値は 2 です。

評価スタック        ローカル変数
┌─────┐             a = 2
│  2  │             b = 3
└─────┘             c = 未設定

ここで重要なのは、ローカル変数 a の値が消えるわけではないという点です。

a の値である 2 が、評価スタックへコピーされます。

手順6:変数bの値をスタックへ積む

続いて、次の命令が実行されます。

ldloc.1

ローカル変数1番、つまり b の値である 3 が評価スタックへ積まれます。

評価スタック        ローカル変数
┌─────┐             a = 2
│  3  │ ← スタックの上
├─────┤             b = 3
│  2  │             c = 未設定
└─────┘

先に 2 が積まれ、その後に 3 が積まれています。

この時点では、まだ加算は行われていません。

手順7:add命令で2と3を加算する

次に、次の命令が実行されます。

add

add は、評価スタックの上にある2つの値を取り出して加算します。

実行前のスタックは次の状態です。

┌─────┐
│  3  │
├─────┤
│  2  │
└─────┘

add は、スタックから 3 と 2 を取り出します。

そして、次の計算を行います。

2 + 3 = 5

計算結果の 5 は、再び評価スタックへ積まれます。

add 実行後

評価スタック
┌─────┐
│  5  │
└─────┘

つまり、add 命令の動きは次のようになります。

加算前

┌─────┐
│  3  │
├─────┤
│  2  │
└─────┘

   ↓ add

加算後

┌─────┐
│  5  │
└─────┘

2つの値が取り出され、1つの計算結果に置き換わっています。

引き算や割り算では値の順番が重要

今回の演算は足し算です。

2 + 3
3 + 2

どちらも結果は 5 になるため、値の順番をあまり意識しなくても同じ結果になります。

しかし、引き算や割り算では順番が重要です。

例えば、評価スタックが次の状態だったとします。

┌─────┐
│  3  │
├─────┤
│  2  │
└─────┘

ここで引き算の命令を実行すると、概念的には次の計算になります。

2 - 3

後から積んだ 3 を左側にして、

3 - 2

と計算するわけではありません。

スタックを使う計算では、値を積む順番が演算結果に影響します。

今回の add では結果が同じですが、スタックの順序を理解することは重要です。

手順8:計算結果5を変数cへ保存する

add の実行後、評価スタックには 5 が積まれています。

次に、次の命令が実行されます。

stloc.2

ローカル変数2番は c です。

評価スタックの一番上にある 5 が、変数 c へ保存されます。

実行前

評価スタック        ローカル変数
┌─────┐             a = 2
│  5  │             b = 3
└─────┘             c = 未設定
stloc.2 実行後

評価スタック        ローカル変数
┌─────┐             a = 2
│ 空  │             b = 3
└─────┘             c = 5

これで、次のC#コードの処理が完了しました。

int c = a + b;

ローカル変数の状態は次のとおりです。

a = 2
b = 3
c = 5

手順9:変数cの値をスタックへ積む

最後に、次のコードを実行します。

Console.WriteLine(c);

WriteLine に c の値を渡すために、まず c の値を評価スタックへ積みます。

ldloc.2

ローカル変数2番は c で、その値は 5 です。

評価スタック        ローカル変数
┌─────┐             a = 2
│  5  │             b = 3
└─────┘             c = 5

手順10:WriteLineへ5を渡す

次に、Console.WriteLine を呼び出します。

call void System.Console::WriteLine(int32)

WriteLine(int32) は、整数を1つ引数として受け取ります。

その引数として、評価スタックの一番上にある 5 が使用されます。

評価スタック
┌─────┐
│  5  │
└─────┘
    │
    │ 引数として渡す
    ▼
Console.WriteLine(5)

その結果、コンソール画面に次の値が表示されます。

5

引数として使われた値は評価スタックから取り除かれるため、メソッド呼び出し後のスタックは空になります。

評価スタック
┌─────┐
│ 空  │
└─────┘

評価スタック全体の変化

コード全体で評価スタックがどのように変化したかをまとめます。

開始
空

ldc.i4.2
[2]

stloc.0
空

ldc.i4.3
[3]

stloc.1
空

ldloc.0
[2]

ldloc.1
[2, 3]

add
[5]

stloc.2
空

ldloc.2
[5]

call WriteLine
空

評価スタックは、値を積んだまま使い続ける場所ではありません。

必要な値を一時的に積み、計算やメソッド呼び出しで使用した後は、再び空になるという動きを繰り返します。

C#の1行が複数のIL命令になる

次のC#コードは1行です。

int c = a + b;

しかし、ILでは複数の命令に分かれます。

ldloc.0
ldloc.1
add
stloc.2

それぞれの処理を日本語にすると、次のようになります。

aの値をスタックへ積む
bの値をスタックへ積む
2つの値を加算する
結果をcへ保存する

C#では1行に見えても、内部では細かい処理へ分解されています。

評価スタックと実際のメモリ上のスタックは同じではない

ここで注意したいのは、ILの評価スタックと、コンピューターのメモリ上にあるスタック領域を完全に同じものとして考えないことです。

評価スタックは、ILの命令が値をやり取りするための仕組みです。

一方、実際のプログラムが使用するメモリ上のスタックは、メソッドの呼び出しやローカルデータなどを管理します。

種類主な役割
ILの評価スタック計算途中の値を受け渡す
メモリ上のスタックメソッド呼び出しなどを管理する
CPUレジスタCPU内部で値を高速に処理する

ILでは評価スタックを使って処理を表現しますが、JITコンパイラが機械語へ変換すると、CPUレジスタが使われることもあります。

そのため、ILの図と全く同じスタックが、そのままCPU内部に作られるわけではありません。

最適化されるとILどおりに動かないこともある

今回のコードでは、a と b の値が最初から決まっています。

int a = 2;
int b = 3;

そのため、コンパイラやJITコンパイラは、計算結果が 5 であることを事前に判断できる場合があります。

最適化によって、概念的には次のコードと同じように処理される可能性があります。

Console.WriteLine(5);

この場合、実際の機械語では、abc を個別に保存したり、実行時に 2 + 3 を計算したりしない可能性があります。

ただし、評価スタックの学習では、最適化される前の基本的なILの動きを理解することが重要です。

入力値を使う場合は実際に加算が必要になる

次のコードでは、値が実行時まで分かりません。

int a = int.Parse(Console.ReadLine());
int b = int.Parse(Console.ReadLine());

int c = a + b;

Console.WriteLine(c);

この場合、事前に計算結果を決めることはできません。

実行時に、入力された a と b の値を評価スタックへ積み、add 命令に相当する処理で実際に加算する必要があります。

入力されたaをスタックへ積む
入力されたbをスタックへ積む
addで加算する
結果をcへ保存する

固定値を使った例は評価スタックの基本を理解しやすい一方、入力値を使った例では、実行時の計算としての役割がより明確になります。

まとめ

今回のコードは次のとおりです。

int a = 2;
int b = 3;
int c = a + b;

Console.WriteLine(c);

ILでは、概念的に次のように処理されます。

2を評価スタックへ積む
2をaへ保存する

3を評価スタックへ積む
3をbへ保存する

aの値2を評価スタックへ積む
bの値3を評価スタックへ積む

add命令で2と3を加算する
計算結果5を評価スタックへ積む

5をcへ保存する

cの値5を評価スタックへ積む
5をWriteLineへ渡す
コンソールに5を表示する

特に重要なのは、次の部分です。

ldloc.0
ldloc.1
add
stloc.2

この4つの命令によって、

aの値を積む
bの値を積む
加算する
結果をcへ保存する

という処理が行われます。

評価スタックの動きを理解すると、C#の式が内部でどのような小さな命令に分解されているかが見えるようになります。

2と3を積む
      ↓
addで5にする
      ↓
5をcへ保存する
      ↓
WriteLineへ渡す

これが、今回のコードを評価スタックの視点から見た基本的な振る舞いです。

訪問数 1 回, 今日の訪問数 1回

広告