C #: kompatibilitas ke belakang dan kelebihan beban

Halo rekan!

Kami mengingatkan semua orang bahwa kami memiliki buku Mark Price yang bagus, " C # 7 dan .NET Core. Pengembangan lintas platform untuk para profesional ." Harap dicatat: ini adalah edisi ketiga, edisi pertama ditulis dalam versi 6.0 dan tidak muncul dalam bahasa Rusia, dan edisi ketiga dirilis dalam versi asli pada November 2017 dan mencakup versi 7.1.


Setelah merilis ringkasan seperti itu, yang melalui pengeditan ilmiah terpisah untuk memeriksa kompatibilitas ke belakang dan kebenaran lain dari materi yang disajikan, kami memutuskan untuk menerjemahkan artikel yang menarik oleh John Skeet tentang apa yang diketahui dan sedikit kesulitan dengan kompatibilitas mundur yang dapat muncul di C #. Selamat membaca.

Kembali pada bulan Juli 2017, saya mulai menulis artikel tentang versi. Segera meninggalkannya, karena topik itu terlalu luas untuk hanya membahasnya dalam satu posting. Pada topik seperti itu, lebih masuk akal untuk menyorot seluruh situs / wiki / repositori. Saya berharap untuk kembali ke topik ini suatu hari nanti, karena saya menganggapnya sangat penting dan saya pikir itu menerima jauh lebih sedikit perhatian daripada yang layak.

Jadi, dalam ekosistem .NET, versi semantik biasanya disambut baik - kedengarannya hebat, tetapi mengharuskan semua orang untuk sama-sama memahami apa yang dianggap sebagai "perubahan mendasar". Inilah yang telah saya pikirkan sejak lama. Salah satu aspek yang paling mengejutkan saya adalah betapa sulitnya untuk menghindari perubahan mendasar saat kelebihan metode. Tentang hal ini (terutama) kami akan membahas posting yang Anda baca; lagipula, topik ini sangat menarik.
Untuk memulai - definisi singkat ...

Sumber dan kompatibilitas biner

Jika saya dapat mengkompilasi ulang kode klien saya dengan versi baru perpustakaan, dan semuanya berfungsi dengan baik, maka ini adalah kompatibilitas pada tingkat kode sumber. Jika saya bisa memindahkan biner klien saya dengan versi baru perpustakaan tanpa kompilasi ulang, maka biner kompatibel. Tak satu pun dari ini adalah superset dari yang lain:

  • Beberapa perubahan mungkin tidak kompatibel dengan kode sumber dan kode biner pada saat yang sama - misalnya, Anda tidak dapat menghapus seluruh tipe publik yang sepenuhnya Anda andalkan.
  • Beberapa perubahan kompatibel dengan kode sumber, tetapi tidak kompatibel dengan kode biner - misalnya, jika Anda mengonversi bidang statis baca-saja publik menjadi properti.
  • Beberapa perubahan kompatibel dengan biner, tetapi tidak kompatibel dengan sumbernya - misalnya, menambahkan kelebihan yang dapat menyebabkan ambiguitas selama kompilasi.
  • Beberapa perubahan kompatibel dengan kode sumber dan kode biner - misalnya, implementasi baru dari tubuh metode.

Jadi apa yang kita bicarakan?

Misalkan kita memiliki perpustakaan umum versi 1.0, dan kami ingin menambahkan beberapa overload untuk menyelesaikan ke versi 1.1. Kami tetap menggunakan versi semantik, jadi kami perlu kompatibilitas ke belakang. Apa artinya ini yang bisa dan tidak bisa kita lakukan, dan dapatkah semua pertanyaan di sini dijawab "ya" atau "tidak"?

Dalam contoh berbeda, saya akan menunjukkan kode dalam versi 1.0 dan 1.1, dan kemudian kode "klien" (yaitu, kode yang menggunakan pustaka), yang mungkin rusak sebagai akibat dari perubahan. Tidak akan ada badan metode, atau deklarasi kelas, karena mereka, pada dasarnya, tidak penting - kami memberikan perhatian utama pada tanda tangan. Namun, jika Anda tertarik, maka semua kelas dan metode ini dapat dengan mudah direproduksi. Misalkan semua metode yang dijelaskan di sini adalah di kelas Library .

Perubahan yang paling sederhana, dihiasi dengan transformasi sekelompok metode menjadi delegasi
Contoh paling sederhana yang muncul di benak saya adalah menambahkan metode parameter di mana sudah ada yang non-parameter:

  //   1.0 public void Foo() //   1.1 public void Foo() public void Foo(int x) 


Bahkan di sini, kompatibilitasnya tidak lengkap. Pertimbangkan kode klien berikut:

  //  static void Method() { var library = new Library(); HandleAction(library.Foo); } static void HandleAction(Action action) {} static void HandleAction(Action<int> action) {} 

Di versi pertama perpustakaan, semuanya baik-baik saja. Memanggil metode HandleAction mengubah kelompok metode ke library.Foo mendelegasikan, dan sebagai hasilnya, sebuah Action dibuat. Dalam versi 1.1, situasinya menjadi ambigu: sekelompok metode dapat dikonversi menjadi Aksi atau Tindakan. Secara tegas, perubahan semacam itu tidak sesuai dengan kode sumber.

Pada tahap ini, tergoda untuk menyerah dan berjanji pada diri Anda sendiri untuk tidak pernah menambah kelebihan lagi. Atau kita dapat mengatakan bahwa kasus seperti itu tidak mungkin cukup untuk tidak takut akan kegagalan semacam itu. Mari kita sebut transformasi dari sekelompok metode di luar cakupan untuk saat ini.

Jenis referensi yang tidak terkait

Pertimbangkan konteks lain di mana Anda harus menggunakan kelebihan dengan jumlah parameter yang sama. Dapat diasumsikan bahwa perubahan pada perpustakaan akan menjadi tidak merusak:

 //  1.0 public void Foo(string x) //  1.1 public void Foo(string x) public void Foo(FileStream x) 

Sekilas, semuanya logis. Kami menjaga metode asli, jadi kami tidak akan merusak kompatibilitas biner. Cara termudah untuk memutusnya adalah dengan menulis panggilan yang bekerja di v1.0, tetapi tidak bekerja di v1.1, atau bekerja di kedua versi, tetapi dengan cara yang berbeda.
Apa ketidakcocokan antara v1.0 dan v1.1 yang dapat diberikan oleh panggilan semacam itu? Kita harus memiliki argumen yang kompatibel dengan string dan FileStream . Tapi ini adalah jenis referensi yang tidak saling terkait ...

Kegagalan pertama adalah mungkin jika kita melakukan konversi implisit yang ditentukan pengguna ke string dan FileStream :

 //  class OddlyConvertible { public static implicit operator string(OddlyConvertible c) => null; public static implicit operator FileStream(OddlyConvertible c) => null; } static void Method() { var library = new Library(); var convertible = new OddlyConvertible(); library.Foo(convertible); } 

Saya harap masalahnya jelas: kode yang sebelumnya tidak ambigu dan bekerja dengan string sekarang ambigu, karena tipe OddlyConvertible dapat secara implisit dikonversi menjadi string dan FileStream (kedua kelebihan ini berlaku, tidak ada yang lebih baik dari yang lain).

Mungkin dalam kasus ini masuk akal untuk melarang konversi yang ditentukan pengguna ... tetapi kode ini dapat diturunkan dan jauh lebih mudah:

 //  static void Method() { var library = new Library(); library.Foo(null); } 

Kami secara implisit dapat mengkonversi nol literal ke semua jenis referensi atau ke semua jenis signifikan nullable ... oleh karena itu, sekali lagi, situasi di versi 1.1 adalah ambigu. Ayo coba lagi ...

Parameter tipe referensi dan tipe signifikan tidak dapat dibatalkan

Misalkan kita tidak peduli dengan transformasi yang ditentukan pengguna, tetapi kami tidak menyukai literal nol yang bermasalah. Bagaimana dalam hal ini menambahkan kelebihan dengan tipe signifikan yang tidak dapat dibatalkan?

  //  1.0 public void Foo(string x) //  1.1 public void Foo(string x) public void Foo(int x) 

Pada pandangan pertama, ini bagus - library.Foo(null) akan berfungsi dengan baik di v1.1. Jadi, apakah dia aman? Tidak, hanya saja tidak dalam C # 7.1 ...

  //  static void Method() { var library = new Library(); library.Foo(default); } 

Literal defaultnya persis nol, tetapi berlaku untuk semua jenis. Ini sangat mudah - dan benar-benar sakit kepala ketika datang ke kelebihan dan kompatibilitas :(

Parameter opsional

Parameter opsional adalah masalah lain. Misalkan kita memiliki satu parameter opsional, dan kami ingin menambahkan yang kedua. Kami memiliki tiga opsi, yang diidentifikasi di bawah ini seperti 1.1a, 1.1b, dan 1.1c.

  //  1.0 public void Foo(string x = "") //  1.1a //   ,         public void Foo(string x = "") public void Foo(string x = "", string y = "") //  1.1b //          public void Foo(string x = "", string y = "") //  1.1c //   ,    ,   //  ,     . public void Foo(string x) public void Foo(string x = "", string y = "") 


Tetapi bagaimana jika klien melakukan dua panggilan:

 //  static void Method() { var library = new Library(); library.Foo(); library.Foo("xyz"); } 

Library 1.1a mempertahankan kompatibilitas di tingkat biner, tetapi melanggar di tingkat kode sumber: sekarang library.Foo() ambigu. Menurut aturan kelebihan dalam C #, metode lebih disukai yang tidak mengharuskan kompiler untuk "mengisi" semua parameter opsional yang tersedia, namun, itu tidak mengatur berapa banyak parameter opsional dapat diisi.

Perpustakaan 1.1b memelihara kompatibilitas tingkat sumber, tetapi melanggar kompatibilitas biner. Kode yang dikompilasi yang ada dirancang untuk memanggil metode dengan parameter tunggal - dan metode seperti itu tidak ada lagi.

Pustaka 1.1c mempertahankan kompatibilitas biner, tetapi penuh dengan kemungkinan kejutan di tingkat kode sumber. Sekarang panggilan library.Foo() diselesaikan menjadi metode dengan dua parameter, sedangkan library.Foo("xyz") diselesaikan menjadi metode dengan satu parameter (dari sudut pandang kompiler, lebih disukai metode dengan dua parameter, terutama karena tidak ada parameter opsional tidak diperlukan pengisian). Ini mungkin dapat diterima jika versi dengan satu parameter hanya mendelegasikan versi dengan dua parameter, dan dalam kedua kasus nilai default yang sama digunakan. Namun, tampaknya aneh bahwa nilai panggilan pertama akan berubah jika metode yang sebelumnya diselesaikan masih ada.

Situasi dengan parameter opsional menjadi lebih membingungkan jika Anda ingin menambahkan parameter baru bukan di akhir, tetapi di tengah - misalnya, cobalah untuk mematuhi perjanjian dan menjaga parameter opsional CancurToken di bagian paling akhir. Saya tidak akan membahas ini ...

Metode Umum

Kesimpulan tipe pada saat terbaik bukanlah tugas yang mudah. Ketika sampai pada penyelesaian kelebihan, pekerjaan ini berubah menjadi mimpi buruk yang seragam.

Misalkan kita hanya memiliki satu metode non-umum di v1.0, dan di v1.1 kita menambahkan metode umum lainnya.

 //  1.0 public void Foo(object x) //  1.1 public void Foo(object x) public void Foo<T>(T x) 

Pada pandangan pertama, ini tidak terlalu menyeramkan ... tapi mari kita lihat apa yang terjadi dalam kode klien:

 //  static void Method() { var library = new Library(); library.Foo(new object()); library.Foo("xyz"); } 

Di perpustakaan v1.0, kedua panggilan diselesaikan di Foo(object) - satu-satunya metode yang tersedia.

Pustaka v1.1 kompatibel ke belakang: jika Anda mengambil file klien yang dapat dieksekusi yang dikompilasi untuk v1.1, maka kedua panggilan masih akan menggunakan Foo(object) . Tetapi, dalam hal kompilasi ulang, panggilan kedua (dan hanya panggilan kedua) akan beralih untuk bekerja dengan metode umum. Kedua metode berlaku untuk kedua panggilan.

Pada panggilan pertama, tipe inferensi akan menunjukkan bahwa T adalah object , jadi mengonversi argumen ke tipe parameter dalam kedua kasus akan dikurangi menjadi object dalam object . Bagus Kompiler akan menerapkan aturan bahwa metode non-generik selalu lebih disukai daripada metode generik.

Pada panggilan kedua, inferensi tipe akan menunjukkan bahwa T akan selalu berupa string , jadi ketika mengonversi argumen ke parameter tipe, kita mendapatkan string ke object untuk metode asli atau string ke string untuk metode umum. Transformasi kedua adalah "lebih baik," sehingga metode kedua dipilih.

Jika kedua metode bekerja dengan cara yang sama, baiklah. Jika tidak, Anda akan merusak kompatibilitas dengan cara yang sangat tidak jelas.

Warisan dan pengetikan dinamis

Maaf, saya sudah kehabisan nafas. Baik pewarisan dan pengetikan dinamis saat menyelesaikan kelebihan dapat memanifestasikan diri mereka dalam cara yang paling "keren" dan misterius.
Jika kita menambahkan metode seperti itu pada satu tingkat hierarki warisan yang akan membebani metode kelas dasar, maka metode baru akan diproses terlebih dahulu dan akan lebih disukai daripada metode kelas dasar, bahkan jika metode kelas dasar lebih akurat ketika mengubah argumen ke parameter tipe. Ada cukup ruang untuk mencampur semuanya.

Hal yang sama berlaku untuk pengetikan dinamis (dalam kode klien); sampai taraf tertentu, situasinya menjadi tidak dapat diprediksi. Anda telah dengan serius mengorbankan keamanan selama kompilasi ... jadi jangan heran jika ada yang rusak.

Ringkasan

Saya mencoba membuat contoh dalam artikel ini cukup sederhana. Semuanya menjadi sangat rumit, dan sangat cepat, ketika Anda memiliki banyak parameter opsional. Versi adalah masalah yang rumit, kepala saya membengkak karenanya.

Source: https://habr.com/ru/post/id414223/


All Articles