Hamid Bluri
Hamid Bluri
خواندن ۱۶ دقیقه·۴ سال پیش

meta programming - program freely

توی این مطلب قراره با برنامه نویسی متا آشنا بشیم ( کمر بنداتون رو ببندید ?)

من این مقاله رو برای نشریه دانشگاهمون نوشتم ( ببخشید که یکم حالت رسمی داره ). از هر نظر، انتقاد، پیشنهاد هم استقبال میشه.
اصل مقاله رو میتونید اینجا بخونید ( توی ویرگول نمیتونستم پانویس بزارم )

فرض کنید در سال های اولیه اختراع کامپیوتر هستیم. کامپیوتر ها فقط 0 و 1 را درک میکنند. ما آدم ها هم میتونیم به این زبان باهاشون حرف بزنیم ولی این کار بسیار سخت و غیر اصولی است ، در ضمن بازدهی این کار هم خیلی است و ساعت ها باید برای نوشتن یک برنامه ساده وقت صرف کنیم (اگر مشکلی در برنامه ایجاد شود، برای پیدا کردن آن باید به معنای واقعی کلمه "مو را از ماست بیرون بکشیم") .

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

 هرچه به سمت بالا حرکت میکنیم سطح دسترسی کمتر میشه
هرچه به سمت بالا حرکت میکنیم سطح دسترسی کمتر میشه

یک نمونه کد به زبان اسمبلی:

 ( کد اسمبلی بالا به زبان nasm نوشته شده که یک زبان اسمبلی برای cpu های اینتل است )
( کد اسمبلی بالا به زبان nasm نوشته شده که یک زبان اسمبلی برای cpu های اینتل است )

توضیح مختصر:

تکه کد بالا با گرفتن یک عدد، توان دوم بعد از آن را محاسبه میکند(برای مثال توان دوم بعدی عدد ۵۶ میشود ۶۴)

نکات قابل توجه برای درک برنامه بالا:

ا macro را در زبان اسمبلی معادل تابع در زبان های برنامه نویسی در نظر بگیرید

ماکرو next_power_of_twoیک ورودی میگیرد که در بدنه macro با نوشتن %1 می شود به آن دسترسی پیدا کرد

  • ا rax یک حافظه 64 بیتی است
  • mov var, value
دستور mov مقدار دوم [ در اینجا value ] را در حافظه اول [ در اینجا var ] کپی میکند
  • or var1, var2
دستور or بین مقدار اول و مقدار دوم که به صورت خانه های بیتی ذخیره شده اند عملیات or انجام داده و نتیجه آن رو در حافظه اول [ در اینجا var1 ] میریزد
  • inc/dec var
به ترتیب مقداری که درون حافظه مقابل [ در اینجا var ] است را یک واحد زیاد یا کم میکند
  • shr var, n
روی مقدار حافظه اول [ در اینجا var ] عملیات شیفت به سمت راست انجام داده و نتیجه آن را در همان حافظه ذخیره میکند ( برای مثال اگر مقدار var به صورت 00110110 باشد بعد از اجرای دستور shr var ,3 مقدار آن به 00000110 تغییر میکند )


زبان اسمبلی پیشرفت خیلی خوبی بود اما برنامه نویسی همچنان سخت بود.

حدود 9 سال بعد ( 1958 ) زبان هایی مثل Fortran, Algo اختراع شدند و برنامه نویسی رو به حالت فرمولی و امروزی در اوردند که بعدا پایه زبان های C, C++, Java و….. شد.

کد مرتب سازی حبابی در زبان برنامه نویسی ++c
کد مرتب سازی حبابی در زبان برنامه نویسی ++c

خب قصد ندارم از موضوع اصلی دور شویم، هدف من این بود که به شما نشان بدهم در کل کار برنامه نویسی به مرور زمان ساده تر شده، و این دلایل واضحی دارد.

ولی موضوعی برنامه نویس ها هنوز خیلی با آن در بیشتر زبان ها مواجه اند، موضوع تکرار بی نیاز کد است.

البته که قاعده هایی برای کد نویسی مثل کد نویسی تمیز وجود دارد که برنامه نویس رو ملزم به پیروی از یک سری اصول مشخص میکند ولی همچنان کد برنامه باید از اصول بعضا بسیار دست و پا گیر زبان پیروی کند که این موضوع آزادی برنامه نویس را بعضی اوقات محدود کرده و باعث ناخوانایی کد میشود. هرچه کد ناخوانا تر, احتمال اشتباه کردن برنامه نویس بیشتر و در نتیجه اشکال یابی برنامه سخت و سخت تر.

اینجاست که meta programming وارد می‌شود و عرض اندامی میکند.



>> تعریف دقیق meta programming

پیشوند متا ( meta ) به خود کلمه اشاره میکند. به طور مثال meta data یعنی داده ای که در مورد داده ی دیگریست ، meta Character یعنی کاراکتری که در مورد کاراکتر های دیگریست و meta programming یعنی برنامه نویسی که در مورد خود برنامه نویسی است. به عبارتی meta programming یعنی برنامه نویسی در برنامه نویسی ?)

در ادامه مطلب متوجه منظورم خواهید شد.

{{ تمامی کد ها بخاطر زیبایی، سادگی و قابل فهم بودن به زبان برنامه نویسی Nim نوشته شده }}


>> برای نوع های متغیری، جا رزور کن؛ Generic ها

توضیح کد:

توابعی با نام reversedنوشتیم که در تعریف اول یک دنباله از اعداد صحیح ( و در تعریف دوم یک دنباله از اعداد اعشاری ) گرفته و دنباله جدیدی را به عنوان برعکس شده تریبت اعضای دنباله اول برمیگرداند. با صدا کردن تابع، اگر ورودی ما دنباله ای از اعداد صحیح باشد تابع اول اجرا شده و اگر دنباله ای اعداد اعشاری باشد، تابع دوم اجرا میشود.


فرض کنید ما یک همچین کدی داریم و برای بقیه نوع های متغیری هم همین داستان ادامه دارد [کپی + تغییر جزئی]. یعنی همه رو باید بنویسم ( کپی کنم)؟ همه این ها فقط بخاطر تفاوت توی یک کلمه؟ خب احتمالا نه:

نحوه استفاده اش هم اینطوریه که قبل از اسم تابع میتونی مشخص کنی اون Type رو:

کاری که میکنه کار ساده ایه - میاد اون اون نوع متغیری که جلوی اسم تابع قرار گرفته [int] رو بجای Type میزاره. خیلی بهمون آزادی کار نمیده ولی خب از تکرار کدمون جلوگیری کرد.

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

نکته ای هم که وجود داره اینه که توی خیلی از زبون ها میشه تعیین کرد که اون Type از چه نوع هایی میتونه باشه - در مثال پایین فقط نوع داده ای عدد اعشاری قابل قبول است

همچنین میتونیم چند نوع Type متغیر داشته باشیم:

>> حرفه ای تر با template ها

کار Template ها به نحوی تعویض کد هست.

فرض کنید که حلقه ای داریم که اول به تعداد مشخصی اجرا شده و دوما بعد از هربار اجرا شدن به مدت خاصی صبر میکند. بجای این که تکه کد زیر رو بار ها و بار ها در برنامه تکرار کنیم:

میتوانیم به سادگی با ایجاد یک template که با نوشتن چیزی شبیه به:

کد بالا را اجرا کند. نحوه پیاده سازی به این صورت هست:

در اینجا ما یک template تعریف کردیم به نام loopWait ( نحوه تعریف template ها بسیار شبیه به نحوه تعریف تابع هاست ) که با ورودی اول آن مشخص کننده تعداد تکرار حلقه، ورودی دوم مشخص کننده تاخیر زمانی است و ورودی سوم تکه کدی است که قرار است درون حلقه تکرار مورد نظر قرار بگیرد.


:: مثالی دیگر:

در زبان Nim ا template ای به نام filterIt وجود دارد که با گرفتن دنباله s و شرط pred ، آن شرط را روی تک تک اعضای دنباله اعمال میکند و دنباله ای جدید با اعضایی که شرط pred برای آن ها برقرار بوده میسازد. ( اعضایی که پیمایش میشوند با متغیر it قابل دسترسی هستند‌ ) - نحوه تعریف آن تقریبا به این صورت است:

مقدار نهایی این template برابر با آخرین عبارت موجود در آن است [که در اینجا متغیر result است]
مقدار نهایی این template برابر با آخرین عبارت موجود در آن است [که در اینجا متغیر result است]


نکته قابل توجه این است که template ها مقدار ورودی خود را evalute نمیکنند . به طور مثال در مثال بالا ورودی filterIt عبارت it < 50 and it > -10 دقیقا بجای pred در عبارات if pred قرار میگیرد

>> با Macro ها قدرت در دستان توست!

ا macro مثل template میتواند کدی را به عنوان ورودی دریافت کرده و کدی خروجی دهد - ولی در macro ها میتوان روی کد خروجی مانور بیشتری داد. در macro ها میتوانیم با کد مثل یک سری دیتا رفتار کنیم؛ آن را بررسی و تغییر دهیم. این موضوع اولین بار توسط زبان های خانواده LISP پیاده سازی شد

قبل از اینکه ورود به دنیای macro ها باید یک سری مقدمات رو بلد باشیم.

وقتی که کدی را که در یک فایل ذخیره شده به کامپایلر یا مفسر زبان برنامه نویسی مورد نظر میدهیم، در واقع کامپایلر یا مفسر فایل کد را به صورت متن مورد بررسی قرار داده و بعد تحلیل و بررسی اولیه چیزی به نام AST ) abstract syntax tree ) برای خود میسازد ( احتمالا توی درس "اصول طراحی کامپایلر" با آن آشنا شده اید ) و بعد با توجه به AST تولید شده به بررسی ارور های منطقی کد یا بهینه سازی ها میپردازد.

برای مثال عبارت جبری 6 + (5 - 1 ) * 3 به چنین چیزی در AST تبدیل میشود
برای مثال عبارت جبری 6 + (5 - 1 ) * 3 به چنین چیزی در AST تبدیل میشود


مثلا کد زیر در زبان برنامه نویسی Nim در کامپایلر:

تبدیل میشود به:

اولا بعضی کلمه ها خلاصه شده:

  • Stmt: statement تکه کد
  • Asgn : assignment عملیات تساوی قرار دادن
  • Ident: identifier نام متغیر یا هرچیز دیگر
  • Elif: else if -
  • Int: integer عدد صحیح
  • Lit : literal وقتی مقداری صریحا نوشته شده باشد
  • Infix: - عملگر میانی

در AST تولید شده بالا همه زیر مجموعه یک ifStmt هستند. شاخه اول آن شامل شرط اول است و در آخر نیز شاخه Else وجود دارد. در اولین زیر مجموعه ElifBranch شرط آن ذکر شده و بعد شاخه StmtList آمده که لیست دستورات درون آن if را شامل میشود. در لیست دستورات if تنها یک عملیات تساوی (assignment) انجام شده که متغیر b را برابر با مقدار false قرار داده. بقیه ماجرا هم به همین ترتیب است.

خلاصه این که هر کدی که ما مینویسیم AST خاصی تولید میکند

جالب نیست؟ از این جالب تر این که با macro ها میتوانیم در AST تولید شده دستکاری کنیم!

نکته قابل توجه این است که کدی که به macro ها داده میشود لازم است که از نظر سینتکسی ( گرامری ) درست باشد ولی لازم نیست که از نظر معنایی و منطقی هم درست باشد.

برای مثال این دو خط از نظر نگارشی درست هستنند ولی از نظر منطقی و معنایی نه. چون در خط اول ما در یک متغیر از نوع عدد یک رشته ریختیم و خط بعد هم در یک آرایه 3 نوع مختلف داده ای قرار دادیم که مجاز به چنین کاری نیستیم چون همه اعضای آرایه باید از یک نوع باشند

در ادامه مثال هایی عینی برای آشنایی با macro ها آورده ام.

:: مثال اول:

چند وقت پیش پروژه ای داشتم که نیاز داشت عددی طراحی کنم که صورت (up) و مخرج(down) رو برای هر کسر جدا داشته باشم ( و تا زمانی که من نخواستم تقسیم را انجام ندهد ). برای این شئ "SNumber" که ساخته بودم باید چند تا تابع پیاده میکردم. مثلا توابع عملیات های ریاضی (+-/*) و چک کردن تساوی ( == ) که این عملیات ها قابل ترکیب با کسری دیگر یا عدد صحیح (int) بود.

تعریف شئ SNumber
تعریف شئ SNumber
کار این تابع اینه که صورت کسر a را در عدد صحیح b ضرب کرده و بعد به عنوان عدد جدید آن را خروجی میدهد
کار این تابع اینه که صورت کسر a را در عدد صحیح b ضرب کرده و بعد به عنوان عدد جدید آن را خروجی میدهد

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

خب اگر من AST تابع ضرب را در خروجی چاپ کنم چنین چیزی تحویل میگیرم:

 اون 3 تا نقطه آخر …..  شامل دستورات بدنه است که آن را ذکر نکردم
اون 3 تا نقطه آخر ….. شامل دستورات بدنه است که آن را ذکر نکردم

تولید شده به ما میگوید که یک تعریف تابع (FuncDef) داریم. چون اسم تابع خاص بود ( عملگر ضرب * ) من آن را درون دو بک تیک قرار دادم وAST تولید شده آن را با AccQuoted نشانه گذاری کرده. شاخه بعدی (FormalParams) شامل خروجی و ورودی های تابع و شاخه بعد لیست دستورات بدنه تابع را آورده (StmtList).

درون شاخه FormalParams ابتدا نوع خروجی تابع و سپس اسم و نوع ورودی های تابع ذکر شده است.

این یعنی تنها کاری که لازم است انجام دهم این است که از کل تابع یک کپی بگیرم و جای دو ورودی را عوض کنم. این کار را با نوشتن یک macro انجام میدهم:

این macro در آخر تابع ورودی و تابع تغییر یافته را پس میدهد
این macro در آخر تابع ورودی و تابع تغییر یافته را پس میدهد

حالا فقط در تعریف تابع این تیکه کد را اضافه کنم:

به چینین چیزی توی Nim قسمت pragma میگن
به چینین چیزی توی Nim قسمت pragma میگن

:: مثال دوم:

چند وقت پیش قصد داشتم از یک کتابخانه برای رنگی نوشتن در ترمینال استفاده کنم ( در زبان c++ ). کتابخانه ای پیدا کردم که فقط شامل یک فایل هدر بود. درون آن را نگاه کردم، وحشت آمیز بود! تقریبا چنین چیزی دیدم:

فرض کنید 16 * 16 خط کد شبیه به این! ( برای 256 ترکیب رنگی )
فرض کنید 16 * 16 خط کد شبیه به این! ( برای 256 ترکیب رنگی )

خب در نگاه اول احتمالا الگویی را پیدا میکنید. اسم تابع ها اسم کامل رنگ هاست و در بدنه تابع هم اسم کوتاه شون آمده. همین! ما الگو رو پیدا کردیم - حالا نوبت پیاده سازی هست:

خب تا الان فقط لیست رنگ ها را تعریف کردیم

روند کار به همان صورت بالا است، یعنی ما قصد داریم توابع تمام ترکیب های رنگی رو ایجاد کنیم، پس روی دنباله colors که به عنوان ورودی گرفتیم دوبار به صورت تو در تو پیمایش میکنیم. بعد اسم اون تابع رو میسازیم که میشه اسم رنگ ( foreground: fg ) به همراه _on_ و اسم رنگ پس زمینه (background : bg ) و بعد این مقدار را به عنوان ورودی به تابع ident دادیم که مشخص کنیم منظور ما اسم متغیری است و نه خود string .

حالا درون superQuote اون الگوی کد را پیاده سازی میکنیم ( و هرجا اگر متغیر خاصی مدنظرمون بود که توی کد باید تعویض میشد آن را بین 2 تا بک تیک ` قرار میدهیم `) - وظیفه تولید کد از الگو به عهده خود superQuote هست.

در انتهای حلقه هم تابعی که ساختیم را به لیست کدی که قصد داریم خروجی دهیم؛ اضافه میکنیم.


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


تبریک! این کد ادیتور من است که بهم لیست توابع موجود رو پیشنهاد داده
تبریک! این کد ادیتور من است که بهم لیست توابع موجود رو پیشنهاد داده


خب مثال های من خیلی حرفه ای نبودند و سعی کردم بیشتر جنبه آشنایی داشته باشند. ولی در بعضی از کتابخانه ها استفاده از macro خیلی ملموس است:

:: وب فریمورک jester:

به همین راحتی ما یک سایت راه انداختیم که اگر کاربر آدرس address.com/hamid را وارد کند، برنامه ما متن my name is hamid را به کاربر نشان میدهد. نکته ای که وجود دارد این است که ماکرو های خود فریمورک jester میاد بعد از بررسی کدی که بهش داده شده، بجای @“name” ( و کلا string literal هایی که پشتشون علامت @ است ) چیزی که جلوی آدرس نوشته شده رو جایگزین میکند ( البته کار های دیگری هم انجام میدهد مثلا تعریف متغیر request در بدنه کد و… که من بهش اشاره نکردم)


:: پارسر متن npeg:

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

در اینجا ما یک سری قاعده تعریف کردیم به اسم word که شامل حروف کوچک انگلیسی میشود، number که شامل اعداد میشود، یک جفت ( pair ) که شامل یک کلمه به همراه علامت مساوی و یک عدد میشود، ( به علامت بزرگ تر قبل از word و number توی تعریف pair دقت کنید - اون علامت به این معنی است که قسمت های علامت گذاری شده را ذخیره کن ) حالا در بدنه آن نوشتیم که چیز هایی که در حین تطبیق الگو ذخیره کردی را در متغیری که بهش داده شده (d) ذخیره کن ( منظور از $1 قسمت ذخیره شده اول و $2 قسمت ذخیره شده دوم هست )

و در نهایت قاعده paires ( جفت ها) میشود یک قاعده pair ای که میتواند بعد از آن با ویرگول pair های دیگری هم آمده باشه

توجه کنید که علامت -> در زبان Nim هیچ معنی خاصی نمیدهد و این ترتیب از نوشتن هم از نظر منطقی مجاز نیست ( ولی از نظر گرامری مشکل ندارد ). همچنین `$1` و `$2` که موقع کامپایل به صورت `[‍capture[1‍` و `[capture[2` تبدیل میشوند که متغیر `capture` دنباله ای از متن های ذخیره شده است )

حالا نحوه استفاده اش به این صورت است:

واقعا چقدر کار ساده تر شد؟


>> پرسش و پاسخ

  • چه زبان هایی از meta programming پشتیبانی میکنند؟

در اینجا چند زبان که از این قابلیت ها پشتبانی میکنند را گرد آوری کرده ام.

با قابلیت دستکاری AST:

  • تقریبا تمام زبان های خانواده LISP
  • ا Nim
  • ا Scala
  • ا Haskell
  • ا OCaml


بدون قابلیت دستکاری AST:

  • ا Elixir
  • ا Rust
  • ا Crystal


نکته: زبان های زیادی از generic ها پشتیانی میکنند (‌ این موضوع در c++ به نام template شناخته میشود )


  • پشتیبانی از metaprogramming در زبان های مختلف؟

در زبان های c++ و Nim که بنده امتحان کردم language server از macro ها و template ها پشتیبانی میکرد.

در cpp که دیباگ کردن آن سخت ولی در زبان Nim دقیق تر است بطوری که هنگام برخورد به ارور برنامه از محل تولید کدی که باعث خطا شده خبر میدهد ( این کد ممکن است از چند macro یا template عبور کرده باشد)

در کل بیشتر به پیشتیبانی آن زبان برنامه نویسی بستگی دارد

ا eval توی زبان های تفسری چطوره؟

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

البته توی Nim هم میتونید از parseStmt برای تبدیل متن به کد در زمان کامپایل استفاده کنید، ولی تا وقتی که چیزایی مثل quote و دسترسی به AST هست چرا ( نه واقعا چرا؟؟ )؟



بررسی کلی و نکات پایانی:

استفاده از meta programming میتونه این مزیت ها رو به ارمغان بیاره:

  1. بهینه سازی کد ( جلوگیری از call stack های اضافی )
  2. کد ساختار مند تر ( مفهومی تر )
  3. بهبود خوانایی و نگهداری کد

بی شک برنامه نویسی به سمت ساده شدن پیش میرود و metaprogramming آینده ی انکار ناپذیر برنامه نویسی خواهد بود. این موضوع را میتوان از پشتیبانی بیشتر زبان های برنامه نویسی از meta programming پی برد.


و در آخر هم جمله ای از زبان برنامه نویس فعال انجمن Nim آقای Peter Munch:

من قبلا اشاره کرده بودم که metaprogramming میتواند خوانایی و نگه داری کد را بهبود دهد. مخالفان metaprogramming احتمالا این موضوع رو مسخره میکنند و میگویند این موضوع دقیقا برعکس است - البته metaprogramming ابزار بسیار قدرتمندی است،‌ و موقع استفاده هر ابزار قدرتمند باید مراقب باشیم.

همونطور که اگر مراقب نباشید اره برقی میتواند پای شما را قطع کند، یک macro ای که با دقت کمی نوشته شده هم میتواند برنامه شما را خراب کند. ولی این به این معنی نیست که نباید از اره برقی یا macro استفاده کنیم!

شاید جایی که من از macro یا template استفاده میکنم،‌ باهاش کار های عجیب و غریبی انجام ندم و فقط باهاش کدم رو کمی باز نویسی میکنم تا خوانایی کدم بیشتر بشه در عین این که سرعت و کیفیت برنامه فدا نشه. از اون جایی که macro ها زمان compile پردازش میشن و نه زمان اجرای برنامه، من میتونم هرچقدر پیچیدگی که مد نظرم هست به برنامه اضافه کنم و مطمئن باشم که خروجی برنامه ام هم بهینه است.


منابع

A History of Computer Programming Languages

https://github.com/strega-nil/asm-string/blob/trunk/string.s#L30-L51

Nim in action book & Nim documentation

Coding Concepts - Generics - DEV Community

Metaprogramming and read- and maintainability in Nim & Meta-programming in Nim - FOSDEM talk companion post

Nim Macros | Steve Flenniken

Quickstart — Manim documentation

Call Stack

برنامه نویسیprogrammingmetaprogrammingبرنامه نویسی متاآینده برنامه نویسی
life is what you choose it to be ...
شاید از این پست‌ها خوشتان بیاید