C#小ネタと言いつつ、IL小ネタになりがちだったので、今日はC#小ネタらしく。
最初にちょっとしたクイズ。 まず、中身は何でもいいんですが適当な2引数のメソッドを用意します。 例として、単純な足し算でも用意しておきましょう。
static int F(int x, int y) => x + y;
以下の2つのコードの挙動は同じでしょうか?違うでしょうか?
1つ目: 一時変数を使用
var temp = F(2, 3);
var result = F(1, temp);
2つ目: 1つの式で計算
var result = F(1, F(2, 3));
まあ、同じですね。副作用を残さない限りは。
オペランドの評価順序
ということで、今日はオペランドの評価順の話です。 上記の2つのコードを、わざと副作用付きに書き換えてみます。
そのためにとりあえず、副作用を起こすメソッドを追加。
Console
にログ出力した後、引数を素通しするだけのメソッドです。
値渡し版と参照渡し版を用意。
// WriteLine + 素通し
static T Log<T>(T x) { Console.WriteLine(x); return x; }
static ref T Log<T>(ref T x) { Console.WriteLine(x); return ref x; }
1つ目(一時変数を使用)を改めて:
var temp = F(Log(2), Log(3));
var result = F(Log(1), temp);
2
3
1
2つ目(1つの式で計算)を改めて:
var result = F(Log(1), F(Log(2), Log(3)));
1
2
3
C#では、式の評価は、上から下へ、左から右へ逐次実行です。 なので、一時変数を導入すると結果が変わります。
1つの式の中でも、演算子の優先順位や結合方向とは無関係に、評価は一律左から右です。 代入(優先度が低い上に右から左に結合)が混ざっていようと、そのオペランドの評価は左から右です。
例えば以下の通り。
bool x = false, y = true;
Log(ref x) = Log(ref y) = Log(1) + Log(2) * Log(3) > Log(4) & Log(5) <= Log(6) - Log(7) | Log(8) == Log(9);
False
True
1
2
3
4
5
6
7
8
9
名前付き引数のオペランド評価
前節の結果はそんなに不思議なことないでしょう。コードから挙動を予想しやすいって意味では書かれてる順番通りが一番です。それに、パフォーマンス的にも悪い選択ではありません。例えば、
var result = F(1, F(2, 3));
というようなコードであれば、コンパイル結果は以下のような感じになります(必要なところを抜粋)。
ldc.i4.1
ldc.i4.2
ldc.i4.3
call F
call F
元のC#のオペランドと同じ、1, 2, 3の順でldc
(load constant)しています。
副作用を起こすためにLog
メソッドを挟む場合、このldc
のところが数命令に置き換わりますが、命令の並ぶ順序はこの場合と同じです。
ということで、素直に実装すればいいだけ…
でもなくて、まあ、ほとんどの式は素直に実装していいんですが、一部めんどくさい奴がいます。例えば、名前付き引数。
以下のようなコードを書いたとします。
x
, y
を逆に並べています。
F(1, F(y: 2, x: 3));
すると、コンパイル結果は以下の通り。C#ソースコード上は1, 2, 3だったものが、IL的には1, 3, 2になります。
ldc.i4.1
ldc.i4.3
ldc.i4.2
call F
call F
この結果は副作用がないからこそ、C#コンパイラーの最適化が掛かってこうなっています。 副作用がないことがわかっている場合、評価順を並べ替えを行います。
一方で、副作用があるとそうはいきません。あくまで、C#では左から右への評価が必要です。
例えば以下のようなコードをでは、ちゃんと、1, 2, 3の順での評価が必要です。
F(Log(1), F(y: Log(2), x: Log(3)));
コンパイル結果は以下の通りです。これまでは必要のなかった一時変数(stloc
: ローカル変数へのストア)が必要になります。
ldc.i4.1
call Log
ldc.i4.2
call Log
stloc.0 // 一時変数!
ldc.i4.3
call Log
ldloc.0
call F
call F
ちなみに、これ、C# 4.0の時にはバグってて評価順が狂ってた(逆順になってた)そうです。 C# 5.0でバグ修正した結果、破壊的変更になっていたり(めったにこんなコード書かない上に、バグの修正なので特に問題にはならず)。
タプルの要素の評価順序
もう1個変な例を挙げておきましょう。C# 7で導入されるタプルと分解で、以下のように、swapコードを書けるようになりました。
var x = 1;
var y = 2;
(x, y) = (y, x);
Console.WriteLine($"{x}, {y}");
これ、同じ処理をタプルを使わず書くとすると、まあ、以下のようにしますよね。
var temp = x;
x = y;
y = temp;
こいつらにも副作用を加えてみましょう。
まず、タプルを使うもの。
var x = 1;
var y = 2;
(Log(ref x), Log(ref y)) = (Log(y), Log(x));
ちゃんと、これも左から右に順に評価されます。すなわち、ref x
, ref y
, y
, x
の順。なので結果は以下の通り。
1
2
2
1
で、これをタプルなしで副作用も込みで全く同じ挙動にするためにはどうするか。
先ほどの類推で以下のように書いてしまうと、副作用の順序が変わります。
var temp = Log(x);
Log(ref x) = Log(y);
Log(ref y) = temp;
1
1
2
2
正しくは、以下のように書かないと同じにはなりません。
ref var rx = ref Log(ref x);
ref var ry = ref Log(ref y);
var vy = Log(y);
var vx = Log(x);
rx = vy;
ry = vx;
まとめ
副作用があっても常に一定の結果になるように、C#では、オペランドの評価順が常に左から右、書かれている通りの順序で行われます(まあ、割かし最近のプログラミング言語では大体同じで順序保証があります)。
ただ、順序保証がない場合に比べて、保証のためのコストがちょっとだけかかります(なので、古いプログラミング言語では「コンパイラーの実装ごとに変えていい」となっているものも結構あります)。
まあ、これだけ書いておいて身もふたもない結論で締めますが、副作用起こすような式を書くやつが悪い。