فارسی

قدرت حاشیه‌نویسی‌های واریانس و محدودیت‌های پارامتر نوع تایپ‌اسکریپت را برای ایجاد کدی انعطاف‌پذیر، امن و قابل نگهداری آزاد کنید. بررسی عمیق با مثال‌های عملی.

حاشیه‌نویسی‌های واریانس در تایپ‌اسکریپت: تسلط بر محدودیت‌های پارامتر نوع برای کد قوی

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

درک واریانس

واریانس توصیف می‌کند که چگونه رابطه زیرنوعی بین تایپ‌ها بر رابطه زیرنوعی بین تایپ‌های ساخته‌شده (مثلاً تایپ‌های جنریک) تأثیر می‌گذارد. بیایید اصطلاحات کلیدی را بررسی کنیم:

به خاطر سپردن این مفاهیم با یک قیاس ساده‌تر است: کارخانه‌ای را در نظر بگیرید که قلاده سگ تولید می‌کند. یک کارخانه هم‌وردا ممکن است بتواند برای همه انواع حیوانات قلاده تولید کند اگر بتواند برای سگ‌ها قلاده تولید کند، و به این ترتیب رابطه زیرنوعی را حفظ می‌کند. یک کارخانه پادوردا کارخانه‌ای است که می‌تواند هر نوع قلاده حیوانی را *مصرف* کند، با این فرض که می‌تواند قلاده سگ را مصرف کند. اگر کارخانه فقط بتواند با قلاده‌های سگ کار کند و نه هیچ چیز دیگر، نسبت به نوع حیوان ناوردا است.

چرا واریانس اهمیت دارد؟

درک واریانس برای نوشتن کدهای ایمن از نظر نوع، به ویژه هنگام کار با جنریک‌ها، حیاتی است. فرض نادرست هم‌وردایی یا پادوردایی می‌تواند منجر به خطاهای زمان اجرا شود که سیستم نوع تایپ‌اسکریپت برای جلوگیری از آنها طراحی شده است. این مثال ناقص را در نظر بگیرید (در جاوااسکریپت، اما برای نشان دادن مفهوم):

// مثال جاوااسکریپت (فقط برای توضیح، نه تایپ‌اسکریپت)
function modifyAnimals(animals, modifier) {
  for (let i = 0; i < animals.length; i++) {
    animals[i] = modifier(animals[i]);
  }
}

function sound(animal) { return animal.sound(); }

function Cat(name) { this.name = name; this.sound = () => "Meow!"; }
Cat.prototype = Object.create({ sound: () => "Generic Animal Sound"});
function Animal(name) { this.name = name; this.sound = () => "Generic Animal Sound"; }

let cats = [new Cat("Whiskers"), new Cat("Mittens")];

// این کد خطا می‌دهد زیرا تخصیص Animal به آرایه Cat صحیح نیست
//modifyAnimals(cats, (animal) => new Animal("Generic")); 

// این کد کار می‌کند زیرا Cat به آرایه Cat تخصیص داده می‌شود
modifyAnimals(cats, (cat) => new Cat("Fuzzy"));

//cats.forEach(cat => console.log(cat.sound()));

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

محدودیت‌های پارامتر نوع

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

کلمه کلیدی extends

راه اصلی برای تعریف محدودیت‌های پارامتر نوع استفاده از کلمه کلیدی extends است. این کلمه کلیدی مشخص می‌کند که یک پارامتر نوع باید زیرنوعی از یک نوع خاص باشد.

function logName<T extends { name: string }>(obj: T): void {
  console.log(obj.name);
}

// استفاده معتبر
logName({ name: "Alice", age: 30 });

// خطا: آرگومان از نوع '{}' قابل تخصیص به پارامتر از نوع '{ name: string; }' نیست.
// logName({});

در این مثال، پارامتر نوع T محدود شده است به نوعی که دارای یک ویژگی name از نوع string باشد. این تضمین می‌کند که تابع logName می‌تواند با خیال راحت به ویژگی name آرگومان خود دسترسی داشته باشد.

محدودیت‌های چندگانه با انواع اشتراکی (Intersection Types)

شما می‌توانید چندین محدودیت را با استفاده از انواع اشتراکی (&) ترکیب کنید. این به شما امکان می‌دهد مشخص کنید که یک پارامتر نوع باید چندین شرط را برآورده کند.

interface Named {
  name: string;
}

interface Aged {
  age: number;
}

function logPerson<T extends Named & Aged>(person: T): void {
  console.log(`Name: ${person.name}, Age: ${person.age}`);
}

// استفاده معتبر
logPerson({ name: "Bob", age: 40 });

// خطا: آرگومان از نوع '{ name: string; }' قابل تخصیص به پارامتر از نوع 'Named & Aged' نیست.
// ویژگی 'age' در نوع '{ name: string; }' وجود ندارد اما در نوع 'Aged' الزامی است.
// logPerson({ name: "Charlie" });

در اینجا، پارامتر نوع T محدود شده است به نوعی که هم Named و هم Aged باشد. این تضمین می‌کند که تابع logPerson می‌تواند با خیال راحت به هر دو ویژگی name و age دسترسی داشته باشد.

استفاده از محدودیت‌های نوع با کلاس‌های جنریک

محدودیت‌های نوع هنگام کار با کلاس‌های جنریک به همان اندازه مفید هستند.

interface Printable {
  print(): void;
}

class Document<T extends Printable> {
  content: T;

  constructor(content: T) {
    this.content = content;
  }

  printDocument(): void {
    this.content.print();
  }
}

class Invoice implements Printable {
  invoiceNumber: string;

  constructor(invoiceNumber: string) {
    this.invoiceNumber = invoiceNumber;
  }

  print(): void {
    console.log(`Printing invoice: ${this.invoiceNumber}`);
  }
}

const myInvoice = new Invoice("INV-2023-123");
const document = new Document(myInvoice);
document.printDocument(); // خروجی: Printing invoice: INV-2023-123

در این مثال، کلاس Document جنریک است، اما پارامتر نوع T محدود شده است به نوعی که اینترفیس Printable را پیاده‌سازی کند. این تضمین می‌کند که هر شیئی که به عنوان content یک Document استفاده می‌شود، یک متد print خواهد داشت. این امر به ویژه در زمینه‌های بین‌المللی که چاپ ممکن است شامل فرمت‌ها یا زبان‌های متنوعی باشد و نیازمند یک اینترفیس مشترک print است، مفید می‌باشد.

هم‌وردایی، پادوردایی و ناوردایی در تایپ‌اسکریپت (بازبینی شده)

اگرچه تایپ‌اسکریپت حاشیه‌نویسی‌های واریانس صریح (مانند in و out در برخی زبان‌های دیگر) ندارد، اما به طور ضمنی واریانس را بر اساس نحوه استفاده از پارامترهای نوع مدیریت می‌کند. درک تفاوت‌های ظریف نحوه کار آن، به ویژه با پارامترهای تابع، مهم است.

انواع پارامترهای تابع: پادوردایی

انواع پارامترهای تابع پادوردا هستند. این بدان معناست که شما می‌توانید با خیال راحت تابعی را پاس دهید که نوعی عمومی‌تر از آنچه انتظار می‌رود را می‌پذیرد. این به این دلیل است که اگر یک تابع بتواند یک Supertype را مدیریت کند، قطعاً می‌تواند یک Subtype را نیز مدیریت کند.

interface Animal {
  name: string;
}

interface Cat extends Animal {
  meow(): void;
}

function feedAnimal(animal: Animal): void {
  console.log(`Feeding ${animal.name}`);
}

function feedCat(cat: Cat): void {
  console.log(`Feeding ${cat.name} (a cat)`);
  cat.meow();
}

// این معتبر است زیرا انواع پارامترهای تابع پادوردا هستند
let feed: (animal: Animal) => void = feedCat; 

let genericAnimal:Animal = {name: "Generic Animal"};

feed(genericAnimal); // کار می‌کند اما میو نمی‌کند

let mittens: Cat = { name: "Mittens", meow: () => {console.log("Mittens meows");}};

feed(mittens); // این هم کار می‌کند و *ممکن است* بسته به تابع واقعی میو کند.

در این مثال، feedCat یک زیرنوع از (animal: Animal) => void است. این به این دلیل است که feedCat یک نوع خاص‌تر (Cat) را می‌پذیرد، که آن را نسبت به نوع Animal در پارامتر تابع، پادوردا می‌کند. بخش حیاتی، تخصیص است: let feed: (animal: Animal) => void = feedCat; معتبر است.

انواع بازگشتی: هم‌وردایی

انواع بازگشتی تابع هم‌وردا هستند. این بدان معناست که شما می‌توانید با خیال راحت یک نوع خاص‌تر از آنچه انتظار می‌رود را بازگردانید. اگر یک تابع قول بازگرداندن یک Animal را بدهد، بازگرداندن یک Cat کاملاً قابل قبول است.

function getAnimal(): Animal {
  return { name: "Generic Animal" };
}

function getCat(): Cat {
  return { name: "Whiskers", meow: () => { console.log("Whiskers meows"); } };
}

// این معتبر است زیرا انواع بازگشتی تابع هم‌وردا هستند
let get: () => Animal = getCat;

let myAnimal: Animal = get();

console.log(myAnimal.name); // کار می‌کند

// myAnimal.meow();  // خطا: ویژگی 'meow' در نوع 'Animal' وجود ندارد.
// برای دسترسی به ویژگی‌های خاص Cat باید از type assertion استفاده کنید

if ((myAnimal as Cat).meow) {
  (myAnimal as Cat).meow(); // Whiskers meows
}

در اینجا، getCat یک زیرنوع از () => Animal است زیرا یک نوع خاص‌تر (Cat) را باز می‌گرداند. تخصیص let get: () => Animal = getCat; معتبر است.

آرایه‌ها و جنریک‌ها: ناوردایی (عمدتاً)

تایپ‌اسکریپت با آرایه‌ها و بیشتر انواع جنریک به طور پیش‌فرض به عنوان ناوردا رفتار می‌کند. این بدان معناست که Array<Cat> به عنوان زیرنوعی از Array<Animal> در نظر گرفته *نمی‌شود*، حتی اگر Cat از Animal ارث‌بری کند. این یک انتخاب طراحی عمدی برای جلوگیری از خطاهای بالقوه زمان اجرا است. در حالی که آرایه‌ها در بسیاری از زبان‌های دیگر *رفتاری* شبیه به هم‌وردا دارند، تایپ‌اسکریپت برای ایمنی آنها را ناوردا می‌کند.

let animals: Animal[] = [{ name: "Generic Animal" }];
let cats: Cat[] = [{ name: "Whiskers", meow: () => { console.log("Whiskers meows"); } }];

// خطا: نوع 'Cat[]' قابل تخصیص به نوع 'Animal[]' نیست.
// نوع 'Cat' قابل تخصیص به نوع 'Animal' نیست.
// ویژگی 'meow' در نوع 'Animal' وجود ندارد اما در نوع 'Cat' الزامی است.
// animals = cats; // اگر این اجازه داده می‌شد، مشکل‌ساز بود!

// با این حال این کار خواهد کرد
animals[0] = cats[0];

console.log(animals[0].name);

//animals[0].meow();  // خطا - animals[0] به عنوان نوع Animal دیده می‌شود بنابراین meow در دسترس نیست

(animals[0] as Cat).meow(); // برای استفاده از متدهای خاص Cat نیاز به type assertion است

اجازه دادن به تخصیص animals = cats; ناامن خواهد بود زیرا در این صورت می‌توانید یک Animal جنریک به آرایه animals اضافه کنید، که ایمنی نوع آرایه cats (که قرار است فقط شامل اشیاء Cat باشد) را نقض می‌کند. به همین دلیل، تایپ‌اسکریپت استنباط می‌کند که آرایه‌ها ناوردا هستند.

مثال‌های عملی و موارد استفاده

الگوی مخزن (Repository) جنریک

یک الگوی مخزن جنریک برای دسترسی به داده‌ها را در نظر بگیرید. ممکن است یک نوع موجودیت پایه و یک اینترفیس مخزن جنریک داشته باشید که بر روی آن نوع عمل می‌کند.

interface Entity {
  id: string;
}

interface Repository<T extends Entity> {
  getById(id: string): T | undefined;
  save(entity: T): void;
  delete(id: string): void;
}

class InMemoryRepository<T extends Entity> implements Repository<T> {
  private data: { [id: string]: T } = {};

  getById(id: string): T | undefined {
    return this.data[id];
  }

  save(entity: T): void {
    this.data[entity.id] = entity;
  }

  delete(id: string): void {
    delete this.data[id];
  }
}

interface Product extends Entity {
  name: string;
  price: number;
}

const productRepository: Repository<Product> = new InMemoryRepository<Product>();

const newProduct: Product = { id: "123", name: "Laptop", price: 1200 };
productRepository.save(newProduct);

const retrievedProduct = productRepository.getById("123");
if (retrievedProduct) {
  console.log(`Retrieved product: ${retrievedProduct.name}`);
}

محدودیت نوع T extends Entity تضمین می‌کند که مخزن فقط می‌تواند بر روی موجودیت‌هایی که دارای ویژگی id هستند عمل کند. این به حفظ یکپارچگی و ثبات داده‌ها کمک می‌کند. این الگو برای مدیریت داده‌ها در فرمت‌های مختلف مفید است و با مدیریت انواع مختلف ارز در اینترفیس Product، با بین‌المللی‌سازی سازگار می‌شود.

مدیریت رویداد با محموله‌های (Payloads) جنریک

یک مورد استفاده رایج دیگر، مدیریت رویداد است. شما می‌توانید یک نوع رویداد جنریک با یک محموله خاص تعریف کنید.

interface Event<T> {
  type: string;
  payload: T;
}

interface UserCreatedEventPayload {
  userId: string;
  email: string;
}

interface ProductPurchasedEventPayload {
  productId: string;
  quantity: number;
}

function handleEvent<T>(event: Event<T>): void {
  console.log(`Handling event of type: ${event.type}`);
  console.log(`Payload: ${JSON.stringify(event.payload)}`);
}

const userCreatedEvent: Event<UserCreatedEventPayload> = {
  type: "user.created",
  payload: { userId: "user123", email: "alice@example.com" },
};

const productPurchasedEvent: Event<ProductPurchasedEventPayload> = {
  type: "product.purchased",
  payload: { productId: "product456", quantity: 2 },
};

handleEvent(userCreatedEvent);
handleEvent(productPurchasedEvent);

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

ساخت یک خط لوله (Pipeline) تبدیل داده جنریک

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

interface DataTransformer<TInput, TOutput> {
  transform(input: TInput): TOutput;
}

function processData<TInput, TOutput, TIntermediate>(
  input: TInput,
  transformer1: DataTransformer<TInput, TIntermediate>,
  transformer2: DataTransformer<TIntermediate, TOutput>
): TOutput {
  const intermediateData = transformer1.transform(input);
  const outputData = transformer2.transform(intermediateData);
  return outputData;
}

interface RawUserData {
  firstName: string;
  lastName: string;
}

interface UserData {
  fullName: string;
  email: string;
}

class RawToIntermediateTransformer implements DataTransformer<RawUserData, {name: string}> {
    transform(input: RawUserData): {name: string} {
        return { name: `${input.firstName} ${input.lastName}`};
    }
}

class IntermediateToUserTransformer implements DataTransformer<{name: string}, UserData> {
    transform(input: {name: string}): UserData {
        return {fullName: input.name, email: `${input.name.replace(" ", ".")}@example.com`};
    }
}

const rawData: RawUserData = { firstName: "John", lastName: "Doe" };

const userData: UserData = processData(
  rawData,
  new RawToIntermediateTransformer(),
  new IntermediateToUserTransformer()
);

console.log(userData);

در این مثال، تابع processData یک ورودی و دو تبدیل‌کننده می‌گیرد و خروجی تبدیل‌شده را باز می‌گرداند. پارامترهای نوع و محدودیت‌ها تضمین می‌کنند که خروجی تبدیل‌کننده اول با ورودی تبدیل‌کننده دوم سازگار است و یک خط لوله ایمن از نظر نوع ایجاد می‌کند. این الگو هنگام کار با مجموعه‌های داده بین‌المللی که نام فیلدها یا ساختارهای داده متفاوتی دارند، می‌تواند بسیار ارزشمند باشد، زیرا می‌توانید تبدیل‌کننده‌های خاصی برای هر فرمت بسازید.

بهترین شیوه‌ها و ملاحظات

نتیجه‌گیری

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