> ## Content Index
> Fetch the complete content index at: https://arabroot.io/llms.txt
> Use this file to discover other available public pages before exploring further.

# Kotlin من الداخل: الأنواع Types والذاكرة والأخطاء
- URL: https://arabroot.io/articles/kotlin-من-الداخل-الانواع-والذاكرة-والاخطاء/
- Published: 2026-10-31T07:36:56.000Z
- Updated: 2026-10-31T08:35:58.000Z
- Description: قسنا بالبايت ما تحجزه الأنواع التي تقبل null والكلاسات القيمية والدوال inline في Kotlin على JVM، وشغلنا كل طرق الفشل من Platform Types إلى lateinit، لتعرف أين تحميك اللغة وأين يبقى الخطأ عليك.
- Author: فريق عرب رووت
- Tags: الأدلة التقنية, لغات البرمجة عملياً

في [المقال الأول من مرحلة Kotlin](https://arabroot.io/start/#stage-lang-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](https://arabroot.io/articles/%D8%A7%D8%AF%D8%A7%D8%B1%D8%A9-%D8%A7%D9%84%D8%B0%D8%A7%D9%83%D8%B1%D8%A9-%D9%8A%D8%AF%D9%88%D9%8A%D8%A7-%D8%A7%D9%85-garbage-collector-%D8%A7%D9%85-ownership/)، وشرحنا الجدل حول الاستثناءات في مقال [كيف تتعامل لغات البرمجة مع الأخطاء](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D8%AA%D8%AA%D8%B9%D8%A7%D9%85%D9%84-%D9%84%D8%BA%D8%A7%D8%AA-%D8%A7%D9%84%D8%A8%D8%B1%D9%85%D8%AC%D8%A9-%D9%85%D8%B9-%D8%A7%D9%84%D8%A7%D8%AE%D8%B7%D8%A7%D8%A1/)، ومرحلة 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 أخذت طريقاً ثالثاً تبينه الصورة التالية:

![شجرة الأنواع في Kotlin: Any في الأعلى وفوقه Any?، وتحتهما Int وString والكلاسات، وكل نوع له نسخة تقبل null، وNothing في الأسفل تحت كل الأنواع](https://arabroot.io/content/images/2026/10/kotlin-types-memory-01-type-tree.webp)

كل نوع له نسختان: `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](https://kotlinlang.org/docs/numbers.html?ref=arabroot.io) القاعدة: الرقم يبقى بدائياً، إلا إذا كان نوعه يقبل 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](https://kotlinlang.org/docs/typecasts.html?ref=arabroot.io) هذه الشروط. والأنواع العامة Generics في Kotlin تمحى وقت التشغيل Type Erasure مثل Java تماماً، فلا تستطيع أن تسأل `x is List<String>`، إلا داخل دالة `inline` بمعامل `reified` كما تشرح صفحة [Generics](https://kotlinlang.org/docs/generics.html?ref=arabroot.io)، وهذه حالة نادرة في كود الخدمات.

## كيف نقيس الذاكرة بالبايت؟ ThreadMXBean

قبل أن نتحدث عن التغليف بالكلمات، نريد أن نراه بالأرقام. والطريقة الأدق داخل الـ JVM ليست مراقبة الذاكرة الكلية للعملية، فهي تتأثر بجامع القمامة وبحجم الـ Heap، وإنما سؤال الـ JVM نفسها: كم بايتاً حجز هذا الخيط Thread في الـ Heap منذ بدأ؟ وهذا ما تعطيه الدالة `getThreadAllocatedBytes` في الواجهة [com.sun.management.ThreadMXBean](https://docs.oracle.com/en/java/javase/25/docs/api/jdk.management/com/sun/management/ThreadMXBean.html?ref=arabroot.io)، فتقرأ العداد قبل العمل وبعده، والفرق هو ما حجزه العمل بالضبط، بغض النظر عن متى سوف يجمعه جامع القمامة. والملف `Alloc.kt` في المختبر يغلف هذه الفكرة في دالة واحدة:

```kotlin
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`. ولكن أول ترجمة لهذا الملف فشلت، وهذه هي المشكلة الأولى التي ظهرت لنا:

```text
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](https://kotlinlang.org/docs/inline-functions.html?ref=arabroot.io) هو العلامة `@PublishedApi internal`: المتغير يبقى غير ظاهر لكود Kotlin خارج الوحدة، ولكنه في الـ Bytecode متاح للكود المنسوخ. وهذه أول إشارة إلى أن `inline` ليست مجرد تحسين يضيفه المترجم، وإنما تغير شكل الكود الناتج فعلاً.

وطريقة التشغيل بسيطة: نشغل كل قياس ثلاث مرات في نفس العملية، ونطبع الجولة الثالثة فقط، لأن الجولة الأولى فيها تحميل الكلاسات، والمترجم أثناء التشغيل JIT لم يترجم الكود بعد إلى كود الآلة، وقد شرحنا مراحل الإحماء Warm-up في مقال [المترجم والمفسر وJIT](https://arabroot.io/articles/%D8%A7%D9%84%D9%85%D8%AA%D8%B1%D8%AC%D9%85-%D9%88%D8%A7%D9%84%D9%85%D9%81%D8%B3%D8%B1-%D9%88-jit-%D9%88-webassembly-%D8%A8%D8%A7%D9%84%D8%AA%D9%81%D8%B5%D9%8A%D9%84/). وكل قياس يعمل على مليون عنصر، والكود الكامل في `lab/src/main/kotlin/lab/Main.kt`.

## التغليف Boxing: متى يصبح Int كائناً؟

الدوال الثلاث الأولى في التجربة تفعل نفس الشيء بالضبط، وهو أن تملأ مليون رقم ثم تجمعها، والفرق الوحيد هو نوع الحاوية:

```kotlin
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 }
```

وهذه الأسطر الثلاثة من مخرج التشغيل:

```text
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، فلا يوجد حل إلا الكائن.
- والزمن يتبع الذاكرة، فالقائمة أبطأ من المصفوفة بخمس مرات تقريباً في هذا التشغيل، لأن كل رقم يحتاج حجزاً ثم قراءة عبر مرجع إلى مكان آخر في الذاكرة.

والصورة التالية تجمع كل نتائج التجربة في مكان واحد، وسوف نمر عليها سطراً سطراً في الأقسام التالية:

![أعمدة بعدد البايتات التي حجزها كل أسلوب لمليون عنصر: IntArray 4 ميجابايت، List Int 20 ميجابايت، value class 16 بايت، data class 16 ميجابايت، List على Sequence 35 ميجابايت مقابل 224 بايت](https://arabroot.io/content/images/2026/10/kotlin-types-memory-02-alloc-bytes.webp)

نفس العمل بعشرة أساليب: الفرق بين 16 بايتاً و35 ميجابايت هو طريقة الكتابة وليس الخوارزمية

### المقارنة بالمرجع Identity Equality: لماذا 127 تختلف عن 128؟

ولذاكرة `Integer` المؤقتة أثر جانبي مشهور في Java، فهل يظهر في Kotlin؟ الدالة التالية تقارن رقمين من النوع `Int?` بالمحتوى `==` وبالمرجع `===`:

```kotlin
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}")
}
```

```text
127: a == b -> true, a === b -> true
128: c == d -> true, c === d -> false
```

فالمقارنة بالمحتوى `==` صحيحة في الحالتين، وهذا ما تريده دائماً. أما المقارنة بالمرجع فأعطت `true` للرقم 127 لأن الكائنين هما نفس الكائن من الذاكرة المؤقتة، و`false` للرقم 128 لأنهما كائنان مختلفان بنفس القيمة. والفرق عن Java أن هذا الخطأ صعب الوقوع في Kotlin، لأن `==` هي العلامة الطبيعية، ولأن المترجم K2 حذرنا عند البناء بهذا السطر:

```text
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](https://kotlinlang.org/docs/equality.html?ref=arabroot.io) أن المقارنة بالمرجع للأنواع التي تمثل بأنواع بدائية غير مستقرة، فالقاعدة بسيطة: لا تستخدم `===` مع الأرقام أبداً، واجعل التحذيرات أخطاء في خط CI بالخيار `allWarningsAsErrors` حتى لا يمر مثل هذا السطر.

## الكلاسات القيمية Value Classes: نوع جديد دون كائن جديد

والآن مشكلة يعرفها كل من كتب نظاماً مالياً: الدالة `vatHalalas(subtotal: Int)` تأخذ رقماً، ولا شيء يمنع أحداً من أن يعطيها عدد القطع بدل المبلغ، أو المبلغ بالريال بدل الهللات. والحل المعروف أن تعطي كل مفهوم نوعه الخاص، فتكتب كلاساً اسمه `Halalas`، ولكن الثمن في Java وفي Kotlin العادية كائن جديد لكل مبلغ. وKotlin تقدم حلاً ثالثاً اسمه الكلاس القيمي Value Class، تشرحه صفحة [Inline value classes](https://kotlinlang.org/docs/inline-classes.html?ref=arabroot.io): كلاس بخاصية واحدة، يعامله المترجم كنوع مستقل في الفحص، ويحذفه في الـ Bytecode ويضع مكانه القيمة نفسها. وكتبنا النوعين لنقارن:

```kotlin
@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 }
```

```text
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`:

```text
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`:

```text
  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`، ثم استدعينا كل واحدة مليون مرة بدالة مجهولة تعدل متغيراً محلياً:

```kotlin
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 }
```

```text
lambda capturing var, plain HOF                     119,856 bytes       1.0 ms
lambda capturing var, inline HOF                         24 bytes       0.4 ms
```

والنتيجة أقل دراماتيكية مما توقعنا: الدالة العادية حجزت قرابة 120 كيلوبايت، والدالة `inline` 24 بايتاً. ولكن الأهم هو الكود المترجم، فهو يشرح ما كان سيحدث وما منعه:

```text
  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` الذي يطفئه:

```bash
JAVA_OPTS="-XX:-DoEscapeAnalysis" build/install/types-memory/bin/types-memory alloc
```

```text
IntArray 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:

```kotlin
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()
```

```text
map/filter/take on List                          34,907,848 bytes      78.4 ms
map/filter/take on Sequence                             224 bytes       0.1 ms
```

والفرق هنا ليس في الأنواع وإنما في طريقة التنفيذ، وتشرحها صفحة [Sequences](https://kotlinlang.org/docs/sequences.html?ref=arabroot.io): كل دالة على القائمة تنفذ فوراً 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 بنفسها:

```bash
java -XX:+PrintFlagsFinal -version | grep -E ' (UseG1GC|UseSerialGC|UseZGC|MaxHeapSize|InitialHeapSize|MaxRAMPercentage|ActiveProcessorCount) '
```

وفي الـ Container المحدود كان المخرج كما يلي:

```text
      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](https://docs.oracle.com/en/java/javase/25/gctuning/ergonomics.html?ref=arabroot.io) من وثائق 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)`، أي أربعين دورة، منها توقف كامل بهذا الشكل:

```text
[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](https://docs.oracle.com/en/java/javase/25/gctuning/?ref=arabroot.io) كل جامع وخياراته.

![مقارنة بين Container بلا حدود اختارت فيه الـ JVM جامع G1 وHeap بحجم 8 جيجابايت، وContainer بحجم 512 ميجابايت ومعالجين اختارت فيه Serial وHeap بحجم 128 ميجابايت](https://arabroot.io/content/images/2026/10/kotlin-types-memory-03-container-gc.webp)

نفس الـ Image ونفس البرنامج: الـ JVM تختار الجامع وحجم الـ Heap من حدود الـ Container، والحد 1792 ميجابايت

### التسرب Memory Leak في لغة فيها جامع قمامة

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

```kotlin
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 المحدود كان كما يلي:

```text
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`:

```text
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](https://docs.oracle.com/en/java/javase/25/troubleshoot/?ref=arabroot.io) في JDK 25 هذه الخيارات.

## أنواع المنصة Platform Types: من أين جاء NullPointerException؟

والآن نعود إلى الاستثناء الذي بدأ به المقال. فالمكتبة القديمة مكتوبة بـ Java، وفيها كلاس بسيط يرجع اسم المنتج من رقمه، ويرجع null إذا لم يجده، دون أي علامة تقول ذلك:

```java
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](https://kotlinlang.org/docs/java-interop.html?ref=arabroot.io). ومعنى هذا النوع «أنا لا أعرف، والقرار لك»: تستطيع أن تعامله كـ `String` أو كـ `String?`، والمترجم يقبل الاثنين. والدالة التالية جربت الطرق الثلاث:

```kotlin
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")
}
```

```text
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 الحديثة صارت تحمل هذه العلامات.

![الحدود بين كود Java وكود Kotlin: النوع String! يدخل، ومنه ثلاثة طرق: String مع فحص يرمي NPE، أو String? مع معالجة، أو علامات Nullable في Java](https://arabroot.io/content/images/2026/10/kotlin-types-memory-04-platform-types.webp)

حماية null تتوقف عند الحدود مع Java: النوع `String!` يترك القرار لك

## الأخطاء في Kotlin: استثناءات غير مفحوصة Unchecked Exceptions

إذا كنت قادماً من Java فأول ما تلاحظه أن Kotlin لا يوجد فيها استثناءات مفحوصة Checked Exceptions، فلا تجبرك على `try` ولا على `throws` في أي دالة، وكل الاستثناءات عندها مثل `RuntimeException`. وهذا قرار مقصود تشرحه صفحة [Exceptions](https://kotlinlang.org/docs/exceptions.html?ref=arabroot.io)، وقد ناقشنا أسبابه والجدل حوله في مقال الأخطاء، فلن نكرره. وإذا كنت قادماً من Python فالأمر مألوف لك، مع فرق واحد: اللغة تعطيك أدوات لتجعل كثيراً من الأخطاء جزءاً من النوع بدل أن تكون استثناءات. وسوف نمر على هذه الأدوات بمثال واحد يتكرر في كل خدمة: قراءة سطر طلب نصي بالشكل `id:qty`.

```kotlin
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

وأول تجربة أعطت الدالة كمية صفر:

```text
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 وتستحق تنبيهاً. والتجربة الثانية أعطت كمية نصية:

```text
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](https://arabroot.io/articles/%D9%82%D8%B1%D8%A7%D8%A1%D8%A9-%D8%B1%D8%B3%D8%A7%D9%84%D8%A9-%D8%A7%D9%84%D8%AE%D8%B7%D8%A7-stack-trace-%D9%88%D8%A7%D9%84%D8%AA%D8%B5%D8%AD%D9%8A%D8%AD-debugging/).

### القيمة null كخطأ: toIntOrNull والنوع المغلق Sealed Result

والطريقة الأكثر شيوعاً في كود Kotlin الحديث للأخطاء المتوقعة، أي الأخطاء التي هي جزء من عمل الدالة الطبيعي مثل مدخل غير صحيح، ألا ترمي استثناء أصلاً. فالمكتبة الأساسية فيها `toIntOrNull()` بجانب `toInt()`، و`firstOrNull()` بجانب `first()`، و`getOrNull(i)` بجانب `[i]`. والنسخة الآمنة من دالتنا تستخدم نوعاً مغلقاً Sealed يصف كل النتائج الممكنة:

```kotlin
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))
}
```

```text
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](https://kotlinlang.org/api/core/kotlin-stdlib/kotlin/-result/?ref=arabroot.io)، ومعه الدالة `runCatching` التي تنفذ كوداً وتمسك أي استثناء وتعيده كقيمة:

```kotlin
val r = runCatching { parseLine("1:x") }
println("runCatching: isFailure=${r.isFailure}, ${r.exceptionOrNull()}")
println("getOrElse: ${r.getOrElse { OrderLine(0, 0) }}")
```

```text
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` تعبير يرجع قيمة أيضاً:

```kotlin
val qty = try { "12a".toInt() } catch (e: NumberFormatException) { println("caught: ${e.message}"); 0 }
println("try is an expression, qty = $qty")
```

```text
caught: For input string: "12a"
try is an expression, qty = 0
```

وهذا يغنيك عن نمط Java المعروف حيث تعرف المتغير قبل `try` بقيمة مؤقتة ثم تعيد إسناده في الداخل. والطريقتان الأخيرتان في التجربة هما الطريقتان اللتان تعيدان `NullPointerException` إلى كود Kotlin من الباب الخلفي. الأولى هي العلامة `!!`:

```kotlin
val name: String? = LegacyCatalog().findName(9); println(name!!.length)
```

```text
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](https://kotlinlang.org/docs/properties.html?ref=arabroot.io):

```kotlin
class Inventory {
    lateinit var warehouse: String              // نعد المترجم أننا سوف نعطيها قيمة قبل القراءة
    fun describe() = "warehouse = $warehouse"
}
```

```text
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 لا تجبرنا على أي شيء:

```kotlin
@Throws(IOException::class)                     // بدونها لا يستطيع كود Java أن يكتب catch (IOException e)
fun readConfig(path: String): String = LegacyCatalog.load(path)
```

ومن كود Kotlin تمر الاستثناء كما هو، ومن كود Java كتبنا كلاساً يمسكها:

```java
public class UsesKotlin {
    public static String safeRead(String path) {
        try {
            return lab.ErrorsKt.readConfig(path);
        } catch (IOException e) {
            return "fallback (" + e.getMessage() + ")";
        }
    }
}
```

```text
fallback (cannot read /etc/shop.conf)
```

ثم جربنا «ماذا يحدث إذا» حذفنا سطر `@Throws` من ملف Kotlin وأعدنا البناء، فرفض مترجم Java الكود:

```text
> 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](https://kotlinlang.org/docs/java-to-kotlin-interop.html?ref=arabroot.io) هذه العلامة ومعها `@JvmStatic` و`@JvmOverloads`.

### تجاوز المدى Integer Overflow: الخطأ الذي لا يرمي شيئاً

وآخر تجربة هي أخطر خطأ في هذا المقال، لأنه لا يرمي أي استثناء:

```text
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` في حساب المبالغ حتى يتحول التجاوز إلى استثناء واضح بدل فاتورة سالبة، وهذا ما سوف نفعله في دالة الإجمالي في الخدمة الحقيقية.

![خريطة طرق إبلاغ الخطأ في Kotlin: استثناء عبر require وcheck، أو null عبر toIntOrNull، أو نوع مغلق Sealed، أو Result عبر runCatching، مع متى تستخدم كل واحدة](https://arabroot.io/content/images/2026/10/kotlin-types-memory-05-error-map.webp)

أربع طرق لإبلاغ الخطأ، ولكل واحدة مكانها: الاستثناء لما هو استثنائي، والنوع لما هو متوقع

## جدول قرار 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 للمطورين](https://arabroot.io/start/#stage-lang-kotlin) سوف ننتقل إلى التزامن والروتينات المساعدة Coroutines، ونقيس مئة طلب HTTP ومهمة حسابية ثقيلة، ونصنع حالة سباق وجموداً حقيقيين. وهذه المقالات المنشورة تكمل هذا المقال:

[![](https://arabroot.io/content/images/2026/10/memory-management-cover-cover.webp)دليلإدارة الذاكرة: يدوياً أم Garbage Collector أم Ownership؟نقيس بأيدينا في C وGo وPython وRust من يحرر الذاكرة ومتى، وكم يكلف ذلك من الذاكرة والوقت، وما الأخطاء التي تبقى ممكنة في كل طريقة.](https://arabroot.io/articles/%D8%A7%D8%AF%D8%A7%D8%B1%D8%A9-%D8%A7%D9%84%D8%B0%D8%A7%D9%83%D8%B1%D8%A9-%D9%8A%D8%AF%D9%88%D9%8A%D8%A7-%D8%A7%D9%85-garbage-collector-%D8%A7%D9%85-ownership/)[![](https://arabroot.io/content/images/2026/10/error-handling-cover-cover.webp)دليلكيف تتعامل لغات البرمجة مع الأخطاء؟لماذا ترمي Java وPython الاستثناءات بينما ترجع Go وRust الخطأ كقيمة، وكيف تمنع الخطأ من أن يختفي بصمت في كودك.](https://arabroot.io/articles/%D9%83%D9%8A%D9%81-%D8%AA%D8%AA%D8%B9%D8%A7%D9%85%D9%84-%D9%84%D8%BA%D8%A7%D8%AA-%D8%A7%D9%84%D8%A8%D8%B1%D9%85%D8%AC%D8%A9-%D9%85%D8%B9-%D8%A7%D9%84%D8%A7%D8%AE%D8%B7%D8%A7%D8%A1/)[![](https://arabroot.io/content/images/2026/10/paradigms-types-cover-cover.webp)دليلأنماط البرمجة وأنظمة الأنواع: لماذا تختلف اللغات؟نحل مسألة واحدة بعدة أساليب برمجة، ونرى هل تكتشف اللغة أخطاءك قبل التشغيل أم بعده، وماذا يعني ذلك عند اختيار لغة لمشروعك.](https://arabroot.io/articles/%D8%A7%D9%86%D9%85%D8%A7%D8%B7-%D8%A7%D9%84%D8%A8%D8%B1%D9%85%D8%AC%D8%A9-%D9%88%D8%A7%D9%86%D8%B8%D9%85%D8%A9-%D8%A7%D9%84%D8%A7%D9%86%D9%88%D8%A7%D8%B9-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/)[![](https://arabroot.io/content/images/2026/10/compilation-jit-cover-cover.webp)دليلالمترجم Compiler والمفسر Interpreter و JIT و WebAssemblyنشغل دالة واحدة بلغة C وPython وJavaScript وWebAssembly على نفس الجهاز، ونفتح ما كتبه كل مترجم لنعرف لماذا اختلف الوقت من ثلث ثانية إلى دقيقة.](https://arabroot.io/articles/%D8%A7%D9%84%D9%85%D8%AA%D8%B1%D8%AC%D9%85-%D9%88%D8%A7%D9%84%D9%85%D9%81%D8%B3%D8%B1-%D9%88-jit-%D9%88-webassembly-%D8%A8%D8%A7%D9%84%D8%AA%D9%81%D8%B5%D9%8A%D9%84/)[![](https://arabroot.io/content/images/2026/10/debugging-basics-cover-cover.webp)دليلقراءة ال Stack Trace وتصحيح الأخطاء Debugging للمبتدئينكيف تقرأ رسالة الخطأ في Python وJavaScript سطراً بسطر، ومتى تستخدم الطباعة أو نقاط التوقف في VS Code، وكيف يجد git bisect التعديل الذي أدخل الخطأ.](https://arabroot.io/articles/%D9%82%D8%B1%D8%A7%D8%A1%D8%A9-%D8%B1%D8%B3%D8%A7%D9%84%D8%A9-%D8%A7%D9%84%D8%AE%D8%B7%D8%A7-stack-trace-%D9%88%D8%A7%D9%84%D8%AA%D8%B5%D8%AD%D9%8A%D8%AD-debugging/)[![](https://arabroot.io/content/images/2026/10/memory-safety-rust-cover-cover.webp)دليلأمان الذاكرة Memory Safety: لماذا تدفع الحكومات نحو Rust؟ماذا طلبت الحكومات فعلاً بشأن C وC++، ولماذا تسبب أخطاء الذاكرة أغلب الثغرات الخطيرة، ومتى يكون Rust هو الاختيار الصحيح.](https://arabroot.io/articles/%D8%A7%D9%85%D8%A7%D9%86-%D8%A7%D9%84%D8%B0%D8%A7%D9%83%D8%B1%D8%A9-memory-safety-%D9%84%D9%85%D8%A7%D8%B0%D8%A7-%D8%AA%D8%AF%D9%81%D8%B9-%D8%A7%D9%84%D8%AD%D9%83%D9%88%D9%85%D8%A7%D8%AA-%D9%86%D8%AD%D9%88-rust/)

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

- أكتوبر 2026: كتابة المقال، وتنفيذ تجارب الحجز والأخطاء وجامع القمامة على Kotlin 2.4.21 وOpenJDK 25.0.5 (Temurin) داخل Docker، مرة دون حدود ومرة بحدود 2 CPU و512 ميجابايت.