تحقق من كود Android الخاص بك في المستقبل ، الجزء 1: أسس البرمجة الوظيفية والتفاعلية
نشرت: 2022-08-31قد تكون كتابة تعليمات برمجية نظيفة أمرًا صعبًا: المكتبات ، وأطر العمل ، وواجهات برمجة التطبيقات مؤقتة وتصبح قديمة بسرعة. لكن المفاهيم والنماذج الرياضية باقية. إنها تتطلب سنوات من البحث الأكاديمي وقد تدوم أكثر منا.
هذا ليس برنامجًا تعليميًا يوضح لك كيفية القيام بـ X باستخدام Library Y. بدلاً من ذلك ، نركز على المبادئ الدائمة وراء البرمجة الوظيفية والتفاعلية حتى تتمكن من بناء بنية Android موثوقة ومثبتة في المستقبل ، وقياس التغييرات والتكيف معها دون المساومة نجاعة.
تضع هذه المقالة الأسس ، وفي الجزء الثاني ، سوف نتعمق في تنفيذ البرمجة التفاعلية الوظيفية (FRP) ، والتي تجمع بين البرمجة الوظيفية والتفاعلية.
تمت كتابة هذه المقالة مع وضع مطوري Android في الاعتبار ، ولكن المفاهيم ذات صلة ومفيدة لأي مطور لديه خبرة في لغات البرمجة العامة.
البرمجة الوظيفية 101
البرمجة الوظيفية (FP) هي نمط تقوم فيه ببناء برنامجك كتكوين من الوظائف ، وتحويل البيانات من $ A $ إلى $ B $ ، إلى $ C $ ، وما إلى ذلك ، حتى يتم تحقيق الناتج المطلوب. في البرمجة الشيئية (OOP) ، تخبر الكمبيوتر بما يجب القيام به عن طريق التعليمات. تختلف البرمجة الوظيفية: فأنت تتخلى عن تدفق التحكم وتحدد "وصفة الوظائف" لإنتاج نتيجتك بدلاً من ذلك.
نشأت FP من الرياضيات ، وتحديداً حساب لامدا ، وهو نظام منطقي لتجريد الوظيفة. بدلاً من مفاهيم OOP مثل الحلقات أو الفئات أو تعدد الأشكال أو الوراثة ، يتعامل FP بشكل صارم في وظائف التجريد والوظائف ذات الترتيب الأعلى ، والوظائف الرياضية التي تقبل وظائف أخرى كمدخلات.
باختصار ، تمتلك FP "لاعبين" رئيسيين: البيانات (النموذج ، أو المعلومات المطلوبة لمشكلتك) والوظائف (تمثيلات السلوك والتحولات بين البيانات). على النقيض من ذلك ، تربط فئات OOP صراحة بنية بيانات خاصة بمجال معين - والقيم أو الحالة المرتبطة بكل مثيل فئة - بالسلوكيات (الطرق) التي يُقصد استخدامها معها.
سوف ندرس ثلاثة جوانب رئيسية لـ FP عن كثب:
- FP تصريحي.
- يستخدم FP تكوين الوظيفة.
- وظائف FP نقية.
تعتبر هاسكل نقطة انطلاق جيدة للغطس في عالم FP ، وهي لغة مكتوبة بقوة ووظيفية بحتة. أوصي بـ Learn You a Haskell for Great Good! البرنامج التعليمي التفاعلي كمورد مفيد.
المكون رقم 1: البرمجة التصريحية
أول شيء ستلاحظه بخصوص برنامج FP هو أنه مكتوب بأسلوب تصريحي ، وليس بالأسلوب الإلزامي. باختصار ، تخبر البرمجة التصريحية البرنامج بما يجب القيام به بدلاً من كيفية القيام به. دعنا نؤسس هذا التعريف المجرد بمثال ملموس عن البرمجة الحتمية مقابل البرمجة التصريحية لحل المشكلة التالية: إعطاء قائمة بالأسماء ، قم بإرجاع قائمة تحتوي فقط على الأسماء التي تحتوي على ثلاثة أحرف متحركة على الأقل وحروف العلة الموضحة بأحرف كبيرة.
الحل الحتمي
أولاً ، دعنا نفحص الحل الضروري لهذه المشكلة في Kotlin:
fun namesImperative(input: List<String>): List<String> { val result = mutableListOf<String>() val vowels = listOf('A', 'E', 'I', 'O', 'U','a', 'e', 'i', 'o', 'u') for (name in input) { // loop 1 var vowelsCount = 0 for (char in name) { // loop 2 if (isVowel(char, vowels)) { vowelsCount++ if (vowelsCount == 3) { val uppercaseName = StringBuilder() for (finalChar in name) { // loop 3 var transformedChar = finalChar // ignore that the first letter might be uppercase if (isVowel(finalChar, vowels)) { transformedChar = finalChar.uppercaseChar() } uppercaseName.append(transformedChar) } result.add(uppercaseName.toString()) break } } } } return result } fun isVowel(char: Char, vowels: List<Char>): Boolean { return vowels.contains(char) } fun main() { println(namesImperative(listOf("Iliyan", "Annabel", "Nicole", "John", "Anthony", "Ben", "Ken"))) // [IlIyAn, AnnAbEl, NIcOlE] }
سنقوم الآن بتحليل حلنا الضروري مع وضع بعض عوامل التطوير الرئيسية في الاعتبار:
الأكثر فاعلية: يحتوي هذا الحل على الاستخدام الأمثل للذاكرة ويعمل بشكل جيد في تحليل Big O (استنادًا إلى الحد الأدنى من المقارنات). في هذه الخوارزمية ، من المنطقي تحليل عدد المقارنات بين الشخصيات لأن هذه هي العملية السائدة في خوارزميتنا. لنفترض أن $ n $ هو عدد الأسماء ، ولنفترض أن $ k $ هو متوسط طول الأسماء.
- أسوأ عدد من المقارنات: $ n (10k) (10k) = 100nk ^ 2 $
- التفسير: $ n $ (حلقة 1) * $ 10k $ (لكل حرف ، نقارن مع 10 أحرف متحركة محتملة) * $ 10k $ (ننفذ
isVowel()
تحقق مرة أخرى لنقرر ما إذا كنت تريد الأحرف الكبيرة - مرة أخرى ، في أسوأ حالة ، هذا بالمقارنة مع 10 أحرف العلة). - النتيجة: نظرًا لأن متوسط طول الاسم لن يزيد عن 100 حرف ، يمكننا القول أن الخوارزمية تعمل في $ O (n) $ time.
- معقد مع إمكانية قراءة ضعيفة: مقارنة بالحل التعريفي الذي سننظر فيه بعد ذلك ، فإن هذا الحل أطول بكثير ويصعب متابعته.
- عرضة للخطأ: الكود يغير
result
،vowelsCount
، وtransformedChar
؛ يمكن أن تؤدي طفرات الحالة هذه إلى أخطاء طفيفة مثل نسيان إعادة تعيينvowelsCount
مرة أخرى إلى 0. قد يصبح تدفق التنفيذ معقدًا أيضًا ، ومن السهل نسيان إضافة عبارةbreak
في الحلقة الثالثة. - ضعف قابلية الصيانة: نظرًا لأن الكود الخاص بنا معقد وعرضة للخطأ ، فقد يكون من الصعب إعادة بناء أو تغيير سلوك هذا الرمز. على سبيل المثال ، إذا تم تعديل المشكلة لتحديد أسماء ذات ثلاثة أحرف متحركة وخمسة أحرف ساكنة ، فسيتعين علينا إدخال متغيرات جديدة وتغيير الحلقات ، مما يترك العديد من الفرص للأخطاء.
يوضح حل المثال الخاص بنا كيف يمكن أن يبدو رمز الأمر المعقد ، على الرغم من أنه يمكنك تحسين الكود عن طريق إعادة هيكلة الكود إلى وظائف أصغر.
الحل التعريفي
الآن بعد أن فهمنا ماهية البرمجة التصريحية ، دعنا نكشف عن حلنا التعريفي في Kotlin:
fun namesDeclarative(input: List<String>): List<String> = input.filter { name -> name.count(::isVowel) >= 3 }.map { name -> name.map { char -> if (isVowel(char)) char.uppercaseChar() else char }.joinToString("") } fun isVowel(char: Char): Boolean = listOf('A', 'E', 'I', 'O', 'U', 'a', 'e', 'i', 'o', 'u').contains(char) fun main() { println(namesDeclarative(listOf("Iliyan", "Annabel", "Nicole", "John", "Anthony", "Ben", "Ken"))) // [IlIyAn, AnnAbEl, NIcOlE] }
باستخدام نفس المعايير التي استخدمناها لتقييم حلنا الحتمي ، دعنا نرى كيف تصمد الشفرة التعريفية:
- فعّال: يتم تشغيل كل من التطبيقين الإلزامي والتوضيحي في الوقت الخطي ، ولكن الأمر الضروري يكون أكثر فاعلية بعض الشيء لأنني استخدمت
name.count()
هنا ، والذي سيستمر في عد أحرف العلة حتى نهاية الاسم (حتى بعد العثور على ثلاثة أحرف متحركة ). يمكننا حل هذه المشكلة بسهولة عن طريق كتابةhasThreeVowels(String): Boolean
. يستخدم هذا الحل نفس الخوارزمية المستخدمة في الحل الضروري ، لذلك ينطبق تحليل التعقيد نفسه هنا: تعمل الخوارزمية الخاصة بنا في $ O (n) $ time. - موجز مع إمكانية قراءة جيدة: الحل الإلزامي هو 44 سطراً بمسافة بادئة كبيرة مقارنة بطول الحل التعريفي الذي يبلغ 16 سطراً مع مسافة بادئة صغيرة. الخطوط وعلامات التبويب ليست كل شيء ، ولكن من الواضح من نظرة سريعة على الملفين أن حلنا التعريفي أكثر قابلية للقراءة.
- أقل عرضة للخطأ: في هذه العينة ، كل شيء غير قابل للتغيير. نقوم بتحويل
List<String>
بجميع الأسماء إلىList<String>
من الأسماء بثلاثة أحرف متحركة أو أكثر ثم نقوم بتحويل كل كلمةString
إلى كلمةString
ذات أحرف متحركة كبيرة. بشكل عام ، عدم وجود طفرة أو حلقات متداخلة أو فواصل والتخلي عن تدفق التحكم يجعل الكود أبسط مع مساحة أقل للخطأ. - قابلية صيانة جيدة: يمكنك بسهولة إعادة بناء الكود التعريفي نظرًا لقابليته للقراءة ومتانته. في مثالنا السابق (لنفترض أن المشكلة قد تم تعديلها لتحديد أسماء ذات ثلاثة أحرف متحركة وخمسة أحرف ساكنة) ، سيكون الحل البسيط هو إضافة العبارات التالية في حالة
filter
:val vowels = name.count(::isVowel); vowels >= 3 && name.length - vowels >= 5
val vowels = name.count(::isVowel); vowels >= 3 && name.length - vowels >= 5
.
كإيجابي إضافي ، فإن حلنا التصريحي وظيفي بحت: كل وظيفة في هذا المثال نقية وليس لها أي آثار جانبية. (المزيد عن النقاء لاحقًا.)
الحل التعريفي للمكافأة
دعنا نلقي نظرة على التطبيق التصريحي لنفس المشكلة بلغة وظيفية بحتة مثل Haskell لتوضيح كيفية قراءتها. إذا لم تكن معتادًا على Haskell ، فلاحظ أن ملف .
عامل التشغيل في هاسكل يقرأ كـ "بعد". على سبيل المثال ، solution = map uppercaseVowels . filter hasThreeVowels
solution = map uppercaseVowels . filter hasThreeVowels
يترجم إلى "تعيين حروف العلة إلى أحرف كبيرة بعد التصفية للأسماء التي تحتوي على ثلاثة أحرف متحركة."
import Data.Char(toUpper) namesSolution :: [String] -> [String] namesSolution = map uppercaseVowels . filter hasThreeVowels hasThreeVowels :: String -> Bool hasThreeVowels s = count isVowel s >= 3 uppercaseVowels :: String -> String uppercaseVowels = map uppercaseVowel where uppercaseVowel :: Char -> Char uppercaseVowel c | isVowel c = toUpper c | otherwise = c isVowel :: Char -> Bool isVowel c = c `elem` vowels vowels :: [Char] vowels = ['A', 'E', 'I', 'O', 'U', 'a', 'e', 'i', 'o', 'u'] count :: (a -> Bool) -> [a] -> Int count _ [] = 0 count pred (x:xs) | pred x = 1 + count pred xs | otherwise = count pred xs main :: IO () main = print $ namesSolution ["Iliyan", "Annabel", "Nicole", "John", "Anthony", "Ben", "Ken"] -- ["IlIyAn","AnnAbEl","NIcOlE"]
يعمل هذا الحل بشكل مشابه لحل Kotlin التعريفي لدينا ، مع بعض الفوائد الإضافية: يمكن قراءته ، وبسيط إذا فهمت تركيب Haskell ، والوظيفة البحتة ، والكسول.
الماخذ الرئيسية
تعد البرمجة التصريحية مفيدة لكل من البرمجة التصريحية والبرمجة التفاعلية (والتي سنغطيها في قسم لاحق).
- يصف "ما" تريد تحقيقه - بدلاً من "كيفية" تحقيقه ، بالترتيب الدقيق لتنفيذ العبارات.
- إنه يلخص تدفق التحكم في البرنامج وبدلاً من ذلك يركز على المشكلة من حيث التحولات (على سبيل المثال ، $ A \ rightarrow B \ rightarrow C \ rightarrow D $).
- إنه يشجع على كود أقل تعقيدًا وأكثر إيجازًا وأكثر قابلية للقراءة ويسهل إعادة بنائه وتغييره. إذا كان رمز Android الخاص بك لا يقرأ مثل جملة ، فمن المحتمل أنك تفعل شيئًا خاطئًا.
إذا كان رمز Android الخاص بك لا يقرأ مثل جملة ، فمن المحتمل أنك تفعل شيئًا خاطئًا.
سقسقة
ومع ذلك ، فإن البرمجة التصريحية لها بعض الجوانب السلبية. من الممكن أن ينتهي بك الأمر برمز غير فعال يستهلك المزيد من ذاكرة الوصول العشوائي ويعمل بشكل أسوأ من التنفيذ الضروري. الفرز ، والانتشار العكسي (في التعلم الآلي) ، وغيرها من "الخوارزميات المتغيرة" ليست مناسبة لأسلوب البرمجة التصريحي غير القابل للتغيير.
المكون رقم 2: تكوين الوظيفة
تكوين الوظيفة هو المفهوم الرياضي في قلب البرمجة الوظيفية. إذا كانت الدالة $ f $ تقبل $ A $ كمدخل لها وتنتج $ B $ كمخرجاتها ($ f: A \ rightarrow B $) ، والدالة $ g $ تقبل $ B $ وتنتج $ C $ ($ g: B \ rightarrow C $) ، ثم يمكنك إنشاء دالة ثالثة ، $ h $ ، تقبل $ A $ وتنتج $ C $ ($ h: A \ rightarrow C $). يمكننا تعريف هذه الوظيفة الثالثة على أنها تكوين $ g $ مع $ f $ ، ويُشار إليها أيضًا بـ $ g \ circ f $ أو $ g (f ()) $:
يمكن ترجمة كل حل حتمي إلى حل تصريحي عن طريق تحليل المشكلة إلى مشاكل أصغر وحلها بشكل مستقل وإعادة تكوين الحلول الأصغر في الحل النهائي من خلال تكوين الوظيفة. دعنا نلقي نظرة على مشكلة الأسماء الخاصة بنا من القسم السابق لرؤية هذا المفهوم قيد التنفيذ. مشاكلنا الصغيرة من الحل الضروري هي:
-
isVowel :: Char -> Bool
: بالنظر إلىChar
، قم بإرجاع ما إذا كان حرفًا متحركًا أم لا (Bool
). -
countVowels :: String -> Int
: عند إعطاءString
، قم بإرجاع عدد أحرف العلة فيها (Int
). -
hasThreeVowels :: String -> Bool
: بالنظر إلىString
، أعد ما إذا كانت تحتوي على ثلاثة أحرف متحركة على الأقل (Bool
). -
uppercaseVowels :: String -> String
: عند إعطاءString
، قم بإرجاعString
جديدة بأحرف متحركة كبيرة.
حلنا التصريحي ، الذي تم تحقيقه من خلال تكوين الوظيفة ، هو map uppercaseVowels . filter hasThreeVowels
map uppercaseVowels . filter hasThreeVowels
.
هذا المثال أكثر تعقيدًا قليلاً من صيغة $ A \ rightarrow B \ rightarrow C $ البسيطة ، لكنه يوضح المبدأ الكامن وراء تكوين الوظيفة.
الماخذ الرئيسية
تكوين الوظيفة مفهوم بسيط ولكنه قوي.
- يوفر إستراتيجية لحل المشكلات المعقدة التي يتم فيها تقسيم المشكلات إلى خطوات أصغر وأبسط ويتم دمجها في حل واحد.
- يوفر وحدات بناء ، مما يتيح لك إضافة أجزاء من الحل النهائي أو إزالتها أو تغييرها بسهولة دون القلق بشأن كسر شيء ما.
- يمكنك تكوين $ g (f ()) $ إذا كان ناتج $ f $ يطابق نوع الإدخال $ g $.
عند إنشاء وظائف ، لا يمكنك تمرير البيانات فحسب ، بل يمكنك أيضًا تمرير الوظائف كإدخال إلى وظائف أخرى - مثال على وظائف الترتيب الأعلى.
المكون رقم 3: النقاء
هناك عنصر أساسي آخر لوظيفة التركيب يجب أن نتناوله: يجب أن تكون الوظائف التي تؤلفها نقية ، ومفهوم آخر مشتق من الرياضيات. في الرياضيات ، جميع الوظائف عبارة عن حسابات ينتج عنها دائمًا نفس المخرجات عند استدعائها بنفس المدخلات ؛ هذا هو اساس النقاء.
دعنا نلقي نظرة على مثال الكود الكاذب باستخدام وظائف الرياضيات. افترض أن لدينا دالة ، makeEven
، تضاعف إدخال عدد صحيح لجعلها متساوية ، وأن الكود الخاص بنا ينفذ السطر makeEven(x) + x
باستخدام الإدخال x = 2
. في الرياضيات ، تُترجم هذه العملية الحسابية دائمًا إلى حساب $ 2x + x = 3x = 3 (2) = 6 $ وهي دالة خالصة. ومع ذلك ، هذا ليس صحيحًا دائمًا في البرمجة - إذا كانت الوظيفة makeEven(x)
تحور x
بمضاعفتها قبل أن تعيد الشفرة النتيجة ، فسيحسب خطنا $ 2x + (2x) = 4x = 4 (2) = 8 $ والأسوأ من ذلك ، أن النتيجة ستتغير مع كل makeEven
.
دعنا نستكشف بعض أنواع الوظائف غير النقية ولكنها ستساعدنا في تحديد النقاء بشكل أكثر تحديدًا:
- الدوال الجزئية: هذه وظائف لم يتم تعريفها لجميع قيم الإدخال ، مثل القسمة. من منظور البرمجة ، هذه هي الوظائف التي تطرح استثناءً:
fun divide(a: Int, b: Int): Float
استثناءًArithmeticException
للمدخلb = 0
الناتج عن القسمة على صفر. - إجمالي الوظائف: يتم تحديد هذه الوظائف لجميع قيم الإدخال ولكن يمكن أن تنتج مخرجات مختلفة أو تأثيرات جانبية عند استدعائها بنفس المدخلات. عالم Android مليء بالوظائف الإجمالية:
Log.d
وLocalDateTime.now
وLocale.getDefault
هي مجرد أمثلة قليلة.
مع وضع هذه التعريفات في الاعتبار ، يمكننا تحديد الوظائف البحتة كوظائف كاملة بدون آثار جانبية. تكوينات الوظائف المبنية باستخدام وظائف نقية فقط تنتج كودًا أكثر موثوقية وقابلية للتنبؤ وقابلية للاختبار.
نصيحة: لجعل الدالة الكلية نقية ، يمكنك تجريد آثارها الجانبية بتمريرها كمعامل دالة ذي ترتيب أعلى. بهذه الطريقة ، يمكنك بسهولة اختبار الوظائف الإجمالية عن طريق تمرير وظيفة مرتبة أعلى تم الاستهزاء بها. يستخدم هذا المثال التعليق التوضيحي @SideEffect
من مكتبة نقوم بفحصها لاحقًا في البرنامج التعليمي ، Ivy FRP:
suspend fun deadlinePassed( deadline: LocalDate, @SideEffect currentDate: suspend () -> LocalDate ): Boolean = deadline.isAfter(currentDate())
الماخذ الرئيسية
النقاء هو المكون الأخير المطلوب لنموذج البرمجة الوظيفية.
- كن حذرًا مع الوظائف الجزئية - فقد تؤدي إلى تعطل تطبيقك.
- تكوين الوظائف الكلية ليس حتميا ؛ يمكن أن تنتج سلوكًا غير متوقع.
- كلما كان ذلك ممكنا ، اكتب وظائف نقية. ستستفيد من زيادة استقرار الكود.
بعد الانتهاء من استعراضنا للبرمجة الوظيفية ، دعنا نفحص المكون التالي من كود Android الذي يتناسب مع المستقبل: البرمجة التفاعلية.
البرمجة التفاعلية 101
البرمجة التفاعلية هي نمط برمجة تعريفي يتفاعل فيه البرنامج مع البيانات أو تغييرات الأحداث بدلاً من طلب معلومات حول التغييرات.
العناصر الأساسية في دورة البرمجة التفاعلية هي الأحداث ، وخط الأنابيب التعريفي ، والحالات ، والمرصدات:
- الأحداث عبارة عن إشارات من العالم الخارجي ، عادةً ما تكون في شكل إدخال المستخدم أو أحداث النظام ، والتي تؤدي إلى تشغيل التحديثات. الغرض من الحدث هو تحويل الإشارة إلى مدخلات خط الأنابيب.
- خط الأنابيب التعريفي عبارة عن تكوين دالة يقبل
(Event, State)
كمدخلات ويحول هذا الإدخال إلىState
جديدة (المخرجات):(Event, State) -> f -> g -> … -> n -> State
. يجب أن تعمل خطوط الأنابيب بشكل غير متزامن للتعامل مع أحداث متعددة دون حظر خطوط الأنابيب الأخرى أو انتظار الانتهاء منها. - الدول هي تمثيل نموذج البيانات لتطبيق البرنامج في وقت معين. يستخدم منطق المجال الحالة لحساب الحالة التالية المرغوبة وإجراء التحديثات المقابلة.
- تستمع المراقبات إلى تغييرات الحالة وتحديث المشتركين بشأن هذه التغييرات. في Android ، يتم تنفيذ الملحوظات عادةً باستخدام
Flow
أوLiveData
أوRxJava
، ويقومون بإخطار واجهة المستخدم بتحديثات الحالة حتى تتمكن من الاستجابة وفقًا لذلك.
هناك العديد من التعريفات والتطبيقات للبرمجة التفاعلية. هنا ، اتخذت نهجًا عمليًا يركز على تطبيق هذه المفاهيم على مشاريع حقيقية.
ربط النقاط: البرمجة التفاعلية الوظيفية
البرمجة الوظيفية والتفاعلية نموذجان قويان. تتجاوز هذه المفاهيم العمر الافتراضي للمكتبات وواجهات برمجة التطبيقات ، وستعمل على تحسين مهارات البرمجة لديك لسنوات قادمة.
علاوة على ذلك ، تتضاعف قوة FP والبرمجة التفاعلية عند الجمع بينهما. الآن بعد أن أصبح لدينا تعريفات واضحة للبرمجة الوظيفية والتفاعلية ، يمكننا تجميع الأجزاء معًا. في الجزء الثاني من هذا البرنامج التعليمي ، نحدد نموذج البرمجة التفاعلية الوظيفية (FRP) ، ونضعه موضع التنفيذ من خلال تطبيق نموذجي ومكتبات Android ذات صلة.
تعرب مدونة Toptal Engineering عن امتنانها لـ Tarun Goyal لمراجعة عينات الكود المقدمة في هذه المقالة.