آموزش برنامه نویسی سوئیفت (Swift): مفهوم ژنریک ها (Generics) – بخش سیزدهم


در بخش قبلی این سری مقالات آموزش سوئیفت در مورد اسامی مستعار نام، مشاهدهگرهای مشخصه و تفاوت self با Self صحبت کردیم. اما زمانی که به توضیح Self رسیدیم، مشاهده کردیم که ابتدا باید مفهوم ژنریک را در زبان سوئیفت روشنتر بکنیم. برای مطالعه بخش قبلی به لینک زیر مراجعه کنید:
از سوی دیگر صحبت کردن در مورد ژنریکها بدون اشاره به Self کار دشواری است. پروتکلها نیز میتوانند از Self جهت گسترش کارکردهای خود بهره بگیرند. با این حال در اغلب مقالات میبینیم که یکراست به موضوع ژنریکها یا پروتکلها پرداخته شده و بهیکباره هر سه موضوع موردبررسی قرار گرفته است.
البته درک این مفاهیم برای نویسندهای که قبلاً با آنها به خوبی آشنا بوده آسان است؛ اما خوانندهای که میخواهد صرفاً یکی از قابلیتهای زبان برنامه نویسی سوئیفت را بشناسد در این زمینه با مشکل مواجه خواهد شد. این مشکل در زمینههای دیگر فناوری نیز رخ میدهد. برخی موارد برای توسعهدهنده یا مدیر آسان هستند و کاربر در آنها دچار مشکل میشود و یا برعکس.
ژنریک
احتمالاً تاکنون زمانی که مشغول کدنویسی سوئیفت بودهاید به این نکته فکر کردهاید که چه خوب میشد اگر مجبور نبودید برای اجرای کارهای تکراری، متدهای تکراری بنویسید. این همان جایی است که ژنریکها به کار میآیند.
ژنریکها امکان ایجاد تابعهایی را با قابلیت استفاده مجدد میدهند که میتوانند در انواع متفاوتی استفاده شوند. تنها نکته این است که این نوع باید با کاری که قرار است اجرا شود متناسب باشد.
این بدان معنی است که میتوان یک تابع منفرد نوشت که مقدار مجموع را محاسبه میکند و مهم نیست که مقادیر ارسالی به آن از نوع int ،double، یا float باشند. این تابع برای هر نوع Binarty Integr نیز کار میکند، اما در مورد انواع String کارکردی نخواهد داشت. در ادامه این تابع ژنریک را مورد بررسی بیشتری قرار میدهیم:
در این بخش نوعی ساختار جدید را شاهد هستیم. ابتدا <T> را میبینید. البته هر چیزی میتواند درون براکتها باشد و عموماً از T برای نمایش نوع T استفاده میشود. همچنین در برخی موارد به صورت <Elements> میبینیم.
نکته دیگری که مشاهده میشود تغییر اعلان تابع است که عنوان آن به صورت زیر است:
در واقع این یک mutating func جدید است که add را فراخوانی میکند و از یک نوع ژنریک با نام <T> استفاده میکند و یک آرگومان منفرد newItem از نوع T میگیرد. Mutating به این معنی است که این تابع میتواند ساختار آرایه items را تغییر دهد.
این نوع از روی نوع لیستی که در ابتدا مقداردهی شده استنباط میشود. به محض این که کامپایلر این خط را ببیند:
همه رخدادهای <T> درون دامنه ساختار List ژنریک را به <Int> تغییر میدهد. از این رو آرایه Items و همه تابعها انتظار نوع Int را خواهند داشت.
اگر یک List جدید با استفاده از <String> ساخته شود و در stringList ذخیره شود، این آرایه و تابع میتواند انتظار استفاده از یک نوع String را داشته باشد.
شما احتمالاً قبلاً این ساختار را دیدهاید مثلاً وقتی که دیکشنریها و آرایهها را میساختید از آن استفاده کردهاید. ما موارد زیادی از این اختصارها را ساختهایم که به طور عمده روی استنباط نوع تکیه دارند؛ اما سوئیفت در پشت صحنه از ما پشتیبانی میکند و به صورت خودکار این نوعها را برای ما گسترش میدهد.
آرایههای اعداد صحیح با استفاده از ساختار ()[Int] اعلان میشوند؛ اما سوئیفت آن را به صورت ()<Array<Int بسط میدهد.
دیکشنریهای رشتهها با استفاده از ()[String: String] اعلان میشوند؛ اما همانند آرایهها، سوئیفت آن را به صورت دیکشنریهای ()<Dictionary<String: String درک میکند.
با این ساختار، میتوانیم تعیین کنیم که دیکشنریها و آرایهها هر دو از نوع ژنریک هستند و مهم نیست که چگونه تنظیم شوند، چون تنها نکته مهم برای آنها این است که هر مقداری که درونشان استفاده میشود با نوع اعلان شده مطابقت داشته باشد.
در ادامه متد دوم کد فوق را نیز بررسی میکنیم:
طرز کار این متد مانند متد add است و به جای T هر نوعی که برای ایجاد List استفاده شده باشد جایگزین میشود؛ اما این متد از Int به عنوان مقدار پارامتر استفاده میکند. دلیل این کار آن است که باید اندیس مبتنی بر Integer آرایه را داشته باشیم. علی غم این که محتوای آرایه ژنریک است؛ اما اندیسها همچنان عدد صحیح هستند.
البته عنصری که در آن اندیس قرار دارد از نوع ژنریک تعریفشده ما خواهد بود؛ اما اگر کاربر هیچ چیزی به آرایه اضافه نکرده باشد چطور؟ ما باید این موقعیت که هیچ عنصری در آرایه نباشد را نیز مدیریت کنیم. بنابراین ابتدا مطمئن میشویم که یک آیتم را در لیست خود داریم. در ادامه میتوانیم تلاش کنیم عنصر را دریافت کنیم. اما اگر نتوانیم آن عنصر را پیدا کنیم مقدار nil بازگشت میدهیم.
دقت کنید که در پاراگراف قبلی گفتیم «تلاش» میکنیم. این یک نکته منطقی است که اگر تمرینهای قبلی را انجام داده باشید، متوجه سرنخ آن میشوید. تصور کنید ما سه آیتم به آرایه اضافه کردهایم؛ اما گزاره زیر را اجرا میکنیم:
به نظر میرسد باید چیز دیگری را نیز بررسی کنیم تا از کرش کردن برنامه جلوگیری کنیم. کشف این نکته را بر عهده شما میگذاریم.
در این بخش یک سؤال دیگر را مطرح میکنیم. اگر یک لیست جدید با استفاده از دستور زیر ایجاد کنیم و مقادیر 3، 4 و 5 را به آن اضافه کنیم:
در این صورت اگر از دستور زیر استفاده کنیم، در زمان استفاده از print(value) دقیقاً چه متنی در کنسول نمایش مییابد؟
سازگاری
شما میتواند کاری کنید که ژنریکها با پروتکلهای خاصی سازگاری داشته باشد. بدین ترتب آنها تنها میتوانند با نوعهای خاصی وهلهسازی شوند. برای نمونه زمانی که از پروتکل BinaryInteger استفاده میکنید، در واقع تعیین کردهاید که صرفاً اعداد صحیح با علامت (+/-) و بی علامت (+) میتوانند در این متد ژنریک استفاده شوند.
امکان تعریف سازگاری با هر نوع وجود دارد؛ اما بهترین استفاده از آن با بهرهگیری از رفتارهای پایه و پروتکلهای پایهای مانند Numeric ،Stridable ،Sequence و/یا Collection است.
اینها رفتارهای پایهای هستند که میتوان سازگاری با آنها را تعریف کرد. در این صفحه (+) میتوانید فهرست کامل را مشاهده کنید؛ اما در ادامه برخی از مواردی که استفاده متداولی دارند را نیز بررسی کردهایم:
- Equatable - امکان بررسی این مسئله را میدهد که مقدار یک متغیر مقدار دیگر برابر است یا نه.
- Comparable - امکان مقایسه مقدار یک متغیر با متغیر دیگر را با استفاده از عملگرهای رابطه (بولی) مانند «بزرگتر از»، «کمتر از»، «برابر» میدهد.
- Hashable - یک هش Integer ایجاد میکند که امکان استفاده از نوع، در یک مجموعه یا یک کلید دیکشنری را میدهد.
برخی اوقات یکی از این موارد برای نیازهای شما کافی است؛ اما در موقعیتهای دیگری نیز ممکن است به بیش از یک مورد نیاز داشته باشید.
برای این که بهترین استفاده را از ژنریکها داشته باشید باید پروتکلهای مختلف توصیفشده در مستندات اپل را بررسی کنید. هر پروتکلی که استفاده میشود، صرفاً باید مطمئن شوید که با آن چه برایش استفاده میکنید سازگار است. این بدان معنی است که نباید فهرستی از سن افراد بسازید که از پروتکل FloatingPoint استفاده کند؛ مگر این که بخواهید از این تابع با اعداد اعشاری (float, double) استفاده کنید.
در ادامه به بررسی روش محدودسازی یک تابع ژنریک برای محدودسازی انواعی که میتوانند استفاده شوند میپردازیم.
ما با استفاده از پروتکل Numeric به کامپایلر اعلام میکنیم، هر نوعی که عدد است را میتواند به جای T قبول کند. این امر به ما اجازه میدهد که از همان تابع برای انواع مختلفی استفاده کنیم.
گرچه افزودن دو عدد به هم دیگر کار چندان بزرگی به نظر نمیرسد؛ اما این روش زمانی که شروع به استفاده از تکنیکهای پیشرفتهتر بکنید، قدرتش را نشان میدهد. استفاده از ژنریکها در قالب یک تابع مانند این، یکی از ویژگیهای سوئیفت است که اغلب توسعهدهندههای مبتدی در پروژههای خود استفاده نمیکنند. حتی میتوان کل یک اپلیکیشن را بدون استفاده از ژنریک نیز نوشت؛ اما بالاخره روزی فرا میرسد و با موقعیتی مواجه شوید که باید از یک تابع برای دو نوع داده متفاوت استفاده کنید. در این حالت باید پروتکلی پیدا کنید که سازگاری داشته باشد و بتوانید آن تابع با نوعبندی قوی را به یک تابع ژنریک تبدیل کرده و به صورت مکرر در هر کجا که لازم است از آن استفاده کنید.
ژنریکها در پروتکلها
این همان نقطهای است که قبلاً به آن رسیدیم و به جای صحبت کردن در ژنریکها در پروتکلها؛ از آن عبور کرده و در مورد سازگاری و مثالهایی از شیوه تفکر لازم برای استفاده از ژنریکها در پروتکلها صحبت کردیم.
ژنریکها در پروتکلها چنان که انتظار میرود عمل میکنند؛ اما سازگاری فاصله زیادی با این وضعیت دارد. بدین ترتیب امکان صحبت بیشتر در مورد Self و همچنین خویشاوند نزدیک آن typealiase که associatedtype نامیده میشود فراهم میآید. ابتدا به توضیح دقیقتر Self میپردازیم.
Self یک الگوریتم جستجوی باینری است که شباهت زیادی به روش جستجو در یک دفترچه شماره تلفن یا دیکشنری دارد. فرض کنید به دنبال کلمه Swift در دیکشنری میگردید.
- ابتدا کتاب را باز میکنید و مثلاً به جایی روی حروف M میرسید.
- S بزرگتر از M است و بنابراین نیمی از صفحهها را به عقب بازمیگردیم تا به جایی مانند T میرسیم.
- S کوچکتر از T است و از این رو دوباره نیمی از صفحههای قبلی را به جلو ورق میزنیم تا به جایی بین حرف M و T برسیم.
- این کار را تا جایی ادامه میدهیم که به صفحهای حاوی کلمه Swift برسیم.
در این مثال، یک پروتکل ارائه میکنیم که میتواند با هر نوعی کار کند به شرط این که آن نوع معادل Self باشد. Self در این چارچوب به این معنی است که میخواهیم مطمئن شویم مقداری که ارسال شده است نیز امکان سازگاری با پروتکل Ordered را دارد. ما یک سازه عددی داریم که از آن برای مقایسه با مقداری از همان نوع بهره میگیریم.
سپس اندیس بالا و پایین آرایه را به دست میآوریم (چون مرتب است) و در ادامه مقداری که به دنبالش هستیم را پیدا میکنیم. به این منظور ابتدا میانه آرایه را مییابیم. الگوریتم زیر این کار را انجام میدهد:
چون که:
0 + (10 - 0) / 2 = 5
در ادامه اگر به نقطه بالا برویم، به دلیل رند کردن به نتیجه زیر میرسیم:
5 + (10–5) / 2 = 8
سپس [if sortedKeys[mid را بررسی کنیم، که مقدار 5 را به دست میدهد، (precedes(k قبل از مقداری است که به دنبالش میگردیم و از این رو مقدار زیر یا مقداری بالاتر از میانه را تنظیم میکنیم:
اگر مقدار مورد نظر در این بازه نباشد، مقدار hi = mid را تنظیم میکنیم، چون میخواهیم هر چیزی پایین تراز mid را بگردد. بدین ترتیب ادامه میدهیم تا زمانی که یک مقدار باقی بماند که lo است.
انواع Associated به منظور placeholder-هایی مشابه <T>؛ اما در اعلان پروتکل استفاده میشوند. مثال لیست فوق را با استفاده از پروتکل با یک نوع Associated بازنویسی میکنیم:
ابتدا associatedtype را داریم که آن را Item مینامیم، زیرا قرار است آیتمهایی را در یک آرایه ذخیره کنیم.
سپس آرایه items را با استفاده از یک getter که با { get } نمایش مییابد ایجاد میکنیم. این دستور به کامپایلر اعلام میکند که این آرایه باید فقط-خواندنی باشد. اگر بخواهیم این آرایه قابل خواندن و قابل نوشتن باشد میتوانیم از { get set } استفاده کنیم. در این حالت تنها میخواهیم که کاربر متغیر را با استفاده از تابع add تعیین کند. در مقالات آینده در مورد getrer-ها و setter-ها بیشتر صحبت خواهیم کرد.
در این مورد نیز یک mutating func داریم، زیرا تابع خودش، یعنی آن struct که مالک متد را تغییر میدهد همچنین متدی برای دریافت آیتمها ایجاد میکنیم که نکته جدیدی ندارد.
Struct با نام <List<T خارج از چارچوب پروتکل و تا حدود زیادی شبیه به وضعیت پیشین است. البته ما هیچ اکستنشنی برای پروتکل ایجاد نکردهایم که بتواند در صورت نیاز کارکردهای پیشفرض را شامل شود. در برخی موارد زمانی که بین انواع مختلف سوئیچ میکنیم، ممکن است به کارکردهای متفاوتی نیاز داشته باشیم. برای نمونه زمانی که از یک <List<String استفاده میکنیم، ممکن است بخواهیم یک آرایه از کاراکترها و یا آرایهای از رشتهها را الحاق کنیم. همین موضوع در مورد <List<Character نیز صدق میکند.
اینک با کسب این دانش جدید میدانیم که پروتکلهای دیگری نیز وجود دارند که انواع رایجی مانند String ،Int ،Double و غیره از چیزی مانند Numeric ارث میبرند و میتوانیم یک اکستنشن از Numeric بسازیم که پروتکل را به خدمت بگیرد و کارکرد پیشفرضی که همه انواع Numeric را در برمیگیرد برای آن تعریف کنیم. در این حالت میتوانیم یک چنین موردی را برای نوعهای StringProtocol برای رشتهها بسازیم.
نکته آخری که باید در مورد ژنریکها بگوییم در خصوص بند where است. بند where یک متمم برای پروتکل یا associatedtypes است.
بدین ترتیب myProtocol یک الزام روی هر چیزی که از این پروتکل استفاده کند، قرار میدهد و همچنین از Hashable استفاده میکند. به طور معمول سازگاری با پروتکلهای کتابخانه استاندارد سوئیفت نیازمند پیادهسازی چند نوع، متغیر و/یا متد associated است که کمی اضافهکاری به نظر میرسد. در مورد Hashable باید کد زیر را به struct یا class خود اضافه کنید.
hashvalue کاملاً سرراست است؛ اما static func ==(lhs:rhs:) -> Bool برای ما کاملاً جدید است.
static به این معنی است که میتوان آن را در هر کجا صرفاً با استفاده از ListA == ListB فراخوانی کرد و دو لیست را برای برابری فشرده میسازد. علامت == جایی است که برابرسازی اجرا میشود و یک روش استفاده از این عملگر محسوب میشود. lhs و rhs به معنی سمت چپ و سمت راست عملگر برابری هستند. ما یک مقدار بولی بازگشت میدهیم اما پیادهسازی این تابع خالی است. بنابراین باید پرسید چه اتفاقی در آن میافتد؟ منطقی که قصد داریم استفاده کنیم استفاده از بررسی برابری است. ما صرفاً یک پیادهسازی پیشفرض میسازیم که چارچوبی مانند زیر دارد:
اگر lhs برابر با rhs باشد، مقدار true و در غیر این صورت مقدار false بازگشت مییابد.
در بخش دوم که بند where با یک نوع associated استفاده شده است، در واقع قصد داریم کارکرد خود را در صورتی ارائه کنیم که شیئی که پروتکل را اختیار کرده است، الزام نوع مرتبط آن را نیز مورد استفاده قرار دهد. اگر شیء این کار را بکند، همه متدهایی که از نوع associated استفاده میکنند را به دست میآورد و در غیر این صورت چنین اتفاقی نخواهد افتاد. پیادهسازی آن به صورت زیر است:
به طور خلاصه تفاوتهای بند where بین سطح پروتکل و سطح نوع associated چنین است که وقتی در سطح پروتکل استفاده میشود، به خدمت گرفتن شیء برای استفاده از پروتکل ارجاع یافته در بند where ضروری است. زمانی که بند where در سطح نوع associated استفاده میشود، شیء برای استفاده از پروتکل تعریفشده در بند where ضروری نیست؛ اما به همه متدهایی که در اختیار پروتکل هستند نیز دسترسی نخواهد داشت.
سخن پایانی
بدین ترتیب به پایان این مقاله با موضوع ژنریکها میرسیم. ژنریکها کارکردهای زیادی را با چند تغییر کوچک در کد در اختیار ما قرار میدهند. این موردی است که در زمان ایجاد پروتکلها قطعاً باید در خاطر داشته باشیم و از خود بپرسیم آیا این پروتکل برای انواع مختلفی استفاده خواهد شد؟ یا این انواع چندان متفاوت هستند که باید به دنبال استفاده از بند where برای محدودسازی کارکردهای ارائه شده باشیم.
اغلب افراد تصور میکنند که در برنامهنویسی مهمترین نکته دست یافتن به منطق طرز کار اپلیکیشنها است. با این که این منطق مهم است؛ اما نکته مهمتر از آن مسائلی مانند کامنت های کد، حفظ خوانایی کد، سازگاری در چینش فایلها و ساختار کد و در نهایت روش مدیریت خطا در کد است.
همه افراد میتوانند اپلیکیشنی بسازند که کار مفیدی انجام دهد؛ اما ممکن است همین اپلیکیشن زمانی که API سرور تغییر پیدا میکند و دادههایی خارج از آن چه مورد انتظار است دریافت میکند از کار بیفتد چون خطاها به درستی مدیریت نشدهاند. به دلیل اهمیت موضوع مدیریت خطا در بخش بعدی قصد داریم به بررسی روش مدیریت خطا در iOS و macOS بپردازیم. تا آن زمان به تمرین کد کدنویسی ادامه بدهید و هر کجا که به مشکلی برخورد کردید به مستندات مراجعه کنید. اگر در اولین بار کدتان کار نکرد نباید نگران شوید زیرا یادگیری ژنریک ها به زمان نیاز دارد. در بخش بعدی این سری مقالات آموزشی، در مورد مدیریت خطا در سوئیفت صحبت خواهیم کرد.
برای مطالعه قسمت بعدی این مجموعه مطلب آموزشی روی لینک زیر کلیک کنید:
اگر این مطلب برای شما مفید بوده است، آموزشهای زیر نیز به شما پیشنهاد میشوند:
- مجموعه آموزشهای برنامهنویسی
- آموزش برنامه نویسی Swift (سوئیفت) برای برنامه نویسی iOS
- مجموعه آموزشهای پروژه محور برنامهنویسی
- مقایسه Swift و React-Native از فریمورکهای ساخت اپلیکیشن در iOS
- پوش نوتیفیکیشن (Push Notification) در iOS با استفاده از Swift — به زبان ساده
==