فصل 6زندگی اسرارآمیز اشیاء
یک نوع دادهی انتزاعی را میتوان با نوشتن یک برنامهی بخصوص فهمید […] برنامهای که این نوع را بر اساس کارهایی تعریف میکند که روی آن میتوان انجام داد.
در فصل 4 به معرفی اشیاء در جاوااسکریپت پرداختیم. در فرهنگ برنامهنویسی، مفهومی وجود دارد که به آن برنامهنویسی شیء گرا گفته میشود؛ مجموعهای از تکنیکها که در آن از اشیاء (و مفاهیم مرتبط با آن) به عنوان اصل مرکزی، برای سازماندهی برنامه استفاده میشود.
با وجود اینکه هیچ کس روی تعریف دقیق آن توافق ندارد، برنامهنویسی شیء گرا، طراحی خیلی از زبانهای برنامهنویسی را شکل داده است، مثل خود جاوااسکریپت. این فصل به توضیح چگونگی پیادهسازی این ایده در جاوااسکریپت میپردازد.
کپسولهسازی (Encapsulation)
ایدهی اصلی در برنامهنویسی شیء گرا، تقسیم برنامه به قسمتهای کوچکتر است؛ با این شرط که هر قسمت مسئول مدیریت وضعیت خودش باشد.
در این روش، بعضی از اطلاعات مربوط به نحوهی کارکرد یک قسمت از برنامه را میتوان به صورت محلی (local) برای همان قسمت نگه داری کرد. کسی که روی قسمتهای دیگر برنامه کار میکند نیازی نیست تا این اطلاعات را به خاطر داشته باشد یا حتی اصلا از وجودشان آگاه باشد. در صورت تغییر این جزئیات محلی، فقط لازم است کدهایی را بهروز کنیم که مستقیما پیرامون آن قرار دارند.
بخشهای مختلف این گونه برنامهها، برای ارتباط با یکدیگر از رابطها (interface) استفاده میکنند؛ مجموعهای محدود از توابع یا متغیرها که قابلیتهای مفیدی را در سطح بالاتری از انتزاع ایجاد میکنند و جزئیات دقیق پیادهسازیشان را از دید استفادهکننده مخفی میسازند.
قسمتهای اینگونه برنامهها را به وسیلهی اشیاء مدلسازی میکنند. رابط آنها شامل مجموعهای مشخص از متدها و خاصیتها میباشد. به آن گروه از خاصیتها که بخشی از رابط محسوب میشوند، عمومی (public) و آنهایی را که کدهای بیرونی نباید به آنها دسترسی داشته باشند، خاصیتهای خصوصی (private) میگویند.
بسیاری از زبانهای برنامهنویسی راهی برای تمایز خاصیتهای خصوصی و عمومی فراهم میسازند و از دسترسی کدهای بیرونی به خاصیتهای خصوصی جلوگیری میکنند. جاوااسکریپت بار دیگر با انتخاب روش مینیمال، این گونه عمل نمیکند – حداقل تا الان. البته کارهایی برای افزودن این ویژگی به زبان در حال شکلگیری است.
با وجود اینکه خود زبان به صورت درونی از این تمایز پشتیبانی نمیکند، برنامهنویسان جاوااسکریپت این مفهوم را به شکل موفقیتآمیزی به کار میبرند. معمولا برای مشخص کردن رابطهای در دسترس از توضیحات یا مستندات برنامه استفاده میکنند. همچنین یک روش رایج برای مشخص کردن خاصیتهای خصوصی، استفاده از کاراکتر زیرخط (_
) در شروع این خاصیتها است.
ایجاد رابط مجزا برای کار با اشیاء و جدا کردن آن از پیادهسازی، ایدهی بسیار خوبی است. این کار را معمولا کپسولهسازی یا مخفیسازی مینامند.
متدها (Methods)
متدها خاصیتهایی هستند که مقدارهای تابع را نگهداری میکنند؛ همین. این یک متد ساده است:
let rabbit = {}; rabbit.speak = function(line) { console.log(`The rabbit says '${line}'`); }; rabbit.speak("I'm alive."); // → The rabbit says 'I'm alive.'
معمولا متدها کاری را روی شیئی انجام میدهند که بر روی آن فراخوانی شدهاند. زمانی که یک تابع به عنوان یک متد فراخوانی میشود – به عنوان یک خاصیت جستجو میشود و بلافاصله فراخوانی میشود؛ مانند object.method()
- متغیری که this
نامیده میشود به طور خودکار به شیئی اشاره میکند که روی آن فراخوانی شده است.
function speak(line) { console.log(`The ${this.type} rabbit says '${line}'`); } let whiteRabbit = {type: "white", speak}; let hungryRabbit = {type: "hungry", speak}; whiteRabbit.speak("Oh my ears and whiskers, " + "how late it's getting!"); // → The white rabbit says 'Oh my ears and whiskers, how // late it's getting!' hungryRabbit.speak("I could use a carrot right now."); // → The hungry rabbit says 'I could use a carrot right now.'
میتوانید this
را یک پارامتر اضافه در نظر بگیرید که به شکلی دیگر ارسال شده است. اگر بخواهید آن را آشکارا ارسال نمایید، میتوانید از متد call
توابع استفاده کنید. این متد مقدار this
را به عنوان آرگومان اول میگیرد و دیگر آرگومانها را به عنوان پارامترهای معمولی تفسیر میکند.
speak.call(hungryRabbit, "Burp!"); // → The hungry rabbit says 'Burp!'
به دلیل اینکه هر تابع متغیر this
مربوط به خود را دارد، که مقدارش بستگی به نحوهی فراخوانی آن تابع دارد، نمیتوان به this
محصور شده در قلمروی تعریف یک تابع معمولی (که با کلیدواژهی function
تعریف شده است) دسترسی داشت.
توابع پیکانی (arrow functions) متفاوت عمل میکنند - این توابع this
را به جایی مقید نمیکنند اما میتوانند متغیر this
موجود در قلمروی پیرامونشان را ببینند. بنابراین، به وسیلهی کدی مانند مثال زیر، میتوان به this
از درون یک تابع محلی اشاره کرد:
function normalize() { console.log(this.coords.map(n => n / this.length)); } normalize.call({coords: [0, 2, 3], length: 5}); // → [0, 0.4, 0.6]
اگر آرگومان ارسالی به تابع map
را به وسیله کلیدواژهی function
نوشته بودیم، کد بالا کار نمیکرد.
پروتوتایپها (Prototypes)
با دقت به کد زیر نگاه کنید.
let empty = {}; console.log(empty.toString); // → function toString(){…} console.log(empty.toString()); // → [object Object]
خاصیتی را از یک شیء تهی بیرون کشیدهام. جادو!
البته واقعا اینطور نیست. نکته اینجاست که هنوز بخشی از اطلاعات مربوط به شیوهی کارکرد اشیاء در جاوااسکریپت را توضیح ندادهام. بیشتر اشیاء علاوه بر خاصیتهای خودشان، خاصیتی به نام prototype نیز دارند. یک prototype (نمونهی اولیه) خود یک شیء دیگر است که به عنوان یک منبع جایگزین برای خاصیتها استفاده میشود. زمانی که یک شیء، درخواستی برای یک خاصیت دریافت میکند و آن خاصیت را ندارد، جستجو در prototype آن صورت میگیرد، سپس prototype متعلق به prototype آن جستجو میشود و این روند ادامه دارد.
بنابراین نمونهی اولیهی (prototype) آن شیء تهی کدام است؟ بله آن prototype جد بزرگ شیء است که تقریبا پشت همهی اشیاء قرار دارد: Object.prototype
.
console.log(Object.getPrototypeOf({}) == Object.prototype); // → true console.log(Object.getPrototypeOf(Object.prototype)); // → null
همانطور که حدس میزنید، object.
، برای بدست آوردن prototype یک شیء استفاده میشود.
روابط بین prototypeها در اشیاء جاوااسکریپت، یک ساختار درختی را شکل میدهند که در ریشهی این ساختار، Object.prototype
قرار میگیرد. درون این شیء چند متد وجود دارد که در تمامی اشیاء حضور دارند، مانند toString
، که عمل تبدیل یک شیء به رشته را انجام میدهد.
بسیاری از اشیاء به طور مستقیم دارای Object.prototype
به عنوان prototype خودشان نیستند، اما در عوض شیء دیگری دارند که مجموعهی متفاوتی از خاصیتهای پیشفرض را فراهم میسازد. توابع از Function.
و آرایهها از Array.prototype
مشتق شدهاند.
console.log(Object.getPrototypeOf(Math.max) == Function.prototype); // → true console.log(Object.getPrototypeOf([]) == Array.prototype); // → true
این گونه prototype ها خود نیز دارای یک prototype میباشند که اغلب همان Object.prototype
است. پس به طور غیر مستقیم متدهایی شبیه toString
را فراهم میکنند.
میتوانید از متد Object.create
برای ساختن یک شیء با یک prototype خاص استفاده کنید.
let protoRabbit = { speak(line) { console.log(`The ${this.type} rabbit says '${line}'`); } }; let killerRabbit = Object.create(protoRabbit); killerRabbit.type = "killer"; killerRabbit.speak("SKREEEE!"); // → The killer rabbit says 'SKREEEE!'
خاصیتی شبیه به speak(line)
در یک تعریف شیء، شکل کوتاه تعریف یک متد است. این کار خاصیتی به نام speak
را تعریف کرده و یک تابع را به عنوان مقدار به آن میدهد.
“protoRabbit” به عنوان یک ظرف برای خاصیتهای مشترک همهی خرگوشها استفاده میشود. یک شیء مجزای خرگوش مثل killerRabbit، دارای خاصیتهای اختصاصی است که فقط متعلق به خودش – در این مثال خاصیت type – است و نیز خاصیتهای مشترکی دارد که آنها را از نمونهی اولیهاش میگیرد.
کلاسها
سیستم prototype جاوااسکریپت را میتوان به عنوان برداشتی نسبتا غیر رسمی از مفهوم کلاسها در برنامهنویسی شیءگرا تفسیر کرد. یک کلاس، سرشت نوعی از شیء را تعریف میکند – دارای تعدادی متد و خاصیت میباشد. به اشیائی که از کلاسها ایجاد میشوند، نمونههای (instance) یک کلاس میگویند.
prototype ها برای تعریف خاصیتهایی مفید میباشند که مقدار مشابهی را در طول همهی نمونههای یک کلاس به اشتراک میگذارند، مانند متدها. خاصیتهایی که برای هر نمونه متفاوت هستند مثل خاصیت type
در مثال خرگوش، بایستی به طور مستقیم در خود اشیاء ذخیره بشوند.
بنابراین برای اینکه بتوان نمونهای از یک کلاس داده شده را ساخت، باید شیئی را ایجاد کنید که از یک prototype درست گرفته شده است، همچنین باید مطمئن شوید که این شیء، خاصیتهایی که نمونههای کلاس قرار است از پیش داشته باشند را دارد. این کاری است که یک تابع سازنده (constructor) انجام میدهد.
function makeRabbit(type) { let rabbit = Object.create(protoRabbit); rabbit.type = type; return rabbit; }
جاوااسکریپت روشی را فراهم ساخته است که بتوان این گونه توابع را آسانتر تعریف کرد. اگر در ابتدای فراخوانی یک تابع کلیدواژهی new
را قرار دهید، آن تابع به عنوان تابع سازنده عمل خواهد کرد. این بدان معنا است که یک شیء با prototype صحیح به طور خودکار ساخته شده، به this
درون تابع مقید میشود و در انتهای تابع برگردانده میشود.
برای دستیابی به نمونهی اولیهای که شیء جدید از روی آن ساخته میشود، میتوانید به خاصیت prototype
تابع سازنده رجوع کنید.
function Rabbit(type) { this.type = type; } Rabbit.prototype.speak = function(line) { console.log(`The ${this.type} rabbit says '${line}'`); }; let weirdRabbit = new Rabbit("weird");
سازندهها (در حقیقت همه توابع) به طور خودکار خاصیتی به نام prototype
را میگیرند، که این خاصیت به صورت پیش فرض، یک شیء خالی را که خود از Object.prototype
گرفته شده است، نگه داری میکند. اگر بخواهید میتوانید این خاصیت را تغییر دهید و با شیئی دیگر عوض کنید. یا میتوانید به شیء خالی فعلی خاصیتهایی را اضافه کنید همانطور که در مثال، این کار انجام شد.
رسم است که نام سازندهها با حروف بزرگ شروع شوند تا بتوان به سادگی بین آنها و توابع معمولی تمایز قائل شد.
درک تفاوت بین چگونگی ارتباط یک prototype با یک سازنده (توسط خاصیت prototype
آن) و اینکه اشیاء چگونه میتوانند prototype داشته باشند (که میتواند به وسیله Object.
بدست آید) اهمیت دارد. prototype واقعی یک سازنده، در واقع Function.
است، به این خاطر که سازندهها از نوع تابع به شمار میآیند. خاصیت prototype
یک سازنده، نمونهی اولیهای را نگهداری میکند که ساخت نمونههای اشیاء از روی آن صورت میگیرد.
console.log(Object.getPrototypeOf(Rabbit) == Function.prototype); // → true console.log(Object.getPrototypeOf(weirdRabbit) == Rabbit.prototype); // → true
استفاده از نماد class
بنابراین مفهوم کلاسها در جاوااسکریپت، در واقع همان توابع سازنده به همراه یک خاصیت prototype میباشد. به همین صورت نیز کار میکنند و تا پیش از سال 2015، این روش تنها راه پیادهسازی موجود بود. این روزها، روش مناسبتری برای پیادهسازی کلاسها در اختیار داریم.
class Rabbit { constructor(type) { this.type = type; } speak(line) { console.log(`The ${this.type} rabbit says '${line}'`); } } let killerRabbit = new Rabbit("killer"); let blackRabbit = new Rabbit("black");
کلیدواژهی class
موجب شروع یک اعلان کلاس میشود که به ما این امکان را میدهد تا سازنده و مجموعهی متدها را یکجا تعریف کنیم. هر تعداد متدی که نیاز باشد را میتوان درون کروشههای تعریف کلاس قرار داد. متدی که با نام constructor
نوشته میشود، به صورت خاصی تفسیر میشود. این متد تابع سازندهی واقعی را فراهم میسازد که به نام Rabbit
قید خواهد خورد. دیگر متدها درون prototype سازنده بستهبندی میشوند. بنابراین، تعریف کلاس به شکل بالا، معادل تعریف سازنده در قسمت قبل است. فقط زیباتر به نظر میرسد.
در تعریف کلاس فقط میتوان متدها –خاصیتهایی که توابع را نگهداری میکنند – را برای اضافه شدن به prototype تعریف کرد. این محدودیت، در مواقعی که قصد دارید مقداری از نوع غیر تابع را ذخیره کنید، ممکن است مشکل ایجاد کند. برای این گونه خاصیتها، میتوانید همچنان به صورت مستقیم prototype را بعد از تعریف کلاس تغییر دهید.
class
درست شبیه function
میتواند به صورت عبارت و دستور استفاده شود. اگر به عنوان عبارت استفاده شود، متغیری تعریف نکرده و سازنده را به عنوان یک مقدار تولید میکند. میتوانید نام کلاس را در این روش از تعریف حذف کنید.
let object = new class { getWord() { return "hello"; } }; console.log(object.getWord()); // → hello
بازنویسی و تغییر خاصیتهای مشتق شده
هنگامی که خاصیتی را به یک شیء اضافه میکنید، فارغ از اینکه در prototype آن وجود داشته باشد یا خیر، خاصیت مورد نظر به خود شیء اضافه خواهد شد. در صورت وجود خاصیتی با همین نام در prototype، خاصیت موجود در prototype بیاثر خواهد بود و پشت خاصیت خود شیء پنهان میشود.
Rabbit.prototype.teeth = "small"; console.log(killerRabbit.teeth); // → small killerRabbit.teeth = "long, sharp, and bloody"; console.log(killerRabbit.teeth); // → long, sharp, and bloody console.log(blackRabbit.teeth); // → small console.log(Rabbit.prototype.teeth); // → small
نمودار زیر شرایطی را به تصویر میکشد که بعد از اجرای کد بالا رخ میدهد. prototypeهای Rabbit
و Object
پشت killerRabbit
قرار میگیرند، مانند نوعی پسزمینه، که در صورت نبودن خاصیتها در خود شیء، به آنها رجوع میشود.
تغییر و بازنویسی خاصیتهایی که در prototype وجود دارند میتواند کاربرد داشته باشد. همانطور که در مثال rabbit teeth (خاصیت teeth در شیء خرگوش) نشان داده شد، میتوان از آن برای مشخص کردن خاصیتهای استثناء در نمونه اشیاء یک کلاس عمومیتر استفاده کرد و اجازه داد اشیاء معمول، مقدار استاندارد را از prototype خود دریافت کنند.
بازنویسی به این شکل (overridding)، همچنین برای تعریف نسخهی متفاوتی از متد toString
برای prototypeهای استاندارد تابع و آرایه نیز استفاده میشود. متدی که با toString
پیشفرض شیء پایه متفاوت است.
console.log(Array.prototype.toString == Object.prototype.toString); // → false console.log([1, 2].toString()); // → 1,2
فراخوانی toString
بر روی یک آرایه نتیجهای شبیه به فراخوانی .
روی آن را خواهد داشت – که باعث میشود بین مقادیر آرایه، ویرگول قرار گیرد. فراخوانی مستقیم Object.
با یک آرایه، رشتهی متفاوتی را تولید میکند. این تابع چیزی در مورد آرایهها نمیداند، پس خیلی ساده واژهی object و نام نوع داده را بین یک جفت براکت چاپ میکند.
console.log(Object.prototype.toString.call([1, 2])); // → [object Array]
ساختار دادهی Map
با واژهی map در فصل پیش آشنا شدیم. از آن برای تغییر یک ساختار داده به وسیلهی اعمال یک تابع به عناصر آن استفاده میشد. درست است که شاید کمی گیجکننده باشد اما در برنامهنویسی، همین واژه برای موضوع مرتبط و نسبتا متفاوتی نیز استفاده میشود.
یک map یک ساختار داده است که مقدارهایی را (کلیدها) به مقدارهای دیگر مرتبط میسازد. به عنوان مثال، ممکن است بخواهید اسمها را به سنها نگاشت (map) کنید. میتوان از یک شیء برای این کار استفاده کرد.
let ages = { Boris: 39, Liang: 22, Júlia: 62 }; console.log(`Júlia is ${ages["Júlia"]}`); // → Júlia is 62 console.log("Is Jack's age known?", "Jack" in ages); // → Is Jack's age known? false console.log("Is toString's age known?", "toString" in ages); // → Is toString's age known? true
در این مثال نام خاصیتهای شیء، برابر با نام اشخاص و مقدارشان برابر با سن افراد تنظیم شده است. اما بیشک، در بین اسامی، کسی به نام toString نداشتهایم. بله به دلیل اینکه اشیاء ساده، از Object.prototype
مشتق شدهاند، به نظر میرسد که این خاصیت از آنجا آمده است.
از این رو، استفاده از اشیاء ساده به جای map خطراتی دارد. راههای متفاوتی برای فرار از این مشکل وجود دارد. اول اینکه میتوان شیئی را بدون prototype ایجاد کرد. اگر به متد Object.create
مقدار null
را بفرستید، شیء تولیدی دیگر از روی Object.prototype
ساخته نمیشود و میتوان با خیال راحت به عنوان یک نگاشت (map) از آن استفاده کرد.
console.log("toString" in Object.create(null)); // → false
نام خاصیت یک شیء باید از نوع رشته باشد. اگر به یک نگاشت نیاز داشته باشید که کلیدهای آن را نتوان به سادگی به رشته تبدیل کرد (مثل استفاده اشیاء به عنوان کلید)، نمیتوانید برای پیادهسازی آن نگاشت از یک شیء استفاده کنید.
خوشبختانه، جاوااسکریپت کلاسی به نام Map
را فراهم ساخته است که دقیقا برای همین هدف نوشته شده است. این کلاس برای ذخیرهی نگاشتها با هر نوع کلیدی استفاده میشود.
let ages = new Map(); ages.set("Boris", 39); ages.set("Liang", 22); ages.set("Júlia", 62); console.log(`Júlia is ${ages.get("Júlia")}`); // → Júlia is 62 console.log("Is Jack's age known?", ages.has("Jack")); // → Is Jack's age known? false console.log(ages.has("toString")); // → false
متدهای get
و set
و has
بخشهایی از رابط شیء Map
هستند. نوشتن ساختار دادهای که بتوان به وسیلهی آن مجموعهی بزرگی از مقدارها را به سرعت به روزرسانی و جستجو کرد کار سادهای نیست، اما نیازی نیست ما نگران آن باشیم. کسانی قبلا این کار را برای ما انجام دادهاند و میتوانیم به سراغ این رابط ساده برویم و از حاصل کار آنها استفاده کنیم.
اگر شیء سادهای دارید و بنا به دلایلی لازم است از آن به عنوان یک ساختار map استفاده کنید، لازم است بدانید که Object.keys
فقط کلیدهای خود یک شیء را برمی گرداند نه آنهایی که در prototype آن قرار دارند. به عنوان یک جایگزین برای عملگر in
، میتوانید از متد hasOwnProperty
استفاده کنید که prototype شیء را در نظر نمیگیرد.
console.log({x: 1}.hasOwnProperty("x")); // → true console.log({x: 1}.hasOwnProperty("toString")); // → false
چندریختی (Polymorphism)
زمانی که تابع String
(که یک مقدار را به رشته تبدیل میکند) را روی یک شیء فراخوانی میکنید، متد toString
آن شیء فراخوانی میشود و سعی میکند تا رشتهای معنادار از شیء مورد نظر تولید کند. پیشتر اشاره کردم که بعضی از prototypeهای استاندارد، نسخهی toString
اختصاصی خودشان را تعریف میکنند تا با این کار بتوانند اطلاعات مفیدتری نسبت به "[object Object]"
تولید کنند. شما نیز میتوانید این کار را انجام دهید.
Rabbit.prototype.toString = function() { return `a ${this.type} rabbit`; }; console.log(String(blackRabbit)); // → a black rabbit
این نمونهای ساده از یک ایدهی قدرتمند است. زمانی که کدی نوشته میشود تا با اشیائی کار کند که دارای یک رابط خاص هستند – در این مثال، یک متد toString
- میتوان با این کد، هر شیء دیگری را که از این رابط پشتیبانی میکند، شامل نمود و به درستی استفاده کرد.
این تکنیک را چندریختی میگویند. یک کد چندریخت میتواند با مقدارهایی از شکلهای مختلف کار کنند مادامیکه این شکلها رابطی که کد انتظارش را دارد پشتیبانی کند.
در فصل 4 اشاره کردم که با استفاده از حلقهی for
/of
میتوان ساختارهای دادهی مختلف را پیمایش کرد. این یک مورد دیگر از چندریختی محسوب میشود – این گونه حلقهها از ساختار داده انتظار دارند که رابط خاصی را در دسترس حلقه قرار دهند، رابطی که آرایهها و رشتهها فراهم میسازند. و شما نیز میتوانید این رابط را به اشیاء خودتان اضافه کنید! اما قبل از اینکه بتوانیم این کار را بکنیم، لازم است بدانیم که سمبل (symbol) چیست.
سمبلها (Symbols)
میتوان برای چندین رابط مختلف از نام خاصیت یکسانی برای کارهای متفاوت استفاده کرد. به عنوان مثال، من میتوانم رابطی تعریف کنم که در آن متد toString
به صورت فرضی، یک شیء را به یک تکه ریسمان تبدیل کند. اما یک شیء نمیتواند هم از رابطی که تعریف کردهایم و هم پیادهسازی استاندارد toString
مطابقت کند.
این کار نه ایدهی خوبی است و نه مشکل رایجی محسوب میشود. بیشتر برنامهنویسان جاوااسکریپت به آن فکر هم نمیکنند. اما طراحان زبان، همان افرادی که شغلشان فکر کردن به همین موضوعات است، به هر حال راه حلی برای ما فراهم ساختهاند.
پیشتر که ادعا کردم نام خاصیتها از جنس رشته هستند، عبارت کاملا دقیقی استفاده نکرده بودم. بله معمولا از جنس رشتهاند اما میتوانند از نوع symbol نیز باشند. سمبلها مقادیری هستند که با تابع Symbol
ایجاد میشوند. برخلاف رشتهها، سمبلهایی که تازه ایجاد میشوند یکتا هستند – نمیتوان یک سمبل یکسان را دوبار ایجاد کرد.
let sym = Symbol("name"); console.log(sym == Symbol("name")); // → false Rabbit.prototype[sym] = 55; console.log(blackRabbit[sym]); // → 55
رشتهای که به تابع Symbol
ارسال میکنید در هنگام تبدیل سمبل به رشته استفاده میشود و میتواند شناسایی آن را مثلا هنگام نشان دادن در کنسول سادهتر کند. فارغ از آن معنای دیگری ندارد – میتوان چندین سمبل را با یک نام تعریف کرد.
منحصر به فرد بودن و امکان استفاده به عنوان نام یک خاصیت، باعث میشود که استفاده از سمبلها، گزینهی مناسبی برای تعریف رابطهایی باشد که میتوانند بیدردسر در کنار دیگر خاصیتها بدون توجه به نام آنها تعریف شوند.
const toStringSymbol = Symbol("toString"); Array.prototype[toStringSymbol] = function() { return `${this.length} cm of blue yarn`; }; console.log([1, 2].toString()); // → 1,2 console.log([1, 2][toStringSymbol]()); // → 2 cm of blue yarn
میتوان در تعریف شیء یا کلاس، خاصیتهایی که نامشان از جنس symbol است را با قراردادن براکت دور نامشان استفاده نمود. این کار باعث میشود که مانند استفاده از براکت برای دسترسی به خاصیتها، نام خاصیت ارزیابی شود. با این کار به متغیری که یک سمبل را نگهداری میکند اشاره کردهایم.
let stringObject = { [toStringSymbol]() { return "a jute rope"; } }; console.log(stringObject[toStringSymbol]()); // → a jute rope
رابط تکرارکننده (Iterator Interface)
انتظار میرود شیئی که به حلقهی for
/of
داده میشود قابل تکرار باشد. یعنی دارای متدی باشد که به وسیلهی Symbol.iterator
نامگذاری شده است (مقداری از نوع سمبل که توسط خود زبان تعریف شده است و به عنوان یک خاصیت از تابع Symbol
ذخیره میشود).
وقتی این متد فراخوانی میشود، خروجی آن یک شیء خواهد بود که رابط دومی را فراهم میسازد، رابط تکرارکننده، رابطی که عمل تکرار را انجام میدهد. این تکرارکننده دارای متدی به نام next
است که نتیجهی بعدی را برمیگرداند. این نتیجه باید یک شیء با خاصیتی به نام value
باشد که مقدار بعدی را در صورت وجود، در دسترس قرار میدهد و خاصیت دیگری به نام done
دارد که در صورت نبود نتیجهای دیگر، برابر با true خواهد بود و در غیر این صورت مقدار false را خواهد داشت.
توجه داشته باشید که نام خاصیتهای next
، value
و done
از نوع رشتهی ساده است نه از جنس سمبل. فقط Symbol.iterator
است که در واقع از جنس سمبل است و احتمالا به اشیاء متفاوت زیادی اضافه خواهد شد.
میتوانیم مستقیما از این رابط استفاده کنیم.
let okIterator = "OK"[Symbol.iterator](); console.log(okIterator.next()); // → {value: "O", done: false} console.log(okIterator.next()); // → {value: "K", done: false} console.log(okIterator.next()); // → {value: undefined, done: true}
بیایید یک ساختار قابل تکرار را پیادهسازی کنیم. در مثال زیر یک کلاس ماتریس خواهیم ساخت که مانند یک آرایهی دوبعدی عمل میکند.
class Matrix { constructor(width, height, element = (x, y) => undefined) { this.width = width; this.height = height; this.content = []; for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { this.content[y * width + x] = element(x, y); } } } get(x, y) { return this.content[y * this.width + x]; } set(x, y, value) { this.content[y * this.width + x] = value; } }
کلاس بالا محتوای خود را در یک آرایه به تعداد عناصر width × height ذخیره میکند. عناصر به صورت ردیف به ردیف ذخیره میشوند، بنابراین به عنوان مثال عنصر سوم در ردیف پنجم در موقعیت 4 × width + 2 ذخیره میشود (با در نظر داشتن اندیسگذاری از صفر).
تابع سازنده، یک طول، یک عرض و تابعی اختیاری برای محتوا میگیرد که این تابع برای مقداردهی اولیه استفاده میشود. متدهای get
و set
برای به روزرسانی عناصر و دریافت آنها در ماتریس تعریف شدهاند.
در زمان پیمایش یک ماتریس، معمولا دانستن موقعیت عناصر به اندازهی خود عناصر مهم هستند، بنابراین تکرارکنندهی ما، اشیائی با خاصیتهای x
و y
و value
تولید میکند.
class MatrixIterator { constructor(matrix) { this.x = 0; this.y = 0; this.matrix = matrix; } next() { if (this.y == this.matrix.height) return {done: true}; let value = {x: this.x, y: this.y, value: this.matrix.get(this.x, this.y)}; this.x++; if (this.x == this.matrix.width) { this.x = 0; this.y++; } return {value, done: false}; } }
آمار پیشرفت تکرار در طول یک ماتریس توسط خاصیتهای x
و y
ضبط و ثبت میشود. متد next
با بررسی اینکه آیا به انتهای ماتریس رسیدهایم یا خیر شروع میشود. اگر به پایان نرسیده بود، ابتدا شیئی را ایجاد میکند که مقدار فعلی را نگه داری کند و سپس موقعیت آن را به روزرسانی میکند و در صورت نیاز به سراغ ردیف بعدی میرود.
بیایید کلاس Matrix
را قابل تکرار کنیم. در این کتاب، گاهی بعد از تعریف کلاسها، prototype را دستکاری خواهم کرد تا متدهایی را به آنها اضافه کنم، در نتیجه کدها مجزا و کوچک خواهند ماند و به دیگر قسمتها وابسته نخواهند شد. در یک برنامهی معمولی، جایی که نیازی نیست تا کدها را به قسمتهای کوچکتر تقسیم کنیم، میتوانید این متدها را مستقیما درون بدنه کلاس تعریف کنید.
Matrix.prototype[Symbol.iterator] = function() { return new MatrixIterator(this); };
اکنون میتوانیم یک ماتریس را به وسیلهی for
/of
پیمایش کنیم.
let matrix = new Matrix(2, 2, (x, y) => `value ${x},${y}`); for (let {x, y, value} of matrix) { console.log(x, y, value); } // → 0 0 value 0,0 // → 1 0 value 1,0 // → 0 1 value 0,1 // → 1 1 value 1,1
گیرندهها(getters) ، گذارندهها(setters) و متدهای ایستا (static)
رابطها بیشتر از متدها تشکیل شدهاند، اما میتوانند خاصیتهایی که مقادیر تابع را نگه داری نمیکنند را نیز داشته باشند. به عنوان مثال، اشیاء Map
دارای خاصیتی به نام size
میباشند که تعداد کلیدهایی که در آنها ذخیره شده است را نگه داری میکند.
در اینگونه اشیاء لزومی ندارد خاصیتی مثل size
را مستقیما در خود نمونه شیء محاسبه و ذخیره نمود. حتی خاصیتهایی که به صورت مستقیم در دسترس هستند، ممکن است متدی را مخفیانه فراخوانی کنند. این گونهی خاصیتها را getter یا گیرنده میگویند که به وسیلهی نوشتن get
در ابتدای نام یک متد، در یک عبارت تعریف شیء یا کلاس، تعریف میشوند.
let varyingSize = { get size() { return Math.floor(Math.random() * 100); } }; console.log(varyingSize.size); // → 73 console.log(varyingSize.size); // → 49
زمانی که کسی مقدار خاصیت size
را درخواست میکند، متدی که به آن پیوند خورده است فراخوانی میشود. میتوانید کار مشابهی را برای مقدار دهی به یک خاصیت هم انجام دهید که به آن setter یا گذارنده میگویند.
class Temperature { constructor(celsius) { this.celsius = celsius; } get fahrenheit() { return this.celsius * 1.8 + 32; } set fahrenheit(value) { this.celsius = (value - 32) / 1.8; } static fromFahrenheit(value) { return new Temperature((value - 32) / 1.8); } } let temp = new Temperature(22); console.log(temp.fahrenheit); // → 71.6 temp.fahrenheit = 86; console.log(temp.celsius); // → 30
کلاس Temperature
در مثال بالا این امکان را فراهم میکند تا میزان دما را به صورت سلسیوس یا فارنهایت بنویسید، اما در درون کلاس، این مقدار فقط به سلسیوس ذخیره میشود و به طور خودکار توسط متدهای گیرنده و گذارنده (setter، getter) به فارنهایت تبدیل میشود.
گاهی اوقات میخواهید تا بعضی خاصیتها را به جای prototype، به طور مستقیم در تابع سازنده داشته باشید. این گونه متدها به نمونهی کلاس دسترسی نخواهند داشت اما میتوان از آنها به عنوان روشهای دیگر ایجاد نمونهها استفاده کرد.
درون تعریف یک کلاس، متدهایی که کلیدواژهی static
در ابتدای آنها نوشته میشود، روی تابع سازنده ذخیره میشوند. بنابراین در کلاس Temperature
میتوانید برای تولید دما به وسیلهی درجهی فارنهایت از Temperature.
استفاده کنید.
ارثبری (inheritance)
بعضی از ماتریسها را به عنوان ماتریسهای متقارن میشناسند. اگر یک ماتریس متقارن را حول قطر بالا-چپ-به-پایین-راست بازتاب کنید، تفاوتی در شکل آن ایجاد نمیشود. به بیان دیگر، مقدار موجود در x,y همیشه مشابه مقدار y,x است.
تصور کنید که به یک ساختار داده مانند Matrix
نیاز داریم با این شرط که متقارن بودن و ماندن ماتریس را ضمانت کند. میتوانیم چنین ساختار دادهای را از صفر بنوسیم، اما این کار باعث میشود که کدهایی را تکرار کنیم که قبلا شبیهشان را نوشتهایم.
سیستم prototype در جاوااسکریپت این امکان را فراهم کرده است که یک کلاس جدید را بر اساس یک کلاس دیگر اما با بازتعریف بعضی از خاصیتهای آن ایجاد کنیم. prototype کلاس جدید از prototype کلاس قبلی مشتق میشود اما به عنوان مثال، تعریف جدیدی را برای متد set
آن در نظر میگیرد.
در اصطلاح برنامهنویسی شیءگرا به این کار ارث بری میگویند. کلاس جدید خاصیتها و رفتار را از کلاسی دیگر به ارث میبرد.
class SymmetricMatrix extends Matrix { constructor(size, element = (x, y) => undefined) { super(size, size, (x, y) => { if (x < y) return element(y, x); else return element(x, y); }); } set(x, y, value) { super.set(x, y, value); if (x != y) { super.set(y, x, value); } } } let matrix = new SymmetricMatrix(5, (x, y) => `${x},${y}`); console.log(matrix.get(2, 3)); // → 3,2
استفاده از واژهی extends
به این معنا است که این کلاس نباید بر اساس prototype پیشفرض Object
ساخته شود بلکه بر اساس کلاس دیگری خواهد بود. این کلاس را superclass (کلاس والد) مینامند. کلاسی که از آن گرفته میشود را subclass (زیرکلاس) میگویند.
برای مقداردهی اولیه یک نمونه از SymmetricMatrix
، سازنده، تابع سازندهی کلاس والدش را به وسیلهی کلیدواژهی super
فراخوانی میکند. این کار لازم است به این دلیل که اگر این شیء جدید قرار است شبیه Matrix
(بهطورکلی) رفتار کند، به خاصیتهایی که ماتریسها دارند نیاز پیدا خواهد کرد. برای اطمینان از متقارن بودن ماتریس، تابع سازنده، متد element
را در بر میگیرد تا مختصات را برای مقادیر پایین قطر اصلی جابجا کند.
متد set
دوباره از super
استفاده میکند، اما این بار هدف، فراخوانی سازندهاش نیست. بلکه برای فراخوانی یک متد خاص از متدهای کلاس والد (superclass) میباشد. متد set
را بازنویسی می کنیم اما می خواهیم از رفتار اصلی آن استفاده کنیم. به دلیل اینکه this.set
به متد set
جدید اشاره میکند، نمیتوان از آن استفاده کرد. درون متدهای کلاس، کلیدواژهی super
راهی فراهم می سازد تا متدهایی که در کلاس والد تعریف شده اند را بتوان فراخوانی کرد.
با کمک ارثبری میتوانیم با کار نسبتا کمتری، نوع دادههای متفاوتی از انواع دادهی موجود بسازیم. درکنار کپسولهسازی و چندریختی، ارثبری یکی از مفاهیم اساسی برنامهنویسی شیءگرا میباشد. البته دو ویژگی اول را عموما به عنوان ایدههایی فوقالعاده میشناسند اما دربارهی ارثبری اختلاف نظرهایی وجود دارد.
در حالیکه کپسولهسازی و چندریختی را میتوان برای جداسازی کدها و کاهش نابسامانی کل برنامه استفاده کرد، ارثبری اساسا کلاسها را به هم وابسته میکند و به شکلی باعث ایجاد درهمریختگی بیشتر میشود. زمانی که از کلاسی ارث میبرید، نسبت به حالتی که فقط قصد استفاده از آن را دارید، باید اطلاعات بیشتری از نحوهی کارکرد آن کلاس داشته باشید. ارثبری میتواند ابزار مفیدی باشد و من گاهی از آن در برنامههایم استفاده میکنم، اما نباید اولین گزینهای باشد که به سراغش میروید. و احتمالا ایدهی خوبی نیست که به دنبال فرصتهایی باشید که در آنها سلسله مراتبی از کلاسها را ایجاد کنید (مثل شجرهنامهای از کلاسها).
عملگر instanceof
گاهی لازم است بدانیم که یک شیء از کلاس خاصی مشتق شده است یا خیر. برای این منظور، جاوااسکریپت یک عملگر دودویی به نام instanceof
در نظر گرفته است.
console.log( new SymmetricMatrix(2) instanceof SymmetricMatrix); // → true console.log(new SymmetricMatrix(2) instanceof Matrix); // → true console.log(new Matrix(2, 2) instanceof SymmetricMatrix); // → false console.log([1] instanceof Array); // → true
این عملگر انواع وارث را مورد کنکاش قرار میدهد، مثلا SymmetricMatrix
نمونهای از Matrix
است. این عملگر را همچنین میتوان برای سازندههای استاندارد مثل Array
نیز استفاده کرد. تقریبا همهی اشیاء نمونهای از Object
هستند.
خلاصه
بنابراین اشیاء کاری بیش از نگهداری خاصیتهای خود انجام میدهند. اشیاء prototype دارند که خود نیز اشیاء دیگری میباشند. تا زمانی که prototype یک شیء خاصیتی را داشته باشد، آن شیء نیز دارای آن خاصیت خواهد بود با وجود اینکه در ظاهر فاقد آن است. prototype اشیاء معمولی، Object.prototype
میباشد.
میتوان از توابع سازنده که معمولا نامشان با حروف بزرگ شروع میشود، با استفاده از کلیدواژهی new
، برای ایجاد اشیاء جدید استفاده کرد. prototype شیء ایجاد شده، شیئی است که در خاصیت prototype
سازنده پیدا میشود. میتوان از این ویژگی برای قرار دادن همهی خاصیتهای مشترک یک نوع خاص در prototype آن بهره برد. روش دیگری برای تعریف یک سازنده و prototype آن وجود دارد که از کلیدواژهی class
استفاده میکند.
میتوانید با تعریف گذارندهها (setters) و گیرندهها (getters)، به طور مخفیانه متدهایی را ایجاد کنید که با هر بار دسترسی به یک خاصیت شیء، فراخوانی میشوند. متدهای ایستا (static) متدهایی هستند که در سازندهی کلاس ذخیره میشوند نه در prototype آن.
عملگر instanceof
را اگر به یک شیء و یک سازنده اعمال کنید، به شما خواهد گفت که آن شیء نمونهای از آن سازنده میباشد یا خیر.
یکی از کارهای مفیدی که میتوان با اشیاء انجام داد این است که یک رابط برای آنها مشخص نمود که دیگران فقط بتوانند از طریق آن رابط با شیء ارتباط برقرار کنند. با این کار، دیگر جزئیات مربوط به ساختار شیء شما کپسوله شده و پشت رابط مخفی میمانند.
اشیائی با انواع مختلف میتوانند رابط یکسانی را پیادهسازی و استفاده کنند (توسط رابط یکسانی به کار گرفته شوند). کدی که برای استفاده از یک رابط نوشته شده است به صورت خودکار میداند که چگونه با هر تعداد شیء متفاوت که آن رابط را دارند کار کند. این کار چندریختی نامیده میشود.
زمانی که چندین کلاس را پیادهسازی میکنیم که تنها در بعضی جزئیات با هم تفاوت دارند، میتوانیم کلاسهای جدید را به عنوان زیرکلاسهای کلاسهای موجود بنویسیم که بعضی از رفتارهای آنها را به ارث ببرند.
تمرینها
تعریف یک نوع بردار
کلاسی به نام Vec
تعریف کنید که نشاندهندهی یک بردار در فضای دوبعدی باشد. این کلاس دو عدد x
و y
را به عنوان پارامتر (عددی) دریافت میکند، که با همین نامها به عنوان خاصیت ذخیره میشوند.
به پروتوتایپ Vec
دو متد plus
و minus
را اضافه کنید که بردار دیگری را به عنوان یک پارامتر گرفته و بردار جدیدی را برمیگرداند، برداری که تفاوت (minus) یا مجموع (plus) مقدارهای x و y دو بردار (this
و پارامترها) را باز میگرداند.
خاصیت گیرندهای (getter) به نام length
به prototype اضافه کنید که طول بردار را محاسبه میکند – فاصلهی بین نقطهی (x,y) از مبدا (0,0).
// Your code here. console.log(new Vec(1, 2).plus(new Vec(2, 3))); // → Vec{x: 3, y: 5} console.log(new Vec(1, 2).minus(new Vec(2, 3))); // → Vec{x: -1, y: -1} console.log(new Vec(3, 4).length); // → 5
اگر تعریف کلاس را فراموش کرده اید، به مثال کلاس Rabbit
مراجعه کنید.
افزودن یک خاصیت گیرنده (getter) به سازنده (constructor) را میتوان با قراردادن واژهی get
در ابتدای نام متد انجام داد. برای محاسبهی فاصلهی بین نقاط (0,0) و (x, y)، میتوانید از قضیهی فیثاغورث استفاده کنید که در آن توان دوم فاصلهای که مورد نظر ما است (وتر) برابر است با مجموع توانهای دوم اندازه مختصات x و y. بنابراین، √(x2 +y2) عددی است که شما لازم دارید. متد Math.sqrt
، برای محاسبهی ریشهی دوم یک عدد در جاوااسکریپت استفاده میشود.
گروهها
محیط استاندارد جاوااسکریپت ساختار دادهی دیگری به نام Set
را فراهم میکند. مانند یک نمونه از Map
، یک set (مجموعه) مجموعهای از مقدارها را نگه داری میکند. برخلاف Map
، این ساختار داده مقادیر را با هم مرتبط نمیکند – فقط مشخص میکند که کدام مقادیر در مجموعه وجود دارند. یک مقدار فقط میتواند یکبار به مجموعه اضافه شود – اگر دوباره یک مقدار را اضافه کنیم، اثری نخواهد داشت.
کلاسی به نام Group
(زیرا Set
قبلا رزرو شده است) تعریف کنید. شبیه Set
، این کلاس متدهای add
، delete
و has
را دارد. سازندهی این کلاس یک گروه (group) خالی ایجاد میکند، متد add
، یک مقدار را به گروه اضافه میکند (البته اگر قبلا عضو گروه نبود)، متد delete
آرگومان ورودیاش را از گروه حذف میکند (البته اگر وجود داشت)، و متد has
، یک مقدار بولی برمیگرداند که نشان میدهد آرگومانش در مجموعه وجود دارد یا خیر.
برای محاسبهی تشابه دو مقدار، از عملگر ===
یا چیزی مشابه مانند indexOf
، استفاده کنید.
به کلاس متد استاتیکی به نام from
اضافه کنید که شیئی قابل شمارش را به عنوان آرگومان دریافت میکند و گروهی ایجاد میکند که دارای تمامی مقادیری است که با پیمایش شیء بدست میآید.
class Group { // Your code here. } let group = Group.from([10, 20]); console.log(group.has(10)); // → true console.log(group.has(30)); // → false group.add(10); group.delete(10); console.log(group.has(10)); // → false
آسانترین روش انجام این تمرین این است که آرایهای از اعضای گروه را در یک خاصیت ذخیره کنید. متدهای includes
یا indexOf
را میتوان برای بررسی وجود یک مقدار در آرایه استفاده کرد.
سازندهی کلاس شما میتواند مجموعهی اعضا را درون یک آرایه قرار دهد. هنگام فراخوانی add
، متد باید ابتدا وجود مقدار در آرایه را بررسی کند و بعد آن را اضافه کند. این کار را میشود با متد push
انجام داد.
حذف یک عنصر از یک آرایه به وسیلهی دستور delete
نیاز به ملاحظات بیشتری دارد. به جای آن میتوانید از متد filter
برای ایجاد آرایهای جدید استفاده کنید که عنصر مورد نظر را ندارد. فراموش نکنید که خاصیتی که اعضای گروه را نگهداری میکند با آرایهی جدید بهروز شود.
متد from
میتواند از یک حلقهی for
/of
برای گرفتن مقدارها از یک شیء قابل تکرار استفاده کند و با فراخوانی add
آنها را درون گروه تازه ایجاد شده قرار دهد.
گروههای قابل تکرار
کلاس Group
را که در مثال قبل ایجاد کردید اکنون قابل تکرار کنید. اگر در مورد شکل و ساختار رابط سوال دارید، میتوانید به بخشی که پیشتر در همین فصل دربارهی رابط تکرارکننده آمد مراجعه نمایید.
اگر از یک آرایه برای نمایش اعضای گروه استفاده کردهاید، فقط به بازگرداندن تکرارکنندهای که با فراخوانی متد Symbol.iterator
روی آرایه ایجاد شده است اکتفا نکنید. این روش کار خواهد کرد اما شما را از هدف این تمرین منحرف میکند.
اگر در هنگام انجام تکرار، گروه تغییر کند، ممکن است تکرارکنندهی شما رفتار عجیبی بروز دهد که نیازی نیست به آن توجه کنید.
// Your code here (and the code from the previous exercise) for (let value of Group.from(["a", "b", "c"])) { console.log(value); } // → a // → b // → c
احتمالا ارزشش را دارد که کلاس جدیدی به نام GroupIterator
تعریف شود. نمونههای تکرارکننده باید خاصیتی برای رصد موقعیت فعلی در گروه داشته باشند. هر بار که next
فراخوانی میشود، این تابع رسیدن به پایان را بررسی میکند که در غیر این صورت، از مقدار فعلی عبور کرده و آن را برمیگرداند.
کلاس Group
خودش متدی به نام Symbol.iterator
میگیرد که در صورت فراخوانی، نمونهای جدید از کلاس تکرارکننده را برای آن گروه برمیگرداند.
قرض گرفتن یک متد
پیشتر در این فصل گفته شد که متد hasOwnProperty
را میتوان به عنوان جایگزینی کاراتر نسبت به عملگر in استفاده کرد البته در شرایطی که قصد دارید تا خاصیتهای prototype را درنظر نگیرید. اما اگر بخواهید در شیءتان کلید "hasOwnProperty"
را داشته باشید چه؟ در این صورت دیگر نمیتوانید آن متد را فراخوانی کنید چراکه خاصیت خود شیء باعث میشود که آن مخفی شود.
آیا میتوانید راهی پیدا کنید که بتوان hasOwnProperty
را روی یک شی فراخونی کرد، شیئی که خاصیتی با همین نام دارد؟
let map = {one: true, two: true, hasOwnProperty: true}; // Fix this call console.log(map.hasOwnProperty("one")); // → true
به یاد داشته باشید که متدهایی که در یک شیء معمولی وجود دارند از Object.prototype
میآیند. همچنین در نظر داشته باشید که میتوانید یک تابع را با یک this
خاص به وسیلهی متد call
توابع، فراخوانی کنید.