Bagaimana bahasa dengan tipe Mungkin alih-alih nol menangani kondisi tepi?

53

Eric Lippert membuat poin yang sangat menarik dalam diskusi tentang mengapa C # menggunakan tipe nulldaripada 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?

Mason Wheeler
sumber
Saya kira jika Maybe adalah bagian dari bahasa itu mungkin itu diterapkan secara internal melalui pointer nol dan itu hanya gula sintaksis. Tapi saya rasa bahasa apa pun tidak benar-benar seperti ini.
panzi
1
@panzi: Ceylon menggunakan pengetikan aliran-sensitif untuk membedakan antara Type?(mungkin) dan Type(bukan nol)
Lukas Eder
1
@RobertHarvey Bukankah sudah ada tombol "pertanyaan bagus" di Stack Exchange?
user253751
2
@panzi Itu adalah optimasi yang bagus dan valid, tetapi tidak membantu dengan masalah ini: Ketika sesuatu bukan Maybe T, itu tidak boleh Nonedan karenanya Anda tidak dapat menginisialisasi penyimpanannya ke pointer nol.
@ Imibis: Saya sudah mendorongnya. Kami mendapat beberapa pertanyaan bagus yang berharga di sini; Saya pikir ini pantas komentar.
Robert Harvey

Jawaban:

45

Kutipan itu menunjuk ke masalah yang terjadi jika deklarasi dan penugasan pengidentifikasi (di sini: anggota contoh) terpisah satu sama lain. Sebagai sketsa kodesemu cepat:

class Broken {
    val foo: Foo  // where Foo and Bar are non-nullable reference types
    val bar: Bar

    Broken() {
        foo = new Foo()
        throw new Exception()
        // this code is never reached, so "bar" is not assigned
        bar = new Bar()
    }

    ~Broken() {
        foo.cleanup()
        bar.cleanup()
    }
}

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 nilainya bar?

Namun, masalah ini ada karena kombinasi dari tiga aspek:

  • Tidak adanya nilai default suka nullatau dijamin inisialisasi untuk variabel anggota.
  • Perbedaan antara deklarasi dan penugasan. Memaksa variabel untuk ditugaskan segera (misalnya dengan letpernyataan seperti yang terlihat dalam bahasa fungsional) adalah mudah adalah untuk memaksa inisialisasi dijamin - tetapi membatasi bahasa dengan cara lain.
  • Rasa spesifik destruktor sebagai metode yang dipanggil oleh runtime bahasa.

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:

// the body of the class *is* the constructor
class Working() {
    val foo: Foo = new Foo()
    FINALIZE { foo.cleanup() }  // block is registered to run when object is destroyed

    throw new Exception()

    // the below code is never reached, so
    //  1. the "bar" variable never enters the scope
    //  2. the second finalizer block is never registered.
    val bar: Bar = new Bar()
    FINALIZE { bar.cleanup() }  // block is registered to run when object is destroyed
}

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.

amon
sumber
Ada juga alasan lain mengapa finalizer harus berurusan dengan nulls: urutan finalisasi tidak dijamin, karena kemungkinan siklus referensi. Tapi saya kira FINALIZEdesain Anda juga memecahkan bahwa: jika foosudah selesai, FINALIZEbagiannya tidak akan berjalan.
svick
14

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 melakukannya self.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

Object o;
try {
    call_can_throw();
    o = new Object();
} catch {}
use(o);

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
7

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 Nothingnilai nilai null (Atau dalam Haskell) untuk muncul di mana kami mengharapkan nilai non-nol karena nilai nol hanya dapat terjadi dalam jenis pembungkus khusus Maybeyang disebut yang tidak dapat dipertukarkan dengan / langsung dapat dikonversi menjadi, tidak langsung jenis yang dapat dibatalkan. Untuk menggunakan nilai yang dibuat nullable dengan membungkusnya dalam Maybe, 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:

dapatkah kita selalu tahu bahwa referensi yang tidak dapat dibatalkan tidak pernah dalam keadaan apapun dianggap tidak valid?

Iya. Intdan Maybe Intdua tipe yang sepenuhnya terpisah. Menemukan Nothingdi dataran Intakan sebanding dengan menemukan "ikan" string dalam Int32.

Bagaimana dengan konstruktor objek dengan bidang tipe referensi yang tidak dapat dibatalkan?

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.

Bagaimana dengan di finalizer dari objek seperti itu, di mana objek tersebut selesai karena kode yang seharusnya mengisi referensi melemparkan pengecualian?

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 Maybetipe data untuk mewakili nullables. Mungkin tipe data aljabar didefinisikan seperti ini:

data Maybe a = Just a | Nothing

Bagi Anda yang tidak terbiasa dengan Haskell, baca ini sebagai "A Maybeadalah salah satu Nothingatau Just a". Secara khusus:

  • Maybeadalah konstruktor tipe : dapat dianggap (salah) sebagai kelas generik (di mana avariabel tipe). Analogi C # adalah class Maybe<a>{}.
  • Justadalah konstruktor nilai : ini adalah fungsi yang mengambil satu argumen tipe adan mengembalikan nilai tipe Maybe ayang berisi nilai. Jadi kodenya x = Just 17analog dengan int? x = 17;.
  • Nothingadalah konstruktor nilai lain, tetapi tidak memerlukan argumen dan yang Maybedikembalikan tidak memiliki nilai selain "Tidak Ada". x = Nothinganalog dengan int? x = null;(dengan asumsi kita membatasi aHaskell kita Int, yang dapat dilakukan dengan menulis x = Nothing :: Maybe Int).

Sekarang setelah dasar-dasar dari Maybetipe 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:

ReferenceType x;

Tidak mungkin di Haskell. Tidak ada masalah dengan menginisialisasi nilai nullkarena 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:

do thing 1
add thing 2 to thing 3
do thing 4
if thing 5:
    do thing 6
return thing 7

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 donotasi yang tampak imperatif hanyalah gula sintaksis untuk meneruskan fungsi anonim kepada >>=operator. Semua fungsi berbentuk:

<optional explicit type signature>
functionName arg1 arg2 ... argn = body-expression

Di mana body-expressionbisa 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 Intke Double? Gunakan fromIntegralfungsi). Satu-satunya yang mungkin memiliki nilai tidak valid terjadi pada saat runtime adalah menggunakan Prelude.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):

data NotSoBroken = NotSoBroken {foo :: Foo, bar :: Bar } 

( foodan barbenar-benar fungsi accessor ke bidang anonim di sini alih-alih bidang yang sebenarnya, tetapi kita dapat mengabaikan detail ini).

The NotSoBrokennilai konstruktor tidak mampu mengambil tindakan apapun selain mengambil Foodan Bar(yang tidak nullable) dan membuat NotSoBrokenkeluar 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 Brokenselalu gagal. Tidak ada cara untuk mematahkan NotSoBrokenkonstruktor nilai dengan cara yang sama (tidak ada tempat untuk menulis kode), tetapi kita dapat membuat fungsi pabrik yang sama-sama cacat.

makeNotSoBroken :: Foo -> Bar -> Maybe NotSoBroken
makeNotSoBroken foo bar = Nothing

(baris pertama adalah tipe deklarasi tanda tangan: makeNotSoBrokenmengambil argumen a Foodan a Barsebagai dan menghasilkan a Maybe NotSoBroken).

Tipe pengembalian harus Maybe NotSoBrokendan bukan hanya NotSoBrokenkarena kami menyuruhnya untuk mengevaluasi Nothing, yang merupakan konstruktor nilai untuk Maybe. 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 useNotSoBrokenyang mengharapkan NotSoBrokensebagai argumen:

useNotSoBroken :: NotSoBroken -> Whatever

( useNotSoBrokenmenerima a NotSoBrokensebagai argumen dan menghasilkan a Whatever).

Dan gunakan seperti ini:

useNotSoBroken (makeNotSoBroken)

Dalam sebagian besar bahasa, perilaku semacam ini dapat menyebabkan pengecualian penunjuk nol. Di Haskell, tipe tidak cocok: makeNotSoBrokenmengembalikan a Maybe NotSoBroken, tetapi useNotSoBrokenmengharapkan a NotSoBroken. Jenis ini tidak dapat dipertukarkan, dan kode gagal dikompilasi.

Untuk menyiasatinya, kita bisa menggunakan casepernyataan untuk bercabang berdasarkan pada struktur Maybenilai (menggunakan fitur yang disebut pencocokan pola ):

case makeNotSoBroken of
    Nothing  -> --handle situation here
    (Just x) -> useNotSoBroken x

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:

  • Pertama, makeNotSoBrokendievaluasi, yang dijamin menghasilkan nilai tipe Maybe NotSoBroken.
  • The casepernyataan memeriksa struktur nilai ini.
  • Jika nilainya Nothing, kode "pegangan situasi di sini" dievaluasi.
  • Jika nilainya cocok dengan Justnilai, cabang lainnya dieksekusi. Perhatikan bagaimana klausa yang cocok secara bersamaan mengidentifikasi nilai sebagai Justkonstruksi dan mengikat NotSoBrokenbidang internalnya ke sebuah nama (dalam hal ini, x). xkemudian dapat digunakan seperti nilai normal NotSoBrokenitu.

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.

ApproachingDarknessFish
sumber
TL; DR harus di atas :)
andrew.fox
@ andrew.fox Poin bagus. Saya akan mengedit.
ApproachingDarknessFish
0

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 nullatau Maybe::noneuntuk 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 #, nullnilainya hampir sama dengan Maybe::none. Anda dapat menetapkan nullhanya untuk variabel dan anggota objek yang pada tingkat tipe dinyatakan sebagai nullable :

String? nullableString = getOptionalString();
Nullable<String> maybe = nullableString; // This is equivalent

Tidak ada bedanya dengan cuplikan berikut:

Maybe<String> optionalString = getOptionalString();

Jadi sebagai kesimpulan, saya tidak melihat bagaimana nullability berlawanan dengan Maybetipe. Saya bahkan akan menyarankan bahwa C # telah menyelinap di Maybetipe itu sendiri dan menyebutnya Nullable<T>.

Dengan metode ekstensi, bahkan lebih mudah untuk mendapatkan pembersihan dari Nullable untuk mengikuti pola monadik:

Resource? resource = initializationThatMayFail();
...
resource.ifExists( Resource r -> r.cleanup() );
Roland Tepp
sumber
2
apa artinya, "konstruktor selesai sepenuhnya atau tidak"? Di Jawa misalnya, inisialisasi bidang (non-final) di konstruktor tidak dilindungi dari perlombaan data - apakah itu memenuhi syarat sebagai sepenuhnya-lengkap atau tidak?
nyamuk
@gnat: apa yang Anda maksud dengan "Di Jawa misalnya, inisialisasi bidang (non-final) di konstruktor tidak dilindungi dari ras data". Kecuali jika Anda melakukan sesuatu yang sangat kompleks yang melibatkan banyak utas, kemungkinan kondisi balapan di dalam konstruktor hampir tidak mungkin terjadi. Anda tidak dapat mengakses bidang objek yang tidak dibangun kecuali dari dalam konstruktor objek. Dan jika konstruksi gagal, Anda tidak memiliki referensi ke objek.
Roland Tepp
Perbedaan besar antara nullanggota implisit dari setiap jenis dan Maybe<T>adalah bahwa dengan Maybe<T>, Anda juga dapat memiliki Tyang tidak memiliki nilai default.
svick
Saat membuat array, sering kali tidak mungkin untuk menentukan nilai yang berguna untuk semua elemen tanpa harus membaca beberapa, juga tidak akan mungkin untuk memverifikasi secara statis bahwa tidak ada elemen yang dibaca tanpa nilai yang berguna telah dihitung untuk itu. Yang terbaik yang bisa dilakukan adalah menginisialisasi elemen array sedemikian rupa sehingga mereka dapat dikenali sebagai tidak dapat digunakan.
supercat
@svick: Dalam C # (yang merupakan bahasa yang dipertanyakan oleh OP), nullbukan anggota implisit dari setiap jenis. Untuk nullmenjadi nilai lebal, Anda perlu mendefinisikan jenis yang dapat nullable secara eksplisit, yang membuat T?(sintaks gula untuk Nullable<T>) dasarnya setara dengan Maybe<T>.
Roland Tepp
-3

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, floatsmenjadi 0,0, boolsmenjadi salah, referensi menjadi nol, dll. Dalam C ++ Anda dapat membuatnya menjalankan penginisialisasi yang berbeda untuk memastikan jenis referensi non-nol tidak pernah nol .

class Foo { Foo(int i) { throw new Exception("Never finishes"); }
class Bar { Bar(string s) { } }

class Broken
{
    val foo: Foo  // where Foo and Bar are non-nullable reference types
    val bar: Bar

    Broken() :
        foo = new Foo(123),// roughly causes a "goto destroy_foo;"
        bar = new Bar("never executes") { }

    // This destructory-function never runs because the constructor never completed
    ~Broken() 
    // This is made-up syntax:
    // : 
    // destroy_bar:
    // bar.~Bar();
    // destroy_foo:
    // foo.~Foo();
    {
    }
}
ryancerium
sumber
2
pertanyaannya adalah tentang bahasa dengan jenis Mungkin
agas
3
Referensi menjadi nol ” - seluruh premis dari pertanyaan adalah bahwa kita tidak memiliki null, dan satu-satunya cara untuk menunjukkan tidak adanya nilai adalah dengan menggunakan Maybetipe (juga dikenal sebagai Option), 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 masih null.
Amon
Sementara c ++ tidak memiliki tipe Mungkin secara eksplisit, hal-hal seperti std :: shared_ptr <T> cukup dekat sehingga saya pikir masih relevan bahwa c ++ menangani kasus di mana inisialisasi variabel dapat terjadi "di luar cakupan" konstruktor, dan sebenarnya diperlukan untuk jenis referensi (&), karena tidak boleh nol.
FryGuy