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

Kotlin لمن يعرف البرمجة: الأدوات والصيغة Syntax في يوم

ثبتنا Kotlin 2.4.21 على JDK 25 وبنينا مشروعاً صغيراً بـ Gradle من الصفر، لترى الصيغة مقارنة بما تعرفه من Java وPython، والأدوات التي سوف تستخدمها كل يوم والمشكلات التي تظهر في أول يوم.

Kotlin لمن يعرف البرمجة: الأدوات والصيغة Syntax في يوم

لنفرض أنك تعمل في شركة ناشئة تبيع القهوة والتمور عبر تطبيق جوال، وتطبيق Android مكتوب بـ Kotlin منذ سنتين على يد مطورين اثنين، بينما الخدمة الخلفية Backend مكتوبة بـ Python وتكتبها أنت. وفي اجتماع الفريق يقترح أحدهم أن تنتقل خدمة الطلبات إلى Kotlin على السيرفر، والسبب أن الفريق كله سوف يقرأ لغة واحدة، ونماذج البيانات مثل المنتج والطلب سوف تكتب مرة واحدة، والـ JVM التي يعمل عليها الكود من أكثر البيئات نضجاً في تشغيل الخدمات. والطلب منك بسيط في ظاهره: جهز بيئة Kotlin على جهازك، واكتب أول مشروع يعمل ويختبر، وتعرف على الأدوات التي سوف تستخدمها كل يوم، قبل أن نبدأ في كتابة الخدمة نفسها.

وأنت لست مبتدئاً، فأنت تعرف المتغيرات والدوال والكلاسات، وتعرف كيف تكتب اختباراً وكيف تقرأ رسالة خطأ، ولا تحتاج من يشرح لك ما هي الحلقة. ما تحتاجه هو خريطة سريعة: ماذا أثبت بالضبط؟ ما الفرق بين المترجم kotlinc وأداة البناء Gradle؟ كيف تبدو الصيغة Syntax مقارنة بما أكتبه اليوم في Python أو Java؟ وما الذي يحدث فعلاً عندما أضغط زر التشغيل؟ وهذا المقال هو الأول في مرحلة لغة Kotlin للمطورين، وهي جزء من مسار «لغات البرمجة عملياً» الذي يأخذ كل لغة بنفس الشكل حتى تستطيع المقارنة بينها: الأدوات أولاً، ثم الأنواع والذاكرة، ثم التزامن، ثم خدمة حقيقية، ثم الأخطاء الشائعة. وكل الأوامر في هذا المقال نفذناها فعلاً على Kotlin 2.4.21 وJDK 25، والمخرج المعروض هو المخرج الحقيقي.

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

  • من أين جاءت Kotlin، ولماذا صارت لغة Android الأولى، ولماذا يكتبها البعض على السيرفر.
  • ما الذي تثبته بالضبط: الـ JDK والمترجم وأداة البناء، والإصدارات التي استخدمناها.
  • المترجم kotlinc وحده: أول برنامج، وأول خطأ حقيقي ظهر لنا وهو NoClassDefFoundError، وملفات السكربت .main.kts.
  • جولة سريعة في الصيغة مقارنة بـ Java وPython: القيم والمتغيرات، والقيم الفارغة null، وكلاسات البيانات Data Classes، والتعبير when، ودوال الامتداد Extension Functions.
  • مشروع Gradle من الصفر: الأمر gradle init، وبنية المجلدات Project Layout، وملف build.gradle.kts سطراً بسطر.
  • البناء والتشغيل والاختبار، وكم استغرق كل منها في المرة الأولى والثانية.
  • أدوات التنسيق والفحص: ktlint وktfmt وdetekt، والمشكلة التي ظهرت مع JDK 25.
  • الأخطاء الشائعة في أول يوم، وقائمة تحقق Checklist.

لماذا ظهرت Kotlin؟ قصة اللغة Language History باختصار

بدأت شركة JetBrains، وهي الشركة التي تصنع IntelliJ IDEA، العمل على Kotlin في 2010، وكان الدافع عملياً جداً: كود الشركة نفسه مكتوب بـ Java، والفريق يريد لغة تعمل على نفس الـ JVM وتستخدم نفس المكتبات، ولكن بكود أقصر وبحماية من الأخطاء التي تتكرر في Java كل يوم، وعلى رأسها NullPointerException. ولم يكن الهدف لغة أكاديمية جديدة، وإنما لغة «عملية» Pragmatic، وهي الكلمة التي اختارتها الشركة في إعلان الإصدار 1.0 في فبراير 2016، حيث وعدت بالتوافق مع الإصدارات القادمة، وبأن أي كود Java موجود يمكن أن يعيش بجانب كود Kotlin في نفس المشروع ويستدعي كل منهما الآخر.

والتحول الكبير جاء من Google، ففي مؤتمر Google I/O عام 2017 أعلنت دعم Kotlin رسمياً في Android، ثم في 2019 أعلنت أن تطوير Android صار «Kotlin أولاً» Kotlin-first، وهذا موثق في صفحة Android's Kotlin-first approach، ومعناه أن الأدوات والمكتبات والوثائق الجديدة تكتب لـ Kotlin قبل Java. ولهذا السبب ستجد أن أغلب من يكتب Kotlin اليوم جاء من Android، وهذه بالضبط حالة الفريق في قصتنا. وعلى السيرفر انتشرت Kotlin بشكل أبطأ، ولكنها اليوم مدعومة رسمياً في Spring Boot، ولها إطار خاص من JetBrains اسمه Ktor سوف نستخدمه في مقال الخدمة الحقيقية من هذه المرحلة.

وفي مايو 2024 صدر الإصدار 2.0 ومعه المترجم الجديد K2، وهو إعادة كتابة كاملة للواجهة الأمامية Frontend من المترجم، وتشرح صفحة What's new in Kotlin 2.0 ما تغير. والمهم لك كمطور جديد أن كل ما تقرؤه اليوم يفترض K2، وأن بعض المقالات القديمة على الإنترنت تتحدث عن سلوك تغير. والإصدارات تسير اليوم بإيقاع ثابت تشرحه صفحة Kotlin release process: إصدار لغة 2.x.0 كل ستة أشهر تقريباً، وإصدار أدوات 2.x.20 بينهما، وإصدارات إصلاح صغيرة مثل 2.x.21. والإصدار الذي استخدمناه هو Kotlin 2.4.21 الصادر في 8 أكتوبر 2026، وهو آخر إصدار مستقر وقت كتابة المقال، أما 2.5.0 فما زال تجريبياً Beta.

وقد يتساءل البعض: إذا كانت Kotlin تعمل على الـ JVM مثل Java، فلماذا لا أكتب Java الحديثة مباشرة، وهي تطورت كثيراً في السنوات الأخيرة وصار فيها Records والتعبير switch والخيوط الافتراضية Virtual Threads؟ والإجابة أن هذا خيار صحيح تماماً لكثير من الفرق، ومرحلة Java في هذا المسار تبني نفس الخدمة بـ Java الحديثة حتى تقارن بنفسك. ولكن هناك ثلاثة أشياء تجعل Kotlin مختلفة فعلاً، وليست مجرد Java بصيغة أقصر: نظام الأنواع يفرق بين القيمة التي قد تكون null والتي لا تكون، والروتينات المساعدة Coroutines جزء من طريقة كتابة الكود غير المتزامن، والكود الواحد يمكن أن يعمل على الـ JVM وعلى Android وعلى المتصفح والجوال عبر Kotlin Multiplatform. وفي حالة فريقنا فإن السبب الأول هو الأهم: الفريق يكتب Kotlin أصلاً، ولا يريد لغتين.

ماذا تثبت بالضبط؟ الـ JDK والمترجم Compiler وأداة البناء Build Tool

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

الطريق من ملف Kotlin إلى برنامج يعمل على JVM: المترجم kotlinc أو Gradle، ثم ملفات class ومكتبة kotlin-stdlib، ثم JVM من JDK 25
ثلاث طبقات بثلاثة إصدارات: الـ JDK يشغل، والمترجم يحول الكود إلى Bytecode، وGradle ينظم البناء ويجلب المكتبات

والطبقات الثلاث كما يلي:

  • الـ JDK (Java Development Kit): وهو الذي يحتوي الآلة الافتراضية JVM التي تشغل الكود، وأدوات مثل java وjavap. وKotlin على الـ JVM تحتاجه دائماً، لأن المخرج النهائي هو ملفات .class مثل Java تماماً. واستخدمنا JDK 25، وهو إصدار الدعم الطويل LTS الحالي، من توزيعة Temurin المجانية. وإذا كنت محتاراً بين توزيعات الـ JDK ورخصها فقد شرحنا ذلك بالتفصيل في مقال هل Java مجانية؟.
  • المترجم Kotlin Compiler: ويأتي بطريقتين، إما كأداة سطر أوامر مستقلة اسمها kotlinc تنزلها كملف zip من صفحة الإصدار، وإما كإضافة Plugin داخل Gradle تنزل المترجم تلقائياً بالإصدار المكتوب في ملف البناء. وفي العمل اليومي سوف تستخدم الطريقة الثانية تقريباً دائماً، والأولى مفيدة لتفهم ما يحدث وللتجارب السريعة.
  • أداة البناء Gradle: وهي التي تقرأ ملف build.gradle.kts، وتنزل المكتبات من Maven Central، وتشغل المترجم والاختبارات وتجمع الناتج. وMaven تعمل مع Kotlin أيضاً، ولكن Gradle هي الافتراضية في Android وفي أغلب مشاريع Kotlin على السيرفر، ولذلك اخترناها. وشرحنا الفكرة العامة لأدوات البناء ودورة حياتها في مقال أدوات البناء Build Tools، فلن نكررها هنا.

وعلى جهازك الشخصي فإن أسهل طريق هو IntelliJ IDEA، فهو ينزل الـ JDK والمترجم ويفتح مشاريع Gradle مباشرة، أو أداة SDKMAN على Linux وmacOS التي تثبت java وkotlin وgradle بإصدارات محددة. ولكننا في هذا المقال وكل مقالات المرحلة استخدمنا Docker Image رسمية فيها Gradle 9.8.2 مع JDK 25، وذلك لسببين: أن أي قارئ يستطيع أن يعيد نفس التجربة بنفس الإصدارات بالضبط، وأن جهاز التطوير يبقى نظيفاً. والأمر التالي يبين ما في هذه الـ Image:

docker run --rm gradle:9.8.2-jdk25-noble sh -c 'java -version 2>&1; gradle --version | sed -n 1,12p'

والمخرج سوف يكون كما يلي:

openjdk version "25.0.5" 2026-10-20 LTS
OpenJDK Runtime Environment Temurin-25.0.5+8 (build 25.0.5+8-LTS)
OpenJDK 64-Bit Server VM Temurin-25.0.5+8 (build 25.0.5+8-LTS, mixed mode, sharing)

------------------------------------------------------------
Gradle 9.8.2
------------------------------------------------------------

Build time:    2026-10-19 16:29:26 UTC
Revision:      8de7bcf76e785507ad92f0741ab9235a430c95e6

Kotlin:        2.4.10
Groovy:        4.0.33

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

  • السطر الأول يقول إن الـ JDK هو 25.0.5 وأنه LTS، والسطر الثاني يحدد المورد Temurin ورقم البناء 8. وهذان هما ما تكتبه في ملف الإصدارات لأي مشروع.
  • السطر Kotlin: 2.4.10 هو أهم سطر في المخرج، وهو مصدر خلط شائع: هذا ليس إصدار Kotlin الذي سوف يترجم كودك، وإنما الإصدار المدمج داخل Gradle نفسه لتشغيل ملفات .gradle.kts. أما كودك فيترجمه الإصدار المكتوب في الإضافة kotlin("jvm") version "2.4.21" داخل ملف البناء، وسوف ترى الفرق بعد قليل.
  • الـ Image مبنية على Ubuntu 24.04، والمستخدم داخلها هو root، ولذلك نحدد مجلد ذاكرة Gradle بأنفسنا في Volume منفصل حتى لا تنزل المكتبات في كل تشغيل.

المترجم kotlinc وحده: أول برنامج Hello World

قبل Gradle سوف نبدأ بالمترجم وحده، والسبب أن Gradle يخفي عنك تفاصيل مهمة سوف تحتاجها يوم يظهر خطأ غريب في الإنتاج. نزلنا الملف kotlin-compiler-2.4.21.zip من صفحة الإصدار على GitHub، وحجمه 89,733,618 بايت، وتحققنا من الهاش SHA-256 مقارنة بالملف .sha256 المنشور بجانبه، ثم فككناه فصار مجلداً اسمه kotlinc بحجم 94 ميجابايت، وفيه الأدوات kotlinc وkotlin ومجلد lib بالمكتبات. والملف الأول الذي كتبناه هو التالي:

// أول ملف Kotlin: دالة main في أعلى الملف، ولا يوجد كلاس إلزامي كما في Java
fun main(args: Array<String>) {
    val name = args.firstOrNull() ?: "Kotlin"   // val: قيمة لا تتغير، والنوع String استنتجه المترجم
    var count = 0                               // var: متغير يتغير
    for (i in 1..3) count += i
    println("مرحباً $name، المجموع = $count، الطول = ${name.length}")
}

في الكود أعلاه لاحظ التالي، خاصة إذا كنت قادماً من Java:

  • الدالة main تقف وحدها في الملف، دون public class ودون static. وفي Kotlin تسمى هذه دالة في المستوى الأعلى Top-Level Function، وهي طبيعية تماماً وليست حيلة.
  • val تعني قيمة تعطيها مرة واحدة ولا تتغير، مثل final في Java، وvar متغير عادي. والقاعدة في كود Kotlin الجيد أن تبدأ دائماً بـ val، ولا تستخدم var إلا عندما تحتاج التغيير فعلاً.
  • لم نكتب نوع name ولا count، لأن المترجم يستنتجه من القيمة، وهذا الاستنتاج Type Inference لا يجعل اللغة ديناميكية مثل Python، فالنوع ثابت وقت الترجمة، ولو حاولت أن تضع رقماً في name بعد ذلك فسوف يرفض المترجم.
  • العلامة ?: اسمها عامل إلفيس Elvis Operator، ومعناها «إذا كان ما على اليسار null فخذ ما على اليمين». والدالة firstOrNull() ترجع null إذا كانت القائمة فارغة بدل أن ترمي استثناء.
  • النص "$name" قالب نصي String Template، مثل f-string في Python، و${name.length} لأي تعبير أطول من اسم متغير.

الآن سوف نقوم بترجمة الملف إلى ملف JAR يحتوي مكتبة Kotlin معه، ثم نشغله بـ java العادية، وكل ذلك داخل Container من الـ Image السابقة، والسكربت الكامل موجود في lab/hello/run-kotlinc.sh:

kotlinc -version
kotlinc Hello.kt -include-runtime -d hello.jar
ls -l hello.jar
java -jar hello.jar
java -jar hello.jar سلمان

والمخرج سوف يكون كما يلي:

info: kotlinc-jvm 2.4.21 (JRE 25.0.5+8-LTS)
compile seconds: 27.51
-rw-r--r-- 1 root root 5679656 Oct 31 07:18 hello.jar
مرحباً Kotlin، المجموع = 6، الطول = 6
مرحباً سلمان، المجموع = 6، الطول = 5

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

  • المترجم يعرف نفسه بـ kotlinc-jvm 2.4.21، ويقول إنه يعمل على JRE 25. فالمترجم نفسه برنامج JVM، ولذلك يحتاج الـ JDK حتى قبل أن يترجم سطراً واحداً.
  • ترجمة ملف من سبعة أسطر استغرقت 27.51 ثانية، وهذا رقم يصدم القادم من Go أو حتى من Python. والسبب أن كل استدعاء لـ kotlinc يشغل JVM جديدة من الصفر، ويحمل المترجم كاملاً، ثم يرمي كل شيء بعد ملف واحد، وأضف إلى ذلك أن الجهاز كان مشغولاً بتجارب أخرى في نفس الوقت. ولهذا السبب بالضبط يستخدم Gradle عملية خلفية تبقى حية اسمها Gradle Daemon، ومعها خادم ترجمة Kotlin Daemon، حتى لا تدفع تكلفة التشغيل في كل مرة.
  • حجم hello.jar حوالي 5.4 ميجابايت لبرنامج يطبع سطراً واحداً، لأن الخيار -include-runtime وضع داخله مكتبة Kotlin الأساسية وما تحتاجه. وهذا الحجم لا يتكرر مع كل ملف تضيفه، فهو تكلفة ثابتة تدفعها مرة واحدة.
  • النص العربي ظهر سليماً في الحالتين، لأن Kotlin تستخدم UTF-8 لملفات المصدر افتراضياً، والـ JVM الحديثة تستخدم UTF-8 للمخرج منذ JDK 18.

الخطأ الأول NoClassDefFoundError: أين ذهبت مكتبة kotlin-stdlib؟

الخطأ الحقيقي الأول الذي ظهر لنا كان في التجربة التالية، حيث ترجمنا نفس الملف إلى مجلد عادي دون الخيار -include-runtime، وهذا ما يفعله أي شخص قادم من Java يريد ملفات .class فقط:

kotlinc Hello.kt -d classes
find classes -type f | sort
javap -p -cp classes HelloKt
java -cp classes HelloKt

والمخرج سوف يكون كما يلي:

classes/HelloKt.class
classes/META-INF/main.kotlin_module
Compiled from "Hello.kt"
public final class HelloKt {
  public static final void main(java.lang.String[]);
}
Exception in thread "main" java.lang.NoClassDefFoundError: kotlin/jvm/internal/Intrinsics
	at HelloKt.main(Hello.kt)
Caused by: java.lang.ClassNotFoundException: kotlin.jvm.internal.Intrinsics
	at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:580)

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

  • الأداة javap من الـ JDK قرأت الملف المترجم، وكشفت شيئاً مهماً: الدالة التي كتبناها في المستوى الأعلى صارت دالة static داخل كلاس اسمه HelloKt، أي اسم الملف مع اللاحقة Kt. وهذا ما يجعل Java تستطيع أن تستدعي أي دالة Kotlin، وهو أيضاً ما سوف تكتبه في mainClass داخل Gradle بعد قليل.
  • الكلاس kotlin.jvm.internal.Intrinsics غير موجود. وهذا الكلاس ليس من كودنا، وإنما من مكتبة Kotlin الأساسية kotlin-stdlib، والمترجم أضاف استدعاء له في بداية main ليتحقق أن المصفوفة args ليست null، لأن نوعها في Kotlin Array<String> وليس Array<String>?. فحتى برنامج من سبعة أسطر يعتمد على المكتبة الأساسية، والـ JVM وحدها لا تعرفها.

والإصلاح أن تضع المكتبة في الـ Classpath، والملف kotlin-stdlib.jar موجود في مجلد lib من المترجم وحجمه 1,853,315 بايت:

java -cp classes:/opt/kotlinc/lib/kotlin-stdlib.jar HelloKt
مرحباً Kotlin، المجموع = 6، الطول = 6

وهذا الدرس سوف يتكرر معك بأشكال أخرى، فكل برنامج Kotlin على الـ JVM يحتاج المكتبة الأساسية وقت التشغيل، وأداة البناء هي التي تضمن وجودها: Gradle يضعها في مجلد lib عند التوزيع، أو داخل ملف JAR واحد إذا استخدمت إضافة تجمع الاعتماديات. وإذا ظهر لك NoClassDefFoundError لكلاس يبدأ بـ kotlin. في Docker Image أو على سيرفر، فالسبب غالباً أنك نسخت ملف JAR الخاص بالمشروع وحده دون اعتمادياته.

سكربتات Kotlin Scripts: ملف main.kts يعمل دون مشروع

وللمهام الصغيرة، مثل سكربت يقرأ ملف CSV أو يجرب فكرة، تدعم Kotlin ملفات السكربت التي تنتهي بـ .main.kts، وهي تعمل مباشرة دون دالة main ودون مشروع، وتستطيع أن تطلب مكتبات من Maven في رأس الملف كما تشرح صفحة Kotlin scripting. والسكربت الذي جربناه هو التالي:

// سكربت Kotlin: يعمل مباشرة دون مشروع ودون main
data class Product(val nameAr: String, val priceHalalas: Int)

val products = listOf(Product("قهوة عربية", 4500), Product("تمر سكري", 3200), Product("هيل", 990))
products.filter { it.priceHalalas > 1000 }
    .sortedBy { it.priceHalalas }
    .forEach { println("${it.nameAr}: ${it.priceHalalas / 100.0} ريال") }
println(products.first())
kotlin prices.main.kts
تمر سكري: 32.0 ريال
قهوة عربية: 45.0 ريال
Product(nameAr=قهوة عربية, priceHalalas=4500)
script seconds: 8.21

والسكربت أخرج المنتجين اللذين سعرهما أكثر من 10 ريالات مرتبين بالسعر، ثم طبع أول منتج بصيغة Product(nameAr=..., priceHalalas=...) دون أن نكتب دالة toString، وهذه أول مرة ترى فيها ما يعطيك إياه data class، وسوف نعود إليه بعد قليل. واستغرق التشغيل 8.21 ثانية لأن السكربت يترجم أولاً ثم يعمل، وهذا مقبول لأداة تشغلها يدوياً، وغير مناسب لأي شيء يعمل كثيراً. ولاحظ أيضاً أننا قسمنا الهللات على 100.0 وليس 100، لأن قسمة عددين صحيحين في Kotlin تعطي عدداً صحيحاً مثل Java، وليس رقماً عشرياً مثل / في Python 3، وهذه من أول المفاجآت للقادم من Python.

جولة في الصيغة Syntax: ما الذي يختلف عن Java أو Python؟

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

جدول يقارن صيغة Kotlin بصيغة Java وPython في التعريف والقيم الفارغة والكلاسات والشروط والدوال
نفس الأفكار التي تعرفها، بكلمات مختلفة: أغلب الجديد في Kotlin هو نوع null وكلاسات البيانات والتعبير when
الفكرةJavaPythonKotlin
قيمة ثابتةfinal var x = 5;x = 5 (لا يوجد ثابت حقيقي)val x = 5
متغيرvar x = 5;x = 5var x = 5
قيمة قد تكون فارغةأي مرجع قد يكون nullأي متغير قد يكون NoneString? فقط، وString لا يقبل null
كلاس بياناتrecord Product(...)@dataclassdata class Product(...)
تفرع على الحالاتswitch مع case ... ->matchwhen، وهو تعبير يرجع قيمة
دالة قصيرةطريقة داخل كلاسdef f(x): return x * 2fun f(x: Int) = x * 2
دوال ثابتةstaticدالة في الوحدة Moduleدالة في المستوى الأعلى، أو companion object
مقارنة المحتوىa.equals(b)a == ba == b (تستدعي equals)
مقارنة المرجعa == ba is ba === b
الاستثناءات المفحوصةموجودة throwsغير موجودةغير موجودة

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

القيم الفارغة Null Safety: النوع String? غير النوع String

أهم فكرة في Kotlin كلها هي أن null جزء من النوع. فالمتغير من النوع String لا يمكن أن يحمل null أبداً، والمترجم يرفض أي كود يحاول ذلك، أما إذا أردت قيمة قد تكون فارغة فتكتب String?، وعندها لا يسمح لك المترجم أن تستدعي عليها أي دالة مباشرة حتى تتعامل مع حالة null. والمثال التالي من ملف Main.kt في مشروعنا:

val byId = products.associateBy { it.id } // Map<Int, Product>
val wanted: Product? = byId[7] // النوع Product? يعني: قد تكون null
println("المنتج 7: ${wanted?.nameAr ?: "غير موجود"}")

والقراءة من Map بمفتاح غير موجود ترجع null، ولذلك نوع byId[7] هو Product? وليس Product. ولو كتبت wanted.nameAr مباشرة لرفض المترجم البناء برسالة تقول إن الاستدعاء غير آمن على نوع قد يكون null. والعلامة ?. اسمها الاستدعاء الآمن Safe Call، وتعني «إذا كانت القيمة null فالنتيجة null، وإلا استدع الخاصية»، ومعها ?: تعطي القيمة البديلة، فطبع البرنامج «المنتج 7: غير موجود» بدل أن يرمي استثناء. وللمقارنة، نفس السطر في Java يرمي NullPointerException وقت التشغيل، وفي Python يرمي AttributeError: 'NoneType' object has no attribute، وكلاهما يظهر للمستخدم وليس لك. وتشرح صفحة Null safety باقي الأدوات، ومنها العلامة !! التي تقول للمترجم «أنا متأكد أنها ليست null»، وهي ترمي NullPointerException إذا كنت مخطئاً، ولذلك لا ينصح باستخدامها إلا نادراً، وسوف نجربها مع حالات أخرى في المقال الثاني من المرحلة.

كلاسات البيانات Data Classes والقيم الافتراضية Default Arguments

والملف Product.kt في المشروع يعرف المنتج بهذا الشكل:

package shop

// data class: equals و hashCode و toString و copy يكتبها المترجم
data class Product(
    val id: Int,
    val nameAr: String,
    val priceHalalas: Int,
    val stock: Int = 0, // قيمة افتراضية بدل كتابة أكثر من Constructor
)

// دالة امتداد Extension Function: نضيف دالة إلى Int دون أن نرث منه
fun Int.asRiyals(): String = "%d.%02d ريال".format(this / 100, this % 100)

fun Product.isAvailable(qty: Int): Boolean = qty in 1..stock

في الكود أعلاه لاحظ التالي:

  • السطر الذي يبدأ بـ data class يعرف الكلاس والـ Constructor والخصائص Properties في مكان واحد. والكلمة data تجعل المترجم يكتب الدوال equals وhashCode وtoString وcopy بناء على هذه الخصائص، وهي نفس فكرة record في Java الحديثة و@dataclass في Python، وتشرحها صفحة Data classes.
  • الخاصية stock لها قيمة افتراضية Default Argument، وفي Java كنت سوف تكتب Constructor ثانياً أو نمط Builder لهذا الغرض. وعند الاستدعاء تستطيع أن تسمي المعامل Named Argument كما في Python: Product(1, "قهوة عربية", 4500, stock = 12).
  • الفاصلة بعد آخر معامل Trailing Comma مسموحة، وهي مفيدة في مراجعة الكود لأن إضافة سطر جديد لا تغير السطر الذي قبله.
  • كل المبالغ بالهللات من النوع Int وليس Double، وهذه قاعدة سوف نلتزم بها في كل المرحلة، والسبب شرحناه في مقال كيف تختار لغة البرمجة لمشروعك حين ظهر فرق هللة واحدة بين اللغات بسبب التقريب.

والدالة copy مهمة لأن الخصائص val لا تتغير، فإذا أردت منتجاً بسعر مختلف فأنت تنشئ نسخة جديدة. والسطران التاليان من Main.kt:

val cheaper = products[0].copy(priceHalalas = 4000)
println(cheaper)
println(cheaper == products[0].copy(priceHalalas = 4000))

أخرجا السطرين Product(id=1, nameAr=قهوة عربية, priceHalalas=4000, stock=12) وtrue، فالنسخة احتفظت بكل الخصائص الأخرى، والمقارنة بـ == قارنت المحتوى بين كائنين مختلفين في الذاكرة فأعطت true.

دوال الامتداد Extension Functions: دالة جديدة على كلاس لا تملكه

والسطر fun Int.asRiyals() يضيف دالة إلى النوع Int نفسه، فتستطيع أن تكتب 2970.asRiyals() كأنها دالة أصلية فيه. وهذه ليست وراثة، ولا تغير كلاس Integer في الـ JVM، وإنما المترجم يحولها إلى دالة static عادية تأخذ الرقم كأول معامل، تماماً مثل الدوال المساعدة StringUtils.format(x) في Java، ولكن بصيغة أسهل في القراءة. وتشرح صفحة Extensions قاعدة مهمة هنا: دوال الامتداد تختار وقت الترجمة حسب النوع المعلن، ولا تعمل مثل الدوال الافتراضية Virtual Methods، ولذلك لا تستخدمها لتغيير سلوك كلاس في الوراثة. والمكتبة الأساسية في Kotlin مبنية كلها تقريباً بهذه الطريقة، فالدوال filter وmap وfirstOrNull التي استخدمناها على القوائم هي دوال امتداد على واجهات Iterable من Java.

التعبير when والأنواع المغلقة Sealed Types

والملف Pricing.kt فيه أهم فكرتين بعد null، وهما الأنواع المغلقة Sealed والتعبير when:

package shop

const val VAT_PERCENT = 15

// الضريبة بالهللات مع التقريب للأعلى عند النصف Half Up، بالأعداد الصحيحة فقط
fun vatHalalas(subtotal: Int): Int = (subtotal * VAT_PERCENT + 50) / 100

sealed interface Line {
    data class Item(val product: Product, val qty: Int) : Line
    data class Discount(val halalas: Int) : Line
}

fun subtotal(lines: List<Line>): Int =
    lines.sumOf { line ->
        when (line) { // when على sealed: المترجم يعرف كل الحالات، ولا يحتاج else
            is Line.Item -> line.product.priceHalalas * line.qty // smart cast: line صار Item هنا
            is Line.Discount -> -line.halalas
        }
    }

في الكود أعلاه لاحظ التالي:

  • الواجهة sealed interface Line تقول للمترجم إن كل الأنواع التي تطبقها معروفة ومكتوبة في نفس الوحدة، وهي هنا نوعان فقط: سطر منتج وسطر خصم. وتشرح صفحة Sealed classes and interfaces هذه الفكرة، وهي تقابل sealed interface في Java 17 وما بعدها.
  • التعبير when هنا يرجع قيمة، ولم نكتب فيه else، لأن المترجم يعرف أن Line لها حالتان فقط وقد غطيناهما. ولو أضاف أحد في المستقبل نوعاً ثالثاً مثل Line.Shipping، فسوف يفشل البناء في هذا السطر بالذات حتى يتعامل معه، وهذا أفضل بكثير من حالة default تبتلع النوع الجديد بصمت.
  • بعد is Line.Item استخدمنا line.product مباشرة دون تحويل Cast، لأن المترجم يعرف أن line صار من النوع Item داخل هذا الفرع، وهذا اسمه التحويل الذكي Smart Cast.
  • دالة الضريبة تحسب بالأعداد الصحيحة: نضرب في 15 ونضيف 50 ثم نقسم على 100، فيقرب النصف للأعلى Half Up دون أي Double. وهذه نفس الدالة التي سوف نبني عليها الخدمة الحقيقية في المقال الرابع.
  • const val ثابت وقت الترجمة Compile-Time Constant، ويقابل static final في Java، ولا يقبل إلا الأرقام والنصوص.

ولاحظ أيضاً أن الدالة vatHalalas كتبت بعلامة = بدل الأقواس وreturn، وهذا اسمه جسم التعبير Expression Body، ويستخدم عندما تكون الدالة تعبيراً واحداً. والصورة العامة أن Kotlin تحب التعبيرات Expressions أكثر من الجمل Statements: if تعبير يرجع قيمة، وwhen تعبير، وtry تعبير، ولذلك لا يوجد في Kotlin العامل الثلاثي a ? b : c لأن if (a) b else c تقوم بنفس العمل.

القوائم Collections والدوال المجهولة Lambdas

والقادم من Python سوف يجد نفسه في بيته مع القوائم، فالكود التالي من Main.kt:

products.filterNot { it.isAvailable(1) }.forEach { println("نفد: ${it.nameAr}") }

يقابل في Python [p for p in products if not available(p)]، وفي Java products.stream().filter(...).forEach(...). والأقواس المعقوفة { ... } بعد اسم الدالة هي دالة مجهولة Lambda، والكلمة it هي اسم المعامل الوحيد إذا لم تسمه. وعندما يكون آخر معامل في الدالة Lambda، تستطيع أن تكتبها خارج الأقواس العادية، وهذه القاعدة اسمها Trailing Lambda، وهي التي تجعل كود Kotlin يبدو كأنه لغة خاصة، كما سوف ترى في ملفات Gradle نفسها، وفي مسارات Ktor في مقال الخدمة.

وهناك فرق واحد مهم عن Python يجب أن تتذكره: listOf(...) ترجع قائمة للقراءة فقط Read-Only من النوع List، ولا تستطيع أن تضيف إليها، وإذا أردت قائمة تتغير فتستخدم mutableListOf(...) ونوعها MutableList. والقائمة للقراءة فقط ليست ثابتة تماماً Immutable في الذاكرة، فهي غالباً ArrayList من Java خلف واجهة لا تعرض دوال التعديل، وهذا فرق دقيق سوف نجربه في مقال الأخطاء الشائعة من هذه المرحلة.

وعند تشغيل البرنامج كاملاً بـ gradle run كان المخرج كما يلي:

المنتج 7: غير موجود
المجموع 69.70 ريال، الضريبة 10.46 ريال، الإجمالي 80.16 ريال
نفد: تمر سكري
Product(id=1, nameAr=قهوة عربية, priceHalalas=4000, stock=12)
true

والسطر الثاني يستحق أن نتحقق منه بأنفسنا: ثلاث علب هيل بـ 9.90 ريال تساوي 29.70، وقهوة بـ 45 ريالاً، وخصم 5 ريالات، فالمجموع 69.70 ريال أي 6,970 هللة. والضريبة 15% تساوي 1,045.5 هللة، والنصف يقرب للأعلى فتصبح 1,046 هللة، أي 10.46 ريال، والإجمالي 80.16 ريال. والسطر الثالث يقول إن التمر نفد، لأن مخزونه صفر، والدالة isAvailable(1) تسأل هل الرقم 1 داخل المدى 1..0، وهو مدى فارغ.

مشروع Gradle من الصفر: الأمر gradle init وبنية المشروع Project Layout

الآن سوف نترك المترجم وحده وننتقل إلى الطريقة التي سوف تعمل بها كل يوم. والبداية الأسرع هي أن تطلب من Gradle أن ينشئ لك مشروعاً كاملاً:

gradle init --type kotlin-application --dsl kotlin --project-name demo --package demo \
  --no-split-project --java-version 25 --test-framework kotlintest --use-defaults

واستغرق الأمر 42.8 ثانية في المرة الأولى، وأنشأ الملفات التالية:

./app/build.gradle.kts
./app/src/main/kotlin/demo/App.kt
./app/src/test/kotlin/demo/AppTest.kt
./.gitattributes
./.gitignore
./gradle/libs.versions.toml
./gradle.properties
./gradlew
./gradlew.bat
./gradle/wrapper/gradle-wrapper.jar
./gradle/wrapper/gradle-wrapper.properties
./settings.gradle.kts

والصورة التالية تشرح دور كل ملف، وبعدها التفاصيل:

شجرة مشروع Gradle بلغة Kotlin مع شرح دور كل ملف: settings.gradle.kts وbuild.gradle.kts وlibs.versions.toml وgradlew ومجلدات src
بنية مشروع Kotlin على Gradle: نفس اصطلاح Maven للمجلدات، وملفات البناء نفسها مكتوبة بـ Kotlin
  • settings.gradle.kts: اسم المشروع والوحدات Modules التي فيه. وهنا وحدة واحدة اسمها app، وفي المشاريع الكبيرة تجد وحدات مثل domain وapi.
  • app/build.gradle.kts: ملف بناء الوحدة: الإضافات Plugins والاعتماديات Dependencies وإعدادات المترجم. واللاحقة .kts تعني أنه مكتوب بـ Kotlin نفسها Kotlin DSL، وليس بـ Groovy كما في المشاريع القديمة التي تجد فيها build.gradle فقط.
  • gradle/libs.versions.toml: كتالوج الإصدارات Version Catalog، وهو ملف واحد تكتب فيه كل أرقام الإصدارات للمكتبات والإضافات، ثم تشير إليها الملفات الأخرى بأسماء مثل libs.guava. وتشرح وثائق Gradle فكرته في صفحة Version Catalogs، وفائدته تظهر عندما يكون عندك عشر وحدات تستخدم نفس المكتبة.
  • gradlew وgradle/wrapper: غلاف Gradle Wrapper، وهو سكربت صغير ينزل إصدار Gradle المحدد في gradle-wrapper.properties ويشغله. والقاعدة في أي مشروع حقيقي أن تشغل ./gradlew وليس gradle المثبت على جهازك، حتى يبني كل المطورين وخط CI بنفس الإصدار. ونحن في المختبر استخدمنا gradle مباشرة لأن الـ Image تحدد الإصدار أصلاً.
  • src/main/kotlin وsrc/test/kotlin: الكود والاختبارات، وهو نفس اصطلاح Maven مع kotlin بدل java. وتستطيع أن تضع ملفات Java في src/main/java بجانبها في نفس الوحدة، والمترجمان يريان بعضهما.

وعند قراءة الملف المولد ظهر شيء يستحق الانتباه في libs.versions.toml:

[versions]
guava = "33.5.0-jre"
junit-jupiter-engine = "6.0.1"

[plugins]
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version = "2.4.10" }

فالأمر gradle init كتب إضافة Kotlin بالإصدار 2.4.10، وهو الإصدار المدمج في Gradle 9.8.2 كما رأينا في مخرج gradle --version، وليس آخر إصدار مستقر وهو 2.4.21. وهذا ليس خطأ، ولكنه يعني أن المشروع الجديد يبدأ متأخراً بإصدارين للإصلاحات، ولذلك أول ما تفعله بعد gradle init هو أن تراجع أرقام الإصدارات في الكتالوج وترفعها إلى آخر إصدار مستقر. ولاحظ أيضاً أن الملف المولد أضاف مكتبة Guava كمثال، وهي غير ضرورية في أغلب المشاريع، وأن JUnit هنا هو الإصدار 6.0.1، بينما آخر إصدار من JUnit 6 وقت كتابة المقال هو 6.1.3.

ملف البناء build.gradle.kts سطراً بسطر

وبدلاً من المشروع المولد بنينا مشروعنا الخاص في المجلد lab/shop بوحدة واحدة وملف بناء صغير نفهم كل سطر فيه:

plugins {
    kotlin("jvm") version "2.4.21"
    application
    id("org.jlleitschuh.gradle.ktlint") version "14.2.0"
    id("io.gitlab.arturbosch.detekt") version "1.23.8"
}

repositories { mavenCentral() }

dependencies {
    testImplementation(kotlin("test"))
}

kotlin { jvmToolchain(25) }

application { mainClass.set("shop.MainKt") }

ktlint { version.set("1.8.0") }

tasks.test { useJUnitPlatform() }

في الإعداد أعلاه لاحظ التالي:

  • kotlin("jvm") version "2.4.21" هو السطر الذي يحدد إصدار Kotlin الذي يترجم كودك. فإذا سألك أحد «أي إصدار Kotlin تستخدمون؟» فالإجابة هنا وليس في مخرج gradle --version.
  • application إضافة من Gradle نفسه، تعطيك المهمة run لتشغيل البرنامج، والمهمة installDist لتجهيز مجلد توزيع فيه سكربت تشغيل وكل الاعتماديات.
  • kotlin { jvmToolchain(25) } يقول إن هذا المشروع يترجم ويختبر بـ JDK 25، بغض النظر عن الـ JDK الذي يشغل Gradle نفسه. وهذه فكرة سلسلة الأدوات Toolchains التي تشرحها وثائق Gradle، وتشرح وثائق Kotlin علاقتها بالخيار jvmTarget. وتأكدنا أن Kotlin 2.4.21 يقبل JDK 25 هدفاً دون أي تحذير، فالـ Bytecode الناتج من الإصدار 69 الخاص بـ Java 25.
  • mainClass.set("shop.MainKt"): اسم الكلاس الذي يحتوي main. ولأن main في الملف Main.kt داخل الحزمة shop، فاسم الكلاس هو shop.MainKt، وهذا بالضبط ما كشفته الأداة javap في التجربة الأولى.
  • testImplementation(kotlin("test")) يضيف مكتبة kotlin.test، وهي طبقة رقيقة فوق JUnit، ولذلك نحتاج useJUnitPlatform() في مهمة الاختبار.
  • الإضافتان الأخيرتان لأدوات الفحص، وسوف نجربهما بعد قليل، وإحداهما هي التي فشلت معنا.

البناء والتشغيل والاختبار Build, Run, Test: كم يستغرق كل منها؟

والاختبار الذي كتبناه في src/test/kotlin/shop/PricingTest.kt يتحقق من الحالة التي سوف تتكرر في كل لغات المسار، وهي ثلاث علب قهوة بـ 9.90 ريال:

package shop

import kotlin.test.Test
import kotlin.test.assertEquals

class PricingTest {
    @Test
    fun `vat of 29_70 riyals rounds half up to 4_46`() {
        assertEquals(446, vatHalalas(2970)) // 4.455 ريال تصبح 4.46
    }

    @Test
    fun `discount line lowers the subtotal`() {
        val p = Product(1, "هيل", 990, stock = 5)
        assertEquals(2470, subtotal(listOf(Line.Item(p, 3), Line.Discount(500))))
    }
}

ولاحظ أسماء الدوال بين علامتي `، فـ Kotlin تسمح بأسماء فيها مسافات بهذه الطريقة، والعرف أن تستخدمها في الاختبارات فقط حتى يقرأ تقرير الاختبار كجملة. ثم شغلنا الأوامر الأساسية بالترتيب، وكل أمر داخل Container جديد يشترك مع غيره في Volume واحد لذاكرة Gradle، والسكربت الكامل في lab/run-gradle.sh:

gradle run --console=plain -q
gradle test --console=plain
gradle run --console=plain -q
gradle installDist --console=plain -q
build/install/shop/bin/shop

ومخرج الاختبار كان كما يلي، بعد حذف أسطر التحذيرات العامة من Gradle:

> Task :compileKotlin UP-TO-DATE
> Task :compileJava NO-SOURCE
> Task :classes UP-TO-DATE
> Task :jar
> Task :compileTestKotlin
> Task :testClasses UP-TO-DATE
> Task :test

BUILD SUCCESSFUL in 19s
4 actionable tasks: 3 executed, 1 up-to-date
<testcase name="vat of 29_70 riyals rounds half up to 4_46()" classname="shop.PricingTest" time="0.115"/>
<testcase name="discount line lowers the subtotal()" classname="shop.PricingTest" time="0.037"/>

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

  • المهمة compileKotlin كانت UP-TO-DATE، لأن gradle run قبلها ترجم الكود، وGradle يعرف أن المصدر لم يتغير فلا يعيد الترجمة. وهذا البناء التدريجي Incremental Build هو السبب في أن الأمر الثاني أسرع من الأول بكثير.
  • compileJava NO-SOURCE تعني أن Gradle بحث عن ملفات Java في src/main/java فلم يجد، وهذا طبيعي، وهو يذكرك أن المشروع جاهز لكود مختلط.
  • تقرير JUnit بصيغة XML يحتوي الاختبارين بأسمائهما الكاملة، واستغرق الأول 115 جزءاً من الألف من الثانية لأنه يحمل الكلاسات لأول مرة، والثاني 37.

والجدول التالي يجمع الأزمنة التي قسناها، وهي أزمنة الأمر كاملاً من الـ Shell، أي تشمل تشغيل Gradle وفحص الملفات وليس الترجمة وحدها:

أعمدة تقارن زمن gradle run في المرة الأولى بذاكرة فارغة 552 ثانية، ثم 97 ثانية مع ذاكرة ممتلئة وDaemon جديد، ثم 8 إلى 12 ثانية في المرة الثانية، وتشغيل البرنامج من installDist بين 0.6 و1.6 ثانية
أغلب وقت المرة الأولى تنزيل، وأغلب وقت المرة الثانية تشغيل Gradle نفسه، والبرنامج المبني يعمل في أقل من ثانيتين
الأمرالحالةالزمن
gradle runأول مرة، ذاكرة Gradle فارغة552.1 ثانية
gradle testبعدها مباشرة56.1 ثانية
gradle runالمكتبات موجودة، Container جديد97.0 ثانية
gradle testبعدها19.8 ثانية
gradle runالمرة الثانية دون تغيير8.1 و12.1 ثانية في تشغيلين
gradle installDistبعد البناء10.6 و15.5 ثانية
build/install/shop/bin/shopالبرنامج المبني وحده1.6 ثم 0.6 ثم 0.9 ثانية

والأرقام تحتاج قراءة صحيحة حتى لا تحكم على اللغة من الرقم الأول. فالتشغيل الأول استغرق أكثر من تسع دقائق، وأغلب هذا الوقت تنزيل: إضافة Kotlin للـ Gradle، والمترجم المدمج Kotlin Compiler Embeddable، وإضافتا ktlint وdetekt ومكتباتهما، ومكتبة JUnit، وكل ذلك من الإنترنت إلى ذاكرة Gradle في الـ Volume. وهذا يحدث مرة واحدة على كل جهاز وكل خط CI جديد، ولذلك من أهم إعدادات أي خط CI لمشروع Kotlin أن يحفظ مجلد ذاكرة Gradle بين التشغيلات. والتشغيل الذي استغرق 97 ثانية كانت فيه المكتبات موجودة، ولكن كل Container يبدأ دون Gradle Daemon، فيشغل Gradle من الصفر ويترجم ملفات البناء ويشغل Kotlin Daemon، وهذا وضع خط CI عادي. أما على جهازك فالـ Daemon يبقى حياً بين الأوامر، والمرة الثانية تأخذ ثوان معدودة كما رأيت في الصف الخامس. وكل هذه الأرقام من جهاز كان يشغل تجارب أخرى في نفس الوقت، فخذها كترتيب حجم وليس كقياس دقيق.

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

build/install/shop/bin/shop
build/install/shop/bin/shop.bat
build/install/shop/lib/annotations-13.0.jar
build/install/shop/lib/kotlin-stdlib-2.4.21.jar
build/install/shop/lib/shop.jar
20K	build/install/shop/lib/annotations-13.0.jar
1.8M	build/install/shop/lib/kotlin-stdlib-2.4.21.jar
12K	build/install/shop/lib/shop.jar

فكودنا كله 12 كيلوبايت، والمكتبة الأساسية 1.8 ميجابايت، وهي نفس المكتبة التي غابت في تجربة NoClassDefFoundError، ومعها مكتبة صغيرة اسمها annotations-13.0.jar تحمل علامات @Nullable و@NotNull التي تحتاجها المكتبة الأساسية. والسكربت bin/shop يبني الـ Classpath من مجلد lib ويشغل java، وهذا المجلد بالضبط هو ما سوف ننسخه إلى Docker Image في مقال الخدمة الحقيقية.

أدوات التنسيق Formatter والفحص Linter: ktlint، ktfmt، detekt

في Python تعودت على black وruff، وفي JavaScript على Prettier وESLint، وفي Kotlin الأدوات الشائعة ثلاث، والصورة التالية تبين دور كل واحدة:

ثلاث أدوات: ktlint للتنسيق والقواعد الشكلية، وktfmt للتنسيق فقط، وdetekt للفحص الأعمق، مع المشكلة التي ظهرت عند تشغيل detekt 1.23.8 على JDK 25
التنسيق والفحص أداتان مختلفتان، والمشكلة التي ظهرت لنا كانت في أداة الفحص مع JDK 25
  • ktlint: أداة من Pinterest تفحص الكود حسب أعراف كتابة Kotlin الرسمية Coding Conventions وتصلح أغلب المخالفات تلقائياً، وتعمل من سطر الأوامر أو عبر إضافة ktlint-gradle التي استخدمناها. ووثائقها في مستودع ktlint.
  • ktfmt: أداة من Meta تنسق الكود فقط ولا تسألك عن رأيك، مثل black في Python، ومصدرها في مستودع ktfmt. وتختار بينها وبين ktlint، ولا تستخدم الاثنتين معاً على نفس الكود لأن كلاً منهما سوف يعيد تنسيق ما كتبته الأخرى.
  • detekt: أداة فحص ثابت Static Analysis أعمق، تبحث عن الدوال الطويلة والأرقام السحرية Magic Numbers والاستثناءات التي تبتلع، وتشبه SonarQube أو قواعد ruff الأعمق. ووثائقها في detekt.dev.

ولنرى ktlint يعمل أضفنا إلى نسخة من المشروع دالة كتبناها بتنسيق سيء عمداً: مسافات زائدة بعد fun، ونوع ملتصق بالنقطتين، وقوس في غير مكانه:

fun   badlyFormatted(x:Int):Int{
      return x*2 }
gradle ktlintCheck --console=plain

والمخرج سوف يكون كما يلي، بعد حذف أسطر التحذير عن sun.misc.Unsafe التي سوف نشرحها بعد قليل:

> Task :ktlintMainSourceSetCheck FAILED
/w/shop/src/main/kotlin/shop/Main.kt:4:20 A multiline expression should start on a new line
/w/shop/src/main/kotlin/shop/Pricing.kt:9:21 Newline expected after opening parenthesis
/w/shop/src/main/kotlin/shop/Pricing.kt:9:43 Parameter should start on a newline
/w/shop/src/main/kotlin/shop/Pricing.kt:9:55 Newline expected before closing parenthesis
/w/shop/src/main/kotlin/shop/Pricing.kt:10:5 Expected a blank line for this declaration
/w/shop/src/main/kotlin/shop/Pricing.kt:10:25 Newline expected after opening parenthesis
/w/shop/src/main/kotlin/shop/Pricing.kt:10:41 Newline expected before closing parenthesis
/w/shop/src/main/kotlin/shop/Pricing.kt:16:66 Add a blank line between all when-conditions in case at least one multiline when-condition is found in the statement
/w/shop/src/main/kotlin/shop/Product.kt:16:4 Single space expected after the fun keyword
/w/shop/src/main/kotlin/shop/Product.kt:16:5 Unnecessary long whitespace

والمفاجأة في هذا المخرج أن ktlint لم يعترض على الدالة السيئة وحدها، وإنما على كودنا «النظيف» أيضاً، ففي Pricing.kt كتبنا تعريف sealed interface بأسطر مضغوطة، والأداة تريد كل معامل في سطر، وسطراً فارغاً بين الحالات في when إذا كانت إحداها متعددة الأسطر، وفي Main.kt تريد أن يبدأ التعبير listOf( في سطر جديد. وكل سطر في المخرج فيه اسم الملف ورقم السطر ورقم العمود والقاعدة، فتستطيع أن تذهب إلى المكان مباشرة. وهذه المخالفات ليست أخطاء في البرنامج، وإنما في الشكل، ولكن قيمة الأداة أن الفريق كله يكتب بشكل واحد، فتنظر في مراجعة الكود Code Review إلى المنطق وليس إلى المسافات. ثم شغلنا الإصلاح التلقائي:

gradle ktlintFormat --console=plain -q
tail -4 src/main/kotlin/shop/Product.kt
fun Product.isAvailable(qty: Int): Boolean = qty in 1..stock

fun badlyFormatted(x: Int): Int = x * 2

ولاحظ أن الأداة لم تصلح المسافات فقط، وإنما حولت الدالة من جسم بأقواس وreturn إلى جسم تعبير Expression Body بعلامة =، وهذا هو الشكل المفضل في أعراف Kotlin لدالة من تعبير واحد. والقاعدة العملية أن تشغل ktlintFormat قبل كل Commit، وktlintCheck في خط CI حتى يفشل البناء إذا نسي أحد.

أما أسطر التحذير التي حذفناها من المخرج فكانت تتكرر بهذا الشكل:

WARNING: A terminally deprecated method in sun.misc.Unsafe has been called
WARNING: sun.misc.Unsafe::objectFieldOffset has been called by org.jetbrains.kotlin.com.intellij.util.containers.Unsafe (file:/home/gradle/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-compiler-embeddable/2.2.21/...)
WARNING: sun.misc.Unsafe::objectFieldOffset will be removed in a future release

والتحذير يأتي من الـ JDK 25 نفسه، فهو يحذر من أن دوال الذاكرة في sun.misc.Unsafe سوف تحذف في إصدار قادم، والكود الذي استدعاها جزء من المترجم المدمج داخل ktlint، ولاحظ الإصدار في المسار: 2.2.21، وليس 2.4.21 الذي نستخدمه. فأدوات الفحص تحمل معها نسخة خاصة من مترجم Kotlin لتحليل الكود، وهذه النسخة قد تكون أقدم من لغتك. وهذا التحذير لا يوقف شيئاً اليوم، ولكنه يشرح المشكلة التالية.

المشكلة التي ظهرت: detekt مع JDK 25

وعند تشغيل gradle detekt بالإصدار المستقر الأخير من الأداة، وهو 1.23.8، فشل البناء بهذه الرسالة:

> Task :detekt FAILED
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':detekt' (registered by plugin 'io.gitlab.arturbosch.detekt').
> Invalid value (25) passed to --jvm-target, must be one of [1.6, 1.8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22]

والرسالة واضحة: الإضافة قرأت jvmToolchain(25) من مشروعنا، ومررت 25 كهدف لمحلل detekt، والمحلل لا يعرف إلا الأهداف حتى 22، لأنه مبني على مترجم Kotlin أقدم من JDK 25. والحل الذي تقترحه وثائق الأداة لمثل هذه الحالة هو أن تحدد هدفاً مختلفاً لمهمة detekt وحدها، دون أن تغير هدف المشروع، فأضفنا السطر التالي إلى ملف البناء:

tasks.withType<io.gitlab.arturbosch.detekt.Detekt>().configureEach { jvmTarget = "22" }

وشغلنا المهمة مرة ثانية، ففشلت برسالة أقصر وأغرب:

> Task :detekt FAILED
* What went wrong:
Execution failed for task ':detekt' (registered by plugin 'io.gitlab.arturbosch.detekt').
> 25.0.5

والرسالة 25.0.5 وحدها هي رقم إصدار الـ JDK الذي يشغل Gradle، وهذا يعني أن detekt 1.23.8 لم يستطع أن يفهم إصدار الـ JVM التي يعمل عليها أصلاً، فالمشكلة ليست في الهدف وإنما في البيئة. وهذا درس مهم لأي قادم إلى منظومة الـ JVM: اللغة والـ JDK والأدوات تتحرك كل منها بإيقاع مختلف، وعندما تستخدم أحدث JDK فقد تسبق بعض الأدوات بأشهر.

والإصدار القادم من الأداة، وهو detekt 2.0، ما زال تجريبياً Alpha وقت كتابة المقال، وقد غير اسم الإضافة إلى dev.detekt، فجربناه بتغيير سطر واحد في ملف البناء:

id("dev.detekt") version "2.0.0-alpha.6"
> Task :detekt
Successfully generated markdown at file:///w/shop/build/reports/detekt/detekt.md
Successfully generated html at file:///w/shop/build/reports/detekt/detekt.html
Successfully generated sarif at file:///w/shop/build/reports/detekt/detekt.sarif
Successfully generated checkstyle at file:///w/shop/build/reports/detekt/detekt.xml
e: /w/shop/src/main/kotlin/shop/Pricing.kt:6:64 This expression contains a magic number. Consider defining it to a well named constant. [MagicNumber]
e: /w/shop/src/main/kotlin/shop/Pricing.kt:6:70 This expression contains a magic number. Consider defining it to a well named constant. [MagicNumber]
e: /w/shop/src/main/kotlin/shop/Product.kt:12:59 This expression contains a magic number. Consider defining it to a well named constant. [MagicNumber]
e: /w/shop/src/main/kotlin/shop/Product.kt:12:71 This expression contains a magic number. Consider defining it to a well named constant. [MagicNumber]
> Task :detekt FAILED
> Analysis failed with 4 issues.
BUILD FAILED in 4m 34s

هذه المرة عملت الأداة على JDK 25، وكتبت تقاريرها بأربع صيغ، منها SARIF التي تفهمها منصات مثل GitHub لعرض الملاحظات على سطور الكود مباشرة، ثم وجدت أربع ملاحظات من نوع الرقم السحري Magic Number: الرقمان 50 و100 في دالة الضريبة، والرقمان 100 في دالة asRiyals. وهذه ملاحظات في محلها، فالرقم 100 هنا معناه «عدد الهللات في الريال»، والأفضل أن يكون ثابتاً اسمه HALALAS_PER_RIYAL حتى يقرأ الكود كما يفكر الإنسان. واستغرقت المهمة أكثر من أربع دقائق لأنها نزلت الأداة ومترجمها لأول مرة. والقرار العملي لفريقنا: ktlint في كل بناء من اليوم الأول، وdetekt 2 في خط CI منفصل لا يوقف الدمج حتى يصدر الإصدار المستقر، أو البقاء على JDK 21 إذا كان الفريق يحتاج detekt 1.23.8 المستقر، وهذه مقايضة يجب أن تقررها بوعي وليس بالصدفة.

ماذا يحدث إذا استدعيت كود Java من Kotlin؟ التوافق Java Interop

والسؤال الذي يطرحه كل فريق قادم من Java: هل نستطيع أن نستخدم مكتباتنا وكودنا القديم؟ والإجابة نعم، وهذا من أقوى ما في اللغة، وتشرح صفحة Calling Java from Kotlin التفاصيل. فأي كلاس Java تستدعيه من Kotlin كأنه كلاس Kotlin، والـ Getters والـ Setters تظهر كخصائص، فتكتب user.name بدل user.getName()، والاتجاه المعاكس يعمل أيضاً كما رأيت مع HelloKt. ولكن هناك نقطة واحدة يجب أن تعرفها من اليوم الأول: كود Java لا يقول هل القيمة قد تكون null أم لا، فإذا رجعت دالة Java بـ String، فإن Kotlin لا تعرف هل هو String أم String?، وتعامله كنوع منصة Platform Type تكتبه الرسائل بالشكل String!، وتترك لك القرار. وهذا هو المكان الوحيد الذي يمكن أن يظهر فيه NullPointerException في كود Kotlin لم تستخدم فيه !!، وسوف نجربه بكود Java حقيقي يرجع null في المقال الثاني من المرحلة، لأنه يستحق تجربة كاملة.

أخطاء شائعة Common Mistakes في اليوم الأول

  • الخلط بين إصدار Kotlin في Gradle وإصدار Kotlin في المشروع: السطر Kotlin: 2.4.10 في gradle --version لا علاقة له بكودك، والإصدار الصحيح في kotlin("jvm") version أو في الكتالوج.
  • نسخ ملف JAR وحده إلى السيرفر: ثم يظهر NoClassDefFoundError: kotlin/...، والسبب أن المكتبة الأساسية kotlin-stdlib خارج الملف. استخدم installDist أو إضافة تجمع الاعتماديات في ملف واحد.
  • تشغيل gradle المثبت بدل ./gradlew: فيبني كل مطور بإصدار مختلف من Gradle، وتظهر أخطاء لا يراها غيره.
  • الحكم على اللغة من زمن أول بناء: أغلب الدقائق الأولى تنزيل وتشغيل Daemon، والمقياس الصحيح هو المرة الثانية وزمن تشغيل البرنامج المبني.
  • قسمة الأعداد الصحيحة: 4500 / 100 تساوي 45 و990 / 100 تساوي 9، وليس 9.9 كما في Python، فإذا أردت رقماً عشرياً فأحد الطرفين يجب أن يكون Double، وفي المبالغ المالية لا تفعل ذلك أصلاً واحسب بالهللات.
  • كتابة var لكل شيء: عادة من Python وJavaScript، وفي Kotlin تبدأ بـ val وتغير إلى var عندما يطلب المترجم ذلك.
  • استخدام !! لإسكات المترجم: كل !! هو NullPointerException مؤجل، والطريقة الصحيحة هي ?. و?: أو فحص صريح.
  • استخدام أحدث JDK دون التحقق من الأدوات: كما حدث مع detekt 1.23.8، فقبل أن ترفع الـ JDK في مشروع قائم شغل كل مهام البناء والفحص وليس الترجمة وحدها.
  • تشغيل ktlint وktfmt معاً: اختر واحدة للتنسيق، وإلا فسوف يعيد كل منهما تنسيق ما كتبته الأخرى في كل Commit.

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

  • هل الـ JDK إصدار LTS من توزيعة مجانية معروفة، ورقمه مكتوب في ملف الإصدارات؟
  • هل إصدار Kotlin محدد في الكتالوج أو ملف البناء، وهو آخر إصدار مستقر وليس الإصدار الذي كتبه gradle init؟
  • هل jvmToolchain محدد، حتى لا يعتمد البناء على الـ JDK المثبت عند كل مطور؟
  • هل غلاف gradlew في المستودع، والجميع يستخدمه؟
  • هل خط CI يحفظ ذاكرة Gradle بين التشغيلات؟
  • هل أداة تنسيق واحدة فقط (ktlint أو ktfmt) تعمل في CI وتفشل البناء عند المخالفة؟
  • هل أداة الفحص الأعمق تعمل على نفس الـ JDK الذي يستخدمه المشروع، أم تحتاج إعداداً خاصاً؟
  • هل المبالغ المالية بالأعداد الصحيحة، ولا يوجد Double في أي حساب سعر؟
  • هل مجلد التوزيع أو ملف JAR النهائي يحتوي kotlin-stdlib؟

تدريب عملي

  • أضف نوعاً ثالثاً إلى sealed interface Line اسمه Shipping، وشغل gradle build، واقرأ رسالة المترجم عند التعبير when، ثم أضف الحالة الجديدة.
  • غير val wanted: Product? = byId[7] إلى val wanted: Product = byId[7]، ولاحظ أين يعترض المترجم، ثم جرب byId.getValue(7) وشغل البرنامج، واقرأ الاستثناء الذي يظهر.
  • اكتب سكربت .main.kts يقرأ ملف CSV صغيراً فيه أسماء منتجات وأسعار بالهللات، ويطبع الإجمالي مع الضريبة باستخدام الدالة vatHalalas.
  • شغل javap -p على الكلاس shop.Product بعد البناء، وعد الدوال التي كتبها المترجم بسبب الكلمة data.

الخلاصة

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

  • Kotlin على الـ JVM ثلاث طبقات بثلاثة إصدارات: JDK 25 يشغل، والمترجم 2.4.21 يحول الكود إلى ملفات .class، وGradle 9.8.2 ينظم البناء، وإصدار Kotlin المدمج في Gradle (2.4.10) ليس إصدار مشروعك.
  • الدوال في المستوى الأعلى تصبح دوال static في كلاس اسمه اسم الملف مع Kt، وكل برنامج يحتاج kotlin-stdlib وقت التشغيل، وغيابها يعطي NoClassDefFoundError.
  • أهم ما يختلف في الصيغة: null جزء من النوع (String?)، و== تقارن المحتوى، وdata class تكتب لك equals وcopy، وwhen على نوع sealed يجبرك على تغطية كل الحالات.
  • أول بناء استغرق 552 ثانية أغلبها تنزيل، والمرة الثانية بين 8 و12 ثانية، والبرنامج المبني يعمل في أقل من ثانيتين.
  • ktlint اعترض على عشرة أسطر وأصلحها تلقائياً، وdetekt 1.23.8 لم يعمل على JDK 25، وهذا مثال على أن الأدوات قد تتأخر عن أحدث JDK.

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

دليلكيف تختار لغة البرمجة Programming Language لمشروعك؟نختار لغة خدمة حقيقية بأسئلة الفريق والتوظيف والمكتبات والرخصة، ثم نكتبها بأربع لغات ونقيس البناء والحجم والذاكرة والطلبات في الثانية بأنفسنا.دليلالبرمجة بعدة لغات Polyglot: متى تفيد ومتى تعقد مشروعك؟نستدعي نفس الدالة من Python بخمس طرق، عبر FFI إلى C وGo وعبر HTTP وgRPC، ونقيس كلفة كل حد بين لغتين وتعقيد البناء وما يحدث عندما يتعطل الطرف الآخر.دليلأنماط البرمجة وأنظمة الأنواع: لماذا تختلف اللغات؟نحل مسألة واحدة بعدة أساليب برمجة، ونرى هل تكتشف اللغة أخطاءك قبل التشغيل أم بعده، وماذا يعني ذلك عند اختيار لغة لمشروعك.دليلأدوات البناء Build Tools وماذا يحدث عندما تبني مشروعكما الذي يحدث بين ضغطة البناء والملف الناتج، ولماذا ينجح البناء على جهازك ويفشل على سيرفر CI، ومتى يعطيك نفس النتيجة مرتين.دليلمديرو الحزم Package Managers: npm و pip و Maven و Cargoنجرب npm وpip وMaven وCargo على مشروع واحد بأربع لغات، فنقيس كم حزمة تأتي مع كل سطر، ونرى كيف يجعل ملف القفل التثبيت متطابقاً، وكيف خدع حرف واحد في اسم مكتبة مطوري Rust.دليلالمترجم Compiler والمفسر Interpreter و JIT و WebAssemblyنشغل دالة واحدة بلغة C وPython وJavaScript وWebAssembly على نفس الجهاز، ونفتح ما كتبه كل مترجم لنعرف لماذا اختلف الوقت من ثلث ثانية إلى دقيقة.دليلهل Java مجانية؟ أي JDK تستخدم في شركتكمتى تحتاج اشتراكاً في Oracle JDK ومتى لا تحتاجه، وكيف تعرف نسخ Java التي تعمل عندك وتنقلها إلى نسخة مجانية بأمان.

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

  • أكتوبر 2026: كتابة المقال، وتنفيذ التجارب على Kotlin 2.4.21 وGradle 9.8.2 وOpenJDK 25.0.5 (Temurin) داخل Docker، مع ktlint 1.8.0 وdetekt 1.23.8 و2.0.0-alpha.6.
نشرة عرب رووت | ArabRoot

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

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

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

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