شی گرایی در جاوااسکریپت: از زنجیره پروتوتایپ تا الگوهای مدرن
شیگرایی در جاوااسکریپت: از زنجیره پروتوتایپ تا الگوهای مدرن. زنجیره پروتوتایپ، تفاوت کلاس در جاوااسکریپت با PHP و پایتون، تلههای this، انتخاب بین ارثبری و ترکیب، و نگاهی به رفتار موتور V8 در اجرای OOP.
اولین باری که در یک پروژه واقعی، کدِ class در جاوااسکریپت را باز کردم، از دنیای PHP آمده بودم و انتظار داشتم همان چیزی باشد که در Laravel یا Django دیده بودم؛ اما رفتاری که در this و متدهای ارثبری دیدم، به من فهماند که کلاس در جاوااسکریپت یک لایه نازک روی چیزی بسیار قدیمیتر است: زنجیره پروتوتایپ. همین درک، بعدها باعث شد ساعتها دیباگ بیفایده در پروژههای مختلف را کنار بگذارم.
واژه OOP (Object-Oriented Programming) در جاوااسکریپت، برخلاف PHP و پایتون، معنای متفاوتی دارد. این زبان نه کلاس-محور است و نه شیگرای محض. ماهیت آن prototype-based است و همین، منشأ تمام تفاوتهای ظریف و تلههای پنهانی است که حتی در حرفهایترین پروژهها هم دیدهام.
در این نوشته، مسیری را میروم که خودم برای فهم عمیق این مفهوم طی کردم: از تابع سازنده، تا class مدرن، و بعد پایینتر تا موتور اجرایی که این انتزاع را پشتیبانی میکند. اگر میخواهید OOP در جاوااسکریپت را نه بهعنوان سینتکس، بلکه بهعنوان یک طراحی ذهنی درک کنید، این مسیر برایتان کارساز خواهد بود.
چرا شی گرایی در جاوااسکریپت متفاوت است؟
اگر از یک توسعهدهنده PHP بپرسید کلاس چیست، احتمالاً میگوید: «نقشهای برای ساختن آبجکت.» در پایتون هم تقریباً همین پاسخ را میشنوید. اما اگر همین سوال را از یک مهندس کهنهکار جاوااسکریپت بپرسید، ممکن است بگوید: «یک رویهی سینتکتیکی که روی زنجیره پروتوتایپ کشیده شده.» و همین تفاوت زبان، منشأ تمام تلههای عملی است.
جاوااسکریپت یک زبان prototype-based است. یعنی بهجای اینکه اشیاء را از روی «نقشه» بسازد، آنها را از روی «شیء نمونه» میسازد. کلاسها در ES6 یک دروازهی زیبا برای کار با همین مکانیزم قدیمی هستند؛ مکانیزمی که از روز اول در زبان وجود داشت و امروز هم زیر پوست class جریان دارد.
نکتهای که در دهها پروژه دیدهام: توسعهدهندههایی که این لایه را نمیشناسند، در مواجهه با مسائلی مثل this گم میشوند، از new بهعنوان یک جادو استفاده میکنند، و با یک رفتار غیرمنتظره در وراثت ساعتها وقت تلف میکنند. اگر با آموزش جاوااسکریپت از صفر شروع کردهاید، الان وقتش رسیده که به این لایه عمیقتر نگاه کنید.
جاوااسکریپت کلاس ندارد؛ فقط زنجیرهای از اشیاء دارد و کلاس، لباس رسمی آن است.
پایه پنهان: زنجیره پروتوتایپ
قبل از هر چیز، باید مدل ذهنی درست را ساخت. در جاوااسکریپت هر شیء یک پیوند داخلی به نام [[Prototype]] دارد. وقتی به خاصیتی دسترسی میخواهید که در خودِ شیء وجود ندارد، موتور به این پیوند نگاه میکند و اگر آنجا هم نبود، به پیوند بعدی میرود؛ تا جایی که به null برسد. این دقیقاً همان چیزی است که Object.getPrototypeOf آن را آشکار میکند.
بگذارید با یک مثال ساده نشان دهم:
const animal = {
sound() {
return "some sound";
}
};
const dog = Object.create(animal);
console.log(dog.sound()); // "some sound"
هیچ کلاسی در کار نیست. هیچ new و constructor هم نیست. اما رفتار وراثت رخ میدهد، چون dog بهعنوان نمونهای از animal ساخته شده و آنجا پیوند خورده است. این پایهای است که تمام classها روی آن ساخته شدهاند. اگر این را نفهمید، بقیه مطالب برایتان یک جعبه سیاه خواهد بود.
برای اینکه درک عمیقتری از مفاهیم پایه داشته باشید، پیشنهاد میکنم مفاهیم پایه جاوااسکریپت را یک بار مرور کنید؛ بهخصوص بخش آبجکتها.
توابع سازنده: دنیای قبل از class
قبل از ES6، روش مرسوم ساخت شیء از «الگو»، استفاده از تابع سازنده بود. الگویی که هنوز در کدبیسهای بزرگ میبینم و یکی از نقاط قوت درک آن، فهمیدن رفتار پشت صحنه است:
function User(name, email) {
this.name = name;
this.email = email;
}
User.prototype.greet = function () {
return "Hi, " + this.name;
};
const u = new User("Ali", "ali@example.com");
console.log(u.greet()); // "Hi, Ali"
نکتهی ظریفی که در این الگو وجود دارد: متد greet روی خودِ شیء نیست؛ روی User.prototype است. بنابراین تمام نمونههای ساختهشده، همان یک تابع را به اشتراک میگذارند. این یکی از بهترین نمونههای صرفهجویی در حافظه است و همین ایده، دلیل وجود OOP در جاوااسکریپت شده است.
اما همین الگو یک تله هم دارد: اگر متد را داخل خود تابع تعریف کنید (نه روی prototype)، هر نمونه یک کپی جداگانه از متد خواهد داشت. در پروژههای بزرگی که با Vuex یا Redux کار میکردم، دیدهام که همین جزئیات کوچک، مصرف حافظهی مرورگر را چند برابر کرده است.
ES6 Classes: شکر سینتکتیک یا چیز بیشتر؟
با ES6، class به زبان اضافه شد. اما این یک تغییرِ مدل ذهنی نیست؛ یک روکشِ خوانا روی همان مکانیزم پروتوتایپی است. سینتکس بسیار شبیه به کلاسهای پایتون و PHP است، اما رفتار درونی همان چیزی است که قبلاً دیدید:
class User {
constructor(name, email) {
this.name = name;
this.email = email;
}
greet() {
return "Hi, " + this.name;
}
}
const u = new User("Ali", "ali@example.com");
console.log(u.greet()); // "Hi, Ali"
سه تفاوت مهم که در پروژهها بهکارم آمده است:
- سختگیری:
classهمیشه در حالت strict اجرا میشود. کوچکترین خطای تعریف متغیر، بلافاصله error میدهد نه اینکه بیصدا شکست بخورد. - hoisting متفاوت: تابع سازنده hoisted میشود، اما کلاس نه. اگر قبل از تعریف از کلاس استفاده کنید، خطای
ReferenceErrorمیگیرید. - عدم امکان فراخوانی بدون new: نمیتوانید
User()را صدا بزنید و انتظار داشته باشید کار کند. این خودش یک محافظ است.
برای دیدن تفاوتهای ظریفتر و مواردی که در ES6 اضافه شدند، آموزش ES6 در جاوااسکریپت نقطهی شروع خوبی است.
چهار ستون شی گرایی در جاوااسکریپت
چهار مفهوم بنیادین OOP در جاوااسکریپت رنگ و بوی خاصی دارند. جدول زیر، مقایسهای سریع از این چهار ستون با آنچه در زبانهای کلاس-محور میبینیم ارائه میدهد:
| اصل | در جاوااسکریپت | نکته عملی |
|---|---|---|
| کپسولهسازی (Encapsulation) | با closures، WeakMap یا فیلدهای خصوصی # | برخلاف TypeScript، دسترسیها private نیستند مگر خودتان بسازید |
| ارثبری (Inheritance) | extends + super، زیر پوست زنجیره prototype | ارثبری از یک class، prototype را تغییر میدهد، نه کپی میسازد |
| چندریختی (Polymorphism) | override متدها با همان نام در کلاس فرزند | Dynamic dispatch در V8 سریع است اما نه به سرعت فراخوانی مستقیم |
| انتزاع (Abstraction) | تعریف اینترفیس با عرف، نه با keyword | معمولاً با کلاس انتزاعی ساده میسازیم |
در جاوااسکریپت، کپسولهسازی مدتها نبود تا اینکه فیلدهای #private اضافه شد. حتی امروز هم بسیاری از کدبیسها از توافقنامگذاری (مثلاً _name) استفاده میکنند، که فقط یک عرف است نه محدودیت واقعی. برای درک کامل این تفاوتها، پیشنهاد میکنم برنامهنویسی شیگرا با مثالهای ساده را مرور کنید.
در جاوااسکریپت، private واقعی به معنای زبانیاش وجود نداشت؛ امروز هم فقط با #private در سطح فیلد در دسترس است.
تلهی this و راههای فرار
بزرگترین سرمنشأ باگهای OOP در جاوااسکریپت، this است. مقدار this بستگی دارد به اینکه تابع چطور صدا زده شده، نه کجا تعریف شده. چهار قاعدهی ساده:
- در فراخوانی متد،
thisهمان شیء سمت چپ نقطه است:obj.method(). - در فراخوانی مستقیم تابع،
thisدر حالت strict معادلundefinedو در حالت غیر strict،windowاست. - در تابع سازنده با
new،thisهمان شیء تازه ساختهشده است. - در
callوapplyوbind، خودتان مقدارthisرا تعیین میکنید.
مشکلی که در پروژهها مکرر دیدهام: متدی را بهعنوان callback پاس میدهید و یکدفعه خطای Cannot read property of undefined میگیرید. چون اینجا دیگر this همان شیء نبوده است. راهحل تمیز: استفاده از arrow function یا bind. برای درک دقیقتر تلههای این مفهوم، مبحث مدیریت خطا در مدیریت خطا در جاوااسکریپت را ببینید.
ارثبری یا ترکیب؟ تصمیمی که معمارها میگیرند
یکی از بحثهای دائمی در تیمهای فنی که در آنها حضور داشتهام: «از ارثبری استفاده کنیم یا ترکیب؟» پاسخ دادن به این سوال، تجربه میخواهد. تجربهی من این است که ترکیب در اکثر سناریوها برنده است، مخصوصاً وقتی چند نوع رفتار متفاوت دارید که میخواهید بینشان جابهجا شوید.
ارثبری در جاوااسکریپت یک مشکل ساختاری دارد: رابطهی «is-a» را سختکد میکند. اگر بعداً مجبور شوید رابطه را عوض کنید، باید کل درخت را بازطراحی کنید. ترکیب این انعطاف را میدهد:
const canSwim = {
swim() { return "swimming"; }
};
const canFly = {
fly() { return "flying"; }
};
class Duck {
constructor() {
Object.assign(this, canSwim, canFly);
}
}
const donald = new Duck();
console.log(donald.swim()); // "swimming"
console.log(donald.fly()); // "flying"
این الگو که به آن Mixin هم میگویند، در کتابخانههای بزرگ جاوااسکریپت مثل Vue و Lodash جای پررنگی دارد. علت: تطبیقپذیری بالاتر با تغییرات آتی. برای مشاهده همان بحث در زبانی دیگر، مقالهی آموزش شی گرایی در PHP را ببینید که در آنجا محدودیتهای تکارثبری را باز کردهام.
اشتباهات رایجی که در پروژهها دیدهام
- استفاده از this در callbackهای معمولی: پرتکرارترین اشتباه. حل میشود با arrow function یا ذخیرهی this در متغیر
self. - تعریف متد در constructor: باعث میشود هر نمونه یک کپی جدا داشته باشد و مصرف حافظه بالا برود.
- extends برای صرفاً اشتراک کد: اگر رابطهی «is-a» منطقی نیست، از ارثبری استفاده نکنید؛ از Mixin یا ترکیب استفاده کنید.
- نادیده گرفتن strict mode: در حالت غیر strict، اشتباهات کوچک مثل اشتباهتایپی در نام متغیر بیصدا رد میشوند. در پروژههای جدید حتماً ماژولها را strict کنید.
- تکیه بر instanceof در کد پرتغییر: در چند ریپازیتوری که دیدهام،
instanceofباعث شده یک ریفکتور ساده، شکستهای عجیب بدهد. برای بررسی نوع، بیشتر به duck typing و رفتار اعتماد کنید.
برای آنکه این اشتباهات را در کنار سایر مسائل مشابه ببینید، نگاهی به مفاهیم پیشرفته جاوااسکریپت بیندازید.
زیر لایه class: نگاهی به موتور اجرا
اینجا وارد لایهای میشویم که معمولاً فقط توسعهدهندههای ارشد یا مهندسان پلتفرم به آن دسترسی پیدا میکنند. اینکه موتور V8 (و موتورهای دیگر مثل SpiderMonkey و JavaScriptCore) چگونه کلاسهای جاوااسکریپت را اجرا میکنند، تأثیر مستقیمی روی تصمیمهای معماری دارد.
سه نکته که از مطالعهی رفتار موتورها و پروفایلینگ پروژههای واقعی به آن رسیدهام:
- Shape و Hidden Class: وقتی یک آبجکت با همان ترتیب و همان نامهای خاصیت ساخته میشود، V8 یک Shape واحد برایش میسازد و دسترسیها بهینه میشود. اگر ترتیب ساخت را عوض کنید یا مدام خاصیت اضافه کنید، shape بیاعتبار میشود و دسترسیها کندتر. این توضیح میدهد چرا در constructor خوب است همهی فیلدها را همانجا مقداردهی اولیه کنید.
- Inline Cache: موتور تا چند فراخوانی متد را monomorphic نگه میدارد؛ اما اگر چند کلاس مختلف با یک نام متد سروکار داشته باشند، polymorphic میشود و هزینهی dispatch بالا میرود. در سیستمهایی با polymorphic heavy (مثل رندر لیستی از کامپوننتهای متفاوت) این هزینه اندازهگیریشدنی است.
- Deoptimization بر اثر prototype mutability: اگر در زمان اجرا prototype یک کلاس را تغییر دهید، V8 مجبور میشود کدِ بهینهشدهی قبلی را دور بریزد و از نو بهینه کند. اینجاست که «کد تمیز در طراحی، اما کند در اجرا» معنا پیدا میکند. برای مطالعهی این دسته مسائل، مقالهی بهینه سازی جاوااسکریپت را پیشنهاد میکنم.
یک نکتهی ظریف که در تحلیل پروفایل یک اپلیکیشن بزرگ دیدم: با تبدیل هزاران کلاس به همان قالب استاندارد و افزودن متدها قبل از انتشار به prototype (نه در زمان اجرا)، مصرف CPU حدود پانزده درصد کاهش پیدا کرد. این نتیجه در پروژههای دیگر تضمینشده نیست، اما اصل «تغییر ندادن prototype در زمان اجرا» توصیهای است که با خیال راحت میتوانم بدهم.
کدی که زیبا نوشته میشود اما زمان اجرا prototype را دستکاری میکند، از نظر موتور، دشمنِ خودش است.
برای درک ارتباط این لایه با تایپسیستم و کامپایل، تفاوت TypeScript و JavaScript را ببینید. در آنجا توضیح دادهام که چرا TypeScript این محدودیتها را در زمان کامپایل آشکار میکند و بار runtime را کاهش میدهد.
آنچه از این مسیر با خودم بردم
شی گرایی در جاوااسکریپت، یک تکنیک نیست؛ یک لایهی فکری است که بدون فهم زنجیرهی پروتوتایپ، تبدیل به جعبهی سیاه میشود. سه درس عملی که در پروژههای مختلف روی آنها تأکید کردهام:
- هرگز
classرا بدون فهم پایه پروتوتایپ بهکار نبرید. یک ساعت مطالعه در این مورد، ماهها دیباگ را ذخیره میکند. - ترکیب را بر ارثبری ترجیح دهید، مگر اینکه رابطهی «is-a» واقعاً معنادار باشد.
- در پروژههای پرترافیک، حواستان به تغییر prototype در زمان اجرا باشد؛ این کار موتور را مجبور به deoptimization میکند.
مطالعهی این مسیر را میتوانید با یادگیری جاوااسکریپت با پروژههای واقعی تکمیل کنید. اگر بین انتخاب این مسیر و تایپسیستم مردد هستید، آموزش تایپ اسکریپت از صفر نقطهی شروع مناسبی است.
تجربهی شما از این مسیر چیست؟ اگر جایی گیر کردهاید — مثلاً با یک باگ عجیب در this یا با یک تصمیم معماری بین ارثبری و ترکیب — سناریو را در دیدگاهها بنویسید. مخصوصاً اگر در کدبیسهای بزرگ با چالشهای مشابه روبرو شدهاید، جزئیات همان مشکل برای خواننده بعدی از هر توضیح کلی مفیدتر است. 🚀