הבלוג של ינון פרק

טיפים קצרים וחדשות למתכנתים

תיקונים קלים, תיקונים קשים ובדיקות

08/12/2023

בפרויקט Node.JS שעבדתי עליו מצאתי את השורה הזאת:

const [host, port] = address.split(':');

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

התיקון הקל הוא בסך הכל:

const [host, port = '8080'] = address.split(':');

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

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

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

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

טיפ סקאלה: החזרת תוצאה עתידית

07/12/2023

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

הפונקציה הבאה מייצרת ומחזירה רשימה של מספרים:

  def createNumbers(): List[Int] =
    (1 to 10).map(_ =>
      Thread.sleep(Random.nextInt(500))
      Random.nextInt(100)).toList

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

createNumbers().foreach(println(_))

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

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

  def createFutureNumbers(): List[Future[Int]] =
    (1 to 10).map(_ => Future[Int] {
      Thread.sleep(Random.nextInt(500))
      Random.nextInt(100)
    }).toList

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

val futures = createFutureNumbers()
futures.foreach(f => f.onComplete(i => println(i.get)))

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

  @main
  def f(): Unit =
      val futures = createFutureNumbers()
      futures.foreach(f => f.onComplete(i => println(i.get)))
      Await.ready(Future.sequence(futures), Duration.Inf)

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

  def f(): Unit =
      val futures = createFutureNumbers().map {f =>
        f.flatMap(i => Future({
          Thread.sleep(i)
          i * i
        }))
      }

      futures.foreach(f => f.onComplete(i => println(i.get)))
      Await.ready(Future.sequence(futures), Duration.Inf)

לא יכול ללמד את זה

06/12/2023

שני המימושים הבאים בפייתון לזיהוי האם מספר הוא ראשוני שקולים לגמרי:

# v1 -
def is_prime_v1(n):
    for i in range(2, int(n ** 0.5) + 1):
        if n % i == 0:
            return False
    return True

# v2 -
from math import sqrt
def is_prime_v2(n):
    return not any(n % i == 0 for i in range(2, int(sqrt(n)) + 1))

ההבדל היחיד הוא כמה מושגים צריך להכיר כדי להבין את הקוד. במימוש הראשון מספיק להבין מה זה פונקציה ולהתמודד עם לולאות for ו range. המימוש השני כבר דורש היכרות עם Generator Comprehension, עם הפונקציה any ועם פקודת import.

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

מכתב פתוח לתלמיד שנרדם (או: ואם אני צריך עוד זמן)

05/12/2023

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

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

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

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

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

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

הקצב היחיד שעובד הוא הקצב שעובד עבורך.

טיפ סקאלה: הדפסת דיבג באמצע Pipeline

04/12/2023

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

(->> 10
  (range)
  (map #(* %1 %1))
  (reduce +)
  (println))

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

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

(defn debug [n]
  (println n)
  n)

(->> 10
  (debug)
  (range)
  (debug)
  (map #(* %1 %1))
  (debug)
  (reduce +)
  (println))

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

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

  println(
    (0 to 9)
      .map(_.pow(2))
      .sum
  )

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

לשמחתנו מנגנון ה Extensions של סקאלה מאפשר לפתור את שתי הבעיות יחסית בקלות. אני מגדיר Extension גנרי שיוסיף את הפונקציה debug לכל ערך בשפה. זה נראה ככה:

extension [A](value: A)
  def debug(): A =
    println(value)
    value
}

@main
def main(): Unit =
  println(
    (0 to 9)
      .debug()
      .map(_.pow(2))
      .debug()
      .sum
  )

והפלט:

NumericRange 0 to 9
Vector(0, 1, 4, 9, 16, 25, 36, 49, 64, 81)
285

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


extension [A](value: A)
  def debug(): A =
    println(value)
    value

  def eagerDebug(): A = value match {
    case it: Iterable[_] =>
      println(it.toList)
      value
    case it: Iterator[_] =>
      println(it.toList)
      value
    case _ =>
      println(value.getClass)
      value
  }

@main
def main(): Unit =
  println(
    (0 to 9)
      .eagerDebug()
      .map(_.pow(2))
      .debug()
      .sum
  )

ואם כבר הוספנו את debug נוכל להשתמש בה גם בסוף הפייפליין כדי להדפיס בסגנון קלוז'ר:

@main
def main(): Unit =
  (0 to 9)
    .eagerDebug()
    .map(_.pow(2))
    .debug()
    .sum
    .debug()

סקריפטים ב JavaScript? תנו צ'אנס לדינוזאור

03/12/2023

אחת הבעיות בשימוש ב node.js לכתיבת סקריפטים היא הקובץ package.json, או יותר נכון הצורך לצרף אותו לסקריפט כדי להתקין את התלויות. התוכנית הבאה ב node.js מתוך קוד הדוגמה של המודול wikipedia על npm לא יכולה לרוץ לבד:

const wiki = require('wikipedia');

(async () => {
    try {
        const page = await wiki.page('Batman');
        console.log(page);
        //Response of type @Page object
        const summary = await page.summary();
        console.log(summary);
        //Response of type @wikiSummary - contains the intro and the main image
    } catch (error) {
        console.log(error);
        //=> Typeof wikiError
    }
})();

בשביל שהיא תעבוד אני צריך ליצור איתה בתיקיה קובץ package.json ולהפעיל npm install או להפעיל

npm install wikipedia

משורת הפקודה לפני הרצה.

עכשיו בואו נראה איך אותו טריק יעבוד עם דינו. דבר ראשון צריך לשנות את שורת ה require ל import, ולציין שאני טוען את המודול מ npm כך שקוד התוכנית יהיה:

import wiki from 'npm:wikipedia';


(async () => {
    try {
        const page = await wiki.page('Batman');
        console.log(page);
        //Response of type @Page object
        const summary = await page.summary();
        console.log(summary);
        //Response of type @wikiSummary - contains the intro and the main image
    } catch (error) {
        console.log(error);
        //=> Typeof wikiError
    }
})();

ומכאן זה רק משתפר. אני מריץ את הקוד ודינו מתחיל להזהיר אותי:

$ deno run demo.js

⚠️  ┌ Deno requests env access to "npm_config_no_proxy".
   ├ Run again with --allow-env to bypass this prompt.
   └ Allow? [y/n] (y = yes, allow; n = no, deny) >

שים לב, הוא אומר, הסקריפט מעוניין לקרוא מידע ממשתנה סביבה ואפילו כותב את השם של משתנה הסביבה. אני יכול לאשר עם y או להריץ מחדש עם --allow-end כדי שלא ישאל יותר על גישה למשתני סביבה. אני מאשר וממשיכים לשאלות הבאות. כך זה נראה בסוף:

$ deno run demo.js

✅ Granted env access to "npm_config_no_proxy".
✅ Granted env access to "NPM_CONFIG_NO_PROXY".
✅ Granted env access to "no_proxy".
✅ Granted env access to "NO_PROXY".
✅ Granted env access to "npm_config_https_proxy".
✅ Granted env access to "NPM_CONFIG_HTTPS_PROXY".
✅ Granted env access to "https_proxy".
✅ Granted env access to "HTTPS_PROXY".
✅ Granted env access to "npm_config_proxy".
✅ Granted env access to "NPM_CONFIG_PROXY".
✅ Granted env access to "all_proxy".
✅ Granted env access to "ALL_PROXY".
✅ Granted read access to "/Users/ynonp/Library/Caches/deno/npm/node_modules".
✅ Granted read access to "/Users/ynonp/Library/Caches/deno/node_modules".
✅ Granted read access to "/Users/ynonp/Library/Caches/node_modules".
✅ Granted read access to "/Users/ynonp/Library/node_modules".
✅ Granted read access to "/Users/ynonp/node_modules".
✅ Granted read access to "/Users/node_modules".
✅ Granted read access to "/node_modules".
✅ Granted net access to "en.wikipedia.org".

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

$ deno run --allow-net --allow-read --allow-env demo.js

כדי לדלג על השאלות.

סך הכל כתיבה והרצת סקריפט עם דינו נתנה לי שני יתרונות משמעותיים על פני node:

  1. אין צורך בקובץ package.json, כל התלויות רשומות בתוך קובץ הסקריפט וההתקנה קורית אוטומטית בהרצה הראשונה.

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

בדיקות בתור First Class Citizen

01/12/2023

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

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

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

  1. נעדיף להשתמש בבסיס נתונים שיכול לרוץ מקומית מתוך קוד הבדיקה, במקום בבסיס נתונים שמחייב חיבור לרשת.

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

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

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

קוד אידמפוטנטי

30/11/2023

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

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

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

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