שפות תכנות - Haskell

2025-04-10 - תכנות, שפות תכנות

לקראת סוף שנות השמונים עלתה ההתעניינות בשפות פונקציונליות בקרב מספר לא מבוטל של חוקרים בתחום מדעי המחשב. ספציפית, בשפות פונקציונליות בעלות סדר הערכה לא-דקדקני (באנגלית, non-strict evaluation). השפה הפופולארית ביותר בתחום הייתה Miranda, אך שפה זו לא הייתה "חופשית" או "קוד פתוח". לכן נוצר מצב בו יש מספר דו-ספרתי של שפות תכנות מסוג זה בשימוש, ומאמרים בתחום נכתבו בשלל שפות. מתישהו, לחבר'ה האלה נמאס והם החליטו להקים ועדה שתגדיר שפה סטנדרטית וחופשית אחת שבה כולם יוכלו להשתמש למחקר שלהם.

כך התחיל הסיפור של Haskell, שפת תכנות משנת 90 (לפני גו, רובי, ג'אווה ופייתון) שקצת נראית כאילו היא מהעתיד.

למרות ש-Haskell אינה שפה מאוד פופולארית, ההשפעה שלה על עולם התכנות מאוד מורגשת ברגע שיודעים לחפש אותה, ואפשר לראות את ההשפעה שלה בשפות כמו Java, C#, C++, Python, Rust, Swift, Scala, Clojure, ועוד.

למרות ש-Haskell התחילה כפרויקט עם מטרות אקדמיות, השפה נמצאת בשימוש בפרויקטים מעשיים בתעשייה ובתחום האופן סורס, ותוכלו למצוא קומפיילרים, מערכות ווב, ואפילו משחקים הכתובים בשפה. בין הפרוייקטים האופן סורס הבולטים מביניהם תמצאו את pandoc, xmonad, ו-shellcheck.

הגדרות

Haskell היא שפת תכנות פונקציונלית טהורה בעלת טיפוסיות סטטית ועם סמנטיקה עצלה. בואו נסקור מה המילים האלו אומרות.

פונקציונלית

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

אם יצא לכם לשרשר פעולות ב-bash ע"י האופרטור |, עשיתם תכנות פונקציונלי במידה מסויימת.

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

שפת תכנות פונקציונלית היא כזו שמקדמת את סגנון התכנות הפונקציונלי, והופכת אותו לנגיש וקל.

הנה כמה פיצ'רים שבדרך כלל תמצאו בשפות תכנות פונקציונליות:

> ביטויים בכל מקום (Expression oriented)

בהרבה שפות אימפרטיביות (כמו שפת C) קיימת הפרדה מסויימת בין ערכים וחישובים כמו "hello" או 1 + 1, לבין פקודות כמו if ו- while. לפקודות אין תוצאה והן לא מחזירות ערך שאפשר לשים במשתנה.

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

> פונקציות כ"אזרחים מהשורה הראשונה" (first-class)

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

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

> ערכים בלתי ניתנים לשינוי (immutable)

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

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

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

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

טיפוסיות סטטית

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

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

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

    הסקל מאחדת את השיטה הראשונה, השניה, ווריאציה קצת שונה של השלישית, לקונספט אחד שנקרא מבני טיפוסים אלגבריים (Algebraic Data Types, או בקיצור ADTs). הנה דוגמא:

    data Command
        = SendMessage Target Text
        | Join Channel
        | Part Channel
        | Quit

    בטיפוס אחד שהגדרנו, ששמו Command, אפשר לראות את שלוש השיטות ביחד: אנחנו משתמשים ב | כדי להבדיל בין ערכים אפשריים שונים מאותו טיפוס, לכל ערך אפשרי יש תגית שהיא המילה הראשונה, והטיפוסים שבאים אחריה הם חלק מהמבנה של התגית. כך למעשה אנחנו מקבלים וריאציה של union שנקראת tagged union - יש לנו אלטרנטיבות שונות, אבל לכל אלטרנטיבה מוצמדת תגית שאומרת מה האלטרנטיבה. בנוסף, לכל תגית יכולים להיות אפס או יותר טיפוסים נלווים, וככה אנחנו יכולים למדל enum-ים (בלי טיפוסים נלווים בכלל) או struct-ים (עם תגית אחת בלבד ומספר טיפוסים נלווים).

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

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

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

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

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

עצלה

כשאנחנו אומרים ששפת תכנות היא עצלה, אנחנו מדברים על סדר החישובים (evaluation order) שלה. כלומר, מתי בדיוק אנחנו מריצים חישובים, ובאיזה סדר?

למי שרגיל לשפות תכנות פופולאריות, זו נשמעת כמו שאלה קצת מטופשת עם תשובה פשוטה. לשפות פופלריות (ולרוב אלו שלא) יש סדר חישוב דקדקני (strict). כלומר, כשאנחנו מגדירים משתנה חדש, למשל x = 1 + 1, אנחנו קודם כל מחשבים את הביטוי, ואז מציבים את ערך התוצאה בx. ואם אנחנו קוראים לפונקציה עם ארגומנטים, אנחנו קודם מחשבים את כל הארגומנטים, ואז שולחים את התוצאות.

הסקל, למרבה ההפתעה, עושה משהו קצת אחר.

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

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

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

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

טהורה

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

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


#include <stdio.h>

int read_int_plus(int other) {
    int result;
    scanf("%d", &result);
    return result + other;
}

int main(void) {
    int result1 = read_int_plus(1);
    int result2 = read_int_plus(2);

    printf("%d\n", result2 - result1);

    return 0;
}

בתוכנית הזו, אני רוצה לקרוא שני מספרים מהקלט הסטנדרטי, אל הראשון לחבר את המספר 1, ואל השני את המספר 2. לבסוף, אני רוצה לחסר את המספר השני מהראשון ולהדפיס את התוצאה לפלט הסטנדרטי. לדוגמא עם המספרים 1 ו-0 כקלט, אצפה לקבל את התוצאה 0 כפלט. ואכן, בשפת סי שלה סדר חישובים דקדקני, אנחנו נחשב את החישובים אחד אחרי השני. אך לו ל-C היה סדר חישוב לא-דקדקני, מה הייתה התוצאה? חישבו על זה לרגע...

...

התוצאה הייתה 2! למה? כי לא היינו מבצעים חישובים עד שהיינו מגיעים לprintf, ואז היינו מחשבים החיסור, שצריך את שני הארגומנטים שלו כדי להחזיר תוצאה, ואז היינו מחשבים אותם משמאל לימין, כלומר את result2 ראשון ואז את result1, ומאחר שכדי לחשב את התוצאה של result2 אנחנו צריכים לקרוא מהקלט, אז הרי אנחנו קוראים את המספר הראשון, למרות שרצינו שהתוצאה של result2 תשתמש במספר השני שבקלט!

אז הדבר המעניין (מאוד) שהסקל הוסיפה, הוא שאי אפשר להתייחס לפונקציה כמו read_int_plus כפונקציה שמחזירה int אלא זו פונקציה שמחזירה IO Int, כלומר חישוב, שעלול לכלול אפקטי I/O, שברגע שנבצע אותו יחזיר לנו int. בנוסף, היא מגדירה דרך לקבוע את הסדר בין הפעולות שמחזירות חישובים הכוללים IO.

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

-

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

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

איך נראית תוכנית בשפת Haskell?

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

אז עכשיו שתיאמנו ציפיות, בואו נסתכל על תוכנית יחסית פשוטה שמטרתה לחשוף מידע מדאטהבייס של SQLite3 בצורת REST API.

(אם אתם ממהרים, אתם יכולים לראות קוד המקור המלא כאן)


{-# language GHC2024, OverloadedStrings #-}

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

זה פיצ'ר שמפחיד הרבה אנשים, אבל בפועל אני לא חושב שהוא כזה מאיים.


import Data.Char (isAlphaNum)
import Data.String
import Data.Text qualified as T
import Data.ByteString.Lazy qualified as BL
import Data.Text.Encoding qualified as T

import Database.Sqlite.Easy qualified as Sqlite

import Web.Twain qualified as Twain
import Network.Wai.Handler.Warp qualified as Warp
import qualified Network.Wai.Middleware.RequestLogger as Logger
import Control.Monad.IO.Class (liftIO)

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

המעטפת

כל שפת תכנות מוצלחת מנסה לעודד אותנו לגשת לבניית תוכנה בצורה מסויימת. כפי שהזכרנו, הסקל היא שפה טהורה, כלומר קיימת הפרדה בין קוד שיכול לבצע אפקטים ופעולות I/O, וקוד שלא. ההפרדה הזאת מעודדת תוכניתני הסקל בהרבה מקרים לכיוון תבנית שנקראת Functional core, imperative shell - לדחוף את כל הקוד שמתעסק באפקטים לשכבת מעטפת חיצונית דקה, ולהשאיר את הגרעין, ה"ביזנס לוג'יק", חסר אפקטים.

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


main :: IO ()
main = do
  pool <- Sqlite.createSqlitePool "./literest.db"
  putStrLn "Server running at http://localhost:8888 (ctrl-c to quit)"
  Warp.run 8888 (Logger.logStdoutDev (app pool))

כאן אנחנו מגדירים את השם main - נקודת ההתחלה של התוכנית. בסינטקס של השפה, :: מגדיר טיפוס, ו= מגדיר "ערך", או binding.

לפונקציות בשפת הסקל אנחנו לא קוראים עם סוגריים. f x (g y) z אומר שאנחנו קוראים לפונקציה f עם שלושה ארגומנטים: הראשון הוא x, השני הוא g y, והשלישי הוא z, כאשר הפרמטר השני הוא התוצאה של הקריאה לפונקציה g עם הארגומנט y.

המילה השמורה do מתארת התחלה של קטע שבו אנחנו קובעים רצף של פקודות ואת סדרן. בתוך רצף כזה, אנחנו יכולים לחלץ תוצאה מתוך קונטקסט I/O באמצעות הסינטקס <-. בקטע הקוד הזה, אנחנו מריצים פעולת I/O שיוצרת "pool" של חיבורים לדאטהבייס ומחלצים ממנה את ה-pool כך שנוכל להשתמש בו בפעולה הבאה.

כל זה מתקשר למה שדיברנו עליו כשדיברנו על הסקל כשפה פונקציונלית "טהורה".

הפקודה האחרונה מריצה שרת ווב בפורט 8888 שהתנהגותו מוגדרת באמצעות הפונקציה app אותה נגדיר מיד.


app :: Sqlite.Pool Sqlite.Database -> Twain.Application
app dbpool =
  foldr ($)
    (Twain.notFound (Twain.send (Twain.text "404 Not Found.")))
    [ Twain.get "/:rootField" (handleRootField dbpool)
    ]

אנחנו מגדירים את השם app שהוא פונקציה המקבלת את ה-pool של ה-database, ומחזירה הגדרה של אפליקציית ווב.

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


GET /user?project=name,email&select=or(id.eq.2,name.eq.Sammy)

הדוגמא למעלה מבקשת שורות המכילות את העמודות name ו-email מתוך הטבלה user, ובתנאי שהעמודה id שווה ל-2, או העמודה name שווה ל-Sammy.

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


handleRootField :: Sqlite.Pool Sqlite.Database -> Twain.ResponderM a
handleRootField dbpool = do
  -- Parsing
  rootField <- Twain.param "rootField"
  projection <- parseProjection <$> Twain.paramMaybe "project"
  selection <- parseSelection =<< Twain.paramMaybe "select"

  -- Constructing SQL query
  let
    query = Query (Name rootField) projection selection
    sql = compile query
  liftIO (print sql)

  -- Running the query
  res <- liftIO (runSql dbpool sql)

  -- Sending the result back to the user
  case res of
    [[Sqlite.SQLText result]] ->
      Twain.send
        ( Twain.raw
          Twain.ok200
          [(Twain.hContentType, "application/json; charset=utf-8")]
          (BL.fromStrict (T.encodeUtf8 result))
        )
    _ ->
      error "Internal error."

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

בחלק הבא נדבר על המבנים לשאילתות שלנו.

מודל

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


data Query
  = Query
    { rootField :: Name
    , projection :: [Name]
    , selection :: BooleanExpression
    }
    deriving Show

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

השורה האחרונה קצת יותר מיוחדת. היא מבקשת מהקומפיילר ליצור מימוש אוטומטי של typeclass בשם Show. ה-typeclass הוא סוג של ממשק (interface) (אם יצא לכם לכתוב Rust, אז הכירו את ההשראה ל-traits). במקרה הזה אנחנו מקבלים פונקציה שיכולה להמיר ערך מהטיפוס לייצוג טקסטואלי.


newtype Name
  = Name T.Text
    deriving Show

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


data BooleanExpression
  = Eq Name Value
  | And [BooleanExpression]
  | Or [BooleanExpression]
  | Not BooleanExpression
    deriving Show

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


data Value
  = Text T.Text
    deriving Show

type Error = String

אנחנו מסיימים עם הגדרה של עוד שני טיפוסים שימושיים.

מודל עבור SQL

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


data SQL
  = SQL String [Value]
  deriving Show

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

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


instance Semigroup SQL where
  (<>) (SQL str1 vals1) (SQL str2 vals2) =
    SQL (str1 <> str2) (vals1 <> vals2)

instance Monoid SQL where
  mempty = SQL "" []

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


instance IsString SQL where
  fromString str = SQL str []

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


(<+>) :: SQL -> SQL -> SQL
(<+>) s1 s2 = s1 <> " " <> s2

unwordsSQL :: [SQL] -> SQL
unwordsSQL = intercalate " "

intercalate :: Monoid m => m -> [m] -> m
intercalate divider = \case
  x : y : xs -> x <> divider <> intercalate divider (y:xs)
  xs -> mconcat xs

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

בחלק הבא נשתמש בממשק שהגדרנו כדי לבנות שאילתת SQL מה-Query שלנו.

קומפילציה של שאילתא

בחלק הזה נקמפל ערך של שאילתא מטיפוס Query לקוד SQL.

אני אספיילר מראש ואגיד ששאילתא מהצורה:


GET /user?project=name,email&select=or(id.eq.2,name.eq.Sammy)

תתורגם ל-SQL הבא:


SELECT
  json_group_array(
    json_object(
      'name', name,
      'email', email
    )
  )
  FROM
    user
  WHERE
    (id = ?) OR (name = ?)

עם הערכים:


 [Text "2",Text "Sammy"]

אז יאללה.


compile :: Query -> SQL
compile query =
  case query of
    Query { rootField, projection, selection } ->
      unwordsSQL
        [ "SELECT"
        , "json_group_array(json_object(" <>
          intercalate ", " (map compileNameProj projection)
          <> "))"
        , "FROM"
        , compileName rootField
        , "WHERE"
        , compileBoolExpr selection
        ]

כפי שהבטחתי, אנחנו:

  • משרשרים רשימה של SQL-ים עם רווחים ביניהם באמצעות הפונקציה unwordsSQL.

  • יכולים פשוט לרשום "SELECT" ולקבל ערך מטיפוס SQL.

  • מקמפלים כל אלמנט מה-Query במקומו הראוי באמצעות פונקציות נלוות.


compileBoolExpr :: BooleanExpression -> SQL
compileBoolExpr = \case
  Eq field value ->
    compileName field <+> "=" <+> compileValue value
  And [] ->
    "true"
  And exprs ->
    "(" <> intercalate " AND " (map compileBoolExpr exprs) <> ")"
  Or [] ->
    "false"
  Or exprs ->
    "(" <> intercalate " OR " (map compileBoolExpr exprs) <> ")"
  Not expr ->
    "NOT" <+> "(" <> compileBoolExpr expr <> ")"

אנחנו מקמפלים ביטוי בוליאני. case עובד כמו switch בשפות אחרות, רק שאנחנו יכולים לגשת גם לערכים הנלווים לתגית. בנוסף, אנחנו יכולים גם לנסות להתאים אותם לתבנית כלשהי, כמו שאנחנו עושים עבור And [] המתאר ביטוי And עם רשימה ריקה.

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


compileNameProj :: Name -> SQL
compileNameProj field =
  "'" <> compileName field <> "', " <> compileName field

אנחנו מתאימים את שם השדה ועמודה הרלוונטית לפי הפורמט שהפונקציה json_object של SQLite מבקשת.


compileValue :: Value -> SQL
compileValue value = SQL "?" [value]

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


compileName :: Name -> SQL
compileName (Name field)
  | not (T.all isAlphaNum field) =
    error ("Field '" <> T.unpack field <> "' contains non alpha numeric characters")
  | otherwise = SQL (T.unpack field) []

ולבסוף, אנחנו בודקים את השמות של כל השדות מול תנאי שיבטיח שהם לא מכילים תווים מסוכנים והופכים אותם ל-SQL אם הכל תקין.

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

פענוח הבקשה

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


parseProjection :: Maybe T.Text -> [Name]
parseProjection =
  maybe [] id . fmap (map Name . T.split (',' ==))

parseSelection =
  maybe (pure (And [])) (either error pure . parseBooleanExpression)

parseBooleanExpression :: T.Text -> Either Error BooleanExpression
parseBooleanExpression text =
  case T.break (`elem` ['.', '(']) text of
    (_, "") -> Left "Unsupported format."
    (field, value)
      | field == "not" -> do
        values <- parseComplex value
        case values of
          [v] -> Right (Not v)
          _ -> Left "Not accepts exactly one boolean expression"

      | field == "and" ->
        And <$> parseComplex value

      | field == "or" ->
        Or <$> parseComplex value

      | otherwise ->
        parseField field value

parseComplex :: T.Text -> Either Error [BooleanExpression]
parseComplex text =
  case T.uncons text of
    Just ('(', rest) ->
      case T.unsnoc rest of
        Just (fields, ')') ->
          traverse parseBooleanExpression (T.split (',' ==) fields)
        _ -> Left "Unsupported Format"
    Nothing -> Left "Unsupported Format"

parseField :: T.Text -> T.Text -> Either Error BooleanExpression
parseField field text =
  case T.split ('.' ==) text of
    ["", "eq", v] -> Right (Eq (Name field) (Text v))
    _ -> Left "Unsupported operation or format."

דיבור עם הדאטהבייס

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


runSql :: Sqlite.Pool Sqlite.Database -> SQL -> IO [[Sqlite.SQLData]]
runSql pool (SQL sql values) = do
  Sqlite.withPool pool
    (Sqlite.runWith (fromString sql) (fmap fromValue values))

fromValue :: Value -> Sqlite.SQLData
fromValue = \case
  Text s -> Sqlite.SQLText s

הקוד בפעולה

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

נגדיר את הדאטהבייס שלנו:


$ sqlite3 ./literest.db
SQLite version 3.46.1 2024-08-13 09:16:08
Enter ".help" for usage hints.
sqlite> create table user(id int, name text, email text);
sqlite> insert into user values (1, 'haskell', 'haskell@haskell.org');
sqlite> insert into user values (2, 'Dean', 'dean@supernatural.com');
sqlite> insert into user values (3, 'Sammy', 'sam@supernatural.com');
sqlite> select * from user;
1|haskell|haskell@haskell.org
2|Dean|dean@supernatural.com
3|Sammy|sam@supernatural.com

אפעיל את התוכנית לאחר קומפילצייה בטרמינל אחד:


$ ./literest.hs
Server running at http://localhost:8888 (ctrl-c to quit)

ובחלון נפרד אריץ שאילתא:


$ curl "http://localhost:8888/user?project=name,email&select=or(id.eq.2,name.eq.Sammy)" | jq
[
  {
    "name": "Dean",
    "email": "dean@supernatural.com"
  },
  {
    "name": "Sammy",
    "email": "sam@supernatural.com"
  }
]

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


SQL "SELECT json_group_array(json_object('name', name, 'email', email)) FROM user WHERE (id = ?) OR (name = ?)" [Text "2",Text "Sammy"]
GET /user
  Params: [("project","name,email"),("select","or(id.eq.2,name.eq.Sammy)")]
  Accept: */*
  Status: 200 OK 0.000189295s

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

איך ללמוד Haskell?

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

> Reading Simple Haskell
סדרת שקופיות קצרה המציגה את הפיצ'רים הבסיסיים של הסקל.
> Learn Haskell by building a blog generator
ספר אינטרנטי מונחה-פרויקט שמלמד הסקל תוך כדי בניית תוכנית.
> Haskell Beginners 2022 Course
4 הרצאות יוטיוב באורך של שעה כל אחת שסוקרים את עיקר הפיצ'רים של הסקל וכוללים תרגילים.

איך לא ללמוד Haskell?

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

Learn You a Haskell for Great Good

LYAH (בקיצור) הוא כנראה הספר הכי פופולרי ללימוד הסקל. זהו ספר חינמי, נחמד וקליל שהרבה נהנו לקרוא, ועליו הרבה אנשים אוהבים להמליץ.

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

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

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

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

רדיפה אחרי הדבר החם הבא

לעיתים קרובות השיח בקהילות האינטרנטיות של הסקל סובב סביב נושאים "bleeding edge", כלומר רעיונות חדשים ולא בהכרח מבוססים כ-"best practices". שווה לשים לב לכך שלא תמיד נושאים אלו משרתים מטרה שרלוונטית לרוב הפרויקטים, ובפרט לפרויקטים שלכם.

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

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

כמה פרוייקטים מעניינים הכתובים ב-Haskell

הסקל היא שפת תכנות לשימוש כללי ואפשר להשתמש בה לתחומים רבים. שני התחומים הכי נפוצים עבורה הם בניית קומפיילרים/מימוש שפות תכנות, ותכנות Backend.

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

> PostgREST
שרת שחושף RESTful API של דאטהבייס PostgreSQL קיים וההשראה לתוכנית שכתבנו בפוסט הזה.
> hledger
תוכנה המשמשת לחשבונאות באמצעות טקסט.
> Project: M36
מנוע אלגברה רלציונית מתקדם שמושפע במידה רבה מהכתבים של כריס דייט.
> ShellCheck
תוכנה סופר שימושית שמבצעת אנליזה סטטית על סקריפטי באש ומוצאת בעיות ובאגים.
> SimpleX-Chat
פלטפורמת צ'אט ללא הזדהות ששמה דגש גבוה מאוד על פרטיות.
> Defect Process
משחק אקשן האק-אנד-סלאש דו מימדי.

סיכום

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

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

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