بخش عمدهای از مهندسی نرم افزار، مربوط به ساخت کامپوننتهایی است که نه تنها به خوبی و مستحکم توسعه داده شدهاند، بلکه قابلیت استفاده دوباره را نیز دارند.
کامپوننتهایی که قادر هستند بر روی دادههای فعلی و همچنین دادههای آینده، کار کنند، قابلیتهای انعطاف پذیری را برای ساخت سیستمهای نرم افزاری بزرگ در اختیار شما قرار خواهند داد.
در زبان هایی نظیر جاوا و سی شارپ، یکی از ابزارهای اصلی برای ساخت کامپوننتهایی با قابلیت استفاده مجدد، "جنریکها" میباشد که امکان ساخت کامپوننتهایی را میدهند که با انواع دادههای متنوعی به جای یک نوع داده، کار میکنند.
برای شروع به تابع زیر توجه کنید:
function identity(arg: number): number { return arg; }
تابع identity هر آنچه را که به عنوان پارامتر به آرگومان آن ارسال کنیم، بازگشت خواهد داد. میتوانید آن را به مانند دستور "echo" در نظر بگیرید.
بدون استفاده از جنریک ها، باید برای هر نوع داده، یک تابع جدید و یا تابعی را به صورت کلی زیر در نظر بگیریم:
function identity(arg: any): any { return arg; }
در تابع بالا از نوع any استفاده شده است. با استفاده از any، قطعا تابع بالا به صورت عمومی خواهد بود و تمام نوع دادهها را به عنوان آرگومان خواهد پذیرفت. ولی در واقع ما اطلاعات مربوط به اینکه نوع داده بازگشتی توسط تابع چه چیزی است را از دست خواهیم داد.
برای مثال اگر یک عدد را به آن ارسال کنیم، تنها متوجه خواهیم شد که نوع آن any میباشد؛ بنابراین به روشی نیاز داریم تا بتوانیم نوع داده آرگومانهای تابع مورد نظر را کنترل کنیم.
در پیاده سازی زیر، ما از یک type variable خاصی استفاده خواهیم کرد که به جای مقادیر برای انوع دادهها مورد استفاده قرار میگیرد.
function identity<T>(arg: T): T { return arg; }
در تابع بالا با از T به عنوان یک type variable استفاده کردهایم که امکان گرفتن انواع دادههایی را (برای مثال number) که توسط کاربر مهیا میشود، به ما خواهد داد.
این پیاده سازی از تابع identity، تحت عنوان تابع جنریک مطرح میشود که برای دامنهی عظیمی از انواع دادهها میتواند مورد استفاده قرار گیرد و بر خلاف پیاده سازی قبل که از any استفاده کردهایم، در این حالت دیگر اطلاعات نوع داده را از دست نخواهیم داد.
برای استفاده از تابع فوق ما دو روش را پیش رو خواهیم داشت:
- ارسال تمام آرگومانها که شامل آرگومان نوع داده هم میباشد
let output = identity<string>("myString"); // type of output will be 'string'
در کد بالا ما به صراحت T را با نوع داده string با استفاده از < > مقدار دهی کردهایم.
- روش دوم که شاید استفاده رایج از توابع جنریک هم هست، استفاده از امکان type argument inference میباشد.
let output = identity("myString"); // type of output will be 'string'
در کد بالا اینبار به صورت صریح نوع T را مشخص نکردهایم و کامپایلر باتوجه به "myString"، نوع T را تعیین خواهد کرد. درحالیکه استفاده از امکان type argument inference خیلی مفید میباشد و کد را خیلی کم حجم و خوانا در اختیار ما قرار میدهد، ولی در مثالهای پیچیده، امکان این وجود دارد که کامپایلر در تشخیص نوع داده، با خطا مواجه شود. در این صورت استفاده از روش اول مفید خواهد بود.
در ادامه اگر قصد لاگ کردن Length مربوط به آرگومان arg را در هر بار فراخوانی تابع داشته باشیم، میبایستی به شکل زیر عمل کنیم:
function loggingIdentity<T>(arg: T): T { console.log(arg.length); // Error: T doesn't have .length return arg; }
همانطور که انتظار داشتیم، کامپایلر خطایی مبنی بر نداشتن عضوی تحت عنوان length برای آرگومان arg را نمایش خواهد داد. همانطور که قبلا نیز اشاره کردیم، T جانشینی برای تمام نوع دادهها خواهد بود؛ بنابراین در اینجا میتوانیم یک دادهی از نوع number را که عضوی بنام length ندارد، هم به این تابع پاس دهیم.
حال بیایید بگوییم که ما قصد داریم این تابع، با آرایه ای از T کار کند. در این صورت اگر با آرایهها کار کنیم، عضوی به نام length را خواهیم داشت. به پیاده سازی زیر توجه کنید:
function loggingIdentity<T>(arg: T[]): T[] { console.log(arg.length); // Array has a .length, so no more error return arg; }
کد بالا را میتوانیم به این شکل تفسیر کنیم: تابع جنریک loggingIdentity یک type parameter را تحت عنوان T و یک آرگومان را تحت عنوان arg که آرایه ای از T هست، گرفته و آرایهای از T را بازگشت خواهد داد. اگر ما آرایهای از number را به آن پاس دهیم، آرایهای از numberها را بازگشت خواهد داد.
در این حالت استفاده از T به عنوان type variable که بخشی از نوع دادههایی است که ما با آنها کار میکنیم، به جای پشتیبانی از تمام نوع دادهها، انعطاف پذیری بالایی را به ما خواهد داد.
حتی میتوانیم این مثال را به شکل زیر نیز پیاده سازی کنیم:
function loggingIdentity<T>(arg: Array<T>): Array<T> { console.log(arg.length); // Array has a .length, so no more error return arg; }
پیاده سازی بالا خیلی شبیه به پیاده سازی در سایر زبانها هم میباشد.
Generic Types
در این قسمت ما به دنبال یافتن نوع خود توابع بوده و سعی خواهیم کرد اینترفیسهای جنریک را هم پیاده سازی کنیم. نوع توابع جنریک هم بمانند توابع غیر جنریک میباشند؛ به طوری که میتوان لیستی از type parameters هایی را که در حالت function declarations موجود هستند، در ابتدا بنویسیم.
function identity<T>(arg: T): T { return arg; } let myIdentity: <T>(arg: T) => T = identity;
حتی میتوانیم نام متفاوتی را هم برای type parameter در نظر بگیرم:
function identity<T>(arg: T): T { return arg; } let myIdentity: <U>(arg: U) => U = identity;
یا حتی میتوانیم به مانند امضای یک object literal هم کد بالا را بازنویسی کنیم:
function identity<T>(arg: T): T { return arg; } let myIdentity: {<T>(arg: T): T} = identity;
حال میتوانیم این object literal را به یک اینترفیس منتقل کنیم:
interface GenericIdentityFn { <T>(arg: T): T; } function identity<T>(arg: T): T { return arg; } let myIdentity: GenericIdentityFn = identity;
کد بالا خوانایی بالاتری را نسبت به حالت قبل دارد و با تعریف یک اینترفیس به نام GenericIdentityFn و انتقال object literal به داخل آن، میتوانیم از نام اینترفیس به جای استفاده مستقیم از object literal، بهره ببریم.
حتی میتوانیم type parameter تابع جنریک خود را هم به اینترفیس منتقل کنیم.
interface GenericIdentityFn<T> { (arg: T): T; } function identity<T>(arg: T): T { return arg; } let myIdentity: GenericIdentityFn<number> = identity;
باید توجه داشت که پیاده سازی ما کمی متفاوتتر از قبل شده است.الان type parameter ما برای کل اعضای اینترفیس قابل رویت میباشد.فهم این مورد که چه زمانی type parameter را در امضای نامیدن داخل اینترفیس یا بر روی خود اینترفیس استفاده کنیم، خود میتوانید برای شرح اینکه کدام وجههای یک نوع داده جنریک هستند، مفید باشد.
نکته : امکان تعریف enumها و namespaceهای جنریک وجود ندارد.
Generic Classes
تعریف کلاسهای جنریک هم به مانند اینترفیسهای جنریک میباشد. به مثال زیر توجه کنید:
class GenericNumber<T> { zeroValue: T; add: (x: T, y: T) => T; } let myGenericNumber = new GenericNumber<number>(); myGenericNumber.zeroValue = 0; myGenericNumber.add = function(x, y) { return x + y; };
در کد بالا، استفادهای واقعی از کلاس GenericNumber قابل مشاهده است. شاید متوجه شده باشید که هیچ محدودیتی برای استفادهی نوعها برای مثال تنها از نوع number در آن نیست و میتوانید از نوع string هم به شکل زیر استفاده کنید:
let stringNumeric = new GenericNumber<string>(); stringNumeric.zeroValue = ""; stringNumeric.add = function(x, y) { return x + y; }; alert(stringNumeric.add(stringNumeric.zeroValue, "test"));
نکته : برای اعضای استاتیک کلاس نمیتوانید از type parameter کلاس استفاده کنید.
Generic Constraints
اگر مثال اخیر را به یاد داشته باشید، شاید بعضی اوقات لازم باشد که یک تابع جنریک را تعریف کنیم تا تنها با مجموعهای از نوع دادهها کار کند که اتفاقا از امکانات این مجموعه، آگاهی داریم. در همان مثال loggingIdentity، ما نیاز داشتیم تا به خصوصیت length آرگومان arg دسترسی داشته باشیم و کامپایلر در همان ابتدا، به دلیل اینکه همه نوع دادهها از این خصوصیت برخوردار نیستند، خطایی را به ما نشان میدهد.
در ادامه تابعی را پیاده سازی میکنیم که جوابگوی تمام نوع دادهها بوده، به شرطی که حداقل خصوصیت length را داشته باشند. لذا باید نیاز خود را در قالب یک محدودیت بر آنچه که T میتواند انجام دهد، فهرست کنیم.
interface Lengthwise { length: number; } function loggingIdentity<T extends Lengthwise>(arg: T): T { console.log(arg.length); // Now we know it has a .length property, so no more error return arg; }
در کد بالا برای توصیف محدودیت خود از یک اینترفیس به نام Lengthwise استفاده کردهایم که فقط یه خصوصیت length را دارد و با استفاده از آن و کلمهی کلیدی extends، محدودیت خود را اعمال کرده ایم.
استفاده از تابع بالا:
loggingIdentity(3); // Error, number doesn't have a .length property
چون تابع جنریک ما الان محدود میباشد و با تمام نوع دادهها کار نخواهد کرد، با خطای بالا روبرو خواهیم شد.
loggingIdentity({length: 10, value: 3});
در عوض مثال بالا، محدودیت ما را به همراه دارد (داشتن خصوصیت length) و بدون هیچ خطایی جواب خواهیم گرفت.
استفاده از Type Parameterها در تعریف محدودیت
در برخی از سناریوها شاید نیاز باشد که یکی از type parameterها توسط دیگری محدود شده باشد. به مثال زیر توجه کنید:
function find<T, U extends Findable<T>>(n: T, s: U) { // errors because type parameter used in constraint // ... } find (giraffe, myAnimals);
همانطور که مشخص است، کامپایلر ما را با نشان دادن خطایی متوقف خواهد کرد. چون اجازهی استفاده از type parameter را در اعمال محدودیت، نداریم. در عوض میشود به شکل زیر عمل کرد:
function find<T>(n: T, s: Findable<T>) { // ... } find(giraffe, myAnimals);
این بار آرگومان s ما باید از نوع <Findable<T باشد که باز هم توانستهایم محدودیت خود را توسط یک type parameter بر آن یکی اعمال کنیم.
نکته : دو پیاده سازی بالا اصلا یکسان نیستند؛ نوع بازگشی در تابع اول میبایستی از نوع U میبود، ولی در پیاده سازی دوم اینگونه نیست.(در صورت نبودن خطا)
استفاده از کلاسها در جنریکها
زمانی که قصد دارید با استفاده از جنریکها، factoryها را پیاده سازی کنید، باید با استفاده از سازندهی کلاسها، به آنها اشاره کنید. به مثال زیر توجه کنید:
function create<T>(c: {new(): T; }): T { return new c(); }
تابع بالا به عنوان یک object factory میتواند مورد استفاده قرار بگیرد و نکته آن در تعریف نوع آرگومان c میباشد که باز هم به صورت object literal معرفی شده است. اگر در قسمتهای بالا به یاد داشته باشید، میتوان این مورد را هم داخل یک اینترفیس گنجاند.
به عنوان یک مثال پیشرفتهتر هم میتوان به استفاده از prototype property برای استنتاج type parameterها و تحمیل کردن ارتباط بین تابع سازنده و وهله کلاسها، اشاره کرد. به مثال زیر توجه کنید:
class BeeKeeper { hasMask: boolean; } class ZooKeeper { nametag: string; } class Animal { numLegs: number; } class Bee extends Animal { keeper: BeeKeeper; } class Lion extends Animal { keeper: ZooKeeper; } function findKeeper<A extends Animal, K> (a: {new(): A; prototype: {keeper: K}}): K { return a.prototype.keeper; }
در کد بالا از دو کلاس BeeKeeper و ZooKeeper برای نوع بازگشتی متدهای موجود در کلاسهای Bee و Lion استفاده شدهاست. کلاس Animal به عنوان کلاس پایه دو کلاس Bee و Lion که یک خصوصیت numLegs دارد، تعریف شدهاست. از تابع جنریک findKeeper برای مشخص کردن نگهبان مرتبط با Animal ای که به عنوان type parameter توسط A مشخص میشود، استفاده میگردد. محدودیتی که بر روی A اعمال شده است نشان دهندهی این است که نوع دادهی مورد نظر باید حتما یک Animal باشد و همچنین با اعمال محدودیتی که در قالب object literal مشخص است، تعیین شده است که نوع مورد نظر باید یک کلاس باشد و در نهایت با استفاده از prototype مشخص کردهایم که متدی به نام Keeper آن کلاس، باید نوع برگشتی از نوع K را که به عنوان type parameter مطرح شدهی در امضای تابع است، دارا باشد. K نشان دهنده نوع داده بازگشتی این تابع جنریک نیز میباشد.
استفاده از تابع بالا:
findKeeper(Lion).nametag; // typechecks!
بله همانطور که مشخص است، type parameterهای مورد نظر به اصطلاح infer شدهاند و خصوصیت nametag نشان از این دارد که ZooKeeper به صورت خودکار به عنوان نوع داده K تشخیص داده شده است.