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

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

שילוב asyncio עם Threads בפייתון

10/05/2023

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

נדמיין שיש לנו קוד שמשתמש ב AWS כדי לתרגם מילים:

import boto3

client = boto3.client('translate')

def spanish_to_english(text):
    response = client.translate_text(
        Text=text,
        SourceLanguageCode='es',
        TargetLanguageCode='en',
    )

    return response["TranslatedText"]

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

def blocking_io():
    print(f"start blocking_io at {time.strftime('%X')}")
    # Note that time.sleep() can be replaced with any blocking
    # IO-bound operation, such as file operations.
    time.sleep(1)
    print(f"blocking_io complete at {time.strftime('%X')}")

async def main():
    print(f"started main at {time.strftime('%X')}")

    await asyncio.gather(
        asyncio.to_thread(blocking_io),
        asyncio.sleep(1))

    print(f"finished main at {time.strftime('%X')}")


asyncio.run(main())

קריאה רגילה ל blocking_io היתה חוסמת את ה Event Loop וגורמת להמתנה כוללת של שתי שניות. בזכות השימוש ב to_thread, הפונקציה נקראת בתהליכון נפרד וה sleep מתבצע במקביל ל asyncio.sleep, כך שסך הכל התוכנית ממתינה שניה אחת לשתי הפעולות.

הפונקציה נכנסה לשפה בפייתון 3.9 ואתם יכולים למצוא עליה מידע נוסף יחד עם כל הפרטים הקטנים בדף התיעוד: https://docs.python.org/3/library/asyncio-task.html#running-in-threads

היום למדתי: שיפור ביצועים עם Metadata ב Clojure

09/05/2023

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

בקריאה רגילה של קוד אנחנו לא נראה מתי הקומפיילר משתמש ב Reflection ומתי לא, אבל אפשר לבקש ממנו להדפיס אזהרות על שימוש כזה בעזרת המשתנה *warn-on-reflection*. בפרויקט לניגן נגדיר אותו עם:

:global-vars {*warn-on-reflection* true}

ובתוך ה repl אפשר להפעיל פשוט:

(set! *warn-on-reflection* true)

כדי להדליק את האזהרות.

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

=> (set! *warn-on-reflection* true)
=> (let [s "hello there"] (.charAt s 3))
\l

אבל בקוד הזה אין לו מושג ולכן הוא צריך להשתמש ב Reflection כדי לזהות בזמן ריצה את הקלאס:

=> (set! *warn-on-reflection* true)
=> (let [s (apply str "hello")] (.charAt s 3)
Reflection warning, NO_SOURCE_PATH:1:30 - call to method charAt can't be resolved (target class is unknown).
\l

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

אנחנו מדביקים Metadata לערך עם המקרו המיוחד ^, ומושכים את ה metadata עם פונקציית meta. הקוד הבא מגדיר את המשתנה x להחזיק רשימה עם המספרים 1,2,3 ומשייך אליו את ה Metadata שנקרא :yay:

user=> (def x ^:yay [1 2 3])
#'user/x
user=> x
[1 2 3]
user=> (meta x)
{:yay true}

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

user=> (set! *warn-on-reflection* true)
user=> (let [s ^String (apply str "hello")] (.charAt s 3))
\l

עכשיו אני יודע מה אתם חושבים ... מה אם ננסה לעבוד על הקומפיילר ונשייך Metadata לא נכון? מסתבר שאם ה Type Hint לא מתאים קלוז'ר יוותר על האופטימיזציה וינסה למצוא את הפונקציה בזמן ריצה:

user=> (let [s ^Integer (apply str "hello")] (.charAt s 3))
Reflection warning, NO_SOURCE_PATH:1:39 - call to method charAt on java.lang.Integer can't be resolved (no such method).
\l

יש מחיר להבנה

09/05/2023

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

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

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

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

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

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

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

הפחד להתאמץ מדי

08/05/2023

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

  1. הנה תרגיל קשה אבל אני יודע בגדול איך פותרים אותו,

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

  3. בגלל שאני יודע מה הפיתרון, לפתור את האתגר לא ייתן לי יותר מדי,

  4. חבל להשקיע מאמץ סתם, עדיף לעשות דברים יותר מועילים.

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

  1. הנה תרגיל קשה אבל אני יודע בגדול איך פותרים אותו,

  2. אני יושב את השלוש שעות שחשבתי שזה ייקח, מגלה שהכיוון שלי לא היה נכון, ממשיך לשבור את הראש ופותר את האתגר אחרי 7 או 17 שעות.

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

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

תיקון שימוש מסורבל בפונקציה sorted בפייתון

07/05/2023

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

import wikipedia

def k(i):
    """ returns the number of words the value i has in wikipedia """
    return len(wikipedia.summary(i))


words = ["wikipedia", "Python (programming language)", "Ruby (programming language)", "Rust (programming language)"]

print([
    w[0] for w in
    sorted([(w, k(w)) for w in words], key=lambda i: i[1], reverse=True)])

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

המשך קריאה

שבע שנים ועדיין לא יודע גיט

06/05/2023

משתמש רדיט כתב השבוע-

Hello everyone,

I've been using Git for the last 7 years, but I still don't fully understand how it works. I'm hoping someone can help me clarify my understanding of how cloning, feature branches, and merging work in Git.

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

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

ולכן גם הבחירה להבין איך משהו עובד שונה מהבחירה ללמוד להשתמש בו. בשביל להבין איך גיט עובד אני צריך לשאול מה הביא את לינוס לפתח את גיט מלכתחילה במקום להשתמש בפיתרון קיים? מה האקוסיסטם שבתוכו גיט פועל? איך גיט בנוי מבפנים (איזה קבצים הוא מנהל ומה יש בתוך כל קובץ); אני צריך לנסות לשבור דברים וללמוד מה בדיוק מצליח ואיך בדיוק זה נשבר. וכן גם להבין איך אנשים אחרים משתמשים בגיט ואיך המבנה שלו צריך להתאים ל Use Cases שונים משלי. לכל אלה אני לא מגיע בשימוש שוטף בכלי "תוך כדי עבודה". אלה דברים שצריך להקדיש להם זמן וללמוד אותם באופן יזום.

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

הקוד הכי גרוע שכתבתי

05/05/2023

היום הוא שוב תקף אותי, חוזר כאילו משום מקום כשכבר חשבתי שקברתי אותו לנצח - הקוד הכי גרוע שכתבתי. ככה הוא נראה בתוך Ruby Class שיורש מ ActiveRecord::Base:

def is_valid?
  return false if remaining && (remaining <= 0)

  now = Time.now.to_date
  now >= start_date && now <= end_date
end

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

מה שהופך את הקוד הזה לגרוע הוא ההתנגשות עם הקונבנציה שמקיפה אותו - ב Active Record של ריילס יש כבר פונקציה מובנית בשם valid? (בלי ה is) שבודקת אם אוביקט הוא וולידי. השם שאני בחרתי, is_valid? כדי לבדוק משהו אחר לגמרי, מבלבל אותי כל פעם מחדש. זו דוגמה מתוך התיעוד של ריילס לבניית וולידציה מותאמת אישית למחלקה:

class Invoice < ApplicationRecord
  validate :expiration_date_cannot_be_in_the_past,
    :discount_cannot_be_greater_than_total_value

  def expiration_date_cannot_be_in_the_past
    if expiration_date.present? && expiration_date < Date.today
      errors.add(:expiration_date, "can't be in the past")
    end
  end

  def discount_cannot_be_greater_than_total_value
    if discount > total_value
      errors.add(:discount, "can't be greater than total value")
    end
  end
end

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

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

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

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

04/05/2023

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

ניקח לדוגמה את הרשימות:

l1 = ["one", "two", ""]
l2 = ["one", "", "three"]
l3 = ["one", "two", "three"]

עכשיו נשאל - באיזה רשימה כל התאים הם באורך 1 לפחות?

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

def check_list(l: [str]):
    return all((len(i) > 0 for i in l))

שווה לשים לב:

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

  2. אפשר ורצוי לקרוא עוד על all ועל החברה שלה any בדף התיעוד על פונקציות מובנות בפייתון.

מתי למזג ל main?

03/05/2023

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

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

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

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

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

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

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

החזרת מספר בטווח ב TypeScript

02/05/2023

נתבונן בפונקציה הבאה בטייפסקריפט:

function createRandomNumber(): number {
    return Math.floor(Math.random() * 10);
}

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

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

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

type Range10 = 0|1|2|3|4|5|6|7|8|9;
function createRandomNumber() {
    return Math.floor(Math.random() * 10) as Range10;
}

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

הפיתרון הגנרי משתמש ברקורסיה ונראה כך:

export type Range<N extends number,R extends number[]=[]> = 
    R['length'] extends N ?  R[number] : Range<N, [...R, R['length']]>;


function createRandomNumber() {
    return Math.floor(Math.random() * 10) as Range<10>;
}

וההסבר-

  1. הרקורסיה בונה את המערך R, כך שכל איטרציה היא מוסיפה ל R מספר נוסף.

  2. המספר שמתווסף הוא האורך הנוכחי של R, ולכן המספרים יהיו בטווח מאפס עד המספר N.

  3. בסוף הרקורסיה מחזירים איחוד של כל המספרים השמורים ב R.

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