المسارات
/
R
R
/
المنهج
/
البرمجة الوظيفية
ال

البرمجة الوظيفية في R

1 تمرين

نبذة عن البرمجة الوظيفية

ما هي البرمجة الدالية؟

هذا سؤال كبير يستحق إجابة طويلة (ويكيبيديا تقدم واحدة، وAdvanced R يقدم أخرى).

بالنسبة لأغراضنا، هذه بعض السمات الأساسية:

  • دوال نقية، تُسند المدخلات إلى مخرجات باتساق ودون آثار جانبية.
  • بيانات غير قابلة للتغيير.
  • دوال من الدرجة الأولى، يمكن التعامل معها كقيم مثل أي نوع آخر.
  • دوال عالية الرتبة، تأخذ دوال أخرى كوسائط أو كقيم إرجاع.

لكن R ليست لغة دالية خالصة (على عكس Haskell مثلًا).

انظر إلى R كلغة عملية، توفر (بشكل متزايد) إمكانات دالية عندما تكون هذه الإمكانات أسلوبًا نظيفًا وسهلًا لإنجاز المهمة. وعمومًا، تحليل البيانات هو الهدف، والبرمجة مجرد وسيلة لتحقيقه.

الأنابيب

استخدام الأنابيب |> لربط الدوال أسلوب شائع بشكل متزايد في عدة لغات.

باختصار:

  • تصبح النتيجة على يسار الأنبوب أول وسيط في الدالة على اليمين (حيث تتجاهل كلمة "اليمين" فواصل الأسطر الاختيارية).
  • يمكن تضمين أي وسائط متبقية في استدعاء الدالة.
  • في R، يجب أن نوجّه الأنبوب إلى استدعاء دالة، لا إلى اسم فقط، لذا تكون الأقواس الهلالية مطلوبة دائمًا (وهذا يختلف عن بعض اللغات الأخرى).

يجد بعض المبرمجين أنه من المفيد نطق الأنبوب "then" عند مراجعة الكود: نفّذ الخطوة 1 THEN نفّذ الخطوة 2...

يوضح المثال التالي الصياغة.

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 الأساسية في الإصدار v4.1.0، وقد تراه مذكورًا باسم "الأنبوب الأصلي".

سابقًا، كانت عملية التوصيل بالأنابيب تُجرى باستخدام %>% من حزمة 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() للعمل عليها. وبشكل أكثر تقنية، يسميها المبرمجون غالبًا "الدوال عالية الرتبة" ويسميها علماء الرياضيات "functionals"، لأنها تأخذ دوال كوسائط.

في الوقت الحالي، تأمّل 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 الأساسية أيضًا عدة دوال تطبيق أخرى:

  • vapply() تشبه sapply() لكن لها معامل مطلوب لتحديد صيغة المخرجات.
  • apply() للمصفوفات والمصفوفات عالية الأبعاد.
  • mapply() لمعالجة وسائط متعددة من القوائم أو المتجهات على التوازي.
  • tapply() يصعب شرحها أكثر، لكنها تعمل مع عمليات التجميع والمصفوفات غير المنتظمة.
  • vectorize() دالة غامضة نوعًا ما، تبدو وكأنها غلاف حول mapply().

كفى، لقد كفى!

توفير بديل أبسط وأكثر اتساقًا لهذا النوع من الالتباس هدف رئيسي لـ Tidyverse.

توجد الآن حزمة purrr، التي توفر عدة خيارات إضافية مثل map()، مع تحسين الاتساق في التصميم والوضوح في رسائل الخطأ.

ولأن هذه الحزمة أقل تقيدًا بالتوافق العكسي من R الأساسية، فهي تقرّب R كثيرًا من البرمجة الدالية الحديثة كما توجد في لغات أخرى.

تحتوي purrr على دوال كثيرة ومتنوعة يصعب تلخيصها بسهولة. وتشمل الموارد التي تساعدك على التنقل في المكتبة:

Note

تعمل مكتبة purrr تقريبًا بالكامل على بيانات أحادية البعد: المتجهات والقوائم.

سنرى في مفهوم لاحق أن مكتبة dplyr توفر وظائف مكافئة لأطر البيانات ثنائية الأبعاد.

دوال map المتعددة

تُطبّق دالة map() دالة على كل عنصر في المدخل، وتُرجع دائمًا قائمة.

حتى الآن، يبدو هذا مثل lapply()، لكن باسم جديد فقط، وربما مع رسائل خطأ أفضل.

لكن map() مجرد عضو واحد في عائلة كبيرة.

بعض الأسئلة التي يمكن التفكير فيها عند اختيار دالة:

  • ما الذي تريد إرجاعه؟ تُرجع map() دائمًا قائمة، لكن إذا أردت نوعًا معينًا من المتجهات الذرية فاستخدم map_chr() للسلاسل النصية، وmap_int() للأعداد الصحيحة، وهكذا.
  • هل تحتاج إلى كل فهرس موضعي من المدخل؟ أضف i في بداية أسماء الدوال، مثل imap().
  • هل تريد معالجة مدخلات متعددة على التوازي؟
    • استبدل map بـ map2 في الاسم، كما في map2_dbl() لمدخلين (مثلًا القيم والأوزان، لحساب متوسط مرجّح)
    • استبدلها بـ pmap لقائمة من المدخلات المتوازية، كما في pmap_chr(). ونعم، التوازي على مستوى العتاد ممكن.
  • هل تريد معالجة كل عناصر المدخل؟ إن لم يكن كذلك، ففكّر في map_if() لتطبيق مرشّح تحقق (قيمة منطقية)، وmap_at() لتحديد معايير الفهرس الموضعي، وhead_while() لمعالجة المدخل إلى أن يخفق عنصر في تحقق ما.
  • هل بيانات المدخل متداخلة بعمق؟ توجد دوال لهذه الحالة.
  • هل تريد أن تكون قيمة الإرجاع دائمًا من النوع نفسه الذي للمدخل؟ استخدم دوال modify() بدلًا من map، أو modify_tree() للتطبيق العودي.

تأخذ هذه الدوال بيانات المدخل كوسيط أول كلما أمكن، لضمان عملها بشكل جيد مع الأنابيب.

كما أن purrr أكثر تدقيقًا (عن قصد) من R الأساسية. فإذا طلبت شيئًا محددًا، ستُرجع الدوال ذلك بالضبط أو تتوقف برسالة خطأ. قاسٍ، لكنه فعال جدًا في تقليل الـ bugs التي يصعب اكتشافها.

دوال Reduce

عادةً ما تكون قيمة إرجاع دوال map بطول المدخل نفسه.

وبعض الدوال الأخرى تقلّل أبعاد البيانات، مثل sum().

sum(1:100)
#> [1] 5050

في الحالة أعلاه، نحوّل متجهًا بطول 100 إلى عدد صحيح بطول 1. وسنرى في مفهوم مستقبلي عن المصفوفات والمصفوفات المتعددة الأبعاد أن فكرة تقليل الأبعاد أعم من ذلك.

دالة 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

لبعض الأنواع الشائعة من التراكم دوال خاصة بها. ناقش مفهوم دوال المتجهات الدالتين 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، وقد تكون مفيدة في سياقات مثل التنقل في الهياكل الشجرية.

عمق العودية الأقصى هو 5000 افتراضيًا، وهو سخي نسبيًا (القيمة الافتراضية في Python هي 1000). ويمكن تجاوز القيمة الافتراضية.

تحسين استدعاء الذيل ليس معيارًا في 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، وهي مفيدة أحيانًا، لكن غالبًا ما توجد أساليب أبسط.

تعديل عبر GitHub يفتح الرابط في نافذة أو علامة تبويب جديدة

تعلّم البرمجة الوظيفية