لنفرض أنك تعمل في شركة ناشئة تبيع القهوة والتمور عبر تطبيق جوال، وتطبيق 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 حتى يعمل:

والطبقات الثلاث كما يلي:
- الـ 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، لأن نوعها في KotlinArray<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. والجدول التالي يجمع أهم الفروق، وبعده نشرح كل نقطة من كود المشروع الذي بنيناه:

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

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 | أول مرة، ذاكرة 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: أداة من 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.ktfun 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.