انواع تابع-پارامتر ژنریک در تایپ اسکریپت — به زبان ساده

۵۹ بازدید
آخرین به‌روزرسانی: ۰۳ مهر ۱۴۰۲
زمان مطالعه: ۶ دقیقه
انواع تابع-پارامتر ژنریک در تایپ اسکریپت — به زبان ساده

ویرایش مشخصه‌های تایپ اسکریپت/جاوا اسکریپت یکی از نخستین کارهایی است که به عنوان برنامه‌نویسی یاد می‌گیریم. امکان ویرایش مستقیم مشخصه‌ها به صورت ’foo.bar = ‘test وجود دارد. همچنین می‌توان یک کپی سطحی به صورت const shallowFoo = {…foo, bar: ‘test’} ایجاد کرد. در این مقاله با روش ویرایش انواع تابع-پارامتر ژنریک در تایپ اسکریپت آشنا خواهیم شد.

در اغلب موارد این نوع از ویرایش اشیا با نوعی منطق تجاری که در پس آن قرار دارد انجام می‌یابد. در هر حال باید نوعی دلیل برای به‌روزرسانی مشخصه‌های شیء وجود داشته باشد.

می‌توان با انتقال این منطق به یک تابع کمکی به آسانی کد را تست کرده و مورد استفاده مجدد قرار دارد. به این ترتیب به اصل DRY که یکی از اصول برنامه‌نویسی عمل‌گرایانه است، وفادار خواهیم ماند. اما در این حالت نیز چالش جدیدی بروز می‌یابد: چگونه می‌توان این توابع کمکی را ژنریک نگه داشت، در حالی که هدف آن‌ها منفرد و روشن است؟

بیان مسئله

1/**
2 * Non-reusable business logic
3 */
4function ExampleA1() {
5  const productData: ProductData = queryProduct();
6
7  const mappedProductData = {
8    ...productData,
9    id: `product id: ${productData.id}`
10  };
11
12  return mappedProductData;
13}

برای توصیف بهتر مسئله در این مقاله با یک مثال تخیلی کار می‌کنیم که در آن می‌خواهیم ID-های مربوط به اشیای ورودی را ویرایش کنیم.

این مثال یک پیشوند به همه ID-هایی که از API می‌آیند می‌دهد تا آن‌ها را از ID-هایی که از سوی یک فریمورک در اپلیکیشن تعیین شده است متمایز سازد. اینترفیسی که در این مثال استفاده می‌کنیم به توصیف یک محصول با استفاده از دو مشخصه id و price می‌پردازد.

1interface ProductData {
2  id: string;
3  price: number;
4}

در ادامه داده‌ها را بازیابی کرده و پیشوند product-id را به مشخصه id می‌دهیم:

1const productData: ProductData = {id: '123', price: 10};
2const mappedProductData = {
3  ...productData,
4  id: `product-id: ${productData.id}`
5};

در کد فوق `product-id: ${productData.id}` یک «لفظ رشته‌ای» (string literal) است.

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

تابع پایه با قابلیت استفاده مجدد

1/**
2 * Reusable business logic, but limited to the ProductData model
3 */
4function ExampleA2() {
5  const productData: ProductData = queryProduct();
6  const mappedProductData = mapProductData(productData);
7
8  return mappedProductData;
9}

با جدا ساختن منطق در یک تابع مجزا، قابلیت استفاده مجدد افزایش می‌یابد و به عنوان برنامه‌نویس می‌توانیم به مالکان محصول اطمینان بدهیم که مشخصه id همواره به روش یکسانی تغییر خواهد کرد.

1function mapProductData(productData: ProductData) {
2  return {
3    ...productData,
4    id: `product-id: ${productData.id}`
5  };
6}

سپس می‌توانیم تابع را فراخوانی کنیم:

1const mappedProductData = mapProductData({id: '123', price: 10});

بدین ترتیب تابعی با قابلیت استفاده مجدد به دست می‌آید که دارای یک هدف منفرد و روشن است و آن افزودن یک پیشوند به مشخصه id است. اما این راه‌حل چندین مشکل دارد. قبل از هر چیز نوع شیء ProductData از دست رفته است و تابع شیء را با یک اینترفیس بدون نام بازگشت می‌دهد:

تابع-پارامتر ژنریک در تایپ اسکریپت

دوم تابع الزام دارد که همواره از شکل ProductData به عنوان نوع پارامتر استفاده کند. اگر چنین نباشد، تایپ اسکریپت یک خطای linting می‌دهد. همچنین حالت بدتر این است که همگام‌سازی با اینترفیس خود را از دست دهیم. به کد زیر توجه کنید:

1/**
2 * Opps! We discover some problems if we introduce a new data model of another product
3 */
4function ExampleA3() {
5  const anotherProductData: AnotherProductData = queryAnotherProduct();
6  const mappedProductData = mapProductData(anotherProductData);
7
8  // console.log(mappedProductData.size);
9  // => Property 'size' does not exist on type '{ id: string; price: number; }'
10  // Oops, typescript think we lost size!
11
12  return mappedProductData;
13}

راه‌حل

1/**
2 * Fully reusable business logic, that only describes
3 * the minimal model it needs and returns the correct model
4 */
5function ExampleA4() {
6  const anotherProductData: AnotherProductData = queryAnotherProduct();
7  const mappedProductData = mapAnyProductData(anotherProductData);
8
9  console.log("We have size again!", mappedProductData.size); // => L
10
11  return mappedProductData;
12}

برای حل مشکل باید رویکردمان به پارامترهای تابع را مورد بازنگری قرار دهیم. تا به این جا از نوع ثابتی برای پارامترها استفاده می‌کردیم که یک شیء دارای مشخصه‌های id و price بود.

اما نگشت اجرا شده تنها نیازمند دانستن مشخصه id است. با توصیف این شکل کمینه الزامی در تایپ اسکریپت، می‌توان قرارداد دقیق‌تری برای تابع ایجاد کرد. این وضعیت را می‌توان به صورت زیر توصیف کرد:

1interface MinimumProductData {
2  id: string;
3}

برای این که این نگاشت ژنریک‎تر شود، تابع باید هر شیئی که دست کم با MinimumProductData سازگار است را بپذیرد. این کار با استفاده از انواع ژنریک در تایپ اسکریپت ممکن می‌شود.

این همان جایی است که کار کمی پیچیده شود. ابتدا باید دانش خود را در مورد ژنریک‌ها کمی یادآوری کنیم. در اعلان یک تابع ژنریک‌ها پس از نام تابع به صورت <T> تعریف می‌شوند:

1function foo<T>() {
2  ...logic goes here
3}

به این ترتیب T می‌تواند درون خود تابع، و پارامترهایش نیز استفاده شود و/یا نوع آن را بازگشت دهد. برای این که T به صوت خودکار به یک نوع انتساب یابد، پارامتر خود را از نوع T تعریف می‌کنیم تایپ اسکریپت به صورت خودکار نوع را از آرگومان‌های تابع می‌گیرد و به T انتساب می‌دهد.

1function foo<T>(input: T) {
2  ...logic goes here
3}

بنابراین آرگومان تابع هر نوعی داشته باشد، T نیز از همان نوع خواهد بود. ورودی رشته نوع باید از نوع T باشد تا رشته نوع باشد. ورودی شیء نوع و T از شیء نوع خواهند بود و همین طور تا آخر.

1foo('bar'); // And T will be of type string.

اگر به مثال خود بازگردیم، ما نمی‌خواستیم در تابع نگاشت کننده خود یعنی mapProductData یک رشته دریافت کنیم. حتی نمی‌خواستیم یک شیء ژنریک داشته باشیم. ما یک شیء می‌خواستیم که دست کم شکل MinimumProductData با مشخصه id داشته باشد.

این همان جایی است که از کلیدواژه extends در تایپ اسکریپت استفاده می‌کنیم. Extends موجب می‌شود که نوع ژنریک ما دست کم دارای شکل مفروض باشد. در مورد mapAnyProductData می‌خواهیم نوع ژنریک دست کم شامل باشد. نام ژنریک را TProduct می‌گذاریم. به این ترتیب <TProduct extends MinimumProductData> و تابع می‌تواند هر شیء ورودی را تا زمانی که مشخصه id دارد بپذیرد.

1function mapAnyProductData<TProduct extends MinimumProductData>(anyProductData: TProduct) {
2  return {
3    ...anyProductData,
4    id: `product-id: ${anyProductData.id}`
5  };
6}

کاربرد آن نیز نسبت به حالت قبل تغییر نیافته است:

1const mappedProduct = mapAnyProductData({id: '123', price: 10});

توجه کنید که نوع بازگشتی خالی مانده است. شاید وسوسه شوید که برای مقدار بازگشتی نوعی تعیین کنید، اما شاید تنبل بودن در اینجا بهترین گزینه باشد. بدین ترتیب کد غیرضروری حذف می‌شود و خواندن آن کمی آسان‌تر می‌شود به خصوص در زمانی که کد پیچیده‌تر می‌شود، این مسئله اهمیت بیشتری دارد. بدین ترتیب می‌توانیم از این منطق در سراسر اپلیکیشن به طور مجدد استفاده کنیم و تنها به انواع خاصی از اشیا محدود نباشیم، اما همه این اشیا باید دارای مشخصه id باشند.

1const mappedProduct = mapAnyProductData({
2  id: '123', 
3  cost: 10, 
4  type: 'horse', 
5  currency: '£',
6});

اینک کد به خوبی کار می‌کند و مشکل حل شده است.

بسط یک شیء تغییر ناپذیر

1function ExampleB1() {
2  const productData: ProductData = queryProduct();
3  const mappedProductData = mapProductData(productData);
4
5  return mappedProductData;
6}
7
8function ExampleB2() {
9  const productData: ProductData = queryProduct();
10  const mappedProductData = mapProductDataSmart(productData);
11
12  return mappedProductData;
13}

اینک اگر شیئی «تغییرناپذیر» (immutable) داشته باشیم که بخواهیم بسط دهیم چطور؟ در این حالت می‌توانیم از روش زیر استفاده کنیم:

1interface MinimumProductData {
2  price: number;
3}
4function mapProductData<TProduct extends MinimumProductData>(productData: TProduct) {
5  return {
6    ...productData,
7    priceWithVat: productData.price * 1.2
8  };
9}
10const mappedProductData = mapProductData({price: 10, type: 'car'});
11console.log(mappedProductData.priceWithVat); // 12

در اینجا تنها تفاوت آن است که شکل بازگشتی به صورت زیر تعریف می‌شود:

1MinimumProductData {
2  price: number;
3  type: string;
4} & {
5  priceWithVat: number;
6}

اکنون تابع می‌تواند هر نوع شیئی بگیرد و تنها کافی است مشخصه price را داشته باشند. این تابع همان نوع را با اکستنشن priceWithVat بازگشت می‌دهد. این یک روش موفقیت‌آمیز برای بسط یک شیء است که منطق تجاری را بدون تغییر حفظ و نیاز به تکرار کردن را حذف می‌کند. این روش برای ساخت محصولات مقیاس‌پذیر در تایپ اسکریپت مناسب است.

توجه کنید که تایپ اسکریپت انواع اصلی و بسط یافته را با استفاده از کاراکتر & از هم متمایز می‌سازد. این حالت از این واقعیت ناشی می‌شود که mapProductData یک نوع بازگشتی بدون نام دارد. اما اگر بخواهیم دردسر نوع‌بندی تابع را به جان بخریم، باید از اندازه کامل شیء ورودی اطلاع داشته باشیم و از این رو تابع دیگر نمی‌تواند ژنریک باشد. به این ترتیب از این واقعیت بهره می‌گیریم که این یک روش جدید برای نگریستن به انواع بسط یافته در اپلیکیشن محسوب می‌شود. این یک هزینه جزئی در برابر دستیابی به قابلیت استفاده مجدد در تابع‌های تک منظوره است که اشیا را ویرایش می‌کنند.

سخن پایانی

در این مقاله با روش ویرایش ژنریکی یک شیء که به وسیله انواع ژنریک تایپ اسکریپت تعریف شده است، آشنا شدیم. همچنین به توضیح کلیدواژه extends پرداختیم. این کلیدواژه یک شکل کمینه برای پارامترهای تابع در تایپ اسکریپت تعریف می‌کند. در نهایت روش جداسازی منطق تجاری را در یک تابع تک منظوره با قابلیت استفاده مجدد توضیح دادیم که به ایجاد اپلیکیشن‌های با مقیاس‌پذیری بیشتر کمک می‌کند.

اگر این مطلب برای شما مفید بوده است، آموزش‌های زیر نیز به شما پیشنهاد می‌شوند:

==

بر اساس رای ۱ نفر
آیا این مطلب برای شما مفید بود؟
اگر بازخوردی درباره این مطلب دارید یا پرسشی دارید که بدون پاسخ مانده است، آن را از طریق بخش نظرات مطرح کنید.
منابع:
better-programming
نظر شما چیست؟

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *