اسرار توابع جاوا اسکریپت که باید بدانید | راهنمای پیشرفته
هر برنامهنویسی با ساختار تابع آشنا است. تابعها در زبان جاوا اسکریپت جایگاه بسیار رفیعی دارند و غالباً به عنوان شهروندان درجه اول نامیده میشوند. بنابراین باید در استفاده از آنها تبحر زیادی داشته باشید، اما آیا در عمل چنین است؟ در این مقاله با برخی اسرار توابع جاوا اسکریپت آشنا خواهیم شد که همه برنامهنویسان حرفهای باید آنها را بدانند.
تابع خالص
«تابع خالص» (Pure Function) به تابعی گفته میشود که هر دو شرط زیر را داشته باشد:
با ارائه ورودیهای یکسان، همواره خروجی یکسانی ارائه کند.
در زمان اجرای تابع، هیچ عارضه جانبی رخ ندهد.
مثال یکم
1function circleArea(radius){
2 return radius * radius * 3.14
3}
زمانی که مقادیر شعاع (radius) برابر باشند، تابع فوق همواره نتیجه یکسانی بازگشت میدهد. همچنین اجرای این تابع هیچ عارضه جانبی در خارج از تابع ندارد. بنابراین آن را میتوان یک «تابع خالص» نامید.
مثال دوم
1let counter = (function(){
2 let initValue = 0
3 return function(){
4 initValue++;
5 return initValue
6 }
7})()
خروجی اجرای تابع فوق چنین است:
این تابع شمارنده هر بار نتیجه متفاوتی ارائه میکند و از این رو یک تابع خالص محسوب نمیشود.
مثال سوم
1let femaleCounter = 0;
2let maleCounter = 0;
3function isMale(user){
4 if(user.sex = 'man'){
5 maleCounter++;
6 return true
7 }
8 return false
9}
در مثال فوق تابع isMale با دریافت ورودیهای یکسان، همواره نتیجه یکسانی در خروجی عرضه میکند اما این تابع دارای برخی عوارض جانبی است. عارضه جانبی آن، تغییر دادن مقدار متغیر سراسری maleCounter است و از این رو تابع خالص به حساب نمیآید.
کاربرد تابعهای خالص چیست؟
شاید از خود بپرسید چرا بین تابعهای خالص و غیر خالص تمییز قائل میشویم؟ دلیل این مسئله آن است که تابعهای خالص برخی مزیتها دارند و میتوانیم از آنها برای بهبود کیفیت کد در فرایند برنامهنویسی استفاده کنیم. در ادامه این مزیتها را توضیح میدهیم.
تابعهای خالص تمیزتر هستند و خواندنشان آسانتر است
هر تابع خالص همواره وظیفه خاصی را اجرا و نتیجه دقیقی ارائه میکند. به این ترتیب خوانایی کد افزایش مییابد و نوشتن مستندات برای آن آسانتر میشود.
کامپایلر میتواند بهینهسازی بیشتری روی تابعهای خالص انجام دهد
فرض کنید قطعه کدی مانند زیر داریم:
1for (int i = 0; i < 1000; i++){
2 console.log(fun(10));
3}
اگر fun یک تابع خالص نبود، در این صورت fun(10) باید 1000 بار در طی اجرای این کد، اجرا میشد. اگر fun یک تابع خاص بود، ادیتور میتوانست کد را در زمان کامپایل بهینهسازی کند. کد بهینهشده به صورت زیر خواهد بود:
1let result = fun(10)
2for (int i = 0; i < 1000; i++){
3 console.log(result);
4}
تست تابعهای خالص آسانتر است
تستهای تابعهای خالص نیازی به وابستگی به زمینه ندارد. زمانی که تستهای یونیت را برای تابعهای خالص مینویسید، کافی است یک مقدار ورودی بدهید و خروجی تابع را برای تأمین الزامات تعیین کنید.
یک مثال ساده شامل تابع خالصی است که یک آرایه از اعداد به عنوان آرگومان میگیرد و هر عنصر آرایه را 1 واحد افزایش میدهد.
1const incrementNumbers = function(numbers){
2 // ...
3}
کافی است تست یونیت را به صورت زیر بنویسیم:
1let list = [1, 2, 3, 4, 5];
2assert.equals(incrementNumbers(list), [2, 3, 4, 5, 6])
اگر این تابع خالص نباشد، باید عوامل بیرونی زیادی را در نظر بگیریم و این کار آسانی نیست.
تابعهای مرتبه بالا
منظور از یک «تابع مرتبه بالا» (Higher-Order Function) تابعی است که دست کم یکی از شرایط زیر را داشته باشد:
- یک یا چند آرگومان به عنوان ورودی بگیرد.
- یک تابع به عنوان نتیجه بازگشت دهد.
استفاده از تابعهای مرتبه بالا موجب افزایش انعطافپذیری کد میشود و امکان نوشتن کدهای منسجمتر و کارآمدتر را فراهم میسازد.
فرض کنید یک آرایه از اعداد صحیح داریم و میخواهیم یک آرایه جدید بسازیم. عناصر آرایه جدید همان طول آرایه اصلی را دارند و مقدار عنصر متناظر دو برابر مقدار آرایه اصلی است. بدون استفاده از تابعهای مرتبه بالا با کدی مانند زیر مواجه میشویم:
1const arr1 = [1, 2, 3];
2const arr2 = [];
3for (let i = 0; i < arr1.length; i++) {
4 arr2.push(arr1[i] * 2);
5}
در جاوا اسکریپت شیء آرایه یک متد ()map دارد. متد map(callback) یک آرایه جدید ایجاد کرده و آن را با نتایج فراخوانی یک تابع ارائه شده روی هر عنصر در آرایه فراخوانیکننده پر میکند.
1const arr1 = [1, 2, 3];
2const arr2 = arr1.map(function(item) {
3 return item * 2;
4});
5console.log(arr2);
تابع map یک تابع مرتبه بالا است. استفاده صحیح از تابعهای مرتبه بالا موجب بهبود کیفیت کد میشود. در بخشهای بعدی در مورد تابعهای مرتبه بالا بیشتر صحبت خواهیم کرد.
کش کردن تابع
فرض کنید تابعی مانند زیر داریم:
1function computed(str) {
2 // Suppose the calculation in the funtion is very time consuming
3 console.log('2000s have passed')
4
5 // Suppose it is the result of the function
6 return 'a result'
7}
برای افزایش سرعت اجرای برنامه میخواهیم نتیجه عملیات تابع را کَش (cache) کنیم. بدین ترتیب زمانی که در ادامه فراخوانی شود، اگر پارامترها یکسان باشد، تابع دیگر اجرا نخواهد شد و نتیجه کششده به صورت مستقیم بازگشت مییابد.
به این منظور میتوانیم یک تابع به نام cached نوشته و تابع هدف را درون آن قرار دهیم. این تابع کش تابع هدف را به عنوان آرگومان میگیرد و یک تابع پوششیافته تازه در نتیجه بازگشت میدهد. به جای تابع cached میتوانیم نتیجه فراخوانی تابع قبلی را با یک Object یا Map کش کنیم.
1function cached(fn){
2 // Create an object to store the results returned after each function execution.
3 const cache = Object.create(null);
4
5 // Returns the wrapped function
6 return function cachedFn (str) {
7
8 // If the cache is not hit, the function will be executed
9 if ( !cache[str] ) {
10 let result = fn(str);
11
12 // Store the result of the function execution in the cache
13 cache[str] = result;
14 }
15
16 return cache[str]
17 }
18}
به مثال زیر توجه کنید:
تابع تنبل
بدنه تابع به طور معمول شامل برخی گزارههای شرطی است. گاهی اوقات این گزارهها تنها باید یک بار اجرا شوند.
ما میتوانیم عملکرد تابع را با به تأخیر انداختن اجرای این گزارهها پس از اجرای نخست بهبود ببخشیم. به این ترتیب تابع دیگر لازم نیست این گزارهها را در اجراهای بعدی مجدداً اجرا کند. به این تابع، «تابع تنبل» (Lazy Function) گفته میشود. برای نمونه فرض کنید میخواهیم تابعی به نام foo بنویسیم که همیشه شیء Date را در فراخوانی نخست بازگشت میدهد.
1let fooFirstExecutedDate = null;
2function foo() {
3 if ( fooFirstExecutedDate != null) {
4 return fooFirstExecutedDate;
5 } else {
6 fooFirstExecutedDate = new Date()
7 return fooFirstExecutedDate;
8 }
9}
هر بار که تابع فوق اجرا شود، گزاره fooFirstExecutedDate باید اجرا شود. اگر این شرط داوری پیچیده باشد، در این صورت عملکرد برنامه با افت مواجه میشود. در این مرحله میتوانیم از تکنیک تابعهای تنبل برای بهینهسازی این کد بهره بگیریم.
1var foo = function() {
2 var t = new Date();
3 foo = function() {
4 return t;
5 };
6 return foo();
7}
پس از اجرای نخست، تابع اصلی را با تابع جدید بازنویسی میکنیم. زمانی که این تابع در آینده اجرا شوند، گزاره داوری دیگر اجرا نخواهد شد. این کار موجب بهبود کد میشود. این مثال پیچیده به نظر نمیرسد.
زمانی که رویدادهای DOM را به یک عنصر اضافه میکنیم، برای سازگاری با مرورگرهای مدرن و IE باید در مورد محیط مرورگر برخی بررسیها را اجرا کنیم.
1function addEvent (type, el, fn) {
2 if (window.addEventListener) {
3 el.addEventListener(type, fn, false);
4 }
5 else if(window.attachEvent){
6 el.attachEvent('on' + type, fn);
7 }
8}
هر بار که تابع addEvent اجرا شود، باید یک داوری را اجرا کنیم. اما با استفاده از تابعهای تنبل میتوانیم به صورت زیر عمل کنیم:
1function addEvent (type, el, fn) {
2 if (window.addEventListener) {
3 addEvent = function (type, el, fn) {
4 el.addEventListener(type, fn, false);
5 }
6 } else if(window.attachEvent){
7 addEvent = function (type, el, fn) {
8 el.attachEvent('on' + type, fn);
9 }
10 }
11 addEvent(type, el, fn)
12}
برای جمعبندی باید اشاره کنیم که اگر داوری شرطی درون یک تابع باید تنها یک بار اجرا شود، در این صورت میتوانیم آن را با تابعهای تنبل بهینهسازی کنیم. به طور خاص، پس از اجرای اولین داوری تابع اصلی با استفاده از تابع جدیدی بازنویسی میشود و تابع جدید داوری شرطی را حذف میکند.
کاری کردن تابع
«کاری کردن» (Currying) یک تکنیک برای ارزیابی تابع با آرگومانهای چندگانه در یک دنباله از تابعها با آرگومان منفرد است.
به بیان دیگر، زمانی که یک تابع به جای دریافت همه آرگومانها به صورت یکباره، آرگومان اول را دریافت کند و یک تابع جدید بازگشت دهد که آرگومان دوم را میگیرد و دوباره تابع جدیدی بازگشت دهد که آرگومان سوم را میگیرد و همین طور ادامه دهد تا همه آرگومانها دریافت شوند، آن را تابع کاری مینامیم.
این حالت زمانی اتفاق میافتد که یک تابع مانند add(1,2,3) را به add(1)(2)(3) تبدیل کنیم. با استفاده از این تکنیک، میتوان جزییات کوچک را پیکربندی کرده و با سهولت مورد استفاده مجدد قرار داد.
مزیتهای تابعهای کاری
- کاری کردن تابع به جلوگیری از ارسال چندباره متغیرهای یکسان کمک میکند.
- کاری کردن به تولید تابع مرتبه بالا کمک میکند. این مسئله در زمان مدیریت رویداد به شدت مفید است.
- قطعههای کوچک کد را میتوان پیکربندی کرد و با سهولت مورد استفاده مجدد قرار داد.
در ادامه یک مثال ساده از تابع add را میبینیم که سه عملوند به عنوان آرگومان میگیرد و مجموع همه آنها را در نتیجه بازگشت میدهد
1function add(a,b,c){
2 return a + b + c;
3}
آن را میتوان با تعداد کمی آرگومان (با نتایج عجیب) و یا با تعداد زیادی آرگومان (آرگومانها اضافی نادیده گرفته میشوند) فراخوانی کرد.
1add(1,2,3) --> 6
2add(1,2) --> NaN
3add(1,2,3,4) --> 6 //Extra parameters will be ignored.
برای تبدیل یک تابع معمول به یک تابع کاری میتوان به صورت زیر عمل کرد:
کد
1function curry(fn) {
2 if (fn.length <= 1) return fn;
3 const generator = (...args) => {
4 if (fn.length === args.length) {
5
6 return fn(...args)
7 } else {
8 return (...args2) => {
9
10 return generator(...args, ...args2)
11 }
12 }
13 }
14 return generator
15}
مثال
تابع compose
فرض کنید میخواهیم تابعی بنویسیم که در زمان وارد کردن مقدار bitfish عبارت HELLO, BITFISH را بازگشت دهد. همان طور که میبینید این تابع دو مؤلفه دارد:
- الحاق رشتههای ورودی
- تبدیل رشته به حالت حروف بزرگ.
بنابراین میتوانیم کد آن را به صورت زیر بنویسیم:
1let toUpperCase = function(x) { return x.toUpperCase(); };
2let hello = function(x) { return 'HELLO, ' + x; };
3let greet = function(x){
4 return hello(toUpperCase(x));
5};
این مثال تنها دو مرحله دارد، از این رو تابع پیچیدهای به نظر نمیرسد. اگر عملیات بیشتری وجود میداشت، تابع خوشامدگویی باید بیشتر تودرتو میشد و در واقع باید کدی مانند fn3(fn2(fn1(fn0(x)))) مینوشتیم.
به این منظور میتوانیم یک تابع compose بنویسیم که منحصراً برای ترکیببندی تابعها مورد استفاده قرار میگیرد:
1let compose = function(f,g) {
2 return function(x) {
3 return f(g(x));
4 };
5};
بدین ترتیب تابع greet را میتوان از طریق تابع compose به صورت زیر به دست آورد:
1let greet = compose(hello, toUpperCase);
2greet('kevin');
استفاده از تابعهای compose برای ترکیب دو تابع در یک تابع منفرد موجب اجرای کد از راست به چپ و نه از داخل به بیرون میشود که بر خوانایی کد میافزاید.
اکنون تابع compose میتواند تنها از دو پارامتر پشتیبانی کند، اما میخواهیم که تابع تعداد بیشتری پارامتر بگیرد. تابع composer به این ترتیب در پروژه متن-باز مشهور underscore (+) پیادهسازی شده است.
1function compose() {
2 var args = arguments;
3 var start = args.length - 1;
4 return function() {
5 var i = start;
6 var result = args[start].apply(this, arguments);
7 while (i--) result = args[i].call(this, result);
8 return result;
9 };
10};
ما از طریق تابع compose میتوانیم روابط منطقی بین تابعها را بهبود داده و خوانایی کد را افزایش دهیم و بدین ترتیب بسطهای آتی و ریفکتور کردن کد را تسهیل کنیم.