آموزش دیزاین پترن singleton در کاتلین

آموزش دیزاین پترن singleton در کاتلین

دیزاین پترن (Design pattern) ها یا الگوهای طراحی روش های از قبل دانسته شده برای حل چالش های متداول در برنامه نویسی هستند که نه تنها برای توسعۀ نرم افزارها بلکه برای توسعۀ خود زبان های برنامه نویسی هم از آنها استفاده شده است.

برای مثال از دیزاین پترن Injection برای ایجاد مفهوم ارث بری در سبک برنامه نویسی شی گرا بکار رفته است. برای بررسی عمومی دیزاین پترن ها این مقاله را بخوانید.

دیزاین پترن singleton هم یکی از همین الگوهای طراحی (Design patterns) ها هست.

آموزش دیزاین پترن (Design pattern) singleton

دیزاین پترن singleton یکی از ساده ترین الگوهای طراحی است. گاهی ما نیاز داریم که تنها یک نمونه از کلاس خود را داشته باشیم.

برای مثال یک کلاسی داریم که فرآید اتصال به پایگاه دادۀ را در خود دارد و از دیزاین پترن singleton پیروی می کند، و در ادامه توسط چندین شئ قابل دسترسی هست (به جای آنکه از صفر ساخته شود و منجر به افزایش هزینه شود.)

درواقع ما آن کلاس یادشده را را در کلاس های دیگر صدا می زنیم و مطمئن هستیم فقط یک شئ از کلاس پایگاه دادۀ خود را داریم و فقط یک نمونه از آن شئ وجود خواهد داشت و با هربار صدا زدن آن کلاس یک شئ جدید از آن ساخته نخواهد شد و همیشه یک شئ از آن کلاس داریم.

این مفهوم که همواره یک نمونه (شئ) از یک کلاس وجود داشته باشد و نتوان از یک کلاس شئ های بیشتری ساخت، همان رسالت دیزاین پترن singleton می باشد.

به طور مشابه می توان یک بخشی برای مدیریت پیکربندی یکتا در برنامه یا بخشی برای مدیریت خطا وجود داشته باشد و همۀ اعضا با آن بخش های یکتا برای مدیریت پیکربندی یا برای مدیریت خطا در برنامه، در ارتباط باشند.

دیزاین پترن singleton

تعریف

دیزاین پترن singleton یک الگوی طراحی است که نمونه سازی یک کلاس را به تنها یک شئ محدود می کند.

اگر کنترل خوبی روی متغییر های کلاس static و access modifiers ها دارید این کار دشوار نخواهد بود.

چنان که یاد شد این الگو تضمین می کند که یک کلاس فقط یک شئ داشته باشد و یک نقطۀ دسترسی جهانی به آن یک شئ فراهم باشد.

نکتۀ خوب دیزاین پترن singleton این است که امکان دسترسی آسان به شئ singleton یادشده را فراهم می کند و نیازی هم ندارد نگران زمان عمر آن شئ باشید.

البته یک نکتۀ منفی برای دیزاین پترن singleton وجود دارد. در برنامه های multithreading (چند نخی) مشکلاتی که در داستان زدن های عامیانه : ( آشپز که دوتا باشد، آش یا شور می شود یا بی مزه) خوانده می شود برای شئ پیروی دیزاین پترن singleton بوجود می آید.

اغلب بخاطر نکتۀ منفی یاد شده به دیزاین پترن singleton یک ضد الگو (anti pattern) می گویند

شما می توانید با استفاده از کتابخانه های تزریق وابستگی دیزاین پترن singleton را به صورت قُلابی درست کنید.

کجا از دیزاین پترن singleton استفاده کنیم؟

دلایل قابل قبول بسیار کمی برای استفاده از دیزاین پترن (Design pattern) singleton وجود دارد.

یکی از دلایلی که بارها و بارها در بحث اینترنت مطرح می شود کلاس “logging” است. شما می خواهید در سراسر بخش های برنامه از یک فرایند مشترک واحد استفاده کنید تا ببینید login کرده اید یا نکرده اید و اگر نکرده اید اقدام بدان کنید.

مثال

می توانید دسترسی به پایگاه داده را از برای نگرانی های که به علت safe thread نبودن رخ می دهد محدود کنید.

یک کلاسی به نام Storage را در نظر بگیرید؛ این کلاس بر روی یک شئ های (objects) مربوط به SqlQuery کار می کند. هر client (برای مثال Persion) نیاز دارد تا یک شئ SqlQuery بسازد و پیکربندی اش کند و تابع اجرای کلاس Storage را فراخوانی کند.

برای نمایش طرح این مثال به نمودار UML زیر نگاه کنید. (در پایین تر خود کدها آورده شده اند.)

مثال برای دیزاین پترن singleton

Object / Companion Object در کاتلین

دیزاین پترن singleton در کاتلین چگونه است؟ آیا تایپ های object در کاتلین (در کاتلین ما در کنار تایپ های class و interface و data class و… تایپ هایی از نوع Object داریم) یک singleton هست؟

زبان کاتلین ویژگی هایی به نام Object که درصورت ترکیب با کلاس ها Companion Object خوانده می شود ارائه می کند.

آن دو تعریف (Object و Companion Object) برای پیاده سازی دیزاین پترن Singleton در کاتلین به صورت بومی طراحی شده اند. نکته ای که بدان باید اشاره کرد آنست که شئ ها به روش lazy ساخته می شوند همچنین آنها هرگز از بین نمی روند و در تمام طول عمر برنامه در دسترس هستند.

بدین علت به کلاسی که از دیزاین پترن singleton پیروی می کند کلیدواژۀ Object اطلاق می گردد که در عین آنکه یک کلاس می باشد؛ تنها یک شئ از آن وجود دارد؛ که قابل دسترسی است، پس می توان گفت که ما یک Object (شئ) یکتا داریم.

object Storage {
    fun execute(query : SqlQuery) {

    }
}

روش lazy می گوید تا زمانی که یک شئ  را در کد خود صدا نزنیم ساخته نمی شود؛ برخلاف حالت عادی که همان ابتدا، یک شئ هرچند با آن کاری نداشته باشیم بی درنگ در کنار انبوهی دیگر از اشیای تعریفی درون کلاس، تعریف می شود و در حافظه جا اشغال می کند.

خب lazy باعث می شود به محض اجرا شدن کلاس، اشیای درون آن با هم در همان آغاز ساخته نشوند و thread اجرایی دچار کندی نشود و حافظه RAM برا چیزی که فعلا بدان نیازی نیست بی هوده اشغال نگردد.

چگونه در کاتلین از دیزاین پترن singleton استفاده کنیم؟

همانجور که در مثال بالا توضیح داده شد، نقطۀ دسترسی جهانی (Global) را برای کلاس Storage پیاده سازی خواهیم کرد.

Storage به عنوان یک Object (و نه یک class معمولی) تعریف شده است. این یعنی از دیزاین پترن singleton پیروی می کند، بنابرین می توان به صورت سراسری در کدها بدان دسترسی داشت، دارای یک تابع execute است که با شئ های SqlQuery کار می کند. در این تابع دسترسی راستین به پایگاه داده رخ میدهد.

همچنین منطق های دیگری، مانند صف بندی یا دسترسی thread-safe ممکن است در کلاس Storage (که به علت استفاده از کلید واژۀ Object به جای Class برای آن، از منطق دیزاین پترن singleton پیروی می کند) افزوده و پیاده سازی گردد(در ادامه مقاله می خوانید Object به طور پیش فرض safe-thread است).

از سوی دیگر، کلاس Person وجود دارد که interface، Persistable را پیاده سازی می کند. وی یک شئ SqlQuery ایجاد می کند و پرس و جوی SQL مورد نظر را درخواست می کند(در اینجا نشان داده نشده است).

می توان چندین کلاس گوناگون را تصور کرد که interface اِ Persistable را پیاده سازی می کنند. تنها کاری که آنها باید انجام دهند این است که شئ صحیح SqlQuery را تنظیم کرده و در پایان شئ Storage را فراخوانی کنند.

object Storage {
    fun execute(query : SqlQuery) {

    }
}

class SqlQuery {

}

interface Persistable  {
    fun persist()
}

class Person : Persistable {
    override fun persist() {
        val query = SqlQuery()
        Storage.execute(query)
    }
}

fun main() {
    val person = Person()
    person.persist()
}

یک Kotlin Object همچنین می تواند همراه با یک کلاس بهره جویی گردد که بدان Companion Object می گویند. به نوعی مانند یک inner class رفتار می کند و زمانی سودمند است که تنها برخی از قسمت های کلاس static باشند.

پیاده سازی کمی متفاوت از مثال بالا در کد زیر است. کلاس ذخیره کنندۀ کلاس دیگر static نیست. فقط تابع execute آن به صورت static قابل دسترسی است.

توجه داشته باشید که تابع execute به همان روش syntax قبلی قابل دسترسی است. مزیت استفاده از یک Companion Object برای پیاده سازی دیزاین پترن singleton آنست که امکان استفاده از وراثت را فراهم می کند.

class Storage {
    companion object {
        fun execute(query : SqlQuery) {
            
        }
    }
}

class Person : Persistable {
    override fun persist() {
        val query = SqlQuery()
        Storage.execute(query)
    }
}

کلاس singleton همراه با constructor / Initialization

در کاتلین، Object همانند کلاس های دیگر دارای یک init block هست. هرچند که آن هیچ constructor ای ارائه نمی دهد. به طور کلی ما نیاز به یک constructor اختصاصی نداریم زیرا client ها نباید مسئول ساخت کلاس باشند.

اما چه بسا سودمند باشد برخی از پیکربندی ها را پیش از مقداردهی اولیۀ singleton تنظیم کنیم. در مثال ما می توانیم تصور کنیم که پایگاه داده رمزگذاری شده است. کلاس Storage به گذرواژه نیاز دارد تا پس از آنکه برای نخستین بار پایگاه داده ساخته شد آن را باز کند.

پس برای فرستادن پارامتر یا آرگمان به Obejct اِ پیروی دیزاین پترن singleton چه باید کرد؟

فرستادن پارامتر اولیه برای کلاسِ پیروی دیزاین پترن singleton پیش از ساخته شدن

برای پیاده سازی پارامترها ما به یک کلاس عادی و یک Companion Object وconstructor های پیش فرض خصوصی را داریم.

در پیاده سازی زیر کلاس Storage یک constructor خصوصی دارد. پس توسط هیچ client ای قابل نمونه سازی نیست. نیز یک شئ Config نیاز دارد که دارای تمام پارامترها برای راه اندازی Storage می باشد.

از آن سو Companion Object یک تابع getInstance فراهم می کند که شئ singleton را می سازد. این تابع یک پیکربندی اختیاری را به عنوان ورودی می پذیرد تا اولین باری که شئ static ایجاد می گردد استفاده می شود.

همانطور که می بینید شئ Person می تواند Storage class را از روش مشابه صدا بزند(اگر نیازی به فرستادن پارامتر نداشته باشد).

توجه داشته باشید که این رویکرد، بهترین روش نیست. ما نمی توانیم بازرسی کنیم که چه کسی برای نخستین بار قصد دارد Storage کلاس را صدا بزند. از آنجایی که همۀ تماس های بعدی از شئ Config استفاده نمی کنند، بازرسی دقیق پیکربندی (که به لطف Config صورت می گیرد) دشوار است.

data class Config(val param: Int = 0)

class Storage private constructor(private val config: Config) {

    companion object {
        private var instance : Storage? = null

        fun  getInstance(config: Config = Config()): Storage {
            if (instance == null)  // NOT thread safe!
                instance = Storage(config)

            return instance!!
        }

        fun execute(query: SqlQuery) {
            getInstance().execute(query)
        }
    }

    fun execute(query: SqlQuery) {

    }
}

class Person : Persistable {
    override fun persist() {
        val query = SqlQuery()
        Storage.execute(query)
    }
}

بهتر است یک تابع جداگانه بجای constructor برای پیکر بندی دیزاین پترن singleton ساخته شود. با استفاده از یک تابع جداگانه بهتر می توان پیکربندی درست برای Storage را مدیریت کرد نیز safe-thread تر است.

یک گونه پیاده سازی ممکن آن می تواند مانند کد زیر باشد :

object Storage  {

    private val config = Config()
    
    fun configure(config : Config) {
        
    }

    fun execute(query: SqlQuery) {
        
    }
}

Lazy

معمولا خود Object ها از پیش به یک نوع روش Lazy (بالاتر دربارۀ Lazy توضیح داده شد) ساخته می شوند. پس Object ها تنها پس از نخستین بار که صدا زده می شوند حافظه را اشغال خواهند کرد.

اما ما می توانیم حتی متغییر های درون Object ها را هم lazy کنیم، یعنی پس از آنکه یک متغییر که اندرون Object می باشد صدا زده شد، آنگاه ساخته شود (هرچند که خود کلاس Object از قبل ساخته شده باشد) . برای این کار باید از کلیدواژۀ lazy استفاده کنیم.

Lazy از دیزاین پترن delegation پیروی می کند.

object Storage {
    private val heavyData: Int by lazy() { 5 }
    
    fun execute(query : SqlQuery) {
        println("Storage execute")
    }
}

آیا شیوه ای که کاتلین برای پیاده سازی دیزاین پترن singleton بکار می برد safe-thread است؟

در صفحۀ مرجع کاتلین آمده است که پیاده سازی اولیۀ یک Object همانا safe-thread است و در نخستین دسترسیِ بدان، انجام می پذیرد.

دسترسی از سوی جاوا

دسترسی از سوی جاوا

می توان کد های کاتلین و جاوا را به هم تبدیل کرد بنابرین می توان کد کلاس Object کاتلین را (که از دیزاین پترن singleton پیروی می کند) به کد کلاس جاوایی که از دیزاین پترن singleton پیروی می کند تبدیل کرد.

کاتلین یک فیلدی را در کد تبدیل شدۀ جاوا خواهد نهاد که “INSTANCE” خوانده می گردد. کد مثال بالا به صورت زیر در جاوا بررسی می گردد:

public class JavaMain {
    public static void main(String[] args) {
        SqlQuery query = new SqlQuery();
        Storage.INSTANCE.execute(query);
    }
}

تزریق وابستگی

یکی از معایب اصلی استفاده از دیزاین پترن singleton دشواری تست کردن کلاس هایی است که از دیزاین پترن singleton استفاده می کنند. زیرا یک جفت شدگی محکم از طرف client (client : مشتری ایست که با کلاس Object پیروی دیزاین پترن singleton در ارتباط است) به پیاده سازی کلاس Object وجود دارد.

چگونه یک کلاس پیروی دیزاین پترن singleton را تست کنیم؟

چگونه یک کلاس پیروی دیزاین پترن singleton را تست کنیم؟

اگر شما از یک کلاس معمولیِ دارای تابع، همراه با Companion Object استفاده می کنید، می توانید بجای شئ واقعی نسخۀ ارثی آن را بنهید.

کلاس Storage را جوری تغییر می دهیم که interface اِ Storage را پیاده سازی کند. پیاده سازی دوم (نامیده شده به MockStorage) این interface را همچنین پیاده سازی می کند.

نیز کلاس Storage دارای constructor خصوصی و Companion Object عمومی می باشد.

نمونۀ مورد استفاده از نوع IStorage است و بنابرین می توان آن را جایگزین کرد. نمودار UML زیر رابطه را نشان می دهد.

مثال UML برای IStorage دیزاین پترن singleton

interface IStorage {
    fun execute(query: SqlQuery)
}

open class Storage private constructor(): IStorage{

    companion object {
        private var instance : IStorage? = null

        fun  getInstance(): IStorage {
            if (instance == null)  // NOT thread safe!
                instance = Storage()

            return instance!!
        }

        fun setInstance(storage : IStorage) {
            instance = storage
        }
        fun execute(query: SqlQuery) {
            getInstance().execute(query)
        }
    }

    override fun execute(query: SqlQuery) {
        println("Default implementation")
    }
}

class MockStorage : IStorage  {
    override fun execute(query: SqlQuery) {
        println("Mocked implementation")
    }
}

fun main() {
    val testStorage = MockStorage()
    Storage.setInstance(testStorage)

    val person = Person()
    person.persist()
}

مزیت این روش آنست که شما کنترل کامل کد را در دست دارید و به هیچ کتابخانۀ دیگری وابسته نیستید. اما نقطه ضعف این روش آنست که باید حواستان باشد thread ها با هم همزمان امکان اثر گذاری نداشته باشند زیرا شرایط ما safe-thread نیست.

کتابخانه های تزریق وابستگی

تزریق وابستگی مفهومی است که از دیزاین پترن Injection پیروی می کند.

شما با استفاده از کتابخانه های تزریق وابستگی همچنین می توانید دیزاین پترن singleton را بر روی کد های خود اعمال کنید. کاتلین دو چهارچوب برای تزریق وابستگی ارائه کرده است که یکی Koin و دیگری Kodein هست.

همچنین در برنامه نویسی اندروید می توانید برای کاتلین یا جاوا از dagger-hilt برای تزریق وابستگی استفاده کنید.

اگر می خواهید دربارۀ تزریق وابستگی در اندروید بیشتر بدانید و با Koin آشنا شوید این مقاله را بخوانید.

البته Koin و kodein تزریق وابستگی را به طور صد درصدی انجام نمی دهند و یکجورایی شِبهِ تزریق وابستگی هستند اما dagger-hilt تزریق وابستگی را صد درصد انجام می دهد و بسیار قدرتمند است.

اگرچه kodein نسبت به قبل بسیار کمرنگ تر شده است و آموختن Koin بجای آن یک سر و گردن بهتر است اما در این مقاله از Kodein مثال خواهیم زد.

Kodein برای استفاده و پیکر بندی بسیار ساده می باشد. آن، یک سطح از abstraction را پیرامون شئ هایی که می خواهید تزریق کنید قرار می دهد. به طور کلی توصیه می گردد این کتابخانه را بررسی کنید زیرا یک زبان DSL خوب ارائه می دهد.

این کتابخانه سریع و بهینه است. البته شما باید بدین کتابخانه عادت کنید. به طور کلی بسیاری از کلاس ها و ماژول های شما بدین کتابخانه بستگی دارند.

ما مثال خود را طوری تغییر داده ایم که کلاس Person نیاز به یک شئ Kodein دارد. این شئ Kodein وابستگی ای ارائه می کند که از روی type (نوع) مربوطه توانایی برای بازیابی و نقشه برداری را دارد.

خوب اس که می بینیم اکنون می توانیم اشیاء را به طور کامل از وابستگی هایشان جدا کنیم.

open class Storage {
    open fun execute(query : SqlQuery) {
        println("Storage execute")
    }
}

class MockStorage : Storage() {
    override fun execute(query : SqlQuery) {
        println("MockStorage execute")
    }
}

class Person(val kodein: Kodein) : Persistable {

    private val storage by kodein.instance<Storage>()

    override fun persist() {
        val query = SqlQuery()
        storage.execute(query)
    }
}

fun main() {
    val kodein = Kodein {
        bind<Storage>() with singleton { MockStorage() }
    }
    val person = Person(kodein)
    person.persist()
}

در دورۀ جامع نخبگان معماری ها در اندروید کار با تزریق وابستگی های dagger-hilt و Koin را برای پایگاه داده و سمت سرور با Retrofit یاد بگیرید

توسعۀ نرم افزار اندرویدی

در بیشتر اپلیکیشن ها، شماری از کلاس ها وجود دارند که نقطۀ ورود به کدهای برنامه هستند. در اپلیکیشن های مبتنی بر فرانت مانند desktop ، IOS ، یا اپلیکیشن های اندرویدی کلاسی وجود دارد که model های view ها یا … را نگه داری می کند.

کلاس Application

یکی از آن کلاس ها کلاس Application هست. معمولا کلاس Application ابتدایی ترین کلاس در کد های شماست و دارای منطق ارتباط عمومی هست.

می تواند ارائه کنندۀ Factory ها (به عنوان مثال دیزاین پترن Abstract Factory Method)، دروازه های سرور ها و پایگاه های داده و نقطۀ دسترسی اصلی برای مشاهدۀ model ها و کنترل کننده ها باشد. این کلاس پایه برای حفظ وضعیت جهانی (Global) برنامه است.

در خصوص اندروید چنین کلاس Application ای تمامی Activity ها و Service ها را نیز در بر می گیرد. از آنجایی که این کلاس اطلاعات بسیار عمومی (Global) ای دارد منطقی است که از دیزاین پترن singleton پیروی کند و دسترسی singleton برای ارتباط با آن ارائه گردد.

ViewModel

معمولا ViewModel ها نباید تابع دیزاین پترن singleton باشند. آنها داده های پویا را ارائه می کنند و به context یک Activity یا Fragment محدود می گردند.

برجسته و تکمیل شدن کد در IDE ها

بیشتر IDE ها از قابلیت های بومی کاتلین مانند کلمۀ کلیدی Object و Companion Object پشتیبانی می کنند. همانطور که در تصویر زیر مشاهده می کنید Jetbrains Intllij کلاس Object را به درستی نشان می دهد.

پشتیبانی Object به عنوان عامل دیزاین پترن singleton در IDE ی JetBrains

چرا دیزاین پترن singleton به طور گسترده بد مورد استفاده قرار می گیرد؟

نه تنها دیزاین پترن singleton بد فهمیده و مورد استفاده قرار می گیرد بلکه در مناسبت های گوناگون از آن به نام یک ضد الگو (anti-pattern) یاد می گردد. دلیلش آنست که بیشتر مزایای دیزاین پترن singleton می تواند به عنوان بزرگترین اشکال های آن درنگریسته گردد.

برای مثال دیزاین پترن singleton امکان افزودن یک state عمومی را بسیار آسان می کند، که در صورت بزرگ شدن حجم کدها، حفظ آن بسیار سخت است.

افزون بر این واقعیت که می توان بدان در هر مکان و زمان دسترسی داشت، درک سلسله مراتب وابستگی سیستم را بسیار دشوار تر می کند.

تعریف property : متغییر های عمومی کلاس ها در کاتلین property خوانده می شود زیرا در دل خود getter و setter را پیاده سازی می کنند. اما در زبان جاوا به متغییر های عمومی کلاس ها filed می گوید زیرا برای آنها باید جداگانه getter و setter تعریف کرد. و این فرق میان property و filed است.

اگر وابستگی های زیادی به تابع ها و property های کلاس پیروی دیزاین پترن singleton وجود داشته باشد، جابجایی ساده برخی کلاس ها یا تعویض یک پیاده سازی با دیگری می تواند به دردسر جدی تبدیل گردد.

هر کسی که با به صورت جدی برنامه نویسی را توأم با مؤلفه های Global یا Static کار کرده باشد می داند که چرا این یک مشکل است.

در مرحلۀ بعد، از آنجایی که کلاس singleton مستقیما مسئول ایجاد، حفظ و افشای حالت واحد خود است، این اصل مسئولیت تک (Single Responsibility Principle) را نقض می کند که یکی از اصل های مفهوم های SOLID است.

در نهایت از آنجایی که تنها یک نمونه شئ از هر کلاس پیروی دیزاین پترن singleton در زمان اجرای برنامه می تواند وجود داشته باشد، تست کردن آن سخت تر نیز می گردد. در لحظه ای که دو کلاس متفاوت بر یک property یا تابعِ پیروی دیزاین پترن singleton تکیه می کنند، این دو جزء به شدت با هم جفت می شوند.

زیرا تابع های پیروی دیزاین پترن singleton را نمی توان (به آسانی) تقلید یا کرد یا با یک پیاده سازی قُلابی تعویض کرد، تست و آزمایش کردن واحد کلاس وابسته که در انزوای کامل هست، شدنی نیست.

درحالی که ممکن است جذاب بنظر برسد که هر جزء سطح بالا را در چهارچوب دیزاین پترن singleton تعریف کنیم؛ اما در صورت انجام، چه بسا به ناامیدی بفرجاند.

گاهی اوقات معایب می تواند بیشتر مزایا باشد، بنابرین ما نباید از دیزاین پترن singleton زیاد استفاده کنیم و همیشه باید مطمئن شویم که هم خودمان و همگروهمان پیامد های آن را درک می کنیم.

دیدگاه‌ها ۲
ارسال دیدگاه جدید