توی این مطلب قراره با برنامه نویسی متا آشنا بشیم ( کمر بنداتون رو ببندید ?)
من این مقاله رو برای نشریه دانشگاهمون نوشتم ( ببخشید که یکم حالت رسمی داره ). از هر نظر، انتقاد، پیشنهاد هم استقبال میشه.
اصل مقاله رو میتونید اینجا بخونید ( توی ویرگول نمیتونستم پانویس بزارم )
فرض کنید در سال های اولیه اختراع کامپیوتر هستیم. کامپیوتر ها فقط 0 و 1 را درک میکنند. ما آدم ها هم میتونیم به این زبان باهاشون حرف بزنیم ولی این کار بسیار سخت و غیر اصولی است ، در ضمن بازدهی این کار هم خیلی است و ساعت ها باید برای نوشتن یک برنامه ساده وقت صرف کنیم (اگر مشکلی در برنامه ایجاد شود، برای پیدا کردن آن باید به معنای واقعی کلمه "مو را از ماست بیرون بکشیم") .
این دلیل بوجود آمدن زبان های برنامه نویسی است. که کار برای ما انسان ها آسان تر بشه تا بتونیم راحت تر با کامپیوتر ارتباط برقرار کنیم. زبان های اسمبلی، زبان های برنامه نویسی سیستم و زبان های سطح بالا به همین منظور ساخته شدن.
یک نمونه کد به زبان اسمبلی:
توضیح مختصر:
تکه کد بالا با گرفتن یک عدد، توان دوم بعد از آن را محاسبه میکند(برای مثال توان دوم بعدی عدد ۵۶ میشود ۶۴)
نکات قابل توجه برای درک برنامه بالا:
ا macro را در زبان اسمبلی معادل تابع در زبان های برنامه نویسی در نظر بگیرید
ماکرو next_power_of_two
یک ورودی میگیرد که در بدنه macro با نوشتن %1 می شود به آن دسترسی پیدا کرد
دستور mov مقدار دوم [ در اینجا value ] را در حافظه اول [ در اینجا var ] کپی میکند
دستور or بین مقدار اول و مقدار دوم که به صورت خانه های بیتی ذخیره شده اند عملیات or انجام داده و نتیجه آن رو در حافظه اول [ در اینجا var1 ] میریزد
به ترتیب مقداری که درون حافظه مقابل [ در اینجا var ] است را یک واحد زیاد یا کم میکند
روی مقدار حافظه اول [ در اینجا var ] عملیات شیفت به سمت راست انجام داده و نتیجه آن را در همان حافظه ذخیره میکند ( برای مثال اگر مقدار var به صورت 00110110 باشد بعد از اجرای دستور shr var ,3 مقدار آن به 00000110 تغییر میکند )
زبان اسمبلی پیشرفت خیلی خوبی بود اما برنامه نویسی همچنان سخت بود.
حدود 9 سال بعد ( 1958 ) زبان هایی مثل Fortran, Algo اختراع شدند و برنامه نویسی رو به حالت فرمولی و امروزی در اوردند که بعدا پایه زبان های C, C++, Java و….. شد.
خب قصد ندارم از موضوع اصلی دور شویم، هدف من این بود که به شما نشان بدهم در کل کار برنامه نویسی به مرور زمان ساده تر شده، و این دلایل واضحی دارد.
ولی موضوعی برنامه نویس ها هنوز خیلی با آن در بیشتر زبان ها مواجه اند، موضوع تکرار بی نیاز کد است.
البته که قاعده هایی برای کد نویسی مثل کد نویسی تمیز وجود دارد که برنامه نویس رو ملزم به پیروی از یک سری اصول مشخص میکند ولی همچنان کد برنامه باید از اصول بعضا بسیار دست و پا گیر زبان پیروی کند که این موضوع آزادی برنامه نویس را بعضی اوقات محدود کرده و باعث ناخوانایی کد میشود. هرچه کد ناخوانا تر, احتمال اشتباه کردن برنامه نویس بیشتر و در نتیجه اشکال یابی برنامه سخت و سخت تر.
اینجاست که meta programming وارد میشود و عرض اندامی میکند.
پیشوند متا ( meta ) به خود کلمه اشاره میکند. به طور مثال meta data یعنی داده ای که در مورد داده ی دیگریست ، meta Character یعنی کاراکتری که در مورد کاراکتر های دیگریست و meta programming یعنی برنامه نویسی که در مورد خود برنامه نویسی است. به عبارتی meta programming یعنی برنامه نویسی در برنامه نویسی ?)
در ادامه مطلب متوجه منظورم خواهید شد.
{{ تمامی کد ها بخاطر زیبایی، سادگی و قابل فهم بودن به زبان برنامه نویسی Nim نوشته شده }}
توضیح کد:
توابعی با نام reversed
نوشتیم که در تعریف اول یک دنباله از اعداد صحیح ( و در تعریف دوم یک دنباله از اعداد اعشاری ) گرفته و دنباله جدیدی را به عنوان برعکس شده تریبت اعضای دنباله اول برمیگرداند. با صدا کردن تابع، اگر ورودی ما دنباله ای از اعداد صحیح باشد تابع اول اجرا شده و اگر دنباله ای اعداد اعشاری باشد، تابع دوم اجرا میشود.
فرض کنید ما یک همچین کدی داریم و برای بقیه نوع های متغیری هم همین داستان ادامه دارد [کپی + تغییر جزئی]. یعنی همه رو باید بنویسم ( کپی کنم)؟ همه این ها فقط بخاطر تفاوت توی یک کلمه؟ خب احتمالا نه:
نحوه استفاده اش هم اینطوریه که قبل از اسم تابع میتونی مشخص کنی اون Type رو:
کاری که میکنه کار ساده ایه - میاد اون اون نوع متغیری که جلوی اسم تابع قرار گرفته [int]
رو بجای Type میزاره. خیلی بهمون آزادی کار نمیده ولی خب از تکرار کدمون جلوگیری کرد.
غیر از استفاده توی تابع، میتونه توی تعریف کلاس هم استفاده بشه. و حتی میتونیم چند نوع تایپ بشه مشخص کنیم.
نکته ای هم که وجود داره اینه که توی خیلی از زبون ها میشه تعیین کرد که اون Type از چه نوع هایی میتونه باشه - در مثال پایین فقط نوع داده ای عدد اعشاری قابل قبول است
همچنین میتونیم چند نوع Type متغیر داشته باشیم:
کار Template ها به نحوی تعویض کد هست.
فرض کنید که حلقه ای داریم که اول به تعداد مشخصی اجرا شده و دوما بعد از هربار اجرا شدن به مدت خاصی صبر میکند. بجای این که تکه کد زیر رو بار ها و بار ها در برنامه تکرار کنیم:
میتوانیم به سادگی با ایجاد یک template که با نوشتن چیزی شبیه به:
کد بالا را اجرا کند. نحوه پیاده سازی به این صورت هست:
در اینجا ما یک template تعریف کردیم به نام loopWait ( نحوه تعریف template ها بسیار شبیه به نحوه تعریف تابع هاست ) که با ورودی اول آن مشخص کننده تعداد تکرار حلقه، ورودی دوم مشخص کننده تاخیر زمانی است و ورودی سوم تکه کدی است که قرار است درون حلقه تکرار مورد نظر قرار بگیرد.
در زبان Nim ا template ای به نام filterIt وجود دارد که با گرفتن دنباله s و شرط pred ، آن شرط را روی تک تک اعضای دنباله اعمال میکند و دنباله ای جدید با اعضایی که شرط pred برای آن ها برقرار بوده میسازد. ( اعضایی که پیمایش میشوند با متغیر it قابل دسترسی هستند ) - نحوه تعریف آن تقریبا به این صورت است:
نکته قابل توجه این است که template ها مقدار ورودی خود را evalute نمیکنند . به طور مثال در مثال بالا ورودی filterIt عبارت it < 50 and it > -10
دقیقا بجای pred در عبارات if pred
قرار میگیرد
ا macro مثل template میتواند کدی را به عنوان ورودی دریافت کرده و کدی خروجی دهد - ولی در macro ها میتوان روی کد خروجی مانور بیشتری داد. در macro ها میتوانیم با کد مثل یک سری دیتا رفتار کنیم؛ آن را بررسی و تغییر دهیم. این موضوع اولین بار توسط زبان های خانواده LISP پیاده سازی شد
قبل از اینکه ورود به دنیای macro ها باید یک سری مقدمات رو بلد باشیم.
وقتی که کدی را که در یک فایل ذخیره شده به کامپایلر یا مفسر زبان برنامه نویسی مورد نظر میدهیم، در واقع کامپایلر یا مفسر فایل کد را به صورت متن مورد بررسی قرار داده و بعد تحلیل و بررسی اولیه چیزی به نام AST ) abstract syntax tree ) برای خود میسازد ( احتمالا توی درس "اصول طراحی کامپایلر" با آن آشنا شده اید ) و بعد با توجه به AST تولید شده به بررسی ارور های منطقی کد یا بهینه سازی ها میپردازد.
مثلا کد زیر در زبان برنامه نویسی Nim در کامپایلر:
تبدیل میشود به:
اولا بعضی کلمه ها خلاصه شده:
در AST تولید شده بالا همه زیر مجموعه یک ifStmt
هستند. شاخه اول آن شامل شرط اول است و در آخر نیز شاخه Else وجود دارد. در اولین زیر مجموعه ElifBranch
شرط آن ذکر شده و بعد شاخه StmtList
آمده که لیست دستورات درون آن if را شامل میشود. در لیست دستورات if تنها یک عملیات تساوی (assignment) انجام شده که متغیر b را برابر با مقدار false قرار داده. بقیه ماجرا هم به همین ترتیب است.
خلاصه این که هر کدی که ما مینویسیم AST خاصی تولید میکند
جالب نیست؟ از این جالب تر این که با macro ها میتوانیم در AST تولید شده دستکاری کنیم!
نکته قابل توجه این است که کدی که به macro ها داده میشود لازم است که از نظر سینتکسی ( گرامری ) درست باشد ولی لازم نیست که از نظر معنایی و منطقی هم درست باشد.
برای مثال این دو خط از نظر نگارشی درست هستنند ولی از نظر منطقی و معنایی نه. چون در خط اول ما در یک متغیر از نوع عدد یک رشته ریختیم و خط بعد هم در یک آرایه 3 نوع مختلف داده ای قرار دادیم که مجاز به چنین کاری نیستیم چون همه اعضای آرایه باید از یک نوع باشند
در ادامه مثال هایی عینی برای آشنایی با macro ها آورده ام.
چند وقت پیش پروژه ای داشتم که نیاز داشت عددی طراحی کنم که صورت (up) و مخرج(down) رو برای هر کسر جدا داشته باشم ( و تا زمانی که من نخواستم تقسیم را انجام ندهد ). برای این شئ "SNumber" که ساخته بودم باید چند تا تابع پیاده میکردم. مثلا توابع عملیات های ریاضی (+-/*) و چک کردن تساوی ( == ) که این عملیات ها قابل ترکیب با کسری دیگر یا عدد صحیح (int) بود.
خب این تابع فقط برای زمانی کار میکند که ورودی اول آن عدد خاص من و ورودی دومش عدد صحیح باشد، یعنی این تابع برای a * 2 کار میکند ولی برای i2 * a کار نمیکند! خب من ب سادگی متوانم همان تابع را دوباره کپی کنم و فقط جای ورودی اول و دوم آن را عوض کنم. ولی خب هدف ما این بود که از تکرار کد جلوگیری کنیم!
خب اگر من AST تابع ضرب را در خروجی چاپ کنم چنین چیزی تحویل میگیرم:
تولید شده به ما میگوید که یک تعریف تابع (FuncDef) داریم. چون اسم تابع خاص بود ( عملگر ضرب * ) من آن را درون دو بک تیک قرار دادم وAST تولید شده آن را با AccQuoted
نشانه گذاری کرده. شاخه بعدی (FormalParams
) شامل خروجی و ورودی های تابع و شاخه بعد لیست دستورات بدنه تابع را آورده (StmtList
).
درون شاخه FormalParams
ابتدا نوع خروجی تابع و سپس اسم و نوع ورودی های تابع ذکر شده است.
این یعنی تنها کاری که لازم است انجام دهم این است که از کل تابع یک کپی بگیرم و جای دو ورودی را عوض کنم. این کار را با نوشتن یک macro انجام میدهم:
حالا فقط در تعریف تابع این تیکه کد را اضافه کنم:
چند وقت پیش قصد داشتم از یک کتابخانه برای رنگی نوشتن در ترمینال استفاده کنم ( در زبان c++ ). کتابخانه ای پیدا کردم که فقط شامل یک فایل هدر بود. درون آن را نگاه کردم، وحشت آمیز بود! تقریبا چنین چیزی دیدم:
خب در نگاه اول احتمالا الگویی را پیدا میکنید. اسم تابع ها اسم کامل رنگ هاست و در بدنه تابع هم اسم کوتاه شون آمده. همین! ما الگو رو پیدا کردیم - حالا نوبت پیاده سازی هست:
خب تا الان فقط لیست رنگ ها را تعریف کردیم
روند کار به همان صورت بالا است، یعنی ما قصد داریم توابع تمام ترکیب های رنگی رو ایجاد کنیم، پس روی دنباله colors که به عنوان ورودی گرفتیم دوبار به صورت تو در تو پیمایش میکنیم. بعد اسم اون تابع رو میسازیم که میشه اسم رنگ ( foreground: fg ) به همراه _on_ و اسم رنگ پس زمینه (background : bg ) و بعد این مقدار را به عنوان ورودی به تابع ident دادیم که مشخص کنیم منظور ما اسم متغیری است و نه خود string .
حالا درون superQuote اون الگوی کد را پیاده سازی میکنیم ( و هرجا اگر متغیر خاصی مدنظرمون بود که توی کد باید تعویض میشد آن را بین 2 تا بک تیک ` قرار میدهیم `) - وظیفه تولید کد از الگو به عهده خود superQuote هست.
در انتهای حلقه هم تابعی که ساختیم را به لیست کدی که قصد داریم خروجی دهیم؛ اضافه میکنیم.
و در نهایت با صدا زدن این ماکرو و دادن لیست رنگ ها، ما همه اون توابع رو آماده در اختیار داریم
خب مثال های من خیلی حرفه ای نبودند و سعی کردم بیشتر جنبه آشنایی داشته باشند. ولی در بعضی از کتابخانه ها استفاده از macro خیلی ملموس است:
به همین راحتی ما یک سایت راه انداختیم که اگر کاربر آدرس address.com/hamid را وارد کند، برنامه ما متن my name is hamid را به کاربر نشان میدهد. نکته ای که وجود دارد این است که ماکرو های خود فریمورک jester میاد بعد از بررسی کدی که بهش داده شده، بجای @“name”
( و کلا string literal هایی که پشتشون علامت @ است ) چیزی که جلوی آدرس نوشته شده رو جایگزین میکند ( البته کار های دیگری هم انجام میدهد مثلا تعریف متغیر request در بدنه کد و… که من بهش اشاره نکردم)
این پارسر با استفاده از قاعده هایی که در بدنه اش نوشته شده یک الگوریتم ایجاد میکند که با استفاده از آن در متن ورودی الگوری مورد نظر رو پیدا میکند.
در اینجا ما یک سری قاعده تعریف کردیم به اسم word که شامل حروف کوچک انگلیسی میشود، number که شامل اعداد میشود، یک جفت ( pair ) که شامل یک کلمه به همراه علامت مساوی و یک عدد میشود، ( به علامت بزرگ تر قبل از word و number توی تعریف pair دقت کنید - اون علامت به این معنی است که قسمت های علامت گذاری شده را ذخیره کن ) حالا در بدنه آن نوشتیم که چیز هایی که در حین تطبیق الگو ذخیره کردی را در متغیری که بهش داده شده (d) ذخیره کن ( منظور از $1 قسمت ذخیره شده اول و $2 قسمت ذخیره شده دوم هست )
و در نهایت قاعده paires ( جفت ها) میشود یک قاعده pair ای که میتواند بعد از آن با ویرگول pair های دیگری هم آمده باشه
توجه کنید که علامت -> در زبان Nim هیچ معنی خاصی نمیدهد و این ترتیب از نوشتن هم از نظر منطقی مجاز نیست ( ولی از نظر گرامری مشکل ندارد ). همچنین `$1` و `$2` که موقع کامپایل به صورت `[capture[1` و `[capture[2` تبدیل میشوند که متغیر `capture` دنباله ای از متن های ذخیره شده است )
حالا نحوه استفاده اش به این صورت است:
واقعا چقدر کار ساده تر شد؟
در اینجا چند زبان که از این قابلیت ها پشتبانی میکنند را گرد آوری کرده ام.
با قابلیت دستکاری AST:
بدون قابلیت دستکاری AST:
نکته: زبان های زیادی از generic ها پشتیانی میکنند ( این موضوع در c++ به نام template شناخته میشود )
در زبان های c++ و Nim که بنده امتحان کردم language server از macro ها و template ها پشتیبانی میکرد.
در cpp که دیباگ کردن آن سخت ولی در زبان Nim دقیق تر است بطوری که هنگام برخورد به ارور برنامه از محل تولید کدی که باعث خطا شده خبر میدهد ( این کد ممکن است از چند macro یا template عبور کرده باشد)
در کل بیشتر به پیشتیبانی آن زبان برنامه نویسی بستگی دارد
ا eval توی زبان های تفسری چطوره؟
اگر با زبان های تفسیری کار کرده باشید، احتمالا با تابع eval آشنا هستید. این تابع یک ورودی به صورت متن دریافت میکند و آن را به کد تبدیل میکند .اولا شما اینطوری فقط به متن اون کد دسترسی دارید و نه AST. و البته که از cache شدن و بهینه شدن کدتون محروم میشید.
البته توی Nim هم میتونید از parseStmt برای تبدیل متن به کد در زمان کامپایل استفاده کنید، ولی تا وقتی که چیزایی مثل quote و دسترسی به AST هست چرا ( نه واقعا چرا؟؟ )؟
استفاده از meta programming میتونه این مزیت ها رو به ارمغان بیاره:
بی شک برنامه نویسی به سمت ساده شدن پیش میرود و 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