שלום אורח התחבר

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

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

מעדיפים לקרוא מהטלגרם? בקרו אותנו ב:@tocodeil

או הזינו את כתובת המייל וקבלו את הפוסט היומי בכל בוקר אליכם לתיבה:

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

אבל זה לא מספיק, וכאן מגיע הטיפ השני:

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

לכן אם אני אלך להעביר הרצאה על "תכנות אסינכרוני ב Python" לקהל של אנשים שעובדים כ Data Scientists בפייתון אני אחשוב פעמיים לפני שאשתמש בירושה או Design Patterns בדוגמאות שלי, ובאותו זמן אני ארשה לעצמי לרוץ עם דוגמאות שמביאות Data Sets מהרשת וממלאות מהם מערכים ב NumPy כי אני יודע שזה משהו שהקהל שלי עושה כל יום.

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

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

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

class Demo:
    def __init__(self, data=[]):
        self.data = data

    def add(self, x):
        self.data.append(x)

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

d = Demo()
print(d.data)

e = Demo([10, 20, 30])
print(e.data)

f = Demo()
print(f.data)

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

d = Demo()
print(d.data)
d.add(10)

e = Demo([10, 20, 30])
print(e.data)

f = Demo()
print(f.data)

ומדפיס עכשיו:

[]
[10, 20, 30]
[10]

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

class Demo:
    def __init__(self, *data):
        self.data = list(data)

    def add(self, x):
        self.data.append(x)

ובאופן כללי אם אנחנו מקפידים לא להעביר בתור Default Value משהו שהוא Mutable לעולם לא תהיה לנו בעיה של שיתוף ערך בלי שהתכוונו.

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

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

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

def sum_all_numbers(*values):
    res = 0
    for v in values:
        if isinstance(v, numbers.Number):
            res += v

    return res

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


def sum_all_numbers_2(*values):
    return sum([NumericValue(x) for x in values])

מדהים נכון? רק שאז ניגשים לכתוב את NumericValue ומגיעים לזה:

class NumericValue:
    def __init__(self, val):
        if isinstance(val, numbers.Number):
            self.val = val
        else:
            self.val = 0

    def __add__(self, other):
        if isinstance(other, int):
            return self.val + other
        elif isinstance(other, NumericValue):
            return self.val + other.val
        else:
            return NotImplemented

    def __radd__(self, other):
        return self.__add__(other)

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

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

def numeric_value(v):
    return v if isinstance(v, numbers.Number) else 0

def sum_all_numbers_3(*values):
    return sum([numeric_value(v) for v in values])

print(sum_all_numbers_3(10, 20, 'f', 'g', '10', 30))

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

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

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

יש לזה שתי סיבות:

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

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

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

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

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

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

  1. שאנחנו ננקה שאילתות SQL בשורה שמעבירה את השאילתה לדרייבר (באמצעות Bind Variables).

  2. שאנחנו ננקה מידע שנכתב ל HTML בקוד ה View, ממש לפני שהוא נשלח ללקוח (זאת הסיבה שריאקט מנקה את הקלט ואתם צריכים להתאמץ לעקוף את זה עם dangerouslySetInnerHTML).

  3. שאנחנו ננקה מידע שהולך להישלח ל system ממש לפני הקריאה ל system כדי למנוע Shell Injections.

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

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

אתגר יותר מעניין בשילוב הזה בין Rails לאפליקציות צד-לקוח הוא העברת הנתיבים. בעבודה עם ריילס אנחנו רגילים להשתמש ב Route Helpers כדי ליצור באופן אוטומטי נתיבים בתוך קבצי ה .html.erb של היישום. כך לדוגמא התג הבא מייצר את הקישור למסך הלוגין:

<%= link_to 'Login', new_user_session_path %>

וזה מייצר את הקישור למסך צפיה בהודעה לפי ID שלה:

<%= link_to 'Post #4', post_path(id: 4) %>

במעבר לאפליקציות צד-לקוח יש לנו בעיה. אנחנו לא רוצים לכתוב Hard Coded את הנתיבים עצמם. הם כתובים ב config/routes.rb פעם אחת וזה ממש נוח שאפשר לשנות אותם וכל האפליקציה מתעדכנת בצורה אוטומטית. הבעיה שה Route Helpers כגון post_path הם פונקציות, ועוד פונקציות שמיוצרות אוטומטית על ידי ריילס. איך מעבירים את הפונקציות האלה ל JavaScript?

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

נקודת הכניסה תהיה פונקציה ב ApplicationController שתקרא לפני כל פעולה, נקרא לה init. היא תיצור מילון בו המפתח הוא שם של נתיב והערך הוא הנתיב בפורמט של מחרוזת, כאשר הפרמטרים מיוצגים על ידי נקודותיים (כמו שהיינו כותבים אותם בקובץ config/routes.rb). דוגמא למבנה נתונים כזה:

{
    'new_user_session_path': '/users/sign_in',
    'post_path': '/posts/:id',
}

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

class ApplicationController < ActionController::Base
  before_action :init

  def init
    @state = {}
    @routes_for_js = Rails.application.routes.named_routes.map do |route_name|
      route = Rails.application.routes.named_routes[route_name]
      route_params = route.parts.reject {|part| part == :format }
      route_params_hash = route_params.map {|x| [x, ":#{x}"] }.to_h
      route_method = route_name.to_s + '_path'
      [
          route_method,
          self.method(route_method).call(**route_params_hash),
      ]
    end.to_h.reject {|k, v| k.starts_with?('rails_')}
  end
end

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

const routes = JSON.parse(document.querySelector('#routes').dataset.routes);
const routeHelpers = {};

for (let [routeName, routeString] of Object.entries(routes)) {
  const routeNameJS = routeName.replace(/_([a-zA-Z]+)/g, word => (
    word[1].toUpperCase() + word.slice(2)
  ));

  routeHelpers[routeNameJS] = routeParams => (
    routeString.replace(/:(\w+)/g, x => routeParams[x.slice(1)])
  )
}

export default routeHelpers;

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

<a href={routeHelpers.newUserSessionPath()}>Login</a>
<a href={routeHelpers.postPath({ id: 4 })}>Watch Post #4</a>

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

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

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

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

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

@dispatch(tuple)
def go(point):
    x, y = point
    go(x, y)

@dispatch(int, int)
def go(x, y):
    print(f'x = {x}, y = {y}')

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

loc = (10, 10)
go(loc)

ונרצה שהשני ייקרא כשנפעיל את הפונקציה עם שני פרמטרים:

go(10, 10)

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

{
    go: {
        (typle): <function at 0x1088b01e0>
        (int, int): <function at 0x1089d1488>
    }
}

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

מימוש פשוט של המנגנון יכול להיראות כך:

import collections

md = collections.defaultdict(dict)

def dispatch(*types):
    def decorator(f):
        md[f.__name__][types] = f
        def wrapper(*args, **kwargs):
            res = md[f.__name__][tuple(type(a) for a in args)]
            res(*args, **kwargs)

        return wrapper
    return decorator

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

https://github.com/mrocklin/multipledispatch

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

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

מתקינים את הספריה עם:

$ pip install dulwich --global-option="--pure"

ומתוך תוכנית פייתון נוכל עכשיו לכתוב:

from dulwich.repo import Repo
r = Repo('.')
last_commit_id = r.head().decode('ascii')

result_filename = f'result.{last_commit_id}.txt'

with open(result_filename, 'w') as f:
    f.write('Hello World\n')

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

פייתון הפתיע אותי היום עם הודעת השגיאה הבאה:

Traceback (most recent call last):
  File "...", line 31, in search
    if is_text_in_file(file, word):
  File "", line 10, in is_text_in_file
    for line in fileinput.input(file):
  File "/Library/Frameworks/Python.framework/Versions/3.7/lib/python3.7/fileinput.py", line 93, in input
    raise RuntimeError("input() already active")
RuntimeError: input() already active

וזאת הפונקציה שגרמה לבעיה:

def is_text_in_file(file, text):
    for line in fileinput.input(file):
        if text in line:
            return True

    return False

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

כיוון אחד לתקן את זה הוא להוסיף קריאה יזומה ל fileinput.close לפני ה return. וזה עובד:

def is_text_in_file(file, text):
    for line in fileinput.input(file):
        if text in line:
            fileinput.close()
            return True

    return False

אבל בינינו למה להתאמץ כשאפשר לתת ל Python לעבוד בשבילנו? כיוון הרבה יותר טוב יהיה לעטוף את כל הבלוק שמשתמש ב fileinput בפקודת with:

def is_text_in_file(file, text):
    with fileinput.input(file) as f:
        for line in f:
            if text in line:
                return True

    return False

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