Mengapa menggunakan Pengecualian (dicentang)?

17

Belum lama ini saya mulai menggunakan Scala, bukan Java. Bagian dari proses "konversi" antara bahasa untuk saya adalah belajar menggunakan Eithers bukan (dicentang) Exceptions. Saya telah mengkode cara ini untuk sementara waktu, tetapi baru-baru ini saya mulai bertanya-tanya apakah itu benar-benar cara yang lebih baik untuk pergi.

Salah satu keunggulan utama Eithertelah berakhir Exceptionadalah kinerja yang lebih baik; sebuah Exceptionkebutuhan untuk membangun tumpukan-jejak dan sedang dilemparkan. Sejauh yang saya mengerti, melempar Exceptionbukan bagian yang menuntut, tetapi membangun stack-trace adalah.

Tapi kemudian, kita selalu bisa membuat / mewarisi Exceptiondengan scala.util.control.NoStackTrace, dan lebih dari itu, saya melihat banyak kasus di mana sisi kiri sebuah Eithersebenarnya adalah Exception(meneruskan peningkatan kinerja).

Satu keuntungan lain yang dimiliki Eitheradalah keamanan kompiler; kompiler Scala tidak akan mengeluh tentang yang tidak ditangani Exception(tidak seperti kompiler Java). Tetapi jika saya tidak salah, keputusan ini beralasan dengan alasan yang sama yang sedang dibahas dalam topik ini, jadi ...

Dalam hal sintaks, saya merasa seperti- Exceptiongaya jauh lebih jelas. Periksa blok kode berikut (keduanya mencapai fungsi yang sama):

Either gaya:

def compute(): Either[String, Int] = {

    val aEither: Either[String, String] = if (someCondition) Right("good") else Left("bad")

    val bEithers: Iterable[Either[String, Int]] = someSeq.map {
        item => if (someCondition(item)) Right(item.toInt) else Left("bad")
    }

    for {
        a <- aEither.right
        bs <- reduce(bEithers).right
        ignore <- validate(bs).right
    } yield compute(a, bs)
}

def reduce[A,B](eithers: Iterable[Either[A,B]]): Either[A, Iterable[B]] = ??? // utility code

def validate(bs: Iterable[Int]): Either[String, Unit] = if (bs.sum > 22) Left("bad") else Right()

def compute(a: String, bs: Iterable[Int]): Int = ???

Exception gaya:

@throws(classOf[ComputationException])
def compute(): Int = {

    val a = if (someCondition) "good" else throw new ComputationException("bad")
    val bs = someSeq.map {
        item => if (someCondition(item)) item.toInt else throw new ComputationException("bad")
    }

    if (bs.sum > 22) throw new ComputationException("bad")

    compute(a, bs)
}

def compute(a: String, bs: Iterable[Int]): Int = ???

Yang terakhir terlihat jauh lebih bersih bagi saya, dan kode yang menangani kegagalan (baik pencocokan pola Eitheratau try-catch) cukup jelas dalam kedua kasus.

Jadi pertanyaan saya adalah - mengapa menggunakan Eitherlebih dari (dicentang) Exception?

Memperbarui

Setelah membaca jawaban, saya menyadari bahwa saya mungkin gagal menyajikan inti dari dilema saya. Kekhawatiran saya bukan pada kurangnya try-catch; seseorang dapat "menangkap" Exceptiondengan Try, atau menggunakan catchuntuk membungkus pengecualian Left.

Masalah utama saya dengan Either/ Trymuncul ketika saya menulis kode yang mungkin gagal di banyak titik di sepanjang jalan; dalam skenario ini, ketika menemui kegagalan, saya harus menyebarkan kegagalan itu di seluruh kode saya, sehingga membuat kode cara yang lebih rumit (seperti yang ditunjukkan dalam contoh-contoh tersebut).

Sebenarnya ada cara lain untuk memecahkan kode tanpa Exceptionmenggunakan return(yang sebenarnya adalah "tabu" di Scala). Kode masih lebih jelas daripada Eitherpendekatan, dan sementara menjadi sedikit kurang bersih daripada Exceptiongaya, tidak akan ada rasa takut dari yang tidak tertangkap Exception.

def compute(): Either[String, Int] = {

  val a = if (someCondition) "good" else return Left("bad")

  val bs: Iterable[Int] = someSeq.map {
    item => if (someCondition(item)) item.toInt else return Left("bad")
  }

  if (bs.sum > 22) return Left("bad")

  val c = computeC(bs).rightOrReturn(return _)

  Right(computeAll(a, bs, c))
}

def computeC(bs: Iterable[Int]): Either[String, Int] = ???

def computeAll(a: String, bs: Iterable[Int], c: Int): Int = ???

implicit class ConvertEither[L, R](either: Either[L, R]) {

  def rightOrReturn(f: (Left[L, R]) => R): R = either match {
    case Right(r) => r
    case Left(l) => f(Left(l))
  }
}

Pada dasarnya return Leftpenggantian throw new Exception, dan metode implisit pada keduanya rightOrReturn, adalah suplemen untuk propagasi pengecualian otomatis di stack.

Eyal Roth
sumber
Baca ini: mauricio.github.io/2014/02/17/...
Robert Harvey
@RobertHarvey Sepertinya sebagian besar artikel membahas Try. Bagian tentang Eithervs Exceptionhanya menyatakan bahwa Eithers harus digunakan ketika kasus lain dari metode ini adalah "tidak luar biasa". Pertama, ini adalah definisi imho yang sangat, sangat kabur. Kedua, apakah itu sepadan dengan hukuman sintaksis? Maksudku, aku benar-benar tidak akan keberatan menggunakan Eithers jika bukan karena overhead sintaks yang mereka sajikan.
Eyal Roth
EitherSepertinya monad bagiku. Gunakan saat Anda membutuhkan manfaat komposisi fungsional yang disediakan monad. Atau mungkin juga tidak .
Robert Harvey
2
@RobertHarvey: Secara teknis, Eitherdengan sendirinya bukan monad. The proyeksi baik sisi kiri atau sisi kanan adalah monad, tapi Eitherdengan sendirinya tidak. Anda dapat menjadikannya monad, dengan "membiaskan" ke sisi kiri atau kanan. Namun, kemudian Anda memberikan semantik tertentu di kedua sisi Either. Scala Eitherawalnya tidak bias, tetapi bias agak baru-baru ini, sehingga saat ini, itu sebenarnya sebuah monad, tetapi "kebesaran" bukan properti yang melekat Eithermelainkan hasil dari itu menjadi bias.
Jörg W Mittag
@ JörgWMittag: Baiklah, bagus. Itu berarti bahwa Anda dapat menggunakannya untuk semua kebaikan monadiknya, dan tidak terlalu terganggu oleh seberapa "bersih" kode yang dihasilkan (meskipun saya menduga bahwa kode kelanjutan gaya fungsional sebenarnya akan lebih bersih daripada versi Exception).
Robert Harvey

Jawaban:

13

Jika Anda hanya menggunakan blok Eitherimperatif persis seperti itu try-catch, tentu saja itu akan terlihat seperti sintaks yang berbeda untuk melakukan hal yang persis sama. Eithersadalah sebuah nilai . Mereka tidak memiliki batasan yang sama. Kamu bisa:

  • Hentikan kebiasaan Anda menjaga blok percobaan Anda kecil dan berdekatan. Bagian "tangkapan" Eitherstidak harus berada tepat di sebelah bagian "coba". Bahkan dapat dibagikan dalam fungsi umum. Ini membuatnya lebih mudah untuk memisahkan masalah dengan benar.
  • Simpan Eithersdalam struktur data. Anda dapat mengulanginya dan menggabungkan pesan kesalahan, menemukan pesan pertama yang tidak gagal, dengan malas mengevaluasinya, atau serupa.
  • Perpanjang dan sesuaikan. Tidak suka pelat ketel yang terpaksa Anda tulis Eithers? Anda dapat menulis fungsi pembantu untuk membuatnya lebih mudah digunakan untuk kasus penggunaan khusus Anda. Tidak seperti try-catch, Anda tidak terjebak secara permanen dengan hanya antarmuka default yang dihasilkan oleh perancang bahasa.

Catatan untuk sebagian besar kasus penggunaan, Trymemiliki antarmuka yang lebih sederhana, memiliki sebagian besar manfaat di atas, dan biasanya memenuhi kebutuhan Anda juga. An Optionbahkan lebih sederhana jika yang Anda pedulikan adalah kesuksesan atau kegagalan dan tidak memerlukan informasi status seperti pesan kesalahan tentang kasus kegagalan.

Dalam pengalaman saya, Eitherstidak benar-benar disukai daripada Tryskecuali itu Leftadalah sesuatu selain Exception, mungkin pesan kesalahan validasi, dan Anda ingin mengagregasikan Leftnilainya. Misalnya, Anda sedang memproses beberapa input dan Anda ingin mengumpulkan semua pesan kesalahan, bukan hanya salah pada yang pertama. Situasi itu tidak sering muncul dalam praktik, setidaknya untuk saya.

Lihatlah di luar model try-catch dan Anda akan menemukan Eithersjauh lebih menyenangkan untuk dikerjakan.

Pembaruan: pertanyaan ini masih mendapatkan perhatian, dan saya belum melihat pembaruan OP mengklarifikasi pertanyaan sintaks, jadi saya pikir saya akan menambahkan lebih banyak.

Sebagai contoh untuk keluar dari pola pikir pengecualian, ini adalah bagaimana saya biasanya menulis kode contoh Anda:

def compute(): Either[String, Int] = {
  Right(someSeq)
    .filterOrElse(someACondition,            "someACondition failed")
    .filterOrElse(_ forall someSeqCondition, "someSeqCondition failed")
    .map(_ map {_.toInt})
    .filterOrElse(_.sum <= 22, "Sum is greater than 22")
    .map(compute("good",_))
}

Di sini Anda dapat melihat bahwa semantik filterOrElsemenangkap dengan sempurna apa yang terutama kami lakukan dalam fungsi ini: memeriksa kondisi dan mengaitkan pesan kesalahan dengan kegagalan yang diberikan. Karena ini adalah kasus penggunaan yang sangat umum, filterOrElseada di perpustakaan standar, tetapi jika tidak, Anda bisa membuatnya.

Ini adalah titik di mana seseorang menghasilkan contoh tandingan yang tidak dapat dipecahkan dengan cara yang persis sama dengan saya, tetapi poin yang saya coba buat adalah tidak hanya ada satu cara untuk digunakan Eithers, tidak seperti pengecualian. Ada beberapa cara untuk menyederhanakan kode untuk sebagian besar situasi penanganan kesalahan, jika Anda menjelajahi semua fungsi yang tersedia untuk digunakan Eithersdan terbuka untuk eksperimen.

Karl Bielefeldt
sumber
1. Mampu memisahkan trydan catchmerupakan argumen yang adil, tetapi tidakkah ini bisa dengan mudah dicapai Try? 2. Menyimpan Eitherdalam struktur data dapat dilakukan di samping throwsmekanisme; bukankah itu hanya lapisan tambahan di atas penanganan kegagalan? 3. Anda pasti dapat memperpanjang Exceptions. Tentu, Anda tidak dapat mengubah try-catchstruktur, tetapi menangani kegagalan adalah hal yang sangat umum sehingga saya pasti ingin bahasanya memberi saya boilerplate untuk itu (yang saya tidak dipaksa untuk menggunakan). Itu seperti mengatakan bahwa untuk-pemahaman adalah buruk karena itu adalah boiler untuk operasi monadik.
Eyal Roth
Saya baru menyadari bahwa Anda mengatakan bahwa Eithers dapat diperpanjang, di mana jelas mereka tidak bisa (menjadi sealed traitdengan hanya dua implementasi, keduanya final). Dapatkah Anda menguraikan itu? Saya berasumsi Anda tidak bermaksud arti harfiah memperluas.
Eyal Roth
1
Maksud saya diperluas seperti dalam kata bahasa Inggris, bukan kata kunci pemrograman. Menambahkan fungsionalitas dengan membuat fungsi untuk menangani pola umum.
Karl Bielefeldt
8

Keduanya sebenarnya sangat berbeda. EitherSemantik jauh lebih umum daripada hanya untuk mewakili perhitungan yang berpotensi gagal.

Eithermemungkinkan Anda mengembalikan salah satu dari dua jenis yang berbeda. Itu dia.

Sekarang, salah satu dari dua jenis itu mungkin atau mungkin tidak mewakili kesalahan dan yang lain mungkin atau mungkin tidak mewakili keberhasilan, tetapi itu hanya satu kemungkinan kasus penggunaan dari banyak jenis. Eitherjauh, jauh lebih umum dari itu. Anda bisa saja memiliki metode yang membaca input dari pengguna yang diizinkan memasukkan nama atau tanggal mengembalikan suatu Either[String, Date].

Atau, pikirkan tentang lambda literal dalam C♯: mereka mengevaluasi ke Funcatau Expression, yaitu mereka mengevaluasi ke fungsi anonim atau mereka mengevaluasi ke AST. Dalam C♯, ini terjadi "secara ajaib", tetapi jika Anda ingin memberikan jenis yang tepat untuk itu, itu akan menjadi Either<Func<T1, T2, …, R>, Expression<T1, T2, …, R>>. Namun, tak satu pun dari kedua opsi tersebut merupakan kesalahan, dan tak satu pun dari keduanya adalah "normal" atau "luar biasa". Mereka hanya dua jenis berbeda yang dapat dikembalikan dari fungsi yang sama (atau dalam hal ini, dianggap berasal dari literal yang sama). [Catatan: sebenarnya, Funcharus dipecah menjadi dua kasus lain, karena C♯ tidak memiliki Unittipe, dan sesuatu yang tidak mengembalikan apa pun tidak dapat direpresentasikan dengan cara yang sama dengan sesuatu yang mengembalikan sesuatu,Either<Either<Func<T1, T2, …, R>, Action<T1, T2, …>>, Expression<T1, T2, …, R>>

Sekarang, Either dapat digunakan untuk mewakili tipe kesuksesan dan tipe kegagalan. Dan jika Anda setuju pada pemesanan yang konsisten dari kedua jenis, Anda bahkan dapat bias Either untuk memilih salah satu dari dua jenis, sehingga memungkinkan untuk menyebarkan kegagalan melalui rantai monadic. Tetapi dalam kasus itu, Anda memberikan semantik tertentu ke Eitheryang tidak harus dimiliki sendiri.

An Eitheryang memiliki salah satu dari dua jenisnya ditetapkan menjadi tipe kesalahan dan bias ke satu sisi untuk menyebarkan kesalahan, biasanya disebut Errormonad, dan akan menjadi pilihan yang lebih baik untuk mewakili perhitungan yang berpotensi gagal. Dalam Scala, jenis ini disebut Try.

Lebih masuk akal membandingkan penggunaan pengecualian dengan Trydengan Either.

Jadi, ini adalah alasan # 1 mengapa Eithermungkin digunakan lebih dari pengecualian yang diperiksa: Eitherjauh lebih umum daripada pengecualian. Ini dapat digunakan dalam kasus di mana pengecualian bahkan tidak berlaku.

Namun, Anda kemungkinan besar bertanya tentang kasus terbatas di mana Anda hanya menggunakan Either(atau lebih mungkin Try) sebagai pengganti pengecualian. Dalam hal ini, masih ada beberapa keuntungan, atau pendekatan yang agak berbeda mungkin terjadi.

Keuntungan utama adalah bahwa Anda dapat menunda penanganan kesalahan. Anda hanya menyimpan nilai pengembalian, bahkan tanpa melihat apakah itu merupakan keberhasilan atau kesalahan. (Tangani nanti.) Atau berikan di tempat lain. (Biarkan orang lain mengkhawatirkannya.) Kumpulkan mereka dalam daftar. (Tangani semuanya sekaligus). Anda dapat menggunakan rantai monadik untuk menyebarkannya di sepanjang pipa pemrosesan.

Semantik perkecualian tertanam dalam bahasa tersebut. Di Scala, pengecualian melepaskan tumpukan, misalnya. Jika Anda membiarkannya menggembung ke handler pengecualian, maka handler tidak bisa kembali ke tempat kejadiannya dan memperbaikinya. OTOH, jika Anda menangkapnya lebih rendah di tumpukan sebelum melepaskannya dan merusak konteks yang diperlukan untuk memperbaikinya, maka Anda mungkin berada di komponen yang terlalu rendah untuk membuat keputusan yang terinformasi tentang bagaimana untuk melanjutkan.

Hal ini berbeda dalam Smalltalk, misalnya, di mana pengecualian tidak bersantai stack dan resumable, dan Anda dapat menangkap pengecualian tinggi di tumpukan (mungkin sebagai tinggi-tinggi sebagai debugger), memperbaiki kesalahan waaaaayyyyy bawah di susun dan lanjutkan eksekusi dari sana. Atau kondisi CommonLisp di mana mengangkat, menangkap, dan menangani adalah tiga hal yang berbeda alih-alih dua (penanganan mengangkat dan menangkap) seperti halnya pengecualian.

Menggunakan tipe pengembalian kesalahan yang sebenarnya alih-alih pengecualian memungkinkan Anda melakukan hal serupa, dan lebih banyak lagi, tanpa harus memodifikasi bahasa.

Dan kemudian ada gajah yang jelas di ruangan: pengecualian adalah efek samping. Efek sampingnya jahat. Catatan: Saya tidak mengatakan bahwa ini benar. Tetapi ini adalah sudut pandang yang dimiliki beberapa orang, dan dalam hal ini, keuntungan dari jenis kesalahan lebih dari pengecualian jelas. Jadi, jika Anda ingin melakukan pemrograman fungsional, Anda mungkin menggunakan tipe kesalahan karena satu-satunya alasan bahwa mereka adalah pendekatan fungsional. (Perhatikan bahwa Haskell memiliki pengecualian. Perhatikan juga bahwa tidak ada yang suka menggunakannya.)

Jörg W Mittag
sumber
Saya suka jawabannya, meskipun saya masih tidak mengerti mengapa saya harus melupakannya Exception. Eithersepertinya alat yang hebat, tapi ya, saya secara khusus bertanya tentang menangani kegagalan, dan saya lebih suka menggunakan alat yang jauh lebih spesifik untuk tugas saya daripada yang sangat kuat. Menunda penanganan kegagalan adalah argumen yang adil, tetapi orang dapat dengan mudah menggunakan Trymetode melempar Exceptionjika dia ingin tidak menanganinya saat ini. Apakah stack-trace unwinding mempengaruhi Either/ Tryjuga? Anda dapat mengulangi kesalahan yang sama ketika menyebarkannya secara tidak benar; mengetahui kapan menangani kegagalan bukan hal sepele dalam kedua kasus tersebut.
Eyal Roth
Dan saya tidak bisa berdebat dengan alasan "murni fungsional", bukan?
Eyal Roth
0

Yang menyenangkan tentang pengecualian adalah bahwa kode asal telah mengklasifikasikan keadaan luar biasa untuk Anda. Perbedaan antara IllegalStateExceptiondan BadParameterExceptionlebih mudah dibedakan dalam bahasa yang diketik lebih kuat. Jika Anda mencoba menguraikan "baik" dan "buruk", Anda tidak memanfaatkan alat yang sangat kuat yang Anda inginkan, yaitu kompiler Scala. Ekstensi Anda sendiri untuk Throwabledapat memasukkan informasi sebanyak yang Anda butuhkan, di samping string pesan teks.

Ini adalah utilitas sebenarnya Trydalam lingkungan Scala, dengan Throwablebertindak sebagai pembungkus item kiri untuk membuat hidup lebih mudah.

BobDalgleish
sumber
2
Saya tidak mengerti maksud Anda.
Eyal Roth
Eithermemiliki masalah gaya karena itu bukan monad sejati (?); kesewenang-wenangan dari leftnilai menjauhi nilai tambah yang lebih tinggi ketika Anda melampirkan informasi tentang kondisi luar biasa dalam struktur yang tepat. Jadi membandingkan penggunaan Eitherdalam satu kasus mengembalikan string di sisi kiri, dibandingkan try/catchdalam kasus lain menggunakan diketik dengan benar Throwables tidak tepat. Karena nilai Throwablesuntuk memberikan informasi, dan cara ringkas yang Trymenangani Throwables, Tryadalah taruhan yang lebih baik daripada ... Eitheratautry/catch
BobDalgleish
Saya sebenarnya tidak peduli dengan try-catch. Seperti yang dikatakan dalam pertanyaan yang terakhir terlihat jauh lebih bersih bagi saya, dan kode yang menangani kegagalan (baik pencocokan pola pada Either atau try-catch) cukup jelas dalam kedua kasus.
Eyal Roth