Pengecualian python sekarang dianggap anti-pola

Apa itu pengecualian? Dari namanya jelas - mereka muncul ketika pengecualian terjadi dalam program. Anda mungkin bertanya mengapa pengecualian merupakan anti-pola, dan bagaimana kaitannya dengan mengetik? Saya mencoba mencari tahu , dan sekarang saya ingin membicarakan ini dengan Anda, harazhiteli.

Masalah Pengecualian


Sulit menemukan kekurangan pada apa yang Anda hadapi setiap hari. Kebiasaan dan kebutaan mengubah bug menjadi fitur, tetapi mari kita coba melihat pengecualian dengan pikiran terbuka.

Pengecualian sulit dikenali


Ada dua jenis pengecualian: "eksplisit" dibuat dengan memanggil raise langsung dalam kode yang Anda baca; "Tersembunyi" tersembunyi dalam fungsi, kelas, metode yang digunakan.

Masalahnya adalah bahwa pengecualian “tersembunyi” sangat sulit untuk diperhatikan. Mari saya tunjukkan contoh fungsi murni:

 def divide(first: float, second: float) -> float: return first / second 

Fungsi ini hanya membagi satu nomor dengan yang lain, mengembalikan float . Jenis dicentang dan Anda dapat menjalankan sesuatu seperti ini:

 result = divide(1, 0) print('x / y = ', result) 

Pernahkah Anda memperhatikan? Bahkan, pelaksanaan program tidak akan pernah mencapai print , karena membagi 1 dengan 0 adalah operasi yang mustahil, itu akan meningkatkan ZeroDivisionError . Ya, kode seperti itu aman, tetapi tidak bisa digunakan.

Untuk memperhatikan masalah potensial bahkan dalam kode yang sederhana dan mudah dibaca, orang perlu pengalaman. Apa pun di Python dapat berhenti bekerja dengan berbagai jenis pengecualian: pembagian, pemanggilan fungsi, int , str , generator, iterator dalam loop, akses ke atribut atau kunci. Bahkan raise something() dapat menyebabkan crash. Selain itu, saya bahkan tidak menyebutkan operasi input dan output. Dan pengecualian yang dicentang tidak akan lagi didukung dalam waktu dekat.

Memulihkan perilaku normal pada tempatnya tidak dimungkinkan


Tetapi tepatnya untuk kasus seperti itu, kami memiliki pengecualian. Mari kita menangani ZeroDivisionError dan kodenya akan menjadi tipe aman.

 def divide(first: float, second: float) -> float: try: return first / second except ZeroDivisionError: return 0.0 

Sekarang semuanya baik-baik saja. Tetapi mengapa kita mengembalikan 0? Kenapa tidak 1 atau tidak sama sekali? Tentu saja, dalam kebanyakan kasus, mendapatkan None sama buruknya (jika tidak lebih buruk) sebagai pengecualian, tetapi Anda masih harus mengandalkan logika bisnis dan opsi untuk menggunakan fungsi tersebut.

Apa sebenarnya yang kami bagikan? Angka sewenang-wenang, ada unit atau uang tertentu? Tidak setiap opsi mudah diramalkan dan dipulihkan. Mungkin ternyata bahwa lain kali Anda menggunakan satu fungsi, ternyata Anda memerlukan logika pemulihan yang berbeda.

Kesimpulan yang menyedihkan: solusi untuk setiap masalah adalah individual, tergantung pada konteks penggunaan tertentu.

Tidak ada peluru perak untuk berurusan dengan ZeroDivisionError sekali dan untuk semua. Dan kita tidak berbicara tentang kemungkinan I / O kompleks dengan permintaan dan batas waktu berulang.

Mungkin itu tidak perlu untuk menangani pengecualian di mana mereka muncul? Mungkin hanya membuangnya ke proses eksekusi kode - seseorang akan mengetahuinya nanti. Dan kemudian kita dipaksa untuk kembali ke keadaan saat ini.

Proses eksekusi tidak jelas


Yah, mari kita berharap orang lain menangkap pengecualian dan mungkin menanganinya. Misalnya, sistem dapat meminta pengguna untuk mengubah nilai yang dimasukkan, karena tidak dapat dibagi dengan 0. Dan fungsi divide tidak boleh secara eksplisit bertanggung jawab untuk memulihkan dari kesalahan.

Dalam hal ini, Anda perlu memeriksa di mana kami menangkap pengecualian. Ngomong-ngomong, bagaimana cara menentukan dengan tepat di mana ia akan diproses? Apakah mungkin untuk pergi ke tempat yang tepat dalam kode? Ternyata tidak, itu tidak mungkin .

Tidak mungkin untuk menentukan baris kode mana yang akan dieksekusi setelah pengecualian dilemparkan. Berbagai jenis pengecualian dapat ditangani dengan opsi pengecualian yang berbeda, dan beberapa pengecualian dapat diabaikan . Dan Anda bisa melempar pengecualian tambahan di modul lain yang akan dijalankan sebelumnya, dan secara umum akan mematahkan semua logika.

Misalkan ada dua utas independen dalam suatu aplikasi: utas reguler yang berjalan dari atas ke bawah, dan utas pengecualian yang berjalan sesuai keinginan. Bagaimana cara membaca dan memahami kode ini?

Hanya dengan debugger diaktifkan dalam mode "tangkap semua pengecualian".


Pengecualian, seperti goto terkenal, merobek struktur program.

Pengecualian tidak eksklusif


Mari kita lihat contoh lain: kode akses HTTP API jarak jauh yang biasa:

 import requests def fetch_user_profile(user_id: int) -> 'UserProfile': """Fetches UserProfile dict from foreign API.""" response = requests.get('/api/users/{0}'.format(user_id)) response.raise_for_status() return response.json() 

Dalam contoh ini, secara harfiah semuanya bisa salah. Berikut adalah sebagian daftar kemungkinan kesalahan:

  • Jaringan mungkin tidak tersedia dan permintaan tidak akan dieksekusi sama sekali.
  • Server mungkin tidak berfungsi.
  • Server mungkin terlalu sibuk, batas waktu akan terjadi.
  • Server mungkin memerlukan otentikasi.
  • API mungkin tidak memiliki URL seperti itu.
  • Pengguna yang tidak ada dapat ditransfer.
  • Mungkin tidak cukup hak.
  • Server mungkin macet karena kesalahan internal saat memproses permintaan Anda
  • Server dapat mengembalikan respons yang tidak valid atau rusak.
  • Server dapat mengembalikan JSON tidak valid yang tidak dapat diuraikan.

Daftar ini terus dan terus, begitu banyak potensi masalah terletak pada kode dari tiga baris yang disayangkan. Kita dapat mengatakan bahwa itu umumnya hanya bekerja dengan peluang keberuntungan, dan jauh lebih mungkin untuk jatuh dengan pengecualian.

Bagaimana cara melindungi diri sendiri?


Sekarang kami telah memastikan bahwa pengecualian dapat merusak kode, mari cari tahu cara menghilangkannya. Untuk menulis kode tanpa kecuali, ada pola yang berbeda.

  • Di mana-mana menulis except Exception: pass . Jalan buntu. Jangan lakukan itu.
  • Kembali tidak None Terlalu jahat. Akibatnya, Anda harus memulai hampir setiap baris dengan if something is not None: dan semua logika akan hilang di balik sampah pemeriksaan pembersihan, atau Anda akan menderita TypeError sepanjang waktu. Bukan pilihan yang bagus.
  • Tulis kelas untuk kasus penggunaan khusus. Misalnya, User kelas dasar dengan subkelas untuk kesalahan seperti UserNotFound dan MissingUser . Pendekatan ini dapat digunakan dalam beberapa situasi tertentu, seperti AnonymousUser di Django, tetapi membungkus semua kesalahan yang mungkin terjadi di kelas tidak realistis. Ini akan mengambil terlalu banyak pekerjaan dan model domain akan menjadi kompleks yang tak terbayangkan.
  • Gunakan wadah untuk membungkus variabel atau nilai kesalahan yang dihasilkan dalam pembungkus dan terus bekerja dengan nilai wadah. Inilah sebabnya kami membuat proyek @dry-python/return . Sehingga fungsinya mengembalikan sesuatu yang bermakna, diketik, dan aman.

Mari kita kembali ke contoh pembagian, yang mengembalikan 0 ketika kesalahan terjadi. Bisakah kita secara eksplisit menunjukkan bahwa fungsi tidak berhasil tanpa mengembalikan nilai numerik tertentu?

 from returns.result import Result, Success, Failure def divide(first: float, second: float) -> Result[float, ZeroDivisionError]: try: return Success(first / second) except ZeroDivisionError as exc: return Failure(exc) 

Kami menyertakan nilai dalam salah satu dari dua pembungkus: Success atau Failure . Kelas-kelas ini diwarisi dari kelas basis Result . Jenis nilai yang dikemas dapat ditentukan dalam anotasi dengan fungsi yang dikembalikan, misalnya, Result[float, ZeroDivisionError] mengembalikan Success[float] atau Failure[ZeroDivisionError] .

Apa yang ini berikan pada kita? Lebih banyak pengecualian tidak luar biasa, tetapi merupakan masalah yang diharapkan . Juga, membungkus pengecualian dalam Failure memecahkan masalah kedua: kompleksitas mengidentifikasi pengecualian potensial.

 1 + divide(1, 0) # => mypy error: Unsupported operand types for + ("int" and "Result[float, ZeroDivisionError]") 

Sekarang mereka mudah dikenali. Jika Anda melihat Result dalam kode, maka fungsi tersebut dapat mengeluarkan pengecualian. Dan Anda bahkan tahu tipenya di muka.

Selain itu, perpustakaan ini sepenuhnya diketik dan kompatibel dengan PEP561 . Artinya, mypy akan memperingatkan Anda jika Anda mencoba mengembalikan sesuatu yang tidak cocok dengan tipe yang dinyatakan.

 from returns.result import Result, Success, Failure def divide(first: float, second: float) -> Result[float, ZeroDivisionError]: try: return Success('Done') # => error: incompatible type "str"; expected "float" except ZeroDivisionError as exc: return Failure(0) # => error: incompatible type "int"; expected "ZeroDivisionError" 

Bagaimana cara bekerja dengan wadah?


Ada dua metode :

  • map untuk fungsi yang mengembalikan nilai normal;
  • bind untuk fungsi yang mengembalikan wadah lain.

 Success(4).bind(lambda number: Success(number / 2)) # => Success(2) Success(4).map(lambda number: number + 1) # => Success(5) 

Keindahannya adalah bahwa kode ini akan melindungi Anda dari skrip yang gagal, karena .bind dan .map tidak akan dieksekusi untuk kontainer dengan Failure :

 Failure(4).bind(lambda number: Success(number / 2)) # => Failure(4) Failure(4).map(lambda number: number / 2) # => Failure(4) 

Sekarang Anda bisa berkonsentrasi pada proses eksekusi yang benar dan memastikan bahwa kondisi yang salah tidak akan merusak program di tempat yang tidak terduga. Dan selalu ada peluang untuk menentukan kondisi yang salah, memperbaikinya , dan kembali ke jalur yang telah dipahami dari proses tersebut.

 Failure(4).rescue(lambda number: Success(number + 1)) # => Success(5) Failure(4).fix(lambda number: number / 2) # => Success(2) 

Dalam pendekatan kami, "semua masalah diselesaikan secara individual," dan "proses eksekusi sekarang transparan." Nikmati pemrograman yang mengendarai rel!

Tetapi bagaimana cara memperluas nilai dari wadah?


Memang, jika Anda bekerja dengan fungsi yang tidak tahu apa-apa tentang wadah, Anda perlu nilai sendiri. Kemudian Anda dapat menggunakan metode .unwrap() atau .value_or() :

 Success(1).unwrap() # => 1 Success(0).value_or(None) # => 0 Failure(0).value_or(None) # => None Failure(1).unwrap() # => Raises UnwrapFailedError() 

Tunggu, kami harus menyingkirkan pengecualian, dan sekarang ternyata semua panggilan .unwrap() dapat menyebabkan pengecualian lain?

Bagaimana tidak memikirkan UnwrapFailedErrors?


Oke, mari kita lihat bagaimana hidup dengan pengecualian baru. Pertimbangkan contoh ini: Anda perlu memeriksa input pengguna dan membuat dua model dalam database. Setiap langkah dapat diakhiri dengan pengecualian, itulah sebabnya semua metode dibungkus dalam Result :

 from returns.result import Result, Success, Failure class CreateAccountAndUser(object): """Creates new Account-User pair.""" # TODO: we need to create a pipeline of these methods somehow... def _validate_user( self, username: str, email: str, ) -> Result['UserSchema', str]: """Returns an UserSchema for valid input, otherwise a Failure.""" def _create_account( self, user_schema: 'UserSchema', ) -> Result['Account', str]: """Creates an Account for valid UserSchema's. Or returns a Failure.""" def _create_user( self, account: 'Account', ) -> Result['User', str]: """Create an User instance. If user already exists returns Failure.""" 

Pertama, Anda tidak perlu memperluas nilai-nilai dalam logika bisnis Anda sendiri sama sekali:

 class CreateAccountAndUser(object): """Creates new Account-User pair.""" def __call__(self, username: str, email: str) -> Result['User', str]: """Can return a Success(user) or Failure(str_reason).""" return self._validate_user( username, email, ).bind( self._create_account, ).bind( self._create_user, ) # ... 

Semuanya akan bekerja tanpa masalah, tidak ada pengecualian akan .unwrap() , karena .unwrap() tidak digunakan. Tetapi apakah mudah untuk membaca kode seperti itu? Tidak. Dan apa alternatifnya? @pipeline :

 from result.functions import pipeline class CreateAccountAndUser(object): """Creates new Account-User pair.""" @pipeline def __call__(self, username: str, email: str) -> Result['User', str]: """Can return a Success(user) or Failure(str_reason).""" user_schema = self._validate_user(username, email).unwrap() account = self._create_account(user_schema).unwrap() return self._create_user(account) # ... 

Sekarang kode ini dibaca dengan baik. Begini caranya .unwrap() dan @pipeline bekerja bersama: setiap kali metode .unwrap() gagal dan Failure[str] , dekorator @pipeline menangkapnya dan mengembalikan Failure[str] sebagai nilai yang dihasilkan. Ini adalah bagaimana saya mengusulkan untuk menghapus semua pengecualian dari kode dan membuatnya benar-benar aman dan diketik.

Bungkus semuanya


Oke, sekarang kami akan menerapkan alat baru, misalnya, dengan permintaan HTTP API. Ingat bahwa setiap baris dapat memberikan pengecualian? Dan tidak ada cara untuk membuat mereka mengembalikan wadah dengan Result . Tapi Anda bisa menggunakan dekorator @safe untuk membungkus fungsi yang tidak aman dan membuatnya aman. Di bawah ini adalah dua opsi kode yang melakukan hal yang sama:

 from returns.functions import safe @safe def divide(first: float, second: float) -> float: return first / second # is the same as: def divide(first: float, second: float) -> Result[float, ZeroDivisionError]: try: return Success(first / second) except ZeroDivisionError as exc: return Failure(exc) 

Yang pertama, dengan @safe , lebih mudah dan lebih baik untuk dibaca.

Hal terakhir yang harus dilakukan dalam contoh permintaan API adalah menambahkan dekorator @safe . Hasilnya adalah kode berikut:

 import requests from returns.functions import pipeline, safe from returns.result import Result class FetchUserProfile(object): """Single responsibility callable object that fetches user profile.""" #: You can later use dependency injection to replace `requests` #: with any other http library (or even a custom service). _http = requests @pipeline def __call__(self, user_id: int) -> Result['UserProfile', Exception]: """Fetches UserProfile dict from foreign API.""" response = self._make_request(user_id).unwrap() return self._parse_json(response) @safe def _make_request(self, user_id: int) -> requests.Response: response = self._http.get('/api/users/{0}'.format(user_id)) response.raise_for_status() return response @safe def _parse_json(self, response: requests.Response) -> 'UserProfile': return response.json() 

Untuk meringkas cara menyingkirkan pengecualian dan mengamankan kode :

  • Gunakan pembungkus @safe untuk semua metode yang dapat menimbulkan pengecualian. Ini akan mengubah tipe fungsi yang Result[OldReturnType, Exception] ke Result[OldReturnType, Exception] .
  • Gunakan Result sebagai wadah untuk mentransfer nilai dan kesalahan ke dalam abstraksi sederhana.
  • Gunakan .unwrap() untuk memperluas nilai dari wadah.
  • Gunakan @pipeline untuk membuat .unwrap panggilan .unwrap lebih mudah dibaca.

Dengan mematuhi aturan-aturan ini, kita dapat melakukan hal yang persis sama - hanya aman dan mudah dibaca. Semua masalah dengan pengecualian diselesaikan:

  • "Pengecualian sulit dikenali . " Sekarang mereka dibungkus dalam wadah Result diketik, yang membuatnya benar-benar transparan.
  • "Memulihkan perilaku normal di tempat adalah tidak mungkin . " Sekarang Anda dapat dengan aman mendelegasikan proses pemulihan ke pemanggil. Untuk kasus seperti itu, ada .fix() dan .rescue() .
  • "Urutan eksekusi tidak jelas . " Sekarang mereka menyatu dengan aliran bisnis yang biasa. Dari awal hingga akhir.
  • "Pengecualian tidak luar biasa . " Kami tahu! Dan kami berharap ada sesuatu yang salah dan siap untuk apa pun.

Gunakan Kasus dan Batasan


Jelas, Anda tidak dapat menggunakan pendekatan ini di semua kode Anda. Ini akan terlalu aman untuk sebagian besar situasi sehari-hari dan tidak kompatibel dengan perpustakaan atau kerangka kerja lainnya. Tetapi Anda harus menulis bagian terpenting dari logika bisnis Anda persis seperti yang saya tunjukkan, untuk memastikan operasi sistem Anda yang benar dan memfasilitasi dukungan di masa depan.

Apakah topik tersebut membuat Anda berpikir atau bahkan tampak holivarny? Datang ke Moscow Python Conf ++ pada 5 April, kita akan membahas! Selain saya, Artyom Malyshev, pendiri proyek dry-python dan pengembang inti Django Channels, akan ada di sana. Dia akan berbicara lebih banyak tentang dry-python dan logika bisnis.

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


All Articles