آموزش برنامه نویسی سوئیفت (Swift): مفهوم ژنریک ها (Generics) – بخش سیزدهم
در بخش قبلی این سری مقالات آموزش سوئیفت در مورد اسامی مستعار نام، مشاهدهگرهای مشخصه و تفاوت self با Self صحبت کردیم. اما زمانی که به توضیح Self رسیدیم، مشاهده کردیم که ابتدا باید مفهوم ژنریک را در زبان سوئیفت روشنتر بکنیم. برای مطالعه بخش قبلی به لینک زیر مراجعه کنید:
از سوی دیگر صحبت کردن در مورد ژنریکها بدون اشاره به Self کار دشواری است. پروتکلها نیز میتوانند از Self جهت گسترش کارکردهای خود بهره بگیرند. با این حال در اغلب مقالات میبینیم که یکراست به موضوع ژنریکها یا پروتکلها پرداخته شده و بهیکباره هر سه موضوع موردبررسی قرار گرفته است.
البته درک این مفاهیم برای نویسندهای که قبلاً با آنها به خوبی آشنا بوده آسان است؛ اما خوانندهای که میخواهد صرفاً یکی از قابلیتهای زبان سوئیفت را بشناسد در این زمینه با مشکل مواجه خواهد شد. این مشکل در زمینههای دیگر فناوری نیز رخ میدهد. برخی موارد برای توسعهدهنده یا مدیر آسان هستند و کاربر در آنها دچار مشکل میشود و یا برعکس.
ژنریک
احتمالاً تاکنون زمانی که مشغول کدنویسی سوئیفت بودهاید به این نکته فکر کردهاید که چه خوب میشد اگر مجبور نبودید برای اجرای کارهای تکراری، متدهای تکراری بنویسید. این همان جایی است که ژنریکها به کار میآیند.
ژنریکها امکان ایجاد تابعهایی را با قابلیت استفاده مجدد میدهند که میتوانند در انواع متفاوتی استفاده شوند. تنها نکته این است که این نوع باید با کاری که قرار است اجرا شود متناسب باشد.
این بدان معنی است که میتوان یک تابع منفرد نوشت که مقدار مجموع را محاسبه میکند و مهم نیست که مقادیر ارسالی به آن از نوع int ،double، یا float باشند. این تابع برای هر نوع Binarty Integr نیز کار میکند، اما در مورد انواع String کارکردی نخواهد داشت. در ادامه این تابع ژنریک را مورد بررسی بیشتری قرار میدهیم:
1struct List<T> {
2 var items = [Any]()
3
4 mutating func add<T>(newItem: T) {
5 items.append(newItem)
6 }
7
8 func getItem<T>(at index: Int) -> T? {
9 if items.count > 0 {
10 return items[index] as? T
11 } else {
12 print("No items have been added")
13 }
14 return nil
15 }
16
17 init() {
18 self.items = []
19 }
20}
21
22var integerList = List<Int>()
23integerList.add(42)
24integerList.add(57)
25integerList.add(25)
26
27let number = integerList.getItem(at: 1)
28print(number) //prints 57
در این بخش نوعی ساختار جدید را شاهد هستیم. ابتدا <T> را میبینید. البته هر چیزی میتواند درون براکتها باشد و عموماً از T برای نمایش نوع T استفاده میشود. همچنین در برخی موارد به صورت <Elements> میبینیم.
نکته دیگری که مشاهده میشود تغییر اعلان تابع است که عنوان آن به صورت زیر است:
1 mutating func add<T>(newItem: T)
در واقع این یک mutating func جدید است که add را فراخوانی میکند و از یک نوع ژنریک با نام <T> استفاده میکند و یک آرگومان منفرد newItem از نوع T میگیرد. Mutating به این معنی است که این تابع میتواند ساختار آرایه items را تغییر دهد.
این نوع از روی نوع لیستی که در ابتدا مقداردهی شده استنباط میشود. به محض این که کامپایلر این خط را ببیند:
1var integerList = List<Int>()
همه رخدادهای <T> درون دامنه ساختار List ژنریک را به <Int> تغییر میدهد. از این رو آرایه Items و همه تابعها انتظار نوع Int را خواهند داشت.
اگر یک List جدید با استفاده از <String> ساخته شود و در stringList ذخیره شود، این آرایه و تابع میتواند انتظار استفاده از یک نوع String را داشته باشد.
شما احتمالاً قبلاً این ساختار را دیدهاید مثلاً وقتی که دیکشنریها و آرایهها را میساختید از آن استفاده کردهاید. ما موارد زیادی از این اختصارها را ساختهایم که به طور عمده روی استنباط نوع تکیه دارند؛ اما سوئیفت در پشت صحنه از ما پشتیبانی میکند و به صورت خودکار این نوعها را برای ما گسترش میدهد.
آرایههای اعداد صحیح با استفاده از ساختار ()[Int] اعلان میشوند؛ اما سوئیفت آن را به صورت ()<Array<Int بسط میدهد.
دیکشنریهای رشتهها با استفاده از ()[String: String] اعلان میشوند؛ اما همانند آرایهها، سوئیفت آن را به صورت دیکشنریهای ()<Dictionary<String: String درک میکند.
با این ساختار، میتوانیم تعیین کنیم که دیکشنریها و آرایهها هر دو از نوع ژنریک هستند و مهم نیست که چگونه تنظیم شوند، چون تنها نکته مهم برای آنها این است که هر مقداری که درونشان استفاده میشود با نوع اعلان شده مطابقت داشته باشد.
در ادامه متد دوم کد فوق را نیز بررسی میکنیم:
1func getItem<T>(at index: Int) -> T?
طرز کار این متد مانند متد add است و به جای T هر نوعی که برای ایجاد List استفاده شده باشد جایگزین میشود؛ اما این متد از Int به عنوان مقدار پارامتر استفاده میکند. دلیل این کار آن است که باید اندیس مبتنی بر Integer آرایه را داشته باشیم. علی غم این که محتوای آرایه ژنریک است؛ اما اندیسها همچنان عدد صحیح هستند.
البته عنصری که در آن اندیس قرار دارد از نوع ژنریک تعریفشده ما خواهد بود؛ اما اگر کاربر هیچ چیزی به آرایه اضافه نکرده باشد چطور؟ ما باید این موقعیت که هیچ عنصری در آرایه نباشد را نیز مدیریت کنیم. بنابراین ابتدا مطمئن میشویم که یک آیتم را در لیست خود داریم. در ادامه میتوانیم تلاش کنیم عنصر را دریافت کنیم. اما اگر نتوانیم آن عنصر را پیدا کنیم مقدار nil بازگشت میدهیم.
دقت کنید که در پاراگراف قبلی گفتیم «تلاش» میکنیم. این یک نکته منطقی است که اگر تمرینهای قبلی را انجام داده باشید، متوجه سرنخ آن میشوید. تصور کنید ما سه آیتم به آرایه اضافه کردهایم؛ اما گزاره زیر را اجرا میکنیم:
1let value = integerList.getItem(at: 3)
به نظر میرسد باید چیز دیگری را نیز بررسی کنیم تا از کرش کردن برنامه جلوگیری کنیم. کشف این نکته را بر عهده شما میگذاریم.
در این بخش یک سؤال دیگر را مطرح میکنیم. اگر یک لیست جدید با استفاده از دستور زیر ایجاد کنیم و مقادیر 3، 4 و 5 را به آن اضافه کنیم:
1var doubleList = List<Double>()
در این صورت اگر از دستور زیر استفاده کنیم، در زمان استفاده از print(value) دقیقاً چه متنی در کنسول نمایش مییابد؟
1let value = doubleList.getElement(at: 0)
سازگاری
شما میتواند کاری کنید که ژنریکها با پروتکلهای خاصی سازگاری داشته باشد. بدین ترتب آنها تنها میتوانند با نوعهای خاصی وهلهسازی شوند. برای نمونه زمانی که از پروتکل BinaryInteger استفاده میکنید، در واقع تعیین کردهاید که صرفاً اعداد صحیح با علامت (+/-) و بی علامت (+) میتوانند در این متد ژنریک استفاده شوند.
امکان تعریف سازگاری با هر نوع وجود دارد؛ اما بهترین استفاده از آن با بهرهگیری از رفتارهای پایه و پروتکلهای پایهای مانند Numeric ،Stridable ،Sequence و/یا Collection است.
اینها رفتارهای پایهای هستند که میتوان سازگاری با آنها را تعریف کرد. در این صفحه (+) میتوانید فهرست کامل را مشاهده کنید؛ اما در ادامه برخی از مواردی که استفاده متداولی دارند را نیز بررسی کردهایم:
- Equatable - امکان بررسی این مسئله را میدهد که مقدار یک متغیر مقدار دیگر برابر است یا نه.
- Comparable - امکان مقایسه مقدار یک متغیر با متغیر دیگر را با استفاده از عملگرهای رابطه (بولی) مانند «بزرگتر از»، «کمتر از»، «برابر» میدهد.
- Hashable - یک هش Integer ایجاد میکند که امکان استفاده از نوع، در یک مجموعه یا یک کلید دیکشنری را میدهد.
برخی اوقات یکی از این موارد برای نیازهای شما کافی است؛ اما در موقعیتهای دیگری نیز ممکن است به بیش از یک مورد نیاز داشته باشید.
برای این که بهترین استفاده را از ژنریکها داشته باشید باید پروتکلهای مختلف توصیفشده در مستندات اپل را بررسی کنید. هر پروتکلی که استفاده میشود، صرفاً باید مطمئن شوید که با آن چه برایش استفاده میکنید سازگار است. این بدان معنی است که نباید فهرستی از سن افراد بسازید که از پروتکل FloatingPoint استفاده کند؛ مگر این که بخواهید از این تابع با اعداد اعشاری (float, double) استفاده کنید.
در ادامه به بررسی روش محدودسازی یک تابع ژنریک برای محدودسازی انواعی که میتوانند استفاده شوند میپردازیم.
1func add<T: Numeric>(_ value1: T, to value2: T) -> T{
2 return value1 + value2
3}
4
5print(add(1, to: 3) //prints 4
6print(add(3.5, to: 8.7) // prints 12.2
ما با استفاده از پروتکل Numeric به کامپایلر اعلام میکنیم، هر نوعی که عدد است را میتواند به جای T قبول کند. این امر به ما اجازه میدهد که از همان تابع برای انواع مختلفی استفاده کنیم.
گرچه افزودن دو عدد به هم دیگر کار چندان بزرگی به نظر نمیرسد؛ اما این روش زمانی که شروع به استفاده از تکنیکهای پیشرفتهتر بکنید، قدرتش را نشان میدهد. استفاده از ژنریکها در قالب یک تابع مانند این، یکی از ویژگیهای سوئیفت است که اغلب توسعهدهندههای مبتدی در پروژههای خود استفاده نمیکنند. حتی میتوان کل یک اپلیکیشن را بدون استفاده از ژنریک نیز نوشت؛ اما بالاخره روزی فرا میرسد و با موقعیتی مواجه شوید که باید از یک تابع برای دو نوع داده متفاوت استفاده کنید. در این حالت باید پروتکلی پیدا کنید که سازگاری داشته باشد و بتوانید آن تابع با نوعبندی قوی را به یک تابع ژنریک تبدیل کرده و به صورت مکرر در هر کجا که لازم است از آن استفاده کنید.
ژنریکها در پروتکلها
این همان نقطهای است که قبلاً به آن رسیدیم و به جای صحبت کردن در ژنریکها در پروتکلها؛ از آن عبور کرده و در مورد سازگاری و مثالهایی از شیوه تفکر لازم برای استفاده از ژنریکها در پروتکلها صحبت کردیم.
ژنریکها در پروتکلها چنان که انتظار میرود عمل میکنند؛ اما سازگاری فاصله زیادی با این وضعیت دارد. بدین ترتیب امکان صحبت بیشتر در مورد Self و همچنین خویشاوند نزدیک آن typealiase که associatedtype نامیده میشود فراهم میآید. ابتدا به توضیح دقیقتر Self میپردازیم.
Self یک الگوریتم جستجوی باینری است که شباهت زیادی به روش جستجو در یک دفترچه شماره تلفن یا دیکشنری دارد. فرض کنید به دنبال کلمه Swift در دیکشنری میگردید.