لغات البرمجة عملياً الأدلة التقنية

أخطاء شائعة وأسلوب Kotlin الصحيح Idiomatic Kotlin

خمسة عشر خطأً يقع فيها القادمون إلى Kotlin من Java وPython وJavaScript، شغلنا كل واحد منها وعرضنا مخرجه الحقيقي ثم الإصلاح، وفي آخر المقال خطة قراءة من المصادر الرسمية للغة.

أخطاء شائعة وأسلوب Kotlin الصحيح Idiomatic Kotlin

خدمة الطلبات التي بنيناها في مرحلة Kotlin وصلت إلى مراجعة الكود Code Review، والمراجع هو مطور Android في الفريق الذي يكتب Kotlin منذ سنوات. وعاد طلب الدمج Pull Request بخمس عشرة ملاحظة، ولم تكن أي ملاحظة منها عن منطق الطلبات أو قاعدة البيانات، وإنما كلها من نوع واحد: «هذا يعمل، ولكن هكذا تكتب في Java» أو «هذه عادة من Python ستعطيك نتيجة خاطئة في Kotlin دون أي خطأ». وبعض الملاحظات كانت عن أسلوب، وبعضها كان عن أخطاء حقيقية مختبئة: سلة لا تستطيع أن تحذف منها منتجاً، وقائمة «ثابتة» تغيرت، وروتين ألغي ولم يتوقف، وبريد يرسل بعد أن انتهى الطلب.

وهذا المقال هو الأخير في المرحلة، وهو مرجع تعود إليه أكثر مما تقرؤه مرة واحدة: خمسة عشر خطأً يقع فيها القادمون إلى Kotlin من لغات أخرى، وكل خطأ منها كتبناه وشغلناه فعلاً، والمخرج المعروض هو المخرج الحقيقي، ثم الإصلاح بالأسلوب الذي يكتبه من يعمل باللغة كل يوم، أي Idiomatic Kotlin. والكود كله في ملف واحد lab/src/main/kotlin/idioms/Mistakes.kt، يعمل على Kotlin 2.4.21 وJDK 25. وفي آخر المقال خطة قراءة من المصادر الرسمية لمن يريد أن يكمل بعد هذه المرحلة.

وسوف نناقش في هذا المقال ما يلي:

  • لماذا نكتب Kotlin بلهجة لغتنا الأولى، وكيف تراجع كود زميل قادم من لغة أخرى.
  • القيم الفارغة والمساواة: !!، والمصفوفات داخل data class.
  • المجموعات Collections: var داخل HashSet، والقائمة للقراءة فقط التي تتغير، والنسخ السطحي في copy().
  • التحكم في التدفق: return داخل forEach، وفخ ?.let { } ?:، وelse على نوع sealed، ودالة الامتداد التي لا تستدعى أبداً.
  • الأرقام والنصوص: Double للمال، وترتيب القسمة في النسب، وجمع النصوص في حلقة.
  • التوافق مع Java: القيم الافتراضية التي لا يراها كود Java.
  • الروتينات: runCatching التي تبتلع الإلغاء، وGlobalScope.
  • ماذا تكشف الأدوات من هذه الأخطاء: تحذيرات المترجم وأداة detekt.
  • جدول يجمع الأخطاء ومصدرها وإصلاحها، ثم خطة قراءة من المصادر الرسمية.

لماذا نكتب Kotlin بلهجة لغتنا الأولى؟ Mental Models

قبل الأخطاء نفسها، يستحق السؤال وقفة: لماذا يقع مطور خبير في أخطاء من هذا النوع، وهو يعرف البرمجة جيداً؟ والإجابة أن كل لغة تعلمك نموذجاً ذهنياً Mental Model لما يحدث تحت الكود، وهذا النموذج يعمل في الخلفية دون أن تفكر فيه. فالقادم من Java يفكر في المراجع والكائنات والاستثناءات المفحوصة، ويرى !! كأنها افتراض عادي بأن القيمة موجودة، لأنه عاش سنوات يفترض ذلك في كل سطر. والقادم من Python يفكر في الأسماء والقيم والقوائم المرنة، ويتوقع أن القسمة تعطي نتيجة عشرية، وأن ما لا يمكن تعديله لا يتغير. والقادم من JavaScript يفكر في حلقة الأحداث والدوال التي تعمل لاحقاً دون أن ينتظرها أحد، وفي القيم «الصادقة» و«الكاذبة» التي تجعل a ?? b وa || b اختصاراً لكل شيء.

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

كيف بنينا المختبر Lab؟

كل خطأ في المختبر دالة أو اثنتان: النسخة الخاطئة كما يكتبها القادم من لغة أخرى، والنسخة المصلحة. والدالة main تشغل الأخطاء بالترتيب، وتطبع لكل واحد ما يحدث، وإذا رمى خطأ استثناء فإنها تطبعه مع أول سطر من مسار الاستدعاء وتكمل إلى التالي. وشغلناه داخل Container من الـ Image gradle:9.8.2-jdk25-noble بالأمرين gradle installDist ثم build/install/idioms/bin/idioms، والمخرج الكامل في lab/output-main.txt. وبجانب ملف Kotlin يوجد كلاس Java صغير في lab/src/main/java/idioms/LegacyCaller.java يستدعي كود Kotlin، لأن الخطأ الرابع عشر لا يظهر إلا عندما يترجم كود Java في نفس المشروع. وثلاث تجارب إضافية غيرت الكود داخل الـ Container قبل البناء، دون أن تلمس الملفات الأصلية: كتابة when دون else، واستدعاء دالة بقيمة افتراضية من Java دون @JvmOverloads، وتشغيل المترجم بخيار يجعل التحذيرات أخطاء، ومعها تشغيل detekt، وكل واحدة لها ملف مخرج باسمها في نفس المجلد. وأول ما طبعه البناء، قبل أي تشغيل، كان تحذيراً من المترجم سوف نعود إليه في الخطأ العاشر:

w: file:///w/src/main/kotlin/idioms/Mistakes.kt:94:11 This extension is shadowed by a member: 'fun format(): String' defined in 'idioms.Price'.

والصورة التالية تجمع الأخطاء الخمسة عشر بحسب اللغة التي تأتي منها العادة غالباً، وهذا التصنيف تقريبي، فبعض الأخطاء يقع فيها الجميع:

خريطة الأخطاء الخمسة عشر مصنفة بحسب اللغة التي تأتي منها العادة: Java وPython وJavaScript، وأخطاء الروتينات
خمسة عشر خطأً في خمس مجموعات، ومعظمها لا يرمي أي استثناء ويعطي نتيجة خاطئة بصمت

القيم الفارغة Null والمساواة Equality

الخطأ 1: العلامة !! Not-Null Assertion بدل ?: مع رسالة

القادم من Java يرى أن المترجم يعترض على قراءة Map، لأن النتيجة Int?، فيكتب !! ليسكته، كما كان يفترض في Java أن المفتاح موجود:

val stockById = mapOf(1 to 10, 2 to 0)

fun m1Wrong(id: Int): Int = stockById[id]!!
fun m1Fixed(id: Int): Int = stockById[id] ?: error("product $id is not in the catalog")
m1Fixed(9): java.lang.IllegalStateException: product 9 is not in the catalog
Exception: java.lang.NullPointerException
	at idioms.MistakesKt.m1Wrong(Mistakes.kt:15)

والفرق بين السطرين هو الفرق بين ساعة تصحيح وثانية واحدة. فالنسخة الخاطئة رمت NullPointerException بلا أي رسالة، ولا تعرف منها ما الذي كان null ولا لماذا، أما النسخة المصلحة فرمت IllegalStateException تقول إن المنتج 9 غير موجود في الكتالوج. والقاعدة: !! هي الطريقة الوحيدة في Kotlin لصنع NullPointerException بيدك، فلا تكتبها في كود الإنتاج، واستخدم ?: error("...") أو ?: return أو getValue() التي ترمي NoSuchElementException برسالة تذكر المفتاح. وتشرح صفحة Null safety هذه الأدوات، وقد رأينا في المقال الثاني من المرحلة أنواع المنصة Platform Types التي هي الباب الآخر لهذا الاستثناء.

الخطأ 5: مصفوفة Array داخل data class

نبدأ بالخطأ الخامس هنا لأنه عن المساواة أيضاً. القادم من Java يكتب سلة بمصفوفة من أرقام المنتجات، لأن المصفوفة في Java هي الشكل الطبيعي لقائمة ثابتة الطول:

data class Basket(val ids: Array<Int>)
data class BasketFixed(val ids: List<Int>)
Basket(arrayOf(1,2)) == Basket(arrayOf(1,2)) -> false
toString: Basket(ids=[1, 2])
BasketFixed(listOf(1,2)) == BasketFixed(listOf(1,2)) -> true

سلتان فيهما نفس المنتجين، و== تقول إنهما مختلفتان. والسبب أن data class تولد equals بمقارنة كل خاصية بـ equals الخاصة بها، والمصفوفة على الـ JVM لا تعيد تعريف equals، فهي تقارن المرجع، وتشرح صفحة Arrays ذلك وتوصي بتجنب المصفوفات في data class. ولاحظ أن toString يطبع المحتوى بشكل صحيح، فالخطأ يبدو مستحيلاً عند القراءة في السجل. والإصلاح أن تستخدم List كما في BasketFixed، وتترك المصفوفات للأداء في الحسابات الرقمية الكبيرة كما رأينا في المقال الثاني (IntArray)، وإذا احتجتها داخل data class فاكتب equals وhashCode بنفسك بـ contentEquals.

المجموعات Collections: ما تراه ليس دائماً ما في الذاكرة

الخطأ 2: data class فيها var داخل HashSet

سطر في السلة له رقم منتج وكمية، والكمية var لأنها تتغير، والسطور في HashSet حتى لا يتكرر المنتج:

data class CartLine(val productId: Int, var qty: Int)

val cart = hashSetOf(CartLine(1, 1))
val line = cart.first()
line.qty = 3                                  // نعدل الكائن وهو داخل الـ Set
println("cart = $cart")
println("cart.contains(line) = ${cart.contains(line)}")
println("cart.remove(line) = ${cart.remove(line)}, size after remove = ${cart.size}")
cart = [CartLine(productId=1, qty=3)]
cart.contains(line) = false
cart.remove(line) = false, size after remove = 1

وهذا من أغرب ما يمكن أن تراه: السلة تطبع السطر، والسطر هو نفس الكائن الذي فيها، وcontains تقول إنه غير موجود، وremove تفشل، فلا يستطيع العميل أن يحذف المنتج من سلته. والسبب أن HashSet وضعت الكائن في خانة بحسب hashCode وقت الإضافة، وdata class تحسب hashCode من كل الخصائص بما فيها qty، فلما تغيرت الكمية تغير الهاش، وصار البحث يذهب إلى خانة أخرى فارغة. والكائن نفسه ما زال في الخانة القديمة، يظهر عند الطباعة والمرور على المجموعة، ولا يجده البحث أبداً. والصورة التالية تبين ذلك:

HashSet يضع CartLine بكمية 1 في خانة بحسب الهاش، ثم تتغير الكمية إلى 3 فيبحث contains في خانة أخرى ولا يجده
الكائن في خانة الهاش القديم، والبحث يذهب إلى خانة الهاش الجديد

والإصلاح بأسلوب Kotlin أن تكون خصائص data class كلها val، وأن تغير الكمية بإنشاء نسخة جديدة بـ copy(qty = 3)، فتحذف القديمة وتضيف الجديدة. وأفضل من ذلك في هذه الحالة أن تكون السلة Map<Int, Int> من رقم المنتج إلى الكمية، فالمفتاح لا يتغير أبداً. والقاعدة التي تكتبها صفحة Data classes بوضوح: data class للقيم، والقيم لا تتغير.

ونفس المشكلة تظهر بشكل أخطر في كود قاعدة البيانات: إذا كتبت كيان Entity لمكتبة مثل Hibernate على شكل data class، فإن hashCode يتغير عندما تعطيه قاعدة البيانات رقمه بعد الحفظ، أو عندما تحمل المكتبة علاقاته لاحقاً، فيضيع الكائن من أي Set وضعته فيه قبل الحفظ. ولهذا السبب توصي أدلة المكتبات التي تعمل مع Kotlin بأن تكون الكيانات كلاسات عادية تكتب equals وhashCode على المعرف وحده، وتترك data class للقيم التي تنقل بين الطبقات، مثل نماذج JSON التي رأيتها في مقال الخدمة.

الخطأ 3: القائمة List للقراءة فقط ليست ثابتة Immutable

القادم من Python يقرأ أن List في Kotlin لا تقبل add، فيفهم أنها مثل tuple، أي أنها لا تتغير أبداً. والكود التالي يعطي قائمة الأسعار لجزء آخر من البرنامج على أنها List:

val backing = mutableListOf(990, 3250)
val prices: List<Int> = backing            // نعطيها لكود آخر كـ List
backing.add(1575)                          // ثم يتغير المصدر
println("prices seen as List: $prices")
val fixed = listOf(990, 3250)
try { (fixed as MutableList<Int>).add(1575) } catch (e: UnsupportedOperationException) { println("listOf + cast to MutableList: $e") }
val copy = backing.toList()                // نسخة حقيقية
backing.add(500)
println("toList() copy after another add: $copy")
prices seen as List: [990, 3250, 1575]
listOf + cast to MutableList: java.lang.UnsupportedOperationException
toList() copy after another add: [990, 3250, 1575]

في المخرج أعلاه لاحظ التالي:

  • السطر الأول هو المشكلة: الكود الذي استلم prices كـ List رأى عنصراً ثالثاً ظهر فجأة، لأن List في Kotlin واجهة Interface للقراءة فقط، وليست نوعاً ثابتاً. فهي تمنع صاحب هذا المتغير من التعديل، ولا تمنع أي شخص آخر يملك مرجعاً إلى نفس الكائن من نوع MutableList. وتشرح صفحة Collections overview هذا الفرق بين Read-Only وImmutable.
  • السطر الثاني يبين أن listOf مع أكثر من عنصر ترجع على الـ JVM كائناً من Java لا يقبل الإضافة، فالتحويل إلى MutableList نجح في الترجمة ورمى UnsupportedOperationException وقت التشغيل. والتحويل نفسه هو الخطأ، فلا تكتب as MutableList أبداً.
  • السطر الثالث هو الإصلاح: toList() تنسخ المحتوى في كائن جديد، فلم يتأثر بالإضافة اللاحقة.

والقاعدة العملية: إذا كنت تعطي قائمة لكود آخر وتريد أن تضمن أنها لن تتغير تحته، فأعطه نسخة بـ toList()، أو استخدم مكتبة kotlinx.collections.immutable التي تقدم أنواعاً ثابتة فعلاً. وهذا يهم كثيراً مع الروتينات، فقائمة تقرؤها في روتين ويعدلها روتين آخر هي حالة سباق كما رأيت في مقال التزامن.

الخطأ 4: الدالة copy() نسخة سطحية Shallow Copy

data class Order(val id: Int, val items: MutableList<String>)

val original = Order(1, mutableListOf("قهوة"))
val draft = original.copy()
draft.items += "تمر"
println("original.items = ${original.items}")
println("same list object: ${original.items === draft.items}")
val deep = original.copy(items = original.items.toMutableList())
deep.items += "هيل"
println("after deep copy: original=${original.items} deep=${deep.items}")
original.items = [قهوة, تمر]
same list object: true
after deep copy: original=[قهوة, تمر] deep=[قهوة, تمر, هيل]

والخطأ في السطرين الأولين: عدلنا مسودة الطلب draft بإضافة التمر، فتغير الطلب الأصلي original أيضاً، والسطر same list object: true يشرح السبب: copy() تنسخ الخصائص نفسها، أي المراجع، ولا تنسخ ما تشير إليه، فالكائنان يشتركان في نفس القائمة. والسطر الثالث هو النسخ العميق: أعطينا copy قائمة جديدة بـ toMutableList()، فأخذت محتوى الأصل في تلك اللحظة (القهوة والتمر)، ثم أضفنا الهيل إليها وحدها، وبقي الأصل [قهوة, تمر]. والإصلاح الأفضل ليس النسخ العميق، وإنما ألا تكون الخاصية MutableList أصلاً: اجعلها List، وأضف بإنشاء نسخة جديدة copy(items = items + "تمر")، فلا يوجد شيء مشترك قابل للتعديل.

كائنان من copy يشيران إلى نفس القائمة MutableList، وقائمة للقراءة فقط List تشير إلى نفس الكائن الذي يعدله مرجع آخر
القائمة للقراءة فقط وcopy() كلاهما يشارك المرجع: من يملك مرجعاً قابلاً للتعديل يغير ما يراه الآخرون

التحكم في التدفق Control Flow: أقواس تبدو بريئة

الخطأ 6: return داخل forEach

القادم من Java يكتب forEach كأنها حلقة for، ويستخدم return ليتخطى عنصراً كما يستخدم continue:

fun logAll(stock: List<Int>): List<String> {
    val out = mutableListOf<String>()
    stock.forEach { if (it == 0) return@forEach; out += "ok $it" }   // continue
    out += "done"
    return out
}

fun logAllWrong(stock: List<Int>): List<String> {
    val out = mutableListOf<String>()
    run { stock.forEach { if (it == 0) return out; out += "ok $it" } }  // قصدنا continue وكتبنا return
    out += "done"
    return out
}
logAll      -> [ok 5, ok 7, done]
logAllWrong -> [ok 5]
firstOutOfStockWrong -> item 1 is out of stock

النسخة الخاطئة توقفت عند أول مخزون صفر، ولم تطبع 7 ولا «done». والسبب أن forEach دالة inline كما رأينا في المقال الثاني، فجسمها ينسخ داخل الدالة المحيطة، وreturn العادية داخلها ترجع من الدالة المحيطة كلها، وهذا اسمه الرجوع غير المحلي Non-Local Return. والرجوع إلى الدالة المجهولة فقط، أي ما يقابل continue، يكتب return@forEach كما تشرح صفحة Returns and jumps. والسطر الثالث يبين أن الرجوع غير المحلي مفيد عندما تقصده، فدالة «أول منتج نفد» تخرج عند أول نتيجة. والقاعدة: إذا احتجت break أو continue فاكتب حلقة for عادية، فهي أوضح للقارئ من أي علامة.

الخطأ 7: فخ ?.let { } ?: الفرع البديل

وهذا من أشهر أخطاء القادمين من JavaScript الذين يعاملون ?.let { } ?: ... كأنها if/else مختصرة. الدالة audit تسجل الاسم وترجع null أحياناً (هنا: للأسماء الطويلة):

fun audit(msg: String): String? = if (msg.length > 5) null else "logged:$msg"   // ترجع null أحياناً

fun m7Wrong(name: String?): String = name?.let { audit(it) } ?: "name is missing"
fun m7Fixed(name: String?): String = if (name != null) audit(name) ?: "audit skipped" else "name is missing"
m7Wrong("Sara")     -> logged:Sara
m7Wrong("Abdullah") -> name is missing
m7Fixed("Abdullah") -> audit skipped
m7Wrong(null)       -> name is missing

الاسم «Abdullah» موجود وليس null، والنسخة الخاطئة قالت «name is missing». والسبب أن ?: لا تسأل «هل name null؟» وإنما «هل نتيجة كل ما على اليسار null؟»، وlet ترجع نتيجة الـ Lambda، والـ Lambda رجعت null من audit، فعمل الفرع البديل. والنتيجة رسالة خاطئة في السجل وفي الرد، وهذا الخطأ يمر في الاختبار بأي اسم قصير. والإصلاح أن تكتب if صريحة عندما يكون الفرعان مختلفين في المعنى، وتترك ?.let للتحويل البسيط الذي لا يرجع null. وتشرح صفحة Scope functions متى تستخدم let وalso وapply وrun، وأهم سطر فيها أن also ترجع الكائن نفسه وليس نتيجة الـ Lambda، فـ name?.also { audit(it) } ?: ... لا يقع في هذا الفخ.

مسار name?.let { audit(it) } ?: مع اسم موجود: let ترجع null من audit فيعمل الفرع البديل name is missing
العامل ?: يسأل عن نتيجة السلسلة كلها، وليس عن المتغير الأول

الخطأ 9: else على نوع sealed يخفي الحالة الجديدة

طرق الدفع نوع مغلق، والدالة feeWrong تحسب رسوم الدفع: البطاقة 150 هللة، وكل ما عداها صفر، فكتب المطور else كما يكتب default في switch. ثم أضاف فريق آخر نوعاً ثالثاً هو المحفظة الإلكترونية، ورسومها في العقد ليست صفراً:

sealed interface Payment {
    data class Card(val last4: String) : Payment
    data class Cash(val change: Int) : Payment
    data class Wallet(val provider: String) : Payment     // نوع جديد أضيف لاحقاً
}

fun feeWrong(p: Payment): Int = when (p) {
    is Payment.Card -> 150
    else -> 0                                               // Cash لا رسوم... و Wallet أيضاً بالخطأ
}
Card: fee=150
Cash: fee=0
Wallet: fee=0

والبناء نجح، والتشغيل نجح، والمحفظة صارت بلا رسوم دون أن يعرف أحد. ثم جربنا «ماذا يحدث إذا» كتبنا الدالة بالأسلوب الصحيح، أي كل حالة باسمها دون else، على نفس الكود الذي فيه Wallet:

84:fun feeWrong(p: Payment): Int = when (p) {
85-    is Payment.Card -> 150
86-    is Payment.Cash -> 0
87-}
e: file:///w/src/main/kotlin/idioms/Mistakes.kt:84:33 'when' expression must be exhaustive. Add the 'is Wallet' branch or an 'else' branch.
> Task :compileKotlin FAILED

فالمترجم رفض البناء، وقال بالاسم إن الحالة Wallet غير مغطاة. وهذا هو كل الهدف من sealed كما تشرح صفحة Sealed classes: أن يتحول نسيان حالة جديدة من خطأ مالي صامت إلى خطأ ترجمة. وelse على نوع مغلق تلغي هذه الحماية بالكامل، فلا تكتبها إلا إذا كانت الحالات الأخرى كلها فعلاً لها نفس المعنى الآن وفي المستقبل.

الخطأ 10: دالة امتداد Extension بنفس اسم دالة Member في الكلاس

القادم من JavaScript أو Ruby قد يظن أن دالة الامتداد Extension Function تعيد تعريف الدالة الموجودة، كما يفعل تعديل الـ Prototype. فالكلاس Price فيه format() تطبع الهللات، وأراد مطور أن يغيرها لتطبع بالريال، فكتب دالة امتداد بنفس الاسم:

class Price(val halalas: Int) {
    fun format(): String = "$halalas halalas"
}

fun Price.format(): String = "%d.%02d SAR".format(halalas / 100, halalas % 100)   // لا تستدعى أبداً

fun Price.formatSar(): String = "%d.%02d SAR".format(halalas / 100, halalas % 100)
Price(2970).format()    -> 2970 halalas
Price(2970).formatSar() -> 29.70 SAR

ودالة الامتداد لم تستدع أبداً، لأن القاعدة في Extensions أن دالة الكلاس نفسه Member تفوز دائماً عند تساوي الاسم والمعاملات. والمترجم حذرنا من ذلك وقت البناء بالسطر This extension is shadowed by a member الذي رأيته في أول المقال، وهذا سبب آخر لجعل التحذيرات أخطاء في خط CI. والإصلاح اسم مختلف كما في formatSar، أو تعديل الكلاس نفسه إذا كنت تملكه.

الأرقام والنصوص Numbers and Strings

الخطأ 8: Double للمال

وهذا خطأ عام في كل اللغات، ولكنه يتكرر في Kotlin لأن 9.90 تكتب بسهولة وتبدو كسعر:

0.1 + 0.2 = 0.30000000000000004
three items at 0.10 SAR: 0.30000000000000004
9.90 * 3 * 0.15 = 4.455
halalas: 990 * 3 = 2970, vat = 446

ثلاث قطع بعشر هللات صارت 0.30000000000000004 ريال، لأن 0.1 لا يمكن تمثيلها بالضبط في النظام الثنائي، وهذا ما شرحناه بالتفصيل في مقال الأرقام العشرية Floating Point من الداخل. والسطر الثالث خادع أكثر، فالضريبة 4.455 ظهرت صحيحة، ولكنها في الذاكرة قد تكون أقل قليلاً أو أكثر قليلاً من 4.455، فإذا قربتها إلى منزلتين فقد تصبح 4.45 أو 4.46 بحسب الخطأ الصغير المخفي، وهذا بالضبط الفرق بين الفاتورة والمحاسب. والسطر الأخير هو الطريقة التي استخدمناها في الخدمة: كل شيء بالهللات Int، والضريبة بالأعداد الصحيحة، والنتيجة 446 بالضبط في كل مرة. وإذا احتجت كسوراً حقيقية، كنسب الخصم، فاستخدم BigDecimal من Java مع RoundingMode محدد.

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

الخطأ 15: ترتيب القسمة الصحيحة Integer Division في النسب

وبعد أن تعلم المطور القادم من Python أن يحسب بالهللات، وقع في الخطأ المعاكس: حساب نسبة الخصم بالأعداد الصحيحة بترتيب يبدو منطقياً على الورق، أي «اقسم على مئة ثم اضرب في النسبة»:

fun discountWrong(price: Int, percent: Int): Int = price / 100 * percent
fun discountFixed(price: Int, percent: Int): Int = (price * percent + 50) / 100
price=990  15%: wrong=135 fixed=149
price=99  15%: wrong=0 fixed=15
price=3250  15%: wrong=480 fixed=488

خصم 15% على علبة قهوة بـ 9.90 ريال صار 1.35 ريال بدل 1.49، وعلى منتج بـ 99 هللة صار صفراً. والسبب أن 990 / 100 في Kotlin تساوي 9 وليس 9.9، فالقسمة الصحيحة حذفت الجزء العشري قبل الضرب، ثم ضرب الخطأ في 15. وفي Python 3 تعطي / رقماً عشرياً دائماً، و// هي القسمة الصحيحة، ولذلك لا يتوقع القادم منها أن العلامة نفسها تتصرف بشكلين في Kotlin بحسب نوع الطرفين. والقاعدة مع الأعداد الصحيحة: اضرب أولاً ثم اقسم، وأضف نصف المقسوم عليه قبل القسمة إذا أردت التقريب للأقرب، كما فعلنا في دالة الضريبة في الخدمة، وانتبه للتجاوز عند الضرب في أرقام كبيرة، وهنا يأتي دور Math.multiplyExact. ولاحظ أن هذا الخطأ لا يرمي شيئاً ولا يحذر منه المترجم، والاختبار الوحيد الذي يكشفه اختبار بأرقام حقيقية لها جزء عشري، مثل 990 و99، وليس بأرقام مدورة مثل 1000.

الخطأ 13: جمع النصوص String Concatenation في حلقة

القادم من Python أو JavaScript يبني نص الفاتورة بـ += في حلقة، وهذا شكل الفاتورة بعشرين ألف سطر:

fun receiptWrong(n: Int): Int { var s = ""; for (i in 0 until n) s += "line $i\n"; return s.length }
fun receiptFixed(n: Int): Int = buildString { for (i in 0 until n) append("line ").append(i).append('\n') }.length
s += in a loop (20,000 lines)          2930 ms  result=208890
buildString (20,000 lines)                6 ms  result=208890
s += in a loop (20,000 lines)           515 ms  result=208890
buildString (20,000 lines)                3 ms  result=208890

نفس النص بنفس الطول 208,890 حرفاً، و+= أخذت 2.9 ثانية في الجولة الأولى و515 جزءاً من الألف في الثانية، بينما buildString أخذت 3 إلى 6. والسبب أن String على الـ JVM ثابت، فكل += تنشئ نصاً جديداً وتنسخ فيه كل ما سبق، فالعمل يكبر مع مربع عدد الأسطر. وbuildString تستخدم StringBuilder واحداً يكبر عند الحاجة. وفي Python يبدو += سريعاً أحياناً بسبب تحسين خاص في CPython، ولذلك تتعلم العادة هناك ثم تدفع ثمنها هنا. والفرق بين الجولتين هو الإحماء نفسه الذي رأيناه في كل المرحلة.

وفي حالات كثيرة لا تحتاج حتى buildString، فإذا كان النص قائمة عناصر بفاصل فإن lines.joinToString("\n") { "line $it" } تقوم بنفس العمل في سطر واحد وبنفس الكفاءة، لأنها تستخدم StringBuilder داخلياً. والقالب النصي الواحد مثل "$a $b $c" لا مشكلة فيه أصلاً، فالمترجم يحوله إلى بناء واحد، والمشكلة فقط في += المتكرر داخل حلقة.

التوافق مع Java Interop

الخطأ 14: قيم افتراضية Default Arguments لا يراها كود Java

وهذا خطأ يقع فيه من تعلم Kotlin ثم دخل مشروعاً مختلطاً: كتب دالة سعر بالضريبة بمعامل اختياري للنسبة، وهي تعمل من كل كود Kotlin بمعامل واحد، ثم استدعاها زميل من كلاس Java قديم في نفس المشروع:

fun priceWithVat(halalas: Int, vatPercent: Int = 15): Int = halalas + (halalas * vatPercent + 50) / 100

@JvmOverloads
fun priceWithVatJ(halalas: Int, vatPercent: Int = 15): Int = priceWithVat(halalas, vatPercent)
public class LegacyCaller {
    public static int total() {
        return MistakesKt.priceWithVat(990);
    }
}
> Task :compileJava FAILED
/w/src/main/java/idioms/LegacyCaller.java:6: error: method priceWithVat in class MistakesKt cannot be applied to given types;
  required: int,int
  found:    int
  reason: actual and formal argument lists differ in length
BUILD FAILED in 47s

ومترجم Java رفض الاستدعاء، لأن القيم الافتراضية مفهوم في مترجم Kotlin وحده: الـ Bytecode فيه دالة واحدة بمعاملين، ومعها دالة مساعدة مخفية تستخدمها Kotlin فقط. والعلامة @JvmOverloads تجعل المترجم يولد نسخة إضافية لكل معامل اختياري، فاستدعاء MistakesKt.priceWithVatJ(990) من Java ترجم وأعطى:

Java caller: LegacyCaller.total() = 1139

أي 990 هللة زائد ضريبة 149 هللة. ولاحظ اسم الكلاس MistakesKt في كود Java، فهو الكلاس الذي يولده المترجم لدوال المستوى الأعلى في الملف Mistakes.kt كما رأينا في المقال الأول من المرحلة، وتستطيع أن تغيره بالعلامة @file:JvmName("Pricing") حتى يقرأ كود Java بشكل طبيعي. وهذا الخطأ من النوع الجيد، لأنه خطأ ترجمة وليس نتيجة خاطئة، ولكنه يظهر متأخراً، عندما يحاول فريق آخر استخدام مكتبتك. فإذا كانت دوالك العامة ستستدعى من Java، فراجع ثلاث علامات في صفحة Calling Kotlin from Java: @JvmOverloads للقيم الافتراضية، و@JvmStatic لدوال companion object، و@Throws للاستثناءات كما رأينا في المقال الثاني.

الروتينات المساعدة Coroutines: الإلغاء والنطاق

الخطأ 11: runCatching تبتلع الإلغاء Cancellation

القادم من Python أو JavaScript يحب أن يلف كل استدعاء شبكة بشيء يمسك الأخطاء، وrunCatching تبدو الأداة المثالية. والروتين التالي يجلب السعر خمس مرات، وكل جلب يأخذ ثانية، ونلغيه بعد 100 جزء من الألف من الثانية:

val job = launch {
    repeat(5) {
        if (fixed) {
            try { fetchPrice() } catch (e: CancellationException) { throw e } catch (e: Exception) { println("failed: $e") }
        } else {
            runCatching { fetchPrice() }.onFailure { println("  runCatching caught: ${it::class.simpleName}") }
        }
        after++
    }
}
delay(100)
job.cancel()
job.join()
  runCatching caught: JobCancellationException
  runCatching caught: JobCancellationException
  runCatching caught: JobCancellationException
  runCatching caught: JobCancellationException
  runCatching caught: JobCancellationException
fixed=false: loop iterations after cancel=5, join took 14 ms
fixed=true: loop iterations after cancel=0, join took 0 ms

والنسخة الخاطئة أمسكت إشارة الإلغاء JobCancellationException خمس مرات، وأكملت الحلقة كلها كأن شيئاً لم يحدث، فكل جولة استدعت fetchPrice التي رمت الإلغاء فوراً لأن الروتين ملغى، ثم ابتلعتها runCatching وأكملت. وفي هذا المثال كل جولة بعد الإلغاء سريعة، ولكن لو كان في الحلقة كود حاجز أو كتابة في قاعدة البيانات لنفذ كله بعد أن طلبنا الإلغاء، والطلب الذي انتهت مهلته يستمر في العمل. والنسخة المصلحة تعيد رمي CancellationException قبل أن تمسك بقية الاستثناءات، فتوقفت الحلقة عند أول نقطة. وتشرح صفحة Cancellation and timeouts أن الإلغاء استثناء عادي في ظاهره، ولذلك أي catch (e: Exception) أو runCatching حول كود معلق تحتاج هذا السطر.

الخطأ 12: GlobalScope وعمل يستمر بعد انتهاء الطلب

القادم من JavaScript تعود أن يطلق عملاً في الخلفية دون أن ينتظره، مثل إرسال بريد التأكيد بعد الطلب، فيكتب في Kotlin GlobalScope.launch:

@OptIn(DelicateCoroutinesApi::class)
fun handleRequestWrong(log: MutableList<String>) {
    GlobalScope.launch { delay(200); log += "email sent (after the request ended)" }
    log += "request handled"
}

suspend fun handleRequestFixed(log: MutableList<String>) = coroutineScope {
    launch { delay(200); log += "email sent (inside the request scope)" }
    log += "request handled"
}
response sent, log=[request handled]
400 ms later, log=[request handled, email sent (after the request ended)]
fixed: response sent, log=[request handled, email sent (inside the request scope)]

ولاحظ أولاً أن المترجم نفسه أجبرنا على كتابة @OptIn(DelicateCoroutinesApi::class)، فـ GlobalScope معلمة في المكتبة كواجهة حساسة، وتقول وثائقها إنها لا تستخدم في كود التطبيقات عادة. والمخرج يبين لماذا: الرد ذهب للعميل والسجل فيه «request handled» فقط، والبريد أرسل بعد 200 جزء من الألف، خارج أي نطاق، فلا أحد يلغيه إذا ألغي الطلب، ولا أحد يعرف إذا فشل، وإذا أغلقت الخدمة في تلك اللحظة ضاع دون أثر. والنسخة المصلحة تطلق نفس العمل داخل coroutineScope، فالدالة لا ترجع حتى ينتهي البريد، وأي خطأ فيه يصل إلى المستدعي. وإذا كان المطلوب فعلاً ألا ينتظر العميل البريد، فالحل ليس GlobalScope، وإنما نطاق تملكه الخدمة نفسها بدورة حياتها، أو الأفضل طابور Queue خارجي يضمن الإرسال حتى لو أعيد تشغيل الخدمة.

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

خطآن في الروتينات: runCatching تمسك الإلغاء فتكمل الحلقة خمس جولات، وGlobalScope يرسل البريد بعد أن انتهى الطلب خارج أي نطاق
الإلغاء استثناء يجب أن يمر، والعمل في الخلفية يحتاج نطاقاً يملكه أحد

ماذا تكشف الأدوات؟ تحذيرات المترجم Compiler Warnings وأداة detekt

والسؤال الطبيعي بعد هذه القائمة: أليست هناك أداة تكشف هذه الأخطاء تلقائياً؟ وجربنا الأداتين اللتين رأيناهما في المقال الأول على نفس الملف. الأولى أن نجعل كل تحذير من المترجم خطأً، بإضافة سطر واحد إلى ملف البناء:

kotlin { compilerOptions { allWarningsAsErrors.set(true) } }
e: warnings found and -Werror specified
e: file:///w/src/main/kotlin/idioms/Mistakes.kt:94:11 This extension is shadowed by a member: 'fun format(): String' defined in 'idioms.Price'.
> Task :compileKotlin FAILED

فأوقف البناء بسبب الخطأ العاشر وحده، وهذا متوقع، فالمترجم يحذر مما يستطيع أن يثبته من الكود، والخطأ التاسع رفضه المترجم فقط عندما كتبناه دون else. والأداة الثانية هي detekt بالإصدار 2.0.0-alpha.6، لأن الإصدار المستقر 1.23.8 لا يعمل على JDK 25 كما رأينا في المقال الأول، وشغلناها بإعداداتها الافتراضية، وهذا عدد الملاحظات بحسب القاعدة من المخرج الكامل في lab/output-detekt.txt:

> Analysis failed with 77 issues.
     65 [MagicNumber]
      5 [MaxLineLength]
      2 [TooGenericExceptionCaught]
      2 [EmptyElseBlock]
      1 [TooManyFunctions]
      1 [LongMethod]
      1 [CyclomaticComplexMethod]

والنتيجة درس في حد ذاتها: 77 ملاحظة، أغلبها أرقام سحرية لأن الملف مليء بالأسعار والكميات، ولم تكشف أي قاعدة من القواعد الافتراضية !! ولا var في data class ولا المصفوفة فيها ولا GlobalScope. وأغرب ما فيها أن القاعدة TooGenericExceptionCaught أشارت إلى السطر 106، وهو النسخة المصلحة من الخطأ الحادي عشر التي تمسك Exception بعد أن تعيد رمي الإلغاء، وأن EmptyElseBlock أشارت إلى سطرين فيهما if (...) return@forEach ولا يوجد فيهما else أصلاً، وهذا على الأرجح من أخطاء النسخة التجريبية. وفي detekt قواعد أخرى لبعض هذه الأخطاء، مثل قاعدة الاستدعاء غير الآمن على نوع يقبل null، ولكنها غير مفعلة افتراضياً، فتحتاج ملف إعداد تفعلها فيه وتطفئ الضجيج مثل MagicNumber في ملفات الاختبار. والخلاصة العملية: الأدوات تكشف القليل من هذه الأخطاء بإعداداتها الافتراضية، وأكثرها يكشفه مراجع يعرف اللغة، أو اختبار بأرقام حقيقية، أو هذه القائمة.

كيف تراجع كود Kotlin لزميل قادم من لغة أخرى؟ Code Review

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

  1. قراءة الأنواع: ابحث عن كل !! وكل as وكل var داخل data class وكل MutableList في توقيع دالة عامة، فهذه أماكن يقول فيها الكود «ثق بي» للمترجم، وكل واحدة منها تحتاج سبباً مكتوباً.
  2. قراءة التدفق: ابحث عن return داخل الأقواس المعقوفة، وعن ?: بعد سلسلة طويلة، وعن else في when، واسأل في كل مرة: ماذا يحدث في الحالة التي لم يفكر فيها الكاتب؟
  3. قراءة الروتينات: ابحث عن GlobalScope وrunBlocking وcatch (e: Exception) وrunCatching في أي دالة suspend، وعن أي كود حاجز خارج Dispatchers.IO.

وكل ملاحظة في المراجعة تكتب بنفس الشكل الذي استخدمناه في هذا المقال، أي ما الذي سوف يحدث وليس ما هو «الأسلوب الصحيح» فقط: «إذا تغيرت الكمية بعد الإضافة فلن تستطيع حذف السطر من الـ HashSet» تقنع الكاتب أكثر بكثير من «اجعل الخاصية val». والأفضل أن ترفق مع الملاحظة اختباراً صغيراً يفشل، فهذا يحول النقاش من رأي إلى حقيقة. وبعد شهر أو شهرين يصبح الزميل الجديد هو من يكتب هذه الملاحظات للقادم بعده، وهذا هو المؤشر الحقيقي على أن الفريق انتقل إلى Kotlin، وليس عدد الملفات التي تنتهي بـ .kt.

جدول الأخطاء Mistakes Table: المصدر والعرض والإصلاح

#الخطأالعادة منما ظهر في التشغيلالإصلاح
1!!JavaNullPointerException بلا رسالة?: error("...")
2var في data class داخل HashSetJavacontains=false وremove=falseكل الخصائص val، أو Map
3List على أنها ثابتةPythonالقائمة تغيرت تحت المستلمtoList() عند التسليم
4copy() مع خاصية قابلة للتعديلالجميعالمسودة غيرت الأصلخصائص List وcopy(items = items + x)
5Array في data classJava== تعطي falseList
6return داخل forEachJavaالدالة كلها خرجتreturn@forEach أو حلقة for
7?.let { } ?: كـ if/elseJavaScript«name is missing» لاسم موجودif صريحة أو also
8Double للمالالجميع0.30000000000000004الهللات Int أو BigDecimal
9else على sealedJavaالمحفظة بلا رسوم بصمتكل حالة باسمها
10امتداد باسم دالة موجودةJavaScriptالامتداد لم يستدع، وتحذير وقت البناءاسم مختلف
11runCatching حول كود معلقPython، JavaScriptخمس جولات بعد الإلغاءإعادة رمي CancellationException
12GlobalScope.launchJavaScriptالبريد بعد انتهاء الطلبcoroutineScope أو طابور
14قيمة افتراضية تستدعى من JavaKotlin إلى مشروع مختلطcompileJava FAILED@JvmOverloads
15price / 100 * percentPythonخصم 135 بدل 149، وصفر بدل 15اضرب ثم اقسم مع التقريب
13+= على النصوص في حلقةPython2930 مقابل 6 أجزاء من الألفbuildString

ولاحظ في عمود «ما ظهر في التشغيل» أن ثلاثة أخطاء فقط من خمسة عشر رمت استثناء (الأول، والثالث عند التحويل، والحادي عشر داخلياً)، والمترجم تكلم في ثلاثة (التاسع عندما كتبناه بالشكل الصحيح، والعاشر بتحذير، والرابع عشر عندما ترجم كود Java)، والباقي أعطى نتيجة خاطئة أو بطيئة بصمت. وهذا هو سبب هذا المقال: الأخطاء التي تحتاج أن تعرفها مسبقاً هي التي لا تخبرك بنفسها.

قائمة تحقق Checklist لمراجعة كود Kotlin

  • هل يوجد !! خارج الاختبارات؟
  • هل كل خصائص data class هي val، ولا توجد فيها Array ولا MutableList؟
  • هل كل قائمة تعطى لكود آخر نسخة، ولا يوجد as MutableList؟
  • هل كل return داخل Lambda مقصودة، وكل continue مكتوبة return@label أو حلقة for؟
  • هل كل ?.let { } ?: فيها Lambda لا ترجع null؟
  • هل لا يوجد else في when على نوع sealed؟
  • هل التحذيرات أخطاء في CI (allWarningsAsErrors)؟
  • هل لا يوجد Double أو Float في أي مبلغ؟
  • هل كل catch وrunCatching حول كود معلق تعيد رمي CancellationException؟
  • هل لا يوجد GlobalScope، وكل عمل في الخلفية له نطاق يملكه أحد؟
  • هل بناء النصوص الطويلة بـ buildString أو joinToString؟
  • هل كل نسبة بالأعداد الصحيحة تضرب قبل أن تقسم؟
  • هل الدوال العامة التي يستدعيها كود Java عليها @JvmOverloads و@Throws عند الحاجة؟

خطة القراءة Reading Plan من المصادر الرسمية

وكل ما في هذه المرحلة يغطي ما تحتاجه لتبدأ، ولكن اللغة أوسع من خمسة مقالات، والمصادر الرسمية لـ Kotlin من أفضل وثائق اللغات المتاحة، فهي مكتوبة لمطور يعرف البرمجة، ومليئة بأمثلة تعمل في المتصفح. والترتيب التالي هو الذي نقترحه لمطور قادم من لغة أخرى، بحيث يبني كل أسبوع على ما قبله:

  1. الأسبوع الأول، الجولة الرسمية: ابدأ بـ Kotlin Tour، وهي جولة قصيرة في الأساسيات بتمارين تعمل في المتصفح، ثم صفحة Idioms التي تجمع الأساليب الشائعة في صفحة واحدة، ثم Coding conventions حتى تكتب كما يكتب الآخرون من أول يوم.
  2. الأسبوع الأول أيضاً، التمارين: Kotlin Koans مجموعة تمارين صغيرة تبدأ بكود ناقص وتطلب منك إكماله حتى تنجح الاختبارات، وهي أسرع طريقة لتعويد يدك على الصيغة.
  3. إذا كنت قادماً من Java: سلسلة الأدلة الرسمية من Java إلى Kotlin، وأهمها Nullability in Java and Kotlin وCollections in Java and Kotlin وStrings in Java and Kotlin، فهي تأخذ كود Java الذي تعرفه وتكتبه بأسلوب Kotlin سطراً بسطر.
  4. الأسبوع الثاني، الأجزاء التي تختلف: Null safety، ثم Scope functions لتعرف متى تستخدم let وapply وalso وrun، ثم Collections overview وReturns and jumps التي تشرح return@label.
  5. الأسبوع الثالث، التزامن: Coroutines guide كاملاً بالترتيب، وهو أطول جزء وأهمه للخدمات، ثم ارجع إلى مقال التزامن من هذه المرحلة وأعد التجارب.
  6. الأسبوع الرابع، السيرفر: وثائق Ktor من البداية حتى الاختبار والنشر، ثم أعد بناء خدمة الطلبات من مقال الخدمة الحقيقية دون أن تنظر إلى الكود.
  7. للمرجع دائماً: مرجع المكتبة الأساسية kotlin-stdlib عندما تبحث عن دالة، ومواصفات اللغة Kotlin Specification عندما تريد الجواب الدقيق لسؤال مثل «متى يعمل Smart Cast بالضبط؟»، وصفحة Learning materials التي تجمع كل المواد الرسمية الأخرى.

تدريب عملي

  • غير CartLine لتكون كل خصائصها val، واكتب دالة changeQty تحذف السطر القديم وتضيف الجديد، وتأكد أن remove تعمل بعدها.
  • أضف إلى build.gradle.kts الإعداد kotlin { compilerOptions { allWarningsAsErrors.set(true) } }، وأعد البناء، واقرأ ما يرفضه المترجم في ملف الأخطاء.
  • اكتب نسخة من الخطأ 11 تستخدم withTimeout(100) بدل cancel()، وتأكد أن النتيجة نفسها، ثم أصلحها.
  • عد إلى خدمة الطلبات من المقال الرابع، وابحث فيها عن أي من الأخطاء الخمسة عشر.

الخلاصة

وصلنا لنهاية الموضوع ونهاية المرحلة، وأهم ما فيه:

  • أغلب أخطاء القادمين إلى Kotlin لا ترمي استثناء: سلة لا يحذف منها، وقائمة «ثابتة» تتغير، ونسخة تعدل الأصل، ورسوم صفر لطريقة دفع جديدة، ورسالة «غير موجود» لاسم موجود.
  • data class للقيم، فاجعل خصائصها val، ولا تضع فيها مصفوفات ولا قوائم قابلة للتعديل.
  • List للقراءة فقط وليست ثابتة، وcopy() سطحية، والإصلاح نسخ صريحة وخصائص غير قابلة للتعديل.
  • return داخل الدوال inline ترجع من الدالة المحيطة، و?: بعد let تسأل عن نتيجة الـ Lambda، وelse على sealed تلغي حماية المترجم.
  • الإلغاء في الروتينات استثناء يجب أن يمر، وGlobalScope عمل بلا صاحب، و+= على النصوص أبطأ بمئات المرات من buildString في عشرين ألف سطر.
  • القسمة الصحيحة قبل الضرب أعطت خصم 1.35 ريال بدل 1.49، والقيم الافتراضية لا يراها كود Java دون @JvmOverloads، وأداة detekt بإعداداتها الافتراضية لم تكشف أي خطأ من الخمسة عشر بقاعدة مخصصة له.

وبهذا تكتمل مرحلة لغة Kotlin للمطورين: الأدوات والصيغة، ثم الأنواع والذاكرة والأخطاء، ثم التزامن بالروتينات، ثم خدمة الطلبات الحقيقية، ثم هذا المقال. وهذه المقالات المنشورة تكمل المرحلة:

دليلكيف تختار لغة البرمجة Programming Language لمشروعك؟نختار لغة خدمة حقيقية بأسئلة الفريق والتوظيف والمكتبات والرخصة، ثم نكتبها بأربع لغات ونقيس البناء والحجم والذاكرة والطلبات في الثانية بأنفسنا.دليلالبرمجة بعدة لغات Polyglot: متى تفيد ومتى تعقد مشروعك؟نستدعي نفس الدالة من Python بخمس طرق، عبر FFI إلى C وGo وعبر HTTP وgRPC، ونقيس كلفة كل حد بين لغتين وتعقيد البناء وما يحدث عندما يتعطل الطرف الآخر.دليلأنماط البرمجة وأنظمة الأنواع: لماذا تختلف اللغات؟نحل مسألة واحدة بعدة أساليب برمجة، ونرى هل تكتشف اللغة أخطاءك قبل التشغيل أم بعده، وماذا يعني ذلك عند اختيار لغة لمشروعك.دليلكيف تتعامل لغات البرمجة مع الأخطاء؟لماذا ترمي Java وPython الاستثناءات بينما ترجع Go وRust الخطأ كقيمة، وكيف تمنع الخطأ من أن يختفي بصمت في كودك.دليلThreads وasync/await: كود لا يتجمد ولا يضيع بياناتهكيف تكتب كوداً يعمل في أكثر من خيط Thread دون أن تضيع بياناته أو يتجمد، ولماذا تظهر هذه الأخطاء على السيرفر ولا تظهر على جهازك.دليلإدارة الذاكرة: يدوياً أم Garbage Collector أم Ownership؟نقيس بأيدينا في C وGo وPython وRust من يحرر الذاكرة ومتى، وكم يكلف ذلك من الذاكرة والوقت، وما الأخطاء التي تبقى ممكنة في كل طريقة.دليلقراءة ال Stack Trace وتصحيح الأخطاء Debugging للمبتدئينكيف تقرأ رسالة الخطأ في Python وJavaScript سطراً بسطر، ومتى تستخدم الطباعة أو نقاط التوقف في VS Code، وكيف يجد git bisect التعديل الذي أدخل الخطأ.

سجل التحديثات

  • أكتوبر 2026: كتابة المقال، وتشغيل الأخطاء الخمسة عشر وإصلاحاتها على Kotlin 2.4.21 وkotlinx.coroutines 1.11.0 وOpenJDK 25.0.5 داخل Docker.
Could not load content
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.