برنامهنویسی تابعی چیست؟
این پرسش بزرگی است که پاسخ بلندی میطلبد (ویکیپدیا یکی را ارائه میدهد و Advanced R دیگری را).
برای هدف ما، اینها چند ویژگی کلیدی هستند:
با این حال، R یک زبان برنامهنویسی تابعی خالص نیست (برخلاف مثلاً Haskell).
به R همچون زبانی عملگرا نگاه کنید که (بهطور فزاینده) قابلیتهای تابعی را در جایی فراهم میکند که روشی تمیز و آسان برای انجام کار باشند. بهطور کلی، هدف تحلیل داده است و برنامهنویسی تنها وسیلهای برای رسیدن به آن هدف است.
استفاده از لولههای |> برای اتصال توابع، تکنیکی است که بهطور فزاینده در چندین زبان رایج میشود.
بهطور خلاصه:
برخی برنامهنویسان هنگام بررسی کد، تلفظ لوله بهصورت «then» را مفید میدانند: مرحلهی ۱ را انجام بده، بعد مرحلهی ۲ را...
مثال زیر نحوهی نگارش را نشان میدهد.
library(stringr)
"Monday-25-February" |>
str_split("-") |>
unlist() |> # [1] "Monday" "25" "February"
str_sub(1, 3) |> # [1] "Mon" "25" "Feb"
str_to_upper() |> # [1] "MON" "25" "FEB"
str_flatten(collapse = ", ")
#> [1] "MON, 25, FEB"
این نوع لوله در نسخهی ۴.۱.۰ به R پایه اضافه شد و ممکن است ببینید که از آن با نام «لولهی بومی» یاد میشود.
پیشتر، لولهکشی با %>% از بستهی magrittr انجام میشد (این اسم برای دوستداران هنر یک بازی با کلمات است).
«لولهی magrittr» هنوز در دسترس است، اما لولههای بومی نیاز به یک بستهی خارجی را برطرف میکنند و پیادهسازی در برخی جزئیات سادهتر است.
ویرایشگر RStudio (و برخی دیگر) از Ctrl-Shift-M بهعنوان میانبر صفحهکلید برای افزودن یک لوله با فاصلههای مناسب استفاده میکند.
پیشتر دیدیم که بسیاری از توابع در R «برداریشده» هستند: روی کل بردارها عمل میکنند و نیازی به نوشتن حلقههای صریح نیست.
توابعی که تنها از توابع برداریشده استفاده میکنند، بهاحتمال زیاد خودشان بهطور خودکار برداریشده خواهند بود. در یک مثال ساده:
triple <- function(x) return(3 * x)
triple(1:10)
[1] 3 6 9 12 15 18 21 24 27 30
با این حال، توابع پیچیدهتر ممکن است ورودی برداری نپذیرند، برای مثال اگر شامل حلقهها یا بلوکهای if-else باشند.
ما به روشی کلیتر برای اعمال توابع روی بردارها نیاز داریم (مشابه map() در برخی زبانهای دیگر).
چون R در زبان پایه انواع گوناگونی از ساختارهای داده دارد، خانوادهی کاملی از توابع *apply() نیز برای کار با آنها دارد.
از نظر فنیتر، برنامهنویسان اغلب آنها را «توابع مرتبهبالا» و ریاضیدانان «تابعیها» مینامند، چون توابع را بهعنوان آرگومان میگیرند.
فعلاً lapply() و sapply() را در نظر بگیرید.
lapply()«List-apply» برای کار روی ورودیهای لیست طراحی شده است، اما در صورت نیاز، بردارها بیصدا به لیست تبدیل میشوند.
خروجی یک لیست است، پس ممکن است برای رسیدن به یک بردار لازم باشد آن را باز کنید:
h <- function(size) {
switch(size,
"small" = 6,
"medium" = 8,
"large" = 10
)
}
v <- c("small", "large", "medium")
h(v) # bad use of an unvectorized function
#> Error in switch(size, small = 6, medium = 8, large = 10) :
#> EXPR must be a length 1 vector
lapply(v, h) # output is a list
#> [[1]]
#> [1] 6
#> [[2]]
#> [1] 10
#> [[3]]
#> [1] 8
lapply(v, h) |> unlist() # output is a vector
#> [1] 6 10 8
دانشجویانی که با مفهوم Switch آشنا هستند ممکن است متوجه شوند که میتوان بهجای switch اسکالر، از recode_values() برداریشده از کتابخانهی dplyr استفاده کرد.
برداریسازی خوب است و R مدرن راههای متعددی برای دستیابی به آن فراهم میکند.
library(dplyr)
h <- function(size) {
recode_values(size,
"small" ~ 6,
"medium" ~ 8,
"large" ~ 10
)
}
v <- c("small", "large", "medium")
h(v)
#> [1] 6 10 8
sapply()«Simplified-apply» کاری مشابه lapply انجام میدهد، اما همچنین تلاش میکند مقدار بازگشتی را ساده کند: برای مثال، یک بردار با اسم بهجای یک لیست.
بسته به زمینه، ممکن است استفاده از unname() لازم باشد:
sapply(v, h)
small large medium
6 10 8
sapply(v, h) |> unname()
#> [1] 6 10 8
purrr
lapply و sapply سادهاند، برای بیشتر کاربران R آشنا هستند و هنوز برای بردارها و لیستهای ساده بسیار محبوباند.
R پایه چندین تابع apply دیگر هم دارد:
vapply() مشابه sapply() است، اما یک پارامتر الزامی هم دارد که قالب خروجی را مشخص میکند.apply() برای ماتریسها و آرایههای چندبعدی.mapply() برای چندین آرگومان لیستی یا برداری بهصورت موازی.tapply() دشوارتر است، اما با عملیات گروهبندی و آرایههای نامنظم کار میکند.vectorize() تابعی نسبتاً مرموز است که بهنظر میرسد پوششی برای mapply() باشد.بس است دیگر!
فراهم کردن جایگزینی سادهتر و یکدستتر برای این نوع سردرگمی، هدفی کلیدی برای Tidyverse است.
اکنون بستهی purrr وجود دارد که چندین گزینهی اضافی مانند map() را فراهم میکند و در همان حال، یکدستی در طراحی و شفافیت در پیامهای خطا را بهبود میبخشد.
این بسته که در مقایسه با R پایه کمتر به سازگاری با نسخههای قبلی محدود شده است، R را بهطور قابل توجهی به برنامهنویسی تابعی مدرن که در زبانهای دیگر یافت میشود نزدیکتر میکند.
توابع زیادی با تنوع بالا در purrr وجود دارد که خلاصه کردنشان آسان نیست.
منابعی که به پیمایش این کتابخانه کمک میکنند عبارتاند از:
کتابخانهی purrr تقریباً بهطور کامل روی دادههای یکبعدی کار میکند: بردارها و لیستها.
در یک مفهوم بعدی خواهیم دید که کتابخانهی dplyr قابلیتهای معادل را برای دیتافریمهای دوبعدی فراهم میکند.
تابع map() یک تابع را به هر عنصر در ورودی اعمال میکند و همیشه یک لیست بازمیگرداند.
تا اینجا، این شبیه lapply() بهنظر میرسد، فقط با اسمی جدید و شاید با پیامهای خطای بهتر.
با این حال، map() تنها یک عضو از خانوادهای بزرگ است.
چند پرسش برای اندیشیدن هنگام انتخاب یک تابع:
map() همیشه یک لیست بازمیگرداند، اما اگر نوع خاصی از بردار اتمی میخواهید، برای رشتهها از map_chr()، برای اعداد صحیح از map_int() و غیره استفاده کنید.i شروع کنید، مانند imap().map را در اسم با map2 جایگزین کنید، مانند map2_dbl() برای دو ورودی (مثلاً مقادیر و وزنها، برای محاسبهی میانگین وزنی)pmap جایگزین کنید، مانند pmap_chr(). و بله، موازیسازی در سطح سختافزار ممکن است.map_if() برای اعمال یک فیلتر محمولی («منطقی»)، map_at() برای مشخص کردن معیار اندیس موقعیتی، و head_while() برای پردازش ورودی تا زمانی که عنصری یک محمول را برآورده نکند فکر کنید.map از توابع modify() استفاده کنید، یا برای اعمال بازگشتی از modify_tree().این توابع در هر جا که ممکن باشد، دادههای ورودی را بهعنوان آرگومان اول دارند تا مطمئن شوند که با لولهها بهخوبی کار میکنند.
همچنین، purrr (بهعمد) سختگیرتر از R پایه است. اگر چیز مشخصی بخواهید، توابع دقیقاً همان را بازمیگردانند یا با یک پیام خطا متوقف میشوند.
سختگیرانه، اما در کاهش Bugهای سختپیدا بسیار مؤثر است.
توابع map بهطور کلی مقدار بازگشتیای به همان طول ورودی دارند.
برخی توابع دیگر ابعاد داده را کاهش میدهند، برای مثال sum().
sum(1:100)
#> [1] 5050
در حالت بالا، یک بردار به طول ۱۰۰ را به یک عدد صحیح به طول ۱ تبدیل میکنیم. در یک مفهوم آینده دربارهی ماتریسها و آرایهها خواهیم دید که مفهوم کاهش ابعاد کلیتر است.
تابع sum() توکار است (همانند بسیاری از توابع آماری دیگر).
با این حال، ما به روشی نیاز داریم که توابع دلخواه کاهندهی ابعاد را روی یک ساختار داده اعمال کنیم، با استفاده از تابعی مرتبهبالا معادل map().
در چندین زبان دیگر، از اسمهایی مانند fold، foldl و foldr استفاده میشود. در R، تابع مربوطه reduce() است که متناظر با چارچوب مشهور MapReduce (در میان علاقهمندان به الگوریتم) است.
آرگومان اول، طبق معمول، دادههای ورودی است. آرگومان دوم یک تابع دو-آرگومانی است: اغلب یک تابع بینام، اما ممکن است به سادگی یک عملگر حسابی مانند + باشد.
# surround an infix operator with backticks, to use as a function
reduce(1:100, `+`)
#> [1] 5050 # same as sum(1:100)
# anonymous function, alternately add and subtract
reduce(1:10, \(accum, nextval) ifelse(nextval %% 2 == 0, accum + nextval, accum - nextval))
#> [1] 7
آیا مهم است که تابع را از ابتدا یا انتهای ورودی اعمال کنیم؟
با عملگرهای شرکتپذیر مانند + یا * مهم نیست.
بدیهی است که (1 + 2) + 3 == 1 + (2 + 3).
در مقابل، - و / شرکتناپذیر هستند:
(1 - 2) - 3
#> [1] -4
1 - (2 - 3)
#> [1] 2
برای توابعی که جهت در آنها مهم است، یک آرگومان اختیاری .dir وجود دارد.
# default is .dir = "forward"
reduce(1:10, `-`)
#> [1] -53
reduce(1:10, `-`, .dir = "backward")
#> [1] -5
همانند map() و map2()، یک تابع reduce2() هم برای کار روی دو بردار ورودی بهصورت موازی وجود دارد.
در R، کار با بردارها را میپسندیم. فرض کنید بهجای یک مقدار نهایی واحد، میخواهید همهی گامهای میانی را ببینید.
برای این کار، توابع متناظر accumulate() و accumulate2() هستند.
نحوهی نگارش مشابه reduce() است، با چند آرگومان اختیاری اضافی برای مشخص کردن نوع خروجی.
accumulate(1:10, `+`)
#> [1] 1 3 6 10 15 21 28 36 45 55
برخی از انواع رایج accumulate توابع خودشان را دارند.
مفهوم توابع برداری به cumsum() و cumprod() در R پایه پرداخت، و کتابخانهی dplyr برای میانگینهای عددی cummean() و برای بردارهای منطقی cumall() و cumany() را دارد.
معمولاً میخواهیم عناصری از ورودی را که با یک محمول مطابقت دارند بگنجانیم (یا حذف کنیم).
در مفهوم فیلتر برداری یک روش اصطلاحی برای انجام این کار دیدیم.
v <- 1:3
v[v >= 2]
#> [1] 2 3
این روش در R سنتی بسیار بهکار میرود، اما در خطوط لولهی تابعی مدرن بهخوبی جا نمیافتد.
در نتیجه، purrr مجموعهای از توابع محمولی را فراهم میکند که برای کار با لولهها از قرارداد معمول آرگومان f(input, func, ...) پیروی میکنند.
اسمها کمی با بیشتر زبانهای دیگر فرق دارند (اسمهایی مانند filter و drop در R پیشتر معنای کاملاً متفاوتی دارند).
توابع متناظر purrr شامل keep() و discard() و چند تای دیگر است.
همچنین، ما every()، some() و none() را داریم که جای all() و any() را در برگرداندن یک مقدار منطقی واحد میگیرند.
بیشتر زبانهای تابعی از بازگشت بهعنوان جایگزین ترجیحیشان برای حلقهها استفاده میکنند.
R گزینههای دیگر و اغلب بهتری دارد: توابع برداریشده و توابع مرتبهبالا.
با این حال، بازگشت در R پشتیبانی میشود و میتواند در زمینههایی مانند پیمایش ساختارهای درختی مفید باشد.
حداکثر عمق بازگشت بهطور پیشفرض ۵۰۰۰ است که نسبتاً سخاوتمندانه است (مقدار پیشفرض Python برابر ۱۰۰۰ است). میتوان مقدار پیشفرض را بازنویسی کرد.
بهینهسازی فراخوانی انتهایی در R استاندارد نیست، هرچند چند راهحل جایگزین توسعه یافته است.
سنت ایجاب میکند که در اینجا یک مثال فاکتوریل را بهعنوان مثال کلاسیک تعریف بازگشتی نشان دهیم.
fact <- function(n) ifelse(n == 0, 1, n * fact(n - 1))
fact(5)
#> [1] 120
# some more idiomatic R:
factorial(5)
#> [1] 120
prod(1:5)
#> [1] 120
میتوانید از بازگشت در R استفاده کنید و گاهی ارزشمند است، اما اغلب رویکردهای سادهتری هم وجود دارد.