في المقال الأول من مرحلة Kotlin جهز مطور الخدمة الخلفية في متجر القهوة والتمور بيئته، وكتب أول مشروع Gradle، وتعرف على الصيغة. والآن بدأ العمل الحقيقي: خدمة الطلبات الجديدة تحتاج أسماء المنتجات من مكتبة Java قديمة كتبها فريق سابق، وفي أول أسبوع من التجربة الداخلية ظهر في السجلات Logs استثناء لم يتوقعه أحد، فزميله القادم من Android كان يقول له بثقة «Kotlin لا يوجد فيها NullPointerException»، والسجل يقول بوضوح java.lang.NullPointerException: findName(...) must not be null. وفي نفس الأسبوع لاحظ أحدهم أن الخدمة تستهلك ذاكرة أكثر من المتوقع عند حساب تقرير المبيعات، وأن الكود الذي يستخدم List<Int> أبطأ بكثير من كود مشابه بـ IntArray، وسأل سؤالاً منطقياً: إذا كانت Kotlin لا تفرق في الصيغة بين int وInteger كما تفعل Java، فما الذي يحدث في الذاكرة فعلاً؟
وهذان السؤالان هما موضوع هذا المقال: ما الذي يحميك منه نظام الأنواع في Kotlin وما الذي لا يحميك منه، وما الذي تحجزه الأنواع المختلفة في الذاكرة على الـ JVM، وكيف تتعامل اللغة مع الأخطاء دون استثناءات مفحوصة Checked Exceptions. ولن نكرر شرح جامع القمامة Garbage Collector من الصفر، فقد شرحنا الطرق الثلاث لإدارة الذاكرة في مقال إدارة الذاكرة يدوياً أم Garbage Collector أم Ownership، وشرحنا الجدل حول الاستثناءات في مقال كيف تتعامل لغات البرمجة مع الأخطاء، ومرحلة Java في هذا المسار تقيس جامعات القمامة نفسها بالتفصيل. وما يهمنا هنا هو ما يخص Kotlin فوق الـ JVM، وكل رقم في المقال قسناه بأنفسنا على Kotlin 2.4.21 وJDK 25، وكل استثناء هو مخرج حقيقي.
وسوف نناقش في هذا المقال ما يلي:
- شجرة الأنواع في Kotlin:
AnyوUnitوNothing، والأنواع التي تقبل null، والتحويل الذكي Smart Cast. - كيف قسنا الذاكرة بالبايت داخل البرنامج نفسه، والخطأ الذي ظهر في أول ترجمة.
- التغليف Boxing: متى يصبح
IntكائناًInteger، وكم يكلف ذلك في قائمة من مليون عنصر. - الكلاسات القيمية Value Classes، والدوال
inline، والتسلسلات Sequences، وما تحجزه كل واحدة بالأرقام. - تجربة «ماذا يحدث إذا» أطفأنا تحليل الهروب Escape Analysis في الـ JIT.
- جامع القمامة في Container بحجم 512 ميجابايت: لماذا اختارت الـ JVM جامعاً مختلفاً، وكيف يبدو التسرب Memory Leak.
- أنواع المنصة Platform Types: من أين جاء
NullPointerExceptionفي كود Kotlin. - الأخطاء في Kotlin: الاستثناءات غير المفحوصة، و
requireوcheck، وlateinit، وResult، والأنواع المغلقة، و@Throws. - جدول قرار Decision Table لطريقة إبلاغ الخطأ، وقائمة تحقق Checklist.
شجرة الأنواع Type Hierarchy: من Any إلى Nothing
إذا كنت قادماً من Java فأنت تعرف أن كل كائن يرث من Object، وأن الأنواع البدائية Primitives مثل int خارج هذه الشجرة. وإذا كنت قادماً من Python فكل شيء عندك كائن، وNone قيمة يمكن أن تكون في أي متغير. وKotlin أخذت طريقاً ثالثاً تبينه الصورة التالية:

String وString?، وNothing تحت الجميع لأنه نوع لا قيمة له- Any: أعلى الشجرة لكل ما ليس null، ويقابل
Objectفي Java، وفيه الدوالequalsوhashCodeوtoStringفقط. - Any?: فوق
Any، أي كل شيء بما فيه null. فالأنواع التي تقبل null ليست شيئاً جانبياً، وإنما كل نوعTله نوع أكبر منهT?، وStringنوع فرعي منString?، ولهذا تستطيع أن تضعStringحيث يطلبString?، والعكس يحتاج فحصاً. - Int و Double و Boolean: تكتبها كأنها كلاسات عادية، فتكتب
5.coerceAtLeast(1)مثلاً، ولكن المترجم يحولها إلى الأنواع البدائيةintوdoubleفي الـ Bytecode كلما استطاع. وتشرح صفحة Numbers القاعدة: الرقم يبقى بدائياً، إلا إذا كان نوعه يقبل null مثلInt?، أو استخدم كنوع عام Generic مثلList<Int>، فعندها يغلف Boxing في كائنjava.lang.Integer. وهذه الجملة هي موضوع تجربة الذاكرة كلها. - Unit: نوع الدوال التي لا ترجع شيئاً، ويقابل
void، ولكنه نوع حقيقي له قيمة واحدة، ولذلك تستطيع أن تكتبList<Unit>أو دالة عامة ترجعTثم تعطيها دالة ترجعUnit. - Nothing: أسفل الشجرة، وهو نوع ليس له أي قيمة، ومعناه «هذا التعبير لا يرجع أبداً». فالدالة
error("...")نوعهاNothingلأنها ترمي استثناء دائماً، وكذلكthrowوreturnكتعبيرات. ولأنNothingنوع فرعي من كل الأنواع، تستطيع أن تكتبval id = x.toIntOrNull() ?: return Bad("...")، فنوع الطرف الأيمنNothingوالنتيجةInt، وسوف ترى هذا الشكل في كود الأخطاء بعد قليل.
والتحويل الذكي Smart Cast الذي رأيناه في المقال الأول يعمل مع null أيضاً، فبعد if (name != null) يصبح نوع name داخل الفرع String وليس String?، دون أي تحويل يدوي، بشرط أن يكون المتغير val محلياً أو خاصية لا يمكن أن تتغير بين الفحص والاستخدام، وتشرح صفحة Type checks and casts هذه الشروط. والأنواع العامة Generics في Kotlin تمحى وقت التشغيل Type Erasure مثل Java تماماً، فلا تستطيع أن تسأل x is List<String>، إلا داخل دالة inline بمعامل reified كما تشرح صفحة Generics، وهذه حالة نادرة في كود الخدمات.
كيف نقيس الذاكرة بالبايت؟ ThreadMXBean
قبل أن نتحدث عن التغليف بالكلمات، نريد أن نراه بالأرقام. والطريقة الأدق داخل الـ JVM ليست مراقبة الذاكرة الكلية للعملية، فهي تتأثر بجامع القمامة وبحجم الـ Heap، وإنما سؤال الـ JVM نفسها: كم بايتاً حجز هذا الخيط Thread في الـ Heap منذ بدأ؟ وهذا ما تعطيه الدالة getThreadAllocatedBytes في الواجهة com.sun.management.ThreadMXBean، فتقرأ العداد قبل العمل وبعده، والفرق هو ما حجزه العمل بالضبط، بغض النظر عن متى سوف يجمعه جامع القمامة. والملف Alloc.kt في المختبر يغلف هذه الفكرة في دالة واحدة:
package lab
import java.lang.management.ManagementFactory
@PublishedApi
internal val threads = ManagementFactory.getThreadMXBean() as com.sun.management.ThreadMXBean
// عدد البايتات التي حجزها الخيط الحالي في الـ Heap أثناء تنفيذ block، والنتيجة حتى لا يحذفها الـ JIT
inline fun <T> measure(label: String, block: () -> T): T {
val id = Thread.currentThread().threadId()
val before = threads.getThreadAllocatedBytes(id)
val t0 = System.nanoTime()
val r = block()
val ms = (System.nanoTime() - t0) / 1_000_000.0
val bytes = threads.getThreadAllocatedBytes(id) - before
println("%-44s %,14d bytes %8.1f ms".format(label, bytes, ms))
return r
}وجعلنا الدالة measure نفسها inline حتى لا تضيف هي أي حجز إلى ما نقيسه، والسبب سوف يتضح في قسم الدوال inline. ولكن أول ترجمة لهذا الملف فشلت، وهذه هي المشكلة الأولى التي ظهرت لنا:
e: file:///w/src/main/kotlin/lab/Alloc.kt:10:18 Public-API inline function cannot access non-public-API property.
e: file:///w/src/main/kotlin/lab/Alloc.kt:14:17 Public-API inline function cannot access non-public-API property.
> Task :compileKotlin FAILED
> Kotlin compiler: NON_PUBLIC_CALL_FROM_PUBLIC_INLINEوكنا قد كتبنا المتغير threads بالكلمة private. والسبب في رفض المترجم منطقي جداً عندما تفهم ما تفعله inline: جسم الدالة ينسخ حرفياً في كل مكان يستدعيها، ولو كان المستدعي في ملف آخر أو في مكتبة أخرى فسوف يحاول الوصول إلى متغير private لا يراه، وهذا يخالف قواعد الوصول وقت التشغيل. والحل الذي تقترحه صفحة Inline functions هو العلامة @PublishedApi internal: المتغير يبقى غير ظاهر لكود Kotlin خارج الوحدة، ولكنه في الـ Bytecode متاح للكود المنسوخ. وهذه أول إشارة إلى أن inline ليست مجرد تحسين يضيفه المترجم، وإنما تغير شكل الكود الناتج فعلاً.
وطريقة التشغيل بسيطة: نشغل كل قياس ثلاث مرات في نفس العملية، ونطبع الجولة الثالثة فقط، لأن الجولة الأولى فيها تحميل الكلاسات، والمترجم أثناء التشغيل JIT لم يترجم الكود بعد إلى كود الآلة، وقد شرحنا مراحل الإحماء Warm-up في مقال المترجم والمفسر وJIT. وكل قياس يعمل على مليون عنصر، والكود الكامل في lab/src/main/kotlin/lab/Main.kt.
التغليف Boxing: متى يصبح Int كائناً؟
الدوال الثلاث الأولى في التجربة تفعل نفس الشيء بالضبط، وهو أن تملأ مليون رقم ثم تجمعها، والفرق الوحيد هو نوع الحاوية:
const val N = 1_000_000
fun sumIntArray(): Long { val a = IntArray(N) { it }; var s = 0L; for (x in a) s += x; return s }
fun sumListInt(): Long { val a = List(N) { it }; var s = 0L; for (x in a) s += x; return s }
fun sumArrayNullable(): Long { val a = arrayOfNulls<Int>(N); for (i in 0 until N) a[i] = i; var s = 0L; for (x in a) s += x!!; return s }وهذه الأسطر الثلاثة من مخرج التشغيل:
IntArray of 1M 4,000,200 bytes 14.5 ms
List<Int> of 1M (boxed Integer) 19,998,016 bytes 71.4 ms
Array<Int?> of 1M 19,997,992 bytes 30.2 msفي المخرج أعلاه لاحظ التالي:
IntArrayحجزت 4,000,200 بايت، أي 4 بايتات لكل رقم، و200 بايت تقريباً لرأس المصفوفة وما حولها. وهذا هوint[]في Java بالضبط، والمترجم لم يضف شيئاً.List<Int>حجزت قرابة 20 مليون بايت، أي خمسة أضعاف، لنفس الأرقام. والسبب أن القائمة العامة لا تستطيع أن تحملint، فكل رقم صار كائنIntegerفي الـ Heap، وكل كائن يأخذ 16 بايتاً على JVM 64 بت (رأس الكائن 12 بايتاً والقيمة 4)، وفوقها مصفوفة المراجع داخلArrayListبـ 4 بايتات لكل مرجع. والحساب 16 × 1,000,000 + 4 × 1,000,000 يعطي 20 مليوناً، وهو ما قسناه تقريباً.- والفرق البسيط بين 20,000,000 و19,998,016 له سبب جميل: الـ JVM تحتفظ بذاكرة Cache لكائنات
Integerمن -128 إلى 127، فالأرقام من 0 إلى 127 لم تحجز كائنات جديدة، و128 × 16 = 2,048 بايتاً تقريباً هي الفرق. Array<Int?>حجزت نفس الحجم تقريباً لأنInt?يجب أن يحمل null، والنوع البدائيintلا يحمل null، فلا يوجد حل إلا الكائن.- والزمن يتبع الذاكرة، فالقائمة أبطأ من المصفوفة بخمس مرات تقريباً في هذا التشغيل، لأن كل رقم يحتاج حجزاً ثم قراءة عبر مرجع إلى مكان آخر في الذاكرة.
والصورة التالية تجمع كل نتائج التجربة في مكان واحد، وسوف نمر عليها سطراً سطراً في الأقسام التالية:

المقارنة بالمرجع Identity Equality: لماذا 127 تختلف عن 128؟
ولذاكرة Integer المؤقتة أثر جانبي مشهور في Java، فهل يظهر في Kotlin؟ الدالة التالية تقارن رقمين من النوع Int? بالمحتوى == وبالمرجع ===:
fun identity() {
val a: Int? = 127; val b: Int? = 127
val c: Int? = 128; val d: Int? = 128
println("127: a == b -> ${a == b}, a === b -> ${a === b}")
println("128: c == d -> ${c == d}, c === d -> ${c === d}")
}127: a == b -> true, a === b -> true
128: c == d -> true, c === d -> falseفالمقارنة بالمحتوى == صحيحة في الحالتين، وهذا ما تريده دائماً. أما المقارنة بالمرجع فأعطت true للرقم 127 لأن الكائنين هما نفس الكائن من الذاكرة المؤقتة، وfalse للرقم 128 لأنهما كائنان مختلفان بنفس القيمة. والفرق عن Java أن هذا الخطأ صعب الوقوع في Kotlin، لأن == هي العلامة الطبيعية، ولأن المترجم K2 حذرنا عند البناء بهذا السطر:
w: file:///w/src/main/kotlin/lab/Main.kt:55:53 Identity equality for arguments of types 'Int?' and 'Int?' is prohibited.
w: file:///w/src/main/kotlin/lab/Main.kt:56:53 Identity equality for arguments of types 'Int?' and 'Int?' is prohibited.ولاحظ أن الحرف في بداية السطر w: أي تحذير Warning، رغم أن النص يقول «prohibited»، فالبناء نجح. وتشرح صفحة Equality أن المقارنة بالمرجع للأنواع التي تمثل بأنواع بدائية غير مستقرة، فالقاعدة بسيطة: لا تستخدم === مع الأرقام أبداً، واجعل التحذيرات أخطاء في خط CI بالخيار allWarningsAsErrors حتى لا يمر مثل هذا السطر.
الكلاسات القيمية Value Classes: نوع جديد دون كائن جديد
والآن مشكلة يعرفها كل من كتب نظاماً مالياً: الدالة vatHalalas(subtotal: Int) تأخذ رقماً، ولا شيء يمنع أحداً من أن يعطيها عدد القطع بدل المبلغ، أو المبلغ بالريال بدل الهللات. والحل المعروف أن تعطي كل مفهوم نوعه الخاص، فتكتب كلاساً اسمه Halalas، ولكن الثمن في Java وفي Kotlin العادية كائن جديد لكل مبلغ. وKotlin تقدم حلاً ثالثاً اسمه الكلاس القيمي Value Class، تشرحه صفحة Inline value classes: كلاس بخاصية واحدة، يعامله المترجم كنوع مستقل في الفحص، ويحذفه في الـ Bytecode ويضع مكانه القيمة نفسها. وكتبنا النوعين لنقارن:
@JvmInline
value class Halalas(val value: Int) {
operator fun plus(o: Halalas) = Halalas(value + o.value)
}
data class BoxedHalalas(val value: Int) {
operator fun plus(o: BoxedHalalas) = BoxedHalalas(value + o.value)
}
fun sumValueClass(): Int { var s = Halalas(0); for (i in 0 until N) s += Halalas(i and 1023); return s.value }
fun sumDataClass(): Int { var s = BoxedHalalas(0); for (i in 0 until N) s += BoxedHalalas(i and 1023); return s.value }
fun listOfValueClass(): Int = List(N) { Halalas(it and 1023) }.sumOf { it.value }value class Halalas, 1M additions 16 bytes 0.6 ms
data class BoxedHalalas, 1M additions 16,000,032 bytes 167.6 ms
List<Halalas> of 1M (boxed again) 20,000,056 bytes 90.2 msمليون عملية جمع بالكلاس القيمي حجزت 16 بايتاً فقط، أي لا شيء تقريباً، بينما نفس العمل بالـ data class حجز 16 مليون بايت، أي كائناً من 16 بايتاً لكل جمع، واستغرق 167 جزءاً من الألف من الثانية بدل جزء واحد تقريباً. ولكي نفهم لماذا، فتحنا الكلاس المترجم بالأداة javap:
public final class lab.Halalas {
private final int value;
public final int getValue();
public static final int plus-nsUSydU(int, int);
public static java.lang.String toString-impl(int);
public static int hashCode-impl(int);
public static boolean equals-impl(int, java.lang.Object);
private lab.Halalas(int);
public static int constructor-impl(int);
public static final lab.Halalas box-impl(int);
public final int unbox-impl();
public static final boolean equals-impl0(int, int);
}في المخرج أعلاه لاحظ التالي:
- الدالة
plusصارتstaticتأخذintوترجعint، واسمهاplus-nsUSydUوليسplus. وهذا التشويه في الاسم Name Mangling مقصود، حتى لا تتصادم الدالة مع دالة أخرى تأخذintعادياً، وحتى لا يستدعيها كود Java بالخطأ بمبلغ ليس من النوعHalalas. constructor-implترجعintوليس كائناً، فإنشاءHalalas(5)في الكود الناتج هو الرقم 5 بعد التحقق.- الدالتان
box-implوunbox-implموجودتان لأن الكائن الحقيقي ما زال ممكناً، وهذا ما يفسر السطر الثالث في المخرج.
والكود المترجم للدالة sumValueClass يؤكد ذلك، فكل ما فيه عمليات على int:
public static final int sumValueClass();
Code:
0: iconst_0
1: invokestatic #75 // Method lab/Halalas."constructor-impl":(I)I
4: istore_0
...
19: invokestatic #75 // Method lab/Halalas."constructor-impl":(I)I
22: invokestatic #79 // Method lab/Halalas."plus-nsUSydU":(II)I
25: istore_0أما السطر الثالث في القياس، List<Halalas>، فحجز 20 مليون بايت، تماماً مثل List<Int>. والسبب نفس قاعدة التغليف: القائمة العامة تحمل كائنات، فكل Halalas داخلها غلف في كائن عبر box-impl. فالكلاس القيمي مجاني في المتغيرات والمعاملات والقيم المرجعة، ويعود له ثمنه عندما تضعه في List أو Map أو تجعله Halalas? أو تمرره كـ Any. والقاعدة العملية لخدمة الطلبات: استخدم الكلاسات القيمية للمبالغ والمعرفات في توقيعات الدوال لأنها تمنع خلط الأنواع دون ثمن، ولا تتوقع أنها توفر الذاكرة في القوائم الكبيرة.
الدوال inline والدوال المجهولة Lambdas: من يحجز الكائن؟
وفي Kotlin تكتب الدوال المجهولة Lambdas في كل مكان: filter { } وmap { } وrepeat { } وuse { }. وكل Lambda على الـ JVM يمكن أن تصبح كائناً، والسؤال هو متى. كتبنا دالتين من الدرجة الأعلى Higher-Order Functions، متطابقتين تماماً، والفرق الوحيد الكلمة inline، ثم استدعينا كل واحدة مليون مرة بدالة مجهولة تعدل متغيراً محلياً:
fun <T> repeatPlain(times: Int, action: (Int) -> T) { for (i in 0 until times) action(i) }
inline fun <T> repeatInline(times: Int, action: (Int) -> T) { for (i in 0 until times) action(i) }
fun capturePlain(): Long { var total = 0L; repeatPlain(N) { total += it }; return total }
fun captureInline(): Long { var total = 0L; repeatInline(N) { total += it }; return total }lambda capturing var, plain HOF 119,856 bytes 1.0 ms
lambda capturing var, inline HOF 24 bytes 0.4 msوالنتيجة أقل دراماتيكية مما توقعنا: الدالة العادية حجزت قرابة 120 كيلوبايت، والدالة inline 24 بايتاً. ولكن الأهم هو الكود المترجم، فهو يشرح ما كان سيحدث وما منعه:
public static final long capturePlain();
Code:
0: new #127 // class kotlin/jvm/internal/Ref$LongRef
3: dup
4: invokespecial #130 // Method kotlin/jvm/internal/Ref$LongRef."<init>":()V
7: astore_0
8: ldc #7 // int 1000000
10: aload_0
11: invokedynamic #148, 0 // InvokeDynamic #0:invoke:(Lkotlin/jvm/internal/Ref$LongRef;)Lkotlin/jvm/functions/Function1;
16: invokestatic #150 // Method repeatPlain:(ILkotlin/jvm/functions/Function1;)V
19: aload_0
20: getfield #153 // Field kotlin/jvm/internal/Ref$LongRef.element:J
23: lreturn
public static final long captureInline();
Code:
0: lconst_0
1: lstore 5
...
20: lload 5
22: iload_3
23: i2l
24: ladd
25: lstore 5في الكود أعلاه لاحظ التالي:
- في النسخة العادية حول المترجم المتغير المحلي
totalإلى كائنRef$LongRefفي الـ Heap، لأن الدالة المجهولة تحتاج أن تعدله وهي كائن منفصل عن الدالة، والمتغيرات المحلية في الـ JVM لا يمكن مشاركتها بين دالتين. ثم أنشأ كائن الدالة المجهولة بالتعليمةinvokedynamicمن نوعFunction1، ومرره إلىrepeatPlain. والنوعFunction1عام Generic، فالمعاملIntيمر إليه مغلفاًIntegerفي كل استدعاء. - في النسخة
inlineلا يوجدRefولاFunction1ولا استدعاء أصلاً: جسم الحلقة نسخ داخلcaptureInline، وtotalبقي متغيراً محلياً من النوعlongفي الخانة 5، والجمع تعليمةladdمباشرة. فالـ 24 بايتاً هي حجز القياس نفسه وليس الكود.
إذن لماذا لم تحجز النسخة العادية 16 مليون بايت لمليون Integer؟ وهنا يأتي دور المترجم أثناء التشغيل JIT، والتجربة التالية تجيب عن السؤال.
ماذا يحدث إذا أطفأنا تحليل الهروب Escape Analysis؟
المترجم C2 في الـ JVM يفحص كل كائن ينشأ داخل دالة بعد ترجمتها: هل يهرب هذا الكائن من الدالة، أي هل يخزن في مكان يراه كود آخر أو يرجع منها؟ فإذا لم يهرب، يستطيع أن يحذفه ويضع حقوله في المسجلات Registers، وهذا اسمه تحليل الهروب Escape Analysis واستبدال الحقول Scalar Replacement. ولنرى أثره شغلنا نفس البرنامج مرة ثانية بالخيار -XX:-DoEscapeAnalysis الذي يطفئه:
JAVA_OPTS="-XX:-DoEscapeAnalysis" build/install/types-memory/bin/types-memory allocIntArray of 1M 4,000,200 bytes 3.3 ms
List<Int> of 1M (boxed Integer) 19,998,048 bytes 29.5 ms
Array<Int?> of 1M 19,997,992 bytes 436.2 ms
value class Halalas, 1M additions 16 bytes 0.3 ms
data class BoxedHalalas, 1M additions 32,000,032 bytes 11.5 ms
List<Halalas> of 1M (boxed again) 20,000,088 bytes 171.5 ms
lambda capturing var, plain HOF 15,998,016 bytes 12.5 ms
lambda capturing var, inline HOF 24 bytes 0.9 ms
map/filter/take on List 34,907,880 bytes 94.0 ms
map/filter/take on Sequence 224 bytes 2.6 msوالآن ظهر ما كان مخفياً:
- الدالة العادية مع Lambda حجزت 15,998,016 بايتاً، أي كائن
Integerلكل استدعاء تقريباً، فالمعامل يغلف ليمر عبرFunction1. ومع تحليل الهروب كان الـ JIT قد ألغى أغلب هذه الكائنات لأنها لا تخرج من الحلقة. - الـ
data classحجزت 32 مليون بايت بدل 16، أي كائنين لكل جمع:BoxedHalalas(i and 1023)المؤقت ونتيجةplus. ومع التحليل كان الكائن المؤقت يحذف، وتبقى النتيجة لأنها تنتقل من دورة إلى أخرى في المتغيرs. - الكلاس القيمي والدالة
inlineوالـIntArrayلم يتغير فيها شيء، لأن الحذف حدث وقت الترجمة فيkotlincوليس في الـ JIT.
والدرس العملي من هذه التجربة مهم: لا تعتمد على الـ JIT ليصلح ما تستطيع أن تصلحه في الكود، فتحليل الهروب يعمل فقط بعد أن تصبح الدالة ساخنة وتترجم بـ C2، ويعمل داخل حدود ما يستطيع أن يراه، ولا يعمل في الجولة الأولى ولا في دالة كبيرة لم تدمج Inlining. أما inline والكلاسات القيمية فتعطيك نفس النتيجة من أول استدعاء وفي كل بيئة. ولهذا السبب بالضبط جعلت مكتبة Kotlin الأساسية دوال القوائم مثل map وfilter وforEach كلها inline.
القوائم List مقابل التسلسلات Sequence: من يحجز 35 ميجابايت؟
والسطران الأخيران في التجربة يقارنان سلسلة عمليات على قائمة بنفس السلسلة على تسلسل Sequence:
fun chainList(): Int = (0 until N).map { it * 2 }.filter { it % 3 == 0 }.take(10).sum()
fun chainSequence(): Int = (0 until N).asSequence().map { it * 2 }.filter { it % 3 == 0 }.take(10).sum()map/filter/take on List 34,907,848 bytes 78.4 ms
map/filter/take on Sequence 224 bytes 0.1 msوالفرق هنا ليس في الأنواع وإنما في طريقة التنفيذ، وتشرحها صفحة Sequences: كل دالة على القائمة تنفذ فوراً Eager وتنشئ قائمة جديدة كاملة، فـ map أنشأت قائمة من مليون Integer مغلف، وfilter أنشأت قائمة ثانية بثلثها، ثم أخذنا عشرة عناصر فقط ورمينا الباقي. أما التسلسل فكسول Lazy، يمرر كل عنصر عبر السلسلة كلها ثم ينتقل للتالي، ويتوقف عند العنصر العاشر، فحجز 224 بايتاً لكائنات التسلسل نفسها. وهذا يشبه الفرق بين قائمة [...] ومولد (...) في Python، أو بين Stream في Java وقائمة عادية. ولا تجعل كل شيء Sequence، فللتسلسل تكلفة ثابتة لكل عنصر، وهو أبطأ من القائمة في السلاسل القصيرة على مجموعات صغيرة، والقاعدة: استخدمه عندما تكون المجموعة كبيرة والسلسلة فيها أكثر من خطوة، أو عندما تتوقف مبكراً بـ take أو first.
من يدير الذاكرة؟ جامع القمامة Garbage Collector في Container
وكل ما حجزناه في التجارب السابقة يجمعه جامع القمامة في الـ JVM، فـ Kotlin لا تضيف أي إدارة ذاكرة خاصة بها على الـ JVM، وكل ما تعرفه عن جامعات Java ينطبق كما هو. ولذلك سوف نكتفي هنا بتجربتين لهما علاقة مباشرة بخدمتنا، وهي سوف تعمل في Container محدود بـ 512 ميجابايت ومعالجين، كما في مقال الخدمة الحقيقية. شغلنا نفس البرنامج مرتين بالخيار -Xlog:gc: مرة في Container بلا حدود على جهاز فيه 8 معالجات و32 جيجابايت، ومرة بالخيارين --memory 512m --cpus 2، ثم طبعنا ما اختارته الـ JVM بنفسها:
java -XX:+PrintFlagsFinal -version | grep -E ' (UseG1GC|UseSerialGC|UseZGC|MaxHeapSize|InitialHeapSize|MaxRAMPercentage|ActiveProcessorCount) 'وفي الـ Container المحدود كان المخرج كما يلي:
int ActiveProcessorCount = -1 {product} {default}
size_t InitialHeapSize = 8388608 {product} {ergonomic}
size_t MaxHeapSize = 134217728 {product} {ergonomic}
double MaxRAMPercentage = 25.000000 {product} {default}
bool UseG1GC = false {product} {default}
bool UseSerialGC = true {product} {ergonomic}
bool UseZGC = false {product} {default}وهذا المخرج فيه مفاجأتان يجب أن يعرفهما كل من يشغل خدمة JVM في Container صغير:
UseSerialGC = trueبالقيمة{ergonomic}، أي أن الـ JVM اختارت جامع القمامة التسلسلي Serial بنفسها، وليس G1 الذي اختارته في الـ Container الكبير. والسبب مكتوب في صفحة Ergonomics من وثائق JDK 25: الـ JVM تعد الجهاز «من فئة السيرفرات» Server-Class إذا وجدت معالجين أو أكثر وذاكرة 1792 ميجابايت أو أكثر، وإلا اختارت Serial. والـ 512 ميجابايت أقل من الحد، فجامعنا صار جامعاً بخيط واحد يوقف البرنامج كله أثناء العمل.MaxHeapSize = 134217728، أي 128 ميجابايت فقط من 512، لأن القيمة الافتراضية للخيارMaxRAMPercentageهي 25%. فخدمة في Container بنصف جيجابايت تستخدم ربع الذاكرة للـ Heap افتراضياً، وتترك الباقي فارغاً. وفي الـ Container الكبير كانت القيمة 8,401,190,912 بايتاً، أي ربع ذاكرة الجهاز.
والأثر ظاهر في سجل جامع القمامة لنفس تجربة الحجز. ففي الـ Container الكبير بدأ السجل بالسطر Using G1، ووصل الترقيم إلى GC(5)، أي ست دورات فقط، بينما في الـ Container المحدود وصل الترقيم إلى GC(39)، أي أربعين دورة، منها توقف كامل بهذا الشكل:
[2.527s][info][gc] GC(39) Pause Full (Allocation Failure) 109M->17M(118M) 205.006msوالسطر يقرأ هكذا: في الثانية 2.527 من عمر العملية، الدورة رقم 39 كانت توقفاً كاملاً Full Pause بسبب فشل الحجز، فنزل الـ Heap المستخدم من 109 إلى 17 ميجابايت، وحجمه الكلي 118، واستغرقت 205 أجزاء من الألف من الثانية توقف فيها البرنامج تماماً. وفي خدمة ويب يعني هذا أن كل الطلبات التي وصلت في تلك اللحظة انتظرت خمس الثانية. وأرقام التوقف في التجربتين أعلى مما تراه عادة، لأن الجهاز كان يشغل تجارب أخرى في نفس الوقت، فخذها كمقارنة بين الحالتين وليس كقيمة مطلقة. والحل الذي سوف نستخدمه في الخدمة هو أن نحدد -XX:MaxRAMPercentage=75 في متغير البيئة، وأن نعرف أن الجامع Serial في هذا الحجم، فإذا كان التوقف غير مقبول فنختار جامعاً بأنفسنا بالخيار -XX:+UseG1GC ونقيس مرة أخرى. وتشرح وثائق HotSpot GC Tuning Guide كل جامع وخياراته.

التسرب Memory Leak في لغة فيها جامع قمامة
وجامع القمامة لا يجمع إلا ما لا يشير إليه أحد، فإذا احتفظت بمرجع إلى شيء لم تعد تحتاجه فهو تسرب، حتى في Kotlin. والمثال الكلاسيكي في الخدمات ذاكرة مؤقتة Cache بلا حد أقصى، وهذا ما كتبناه عمداً:
fun leak() {
val cache = HashMap<Int, ByteArray>() // كاش بلا حد أقصى: كل طلب يضيف 1 MB ولا يحذف شيئاً
var i = 0
while (true) { cache[i] = ByteArray(1 shl 20); i++; if (i % 16 == 0) println("cached $i MB") }
}وشغلناه بـ Heap أقصاه 64 ميجابايت مع سجل الجامع، والجزء الأخير من المخرج في الـ Container المحدود كان كما يلي:
cached 48 MB
[0.364s][info][gc] GC(22) Pause Full (Allocation Failure) 58M->57M(61M) 16.474ms
[0.369s][info][gc] GC(23) Pause Full (Allocation Failure) 58M->58M(61M) 4.055ms
[0.415s][info][gc] GC(24) Pause Full (Allocation Failure) 58M->58M(61M) 46.814ms
[0.429s][info][gc] GC(25) Pause Full (Allocation Failure) 59M->59M(61M) 13.215ms
[0.433s][info][gc] GC(26) Pause Full (Allocation Failure) 59M->59M(61M) 4.114ms
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at lab.MainKt.leak(Main.kt:72)
at lab.MainKt.main(Main.kt:80)والعلامة التي تميز التسرب في السجل هي الأرقام قبل السهم وبعده: 58M->58M و59M->59M، أي أن الجامع يعمل دورة كاملة ولا يحرر شيئاً، لأن كل البايتات ما زالت مرتبطة بالـ HashMap. والدورات تتقارب حتى تصبح كل بضعة أجزاء من الألف من الثانية، ثم يرمي البرنامج OutOfMemoryError. وإذا رأيت هذا النمط في سجل خدمة في الإنتاج، فالمشكلة ليست في حجم الـ Heap وإنما في مرجع لا يتحرر، وزيادة الذاكرة تؤخر الانهيار فقط. ولكي تعرف من يحمل الذاكرة شغلنا التجربة مرة ثالثة بالخيار -XX:+HeapDumpOnOutOfMemoryError:
java.lang.OutOfMemoryError: Java heap space
Dumping heap to /w/leak.hprof ...
Heap dump file created [65295219 bytes in 1.127 secs]فكتبت الـ JVM صورة كاملة للـ Heap في ملف حجمه 65 ميجابايت لحظة الانهيار، وتفتحه بأداة مثل Eclipse MAT أو VisualVM فترى أن كائن HashMap واحداً يحمل كل الذاكرة. وهذا الخيار نضعه في كل خدمة إنتاج، مع مسار على Volume له مساحة كافية، ومعه -XX:+ExitOnOutOfMemoryError حتى تخرج العملية فيعيد الـ Container تشغيلها بدل أن تبقى حية في حالة غير معروفة. وتشرح وثائق Troubleshooting Guide في JDK 25 هذه الخيارات.
أنواع المنصة Platform Types: من أين جاء NullPointerException؟
والآن نعود إلى الاستثناء الذي بدأ به المقال. فالمكتبة القديمة مكتوبة بـ Java، وفيها كلاس بسيط يرجع اسم المنتج من رقمه، ويرجع null إذا لم يجده، دون أي علامة تقول ذلك:
public class LegacyCatalog {
private final Map<Integer, String> names = new HashMap<>();
public LegacyCatalog() {
names.put(1, "قهوة عربية");
names.put(2, "تمر سكري");
}
public String findName(int id) { // يرجع null إذا لم يجد المنتج
return names.get(id);
}
}ومن جهة Kotlin، المترجم لا يعرف هل findName قد ترجع null، فيعطي نتيجتها نوعاً خاصاً اسمه نوع المنصة Platform Type ويكتب في الرسائل String!، وتشرحه صفحة Calling Java from Kotlin. ومعنى هذا النوع «أنا لا أعرف، والقرار لك»: تستطيع أن تعامله كـ String أو كـ String?، والمترجم يقبل الاثنين. والدالة التالية جربت الطرق الثلاث:
fun platform() {
val catalog = LegacyCatalog()
val n1 = catalog.findName(1) // النوع String! : المترجم لا يعرف هل هو null
println("findName(1).length = ${n1.length}")
val n9: String? = catalog.findName(9) // نحدد نحن أنه قد يكون null
println("findName(9) as String? = ${n9 ?: "غير موجود"}")
val n7: String = catalog.findName(7) // نعده String غير null: Kotlin تضيف فحصاً هنا
println("never printed $n7")
}findName(1).length = 10
findName(9) as String? = غير موجود
Exception in thread "main" java.lang.NullPointerException: findName(...) must not be null
at lab.MainKt.platform(Main.kt:65)
at lab.MainKt.main(Main.kt:79)في المخرج أعلاه لاحظ التالي:
- السطر الأول نجح لأن المنتج 1 موجود، ولكن الكود نفسه خطير:
n1نوعهString!، واستدعاءlengthعليه دون فحص كان سيرميNullPointerExceptionعادياً لو كان الرقم غير موجود. - السطر الثاني هو الطريقة الصحيحة: نكتب النوع
String?صراحة عند الحدود مع Java، فيعود المترجم إلى حمايتنا ويجبرنا على?:. - السطر الثالث هو ما حدث في خدمتنا: كتبنا النوع
Stringغير الفارغ، فأضاف المترجم فحصاً عند لحظة الإسناد، ورمى الاستثناء برسالة تذكر اسم الدالةfindName(...) must not be nullورقم السطر 65. وهذا أفضل بكثير من Java، لأن الاستثناء ظهر عند الحدود مع كود Java وليس بعد عشر دوال عندما يستخدم أحدهم القيمة، ولكنه ما زال استثناء في الإنتاج.
والقاعدة لأي خدمة Kotlin تستدعي كود Java: كل قيمة تعبر الحدود تأخذ نوعاً صريحاً، و? إذا لم تكن متأكداً. وأفضل من ذلك أن تضيف إلى كود Java علامات @Nullable و@NonNull من مكتبة يفهمها مترجم Kotlin، مثل JSpecify، فيصبح النوع String? أو String بدل String!، وتنتقل الحماية من التشغيل إلى الترجمة. وكثير من مكتبات Spring وJDK الحديثة صارت تحمل هذه العلامات.

String! يترك القرار لكالأخطاء في Kotlin: استثناءات غير مفحوصة Unchecked Exceptions
إذا كنت قادماً من Java فأول ما تلاحظه أن Kotlin لا يوجد فيها استثناءات مفحوصة Checked Exceptions، فلا تجبرك على try ولا على throws في أي دالة، وكل الاستثناءات عندها مثل RuntimeException. وهذا قرار مقصود تشرحه صفحة Exceptions، وقد ناقشنا أسبابه والجدل حوله في مقال الأخطاء، فلن نكرره. وإذا كنت قادماً من Python فالأمر مألوف لك، مع فرق واحد: اللغة تعطيك أدوات لتجعل كثيراً من الأخطاء جزءاً من النوع بدل أن تكون استثناءات. وسوف نمر على هذه الأدوات بمثال واحد يتكرر في كل خدمة: قراءة سطر طلب نصي بالشكل id:qty.
data class OrderLine(val productId: Int, val qty: Int)
// require يرمي IllegalArgumentException للمدخلات، و check يرمي IllegalStateException لحالة الكائن
fun parseLine(raw: String): OrderLine {
val parts = raw.split(":")
require(parts.size == 2) { "expected id:qty but got '$raw'" }
val qty = parts[1].toInt() // يرمي NumberFormatException إذا لم يكن رقماً
require(qty > 0) { "qty must be positive, got $qty" }
return OrderLine(parts[0].toInt(), qty)
}الدوال require، check، error: الشروط المسبقة Preconditions
وأول تجربة أعطت الدالة كمية صفر:
Exception in thread "main" java.lang.IllegalArgumentException: qty must be positive, got 0
at lab.ErrorsKt.parseLine(Errors.kt:13)
at lab.ErrorsKt.errors(Errors.kt:43)
at lab.MainKt.main(Main.kt:81)والدالة require(شرط) { رسالة } هي طريقة Kotlin لكتابة الشرط المسبق Precondition على المدخلات، فإذا لم يتحقق الشرط رمت IllegalArgumentException بالرسالة، والرسالة داخل Lambda حتى لا يبنى النص إلا عند الفشل. ومعها أختها check التي ترمي IllegalStateException للتحقق من حالة الكائن نفسه، مثل «لا تستطيع أن ترسل طلباً ألغي»، وerror("...") التي ترمي دائماً ونوعها Nothing. وهذا التقسيم مفيد في الخدمات: IllegalArgumentException تعني أن المستدعي أخطأ، فتحولها إلى رد 400، وIllegalStateException تعني أن الكود نفسه في حالة غير متوقعة، فهي 500 وتستحق تنبيهاً. والتجربة الثانية أعطت كمية نصية:
Exception in thread "main" java.lang.NumberFormatException: For input string: "three"
at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
at java.base/java.lang.Integer.parseInt(Integer.java:565)
at java.base/java.lang.Integer.parseInt(Integer.java:662)
at lab.ErrorsKt.parseLine(Errors.kt:12)ولاحظ أن الدالة toInt() في Kotlin هي Integer.parseInt من Java تحت الغطاء، كما يظهر في السطرين الثاني والثالث من مسار الاستدعاء Stack Trace. ومسار الاستدعاء نفسه يقرأ كما في Java تماماً، من الأعلى حيث حدث الخطأ إلى الأسفل حيث بدأ الاستدعاء، وقد شرحنا قراءته في مقال قراءة رسالة الخطأ Stack Trace.
القيمة null كخطأ: toIntOrNull والنوع المغلق Sealed Result
والطريقة الأكثر شيوعاً في كود Kotlin الحديث للأخطاء المتوقعة، أي الأخطاء التي هي جزء من عمل الدالة الطبيعي مثل مدخل غير صحيح، ألا ترمي استثناء أصلاً. فالمكتبة الأساسية فيها toIntOrNull() بجانب toInt()، وfirstOrNull() بجانب first()، وgetOrNull(i) بجانب [i]. والنسخة الآمنة من دالتنا تستخدم نوعاً مغلقاً Sealed يصف كل النتائج الممكنة:
sealed interface ParseResult {
data class Ok(val line: OrderLine) : ParseResult
data class Bad(val reason: String) : ParseResult
}
fun parseLineSafe(raw: String): ParseResult {
val parts = raw.split(":")
if (parts.size != 2) return ParseResult.Bad("expected id:qty")
val id = parts[0].toIntOrNull() ?: return ParseResult.Bad("id is not a number")
val qty = parts[1].toIntOrNull() ?: return ParseResult.Bad("qty is not a number")
if (qty <= 0) return ParseResult.Bad("qty must be positive")
return ParseResult.Ok(OrderLine(id, qty))
}1:2 -> Ok(line=OrderLine(productId=1, qty=2))
1:x -> Bad(reason=qty is not a number)
7 -> Bad(reason=expected id:qty)ولاحظ السطر ?: return ParseResult.Bad(...)، فهذا هو النوع Nothing الذي تحدثنا عنه في أول المقال: return تعبير نوعه Nothing، ولذلك يقبله المترجم كطرف أيمن لعامل إلفيس، ويصبح نوع id هو Int وليس Int?. والفرق بين هذا الأسلوب والاستثناءات أن المستدعي لا يستطيع أن ينسى الخطأ، فالتعبير when (result) يجبره على حالتي Ok وBad كما رأينا في المقال الأول، ولا يوجد مسار مخفي يقفز عبر عشر دوال. وهذا قريب من Result في Rust، ومن أسلوب Go في إرجاع الخطأ، ولكن بنوع تختاره أنت.
الكلاس Result والدالة runCatching
وفي المكتبة الأساسية كلاس جاهز اسمه Result، ومعه الدالة runCatching التي تنفذ كوداً وتمسك أي استثناء وتعيده كقيمة:
val r = runCatching { parseLine("1:x") }
println("runCatching: isFailure=${r.isFailure}, ${r.exceptionOrNull()}")
println("getOrElse: ${r.getOrElse { OrderLine(0, 0) }}")runCatching: isFailure=true, java.lang.NumberFormatException: For input string: "x"
getOrElse: OrderLine(productId=0, qty=0)وrunCatching مريحة عند الحدود مع مكتبات ترمي استثناءات، ولكن احذر منها في موضعين: الأول أنها تمسك كل شيء من النوع Throwable، بما فيه OutOfMemoryError وأخطاء البرمجة الحقيقية، فتخفيها كأنها مدخل غير صحيح. والثاني أنها تمسك CancellationException التي تستخدمها الروتينات المساعدة Coroutines لإلغاء العمل، فتعطل آلية الإلغاء، وهذه من أشهر الأخطاء في كود Kotlin غير المتزامن وسوف نجربها في مقال الأخطاء الشائعة من هذه المرحلة. ولهذا السبب نفضل في كود الخدمة نوعاً مغلقاً خاصاً مثل ParseResult، فهو يصف أخطاء العمل بأسمائها، ويترك الاستثناءات لما هو استثنائي فعلاً.
try كتعبير Expression، و lateinit، والعلامة !!
وكما أن if وwhen تعبيرات، فإن try تعبير يرجع قيمة أيضاً:
val qty = try { "12a".toInt() } catch (e: NumberFormatException) { println("caught: ${e.message}"); 0 }
println("try is an expression, qty = $qty")caught: For input string: "12a"
try is an expression, qty = 0وهذا يغنيك عن نمط Java المعروف حيث تعرف المتغير قبل try بقيمة مؤقتة ثم تعيد إسناده في الداخل. والطريقتان الأخيرتان في التجربة هما الطريقتان اللتان تعيدان NullPointerException إلى كود Kotlin من الباب الخلفي. الأولى هي العلامة !!:
val name: String? = LegacyCatalog().findName(9); println(name!!.length)Exception in thread "main" java.lang.NullPointerException
at lab.ErrorsKt.errors(Errors.kt:42)ولاحظ أن الاستثناء هنا بلا رسالة أصلاً، بخلاف رسالة نوع المنصة findName(...) must not be null، فلا يقول لك ما الذي كان null، وعليك أن تذهب إلى السطر 42 وتخمن. والثانية هي الخاصية lateinit، وهي وعد للمترجم بأنك سوف تعطي الخاصية قيمة قبل أن تقرأها، وتستخدم كثيراً مع أطر حقن الاعتماديات Dependency Injection والاختبارات، كما تشرح صفحة Properties:
class Inventory {
lateinit var warehouse: String // نعد المترجم أننا سوف نعطيها قيمة قبل القراءة
fun describe() = "warehouse = $warehouse"
}Exception in thread "main" kotlin.UninitializedPropertyAccessException: lateinit property warehouse has not been initialized
at lab.Inventory.getWarehouse(Errors.kt:33)
at lab.Inventory.describe(Errors.kt:34)والرسالة هنا أوضح بكثير من !!، فهي تذكر اسم الخاصية، ولكنها في النهاية خطأ وقت تشغيل في كود ظن صاحبه أنه آمن. والقاعدة: lateinit للحالات التي يضمن فيها إطار العمل الترتيب، و!! لا تستخدمها في كود الخدمة إلا في اختبار، وفي غير ذلك استخدم ?: مع رسالة واضحة: name ?: error("product $id has no name").
كود Java يستدعي Kotlin: العلامة @Throws
وعدم وجود استثناءات مفحوصة في Kotlin يظهر أثره عندما يستدعي كود Java دالة Kotlin. فالدالة التالية تستدعي دالة Java ترمي IOException، وKotlin لا تجبرنا على أي شيء:
@Throws(IOException::class) // بدونها لا يستطيع كود Java أن يكتب catch (IOException e)
fun readConfig(path: String): String = LegacyCatalog.load(path)ومن كود Kotlin تمر الاستثناء كما هو، ومن كود Java كتبنا كلاساً يمسكها:
public class UsesKotlin {
public static String safeRead(String path) {
try {
return lab.ErrorsKt.readConfig(path);
} catch (IOException e) {
return "fallback (" + e.getMessage() + ")";
}
}
}fallback (cannot read /etc/shop.conf)ثم جربنا «ماذا يحدث إذا» حذفنا سطر @Throws من ملف Kotlin وأعدنا البناء، فرفض مترجم Java الكود:
> Task :compileJava FAILED
/w/src/main/java/legacy/UsesKotlin.java:10: error: exception IOException is never thrown in body of corresponding try statement
} catch (IOException e) {
^
1 errorوالسبب أن مترجم Java يقرأ توقيع الدالة في الـ Bytecode، والتوقيع دون @Throws لا يذكر أي استثناء، فيظن أن catch (IOException e) كود لا يمكن الوصول إليه ويرفضه. والاستثناء ما زال يرمى فعلاً وقت التشغيل، ولكن كود Java لا يستطيع أن يمسكه باسمه، وعليه أن يمسك Exception كله. فإذا كانت مكتبتك المكتوبة بـ Kotlin تستخدم من Java، فكل دالة عامة ترمي استثناء مفحوصاً من منظور Java تحتاج @Throws، وتشرح صفحة Calling Kotlin from Java هذه العلامة ومعها @JvmStatic و@JvmOverloads.
تجاوز المدى Integer Overflow: الخطأ الذي لا يرمي شيئاً
وآخر تجربة هي أخطر خطأ في هذا المقال، لأنه لا يرمي أي استثناء:
Int.MAX_VALUE + 1 = -2147483648
Exception in thread "main" java.lang.ArithmeticException: integer overflow
at java.base/java.lang.Math.addExact(Math.java:915)فالجمع العادي في Kotlin على Int يلتف Wrap Around بصمت مثل Java وC، ونتيجة أكبر رقم زائد واحد هي أصغر رقم سالب. وفي نظام بالهللات فإن Int يتسع لقرابة 21 مليون ريال، وهذا يكفي لطلب واحد ولا يكفي لتقرير مبيعات سنة، فاستخدم Long للمجاميع الكبيرة، واستخدم Math.addExact وMath.multiplyExact في حساب المبالغ حتى يتحول التجاوز إلى استثناء واضح بدل فاتورة سالبة، وهذا ما سوف نفعله في دالة الإجمالي في الخدمة الحقيقية.

جدول قرار Decision Table: كيف تبلغ عن الخطأ؟
| الحالة | الطريقة | المثال | السبب |
|---|---|---|---|
| المستدعي أعطى قيمة غير مسموحة | require | كمية صفر أو سالبة | خطأ برمجة عند المستدعي، يتحول إلى 400 عند الحدود |
| الكائن في حالة لا تسمح بالعملية | check أو error | دفع طلب ملغى | حالة غير متوقعة، 500 وتنبيه |
| غياب القيمة شيء طبيعي | نوع T? | منتج غير موجود في Map | المترجم يجبر المستدعي على التعامل معه |
| عدة أسباب فشل متوقعة لها معنى في العمل | نوع sealed | Ok أو Bad(reason) أو OutOfStock | when يغطي كل الحالات، ولا مسار مخفي |
| مكتبة خارجية ترمي استثناءات | runCatching عند الحدود فقط | قراءة ملف إعداد | تحول الاستثناء إلى قيمة، واحذر من CancellationException |
| فشل البنية التحتية | استثناء يصعد إلى معالج عام | قاعدة البيانات لا ترد | لا يستطيع الكود المحلي إصلاحه |
دالة Kotlin تستدعى من Java وترمي IOException | @Throws | readConfig | حتى يستطيع كود Java أن يمسكها باسمها |
أخطاء شائعة Common Mistakes
- الاعتقاد أن Kotlin لا يوجد فيها NullPointerException: يظهر من أنواع المنصة عند الحدود مع Java، ومن
!!، ومنlateinit، ومن كود Java يستدعي كود Kotlin بقيمة null. - ترك نتيجة دالة Java بنوع
String!: اكتب النوع صراحة، وString?عند الشك. - استخدام
List<Int>لملايين الأرقام: خمسة أضعاف الذاكرة في تجربتنا، وIntArrayأوLongArrayهي الصحيحة للبيانات الرقمية الكبيرة. - توقع أن الكلاس القيمي يوفر الذاكرة في القوائم: يغلف من جديد داخل
ListوMapوعند?. - سلاسل
mapوfilterطويلة على قوائم كبيرة ثمtake: كل خطوة تنشئ قائمة كاملة، وasSequence()هي الحل. - الاعتماد على الـ JIT ليحذف الكائنات: تحليل الهروب لا يعمل في كل مكان ولا من أول استدعاء.
- تشغيل خدمة JVM في Container صغير بالإعدادات الافتراضية: Heap بربع الذاكرة وجامع Serial تحت 1792 ميجابايت.
- زيادة الذاكرة عند ظهور
OutOfMemoryError: إذا كان السجل يقول58M->58Mفالمشكلة مرجع لا يتحرر. runCatchingحول كود Coroutines: تبتلعCancellationExceptionوتعطل الإلغاء.- نسيان
@Throwsفي مكتبة تستخدم من Java: مترجم Java يرفضcatchللاستثناء باسمه. Intلمجاميع الهللات الكبيرة دونaddExact: التجاوز صامت ويعطي رقماً سالباً.
قائمة تحقق Checklist
- هل كل قيمة قادمة من Java لها نوع صريح في Kotlin، أو علامات Nullability في كود Java؟
- هل عدد
!!في الكود صفر خارج الاختبارات؟ - هل البيانات الرقمية الكبيرة في
IntArrayأوLongArrayوليس فيList؟ - هل المبالغ والمعرفات أنواع مستقلة (كلاسات قيمية) في توقيعات الدوال؟
- هل المجاميع المالية بـ
Longأو بـMath.addExact؟ - هل
MaxRAMPercentageمحدد في الـ Container، وتعرف أي جامع قمامة اختارته الـ JVM؟ - هل
HeapDumpOnOutOfMemoryErrorوExitOnOutOfMemoryErrorمفعلان، ومسار الصورة على Volume؟ - هل الأخطاء المتوقعة أنواع
sealedأوnull، والاستثناءات لما هو استثنائي فقط؟ - هل التحذيرات أخطاء في CI (
allWarningsAsErrors) حتى لا يمر===على الأرقام؟
تدريب عملي
- غير
Halalasفي المختبر لتكونHalalas?في الدالةsumValueClass، وأعد القياس، ولاحظ متى عاد الحجز. - أضف قياساً لـ
LongArrayمقابلList<Long>، واحسب حجم الكائنLongمن الفرق. - شغل تجربة التسرب بالخيار
-XX:+UseG1GCداخل الـ Container المحدود، وقارن سجل الجامع بسجل Serial. - أضف علامة
@org.jspecify.annotations.NullableإلىfindNameفي كود Java، وأعد البناء، ولاحظ أين يعترض مترجم Kotlin الآن.
الخلاصة
وصلنا لنهاية الموضوع، وأهم ما فيه:
- Kotlin تكتب
Intكأنه كلاس، وتترجمه إلىintبدائي، إلا إذا صارInt?أو دخل في نوع عام، فعندها يصبح كائنIntegerبـ 16 بايتاً، وList<Int>لمليون رقم حجزت 20 ميجابايت مقابل 4 لـIntArray. - الكلاس القيمي
@JvmInline value classيعطيك نوعاً مستقلاً بلا كائن: مليون جمع بـ 16 بايتاً مقابل 16 ميجابايت للـdata class، ويعود الكائن داخل القوائم. - الدوال
inlineتحذف كائن الدالة المجهولة والمتغير المغلفRef، والفرق ظهر كاملاً (16 ميجابايت) عندما أطفأنا تحليل الهروب في الـ JIT، والتسلسل Sequence حجز 224 بايتاً مقابل 35 ميجابايت للقائمة. - في Container بحجم 512 ميجابايت اختارت الـ JVM جامع Serial و Heap بحجم 128 ميجابايت، وارتفع عدد دورات الجامع من 6 إلى 40، وحدد
MaxRAMPercentageبنفسك. - حماية null تتوقف عند الحدود مع Java: النوع
String!أعطاناfindName(...) must not be null، و!!أعطت استثناء بلا رسالة، وlateinitأعطتUninitializedPropertyAccessException. - لا توجد استثناءات مفحوصة: استخدم
requireوcheckللشروط، وnullأوsealedللأخطاء المتوقعة، و@Throwsلكود يستدعى من Java، وaddExactلأن التجاوز صامت.
وفي المقال التالي من مرحلة لغة Kotlin للمطورين سوف ننتقل إلى التزامن والروتينات المساعدة Coroutines، ونقيس مئة طلب HTTP ومهمة حسابية ثقيلة، ونصنع حالة سباق وجموداً حقيقيين. وهذه المقالات المنشورة تكمل هذا المقال:
دليلإدارة الذاكرة: يدوياً أم Garbage Collector أم Ownership؟نقيس بأيدينا في C وGo وPython وRust من يحرر الذاكرة ومتى، وكم يكلف ذلك من الذاكرة والوقت، وما الأخطاء التي تبقى ممكنة في كل طريقة.
دليلكيف تتعامل لغات البرمجة مع الأخطاء؟لماذا ترمي Java وPython الاستثناءات بينما ترجع Go وRust الخطأ كقيمة، وكيف تمنع الخطأ من أن يختفي بصمت في كودك.
دليلأنماط البرمجة وأنظمة الأنواع: لماذا تختلف اللغات؟نحل مسألة واحدة بعدة أساليب برمجة، ونرى هل تكتشف اللغة أخطاءك قبل التشغيل أم بعده، وماذا يعني ذلك عند اختيار لغة لمشروعك.
دليلالمترجم Compiler والمفسر Interpreter و JIT و WebAssemblyنشغل دالة واحدة بلغة C وPython وJavaScript وWebAssembly على نفس الجهاز، ونفتح ما كتبه كل مترجم لنعرف لماذا اختلف الوقت من ثلث ثانية إلى دقيقة.
دليلقراءة ال Stack Trace وتصحيح الأخطاء Debugging للمبتدئينكيف تقرأ رسالة الخطأ في Python وJavaScript سطراً بسطر، ومتى تستخدم الطباعة أو نقاط التوقف في VS Code، وكيف يجد git bisect التعديل الذي أدخل الخطأ.
دليلأمان الذاكرة Memory Safety: لماذا تدفع الحكومات نحو Rust؟ماذا طلبت الحكومات فعلاً بشأن C وC++، ولماذا تسبب أخطاء الذاكرة أغلب الثغرات الخطيرة، ومتى يكون Rust هو الاختيار الصحيح.سجل التحديثات
- أكتوبر 2026: كتابة المقال، وتنفيذ تجارب الحجز والأخطاء وجامع القمامة على Kotlin 2.4.21 وOpenJDK 25.0.5 (Temurin) داخل Docker، مرة دون حدود ومرة بحدود 2 CPU و512 ميجابايت.