• בלוג
  • לולאות בתכנות פונקציונאלי

לולאות בתכנות פונקציונאלי

28/09/2018

לולאות בתכנות פונקציונאלי בנויות אחרת מאשר לולאות של תכנות אימפרטיבי מאחר ותפקידן לעזור לנו להתמקד בפונקציה והקלט שלה. בואו נראה את ההבדל ומה הופך לולאה לפונקציונאלית.

1. הבעיה עם לולאות בקוד אימפרטיבי

המונח קוד אימפרטיבי מתיחס לקוד שבנוי מפקודות שמתבצעות אחת אחרי השניה. כך בנויות רוב שפות התכנות שאתם מכירים וזו פרדיגמה תכנותית שיש לה שורשים עוד ב Assembly וב Fortran. נתבונן בקוד הבא בשפת JavaScript:

let x = 0;

for (let i=0; i < 10; i++) {
  x += i * i;
}

console.log(`Sum(squares) = ${x}`);

הקוד מחשב את סכום כל ריבועי המספרים בין 0 ל-9 ומדפיס אותו. הקוד מורכב משני חלקים: בחלק הראשון מעלים בריבוע את כל אחד מהאיברים ובחלק השני סוכמים את התוצאות. תיאורטית את החלק הראשון יכלנו למקבל ואם היו לנו מספר מכונות יכלנו אולי לשפר ביצועים אם נבקש ממכונה אחרת להעלות בריבוע מחצית מהערכים. לגבי סיכום הערכים אין הרבה ערך למיקבול כאן כי בכל מקרה צריך לסכום את כולם יחד.

אבל בגלל מבנה הלולאה אנחנו לא באמת יכולים לדעת את כל זה בלי להבין את הקוד. וגם אחרי שנקרא את הקוד לא בטוח שנבין מה מותר ומה אסור לעשות איתו ואיזה סוגים של פיצולים יסתדרו.

בשפות תכנות פונקציונאליות המבנה הבסיסי של לולאה משתנה. המבנים הפונקציונאלים נראים אחרת ומתנהגים אחרת כך שהם מאפשרים הרבה יותר בקלות להבין איזה משימות אפשר לפצל ואיזה אי אפשר. הבנה של הלולאות ואיך הן עובדות תעזור לכם לשלב קוד פונקציונאלי בתוכניות שלכם גם בשפות שאינן פונקציונאליות ולהבין טוב יותר את ההבדל בין הגישות.

2. לולאות Map

לולאה בתכנות פונקציונאלי היא בסך הכל פונקציה. לולאת map למשל היא פונקציה שמקבלת פונקציה מסוימת ומפעילה אותה על סידרה של איברים ומחזירה סידרה חדשה של תוצאות.

בדוגמא שלנו נניח שיש לנו את סידרת המספרים:

[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

אז אנחנו יכולים לחשוב על הפעולה "איקס בריבוע" בתור פונקציה ואפילו להגדיר אותה ככזו ב JavaScript:

function sqr(x) {
    return x * x;
}

ולולאת map היא פקודה שכשנעביר לה את סידרת הערכים שהגדרנו ואת הפעולה שהגדרנו היא תחזיר סידרת ערכים חדשה של כל התוצאות:

[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(sqr);

// returns: [ 0, 1, 4, 9, 16, 25, 36, 49, 64, 81 ]

מה שהופך את הפונקציה map למעניינת זה שכמעט לא משנה מה תעבירו בתור הפונקציה, לדפדפן יש חופש מלא בבחירת סדר הפעולות בו map תפעל. בתוך התנאים האלה הדבר היחיד שחשוב זה לקבל מערך של תוצאות חדשות לגמרי.

זאת הסיבה שקוד מהסוג הזה גורם למתכנתים רבים להרגיש עצבנות:

let i = 0;
function addIndex(o) {
  o.idx = i++;
  return o;
}

const items = [{ color: 'red' }, { color: 'blue' }, { color: 'green' }];
items.map(addIndex);

הקוד משתמש בלולאת map בצורה לא נכונה (וכן זו בעיה ש JavaScript מאפשרת את זה. שפה פונקציונאלית אמיתית לא היתה מאפשרת). פקודת map לא אמורה לשנות את הערכים במערך, ולא אמורה להיות מושפעת מסדר הפעולות או ממידע חיצוני. זאת לא המטרה שלה.

הפקודה map היא פקודה מובנית ב JavaScript וגם די קל לבנות אחת בצורה אימפרטיבית - פשוט כותבים לולאת for שמריצה את הפונקציה שקיבלתם על כל אחד מהאיברים ומחזירים מערך של תוצאות:


function map(f, seq) {
  const res = [];
  for (let i=0; i < seq.length; i++) {
    res.push(f(seq[i]));
  }
  return res;
}

אבל זה יהיה קצת לירות לעצמנו ברגל כי כל הרעיון של שפה פונקציונאלית זה שהיא לא תכלול מבנים אימפרטיבים. לכן ברוב השפות הפונקציונאליות יש תמיכה טובה ברקורסיות ומימוש map עשוי להיראות בערך כך:

function map(f, seq) {
  if (seq.length === 0) {
    return [];
  }

  return [f(seq[0]), ...map(f, seq.slice(1))];
}

3. לולאות Filter

תפקידה של לולאת filter הוא לסנן איברים מרשימה על פי תנאי מסוים. הפקודה לוקחת רשימה ופונקציה ומחזירה רשימה חדשה רק של האיברים עבורם הפונקציה החזירה ערך אמת. לדוגמא הקוד הבא:

const even = x => x % 2 == 0;
console.log([0, 1, 2, 3, 4, 5, 6, 7, 8, 9].filter(even));

ידפיס רק את המספרים הזוגיים בין 0 ל-9.

בדומה ל map גם כאן אנחנו מעדיפים לא להשתמש במידע חיצוני ולא לשנות את האיברים בזמן האיטרציה וכך אנחנו מייצרים מבנה שהרבה יותר קל לדבר עליו או על המיקבול שלו: אין שום בעיה להריץ filter מכמה תהליכים במקביל והסדר בו אתם בודקים אם איבר מסוים מתאים לתנאי בכלל לא חשוב כאן.

מימוש filter בצורה רקורסיבית נראה בערך כך:

function filter(f, seq) {
  if (seq.length === 0) {
    return [];
  }

  if (f(seq[0])) {
    return [f(seq[0]), ...filter(f, seq.slice(1))];
  } else {
    return filter(f, seq.slice(1));
  }
}

4. לולאות Reduce

סוג הלולאות האחרון לפוסט הזה הוא לולאות Reduce. בניגוד לשתיים הקודמות לולאות Reduce נועדו לייצג חישובים בהם יש חשיבות לסדר הפעולות על האיברים ויש קשר בין חישוב על איבר מסוים לתוצאה שהגענו אליה עד לכאן.

בדומה ל map גם לולאת reduce מקבלת כקלט פונקציה אבל הפעם היא תעביר לפונקציה גם ערך מיוחד בשם Accumulator. מה שמיוחד ב Accumulator זה שכל פונקציה מקבלת את אותו Accumulator שהקודמת החזירה.

לכן נוכל להשתמש במבנה זה כדי לחשב סכום איברים במערך די בקלות:

function add(acc, value) {
  return acc + value;
}

console.log([0, 1, 2, 3, 4, 5, 6, 7, 8, 9].reduce(add, 0));

הפונקציה add מופעלת בפעם הראשונה עם ערך Accumulator שהוא 0 (כי זה מה שהעברנו כפרמטר שני ל reduce) ועם פרמטר value שהוא המספר 0, כלומר הערך הראשון במערך. מה שהפונקציה תחזיר יעבור בתור ה Accumulator לפונקציה בעת ההפעלה השניה שלה על הערך 1 וכך ממשיכים הלאה עד שמסיימים את הרשימה. הפונקציה האחרונה תקבל בתור Accumulator את המספר 36, תוסיף לו 9 וכך נגיע לסכום 45.

באותו אופן נוכל להפוך רשימה של ערכים זהים לרשימת מספרים בסדר עולה:

[...Array(10)].reduce((acc, val) => [...acc, acc.length], [])

// result: [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ]

את reduce עצמה גם כן יחסית קל לבנות בצורה רקורסיבית:

function reduce(f, seq, acc) {
  if (seq.length === 0) {
    return acc;
  }
  const value = seq[0];

  return reduce(f, seq.slice(1), f(acc, value));
}

5. שילוב לולאות

עכשיו שבנינו את כל העולם סביב הלולאות החדשות שווה לציין יכולת שלהן שלא היתה קיימת בלולאות ה for ו while האימפרטיביות, והיא שילוב לולאות.

נחזור רגע למשחק שהתחלנו ממנו עם חישוב סכום הריבועים. במקום לכתוב לולאה אימפרטיבית נוכל עכשיו להשתמש בדברים שלמדנו כדי לכתוב:

function seq(n) {
  return [...Array(n)].reduce((acc, val) => [...acc, acc.length], [])
}

function sqr(x) { return x * x; }
function add(acc, value) { return acc + value; }

console.log(
  seq(10)
  .map(sqr)
  .reduce(add, 0)
);

קוד פונקציונאלי טיפוסי משתמש בשילוב של הרבה לולאות ממוקדות במקום בלולאה אחת גדולה שבתוכה כותבים את כל הפעולות יחד. השינוי הופך את הקוד לקל יותר לקריאה, להרכבה ולבדיקה.

תכנות פונקציונאלי מביא איתו שינוי חשיבה ושינוי סיגנון. לאחרונה יותר ויותר מתכנתים משלבים רעיונות פונקציונאליים בקוד שהם בעיקר עקב הפופולריות של עבודה עם Immutable Data ותשתיות פיתוח המתבססות על רעיונות אלה. בצד השרת הדבר מאפשר Scaling אופקי באמצעות הוספת עוד מכונות יחסית בקלות. בצד הלקוח הדבר מאפשר זיהוי שינויים יעיל יותר בעמוד וכך כתיבת ארכיטקטורה מבוססת Components בקלות רבה יותר בהשוואה למימוש אימפרטיבי.