كيفية التعامل مع الأخطاء على JVM بشكل أسرع

هناك عدة طرق لمعالجة الأخطاء في لغات البرمجة:


  • استثناءات قياسية للعديد من اللغات (Java و Scala و JVMs الأخرى و python والعديد من اللغات الأخرى)
  • رموز الحالة أو الأعلام (Go ، bash)
  • هياكل البيانات الجبرية المختلفة ، والتي يمكن أن تكون قيمها كل من النتائج الناجحة وأوصاف الخطأ (Scala ، haskell واللغات الوظيفية الأخرى)

تُستخدم الاستثناءات على نطاق واسع جدًا ، من ناحية أخرى يُقال إنها بطيئة. لكن معارضي النهج الوظيفي غالباً ما يروقون الأداء.


في الآونة الأخيرة ، كنت أعمل مع Scala ، حيث يمكنني استخدام كل من الاستثناءات وأنواع البيانات المختلفة بشكل متساوٍ لمعالجة الأخطاء ، لذلك أتساءل عن الطريقة الأكثر ملاءمة وأسرع.


سنتجاهل على الفور استخدام الرموز والأعلام ، نظرًا لأن هذا النهج غير مقبول بلغات JVM ، وفي اعتقادي ، معرض للخطأ جدًا (أعتذر عن التورية). لذلك ، سنقوم بمقارنة الاستثناءات وأنواع مختلفة من ADT. بالإضافة إلى ذلك ، يمكن اعتبار ADT استخدام رموز الخطأ بأسلوب وظيفي.


استكمال : تضاف الاستثناءات دون آثار مكدس للمقارنة


المتسابقين


أكثر قليلاً عن أنواع البيانات الجبرية

بالنسبة لأولئك الذين ليسوا على دراية بـ ADT ( ADT ) - يتكون النوع الجبري من عدة قيم ممكنة ، يمكن أن تكون كل واحدة منها قيمة مركبة (بنية ، سجل).


مثال على ذلك هو Option[T] = Some(value: T) | None Option[T] = Some(value: T) | None ، يُستخدم بدلاً من القيم الخالية: يمكن أن تكون قيمة هذا النوع إما Some(t) إذا كانت هناك قيمة ، أو None إذا لم تكن كذلك.


مثال آخر سيكون Try[T] = Success(value: T) | Failure(exception: Throwable) Try[T] = Success(value: T) | Failure(exception: Throwable) ، الذي يصف نتيجة عملية حسابية يمكن أن تكتمل بنجاح أو مع وجود خطأ.


لذلك المتسابقين لدينا:


  • استثناءات قديمة جيدة
  • استثناءات بدون تتبع مكدس ، لأن ملء تتبع مكدس عملية بطيئة للغاية
  • Try[T] = Success(value: T) | Failure(exception: Throwable) Try[T] = Success(value: T) | Failure(exception: Throwable) - نفس الاستثناءات ، ولكن في غلاف وظيفي
  • Either[String, T] = Left(error: String) | Right(value: T) Either[String, T] = Left(error: String) | Right(value: T) - نوع يحتوي إما على نتيجة أو وصف للخطأ
  • ValidatedNec[String, T] = Valid(value: T) | Invalid(errors: List[String]) ValidatedNec[String, T] = Valid(value: T) | Invalid(errors: List[String]) - نوع من مكتبة Cats ، والذي في حالة وجود خطأ يمكن أن يحتوي على عدة رسائل حول أخطاء مختلفة (ليست List تستخدم هناك ، لكن لا يهم)

ملاحظة في جوهرها ، تتم مقارنة الاستثناءات مع تتبع المكدس ، بدون و ADT ، ولكن يتم تحديد عدة أنواع ، لأن Scala ليس لديه منهج واحد ومن المثير للاهتمام مقارنة العديد.


بالإضافة إلى الاستثناءات ، يتم استخدام السلاسل لوصف الأخطاء ، ولكن مع نفس النجاح في الموقف الحقيقي ، سيتم استخدام فئات مختلفة ( Either[Failure, T] ).


المشكلة


لاختبار معالجة الأخطاء ، نأخذ مشكلة التحليل والتحقق من صحة البيانات:


 case class Person(name: String, age: Int, isMale: Boolean) type Result[T] = Either[String, T] trait PersonParser { def parse(data: Map[String, String]): Result[Person] } 

أي امتلاك Map[String, String] بيانات أولية Map[String, String] تحتاج إلى الحصول على Person أو خطأ إذا كانت البيانات غير صالحة.


رمي


حل للجبهة باستخدام الاستثناءات (فيما يلي سوف أعطي وظيفة person فقط ، يمكنك رؤية الرمز الكامل على جيثب ):
Throwparser.scala


  def person(data: Map[String, String]): Person = { val name = string(data.getOrElse("name", null)) val age = integer(data.getOrElse("age", null)) val isMale = boolean(data.getOrElse("isMale", null)) require(name.nonEmpty, "name should not be empty") require(age > 0, "age should be positive") Person(name, age, isMale) } 

هنا string ، integer boolean التحقق من صحة وجود وشكل أنواع بسيطة وإجراء التحويل.
بشكل عام ، الأمر بسيط للغاية ومفهوم.


ThrowNST (بدون تتبع مكدس)


الكود هو نفسه كما في الحالة السابقة ، ولكن يتم استخدام الاستثناءات دون تتبع مكدس حيثما كان ذلك ممكنًا: ThrowNSTParser.scala


جرب


يكتشف الحل الاستثناءات في وقت سابق ويسمح بدمج النتائج for طريق (عدم الخلط بينه وبين الحلقات بلغات أخرى):
TryParser.scala


  def person(data: Map[String, String]): Try[Person] = for { name <- required(data.get("name")) age <- required(data.get("age")) flatMap integer isMale <- required(data.get("isMale")) flatMap boolean _ <- require(name.nonEmpty, "name should not be empty") _ <- require(age > 0, "age should be positive") } yield Person(name, age, isMale) 

أكثر غرابة قليلاً بالنسبة للعين الهشة ، ولكن نظرًا لاستخدامها ، فهي تشبه إلى حد كبير الإصدار مع وجود استثناءات ، بالإضافة إلى أن التحقق من وجود حقل والتحليل من النوع المرغوب يحدث بشكل منفصل (يمكن قراءة flatMap هنا كما يلي)


إما


هنا يتم إخفاء النوعين خلف الاسم المستعار للنتائج حيث يتم إصلاح نوع الخطأ:
EitherParser.scala


  def person(data: Map[String, String]): Result[Person] = for { name <- required(data.get("name")) age <- required(data.get("age")) flatMap integer isMale <- required(data.get("isMale")) flatMap boolean _ <- require(name.nonEmpty, "name should not be empty") _ <- require(age > 0, "age should be positive") } yield Person(name, age, isMale) 

نظرًا لأن المعيار Either مثل Try يشكل أحاديًا في Scala ، فقد تم عرض الكود بنفس الطريقة تمامًا ، والفرق هنا هو أن السلسلة تظهر هنا كخطأ وأن الاستثناءات يتم استخدامها بشكل ضئيل (فقط للتعامل مع الأخطاء عند تحليل عدد)


التحقق من صحة


هنا يتم استخدام مكتبة Cats من أجل عدم الحصول على أول ما حدث ، ولكن قدر الإمكان (على سبيل المثال ، إذا كانت عدة حقول غير صالحة ، فستتضمن النتيجة أخطاء تحليل لكل هذه الحقول)
ValidatedParser.scala


  def person(data: Map[String, String]): Validated[Person] = { val name: Validated[String] = required(data.get("name")) .ensure(one("name should not be empty"))(_.nonEmpty) val age: Validated[Int] = required(data.get("age")) .andThen(integer) .ensure(one("age should be positive"))(_ > 0) val isMale: Validated[Boolean] = required(data.get("isMale")) .andThen(boolean) (name, age, isMale).mapN(Person) } 

يشبه هذا الرمز بالفعل النسخة الأصلية مع وجود استثناءات ، ولكن لم يتم فصل التحقق من القيود الإضافية عن حقول التحليل وما زلنا نتلقى العديد من الأخطاء بدلاً من واحدة ، الأمر يستحق ذلك!


اختبار


للاختبار ، تم إنشاء مجموعة بيانات بنسبة مئوية مختلفة من الأخطاء وتحليلها في كل طريقة من الطرق.


النتيجة على جميع النسب المئوية للأخطاء:


بمزيد من التفاصيل ، عند نسبة مئوية منخفضة من الأخطاء (يختلف الوقت هنا منذ استخدام عينة أكبر):


إذا كان جزء من الأخطاء لا يزال استثناءً في تتبع المكدس (في حالتنا ، فإن خطأ تحليل الرقم سيكون استثناءً لا نتحكم فيه) ، بالطبع سوف يتدهور أداء طرق معالجة الأخطاء "السريعة" بشكل كبير. Validated يتأثر بشكل خاص ، لأنه يجمع كل الأخطاء ونتيجة لذلك يتلقى استثناء بطيء أكثر من غيرها:


الاستنتاجات


كما أوضحت التجربة ، الاستثناءات مع تتبع المكدس تكون بطيئة جدًا (أخطاء 100٪ هي الفرق بين Throw و Either أكثر من 50 مرة!) ، وعندما لا توجد استثناءات عملياً ، يكون سعر استخدام ADT له. ومع ذلك ، فإن استخدام الاستثناءات بدون تتبعات المكدس يكون سريعًا (وبنسبة مئوية منخفضة من الأخطاء بشكل أسرع) مثل ADT ، ولكن إذا تجاوزت هذه الاستثناءات نفس التحقق ، فلن يكون تتبع مصدرها أمرًا سهلاً.


إجمالًا ، إذا كان احتمال حدوث استثناء هو أكثر من 1٪ ، فإن الاستثناءات دون آثار مكدس تعمل بسرعة أكبر أو يتم Validated أو بشكل منتظم Either بنفس السرعة. مع وجود عدد كبير من الأخطاء ، يمكن أن يكون Either أسرع قليلاً من Validated من Validated فقط بسبب دلالات فشل سريع.


يوفر استخدام ADT لمعالجة الأخطاء ميزة أخرى على الاستثناءات: يتم ربط إمكانية حدوث خطأ في النوع نفسه ويصعب تفويتها ، كما هو الحال عند استخدام Option بدلاً من القيم الخالية.

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


All Articles