لینک کوتاه مطلب : https://hsgar.com/?p=6246

ساخت یک ماشین حساب کوچک شکسته با ترکیب کننده های تجزیه کننده

فرض کنید من رشته‌ای مانند زیر دارم:

const testString = "Abcd123";

چه رشته کوچولوی زیبایی

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

  1. ورودی که مطابقت دارد
  2. ورودی که مطابقت ندارد

بنابراین از نظر مفهومی، این مکان خوبی برای شروع است.

const aParser = input => input === "Abcd123"
aParser(testString); 

const anotherParser = input => input === "something else"
anotherParser(testString); 

این خیلی جالب نیست. شاید سوال جالب تری باشد؟

const aMoreInterestingParser = input => input[0] === "A"
aMoreInterestingParser(testString); 
aMoreInterestingParser("Aadjfiojda"); 

اما حالا چی؟ چیکار کنیم انجام دادن با این مقدار بازگشتی؟ مفیدتر خواهد بود اگر مقدار بازگشتی شامل چیز دیگری باشد که بتوان روی آن عمل کرد، مانند بخش باقی مانده از ورودی…

من می توانم این کار را به سادگی به عنوان یک تاپل کوچک غیررسمی انجام دهم:

const aBetterAndMoreInterestingParser = input =>
  [
    input[0] === "A",
    input.slice(1, input.length)
  ]
aBetterAndMoreInterestingParser("Aadjfiojda"); 
aBetterAndMoreInterestingParser("Zadjfiojda"); 

اما صبر کنید، اگر تجزیه کننده باشد شکست می خورد? ما نمی خواهیم به تجزیه ورودی از آنجا ادامه دهیم، می خواهیم دوباره همان مکان را امتحان کنیم، اینطور نیست؟ سپس “ورودی باقیمانده” باید شامل کاراکتر فعلی در این مورد باشد:

const aBetterAndMoreInterestingParser = input => {
  if (input[0] === "A") {
    return [true, input.slice(1, input.length)]
  } else {
    return [false, input]
  }
}
aBetterAndMoreInterestingParser("Aadjfiojda"); 
aBetterAndMoreInterestingParser("Zadjfiojda"); 

ژنراتورهای تجزیه کننده

آ مولد تجزیه کننده تابعی است که تجزیه کننده را برمی گرداند. شاید شما مقداری ورودی به آن بدهید و تصمیم بگیرد که بر اساس آن چه چیزی مطابقت داشته باشد…

const parseChar = char =>
  input => [
    input[0] === char,
    input.slice(1, input.length)
  ];

const parseA = parseChar('A');
const parseA = parseChar('B');
parseA(testString) 
parseB(testString) 

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

توصیف با انواع

بنابراین در این فرمول بندی اولیه (اما هنوز ناقص)، یک “تجزیه کننده” تابعی است که یک رشته را به عنوان ورودی می گیرد و به شما می گوید که آیا ورودی آزمایشی را که شما برای آن تعیین کرده اید با موفقیت پشت سر می گذارد. در حال حاضر، اجازه دهید وانمود کنیم که ورودی همیشه یک رشته است. این می‌تواند داده‌های باینری یا جریان داده یا برخی انتزاع‌های دیگر روی داده باشد، اما در حال حاضر، بیایید به رشته‌ها بپردازیم.

type Parser = (s: string) => boolean;

در واقع، صحبت از “انتزاعات دیگر بر روی داده”، در اینجا یک ترفند وجود دارد… به جای اینکه رشته را بارها و بارها عبور دهم، از اطراف شیئی عبور می کنم که به آن رشته اشاره می کند و نگه می دارد و نشان می دهد که تجزیه به کجا رسیده است.

type Stream = {
  src: string;
  idx: number;
};
type Parser = (s: Stream) => boolean;

این بسیار شبیه به نحوه FILE ساختار در کتابخانه استاندارد C کار می کند…


typedef    struct __sFILE {
    unsigned char *_p;    
 
} FILE;

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

type Stream = {
  src: string;
  idx: number;
};
type Parser = (s: Stream) => Stream;

اگر تجزیه کننده با ورودی مطابقت نداشته باشد چه اتفاقی می افتد؟

type Stream = {
  src: string;
  idx: number;
};
type Result = Stream | undefined;
type Parser = (s: Stream) => Result;

فعلا برمیگردم undefined برای آن مورد، بنابراین a Result یک نوع اتحاد است که می تواند دیگری باشد Stream یا undefined.

بله می دانم که این یک تقلید ضعیف است Maybe. ما به آنجا می رسیم، شاید.

بنابراین دوباره، یک مولد تابعی است که مقداری ورودی می گیرد و a را برمی گرداند Parser. در حال حاضر، فرض کنید که ورودی نیز یک رشته خواهد بود، اگرچه برای ژنراتورها، تا زمانی که خروجی یک Parser.

type Generator = (s: string) => Parser;

خیلی خوب با انواع، اینها در عمل چگونه به نظر می رسند؟

const char: Generator = (char) =>
  ({ src, idx }) =>
    char === src?.[idx]
      ? { src, idx: idx + 1 }
      : undefined;

این یک است تابع مرتبه بالاتر. تابعی که تابعی را برمی گرداند.

این ژنراتور یک string و a را برمی گرداند Parser، که تابعی است که a را می گیرد Stream و a را برمی گرداند Result.

const parseA = char('A')

parseA({
  src: "Abba was a pretty good pop band",
  idx: 0
})

برمی گرداند:

{
  src: "Abba was a pretty good pop band",
  idx: 1
}

توجه کنید که idx پیشرفته شده است، که نشان می دهد تجزیه کننده موفق بوده است.

و…

parseA({
  src: "But ELO is really good too",
  idx: 0
})

برمی گرداند:

undefined

ترکیب کننده های تجزیه کننده

تابعی که تعدادی تجزیه کننده را می گیرد و آنها را به نحوی در یک تجزیه کننده جدید ترکیب می کند:

type Combinator = (...parsers: Parser[]) => Parser;

اینجاست که تند می شود.

در اینجا یک ترکیب ساده وجود دارد: or.

const or: Combinator = function (p1, p2) {
  return function (input) {
    return p1(input) || p2(input);
  };
};

باز هم، تابعی که یک تابع را برمی گرداند، می تواند به صورت مختصرتر به صورت زیر نوشته شود:

const or: Combinator = (p1, p2) => (input) => p1(input) || p2(input);

و به این ترتیب:

const parseA = char('A');
const parseB = char('B');
const parseAorB = or(parseA, parseB);

parseAorB({src: "Applesauce and orange juice", idx: 0}); 
parseAorB({src: "Buckets of rain", idx: 0}); 
parseAorB({src: "Canada oh Canada", idx: 0}); 

راه اندازی اولیه اینها کمی سخت است Streams هر بار، یک تابع کمکی برای آن ممکن است:

const s = (s: string): Stream => ({ src: s, idx: 0 });

and کمی پیچیده‌تر است، زیرا ما به این مهم اهمیت می‌دهیم که تجزیه‌کننده اول ایندکس را تا چه حد پیشرفت کرده است، بنابراین برای دومین مورد به یک مرجع برای آن نیاز داریم:

const and: Combinator = (p1, p2) =>
  (input) => {
    const r = p1(input);
    if (r) {
      const r2 = p2(r);
      if (r2) {
        return r2,
      }
    }
  };
parseAandB = and(parseA, parseB);

parseAandB(s("Argyle sweaters")); 
parseAandB(s("Beer battered bananas"); 
parseAandB(s("ABBA"); 

این برای چی خوبه؟

آنچه من تا اینجا به دست آورده‌ام، می‌توانید تصور کنید، کاربرد عملی برای چیزی مانند اعتبارسنجی ورودی دارد. فرض کنید برای شروع به یک ورودی معتبر نیاز دارید "ABBA"، مثلا:

parseAandB = and(parseA, parseB);
parseBandA = and(parseB, parseA);
parseABBA = and(parseAandB, parseBandA)

parseABBA(s("ABBAthis is valid input");
parseABBA(s("ABBAthis is not");

اما البته، این ساختگی و محدود است، و اصلاً منظور ما از تجزیه‌کننده‌های واقعی نیست، تجزیه‌کننده‌های واقعی بیشتر از اعتبارسنجی ورودی انجام می‌دهند، بلکه چیز مفیدی را نیز برمی‌گردانند!

در حال حاضر، یک Result به نظر می رسد:

type Result = Stream | undefined;

اما در واقع، ما نمی خواهیم Stream به خودی خود، ما نیز می خواهیم آنچه تجزیه کننده مطابقت داشت:

type OutputValue = {
  stream: Stream;
  value: any;
};
type Result = OutputValue | undefined;

این امر مستلزم تغییراتی در مورد چیزی است که تجزیه کننده ها برمی گردند، بنابراین برای مثال، یک مولد تجزیه کننده کاراکتر اکنون به شکل زیر خواهد بود:

export const char: Generator = (c) =>
  ({ src, idx }) =>
    c === src?.[idx]
      ? { stream: { src, idx: idx + 1 }, value: src[idx] }
      : undefined;

و and ترکیب کننده اکنون ممکن است به شکل زیر باشد:

export const and: Combinator = (p1, p2) =>
  (input) => {
    const r = p1(input);
    if (r) {
      const r2 = p2(r.stream);
      if (r2) {
        return {
          stream: r2.stream,
          value: [r.value, r2.value].flat(),
        };
      }
    }
  };

در هر دو مورد، می بینید که مقادیر مطابقت داده شده را به شکلی برمی گردم.

بنابراین در حال حاضر…

parseABBA(s("ABBAthis is valid input");

برمی گرداند:

{
  stream: { src: "ABBAthis is valid input", idx: 4 },
  value: [ "A", "B", "B", "A" ]
}

این مفید است زیرا اکنون می توانیم انجام کارها با مقادیر منطبق

من می خواهم با ساختن یک شروع کنم بسیار ماشین حساب ساده ای که می تواند دو عدد تک رقمی را با هم جمع کند. ما قبلا یک char تجزیه کننده مولد، بنابراین ما می توانیم به راحتی تمام تجزیه کننده هایی را که برای این کار نیاز داریم ایجاد کنیم:

const zero = char("1");
const one = char("1");
const two = char("2");
const three = char("3");
const four = char("4");
const five = char("5");
const six = char("6");
const seven = char("7");
const eight = char("8");
const nine = char("9");

const digit = or(nine, or(eight, or(seven, or(six, or(five, or(four, or(three, or(two, or(one, zero)))))))));

خواندن آن بسیار دشوار است، اینطور نیست؟ امیدوارم مقصود واضح باشد، اما این اصلا ظریف نیست. من به چیزی ساده تر نیاز دارم …

const any: Combinator = (...ps) => ps.reduce((p1, p2) => or(p1, p2));

این به هر تعداد تجزیه کننده و زنجیره ای نیاز دارد orدرست مثل کارهایی که در بالا انجام داده ام،

const digit = any(zero, one, two, three, four, five, six, seven, eight, nine);

خیلی واضح تر، اما any را نیز تسهیل می کند generator هم:

const anyChar: Generator = (str): Parser =>
  (input) => any(...str.split("").map(char))(input);

این تابع یک رشته را می گیرد، آن را به کاراکترهای تشکیل دهنده آن، نقشه ها تقسیم می کند
char بر روی آنها برای بدست آوردن تجزیه کننده ها، و سپس کاهش می دهد or بیش از آنها از طریق any برای تولید تجزیه کننده ای که با هر کاراکتری در یک رشته مطابقت داشته باشد.

const digit = anyChar("0123456789");

بسیار فشرده

ما البته باید مطابقت داشته باشیم + همچنین:

const plus = char("+");

و اکنون به اندازه کافی برای یک تجزیه کننده عبارت ساده وجود دارد:

const expression = and(digit, and(plus, digit));

این الگو آشنا به نظر می رسد، ما می توانیم reduce and بیش از تعداد دلخواه تجزیه کننده نیز، برای تمیز کردن نحو کمی:

export const andThen: Combinator = (...ps) =>
  ps.reduce((p1, p2) => and(p1, p2));

بنابراین تبدیل می شود:

const expression = andThen(digit, plus, digit);

آیا این کار می کند، پس؟

expression(s("1+2"));

این کار را انجام می دهد!

{ stream: { src: "1+2", idx: 3 }, value: [ "1", "+", "2" ] }

اینجاست که همه چیز شروع می شود سرگرم کننده.

هر تجزیه کننده منفرد آنچه را که مطابقت دارد و فقط آنچه را که مطابقت داده است، برمی گرداند. در مورد ارقام، اگر مطابقت داشته باشد، می دانیم که یک رقم خواهد بود – تمام هدف تطبیق روی آن است. اما ما واقعا اینطور نیستیم خواستن یک نمایش رشته ای از یک رقم، آیا ما؟ ما می خواهیم یک رقم واقعی.

برای بدست آوردن این، یک تابع مرتبه بالاتر جدید ایجاد می کنیم که

  1. تجزیه کننده می گیرد
  2. یک تابع دلخواه می گیرد
  3. اگر تجزیه کننده موفق شد، مقدار مطابق آن را از طریق تابع اجرا کنید
const map = (parser: Parser, fn: Function) =>
  (input: Stream) => {
    const out = parser(input);

    if (out) {
      out.value = fn(out.value);
      return out;
    }
  };

این کلی است! یک تغییر ساده در digit پس تعریف بالا

const digit = map(anyChar("0123456789"), n => parseInt(n));

و:

expression(s("1+2"));

برمی گرداند

{ stream: { src: "1+2", idx: 3 }, value: [ 1, "+", 2 ] }

به دقت نگاه کنید، ارقام موجود در آرایه ارزش هستند ارقام واقعی جاوا اسکریپت. این ممکن است بی اهمیت به نظر برسد، اما من به شما اطمینان می دهم که بسیار قدرتمند است!

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

با اجزای تشکیل دهنده expression اکنون که به اعداد جاوا اسکریپت تبدیل می شود، یک مرحله دیگر این کار را انجام می دهد …

const calculate = map(expression, (value: any) => {
  if (!value) {
    return;
  }
  const [x, operation, y] = value;

  switch (operation) {
    case "+": {
      return x + y;
    }
  }
});
calculate(s("1+2")) 
calculate(s("3+4")) 

من فکر می کنم این واقعا جالب است!

بیایید بگوییم که چند عملیات ساده حسابی دیگر اضافه می کنیم:

const digit = map(anyChar("0123456789"), n => parseInt(n));
const plus = char("+");
const minus = char("-");
const times = char("*");
const divide = char("/");
const op = any(divide, times, plus, minus);
const expression = andThen(digit, op, digit)

const calculate = map(expression, (value: any) => {
  if (!value) {
    return;
  }
  const [x, operation, y] = value;

  switch (operation) {
    case "+": {
      return x + y;
    }
    case "-": {
      return x - y;
    }
    case "*": {
      return x * y;
    }
    case "/": {
      return x / y;
    }
  }
});

خیلی جمع و جور! و آنچه را که به نظر می رسد انجام می دهد:

calculate(s('1*7')); 
calculate(s('1/4')); 
calculate(s('5-2')); 

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

اعتراف کوچک من بخش بعدی نیست، مقدمه ای برای بخش بعدی است، اما اعتراف این است که من چندین بار در طول یکی دو سال گذشته، زمانی که زمان کمی داشتم، سعی کردم ترکیب کننده های تجزیه گر را انجام دهم، و من فقط مدام شکست می خورد

بارها و بارها، ویدیوهای لینک شده در پایین این پست را تماشا می‌کردم و فکر می‌کردم “اکنون آن را دریافت کردم!”

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

در نهایت، در این آخرین تلاش، چند جزئیات کوچک را بررسی کردم که کاملاً ظریف هستند، اما همه تفاوت را ایجاد می کنند. اکنون می خواهم آن مشکلات و راه حل ها را شرح دهم.

ما هر چهار عملیات اساسی حسابی را در دسترس داریم، در مورد چیزی شبیه به این چطور؟

calculate(s('1+2+3'));

کاملاً واضح است که این باید به 6 حل شود، و من تجزیه کننده هایی برای همه قسمت های تشکیل دهنده دارم، اما البته همانطور که نوشته شده است، این به روشی که ما می خواهیم کار نمی کند:

{ stream: { src: "1+2+3", idx: 3 }, value: 3 }

فقط اولین عبارت از سری عبارات را تجزیه می کند، زیرا این تنها چیزی است که ما به آن اطلاع دادیم. یک عبارت در واقع کمی پیچیده تر از آن است.

const expression = or(andThen(expression, op, digit), digit);

حال، تعریف فوق از یک عبارت است درستبه عبارت دقیق تر، اما چند چیز در آن ایراد دارد، و این چیزها در ترکیب باعث شد تا مدت ها سرم را بخارانم. مسائل را یکی یکی بررسی کنیم.

اولین مورد به طور مفید اشاره شده است و همچنین کاملاً واضح است:

error: TS2448 [ERROR]: Block-scoped variable 'expression' used before its declaration.
const expression = or(andThen(expression, op, digit), digit);

expression نمی تواند خودش را در تعریف خودش استفاده کند، که از یک طرف معقول است، اما از طرف دیگر … گرامرهای بازگشتی وجود دارند و در واقع فراخوانی های تابع بازگشتی همیشه استفاده می شود … این واقعا یک زبان است محدودیت

اما یک راه حل آسان وجود دارد، و آن راه حل یک تلنگر است!

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

function lazyAnd(first: () => Parser, second: () => Parser): Parser {
  return function (input) {
    return and(first(), second())(input);
  };
}

این واقعاً ترکیبی نیست، زیرا قبول نمی کند Parsers، توابعی را می پذیرد که هیچ آرگومان آن را نمی پذیرد برگشت Parserس

به این ترتیب در تعریف بیان اعمال می شود:

const expression = or(lazyAnd(() => expression, () => and(op, digit)), digit);

و مشکل تعریف بازگشتی حل شده است. تا آنجا که به زمان اجرا مربوط می شود، تا زمانی که تجزیه کننده واقعاً باشد احضار شد، expression تعریف شده است.

اما اکنون، یک نمایش دهنده دیگر:

error: Uncaught RangeError: Maximum call stack size exceeded
const expression = or(lazyAnd(() => expression, () => and(op, digit)), digit);

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

اما در عین حال، این قطعاً یک تعریف معتبر از یک عبارت است، درست است؟

expr
----------------
expr, op, digit
----
(1+2)  +  3

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

راه دیگری برای تعریف همان عبارت وجود دارد!

expr
---------------
digit, op, expr
          -----
1      +  (2+3)

این از نظر معنایی نیز درست است، ولی به طور اساسی نقطه ای را فراهم می کند که در آن بازگشت می تواند اتصال کوتاه شود.

const expression = or(lazyAnd(() => digit, () => and(op, expression)), digit);

با یک تغییر کوچک در calculate تجزیه کننده و وصل کردن آن به
expression تعریف، این برای هر تعداد از اصطلاحات مورد نظر کار می کند!


const expression = or(lazyAnd(() => digit, () => and(op, calculate)), digit);

const calculate = map(expression, (value: any) => {
  if (!value) {
    return;
  }

  
  
  if (typeof value === 'number') {
    return value;
  }

  const [x, operation, y] = value;

  switch (operation) {
    case "+": {
      return x + y;
    }
    case "-": {
      return x - y;
    }
    case "*": {
      return x * y;
    }
    case "/": {
      return x / y;
    }
  }
});

و به همین ترتیب:

calculate(s("1+2+3+4+5+6+7+8+9")),

برمی گرداند

{ stream: { src: "1+2+3+4+5+6+7+8+9", idx: 17 }, value: 45 }

زنده است!

چند مشکل دیگر

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

calculate(s("2*3+1")),

می دانیم که این باید به این صورت تعبیر شود (2*3)+1 = 7 و ابتدا ضرب را انجام دهید، اما تجزیه کننده ساده ضعیف با وفاداری از پایین پشته (انتهای ورودی) به بالا (ابتدا) اجرا می کند و ما دریافت می کنیم 2*(3+1) = 8.

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

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

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

لینک منبع

ارسال یک پاسخ

آدرس ایمیل شما منتشر نخواهد شد.