فصل 3توابع
مردم تصور میکنند که دانش و مهارت کامپیوتر مخصوص نابغهها است اما واقعیت برعکس است، فقط افراد زیادی روی ساختههای یکدیگر کار میکنند، مثل دیواری که از سنگهای کوچک ساخته میشود.
توابع یکی از پایههای اصلی برنامهنویسی به زبان جاوااسکریپت میباشند. مفهوم گذاشتن بخشی از برنامه درون یک مقدار، کاربردهای بسیاری دارد. میتوان از این مفهوم برای ساختاردهی به برنامههای بزرگتر استفاده کرد، از تکرار پرهیز نمود، به زیربرنامهها نام اختصاص داد و آنها را به شکلی مستقل مجزا کرد.
استفاده از توابع برای تعریف واژهی جدید، واضحترین کاربرد آنها میباشد. خلق واژهای جدید در یک نثر، معمولا سبک خوبی نیست اما در برنامهنویسی این کار اجتناب ناپذیر است.
به طور معمول، انگلیسی زبانان بزرگسال در دامنهی واژگانشان حدود 20,000 کلمه دارند. زبانهای برنامهنویسی کمی وجود دارند که 20,000 دستور از پیش تعریف شده داشته باشند. البته این دستورات یا واژگانی که در دسترس هستند نیز با دقت تعریف میشوند که در نتیجه انعطاف کمتری نسبت به زبانهای بشری دارند. بنابراین، معمولا لازم است تا مفاهیم جدیدی را برای اجتناب از تکرار بیمورد تعریف کنیم.
تعریف یک تابع
تعریف یک تابع همان تعریف عادی یک انتساب (متغیر) است با این تفاوت که مقداری که به متغیر اختصاص داده میشود، از جنس تابع است. به عنوان مثال، کد پیش رو متغیر square
را تعریف میکند که به تابعی ارجاع میدهد که مربع عدد داده شده را تولید میکند:
const square = function(x) { return x * x; }; console.log(square(12)); // → 144
یک تابع را میتوان به وسیلهی یک عبارت که با کلیدواژهی function
شروع میشود ایجاد کرد. توابع دارای مجموعهای از پارامترها (در مثال بالا فقط x
) و یک بدنه میباشند. بدنه خود حاوی دستوراتی است که در صورت فراخوانی تابع اجرا میشوند. بدنهی تابع باید همیشه درون کروشهها قرار گیرد حتی زمانی که فقط حاوی یک دستور است (مانند مثال قبل).
یک تابع میتواند چندین پارامتر داشته باشد یا هیچ پارامتری نداشته باشد. در مثال پیش رو، تابع makeNoise
هیچ پارامتری ندارد در حالیکه تابع power
دو پارامتر دارد:
const makeNoise = function() { console.log("Pling!"); }; makeNoise(); // → Pling! const power = function(base, exponent) { let result = 1; for (let count = 0; count < exponent; count++) { result *= base; } return result; }; console.log(power(2, 10)); // → 1024
بعضی توابع مقداری را برمی گردانند، مانند تابع power
و square،
و بعضی توابع مانند makeNoise
فقط اثر جانبی تولید میکنند و مقداری را باز نمیگردانند. دستوری به نام return
مسئول بازگرداندن یک مقدار از تابع است. زمانی که برنامه به این دستور میرسد، به سرعت از تابع فعلی خارج شده و مقدار "برگردانده شده" را به قسمتی از برنامه که تابع در آنجا فراخوانی شده، ارسال میکند. استفاده از دستور return
بدون عبارتی بعد از آن، باعث میشود که تابع مقدار undefined
را برگرداند. توابعی که اصلا دستور return
را ندارند مانند makeNoise
نیز مقدار undefined
را برمیگردانند.
پارامترها در توابع درست شبیه به متغیرهای عادی رفتار میکنند اما مقدار اولیه آنها توسط فراخواننده تابع مقداردهی میشود نه کدی که در بدنه تابع نوشته میشود.
متغیرها و قلمروی آنها (Scope)
هر متغیری دارای یک قلمروی (scope) دسترسی است که عبارت است از بخشی از برنامه که در آن، متغیر در دسترس و قابل مشاهده است. برای متغیرهایی که بیرون از یک تابع یا یک بلاک تعریف شدهاند، این قلمرو شامل کل برنامه میشود – میتوانید به این متغیرها در هرجای برنامه دسترسی داشته باشید. به این متغیرها متغیرهای سراسری (global) گفته میشود.
اما متغیرهایی که برای پارامترهای توابع ایجاد میشوند یا آنهایی که درون یک تابع تعریف میشوند، فقط در درون همان تابع قابل ارجاع میباشند، به همین علت به آنها متغیرهای محلی گفته میشود. هر بار که تابعی فراخوانی میشود، یک نمونه از این متغیرها ایجاد میشود. این کار باعث میشود که توابع به نوعی نسبت به هم مجزا (ایزوله) شوند - هر فراخوانی تابع در فضای اختصاصی خودش عمل میکند (محیط محلی خودش) و معمولا میتوان این نحوهی عملکرد را از برنامه متوجه شد و نیازی نیست جزئیات مرتبط با آن را در محیط اجرایی بدانیم.
متغیرهایی که با let
یا const
تعریف میشوند در واقع نسبت به بلاکی که درون آن تعریف شدهاند محلی هستند (متعلق به همان بلاک میباشند)، بنابراین اگر یکی از آنها را در درون یک حلقه تعریف کنید، کدی که قبل و بعد از حلقه قرار گرفته نمیتواند آن را "ببیند". در نسخههای قبل از 2015 جاوااسکریپت، فقط توابع بودند که میتوانستند قلمرو یا حوزهی جدیدی ایجاد کنند، بنابراین متغیرهای قدیمی که با var
تعریف میشدند، در تابعی که در آن تعریف شده بودند، دیده میشدند. اگر هم درون یک تابع نبودند، در سراسر برنامه در دسترس بودند.
let x = 10; if (true) { let y = 20; var z = 30; console.log(x + y + z); // → 60 } // y is not visible here console.log(x + z); // → 40
هر قلمرو میتواند قلمروی پیرامونش را ببیند، مثلا در برنامهی بالا، متغیر x
در درون بلاک قابل مشاهده است. یک استثناء در اینجا زمانی رخ میدهد که چندین متغیر با یک نام وجود داشته باشند که در این صورت، کد برنامه میتواند درونیترین مورد را ببیند. به عنوان مثال، در تابع halve
مثال پایین، زمانی که کد درون تابع به متغیر n
اشاره میکند، متغیر n
خود تابع استفاده میشود نه متغیر سراسری n
.
const halve = function(n) { return n / 2; }; let n = 10; console.log(halve(100)); // → 50 console.log(n); // → 10
قلمروی تودرتو
جاوااسکریپت تنها بین متغیرهای سراسری و محلی تفاوت قائل نمیشود. میتوان توابع و بلاکها را نیز به شکل تودرتو ایجاد کرد که باعث ایجاد درجههای مختلفی از محلی بودن میگردد.
به عنوان مثال، این تابع – که مواد لازم برای تهیه حمص (نوعی غذا) را برمی گرداند – تابعی دیگر در درون خود دارد:
const hummus = function(factor) { const ingredient = function(amount, unit, name) { let ingredientAmount = amount * factor; if (ingredientAmount > 1) { unit += "s"; } console.log(`${ingredientAmount} ${unit} ${name}`); }; ingredient(1, "can", "chickpeas"); ingredient(0.25, "cup", "tahini"); ingredient(0.25, "cup", "lemon juice"); ingredient(1, "clove", "garlic"); ingredient(2, "tablespoon", "olive oil"); ingredient(0.5, "teaspoon", "cumin"); };
کدی که در تابع ingredient
قرار دارد میتواند متغیر factor
را از تابع بیرونی ببیند. اما متغیرهای محلیاش مانند unit
یا ingredientAmount
توسط تابع بیرونی قابل مشاهده نیستند.
مجموعهی متغیرهایی که در درون یک بلاک قابل رویت میباشند بستگی به مکان بلاک در متن برنامه دارند. هر قلمروی محلی همچنین میتواند قلمروهای محلیای را که آن را در بر گرفتهاند ببیند و همهی قلمروها میتوانند قلمروی سراسری را ببینند. این روش رویتپذیری متغیر را حوزهبندی لغوی (Lexical Scoping) مینامند.
استفاده از توابع به عنوان مقدار
متغیری که به یک تابع منتسب شده است، معمولا به سادگی، نقش یک نام را برای قطعهای از برنامه، بازی میکند. این گونه متغیرها یک بار تعریف شده و دیگر تغییری نمییابند. این امر ممکن است باعث شود که نام تابع با خود تابع اشتباه گرفته شود.
اما این دو متفاوتند. یک مقدار تابع میتواند همهی چیزهایی که دیگر مقدارها، انجام میدهند را انجام دهد – به جز فراخوانی معمولی، میتوانید از آنها در عبارتها استفاده کنید. میتوان مقدار تابع را در متغیر جدیدی ذخیره کرد، به عنوان آرگومان به یک تابع فرستاد و موارد مشابه. همچنین متغیری که یک تابع را نگه داری میکند هنوز فقط یک متغیر عادی است و میتوان مقدار جدیدی را به آن اختصاص داد البته اگر به عنوان ثابت تعریف نشده باشد. مثلا:
let launchMissiles = function() { missileSystem.launch("now"); }; if (safeMode) { launchMissiles = function() {/* do nothing */}; }
در فصل 5، در بارهی کارهای جالبی بحث خواهیم کردکه میتوان به وسیلهی ارسال توابع به عنوان مقدار به دیگر توابع انجام داد.
استفاده از روش اعلان تابع
یک روش نسبتا کوتاهتر نیز برای ایجاد یک تابع وجود دارد. در صورت استفاده از کلیدواژهی function
در ابتدای یک دستور، این دستور به شکل متفاوتی عمل میکند.
function square(x) { return x * x; }
این روش، تعریف تابع با اعلان میباشد. این دستور متغیری به نام square
را تعریف کرده و آن را به تابع داده شده انتساب میدهد. تعریف تابع به این روش اندکی سادهتر به نظر میرسد و در این روش نیازی به استفاده از نقطهویرگول بعد از تعریف تابع نمیباشد.
در این روش تعریف تابع، نکتهی ظریفی وجود دارد که لازم است به آن توجه شود.
console.log("The future says:", future()); function future() { return "You'll never have flying cars"; }
کد بالا به درستی کار میکند اگرچه تعریف تابع پایینتر از خطی که آن را فراخوانی میکند اتفاق افتاده است. دلیل این اتفاق این است که اعلانهای توابع در جاوااسکریپت، به عنوان بخشی از جریان کنترل بالا به پایین عادی برنامه محسوب نمیشوند. به طور مفهومی به بالای قلمروی خودشان منتقل میشوند و میتوان از آنها در همهی کدهای موجود در آن قلمرو استفاده کرد. این ویژگی گاهی کاربرد دارد چون این آزادی را به ما میدهد تا به شکلی کدها را مرتب کنیم که بهتر سازماندهی بشوند بدون اینکه نگران این باشیم که توابع ما حتما قبل از محل فراخوانیشان تعریف شده باشند.
توابع پیکانی (Arrow functions)
روش سومی هم برای تعریف توابع وجود دارد، که با روشهای قبلی خیلی متفاوت به نظر میرسد. به جای استفاده از کلیدواژهی function
از یک پیکان (=>
) استفاده میشود که از علامت مساوی و بزرگتر تشکیل شده است. (نباید با عملگر بزرگتر-یا-مساوی-از اشتباه گرفته شود، که به شکل >=
نوشته میشود)
const power = (base, exponent) => { let result = 1; for (let count = 0; count < exponent; count++) { result *= base; } return result; };
کاراکتر پیکان درست بعد از لیستی از پارامترها میآید که بعد از آن بدنهی تابع خواهد آمد. میتوان به نوعی این طور تفسیرش کرد که " این ورودی (این پارامترها)، این نتیجه (بدنهی تابع) را تولید خواهد کرد".
زمانی که تنها یک پارامتر وجود دارد، میتوانید از نوشتن پرانتز صرف نظر کنید. اگر بدنهی تابع فقط شامل یک عبارت است، نه یک بلاک که توسط کروشهها محصور شده، آن عبارت توسط تابع برگردانده میشود. بنابراین هر دو روش تعریف تابع square
در زیر، کار مشابهی را انجام میدهند:
const square1 = (x) => { return x * x; }; const square2 = x => x * x;
زمانی که یک تابع پیکانی (arrow function) فاقد پارامتر است، لیست پارامترهایش به صورت یک جفت پرانتز خالی نوشته میشود.
const horn = () => { console.log("Toot"); };
نمیتوان دلیل محکمی برای وجود نیاز به هر دو نوع تعریف تابع (روش پیکانی و معمول)، پیدا کرد. از موارد جزئی که بگذریم (که در فصل 6 به آنها میپردازیم)، هر دوی آنها کار یکسانی را انجام میدهند. روش پیکانی در نسخهی 2015 به زبان اضافه شد؛ بیشتر به این خاطر که بتوان توابع را به شکل کوتاهتر و خلاصه نوشت و از درازنویسی پرهیز کرد. در فصل 5 از آنها زیاد استفاده خواهیم کرد.
پشتهی فراخوانی توابع
جریان کنترل برنامه در توابع اندکی پیچیدهتر است. بجاست تا نگاهی دقیقتر به نحوهی جریان کنترل در توابع بیاندازیم. در اینجا برنامهی سادهای داریم که چند فراخوانی تابع دارد:
function greet(who) { console.log("Hello " + who); } greet("Harry"); console.log("Bye");
اجرای این برنامه به طور کلی به این شکل خواهد بود: فراخوانی تابع greet
باعث میشود که کنترل برنامه به شروع بدنهی آن تابع (خط 2) بپرد. تابع console.log
(که تابعی از پیش ساخته شده در مرورگر است) را فراخوانی میکند، که کنترل را در دست گرفته، کارش را انجام میدهد و دوباره کنترل را به خط 2 باز میگرداند. سپس به انتهای تابع greet
میرسد بنابراین به جایی که در ابتدا فراخوانی شده بود باز می گردد، خط 4. خط بعدی دوباره console.log
را فراخوانی میکند. بعد از آن، برنامه به پایان خود میرسد.
میتوانیم این جریان کنترل را به صورت نمادین به این صورت نشان دهیم:
not in function in greet in console.log in greet not in function in console.log not in function
به دلیل اینکه تابع پس از اجرا باید به نقطهای که از آنجا فراخوانی شده است بازگردد، کامپیوتر باید مکانی از برنامه که تابع از آنجا فراخوانی شده است را به خاطر داشته باشد. در مورد اول، console.log
، پس از اجرا باید به تابع greet
و در مورد بعدی به انتهای برنامه برگردد.
به جایی از کامپیوتر که این محل یا زمینه (context) در آن ذخیره میشود، پشتهی فراخوانی (call stack) گفته میشود. هر بار که تابعی فراخوانی میشود، محل فعلی فراخوانی در بالای این "پشته" قرار میگیرد. زمانی که اجرای تابع تمام میشود، عنصر بالای پشته حذف میشود و از آن محل برای ادامهی اجرای برنامه استفاده خواهد شد.
ذخیره کردن این پشته نیاز به فضایی در حافظهی کامپیوتر دارد. در صورت رشد بیش از اندازهی پشته، کامپیوتر با مشکل روبرو شده و پیغام “out of stack space” یا “too much recursion” را تولید میکند. کدی که در ادامه میآید این موضوع را بیشتر باز میکند. در این مثال کامپیوتر با مسئلهی بسیار مشکلی روبرو میشود که موجب میشود به طور بینهایت بین دو تابع گیر بیفتد. اگر محدودیت حافظه برای پشته نبود، احتمالا اجرای این برنامه بینهایت میشد. اما در واقعیت، ما با کمبود فضا روبرو میشویم، یا اینکه پشته از کار خواهد افتاد.
function chicken() { return egg(); } function egg() { return chicken(); } console.log(chicken() + " came first."); // → ??
آرگومانهای اختیاری
کد مثال زیر معتبر است و بدون هیچ مشکلی کار میکند:
function square(x) { return x * x; } console.log(square(4, true, "hedgehog")); // → 16
تابع square
را فقط با یک پارامتر تعریف کردیم. اما زمانی که به شکل بالا با سه پارامتر آن را فراخوانی میکنیم، با خطایی روبرو نمیشویم. این تابع به جز آرگومان اول از دیگر آرگومانها صرف نظر میکند و مربع عدد پارامتر اول را حساب میکند.
جاوااسکریپت نسبت به تعداد آرگومانهای دریافتی، بسیار روشن فکرانه عمل میکند. اگر آرگومانهای بیشتری نسبت به آنچه از قبل تعریف شده است ارسال نمایید، به سادگی از آنها چشم پوشی میکند. اگر آرگومان کمتری ارسال کنید، به آرگومانهایی که مقداردهی نشدهاند مقدار undefined
را اختصاص میدهد.
جنبهی منفی این کار این است که ممکن است که شما ناخواسته و تصادفی تعداد اشتباهی آرگومان را به تابع ارسال کنید و اصلا متوجه آن هم نشوید.
و جنبه مثبت این است که میتوان با استفاده از آن به یک تابع اجازه داد که با آرگومانهای متعدد و متفاوتی فراخوانی شود. به عنوان مثال، تابع minus
که در ادامه میآید سعی میکند که عملگر -
را با عمل کردن روی یک یا دو آرگومان شبیهسازی کند:
function minus(a, b) { if (b === undefined) return -a; else return a - b; } console.log(minus(10)); // → -10 console.log(minus(10, 5)); // → 5
اگر بعد از نوشتن پارامتر، علامت =
قرار داده و عبارتی را بنویسید، مقدار آن عبارت در صورتی که آرگومان ارسال نشود، جایگزین میشود.
به عنوان مثال، این نسخه از تابع power
آرگومان دومش را اختیاری تعریف کرده است. اگر آرگومان دوم را ارسال نکنید یا اینکه مقدار undefined
را بفرستید، مقدار پیشفرض 2 در نظر گرفته میشود و تابع شبیه به تابع square
عمل میکند.
function power(base, exponent = 2) { let result = 1; for (let count = 0; count < exponent; count++) { result *= base; } return result; } console.log(power(4)); // → 16 console.log(power(2, 6)); // → 64
در فصل بعد، با روشی آشنا خواهیم شد که به وسیلهی آن میتوان در بدنهی تابع به لیست دقیق آرگومانهای ارسالی دست پیدا کرد. با استفاده از این ویژگی، میتوانیم در یک تابع به تعداد دلخواه آرگومان دریافت کنیم. مثلا تابع console.log
از این ویژگی استفاده میکند – همهی مقادیری که به آن داده میشود را به خروجی ارسال میکند.
console.log("C", "O", 2); // → C O 2
بستار (closure)
استفاده از توابع به عنوان مقدار، وقتی با این واقعیت ترکیب میشود که متغیرهای محلی با هر بار فراخوانی یک تابع از نو ایجاد میشوند، سوال جالبی را به ذهن میآورد. زمانی که آن فراخوانی تابع، که موجب ایجاد آنها شده بود، دیگر فعال نیست، برای متغیرهای محلی چه اتفاقی میافتد؟
کدی که در ادامه میآید مثالی از این مفهوم را نمایش میدهد. تابعی به نام wrapValue
را تعریف کرده که درون آن متغیری محلی ایجاد شده است. سپس تابعی را بازمیگرداند که به این متغیر محلی دسترسی داشته و آن را برمیگرداند.
function wrapValue(n) { let local = n; return () => local; } let wrap1 = wrapValue(1); let wrap2 = wrapValue(2); console.log(wrap1()); // → 1 console.log(wrap2()); // → 2
این کار مجاز و معتبر است و به خوبی کار میکند درست همانطور که انتظارش را دارید – هر دوی نمونههای متغیر مورد نظر هنوز قابل دستیابی هستند. این وضعیت مثال خوبی از این واقعیت است که متغیرهای محلی با هر با فراخوانی از نو ایجاد میشوند و فراخوانیهای مختلف به متغیرهای محلی هر فراخوانی آسیبی نمیرساند.
این ویژگی – امکان رجوع به یک نمونهی مشخص متغیری محلی در یک قلمروی بسته را – بستار یا کلوژر مینامند. تابعی که به متغیرهای محلی قلمروی پیرامونش ارجاع میدهد، یک بستار یا کلوژر نامیده میشود. این رفتار نیاز به در نظر گرفتن طول عمر متغیرها را برطرف میکند و راه را برای استفادههای خلاقانه از مقدارهای تابع باز میکند.
با کمی تغییر میتوانیم مثال قبل را به عنوان روشی برای ایجاد توابعی استفاده کنیم که عمل ضرب را با یک مقدار دلخواه انجام میدهند.
function multiplier(factor) { return number => number * factor; } let twice = multiplier(2); console.log(twice(5)); // → 10
نیازی نیست مانند مثال wrapValue
به طور صریح متغیر local
وجود داشته باشد چون پارامتر یک تابع خود یک متغیر محلی محسوب میشود.
درک و فکر کردن به برنامههایی شبیه مثال بالا نیاز به کمی تمرین دارد. یک مدل ذهنی خوب این است که فرض کنید که مقدارهای تابع، هم کد بدنهشان را نگه میدارند و هم محیطی که در آن ایجاد میشوند. وقتی فراخوانی میشوند، بدنهی تابع، محیطی که تابع در آن ایجاد شده را میبیند نه محیطی که در آن فراخوانی میشود.
در مثال، multiplier
فراخوانی شده و محیطی را ایجاد کرده است که در آن پارامتر factor
به 2 اختصاص داده شده است. مقدار تابعی که برمیگرداند، که در twice
ذخیره شده است، محیط را به خاطر میآورد. بنابراین در هنگام فراخوانی، آرگومانش را در 2 ضرب میکند.
بازگشتی
فراخوانی یک تابع توسط خودش امری کاملا معتبر است البته تا زمانی که به نحوی انجام شود که باعث سرریز پشته نشود. تابعی که خودش را فراخوانی میکند را تابع بازگشتی مینامند. بازگشت این امکان را فراهم میسازد که بعضی توابع را به سبک دیگری بتوان نوشت. به عنوان نمونه، تابع power
را به صورت بازگشتی بازنویسی میکنیم:
function power(base, exponent) { if (exponent == 0) { return 1; } else { return base * power(base, exponent - 1); } } console.log(power(2, 3)); // → 8
این روش تقریبا شبیه به روشی است که ریاضیدانان "توان" اعداد را تعریف میکنند و احتمالا این مفهوم را نسبت به استفاده از روش حلقه واضحتر بیان میکند. این تابع خودش را چندین مرتبه با توانهای کوچکتر فراخوانی میکند تا به حاصل مجموعهی ضربها برسد.
اما این روش پیادهسازی یک مشکل دارد: در پیادهسازیهای رایج جاوااسکریپت، این روش تقریبا سه برابر کندتر از روش استفاده از حلقه عمل میکند. پیمایش یک حلقهی ساده بسیار هزینهی کمتری نسبت به فراخوانی چندبارهی یک تابع دارد.
معمای انتخاب بین خوانایی کد و سرعت اجرای بهتر، مسالهی جالبی است. میتوان آن را به عنوان نوعی از مسائل مربوط به انسان پسند بودن و ماشین پسند بودن در نظر گرفت. تقریبا همهی برنامهها را میتوان سریعتر ساخت اما به بهای بزرگتر و پیچیده کردن آنها. برنامهنویس باید تصمیمی بر اساس تعادل بین این دو بگیرد.
در مورد تابع power
که پیشتر آمد، روش استفاده از حلقه همچنان قابل خواندن و فهمیدن است. زیاد توجیه ندارد که آن را با روش بازگشتی جایگزین کنیم. اگرچه گاهی یک برنامه با گونهای از مفاهیم پیچیده روبرو است که صرف نظر کردن از مقداری سرعت یا کارایی در برابر سرراستی بیشتر گزینهای جذاب به نظر میرسد.
نگرانی دربارهی سرعت اجرای برنامه میتواند شما را از مسئلهی اصلی دور کند. هنگام برنامهنویسی، شما مشغول حل مسئلهای دشوار هستید و وقتی فاکتور پیچیدهی دیگری به طور همزمان شما را نگران کند، از پیشرفت کار جلوگیری میکند.
بنابراین، همیشه در ابتدا کدی را بنویسید که به طور صحیح کار میکند و قابل درک است. اگر نگرانید که خیلی کند عمل میکند – که معمولا این طور نخواهد بود، چرا که اکثر کدها آن قدر به تعداد بالا اجرا نمیشوند که زمان قابل توجهی بگیرند – میتوانید بعد از اتمام کار، اندازهگیری کرده و در صورت نیاز آن را بهبود دهید.
نمیتوان فرض کرد که همیشه استفاده از بازگشتی نسبت به حلقه، کارایی کمتری دارد. بعضی مسائل واقعا به روش بازگشتی بهتر و کاراتر حل میشوند. اکثر این مسائل مربوط به پیمایش و پردازش چندین شاخه میشوند که هر کدام ممکن است از شاخههای دیگر تشکیل شده باشند.
به این مسئله توجه کنید: با شروع از عدد 1 و اضافه کردن عدد 5 یا ضرب کردن در عدد 3 به صورت مداوم، میتوان بینهایت عدد جدید تولید کرد. چگونه میتوانید تابعی بنویسید که عددی را گرفته و دنبالهای از ضرب و جمعهایی که منجر به تولید آن عدد شدهاند را برگرداند؟
به عنوان مثال، عدد 13 را میتوان با ضرب 1 در 3 و دو بار افزودن 5 بدست آورد در حالیکه عددی مثل 15 را اصلا نمیتوان به این شیوه تولید کرد.
راه حل استفاده از روش بازگشتی:
function findSolution(target) { function find(current, history) { if (current == target) { return history; } else if (current > target) { return null; } else { return find(current + 5, `(${history} + 5)`) || find(current * 3, `(${history} * 3)`); } } return find(1, "1"); } console.log(findSolution(24)); // → (((1 * 3) + 5) * 3)
توجه داشته باشید که برنامهی بالا لزوما کوتاهترین دنبالهی عملیات را پیدا نمیکند. هدف تابع فقط پیدا کردن هر دنبالهای از عملیات صحیح است.
اگر متوجه چگونگی کارکرد تابع بالا نشدید، نگران نباشید. بیایید با هم آن را بررسی کنیم چرا که تمرین بسیار خوبی برای تفکر بازگشتی است.
تابع درونی find
عمل اصلی بازگشتی را انجام میدهد. دو آرگومان میگیرد که شامل عدد فعلی و رشتهای است که نحوهی رسیدن به عدد را ضبط میکند. اگر راه حلی پیدا کرد، رشتهای حاوی دنبالهی عملیات تا عدد مورد نظر را برمیگرداند. اگر راه حلی وجود نداشت، مقدار null
برگردانده میشود.
برای این کار، تابع یکی از این سه کار را انجام میدهد. اگر عدد فعلی عدد هدف بود، تاریخچهی فعلی (history) به عنوان پاسخ برگردانده میشود. اگر عدد فعلی از عدد هدف بزرگتر بود، معنایی ندارد که کاوش بیشتری برای کشف تاریخچه انجام شود زیرا هر عمل جمع یا ضرب، عدد را فقط بزرگتر میکند بنابراین مقدار null
را برمیگرداند. در نهایت، اگر هنوز عدد فعلی از عدد هدف کوچکتر باشد، تابع هر دو مسیر ممکن که از عدد فعلی شروع میشود را آزمایش میکند و این کار را با دوبار فراخوانی خودش، یکبار برای جمع و یکبار برای ضرب انجام میدهد. اگر اولین فراخوانی چیزی به غیر از null
را تولید کرد، آن را برمیگرداند. در غیر این صورت، فراخوانی دوم بازگردانده میشود – فارغ از اینکه رشته یا null
را تولید کند.
اجازه دهید برای درک بهتر نحوهی عملکرد تابع، نگاهی بیاندازیم به همهی فراخوانیهای find
که برای پیدا کردن جواب مساله برای عدد 13
صورت میگیرند.
find(1, "1") find(6, "(1 + 5)") find(11, "((1 + 5) + 5)") find(16, "(((1 + 5) + 5) + 5)") too big find(33, "(((1 + 5) + 5) * 3)") too big find(18, "((1 + 5) * 3)") too big find(3, "(1 * 3)") find(8, "((1 * 3) + 5)") find(13, "(((1 * 3) + 5) + 5)") found!
تورفتگی موجود در کد بالا برای نشان دادن عمق پشتهی فراخوانی توابع است. اولین بار که find
فراخوانی میشود، خودش را برای کاوش راه حلی که با (1 + 5)
شروع میشود فراخوانی میکند. این فراخوانی با استفاده بازگشت، تمامی راه حلهایی که عددی کمتر یا برابر عدد هدف را تولید میکند را مورد کاوش قرار میدهد. با توجه به این که این فراخوانی نمیتواند به راه حلی برسد مقدار null
به عنوان خروجی فراخوانی اول بازگردانده میشود. عملگر ||
در اینجا باعث کاوش (1 * 3)
میشود. این جستجو شانس بیشتری دارد — اولین فراخوانی بازگشتی آن، توسط فراخوانی بازگشتیاش، به عدد هدف میرسد. درونیترین فراخوانی بازگشتی، یک رشته را برمیگرداند و هر کدام از عملگرهای ||
در فراخوانیهای میانی، آن رشته را دست به دست میکنند تا در نهایت راه حل برگردانده شود.
رشد توابع
برای اضافه کردن توابع به برنامه، دو حالت کم و بیش طبیعی قابل تصور است.
زمانی که متوجه میشوید کد مشابهی را چندین بار تکرار میکنید. احتمالا ترجیح میدهید که این کار را نکنید. داشتن کد بیشتر به معنای ایجاد فضای بیشتر برای پنهان شدن اشتباهات است و ایجاد کار بیشتر برای افرادی که قرار است با خواندن کدها برنامهتان را درک کنند. بنابراین قسمت تکراری را گرفته و نام خوبی برای آن انتخاب و آن را به تابع تبدیل میکنیم.
حالت دوم زمانی است که متوجه میشوید به قابلیتی در برنامه نیاز دارید که هنوز آن را ننوشتهاید و این قابلیت میتواند تابع خودش را داشته باشد. در این صورت ابتدا نامی برای این تابع در نظر میگیرید و بعدا بدنهی آن را مینویسید. ممکن است قبل از اینکه خود تابع را تعریف کنید حتی از آن در دیگر کدها استفاده کنید.
سختی پیدا کردن یک نام خوب برای یک تابع نشانه خوبی است تا بفهمیم که مفهومی که قصد داریم به تابع تبدیلش کنیم چقدر برای ما شفاف و روشن است. اجازه بدهید تا مثالی را بررسی کنیم.
قصد داریم تا برنامهای بنویسیم که دو عدد را چاپ کند: تعداد گاوها و مرغهای یک مزرعه به همراه کلمات Cows
و Chickens
بعد از آنها و نشان دادن هر دوی عددها با طول 3 رقم (استفاده از 0 برای ترازبندی).
007 Cows 011 Chickens
با توجه به مسئله به تابعی با دو آرگومان نیاز داریم. تعداد گاوها و تعداد مرغها.
function printFarmInventory(cows, chickens) { let cowString = String(cows); while (cowString.length < 3) { cowString = "0" + cowString; } console.log(`${cowString} Cows`); let chickenString = String(chickens); while (chickenString.length < 3) { chickenString = "0" + chickenString; } console.log(`${chickenString} Chickens`); } printFarmInventory(7, 11);
استفاده از.length
بعد از مقدار رشتهای، طول رشته را به ما میدهد. بنابراین، حلقه while
عمل افزودن صفر به ابتدای رشتهی اعداد را تا زمانی ادامه میدهد که حداقل طول آن سه کاراکتر بشود.
ماموریت تمام است! اما درست زمانی که قرار است برنامه را برای کشاورز مثالمان ( به همراه صورت حساب) ارسال کنیم، با ما تماس میگیرد و اعلام میکند که اخیرا پرورش خوک را نیز شروع کرده است و آیا ما میتوانیم قابلیت نرمافزار را افزایش داده تا تعداد خوکها را نیز چاپ کند؟
حتما میتوانیم. اما درست وقتی که یک بار دیگر در حال کپی و الصاق آن چهار خط هستیم، کمی صبر کرده و تجدید نظر میکنیم. باید راه بهتری برای این کار وجود داشته باشد. اولین تلاش ما اینگونه است:
function printZeroPaddedWithLabel(number, label) { let numberString = String(number); while (numberString.length < 3) { numberString = "0" + numberString; } console.log(`${numberString} ${label}`); } function printFarmInventory(cows, chickens, pigs) { printZeroPaddedWithLabel(cows, "Cows"); printZeroPaddedWithLabel(chickens, "Chickens"); printZeroPaddedWithLabel(pigs, "Pigs"); } printFarmInventory(7, 11, 3);
روش جدید به خوبی کار میکند. اما نام printZeroPaddedWithLabel
کمی ناجور به نظر میرسد. ظاهرا سه چیز مختلف – چاپ کردن (printing)، ترازکردن با صفر (zero-padding) و افزودن برچسب (adding a lable) – در یک تابع مخلوط شده است.
به جای به دوش کشیدن کل قسمت تکراری در یک تابع، اجازه دهید یک مفهوم را انتخاب کنیم.
function zeroPad(number, width) { let string = String(number); while (string.length < width) { string = "0" + string; } return string; } function printFarmInventory(cows, chickens, pigs) { console.log(`${zeroPad(cows, 3)} Cows`); console.log(`${zeroPad(chickens, 3)} Chickens`); console.log(`${zeroPad(pigs, 3)} Pigs`); } printFarmInventory(7, 16, 3);
داشتن یک تابع با یک نام خوب و واضح مثل zeroPad
کار را برای کسی که قصد دارد کد برنامه را بفهمد آسانتر خواهد کرد. همچنین میتواند در موقعیتهای بیشتری مورد استفاده قرار گیرد تا اینکه فقط مخصوص این برنامه باشد. به عنوان مثال، میتوانید از این تابع در چاپ جدولی از اعداد که به خوبی تراز شدهاند استفاده کنید.
چقدر تابعمان باید هوشمند و جامع باشد؟ میتوانیم هر چیزی بنویسیم از تابعی که عمل بسیار ساده افزودن کاراکتر برای تراز عدد در سه کاراکتر را انجام میدهد تا یک سیستم پیچیدهی عمومی قالببندی اعداد که قادر است اعداد اعشاری، منفی، ترازبندی نقطهها، فاصلهگذاری با کاراکترهای مختلف و غیره را مدیریت کند.
یک قاعدهی کاربردی در اینجا این است که هوشمندی بیشتری به تابع اضافه نکنیم مگر در حالتی که قطعا مطمئن هستیم که از آن استفاده خواهیم کرد. ممکن است وسوسه شویم که برای هرعملکرد کوچکی که نیاز داریم، چهارچوبهای (framework) عمومی بنویسیم. در مقابل این وسوسه باید مقاومت کرد. در غیر این صورت برنامه جلو نخواهد رفت و در پایان کدهای بسیاری خواهید نوشت که هرگز استفاده نخواهید کرد.
توابع و اثرات جانبی
به طور کلی میتوان توابع را به دو گروه تقسیم کرد: آنهایی که برای اثرات جانبیشان فراخوانی میشوند و آنهایی که برای مقداری که برمیگردانند استفاده میشوند. (اگرچه قطعا میتوان تابعی داشت که هم اثر جانبی داشته باشد و هم مقداری را بازگرداند).
اولین تابع کمکی که در مثال مربوط به مزرعه آمد، تابع printZeroPaddedWithLabel
بود که برای اثر جانبیاش فراخوانی شد: چاپ یک خط در خروجی. در نسخهی دوم، تابع zeroPad
برای مقداری که برمیگرداند فراخوانده شد. اینکه تابع دوم در موقعیتهای بیشتری نسبت به تابع اول کاربرد دارد تصادفی نیست. توابعی که مقدار برمیگردانند نسبت به توابعی که مستقیما اثرات جانبی خاصی را اجرا میکنند را میتوان آسانتر با روشهای جدید ترکیب کرد.
یک تابع ناب (pure) شکل خاصی از یک تابع است که مقداری را برمیگرداند و نه تنها خودش اثر جانبی ندارد، بلکه به اثرات جانبی دیگر کدها نیز وابستگی ندارد – مثلا متغیرهای سراسری که ممکن است در کدهای دیگر تغییر کنند را مورد استفاده قرار نمیدهد. یک تابع ناب ویژگی خوبش در این است که اگر با آرگومانهای ثابت و مشابهی فراخوانی شود، همیشه مقدار مشابهی را برمیگرداند (و رفتار متفاوتی انجام نمیدهد). فراخوانی تابعی با این ویژگی را میتوان بدون تغییر در معنای کد برنامه، معادل مقدار بازگشتیاش در نظر گرفت. زمانی که از صحت عملکرد یک تابع ناب مطمئن نیستید به راحتی میتوانید با فراخوانی، آن را آزمایش کنید و اگر در آن بستر (context) به درستی کار کرد، در همهی بسترها هم به درستی کار خواهد کرد. توابع غیرناب اما برای آزمایش نیاز به شرایط و پیشنیازهای بیشتری دارند.
البته نیازی نیست هنگام نوشتن توابعی که ناب نیستند احساس بدی داشته باشید یا اینکه برای حذف آنها از کدهایتان جنگی به راه بیاندازید. اثرات جانبی معمولا کاربرد خودشان را دارند. مثلا هیچ راه نابی برای نوشتن نسخهای از تابع console.log
وجود ندارد و مشخص است که console.log
بسیار مفید است. بعضی کارها با استفاده از اثرات جانبی راحتتر بیان میشوند و کارایی بیشتری دارند. بنابراین یکی از دلایل استفاده نکردن از توابع ناب میتواند موضوع سرعت محاسبه باشد.
خلاصه
این فصل به شما آموخت که چگونه توابع خودتان را بنویسید. زمانی که کلیدواژهی function
به عنوان یک عبارت استفاده میشود، میتواند یک مقدار تابع را ایجاد کند. زمانی هم که به عنوان یک دستور استفاده میشود، متغیری را اعلان میکند و یک مقدار تابع به آن منتسب میکند. روش پیکانی (arrow function) نیز راهی دیگر برای ایجاد توابع است.
// Define f to hold a function value const f = function(a) { console.log(a + 2); }; // Declare g to be a function function g(a, b) { return a * b * 3.5; } // A less verbose function value let h = a => a % 3;
نقطهی کلیدی در فهم توابع، درک مفهوم قلمروها است. هر بلاک از کد قلمروی جدیدی را ایجاد میکند. پارامترها و متغیرهایی که درون یک بلاک اعلان میشوند نسبت به آن تابع محلی هستند، و از بیرون بلاک قابل دسترسی نیستند. متغیرهایی که با کلیدواژهی var
ایجاد میشوند به شکل متفاوتی عمل میکنند – محدودهی آنها تا پایان حوزهی نزدیکترین تابع یا فضای سراسری برنامه است.
جداسازی کارهایی که برنامهی شما انجام میدهد به وسیلهی توابع مختلف، کاری مفید است. این کار باعث میشود که از تکرار بیمورد پرهیز کنید و به سازماندهی یک برنامه به وسیلهی دستهبندی آن به قسمتهایی تخصصی، کمک میکند.
تمرینها
کمینه
در فصل قبل با تابع استاندارد Math.min
که کوچکترین عدد را از بین آرگومانهای ورودی برمیگرداند آشنا شدید. خودمان هم میتوانیم این کار را برنامهنویسی کنیم. تابعی به نام min
بنویسید که دو آرگومان دریافت کرده و کوچکترین آنها را باز میگرداند.
// Your code here. console.log(min(0, 10)); // → 0 console.log(min(0, -10)); // → -10
اگر در تعریف تابع، با گذاشتن کروشهها و پرانتزها در جای درست مشکل دارید، یکی از مثالهای این فصل را کپی کرده و ویرایش نمایید.
یک تابع میتواند چندین دستور return
داشته باشد.
بازگشت
قبلا دیده بودیم که عملگر %
(باقیمانده) را میتوان برای تشخیص زوج یا فرد بودن یک عدد استفاده کرد که برای این کار با استفاده از % 2
، بخشپذیری بر 2 مورد آزمایش قرار میگرفت. در اینجا با راهی دیگر برای تشخیص زوج یا فرد بودن یک عدد صحیح مثبت آشنا میشویم:
تابع بازگشتی isEven
را با توجه به توضیحات بالا تعریف کنید. تابع باید پارامتری مثبت و از جنس اعداد صحیح دریافت کند و مقداری از جنس بولی برگرداند.
تابع را با مقادیر 50 و 75 تست کنید. بررسی کنید که اگر -1 را به آن بدهید چه خواهد شد. چرا؟ آیا میتوانید راهی برای حل مشکل پیش آمده پیدا کنید؟
// Your code here. console.log(isEven(50)); // → true console.log(isEven(75)); // → false console.log(isEven(-1)); // → ??
تابع شما احتمالا به چیزی شبیه به تابع درونی find
در مثال بازگشتی findSolution
نزدیک خواهد بود، که حاوی یک مجموعهی if
/else if
/else
بود که صحت یکی از سه حالت را آزمایش میکرد. else
آخر، که به سومین حالت مربوط میشود، عمل فراخوانی بازگشتی را انجام میدهد. هر یک از شاخهها باید یک دستور return
داشته باشند یا به شکلی مقداری را برای خروجی مهیا کنند.
زمانی که عدد منفی داده شود، تابع مکرر خودش را با عدد منفی بیشتر فراخوانی میکند، بنابراین از بازگرداندن نتیجه دورتر و دورتر میشود. در نهایت با کمبود فضای پشته روبرو شده و از کار میافتد.
شمارش دانه
برای بدست آوردن کاراکتر یا حرف Nام یک رشته، میتوانید از "string"[N]
استفاده کنید. مقداری که برگردانده میشود رشتهای است که فقط یک کاراکتر دارد. (مثلا رشتهی "b"
). کاراکتر اول در جایگاه صفرم قرار دارد. بنابراین آخرین کاراکتر در جایگاه string.
قرار میگیرد. به عبارتی دیگر، یک رشتهی دو کاراکتری طولش 2 کاراکتر است و کاراکترهایش در جایگاه 0 و 1 قرار دارند.
تابعی به نام countBs
بنویسید که رشتهای را به عنوان تنها آرگومانش میپذیرد و عددی را برمیگرداند که نشان میدهد چند کاراکتر “B” بزرگ در رشته وجود دارد.
بعد، تابعی به نام countChar
بنویسید که شبیه countBs
کار میکند اما آرگومان دومی نیز دریافت میکند که مشخصکننده کاراکتری است که باید شمرده بشود (به جای اینکه فقط B شمرده شود). تابع countBs
را بازنویسی کنید تا این ویژگی جدید را داشته باشد.
// Your code here. console.log(countBs("BBC")); // → 2 console.log(countChar("kakkerlak", "k")); // → 4
تابع شما به یک حلقه نیاز دارد که به تک تک کاراکترهای رشته بپردازد. این حلقه میتواند از شاخص صفر تا یک-عدد-کمتر-از-طول-رشته را پیمایش کند (< string.
). اگر کاراکتر موجود در موقعیت فعلی معادل کاراکتری بود که تابع به دنبال آن است، به متغیر شمارنده، 1 واحد اضافه میکند. بعد از پایان حلقه، شمارنده را میتوان بازگرداند.
حواستان باشد که همهی متغیرهایی که در تابع استفاده میشود به صورت محلی در همان تابع به وسیلهی let
یا const
تعریف شوند.