المُنشِئ الحافظ للنوع (Type-Safe Builder) في لغة Kotlin

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

المُنشِئ الحافظ للنوع (Type-Safe Builder)

يُتاح بناءُ المُنشِئ الستاتيكيّ الحافظ للنوع في لغة Kotlin باستخدام الدوال ذات التسمية المعبِّرة كمُنشِئ (builder) بالإضافة إلى قيم حرفية (literals) للدوال مع المستقبِل (receiver)، إذ يسمح المُنشِئ الحافظ للنوع ببناء لغات مُخصَّصة المجال (DSL) بالاعتماد على Kotlin بما يتناسب مع إنشاء بُنى البيانات الهرميّة المعقَّدة بطريقةٍ نصف تصريحية (semi-declarative)، وهذه بعض الأمثلة من حالات استخدامه:

  • توليد ترميزٍ (markup) باستخدام شيفرة Kotlin مثل HTML أو XML
  • تصميم أجزاء واجهات المستخدم (UI) برمجيًا مثل Anko
  • ضبط مسارات (routes) خادم الويب مثل Ktor

مثال عن المُنشِئ الحافظ للنوع

لتكن الشيفرة الآتية:

import com.example.html.* // راجع التصريحات بالأسفل

fun result(args: Array<String>) =
    html {
        head {
            title {+"XML encoding with Kotlin"}
        }
        body {
            h1 {+"XML encoding with Kotlin"}
            p  {+"this format can be used as an alternative markup to XML"}

            // عنصر يحتوي على خواصّ ومحتوى نصّيّ
            a(href = "http://kotlinlang.org") {+"Kotlin"}

            // محتوى مختلط
            p {
                +"This is some"
                b {+"mixed"}
                +"text. For more see the"
                a(href = "http://kotlinlang.org") {+"Kotlin"}
                +"project"
            }
            p {+"some text"}

            // محتوى مُولَّد
            p {
                for (arg in args)
                    +arg
            }
        }
    }

وهي شيفرة تخضع تمامًا لقواعد لغة Kotlin، وبإمكانك تعديل الشيفرة وتشغيلها في المستعرض.

آلية العمل

يجب أولًا تعريف النموذج المطلوب بناؤه؛ وهو نموذج وسوم HTML‏ (HTML tags) في مثالنا، ويُستخدَم لذلك عددٌ من الأصناف (classes)، فتكون مثلًا الكلمة المفتاحيّة HTML صنفًا يعبِّر عن العنصر <html>، وهو يعرِّف كافّة العناصر الأبناء له مثل <head> و <body>، يكون الشكل العام للشيفرة:

html {
 // ...
}

إذ إنّ html هو استدعاء دالةٍ تقبل تعبير lambda كوسيط (argument)، وتُعرَّف هذه الدالة بالشكل:

fun html(init: HTML.() -> Unit): HTML {
    val html = HTML()
    html.init()
    return html
}

يُلاحظ أنّ لهذه الدالة معاملٌ واحدٌ باسم init والذي هو بحدّ ذاته دالة، أمّا نوع الدالة فهو HTML.() -> Unit أي هو نوع دالةٍ بمستقبِل (receiver)، ولذلك يجب تمرير كائنٍ من النوع HTML (كمستقبِلٍ) للدالة حيث يُسمَح باستدعاء عناصر هذا الكائن داخل الدالة، وتُستخدَم الكلمة المفتاحية this للوصول إلى المستقبِل كما يلي:

html {
    this.head { /* ... */ }
    this.body { /* ... */ }
}

حيث إن head و body هما دالتان للصنف HTML، وعند الاستغناء عن this -وهذا ممكن- ستنتج شيفرةٌ مشابهةٌ جدًا لبنية المنشِئ (builder) بالشكل:

html {
    head { /* ... */ }
    body { /* ... */ }
}

لو نظرنا إلى بُنية الدالة html المُعرَّفة سابقًا فسنجد أنّها تُنشِئ كائنًا من HTML وتهيّئُه عبر استدعاء الدالة المُمرَّرة كمعاملٍ (وهي في مثالنا إما head أو body عبر الكائن من HTML) ثم تعيد ذلك الكائن، وهذا بالضبط ما يجب أن يقوم به المُنشِئ. وتُعرَّف الدالتان head و body في الصنف HTML بشكلٍ مشابهٍ لتعريف html ، وتختلفان عنها بأمرٍ واحدٍ فقط من حيث أنهما تضيفان الكائن المنشَأ إلى مجموعة children للكائن من HTML المحيط (enclosing) بهما، كما في الشيفرة:

fun head(init: Head.() -> Unit) : Head {
    val head = Head()
    head.init()
    children.add(head)
    return head
}

fun body(init: Body.() -> Unit) : Body {
    val body = Body()
    body.init()
    children.add(body)
    return body
}

وتقوم الدالتان -في الواقع- بنفس المهمة، لذلك يُستفاد من تعميم initTag بالشكل:

protected fun <T : Element> initTag(tag: T, init: T.() -> Unit): T {
    tag.init()
    children.add(tag)
    return tag
}

وبهذا تصبح الدالتان بشكلٍ أبسط كما يلي:

fun head(init: Head.() -> Unit) = initTag(Head(), init)

fun body(init: Body.() -> Unit) = initTag(Body(), init)

ويمكن استخدامهما لبناء العنصرين <head> و <body>، أمّا لإضافة نصّ إلى جسم الوسم (tag body) تصبح الشيفرة كما يلي:

html {
    head {
        title {+"XML encoding with Kotlin"}
    }
    // ...
}

حيث أُضيفت السلسلة النصّيّة المطلوبة مسبوقةً بالمعامل الأحادي (unary operator) + ، والذي سيقوم بالعملية الأحاديّة البادئة (prefix) المُعرَّفة عبر الدالة الإضافيّة unaryPlus()‎ والموجودة في الصنف المُجرَّد TagWithText الواقع بمستوى أعلى من Title (أبٌ له) بالشكل:

operator fun String.unaryPlus() {
    children.add(TextElement(this))
}

إذ تتلخص مهمّة المعامل + هنا بتغليف (wrap) السلسلة في كائنٍ من TextElement وإضافته إلى مجموعة children بجيث يصبح جزءًا صحيحًا من شجرة الوسوم (tag tree).

ويكون كل ماسبق مُعرَّفًا في الحزمة com.example.html التي يجري استيرادها في بداية شيفرة المُنشِئ (بالأعلى)، ويتوفَّر التعريف الكامل للحزمة في الفقرة الأخيرة من هذه الصفحة.

التحكُّم بالمجال (Scope) عبر ‎@DslMarker (بدءًا من الإصدار 1.1)

عند استخدام لغات البرمجة مُخصَّصة المجال (DSL) ستظهر مشكلة استدعاء الكثير من الدوال في سياق البرنامج حيث بالإمكان استدعاء التوابع (methods) من كل مستقبلٍ ضمنيٍّ مُمكنٍ داخل lambda، وقد تصدر عن هذا نتيجةٌ متناقضةٌ كأن يكون الوسم head داخل وسم head آخر مثل:

html {
    head {
        head {} // يجب منع حدوث هذا
    }
    // ...
}

إذ يجب السماح بعناصر المستقبِل (receiver) الضمنيّ this@head الأقرب فقط، ومنع استدعاء head()‎ لأنها عنصرٌ من مستقبِلٍ خارجيٍّ this@html. ولحلِّ هذه المشكلة طُرحت آليةٌ خاصةٌ بدءًا من الإصدار Kotlin 1.1 بهدف التحكُّم بمجال المستقبِل (receiver scope)، وذلك من خلال توصيف (annotate) أنواع كلِّ المستقبِلات المُستخدَمة في DSL بنفس التوصيف المُحدِّد (marker annotation)، فيُستخدَم مثلًا التوصيف ‎@HTMLTagMarker لمُنشِئ HTML بالشكل:

@DslMarker
annotation class HtmlTagMarker

ويدعى صنف التوصيف باسم DSL marker (مُحدِّد DSL) إن كان له التوصيف ‎@DslMarker، وتُعدُّ كلُّ أصناف الوسوم في مثالنا توسعةً (extend) عن الصنف الأعلى (superclass) ذاته Tag ، ولذلك يكفي توصيف الصنف الأعلى بالتوصيف ‎@HtmlTagMarker ، وسيعامِل المُترجِم حينها كافّةً الأصناف الموروثة (inherited) وكأنها مُوصَّفة، كما يلي:

@HtmlTagMarker
abstract class Tag(val name: String) { ... }

ولا يجب إذن توصيف الأصناف HTML أو Head بالتوصيف ‎@HtmlTagMarker بسبب احتواء صنفهم الأعلى (superclass) على التوصيف:

class HTML() : Tag("html") { ... }
class Head() : Tag("head") { ... }

وبعد القيام بتلك الخطوة، سيحدِّد المُترجِم أيّ المستقبِلات الضمنيّة جزءٌ من نفس DSL ليسمح باستدعاء العناصر من المستقبِلات الأقرب فقط، مثل:

html {
    head {
        head { } // خطأ: عنصر من مستقبل خارجي
error: a member of outer receiver
    }
    // ...
}

ويبقى بالإمكان استدعاء عناصر من المستقبلات الخارجيّة ولكن يجب حينئذٍ تحديدُ المستقبِل بشكلٍ صريحٍ (explicit) كما يلي:

html {
    head {
        this@html.head { } // شيفرة صحيحة
    }
    // ...
}

التعريف الكامل للحزمة com.example.html

توضِّح الشيفرة الآتية تعريف الحزمة com.example.html لأجل العناصر المذكورة مسبقًا وحسب، إذ تبني هذه الحزمة شجرة HTML، وترتكز بشكل مكثَّفٍ على استخدام الدوال الإضافيّة (extension functions) وتعابير lambda بالمستقبِلات.

تذكّر أنّ التوصيف ‎@DslMarker متاحٌ فقط بدءًا من الإصدار Kotlin 1.1.

package com.example.html

interface Element {
    fun render(builder: StringBuilder, indent: String)
}

class TextElement(val text: String) : Element {
    override fun render(builder: StringBuilder, indent: String) {
        builder.append("$indent$text\n")
    }
}

@DslMarker
annotation class HtmlTagMarker

@HtmlTagMarker
abstract class Tag(val name: String) : Element {
    val children = arrayListOf<Element>()
    val attributes = hashMapOf<String, String>()

    protected fun <T : Element> initTag(tag: T, init: T.() -> Unit): T {
        tag.init()
        children.add(tag)
        return tag
    }

    override fun render(builder: StringBuilder, indent: String) {
        builder.append("$indent<$name${renderAttributes()}>\n")
        for (c in children) {
            c.render(builder, indent + "  ")
        }
        builder.append("$indent</$name>\n")
    }

    private fun renderAttributes(): String {
        val builder = StringBuilder()
        for ((attr, value) in attributes) {
            builder.append(" $attr=\"$value\"")
        }
        return builder.toString()
    }

    override fun toString(): String {
        val builder = StringBuilder()
        render(builder, "")
        return builder.toString()
    }
}

abstract class TagWithText(name: String) : Tag(name) {
    operator fun String.unaryPlus() {
        children.add(TextElement(this))
    }
}

class HTML : TagWithText("html") {
    fun head(init: Head.() -> Unit) = initTag(Head(), init)

    fun body(init: Body.() -> Unit) = initTag(Body(), init)
}

class Head : TagWithText("head") {
    fun title(init: Title.() -> Unit) = initTag(Title(), init)
}

class Title : TagWithText("title")

abstract class BodyTag(name: String) : TagWithText(name) {
    fun b(init: B.() -> Unit) = initTag(B(), init)
    fun p(init: P.() -> Unit) = initTag(P(), init)
    fun h1(init: H1.() -> Unit) = initTag(H1(), init)
    fun a(href: String, init: A.() -> Unit) {
        val a = initTag(A(), init)
        a.href = href
    }
}

class Body : BodyTag("body")
class B : BodyTag("b")
class P : BodyTag("p")
class H1 : BodyTag("h1")

class A : BodyTag("a") {
    var href: String
        get() = attributes["href"]!!
        set(value) {
            attributes["href"] = value
        }
}

fun html(init: HTML.() -> Unit): HTML {
    val html = HTML()
    html.init()
    return html
}

مصادر