איך לתחזק עשרות אלפי שורות קוד ולהישאר פרודקטיביים

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

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

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

כתבו קוד קריא

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

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

לדוגמה, קטע הקוד הבא הוא קשה מאוד לתחזוקה:

temp = datetime.now()
r = []
for x in lst:
    if temp - x.d > timedelta(10):
        continue
    r.append(x)

לעומת קטע הקוד הבא, שהתנהגותו דומה:

RECORD_TTL = timedelta(days = 10)

def discard_old_records(record_list):
    result = []
    now = datetime.now()
    for record in record_list:
        if now - record.date > RECORD_TTL:
            continue
        result.append(record)
    return result

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

העדיפו קוד קריא על הערות

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

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

לדוגמה, ההערה הבאה מיותרת לחלוטין:

# Return output data formatted as JSON
return json.dumps(output_data)

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

הנה דוגמה להערה מועילה:

# Due to a bug in service X (http://...) we should retry on a 500 error
if status_code == 500:
    raise TemporaryError

כסו את הקוד בבדיקות

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

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

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

  1. לדאוג שלפחות 70% משורות הקוד מכוסות על ידי הבדיקות.
  2. לבדוק בעדיפות עליונה חלקים חיוניים ורגישים בקוד (למשל קוד אבטחה ואימות הרשאות, שליחת מיילים ללקוחות, flow ראשי של התוכנית וכד').
  3. לבדוק בעדיפות עליונה קוד תשתיתי, עליו נסמכים חלקים אחרים בתוכנית.

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

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

השתמשו בכלי ניתוח קוד סטאטי

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

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

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

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

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

תעדו שינויים ב-source control

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

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

הנה דוגמה להודעת commit גרועה:

changing files

והנה דוגמה להודעות commit שימושית:

bugfix: handling sporadic failures on service X

החיו קוד מת

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

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

כדי לא להגיע למצב הזה, השקיעו מדי פעם זמן לביצוע "החייאה" לקוד מת:

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

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

שימוש ב-Hypothesis למציאה אוטומטית של מקרי קצה בבדיקות יחידה

כאשר כותבים בדיקות יחידה (unit tests) עבור הקוד שלנו, רוב הבדיקות בנויות כך:

  1. הגדרת המידע עליו ירוץ הקוד הנבדק.
  2. הרצת הקוד הנבדק על המידע.
  3. וידוא נכונות הפלט ע"י assertions כלשהם.

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

מה אם יכולנו להפחית את המקרים האלו ע"י שינוי מבנה הבדיקה למשהו כזה:

  1. הגדרת מרחב הקלטים האפשריים עליו ירוץ הקוד הנבדק.
  2. הרצת הקוד על כל אחד מהקלטים האלו בצורה אוטומטית.
  3. וידוא נכונות הפלט ע"י assertions כלשהם.

ספריית Hypothesis מאפשרת לנו לעשות בדיוק את זה!

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

def my_encode(value):
    """ return an encoded value """

def my_decode(decoded_value):
    """ return a decoded value """

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

import unittest
from hypothesis import given
from hypothesis.strategies import text

class TestEncoding(unittest.TestCase):
    @given(text())
    def test_decode_inverts_encode(self, value):
        self.assertEqual(decode(encode(value)), value)

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

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

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

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