Penasaran null-coalescing perilaku tersirat kustom operator konversi

542

Catatan: ini tampaknya telah diperbaiki di Roslyn

Pertanyaan ini muncul ketika menulis jawaban saya untuk yang ini , yang berbicara tentang asosiatifitas operator penggabungan nol .

Sama seperti pengingat, ide dari operator null-coalescing adalah ekspresi dari form

x ?? y

pertama mengevaluasi x, lalu:

  • Jika nilai xnol, ydievaluasi dan itu adalah hasil akhir dari ekspresi
  • Jika nilai xnon-null, yyang tidak dievaluasi, dan nilai xadalah hasil akhir dari ekspresi, setelah konversi ke jenis kompilasi-waktu yjika diperlukan

Sekarang biasanya tidak perlu konversi, atau itu hanya dari jenis nullable ke non-nullable - biasanya jenisnya sama, atau hanya dari (katakanlah) int?ke int. Namun, Anda dapat membuat operator konversi tersirat Anda sendiri, dan itu digunakan jika perlu.

Untuk kasus sederhana x ?? y, saya belum melihat perilaku aneh. Namun, dengan (x ?? y) ?? zsaya melihat beberapa perilaku membingungkan.

Berikut ini adalah program pengujian singkat tapi lengkap - hasilnya ada di komentar:

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;
    }
}

Jadi kami memiliki tiga tipe nilai khusus A,, Bdan C, dengan konversi dari A ke B, A ke C, dan B ke C.

Saya dapat memahami kasus kedua dan ketiga ... tetapi mengapa ada konversi tambahan A ke B dalam kasus pertama? Khususnya, saya benar - benar berharap kasus pertama dan kedua menjadi hal yang sama - setelah itu, hanya mengekstraksi ekspresi menjadi variabel lokal.

Adakah yang tahu apa yang terjadi? Saya sangat ragu untuk menangis "bug" ketika datang ke kompiler C #, tapi saya bingung apa yang terjadi ...

EDIT: Oke, ini contoh buruk tentang apa yang terjadi, berkat jawaban konfigurator, yang memberi saya alasan lebih lanjut untuk menganggapnya sebagai bug. Sunting: Sampel bahkan tidak perlu dua operator penggabungan nol sekarang ...

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;
    }
}

Output dari ini adalah:

Foo() called
Foo() called
A to int

Fakta bahwa Foo()dipanggil dua kali di sini sangat mengejutkan bagi saya - saya tidak dapat melihat alasan mengapa ekspresi dievaluasi dua kali.

Jon Skeet
sumber
32
Saya yakin mereka berpikir "tidak ada yang akan menggunakannya dengan cara seperti itu" :)
cyberised
57
Ingin melihat sesuatu yang lebih buruk? Coba gunakan baris ini dengan semua konversi implisit: C? first = ((B?)(((B?)x) ?? ((B?)y))) ?? ((C?)z);. Anda akan mendapatkan:Internal Compiler Error: likely culprit is 'CODEGEN'
konfigurator
5
Perhatikan juga bahwa ini tidak terjadi ketika menggunakan ekspresi Linq untuk mengkompilasi kode yang sama.
konfigurator
8
@Peter pola yang tidak mungkin, tetapi masuk akal untuk(("working value" ?? "user default") ?? "system default")
Factor Mystic
23
@ yes123: Ketika berhadapan dengan konversi saja, saya tidak sepenuhnya yakin. Melihatnya menjalankan metode dua kali membuatnya cukup jelas ini adalah bug. Anda akan kagum pada beberapa perilaku yang terlihat salah tetapi sebenarnya sepenuhnya benar. Tim C # lebih pintar daripada saya - Saya cenderung menganggap saya bodoh sampai saya membuktikan bahwa ada sesuatu yang salah mereka.
Jon Skeet

Jawaban:

418

Terima kasih kepada semua orang yang berkontribusi dalam menganalisis masalah ini. Ini jelas merupakan bug penyusun. Tampaknya hanya terjadi ketika ada konversi terangkat yang melibatkan dua jenis nullable di sisi kiri operator penggabungan.

Saya belum mengidentifikasi di mana tepatnya ada yang salah, tetapi pada beberapa titik selama fase kompilasi "nullable menurunkan" - setelah analisis awal tetapi sebelum pembuatan kode - kami mengurangi ekspresi

result = Foo() ?? y;

dari contoh di atas hingga yang setara dengan moral:

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

Jelas itu tidak benar; penurunan yang benar adalah

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

Tebakan terbaik saya berdasarkan analisis saya sejauh ini adalah bahwa pengoptimal nullable keluar dari jalur di sini. Kami memiliki pengoptimal yang dapat dibatalkan yang mencari situasi di mana kami tahu bahwa ekspresi tertentu dari jenis yang tidak dapat dibatalkan tidak mungkin nol. Pertimbangkan analisis naif berikut: pertama-tama kita mungkin mengatakan itu

result = Foo() ?? y;

sama dengan

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

dan kemudian kita bisa mengatakan itu

conversionResult = (int?) temp 

sama dengan

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

Tetapi pengoptimal dapat melangkah dan berkata "tunggu dulu, kami sudah memeriksa bahwa temp bukan nol; tidak perlu memeriksanya untuk null untuk yang kedua kalinya hanya karena kami memanggil operator konversi yang diangkat". Kami ingin mereka mengoptimalkannya menjadi adil

new int?(op_Implicit(temp2.Value)) 

Dugaan saya adalah bahwa kita berada di suatu tempat yang menyembunyikan fakta bahwa bentuk yang dioptimalkan (int?)Foo()adalah new int?(op_implicit(Foo().Value))tetapi itu sebenarnya bukan bentuk yang dioptimalkan yang kita inginkan; kami ingin bentuk yang dioptimalkan dari Foo () - diganti-dengan-sementara-dan-kemudian dikonversi.

Banyak bug di kompiler C # adalah hasil dari keputusan caching yang buruk. Sebuah kata bijak: setiap kali Anda menyimpan fakta untuk digunakan nanti, Anda berpotensi menciptakan inkonsistensi jika sesuatu yang relevan berubah . Dalam hal ini hal yang relevan yang telah berubah pasca analisis awal adalah bahwa panggilan ke Foo () harus selalu direalisasikan sebagai pengambilan sementara.

Kami melakukan banyak reorganisasi dari pass penulisan ulang nullable di C # 3.0. Bug mereproduksi dalam C # 3.0 dan 4.0 tetapi tidak dalam C # 2.0, yang berarti bahwa bug itu mungkin buruk saya. Maaf!

Saya akan memasukkan bug ke dalam basis data dan kami akan melihat apakah kami dapat memperbaikinya untuk versi bahasa yang akan datang. Sekali lagi terima kasih untuk analisis Anda; itu sangat membantu!

PEMBARUAN: Saya menulis ulang pengoptimal yang dapat dibatalkan dari awal untuk Roslyn; sekarang melakukan pekerjaan yang lebih baik dan menghindari kesalahan aneh semacam ini. Untuk beberapa pemikiran tentang cara kerja pengoptimal di Roslyn, lihat seri artikel saya yang dimulai di sini: https://ericlippert.com/2012/12/20/nullable-micro-optimizations-part-one/

Eric Lippert
sumber
1
@Eric Saya ingin tahu apakah ini juga akan menjelaskan: connect.microsoft.com/VisualStudio/feedback/details/642227
MarkPflug
12
Sekarang saya memiliki Pratinjau Pengguna Akhir Roslyn, saya dapat mengonfirmasi bahwa itu sudah diperbaiki di sana. (Namun masih ada dalam kompiler C # 5 asli.)
Jon Skeet
84

Ini pasti bug.

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();
    }
}

Kode ini akan menampilkan:

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

Itu membuat saya berpikir bahwa bagian pertama dari setiap ??ekspresi penggabungan dievaluasi dua kali. Kode ini membuktikannya:

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

output:

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

Ini tampaknya terjadi hanya ketika ekspresi membutuhkan konversi antara dua jenis nullable; Saya telah mencoba berbagai permutasi dengan salah satu sisi menjadi string, dan tidak ada satupun yang menyebabkan perilaku ini.

konfigurator
sumber
11
Wow - mengevaluasi ekspresi dua kali sepertinya sangat salah. Terlihat dengan baik.
Jon Skeet
Ini sedikit lebih mudah untuk melihat apakah Anda hanya memiliki satu panggilan metode di sumber - tetapi itu masih menunjukkan dengan sangat jelas.
Jon Skeet
2
Saya telah menambahkan contoh yang sedikit lebih sederhana dari "evaluasi ganda" ini untuk pertanyaan saya.
Jon Skeet
8
Apakah semua metode Anda seharusnya menghasilkan "X ()"? Itu membuatnya agak sulit untuk mengatakan metode apa yang sebenarnya dikeluarkan ke konsol.
jeffora
2
Tampaknya akan X() ?? Y()berkembang secara internal X() != null ? X() : Y(), maka mengapa itu akan dievaluasi dua kali.
Cole Johnson
54

Jika Anda melihat pada kode yang dibuat untuk case yang dikelompokkan Kiri itu benar-benar melakukan sesuatu seperti ini ( 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);
}

Menemukan lain, jika Anda menggunakannya first akan menghasilkan jalan pintas jika keduanya adan badalah null dan kembali c. Namun jika aatau btidak nol itu mengevaluasi kembali asebagai bagian dari konversi implisit untuk Bsebelum mengembalikan yang aatau btidak nol.

Dari Spesifikasi C # 4.0, §6.1.4:

  • Jika konversi yang dapat dibatalkan adalah dari S?ke T?:
    • Jika nilai sumber adalah null( HasValueproperti adalah false), hasilnya adalah nullnilai tipe T?.
    • Jika tidak, konversi dievaluasi sebagai unwrapping dari S?ke S, diikuti oleh konversi yang mendasari dari Ske T, diikuti oleh pembungkus (§4.1.10) dari Tke T?.

Ini tampaknya menjelaskan kombinasi pembungkusan-pembungkus kedua.


Kompiler C # 2008 dan 2010 menghasilkan kode yang sangat mirip, namun ini terlihat seperti regresi dari kompiler C # 2005 (8.00.50727.4927) yang menghasilkan kode berikut untuk yang di atas:

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

Aku ingin tahu apakah ini bukan karena sihir tambahan yang diberikan pada sistem inferensi tipe?

pengguna7116
sumber
+1, tapi menurut saya itu tidak menjelaskan mengapa konversi dilakukan dua kali. Seharusnya hanya mengevaluasi ekspresi satu kali, IMO.
Jon Skeet
@ Jon: Saya sudah bermain-main dan menemukan (seperti @configurator lakukan) bahwa ketika dilakukan di Pohon Ekspresi berfungsi seperti yang diharapkan. Bekerja membersihkan ekspresi untuk menambahkannya ke posting saya. Saya harus menyatakan bahwa ini adalah "bug".
user7116
@ Jon: ok ketika menggunakan Pohon Ekspresi itu berubah (x ?? y) ?? zmenjadi lambda bersarang, yang memastikan evaluasi in-order tanpa evaluasi ganda. Ini jelas bukan pendekatan yang diambil oleh kompiler C # 4.0. Dari apa yang dapat saya katakan, bagian 6.1.4 didekati dengan cara yang sangat ketat dalam jalur kode khusus ini dan sementara waktu tidak dielakkan sehingga menghasilkan evaluasi ganda.
user7116
16

Sebenarnya, saya akan menyebutnya bug sekarang, dengan contoh yang lebih jelas. Ini masih berlaku, tetapi evaluasi ganda jelas tidak baik.

Tampaknya seolah-olah A ?? Bdiimplementasikan sebagai A.HasValue ? A : B. Dalam hal ini, ada banyak casting juga (mengikuti casting reguler untuk ?:operator ternary ). Tetapi jika Anda mengabaikan semua itu, maka ini masuk akal berdasarkan cara penerapannya:

  1. A ?? B meluas ke A.HasValue ? A : B
  2. Aadalah milik kita x ?? y. Luaskan kex.HasValue : x ? y
  3. ganti semua kemunculan A -> (x.HasValue : x ? y).HasValue ? (x.HasValue : x ? y) : B

Di sini Anda dapat melihat bahwa x.HasValuedicentang dua kali, dan jika x ?? ymembutuhkan casting, xakan dicasting dua kali.

Saya meletakkannya hanya sebagai artefak tentang bagaimana ??diimplementasikan, daripada bug kompiler. Take-Away: Jangan membuat operator casting implisit dengan efek samping.

Tampaknya menjadi kompiler bug yang berputar di sekitar bagaimana ??diterapkan. Take-away: jangan bersarang ekspresi penggabungan dengan efek samping.

Philip Rieck
sumber
Oh aku pasti tidak akan ingin menggunakan kode seperti ini biasanya, tapi saya pikir itu bisa masih digolongkan sebagai bug compiler dalam ekspansi pertama Anda harus mencakup "tetapi hanya mengevaluasi A dan B sekali". (Bayangkan jika mereka adalah pemanggilan metode.)
Jon Skeet
@Jon Saya setuju bahwa itu bisa juga - tapi saya tidak akan menyebutnya jelas. Yah, sebenarnya, saya bisa melihat bahwa A() ? A() : B()mungkin akan mengevaluasi A()dua kali, tetapi A() ?? B()tidak begitu banyak. Dan karena itu hanya terjadi pada casting ... Hmm .. Saya baru saja berbicara pada diri saya sendiri untuk berpikir itu pasti tidak berlaku dengan benar.
Philip Rieck
10

Saya bukan ahli C # sama sekali seperti yang Anda lihat dari riwayat pertanyaan saya, tetapi, saya mencoba ini dan saya pikir ini adalah bug .... tetapi sebagai pemula, saya harus mengatakan bahwa saya tidak mengerti semuanya berjalan di sini jadi saya akan menghapus jawaban saya jika saya jauh.

Saya sampai pada bugkesimpulan ini dengan membuat versi berbeda dari program Anda yang berhubungan dengan skenario yang sama, tetapi tidak terlalu rumit.

Saya menggunakan tiga properti integer nol dengan backing store. Saya mengatur masing-masing ke 4 dan kemudian menjalankanint? something2 = (A ?? B) ?? C;

( Kode lengkap di sini )

Ini hanya membaca A dan tidak ada yang lain.

Pernyataan ini bagi saya sepertinya bagi saya seharusnya:

  1. Mulai dalam tanda kurung, lihat A, kembalikan A dan selesaikan jika A tidak nol.
  2. Jika A adalah nol, evaluasi B, selesaikan jika B bukan nol
  3. Jika A dan B adalah nol, evaluasi C.

Jadi, karena A bukan nol, itu hanya melihat A dan selesai.

Dalam contoh Anda, meletakkan breakpoint pada Kasus Pertama menunjukkan bahwa x, y dan z semuanya tidak nol dan oleh karena itu, saya berharap mereka diperlakukan sama seperti contoh saya yang kurang kompleks .... tapi saya takut saya terlalu banyak seorang pemula C # dan telah melewatkan inti pertanyaan ini sepenuhnya!

Wil
sumber
5
Contoh Jon agak dari kasus sudut yang tidak jelas karena ia menggunakan struct nullable (tipe-nilai yang "mirip" dengan tipe bawaan seperti a int). Dia mendorong case lebih jauh ke sudut yang tidak jelas dengan menyediakan beberapa konversi tipe implisit. Ini membutuhkan kompiler untuk mengubah jenis data saat memeriksa null. Karena konversi tipe implisit inilah contohnya berbeda dari contoh Anda.
user7116