Eric Lippert membuat poin yang sangat menarik dalam diskusi tentang mengapa C # menggunakan tipe null
daripada Maybe<T>
tipe :
Konsistensi sistem tipe adalah penting; dapatkah kita selalu tahu bahwa referensi yang tidak dapat dibatalkan tidak pernah dalam keadaan apapun dianggap tidak valid? Bagaimana dengan konstruktor objek dengan bidang tipe referensi yang tidak dapat dibatalkan? Bagaimana dengan di finalizer dari objek seperti itu, di mana objek tersebut selesai karena kode yang seharusnya mengisi referensi melemparkan pengecualian? Jenis sistem yang berbohong kepada Anda tentang jaminannya berbahaya.
Itu sedikit membuka mata. Konsep-konsep yang terlibat menarik minat saya, dan saya telah melakukan beberapa bermain-main dengan kompiler dan sistem ketik, tetapi saya tidak pernah memikirkan skenario itu. Bagaimana bahasa yang memiliki tipe Maybe alih-alih null handle edge case seperti inisialisasi dan pemulihan kesalahan, di mana referensi non-null yang seharusnya dijamin tidak, pada kenyataannya, dalam keadaan valid?
sumber
Type?
(mungkin) danType
(bukan nol)Maybe T
, itu tidak bolehNone
dan karenanya Anda tidak dapat menginisialisasi penyimpanannya ke pointer nol.Jawaban:
Kutipan itu menunjuk ke masalah yang terjadi jika deklarasi dan penugasan pengidentifikasi (di sini: anggota contoh) terpisah satu sama lain. Sebagai sketsa kodesemu cepat:
Skenario sekarang adalah bahwa selama pembangunan sebuah instance, kesalahan akan dilemparkan, sehingga konstruksi akan dibatalkan sebelum instance tersebut sepenuhnya dibangun. Bahasa ini menawarkan metode destruktor yang akan berjalan sebelum memori dialokasikan, misalnya untuk membebaskan sumber daya non-memori secara manual. Itu juga harus dijalankan pada objek yang dibangun sebagian, karena sumber daya yang dikelola secara manual mungkin sudah dialokasikan sebelum konstruksi dibatalkan.
Dengan nulls, destructor dapat menguji apakah suatu variabel telah ditetapkan seperti
if (foo != null) foo.cleanup()
. Tanpa nulls, objek sekarang dalam keadaan tidak terdefinisi - berapakah nilainyabar
?Namun, masalah ini ada karena kombinasi dari tiga aspek:
null
atau dijamin inisialisasi untuk variabel anggota.let
pernyataan seperti yang terlihat dalam bahasa fungsional) adalah mudah adalah untuk memaksa inisialisasi dijamin - tetapi membatasi bahasa dengan cara lain.Sangat mudah untuk memilih desain lain yang tidak menunjukkan masalah ini, misalnya dengan selalu menggabungkan deklarasi dengan penugasan dan memiliki bahasa menawarkan beberapa blok finalizer alih-alih metode finalisasi tunggal:
Jadi tidak ada masalah dengan tidak adanya null, tetapi dengan kombinasi satu set fitur lain dengan tidak adanya null.
Pertanyaan yang menarik sekarang adalah mengapa C # memilih satu desain tetapi tidak yang lain. Di sini, konteks kutipan mencantumkan banyak argumen lain untuk null dalam bahasa C #, yang sebagian besar dapat diringkas sebagai "keakraban dan kompatibilitas" - dan itu adalah alasan bagus.
sumber
null
s: urutan finalisasi tidak dijamin, karena kemungkinan siklus referensi. Tapi saya kiraFINALIZE
desain Anda juga memecahkan bahwa: jikafoo
sudah selesai,FINALIZE
bagiannya tidak akan berjalan.Cara yang sama Anda menjamin data lain dalam keadaan valid.
Seseorang dapat menyusun semantik dan mengontrol aliran sehingga Anda tidak dapat memiliki variabel / bidang dari beberapa jenis tanpa sepenuhnya menciptakan nilai untuk itu. Alih-alih membuat objek dan membiarkan konstruktor memberikan nilai "awal" ke bidangnya, Anda hanya bisa membuat objek dengan menentukan nilai untuk semua bidangnya sekaligus. Alih-alih mendeklarasikan variabel dan kemudian menetapkan nilai awal, Anda hanya bisa memperkenalkan variabel dengan inisialisasi.
Misalnya, di Rust Anda membuat objek tipe struct melalui
Point { x: 1, y: 2 }
alih-alih menulis konstruktor yang melakukannyaself.x = 1; self.y = 2;
. Tentu saja, ini mungkin berbenturan dengan gaya bahasa yang ada dalam pikiran Anda.Pendekatan pelengkap lainnya adalah menggunakan analisis liveness untuk mencegah akses ke penyimpanan sebelum inisialisasi. Ini memungkinkan mendeklarasikan variabel tanpa segera menginisialisasi, asalkan itu terbukti ditugaskan sebelum membaca pertama. Ini juga dapat menangkap beberapa kasus terkait kegagalan seperti
Secara teknis, Anda juga bisa mendefinisikan inisialisasi default arbitrer untuk objek, mis. Nol semua bidang numerik, membuat array kosong untuk bidang array, dll. Tetapi ini agak sewenang-wenang, kurang efisien daripada opsi lain, dan dapat menutupi bug.
sumber
Begini cara Haskell melakukannya: (tidak persis berlawanan dengan pernyataan Lippert karena Haskell bukan bahasa Berorientasi Objek).
PERINGATAN: jawaban panjang lebar dari penggemar Haskell yang serius di depan.
TL; DR
Contoh ini menggambarkan secara persis perbedaan Haskell dari C #. Alih-alih mendelegasikan logistik konstruksi struktur ke konstruktor, itu harus ditangani dalam kode di sekitarnya. Tidak ada cara untuk
Nothing
nilai nilai null (Atau dalam Haskell) untuk muncul di mana kami mengharapkan nilai non-nol karena nilai nol hanya dapat terjadi dalam jenis pembungkus khususMaybe
yang disebut yang tidak dapat dipertukarkan dengan / langsung dapat dikonversi menjadi, tidak langsung jenis yang dapat dibatalkan. Untuk menggunakan nilai yang dibuat nullable dengan membungkusnya dalamMaybe
, kita harus terlebih dahulu mengekstraksi nilai menggunakan pencocokan pola, yang memaksa kita untuk mengalihkan aliran kontrol ke cabang di mana kita tahu pasti bahwa kita memiliki nilai non-nol.Karena itu:
Iya.
Int
danMaybe Int
dua tipe yang sepenuhnya terpisah. MenemukanNothing
di dataranInt
akan sebanding dengan menemukan "ikan" string dalamInt32
.Bukan masalah: konstruktor nilai di Haskell tidak bisa melakukan apa pun selain mengambil nilai yang diberikan dan menyatukannya. Semua logika inisialisasi terjadi sebelum konstruktor dipanggil.
Tidak ada finalizer di Haskell, jadi saya tidak bisa mengatasi ini. Namun, tanggapan pertama saya tetap bertahan.
Jawaban Lengkap :
Haskell tidak memiliki null dan menggunakan
Maybe
tipe data untuk mewakili nullables. Mungkin tipe data aljabar didefinisikan seperti ini:Bagi Anda yang tidak terbiasa dengan Haskell, baca ini sebagai "A
Maybe
adalah salah satuNothing
atauJust a
". Secara khusus:Maybe
adalah konstruktor tipe : dapat dianggap (salah) sebagai kelas generik (di manaa
variabel tipe). Analogi C # adalahclass Maybe<a>{}
.Just
adalah konstruktor nilai : ini adalah fungsi yang mengambil satu argumen tipea
dan mengembalikan nilai tipeMaybe a
yang berisi nilai. Jadi kodenyax = Just 17
analog denganint? x = 17;
.Nothing
adalah konstruktor nilai lain, tetapi tidak memerlukan argumen dan yangMaybe
dikembalikan tidak memiliki nilai selain "Tidak Ada".x = Nothing
analog denganint? x = null;
(dengan asumsi kita membatasia
Haskell kitaInt
, yang dapat dilakukan dengan menulisx = Nothing :: Maybe Int
).Sekarang setelah dasar-dasar dari
Maybe
tipe tersebut keluar dari jalan, bagaimana Haskell menghindari masalah yang dibahas dalam pertanyaan OP?Yah, Haskell benar - benar berbeda dari sebagian besar bahasa yang dibahas sejauh ini, jadi saya akan mulai dengan menjelaskan beberapa prinsip dasar bahasa.
Pertama, di Haskell, semuanya tidak berubah . Segala sesuatu. Nama merujuk ke nilai, bukan ke lokasi memori tempat nilai dapat disimpan (ini saja merupakan sumber penghapusan bug yang sangat besar). Tidak seperti di C #, di mana deklarasi variabel dan tugas adalah dua operasi terpisah, di Haskell nilai diciptakan dengan mendefinisikan nilai mereka (misalnya
x = 15
,y = "quux"
,z = Nothing
), yang tidak pernah bisa berubah. Karenanya, kode seperti:Tidak mungkin di Haskell. Tidak ada masalah dengan menginisialisasi nilai
null
karena semuanya harus secara eksplisit diinisialisasi ke nilai agar ada.Kedua, Haskell bukan bahasa berorientasi objek : itu adalah bahasa murni fungsional , jadi tidak ada objek dalam arti kata yang ketat. Sebaliknya, ada hanya fungsi (konstruktor nilai) yang mengambil argumen mereka dan mengembalikan struktur yang digabung.
Selanjutnya, sama sekali tidak ada kode gaya imperatif. Maksud saya, sebagian besar bahasa mengikuti pola seperti ini:
Perilaku program dinyatakan sebagai serangkaian instruksi. Dalam bahasa yang Berorientasi Objek, deklarasi kelas dan fungsi juga memainkan peran besar dalam aliran program, tetapi pada intinya, "daging" dari eksekusi program mengambil bentuk serangkaian instruksi yang akan dieksekusi.
Di Haskell, ini tidak mungkin. Alih-alih, aliran program ditentukan sepenuhnya oleh fungsi chaining. Bahkan
do
notasi yang tampak imperatif hanyalah gula sintaksis untuk meneruskan fungsi anonim kepada>>=
operator. Semua fungsi berbentuk:Di mana
body-expression
bisa apa saja yang mengevaluasi suatu nilai. Jelas ada lebih banyak fitur sintaksis yang tersedia tetapi intinya adalah tidak adanya urutan pernyataan yang lengkap.Terakhir, dan mungkin yang paling penting, sistem tipe Haskell sangat ketat. Jika saya harus meringkas filosofi desain pusat dari sistem tipe Haskell, saya akan mengatakan: "Buat sebanyak mungkin hal yang salah pada waktu kompilasi sehingga sesedikit mungkin menjadi salah saat runtime." Tidak ada konversi tersirat apa pun (ingin mempromosikan
Int
keDouble
? GunakanfromIntegral
fungsi). Satu-satunya yang mungkin memiliki nilai tidak valid terjadi pada saat runtime adalah menggunakanPrelude.undefined
(yang tampaknya hanya harus ada di sana dan tidak mungkin untuk menghapus ).Dengan semua ini dalam pikiran, mari kita lihat contoh "rusak" amon dan coba untuk mengekspresikan kembali kode ini di Haskell. Pertama, deklarasi data (menggunakan sintaks rekaman untuk bidang bernama):
(
foo
danbar
benar-benar fungsi accessor ke bidang anonim di sini alih-alih bidang yang sebenarnya, tetapi kita dapat mengabaikan detail ini).The
NotSoBroken
nilai konstruktor tidak mampu mengambil tindakan apapun selain mengambilFoo
danBar
(yang tidak nullable) dan membuatNotSoBroken
keluar dari mereka. Tidak ada tempat untuk meletakkan kode imperatif atau bahkan secara manual menetapkan bidang. Semua logika inisialisasi harus dilakukan di tempat lain, kemungkinan besar dalam fungsi pabrik khusus.Dalam contohnya, konstruksi
Broken
selalu gagal. Tidak ada cara untuk mematahkanNotSoBroken
konstruktor nilai dengan cara yang sama (tidak ada tempat untuk menulis kode), tetapi kita dapat membuat fungsi pabrik yang sama-sama cacat.(baris pertama adalah tipe deklarasi tanda tangan:
makeNotSoBroken
mengambil argumen aFoo
dan aBar
sebagai dan menghasilkan aMaybe NotSoBroken
).Tipe pengembalian harus
Maybe NotSoBroken
dan bukan hanyaNotSoBroken
karena kami menyuruhnya untuk mengevaluasiNothing
, yang merupakan konstruktor nilai untukMaybe
. Jenis tidak akan berbaris jika kita menulis sesuatu yang berbeda.Selain tidak ada gunanya, fungsi ini bahkan tidak memenuhi tujuan sebenarnya, seperti yang akan kita lihat ketika kita mencoba menggunakannya. Mari kita membuat fungsi yang disebut
useNotSoBroken
yang mengharapkanNotSoBroken
sebagai argumen:(
useNotSoBroken
menerima aNotSoBroken
sebagai argumen dan menghasilkan aWhatever
).Dan gunakan seperti ini:
Dalam sebagian besar bahasa, perilaku semacam ini dapat menyebabkan pengecualian penunjuk nol. Di Haskell, tipe tidak cocok:
makeNotSoBroken
mengembalikan aMaybe NotSoBroken
, tetapiuseNotSoBroken
mengharapkan aNotSoBroken
. Jenis ini tidak dapat dipertukarkan, dan kode gagal dikompilasi.Untuk menyiasatinya, kita bisa menggunakan
case
pernyataan untuk bercabang berdasarkan pada strukturMaybe
nilai (menggunakan fitur yang disebut pencocokan pola ):Jelas potongan ini perlu ditempatkan di dalam beberapa konteks untuk benar-benar dikompilasi, tetapi ini menunjukkan dasar-dasar bagaimana Haskell menangani nullables. Berikut ini penjelasan langkah demi langkah dari kode di atas:
makeNotSoBroken
dievaluasi, yang dijamin menghasilkan nilai tipeMaybe NotSoBroken
.case
pernyataan memeriksa struktur nilai ini.Nothing
, kode "pegangan situasi di sini" dievaluasi.Just
nilai, cabang lainnya dieksekusi. Perhatikan bagaimana klausa yang cocok secara bersamaan mengidentifikasi nilai sebagaiJust
konstruksi dan mengikatNotSoBroken
bidang internalnya ke sebuah nama (dalam hal ini,x
).x
kemudian dapat digunakan seperti nilai normalNotSoBroken
itu.Jadi, pencocokan pola menyediakan fasilitas yang kuat untuk menegakkan keamanan jenis, karena struktur objek tidak dapat dipisahkan dengan percabangan kontrol.
Saya harap ini adalah penjelasan yang komprehensif. Jika itu tidak masuk akal, lompatlah ke Learn You A Haskell For Great Good! , salah satu tutorial bahasa online terbaik yang pernah saya baca. Semoga Anda akan melihat keindahan yang sama dalam bahasa ini yang saya lakukan.
sumber
Saya pikir kutipan Anda adalah argumen orang bodoh.
Bahasa modern saat ini (termasuk C #), menjamin Anda bahwa konstruktor telah selesai atau tidak.
Jika ada pengecualian dalam konstruktor dan objek dibiarkan sebagian tidak diinisialisasi, memiliki
null
atauMaybe::none
untuk keadaan tidak diinisialisasi tidak membuat perbedaan nyata dalam kode destruktor.Anda hanya harus menghadapinya. Ketika ada sumber daya eksternal untuk dikelola, Anda harus mengelola sumber daya tersebut secara eksplisit dengan cara apa pun. Bahasa dan perpustakaan dapat membantu, tetapi Anda harus memikirkan ini.
Btw: Dalam C #,
null
nilainya hampir sama denganMaybe::none
. Anda dapat menetapkannull
hanya untuk variabel dan anggota objek yang pada tingkat tipe dinyatakan sebagai nullable :Tidak ada bedanya dengan cuplikan berikut:
Jadi sebagai kesimpulan, saya tidak melihat bagaimana nullability berlawanan dengan
Maybe
tipe. Saya bahkan akan menyarankan bahwa C # telah menyelinap diMaybe
tipe itu sendiri dan menyebutnyaNullable<T>
.Dengan metode ekstensi, bahkan lebih mudah untuk mendapatkan pembersihan dari Nullable untuk mengikuti pola monadik:
sumber
null
anggota implisit dari setiap jenis danMaybe<T>
adalah bahwa denganMaybe<T>
, Anda juga dapat memilikiT
yang tidak memiliki nilai default.null
bukan anggota implisit dari setiap jenis. Untuknull
menjadi nilai lebal, Anda perlu mendefinisikan jenis yang dapat nullable secara eksplisit, yang membuatT?
(sintaks gula untukNullable<T>
) dasarnya setara denganMaybe<T>
.C ++ melakukannya dengan memiliki akses ke penginisialisasi yang terjadi sebelum badan konstruktor. C # menjalankan penginisialisasi default sebelum badan konstruktor, C secara kasar menetapkan 0 untuk semuanya,
floats
menjadi 0,0,bools
menjadi salah, referensi menjadi nol, dll. Dalam C ++ Anda dapat membuatnya menjalankan penginisialisasi yang berbeda untuk memastikan jenis referensi non-nol tidak pernah nol .sumber
null
, dan satu-satunya cara untuk menunjukkan tidak adanya nilai adalah dengan menggunakanMaybe
tipe (juga dikenal sebagaiOption
), yang tidak dimiliki AFAIK C ++ di perpustakaan standar. Tidak adanya null memungkinkan kami untuk menjamin bahwa suatu bidang akan selalu valid sebagai properti dari sistem tipe . Ini adalah jaminan yang lebih kuat daripada secara manual memastikan bahwa tidak ada jalur kode di mana variabel mungkin masihnull
.