奇妙なnull融合演算子のカスタムの暗黙的な変換動作


542

注:これはRoslynで修正されたようです

私の答えを書くときにこの質問は生まれたこの1の関連性について語る、nullで合体演算子

念のため、null融合演算子の考え方は、フォームの式

x ?? y

最初にを評価しx、次に:

  • の値xがnullの場合、y評価され、それが式の最終結果になります
  • 値があればx非ヌルである、yされていない評価され、値がxのコンパイル時の型に変換した後、発現の最終的な結果であり、y必要に応じて

今、通常、変換の必要はありません、またはそれは、非NULL可能1にNULL可能タイプからだけだ-通常のタイプが同じである、または単に(と言う)からint?int。ただし、独自の暗黙の変換演算子を作成でき、それらは必要に応じて使用されます。

の単純なケースではx ?? y、奇妙な動作は見たことがありません。しかし、(x ?? y) ?? z私はいくつかの混乱した行動を見ています。

以下は短いが完全なテストプログラムです-結果はコメントにあります:

using System;

public struct A
{
    public static implicit operator B(A input)
    {
        Console.WriteLine("A to B");
        return new B();
    }

    public static implicit operator C(A input)
    {
        Console.WriteLine("A to C");
        return new C();
    }
}

public struct B
{
    public static implicit operator C(B input)
    {
        Console.WriteLine("B to C");
        return new C();
    }
}

public struct C {}

class Test
{
    static void Main()
    {
        A? x = new A();
        B? y = new B();
        C? z = new C();
        C zNotNull = new C();

        Console.WriteLine("First case");
        // This prints
        // A to B
        // A to B
        // B to C
        C? first = (x ?? y) ?? z;

        Console.WriteLine("Second case");
        // This prints
        // A to B
        // B to C
        var tmp = x ?? y;
        C? second = tmp ?? z;

        Console.WriteLine("Third case");
        // This prints
        // A to B
        // B to C
        C? third = (x ?? y) ?? zNotNull;
    }
}

3つのカスタム値型、持っている私たちは、そうABおよびCCに、AからBへの変換で、CにA、およびBを

2番目のケースと3番目のケースの両方を理解できますが、なぜ最初のケースで余分なAからBへの変換があるのですか 特に、最初のケースと2番目のケースが同じであることを本当に期待していました。結局のところ、式をローカル変数に抽出するだけです。

何が起こっているのか?C#コンパイラに関しては、「バグ」を叫ぶのを非常にためらっていますが、何が起こっているのかについて困惑しています...

編集:わかりました、これは、コンフィギュレーターの回答のおかげで、何が起こっているのかという厄介な例です。編集:サンプルでは、​​2つのnull結合演算子も必要ありません...

using System;

public struct A
{
    public static implicit operator int(A input)
    {
        Console.WriteLine("A to int");
        return 10;
    }
}

class Test
{
    static A? Foo()
    {
        Console.WriteLine("Foo() called");
        return new A();
    }

    static void Main()
    {
        int? y = 10;

        int? result = Foo() ?? y;
    }
}

この出力は次のとおりです。

Foo() called
Foo() called
A to int

Foo()ここで2回呼び出されるという事実は、私にとって非常に驚くべきことです。式が2回評価される理由は何もわかりません。


32
私は彼らが「だれもそれをそのような方法で使用することは決してないだろう」と思ったに
違いない

57
もっと悪いものを見たいですか?すべての暗黙の変換でこの行を使用してみてください:C? first = ((B?)(((B?)x) ?? ((B?)y))) ?? ((C?)z);。あなたは得られます:Internal Compiler Error: likely culprit is 'CODEGEN'
コンフィギュレータ

5
また、Linq式を使用して同じコードをコンパイルする場合、これは発生しないことにも注意してください。
コンフィギュレー

8
@Peterのパターンはありそうもないが、もっともらしい(("working value" ?? "user default") ?? "system default")
Factor Mystic

23
@ yes123:変換だけを扱っていたとき、私は完全には確信していませんでした。メソッドを2回実行するのを見て、これがバグであることが明らかになりました。正しくないように見えても実際には完全に正しい動作に驚かれることでしょう。C#チームは私より賢いです。何かが彼らのせいだと証明するまで、私は愚かだと思いがちです。
Jon Skeet、2013年

回答:


418

この問題の分析に貢献してくれたすべての人に感謝します。明らかにコンパイラのバグです。これは、合体演算子の左側に2つのnull許容型が含まれるリフト変換がある場合にのみ発生するようです。

正確にどこで問題が発生するかはまだ特定していませんが、コンパイルの「nullable低下」フェーズのある時点で-最初の分析の後、コード生成の前に-式を減らします

result = Foo() ?? y;

上記の例から道徳的に同等のものに:

A? temp = Foo();
result = temp.HasValue ? 
    new int?(A.op_implicit(Foo().Value)) : 
    y;

明らかにそれは正しくありません。正しい下げは

result = temp.HasValue ? 
    new int?(A.op_implicit(temp.Value)) : 
    y;

これまでの私の分析に基づいた私の最良の推測は、nullableオプティマイザがここから脱線していることです。null許容型の特定の式がnullになる可能性がないことがわかっている状況を探すnull許容オプティマイザーがあります。次の単純な分析を考えてみましょう。

result = Foo() ?? y;

と同じです

A? temp = Foo();
result = temp.HasValue ? 
    (int?) temp : 
    y;

そして、私たちはそれを言うかもしれません

conversionResult = (int?) temp 

と同じです

A? temp2 = temp;
conversionResult = temp2.HasValue ? 
    new int?(op_Implicit(temp2.Value)) : 
    (int?) null

しかし、オプティマイザは介入して、「ちょっと待って、tempがnullでないことをすでに確認しました。リフトされた変換演算子を呼び出しているので、もう一度nullを確認する必要はありません」と言うことができます。最適化して、

new int?(op_Implicit(temp2.Value)) 

私の推測では、私たちがどこかの最適化された形式があるという事実キャッシュしているということです(int?)Foo()ですnew int?(op_implicit(Foo().Value))が、それは実際に私たちが望む最適化されたフォームではありません。Foo()-replaced-with-temporary-and-then-convertedの最適化された形式が必要です。

C#コンパイラの多くのバグは、不適切なキャッシュ決定の結果です。賢明な言葉:後で使用するためにファクトをキャッシュするたびに、関連する変更があった場合に矛盾が生じる可能性があります。この場合、初期分析後に変更された関連事項は、Foo()の呼び出しを常に一時的なフェッチとして実現する必要があることです。

C#3.0では、null可能書き換えパスの多くの再編成を行いました。バグはC#3.0および4.0では再現されますが、C#2.0では再現されません。つまり、バグはおそらく私のバグでした。ごめんなさい!

データベースにバグを入力し、今後のバージョンの言語で修正できるかどうかを確認します。分析してくださった皆さん、ありがとうございました。それはとても役に立ちました!

更新:ヌル可能オプティマイザーをRoslynのゼロから書き直しました。今ではより良い仕事をし、この種の奇妙なエラーを回避します。Roslynのオプティマイザがどのように機能するかについてのいくつかの考えについては、ここから始まる私の一連の記事を参照してくださいhttps : //ericlippert.com/2012/12/20/nullable-micro-optimizations-part-one/


1
@Ericこれも説明できるかどうか疑問に思います:connect.microsoft.com/VisualStudio/feedback/details/642227
MarkPflug

12
Roslynのエンドユーザープレビューを取得したので、そこで修正されていることを確認できます。(ただし、ネイティブC#5コンパイラにはまだ存在します。)
Jon Skeet 14

84

これは間違いなくバグです。

public class Program {
    static A? X() {
        Console.WriteLine("X()");
        return new A();
    }
    static B? Y() {
        Console.WriteLine("Y()");
        return new B();
    }
    static C? Z() {
        Console.WriteLine("Z()");
        return new C();
    }

    public static void Main() {
        C? test = (X() ?? Y()) ?? Z();
    }
}

このコードは出力します:

X()
X()
A to B (0)
X()
X()
A to B (0)
B to C (0)

そのため、各??合体式の最初の部分は2回評価されると思いました。このコードはそれを証明しました:

B? test= (X() ?? Y());

出力:

X()
X()
A to B (0)

これは、式で2つのnull許容型間の変換が必要な場合にのみ発生するようです。私は文字列である側の1つを使用してさまざまな順列を試しましたが、どれもこの動作を引き起こしませんでした。


11
うわー-式を2回評価することは実際に非常に間違っているようです。よく見つかりました。
Jon Skeet

ソースにメソッド呼び出しが1つしかないかどうかを確認する方が少し簡単ですが、それでも非常に明確に示されています。
Jon Skeet、2011

2
私の質問に、この「二重評価」のもう少し簡単な例を追加しました。
Jon Skeet

8
すべてのメソッドが「X()」を出力することになっていますか?実際にコンソールに出力しているメソッドを特定するのはやや難しくなります。
jeffora

2
X() ?? Y()内部的にX() != null ? X() : Y()に拡張されているように見えるため、2回評価されるのはなぜですか。
Cole Johnson、

54

左グループ化されたケースで生成されたコードを見ると、実際には次のようになります(csc /optimize-):

C? first;
A? atemp = a;
B? btemp = (atemp.HasValue ? new B?(a.Value) : b);
if (btemp.HasValue)
{
    first = new C?((atemp.HasValue ? new B?(a.Value) : b).Value);
}

別の検索、あなたがあれば使用し first、それは両方の場合のショートカットが生成されますaし、bヌルとリターンですc。しかし、aまたはbがnullでない場合aは、暗黙の変換の一部として再評価されてからBaまたはのどちらbがnullでないかが返されます。

C#4.0仕様、§6.1.4から:

  • null可能変換がからS?への場合T?
    • ソース値がnullHasValueproperty is false)の場合、結果はnulltype の値ですT?
    • そうでない場合、変換はよりアンラップとして評価されるS?までSの下地の変換に続いて、STの折り返し(§4.1.10)、続いTT?

これは、2番目のアンラッピングとラッピングの組み合わせを説明しているようです。


C#2008および2010コンパイラーは非常に類似したコードを生成しますが、これはC#2005コンパイラー(8.00.50727.4927)からの回帰のように見え、上記のコードを生成します。

A? a = x;
B? b = a.HasValue ? new B?(a.GetValueOrDefault()) : y;
C? first = b.HasValue ? new C?(b.GetValueOrDefault()) : z;

これは型推論システムに追加された魔法が原因ではないのだろうか?


+1が、変換が2度実行される理由を実際に説明しているとは思いません。IMO、式を1回だけ評価する必要があります。
Jon Skeet

@ジョン:私は遊んでいて、(@ configuratorと同様に)式ツリーで実行すると、期待どおりに機能することを発見しました。表現を整理して投稿に追加しています。これは「バグ」であると私は主張しなければならないでしょう。
user7116

@ジョン:式ツリーを使用すると(x ?? y) ?? z、ネストされたラムダに変わります。これにより、二重評価なしで順序どおりの評価が保証されます。これは明らかに、C#4.0コンパイラで採用されているアプローチではありません。私が言うことができることから、セクション6.1.4はこの特定のコードパスで非常に厳密な方法でアプローチされ、一時が省略されないため、二重評価が発生します。
user7116

16

実際には、これをバグと呼び、より明確な例を示します。これはまだ成り立つが、二重評価は確かに良くない。

としてA ?? B実装されているようA.HasValue ? A : Bです。この場合、キャストもたくさんあります(3項?:演算子の通常のキャストに続きます)。しかし、それをすべて無視する場合、これは実装方法に基づいて意味があります。

  1. A ?? B に拡大する A.HasValue ? A : B
  2. Aです x ?? y。に展開x.HasValue : x ? y
  3. Aのすべての出現箇所を置き換える-> (x.HasValue : x ? y).HasValue ? (x.HasValue : x ? y) : B

ここでx.HasValueは、2回チェックされていることがわかります。キャストがx ?? y必要な場合は、x2回キャストされます。

??コンパイラーのバグではなく、実装方法の成果物として単純に書き留めておきます。 要点:副作用のある暗黙のキャスト演算子を作成しないでください。

??実装方法を中心としたコンパイラのバグのようです。要点:副作用を伴う合体式をネストしないでください。


確かにこのようなコードを通常は使用したくありませんが、最初の拡張に「AとBを1回だけ評価する」を含める必要があるという点で、コンパイラのバグとして分類できると思います。(それらがメソッド呼び出しであった場合を想像してください。)
Jon Skeet、

@ジョン私もそれが可能であることに同意します-しかし、私はそれを明確とは言いません。まあ、実際には、それは2回A() ? A() : B()評価される可能性A()A() ?? B()ありますが、それほど評価されません。そして、それはキャスティングでのみ発生するので...うーん..私はちょうどそれが確かに正しく動作していないと考えているように自分自身を話しました。
Philip Rieck、

10

私の質問履歴からわかるように、私はC#の専門家ではありませんが、これを試しましたが、バグだと思います...しかし、初心者として、私はすべてのことを理解しているわけではないと言いますここでオンになっているので、道が外れている場合は回答を削除します。

同じbug結論を扱うが、それほど複雑ではないプログラムの異なるバージョンを作成することで、この結論に達しました。

バッキングストアで3つのnull整数プロパティを使用しています。それぞれを4に設定して実行しますint? something2 = (A ?? B) ?? C;

ここに完全なコード

これはAのみを読み取るだけです。

私にとってのこの発言は、私にとっては次のようになります。

  1. 括弧で始め、Aを見て、Aを返し、Aがnullでない場合は終了します。
  2. Aがnullの場合、Bを評価し、Bがnullでない場合は終了します
  3. AとBがnullの場合、Cを評価します。

したがって、Aはnullではないため、Aのみを見て終了します。

あなたの例では、最初のケースにブレークポイントを置くと、x、y、zがすべてnullではないことが示されます。したがって、これらは、それほど複雑ではない例と同じように扱われることを期待します。 C#初心者のこの質問のポイントを完全に逃した!


5
Jonの例は、null許容の構造体(のような組み込み型に「類似した」値型)を使用しているという、あいまいなコーナーケースintです。彼は、複数の暗黙的な型変換を提供することで、ケースをさらにあいまいなコーナーに押し込みます。これには、コンパイラがに対してチェックしながらデータのタイプを変更する必要がありますnull。彼の例があなたのものと異なるのは、これらの暗黙の型変換のためです。
user7116
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.