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

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

הדברים הקטנים

18/09/2021

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

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

function create(db, task) {
  return db('tasks').insert(task).then(([createdId]) => ({ ...task, id: createdId }));
}

function select(db, ...columns) {
  return db('tasks').select(columns);
}

function destroy(db, task) {
  return db('tasks').where('id', task.id).del();
}

function update(db, which, what) {
  return db('tasks').where('id', which.id).update(what);
}

module.exports = { create, select, destroy, update };

אנשים אהבו את זה כי בתוכנית הראשית היית יכול לכתוב דברים כמו:

const newTask = await tasks.create(db, { text: 'new task', done: false });
await tasks.update(db, newTask, { done: true });

console.log(await tasks.select(db, '*'));
await tasks.destroy(db, newTask);

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

ה Refactoring במקרה הזה היה קסום כמו שהיה פשוט. יוצרים קובץ אחד בשם repo.js עם הקוד הבא:

module.exports = function(name) {
  function create(db, item) {
    return db(name).insert(item).then(([createdId]) => ({ ...item, id: createdId }));
  }

  function select(db, ...columns) {
    return db(name).select(columns);
  }

  function destroy(db, item) {
    return db(name).where('id', item.id).del();
  }

  function update(db, which, what) {
    return db(name).where('id', which.id).update(what);
  }

  return { create, select, destroy, update };
}

ועכשיו מי שרוצה רק את הפעולות הבסיסיות מסתפק בשורה אחת:

module.exports = require('./repo')('tasks');

וגם מי שצריך יותר יכול לקחת את 4 הפעולות מ repo ולהוסיף עליהן פעולות חדשות.

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

(נ.ב. אם אתם כבר עובדים ב knex ורוצים להרחיב אותו עם יכולות של ORM שווה לבדוק את פרויקט Bookshelf.js שעושה בדיוק את זה)

תכנות מונחה דיבאגר

17/09/2021

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

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

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

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

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

פייק

16/09/2021

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

התרגיל הוא פשוט ועוזר להתאמן על פעולות חילוק במספרים שלמים ושארית. אם נישאר רק עם המספרים הקטנים מ-40 אפשר לנסות את הפיתרון הבא:

symbols = (
        [10, 'X'],
        [5, 'V'],
        [1, 'I' ]
        )

def convert_to_roman(number):
    result = ''
    for i, r in symbols:
        result += r * (number // i)
        number = number % i
    return result

קוד הבדיקה הבא משתמש בפיתרון ומראה שהוא עובד לפחות על 5 מספרים:

result = {
        28: 'XXVIII',
        12: 'XII',
        6: 'VI',
        32: 'XXXII',
        21: 'XXI',
        }

for k, v in result.items():
    if convert_to_roman(k) != v:
        raise Exception(f"Conversion failed for {k}. Expected: '{v}'; Got: '{convert_to_roman(k)}'")

print("All OK")

אבל כשננסה להפעיל את ההמרה על מספר כמו 19 נקבל שגיאה. ההמרה הנכונה של 19 לספירה רומית היא XIX אבל אצלנו הוא יופיע בתור XVIIII. איך מתקנים?

כיוון אחד יכול להיות לנסות לשכנע את המחשב לבדוק כל מיני אפשרויות לבנות את המספרים, ולכתוב את הקוד שמזהה ש 19 הוא 20-1 בדיוק כמו שהוא 15+4 אבל ש 20-1 יותר קצר לכתיבה ולכן כדאי להמיר לזה.

כיוון יותר קל הוא לרמות:

symbols = (
        [10, 'X'],
        [9, 'IX'],
        [5, 'V'],
        [4, 'IV'],
        [1, 'I' ],
        )

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

הפונקציות map ו filter לאוביקטים ב JavaScript

15/09/2021

הפונקציות Object.entries ו Object.fromEntries הן סוג של אוצר לכל מי שאוהב תכנות פונקציונאלי ו JavaScript כי הן מאפשרות לנו לסגור את הפער בין אוביקטים למערכים.

במערכים הפונקציות map ו filter מגיעות מובנות עם המערך, כך שאם יש לי רשימה של מספרים אני יכול בקלות לקבל את ריבועי המספרים, או את ריבועי המספרים הגדולים מ 50:

const numbers = [5, 6, 7, 8, 9, 10];
const squares = numbers.map(x => x * x);
const largeSquares = numbers.map(x => x * x).filter(x => x > 50);

אבל מה לגבי אוביקטים? הדרך הקלה להפעיל map ו filter על אוביקטים היא להפוך אותם למערכים, להפעיל את הלוגיקה ואז להחזיר חזרה לאוביקטים.

לדוגמה קחו אוביקט קונפיגורציה שמגדיר מספר URL-ים חשובים ליישום:

const urls = {
  google: 'http://www.google.com',
  ddg: 'http://www.duckduckgo.com',
  bing: 'http://www.bing.com'
}

בעזרת Object.entries ו Object.fromEntries אני מריץ בקלות טרנספורמציות על האוביקט. למשל בשביל להפוך את כל ה URL-ים ל https ולוותר על כל מנוע חיפוש שהשם שלו ארוך מ-4 אותיות אני מריץ:

Object.fromEntries(
    Object.entries(urls).
        map(([key, value]) => 
            [key, value.replace('http://', 'https://')]).
        filter(([key, value]) => key.length < 5))

ומקבל חזרה:

{ ddg: 'https://www.duckduckgo.com', bing: 'https://www.bing.com' }

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

זה היה שם לפניי

14/09/2021

ואולי את יודעת יותר ממה שידע המתכנת שכתב את זה במקור?

ואולי את רואה יותר דברים ממה שהוא ראה?

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

ואולי את יותר מנוסה ממה שהוא היה כשהוא כתב את זה במקור?

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

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

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

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

לאן הולכים הקומיטים אחרי reset?

13/09/2021

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

חוץ מה Branch-ים וה Commit-ים יש עוד סימניה חשובה שנקראת HEAD. ה HEAD הוא מצביע ל Branch והוא מסמן לנו מה הקומיט "הנוכחי", כלומר מה הקומיט שעכשיו אנחנו עובדים עליו.

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

* 8131ef2 (HEAD -> main) third commit
* 3a40bc4 second commit
* e8006f8 first commit

בגרף יש שלושה קומיטים, בראנץ אחד בשם main וה HEAD שלנו הוא אותו main.

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

$ git reset --hard HEAD~

ואז נסתכל בלוג:

3a40bc4 (HEAD -> main) second commit
e8006f8 first commit

בום! לאן נעלם הקומיט השלישי?

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

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

אם אתם זוכרים את מזהה הקומיט אתם יכולים בקלות לחזור אליו עם:

$ git reset --hard 8131ef2

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

$ git reflog

3a40bc4 (HEAD -> main) HEAD@{0}: reset: moving to HEAD~
8131ef2 HEAD@{1}: reset: moving to 8131ef2
3a40bc4 (HEAD -> main) HEAD@{2}: reset: moving to main
3a40bc4 (HEAD -> main) HEAD@{3}: reset: moving to HEAD~
8131ef2 HEAD@{4}: commit: third commit
3a40bc4 (HEAD -> main) HEAD@{5}: commit: second commit
e8006f8 HEAD@{6}: commit (initial): first commit

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

$ git reflog expire --expire-unreachable=now --all

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

מחיקה מוחלטת של אותו קומיט אפשרית עם הפקודה:

$ git gc --prune=now

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

הערכות זמנים (טייק 712)

12/09/2021

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

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

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

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

אינדאסטרי סטנדרט

10/09/2021

הכלי שנחשב Industry Standard הוא לא בהכרח יותר טוב, לא בהכרח יותר פשוט ולא בהכרח יותר יעיל מהמתחרים. היתרונות הם אחרים:

  1. מישהו שמשתמש בו כנראה כבר נתקל ברוב הבעיות שאתם צפויים להיתקל בהן.

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

  3. יש תיעוד לא רע בכלל, ודי הרבה תשובות ב Stack Overflow וב Github Issues של האנשים שלא הצליחו למצוא את מה שמחפשים בתיעוד.

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

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

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

כשמישהו מספר לי על Bundler יותר טוב מ Webpack, על Web Framework יותר טוב מ Express, על GraphQL Federation יותר טוב מ Apollo או על Orchestrator יותר טוב מקוברנטס אני לא מתווכח. כל עוד אני שומר לעצמי את האפשרות להחליף בעתיד, אני אפילו שמח לתת לכלי החדש צ'אנס.

טיפ JavaScript: טיפול באוביקט או Promise מאותה פונקציה

09/09/2021

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

function capitalize(item) {
  return item.replace(/\b(\w)/g, c => c.toUpperCase());
}

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

function capitalize(itemOrPromise) {
  if (itemOrPromise.then) {
    return itemOrPromise.then(capitalize);
  }

  // Now we know it's an item
  return itemOrPromise.replace(/\b(\w)/g, c => c.toUpperCase());
}

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

ומי שבכלל רוצה להתחשב יכול להוסיף גם טיפול במערכים ועל הדרך במערכים של Promises:

function capitalize(itemOrPromise) {
  if (Array.isArray(itemOrPromise)) {
    return itemOrPromise.map(capitalize);
  }

  if (itemOrPromise.then) {
    return itemOrPromise.then(capitalize);
  }

  // Now we know it's an item
  return itemOrPromise.replace(/\b(\w)/g, c => c.toUpperCase());
}

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

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

function handleAllThenThings(func) {
  return function handler(itemOrPromise) {
    if (Array.isArray(itemOrPromise)) {
      return itemOrPromise.map(handler);
    }

    if (itemOrPromise.then) {
      return itemOrPromise.then(handler);
    }

    // Now we know it's an item
    return func(itemOrPromise);
  };
}