برنامه نویسی تابعی در جاوا اسکریپت<!-- --> - Husen's Blog

برنامه نویسی تابعی در جاوا اسکریپت

جاوا اسکریپت یک زبان چند پارادایمی است و می توان آن را بر اساس پارادایم های مختلف برنامه نویسی نوشت. پارادایم برنامه نویسی اساساً مجموعه ای از قوانین است که هنگام نوشتن کد از آنها پیروی می کنیدم

برنامه نویسی تابعی (FP) جدیداً کشف نشده و به قدمت برنامه نویسی است. و امروزه برنامه نویسی تابعی در حال پیشرفت است و تقریباً هر زبان برنامه نویسی از جمله جاوا، پایتون، جاوا اسکریپت و ... از برنامه نویسی تابعی استفاده می کنند.

پارادایم های برنامه نویسی اعلامی در مقابل امری

برنامه نویسی تابعی یک زیرشاخه از برنامه نویسی اعلامی (Declarative) است.

برنامه نویسی اعلامی

HTML و SQL یک نمونه از برنامه نویسی اعلامی است.

SELECT * FROM customers
<div></div>

در مثال‌های کد بالا، ما SELECT یا نحوه رندر یک div را پیاده‌سازی نمی‌کنیم. ما فقط به کامپیوتر می گوییم که چه کاری (what) انجام دهد، بدون اینکه چگونه (how).

برنامه نویسی امری

در برنامه نویسی امری ما نحوه انجام یه کاری را دقیقا قدم به قدم به کامپیوتر می گوییم. چیزی شبیه کد زیر:

for (let i = 0; i < arr.length; i++) {
    increment += arr[i]
}

ما دقیقاً به کامپیوتر می گوییم که چه کاری انجام دهد. در آرایه ای به نام arr تکرار کند و سپس هر یک از آیتم های آرایه را افزایش دهد.

مقایسه برنامه نویسی اعلامی و امری

در جاوا اسکریپت ما میتوانیم به هردو صورت امری و اعلامی کد بزنیم. و برای همین جاوا اسکریپت یک زبان چند پارادامی (multi-paradigm) است. و برنامه نویسی تابعی از پارادایم امری پیروی می کند.

برنامه نویسی اعلانی دقیقا به این صورت است که از کامپیوتر بخواهیم برای ما یک فنجان چایی درست کند و اصلا فراید انجام آن برای ما مهم نیست. فقط یک فنجان چایی می خواهیم.

ولی در برنامه نویسی امری باید جز به جز گفته شود:

  • برو تو آشپزخونه
  • اگر در اتاق کتری وجود دارد و برای یک فنجان چای آب کافی دارد، کتری را روشن کنید.
  • اگر در اتاق کتری وجود دارد و آب آن برای یک فنجان چای کافی نیست، کتری را با آب کافی برای یک فنجان چای پر کنید، سپس کتری را روشن کنید.
  • و غیره

برنامه نویسی تابعی

چون برنامه نویسی تابعی نوعی از برنامه نویسی اعلامی است کد نهایی خیلی کمتری دارد زیرا جاوا اسکریپت اکثر توابع داخلی مورد نیازمان را دارد.

همچنین به ما این امکان را می‌دهد که مقدار زیادی انتزاع کنیم (abstract) (نیازی نیست عمیقاً بفهمیم که چگونه کاری انجام می‌شود)، فقط تابعی را فراخوانی می‌کنیم که این کار را برای ما انجام می‌دهد.

برنامه نویسی تابعی دو قانون اساسی دارد:

  1. طراحی نرم افزار با استفاده از توابع خالص و مجزا (pure and isolated functions)
  2. جلوگیری از تغییرپذیری (mutability) و عوارض جانبی (side-effects)

توابع خالص

اگر بخواییم یک pure function بسازیم نیازمند یک تابعی هستیم که side effect نداشته باشد.

side effect زمانی اتفاق می افتد که کد ما با state قابل تغییر (mutable) در تعامل باشد. (یعنی بخواند یا بنویسد)

نباید وابستگی بر state برنامه که شامل متغییرهای سراسری می باشد داشته باشد. هر چیزی که شما نیاز دارید باید به عنوان یک آرگومان به تابع منتقل شود. این باعث می‌شود که وابستگی‌های شما بسیار واضح‌تر و قابل کشف‌تر باشد.

const person = {
    name: 'Kyle',
    friends: ['Jhon', 'Sally']
}

function addFriend(p, name) {
    return { ...p, friends: [...p.friends, name] }
}

console.log(addFriend(person, 'Joey'))
// { name: 'Kyle', friends: ['Jhon', 'Sally', "Joey"] }
console.log(person)
// { name: 'Kyle', friends: ['Jhon', 'Sally'] }

ما اگر داخل تابع addFriend Destructuring نمی کردیم چون در جاوا اسکریپت اگر بصورت مستقیم با p کار می کردیم خاصیت pure بودن تابع از دست می رفت. برای مثال چون ما یک کپی با تمام خواص person ساخته و به addFriends ارسال کردیم اگر از p.friends.push(name) استفاده بکنیم درواقع خواص person اصلی را تغییر می دهیم و این خاصیت pure بودن تابع را از بین می برد.

می توانیم از دو تابع pure داخل هم نیز استفاده کنیم.

...
function addFriend(p, name) {
    return { ...p, friends: addElement(...p.friends, name) }
}

function addElement(a, element) {
    return { ...a, element }
}
...

و همچنین یکی از خصوصیت های اصلی تابع pure این است که در هر فراخوانی باید خروجی یکسان داشته باشد. برای همین برای مثال نمیتوانیم از متد Math.random() در name استفاده کنیم.

تغییر ناپذیری

هیچ چیزی را تغییر نده! نباید آنها در طول زمان تغییر کنند. اگر چیزی برای ساختار داده ما باید تغییر کند، یک کپی از آنرا گرفته و آن کپی را تغییر می دهیم. هیچ وقت state ها را تغییر ندهید.

در جاوا اسکریپت هر String ویا Boolean و... را با const تعریف کنیم immutable هستند و قابل تغییر نیستند. ولی این مورد برای Object ها و Array ها صدق نمی کند. const فقط از ما در برابر مقداردهی اولیه روی متغیر محافظت می کند.

برای حل این مشکل همان طور که در بالاهم اشاره شد میتوانیم از Destructuring استفاده کنیم.

روش دیگر استفاده از structuredClone() است.

const person = {
    name: 'Kyle',
    friends: ['Jhon', 'Sally']
}

const p = structuredClone(person)
p.friends.push('Joey')

console.log(person)
// { name: 'Kyle', friends: ['Jhon', 'Sally'] }

و بهترین روشی که می شود استفاده کرد و بصورت عمیق آبجک immutable برای ما تحویل دهد استفاده از متد static Object.freeze(obj) است. البته برای deep کردن قضیه از loop استفاده می کنیم.

با Object.freeze(obj) یک آبجکت دیگر قابل تغییر نیست: ویژگی های جدید را نمی توان اضافه کرد، ویژگی های موجود را نمی توان حذف کرد، شمارش پذیری، پیکربندی، قابلیت نوشتن یا مقدار آنها را نمی توان تغییر داد، وprototype آبجکت را نمی توان دوباره اختصاص داد.

const person = deepClone({
    name: 'Kyle',
    friends: ['Jhon', 'Sally'],
    address: {
        street: '1234'
    }
})

person.friends.push('Joey') 
console.log(person) // Error: can't define array index property past the end of an array with non-writable length

person.friends[0] = 'Joey'
person.address.street = '1'
console.log(person)
// { name: 'Kyle', friends: ['Jhon', 'Sally'], address: {street: '1234'}}

function deepClone(obj) {
    Object.values(obj).forEach((value) => {
        if (value && typeof value === 'object') {
            deepClone(value)
        }
    })

    return Object.freeze(obj)
}

Higher Order Functions

توابعی که می توانند به یک متغیر قرار داده شوند، به تابع دیگری پاس داده شوند، یا از تابع دیگری بازگردانده شوند، درست مانند هر مقدار عادی دیگر، Higher Order Functions نامیده می شوند.

در جاوا اسکریپت همه توابع توابع first class هستند. توابعی که دارای وضعیت first class هستند به ما امکان می دهند توابع Higher Order ایجاد کنیم.

یک Higher Order Function تابعی است که یا یک تابع را به عنوان آرگومان می گیرد یا یک تابع را برمی گرداند یا هر دو را! شما می توانید از Higher Order Function برای جلوگیری از تکرار خود در کد استفاده کنید.

توابعی مثل .map() و .reduce() و .filter() و... همگی Higher Order Function محسوب می شوند. در برنامه نویسی فانکشنال از Loop ها استفاده نمی شود و بجای آن از توابعی مثل .map() استفاده می شود. چون بنوعی loop هایی مثل for باعث از بین رفتن immutability می شوند.

در این قطعه کد ما سعی کردیم یک Higher Order Function شخصی بسازیم و لیستی از آبجکت های دارای name را گرفته و آبجک ها را در آبجکت دیگری با نامشان قرار دهد.

const people = [
    {
        name: 'Kyle',
        friends: ['Jhon', 'Sally']
    },
    {
        name: 'Joey',
        friends: ['Kyle']
    },
    {
        name: 'Sally',
        friends: ['Jhon', 'Kyle']
    }
]

const result = groupBy(people, (person) => person.name)
console.log(result) 
// { Kyle: [{ name: "Kyle", friends: […] }], Joey: [{ name: "Joey", friends: […] }], Sally: [{ name: "Sally", friends: […] }] }

function groupBy(array, func) {
    return array.reduce((grouping, element) => {
        const key = func(element)
        if (grouping[key] == null) grouping[key] = []
        grouping[key].push(element)
        return grouping
    }, {})
}

این توابع زیاد مورد استفاده قرار می گیرند زیرا تابع زیربنایی را تغییر نمی دهند (بدون تغییر حالت) اما بر روی یک کپی از آرایه عملیات را انجام می دهند.

Referentially Transparent

وقتی از قانون عدم تغییر state پیروی می کنید، کد شما referentially transparent می شود. یعنی فراخوانی های تابع شما را می توان با مقادیری که نشان می دهد جایگزین کرد بدون اینکه بر نتیجه تأثیر بگذارد.

const greetAuthor = function () {
    return 'Hi Kealan'
}

برنامه نویسی تابعی با عبارات referentially transparent باعث می شود اگر به شی گرایی عادت دارید، شروع به تفکر متفاوت در مورد کد خود کنید.

اما چرا؟

زیرا به جای آبجکت و state قابل تغییر در کد خود، شما شروع به داشتن pure function و بدون تغییر state می کنید. شما به وضوح درک می کنید که انتظار دارید تابع شما چه چیزی را برگرداند (زیرا هرگز تغییر نمی کند، زمانی که معمولاً بسته به state خارج از تابع ممکن است انواع داده های مختلف را برگرداند).

این می تواند به شما کمک کند جریان (flow) را بهتر درک کنید.

Function Composition

درواقع صدا زدن تابعی داخل تابع دیگر است. composition به ما این امکان را می دهد که کد خود را از توابع قابل استفاده مجدد ساختار بدهیم تا تکرار و مکررات نکنیم. می‌توانیم با عملکردهایی مانند بلوک‌های ساختمانی کوچک رفتار کنیم که می‌توانیم با هم ترکیب کنیم تا به خروجی پیچیده‌تری برسیم.

const array = [1, 2, 3, 4, 5]

function double(element) {
    return element * 2
}

function addOne(element) {
    return element + 1
}

function doubleAndAddOne(element) {
    return addOne(double(element))
}

console.log(array.map(doubleAndAddOne)) // [ 3, 5, 7, 9, 11 ]

پکیج lodash یک باندل fp دارد که در این مورد واقعا کمک کننده است.

import { flow } from 'lodash/fp'
...
const doubleAndAddOne = flow(double, addOne)
console.log(array.map(doubleAndAddOne)) // [ 3, 5, 7, 9, 11 ]

Recursion

صدا کردن تابع داخل بدنه خود تابع recursion یا تابع بازگشتی است.

function recurse(start, end) {
    if (start == end) {
        console.log(end)
        return
    } else {
        console.log(start)
        return recurse(start + 1, end)
    }
}

recurse(1, 10)
// 1, 2, 3, 4, 5, 6, 7, 8, 9, 10

این قطعه کد از آرگومان start تا آرگومان end محاسبه می شود. و این کار را با فراخوانی مجدد تابع خود انجام می دهد.

Currying

currying تبدیل یک تابع با چندین آرگومان به دنباله ای از توابع تک آرگومان است. یعنی تبدیل تابعی مانند f(a, b, c, ...) به تابعی مانند f(a)(b)(c)... . currying یک تابع، تابعی را که آریتی بیش از یک عدد دارد، به 1 تبدیل می کند. این کار را با برگرداندن یک تابع داخلی برای گرفتن آرگومان بعدی انجام می دهد. به این مثال توجه کنید:

// function sum(a, b) {
//     return a + b
// }

function sum(a) {
    return (b) => a + b
}

console.log(sum(1)(2)) // 3

مزیت بزرگ currying زمانی است که شما نیاز دارید چندین بار از یک تابع استفاده مجدد کنید اما فقط یک (یا کمتر) از پارامترها را تغییر دهید. بنابراین می توانید اولین فراخوانی تابع را ذخیره کنید، چیزی شبیه به این:

function curryAdd(firstNum) {
    return function (secondNum) {
        return firstNum + secondNum
    }
}

let add10 = curryAdd(10)
add10(2) // Returns 12

let add20 = curryAdd(20)
add20(2) // Returns 22

خب با استفاده از lodash/fp لیست موجود را براساس نامشان مرتب می کنیم سپس براساس طول رشته نام هر آبجکت آنها را گروه بندی می کنیم.

import { compose, groupBy, sortBy } from 'lodash/fp'

const array = [
    {
        name: 'Kyle'
    },
    {
        name: 'Sally'
    },
    {
        name: 'Joey'
    }
]

console.log(
    groupBy(
        (element) => element.name.length,
        sortBy((element) => element.name, array)
    )
)
// { 4: [{ name: 'Joey' }, { name: 'Kyle' }], 5: [{ name: 'Sally' }] }

const composedFunction = compose(
    groupBy((element) => element.name.length),
    sortBy((element) => element.name)
)

console.log(composedFunction(array))
// { 4: [{ name: 'Joey' }, { name: 'Kyle' }], 5: [{ name: 'Sally' }] }

نتیجه

امیدوارم که این مقاله برنامه نویسی تابعی را به سادترین شکل توضیح داده باشد. برنامه نویسی تابعی لزوما بهترین روش نیست از جمله شی گرایی. همه چیز به مشکل مربوط است و روش حل آن. برنامه نویسی تابعی بیشتر برای مسائل ریاضی مانند تجزیه و تحلیل داده ها مورد استفاده قرار می گیرد. اما حقیقتا به مشکل مربوط است که بخواهیم چگونه آن را حل کنیم و آیا برنامه نویسی تابعی بهترین روش حل آن مشکل است یا خیر.