الأصناف (Classes) والوراثة (Inheritance) في لغة Kotlin

من موسوعة حسوب

تُستخدم الكلمة المفتاحيّة class للتصريح (declaration) عن الصنف بالصيغة الآتية (اسم الصنف Invoice):

class Invoice {
}

ويحتوي التصريح على اسم الصنف (class name) وترويسة الصنف (class header) (والتي تُحدِّد معاملات النوع والباني الأساسيّ ...إلخ.) وبُنية الصنف (class body) محاطةً بالقوسين {}، وإن كلًا من ترويسة الصنف وبُنيته اختياريتان؛ فإذا كان الصنف خاليًا لا حاجة للأقواس، مثل:

class Empty

الباني (Constructor)

يوجد لكلّ صنف في لغة Kotlin بانٍ رئيسيّ (primary) واحدٌ وبانٍ -أو أكثر- ثانويّ (secondary)، إذ يُعدُّ الباني الرئيسيّ جزءًا من ترويسة الصنف (header) حيث يُضاف بعد اسم الصنف مباشرةً (وقد تُضاف له المعاملات [parameters])، مثل:

class Person constructor(firstName: String) {
}

وإن لم يكن للباني الرئيسيّ توصيفٌ (annotation) أو مُحدِّد وصول (visibility modifier) فتُحذَف حينئذِ الكلمة المفتاحيّة constructor مثل:

class Person(firstName: String) {
}

وكما يُلاحظ أن الباني الرئيسيّ لا يحتوي على أيّة شيفرةٍ لأنّ شيفرة التهيئة الأولية (initialization code) تُكتب في أجزاء خاصّةٍ للتهيئة (initializer blocks) والتي تُسبَق بالكلمة المفتاحيّة init، إذ تُنفَّذُ هذه الأجزاء -عند عملية الإنشاء من هذا الصنف- بنفس الترتيب الموجودة فيه ضمن الصنف متداخلةً مع عمليات تهيئة الخاصّيّات (property initializers)، مثل:

class InitOrderDemo(name: String) {
    val firstProperty = "First property: $name".also(::println)

    init {
        println("First initializer block that prints ${name}")
    }

    val secondProperty = "Second property: ${name.length}".also(::println)

    init {
        println("Second initializer block that prints ${name.length}")
    }
}

ويٌسمَح باستخدام معاملات (parameters) الباني الأساسيّ في أجزاء التهيئة كما يُمكِن استخدامها أيضًا في تهيئة الخاصّيّات (properties) المُصرَّح عنها في بُنية الصنف (class body)، مثل:

class Customer(name: String) {
    val customerKey = name.toUpperCase()
}

وتدعم Kotlin صيغةً مختصرةً للتصريح عن الخاصّيّات وتهيئتها الأولية في الباني الأساسيّ، وهي:

class Person(val firstName: String, val lastName: String, var age: Int) {
    // ...
}

وكما هو الحال في الخاصّيّات بشكلٍ عامّ، قد تُعرَّف الخاصّيّات في الباني الأساسيّ كخاصيّات متغيّرة (mutable)‏ var أو خاصّيات للقراءة فقط (read-only)‏ val. وفي حال وجود توصيفٍ (annotation) أو مُحدِّد وصولٍ (visibility modifier) للباني فلا بُدَّ من وجود الكلمة المفتاحيّة constructor إذ يُوضع التوصيف أو المُحدِّدات قبلها بالصيغة:

class Customer public @Inject constructor(name: String) { ... }

المزيد عن مُحدِّدات الوصول (visibility modifiers).

الباني الثانويّ (Secondary Constructors)

يُصرَّح عن الباني الثانويّ في الصنف (class) بالبدء به عبر الكلمة المفتاحيّة constructor، مثل:

class Person {
    constructor(parent: Person) {
        parent.children.add(this)
    }
}

فإذا احتوى الصنف بانيًا رئيسيًا (primary constructor) فإن كلَّ بانٍ ثانويّ سيُفضي في النهاية إليه إما مباشرةً أو بطريقةٍ غير مباشرة عبر بانٍ ثانويّ آخر، وبكلا الحالتين يكون الوصول للباني الرئيسيّ من خلال الكلمة المفتاحيّة this، مثل:

class Person(val name: String) {
    constructor(name: String, parent: Person) : this(name) {
        parent.children.add(this)
    }
}

ويُلاحظ بأن الشيفرة في أجزاء التهيئة (initializer blocks) تصبح جزءًا من الباني الأساسيّ حيث يكون الانتقال للباني الرئيسيّ بمثابة التعليمة الأولى في الباني الثانويّ، وبالتالي فإن كلّ الشيفرات الموجودة في أجزاء التهيئة ستُنفَّذ قبل الباني الثانويّ، حتى وإن لم يكن هناك بانٍ رئيسيّ للصنف فإن هذا الانتقال سيكون ضمنيًا (implicit) وستُنفَّذ أجزاء التهيئة أيضًا، مثل:

class Constructors {
    init {
        println("Init block")
    }

    constructor(i: Int) {
        println("Constructor")
    }
}

أما إذا لم يحتوِ الصنف (غير المُجرَّد non-abstract) على أي تصريحٍ لبانٍ (رئيسيّ أو ثانويّ) فسيُولَّد تلقائيًا بانٍ افتراضيّ بدون وسائط (arguments)، ويكون مُحدِّد الوصول له من النوع العامّ (public)، فإذا أردت ألا يكون عامًّا فعليك بإنشاء بانٍ رئيسيٍّ فارغٍ وبالمُحدِّد المناسب، مثل:

class DontCreateMe private constructor () {
}

ويُلاحظ في بيئة JVM أنّه إن كان لكل معاملات (parameters) الباني قيمٌ افتراضيّةٌ فإن المُترجم (compiler) سيُولِّد بانيًا إضافيًا بدون معاملات (parameterless)، والذي سيستخدم القيم الافتراضيّة، وهذا ما يجعل لغة Kotlin سهلة الاستخدام مع المكتبات (libraries) مثل Jackson أو JPA اللتان تنشِئان الكائنات باستخدام البواني بدون المعاملات. مثل:

class Customer(val customerName: String = "")

إنشاء كائنات (Instances) من الصنف

لإنشاء كائنٍ من أحد الأصناف يُستدَعى الباني (constructor) فيه كما لو أنّه دالةٌ عاديّة، مثل:

val invoice = Invoice()

val customer = Customer("Joe Smith")

إذ لا تعتمد لغة Kotlin على وجود الكلمة المفتاحيّة new كما هو الحال في لغة Java.

أمّا لإنشاء الكائنات من الأصناف المتداخلة (nested) أو الأصناف الداخليّة (inner) أو الداخليّة المجهولة (anonymous inner) راجع الأصناف المتداخلة.

عناصر الصنف (Class Members)

قد يحتوي الصنف على:

الوراثة (Inheritance)

تشترك كل الأصناف في لغة Kotlin بالصنف Any باعتباره الصنفَ الأعلى (superclass) الافتراضيّ لأي صنفٍ لم يُصرَّح عن صنفٍ أعلى له، مثل:

class Example // وراثة ضمنيّة من الصنف Any

ملاحظة: إن الصنف Any ليس من java.lang.Object;‎ إذ لا يحتوي إلا على الدوال الثلاثة: equals()‎ و hashCode()‎ و toString()‎. وللتصريح عن الصنف الأعلى (superclass) بشكلٍ واضحٍ يُوضَع النوع بعد الرمز : في ترويسة الصنف (class header)، مثل:

open class Base(p: Int)

class Derived(p: Int) : Base(p)

تعبِّر الكلمة المفتاحيّة open (قبل اسم الصنف) عن المعنى المعاكس للكلمة المفتاحية final في لغة Java، إذ يُسمَح (بوجود open) بعملية الوراثة من هذا الصنف لأنّ الحالة الافتراضيّة للأصناف في لغة Kotlin هي final. وإذا احتوى الصنف المُشتق (derived) على بانٍ أساسيٍّ فتجب تهيئة الصنف الأساسي (base) هناك بالاعتماد على معاملات (parameters) هذا الباني، أما إن لم يكن للصنف بانٍ رئيسيّ فإن كلَّ بانٍ ثانويّ يجب أن يُهيِئ النوعَ الأساسيّ (base type) باستخدام الكلمة المفتاحيّة super أو أن ينقل تلك المهمة لبانٍ آخر غيره، وبالتالي فإنه من الممكن أن تستدعي عدة بوانٍ ثانويّة العديدَ من بواني النوع الأساسيّ، مثل:

class MyView : View {
    constructor(ctx: Context) : super(ctx)

    constructor(ctx: Context, attrs: AttributeSet) : super(ctx, attrs)
}

إعادة تعريف التوابع (Overriding Methods)

تتطلَّب لغة Kotlin -على عكس لغة Java- التصريح بشكلٍ واضحٍ عن إمكانيّة إعادة تعريف العنصر من خلال إضافة الكلمة المفتاحيّة open قبله وبإضافة الكلمة المفتاحيّة override عند إعادة تعريفه، مثل:

open class Base {
    open fun v() {}
    fun nv() {}
}
class Derived() : Base() {
    override fun v() {}
}

إذ من الضرور يّ إضافة override إلى الدالة Derived.v()‎، وإذا لم تُضف سينجم خطأ في الترجمة (compilation)، أمّا إن لم تٌضف الكلمة المفتاحيّة open الموجودة عند الدالة Base.nv()‎ فلا يُسمَح بإعادة تعريفها سواءً باستخدام override أو بدونها، لأنّ الوصول إلى العناصر (members) الموجودة في الأصناف من نوع final (بدون وجود open) غير مسموحٍ به، ويُعدُّ العنصر المسبوق بالكلمة المفتاحيّة override من نوع open بالحالة الافتراضيّة (أي من الممكن إعادةُ تعريفه أيضًا في الأصناف الفرعيّة (subclasses))، ولمنع ذلك تجب إضافة الكلمة المفتاحيّة final كما في الشيفرة:

open class AnotherDerived() : Base() {
    final override fun v() {}
}

إعادة تعريف الخاصّيّات (Overriding Properties)

تماثل إعادة تعريف الخاصّيّات طريقة إعادة تعريف التوابع (methods) في الفقرة السابقة؛ إذ لا بُدّ من وجود الكلمة المفاحيّة override قبل الخاصيّات التي يُعاد تعريفها في الصنف المُشتقّ (derived) والموجودة بالأصل في الصنف الأعلى (superclass) بشرط أن تكون الخاصّيّتان متوافقتَين (compitable)، ويُعاد التعريف إمّا باستخدام التهيئة (initializer) أو من خلال تابع الوصول get()‎، مثل:

open class Foo {
    open val x: Int get() { ... }
}

class Bar1 : Foo() {
    override val x: Int = ...
}

ويُسمح بإعادة تعريف الخاصّيّة من النوع val بخاصّيّةٍ من النوع var لكن تُمنَع الحالة العكسية؛ وذلك لأنّ الخاصيّة من النوع val تٌصرِّح بالأصل عن تابع getter وتعيد تعريفه كنوع var وكذلك تُصرِّح عن تابع setter في الصنف المُشتقّ(derived). وقد تُستخدَم الكلمة المفتاحيّة override كجزءٍ من تعريف الخاصّيّة في الباني الأساسيّ (primary constructor)، مثل:

interface Foo {
    val count: Int
}

class Bar1(override val count: Int) : Foo

class Bar2 : Foo {
    override var count: Int = 0
}

ترتيب عمليات تهيئة الصنف المُشتقّ (Derived class initialization order)

عند إنشاء كائنات (instances) من الصنف المُشتقّ فإن عمليات التهيئة الموجودة في الصنف الأساسيّ (base class) ستُنفَّذ أولًا (تسبقها فقط عمليات الوسائط (arguments) في الباني الأساسيّ)، وبالتالي فإنها ستجري قبل أيّ من عمليات التهيئة الموجودة في الصنف المُشتقّ، مثل:

open class Base(val name: String) {

    init { println("Initializing Base") }

    open val size: Int = 
    name.length.also { println("Initializing size in Base: $it") }
}

class Derived(
    name: String,
    val lastName: String
) : Base(name.capitalize().also { println("Argument for Base: $it") }) {

    init { println("Initializing Derived") }

    override val size: Int =
    (super.size + lastName.length).also { println("Initializing size in Derived: $it") }
}

وهذا يعني أنّ الخاصّيّات (properties) المُعرَّفة أو مُعادَة التعريف (overridden) في الصنف المُشتقّ لا تُهيَّأ في الوقت الذي تُنفَّذ فيه عمليات الباني (constructor) في الصنف الأساسيّ، وبالتالي قد يحدث خللٌ تنفيذيّ (runtime failure) إذا كانت أيًا من هذه الخاصّيّات في الصنف المُشتقّ مستخدمةً في عمليات التهيئة في الصنف الأساسيّ إما مباشرةً أو بشكلٍ غير مباشرٍ (عبر تعريف الاستخدام (implementation) لأيّ عنصرٍ آخر من نوع open مُعاد التعريف (overridden))، ولتجنُّب حدوث ذلك يجب ألّا تُعرَّف العناصر من نوع open في الصنف الأساسيّ في أيّ من البواني أو تهيئة الخاصيات أو أيّ من أجزاء التهيئة (blocks) المُعرَّفة بالكلمة المفتاحيّة init.

استدعاء تعريف الاستخدام (implementation) من الصنف الأعلى (superclass)

بإمكان الشيفرة الموجودة في الصنف المٌشتقّ استدعاء الدوال من الصنف الأعلى ودوال الوصول إلى الخاصّيّات (accessors) باستخدام الكلمة المفتاحية super، مثل:

open class Foo {
    open fun f() { println("Foo.f()") }
    open val x: Int get() = 1
}

class Bar : Foo() {
    override fun f() { 
        super.f()
        println("Bar.f()") 
    }
    
    override val x: Int get() = super.x + 1
}

أما للوصول إلى الصنف الأعلى (superclass) للصنف الخارجيّ (outer) من الصنف الداخليّ (inner) فتُستخدَم الكلمة المفتاحيّة super مقيّدةً باسم الصنف الخارجي بالصيغة super@Outer كما في الشيفرة الآتية:

class Bar : Foo() {
    override fun f() { /* ... */ }
    override val x: Int get() = 0
    
    inner class Baz {
        fun g() {
            super@Bar.f() // استدعاء تعريف الاستخدام للدالة f()
            println(super@Bar.x) // استخدام تعريف الاستخدام لدالة الوصول إلى الخاصية x
        }
    }
}

إعادة تعريف القواعد (Overriding Rules)

تخضع وراثة تعريف الاستخدام (implementation inheritance) في Kotlin للقاعدة الآتية:

"إذا ورِث صنفٌ ما عدة تعريفاتٍ للاستخدام (implementations) من نفس العنصر (member) من الأصناف الأعلى المباشرة (immediate superclasses) فيجب أن يحتوي على إعادة تعريفٍ (override) لهذا العنصر ليكون له تعريف الاستخدام الخاصِّ به (ربما باستخدام إحدى التعريفات التي يرثها)"، وتستخدم الكلمة المفتاحية super مقيّدة باسم النوع الأعلى (supertype) ومحاطةً بالأقواس <> بالصيغة: super<Base>‎ للإشارة إلى النوع الأعلى الذي يُؤخَذ منه تعريف الاستخدام، مثل:

open class A {
    open fun f() { print("A") }
    fun a() { print("a") }
}

interface B {
    fun f() { print("B") } // عناصر الواجهة تكون بالحالة الافتراضيّة مفتوحةً open
    fun b() { print("b") }
}

class C() : A(), B {
    // يتطلب المترجم وجود إعادة تعريف للدالة f()
    override fun f() {
        super<A>.f() // استدعاء A.f()
        super<B>.f() // استدعاء B.f()
    }
}

إذ لا مشكلة في وراثة الدالتين a()‎ و b()‎ من كلٍّ من الصنفين A و B (بالترتيب) لأنّ الصنف C يرِث تعريفًا واحدًا فقط من كلٍّ من هذه الدوال، أمّا بالنسبة للدالة f()‎ فيرث الصنف C تعريفين لاستخدامها وبالتالي فعليه أن يحتوي على إعادة تعريف (override) للدالة f()‎ ليكون له تعريف الاستخدام الخاصّ به لإزالة ذلك الالتباس.

الأصناف المُجرَّدة (Abstract Classes)

قد يُصرَّح عن الصنف أو بعضٍ من عناصره (members) بالكلمة المفتاحيّة abstract (مُجرَّد) بحيث تكون هذه العناصر دون تعريفٍ للاستخدام (implementation) ضمن الصنف الموجودة فيه، ولا حاجة لإضافة الكلمة المفتاحيّة open للصنف أو العناصر المجرَّدة فهي موجودةٌ بالحالة الافتراضيّة.

وبالإمكان إعادة تعريف (override) العنصر المفتوح غير المُجرَّد في عنصرٍ آخر مُجرَّد، مثل:

open class Base {
    open fun f() {}
}

abstract class Derived : Base() {
    override abstract fun f()
}

الكائنات المرافقة (Companion Objects)

لا وجود للتوابع الستاتيكيّة (static methods) في Kotlin -على عكس لغتيّ البرمجة Java وC#‎- ويُستعاض عنها بالدوال على مستوى الحزمة (package-level functions).

وعند الحاجة إلى كتابة دالةٍ قابلةٍ للاستدعاء دون إنشاء كائنٍ (instance) من الصنف ولكن لها صلاحيّة الوصول (access) إلى ما داخل الصنف (للتابع المُنتِج (factory method) مثلًا) فيُمكن جعلها كعنصرٍ (member) من تصريح الكائن نفسه داخل ذلك الصنف.

وبشكل أكثر تفصيلًا؛ إذا صُرِّح عن الكائن المرافق داخل الصنف فيمكن حينئذٍ استدعاء عناصره بنفس صيغة استدعاء التوابع الستاتيكيّة في لغتيّ Java وC#‎ بالاعتماد على اسم الصنف فقط.

مصادر