היכרות עם שפת Haskell - חלק 1

2025-06-01 - תכנות, שפות תכנות

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

רעיומות מרכזיים ומיינדסט

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

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

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

ביטויים

הסקל, בשונה משפה כמו Lua ובדומה לשפת C, לא מאפשרת לכתוב ביטויים "ככה סתם" בקובץ בלי לקשור אותם לשם כלשהו. כלומר, היא אינה שפת סקריפט.

כדי לקשור ביטוי לשם נרשום את השם, אחריו את התו שווה (=), ואז את הביטוי. לדוגמא:


calculation = 1 + 1

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


bigger = max 3 calculation

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


main = putStrLn (take 5 "Hello, world!")

אגב, את כל זה אתם יכולים לנסות אונליין ב-Haskell Playground.

סינטקס מוזר?

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


void main() { putStrLn(take(5, "Hello, world!")); }

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

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


threeOrBigger = max 3

בהסקל, התשובה היא ש-threeOrBigger היא ערך קצת יותר מעניין מ-bigger ממקודם - היא פונקציה. פונקציה שמקבלת מספר ואז קוראת לפונקציה max עם הערכים 3 והמספר שהזנו לה. אם כך, מה הערך של הביטוי הבא?


newResult = threeOrBigger 10

אם עניתם 10, צדקתם. הצורך שבה נהוג לחשוב על קוד שכתוב בהסקל הוא בעזרת "מודל ההחלפה" - כשאני רואה שם כלשהו, לדוגמה threeOrBigger, אני מחליף אותו (כמו קופי פייסט) בראש בביטוי אליו הוא שווה, שכפי שהגדרנו למעלה הוא max 3, ואז אנחנו מקבלים (max 3) 10, ואנחנו יכולים לחזור על התהליך הזה עד שאין מה להחליף (או הכל כבר ברור).

תוכלו לבדוק את התשובה בכך שתריצו את הקוד הבא:


main = putStrLn ("The result is: " <> show newResult)

newResult = threeOrBigger 10

threeOrBigger = max 3

הפונקציה show ממירה את הערך המספרי 10 למחרוזת "10" כדי שנוכל להדפיסה על המסך, והאופרטור <> משמש לשרשור שתי מחרוזות למחרוזות אחת.

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

פונקציות ואופרטורים

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


increment x = x + 1

שימו לב שאנחנו לא משנים את x שקיבלנו, אלא מחזירים חישוב שתוצאתו היא x + 1!

ולדוגמא, פונקציה עם שני פרמטרים + if expression. שימו לב שה else הוא חובה, מאחר שמדובר בביטוי שמחזיר ערך ולא בפקודה. שימו לב גם שכמו פייתון, הסקל משתמשת באינדנטציה (הזחה בעברית) בשביל סקופ. החוק בגדול: אם ביטוי מסויים הוא חלק מביטוי אחר, שימו אותו בהזחה אחת קדימה ביחס לביטוי הקודם.


max a b =
  if a > b
    then a
    else b

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


a <^> b =
  if a > b
    then a
    else b

או, מאחר וכבר הגדרנו את max:


(<^>) = max

הרבה מתכנתים לא אוהבים את הרעיון שאפשר להגדיר אופרטורים בשפה, מאחר ושמות עוזרים מאוד לקונטקסט ואופרטורים הם לפעמים סתם תוים ללא קונטקסט. ועדיין, יש לא מעט מקרים בהם אופרטורים עוזרים מאוד לקריאות. לדוגמא, אחד האופרטורים היחסית יותר מודרניים שכמה שפות אימצו (לדוגמא, Elm ו-Elixir) הוא |> (pipe) שמשחק את אותו תפקיד של הפייפ של bash. קחו שורת קוד שיש בה כמה פייפים ונסו לכתוב אותה באמצעות פונקציה רגילה. האם אתם מעדיפים את הגרסא עם האופרטור או הפונקציה? כתבו לי בתגובות 😉

הנה עוד דוגמה לאופרטור מאוד נוח שקיים בהסקל - הרכבה של פונקציות. בנוסף, נציג גם צורה לכתיבה של פונקציה אנונימית (חסרת שם) שנקראת גם "ביטוי למבדה":


f . g = \x -> f (g x)

הצורה של ביטוי למבדה נראת כך: \ מתחיל ביטוי למבדה, אחריו יש ארגומנטים לביטוי למבדה, ואחריו -> שמציין את תחילת גוף הפונקציה. אז במקרה הזה כאשר יש לנו שתי פונקציות f ו-g, הביטוי f . g מקביל לפונקציה שמקבלת ערך (נקרא לו x) ומחזירה כתוצאה את f (g x).

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

פונקציות מסדר גבוה

פונקציות שמקבלות פונקציות אחרות כארגומנטים הן מספיק מיוחדות כדי שיהיה להן שם ספציפי - פונקציות מסדר גבוה (higher-order functions).

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


sumOfDoubledOdds = sum . map (*2) . take 10 . filter (\x -> mod x 2 == 1)

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

  1. filter (\x -> mod x 2 == 1)
  2. take 10
  3. map (*2)
  4. sum

אז קודם כל אנחנו מפלטרים את הרשימה בכך שאנחנו מפעילים את הפונקציה
\x -> mod x 2 == 1 על כל אחד מהאיברים ברשימה, ושומרים את מי שמחזיר True.

אחר כך אנחנו לוקחים את 10 המספרים האי זוגיים הראשונים (או פחות אם אין מספיק).

לאחר מכן אנחנו ממפים (יענו map) כל איבר ברשימה (שנשארה) לאותו ערך *2 - וכן, *2 זה פשוט כתיב מקוצר ל
\x -> x * 2.

לבסוף, אנחנו סוכמים את כל האיברים ברשימה שנשארה.

אם מתחשק לכם להתקין הסקל על המחשב שלכם (באמצעות GHCup), תקבלו בנוסף כלי REPL בדומה לפייתון ונוד ששמו GHCi, שבאמצעותו תוכלו לנסות את הקוד למעלה:


$ ghci
GHCi, version 9.12.2: https://www.haskell.org/ghc/  :? for help
ghci> sumOfDoubledOdds = sum . map (*2) . take 10 . filter (\x -> mod x 2 == 1)
ghci> sumOfDoubledOdds [1,2,3,4,6,7,8]
22

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

---

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

השני, סדר חישובים עצל - סדר החישובים של הסקל מאפשר לקוד למעלה להיות הרבה יותר יעיל משאולי ציפיתם - במקום ארבעה מעברים על הרשימה, רק אחד קורה בפועל, ואנחנו יכולים לרשום שאנחנו רוצים קודם לפלטר את הרשימה ואז לבחור את 10 הראשונים, ועדיין נרוץ על הרשימה רק עד שנגיע למספר האי-זוגי העשירי! לא מאמינים לי? הנה דוגמא!


ghci> sumOfDoubledOdds [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,error "boom"]
200
ghci> sumOfDoubledOdds [1,2,3,4,5,6,7,8,9,10,11,12,13,14,error "boom"]
*** Exception: boom

HasCallStack backtrace:
  error, called at :8:52 in interactive:Ghci8

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

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

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

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

רוצים להגיב? בדקו מהי שאלת הסינון בעמוד הראשי.