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

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

ככה מצאתי את זה

26/11/2022

בשיטוט מקרי בקוד של axios נתקלתי בקוד הבא עבור אחת הבדיקות:

// from: https://github.com/axios/axios/blob/v1.x/test/specs/promise.spec.js#L10

it('should provide succinct object to then', function (done) {
  let response;

  axios('/foo').then(function (r) {
    response = r;
  });

  getAjaxRequest().then(function (request) {
    request.respondWith({
      status: 200,
      responseText: '{"hello":"world"}'
    });

    setTimeout(function () {
      expect(typeof response).toEqual('object');
      expect(response.data.hello).toEqual('world');
      expect(response.status).toEqual(200);
      expect(response.headers['content-type']).toEqual('application/json');
      expect(response.config.url).toEqual('/foo');
      done();
    }, 100);
  });
});

הם מגדירים שרת פיקטיבי ומאתחלים את פרטי התשובה הפיקטיבית שהוא ישלח, שולחים בקשת רשת עם axios ואז מחכים 100 מילי שניות ובודקים את אוביקט התשובה. אבל למה לחכות דווקא 100 מילי שניות? לפי גיט בליים האשמה היא בקומיט 46a9639. כל הבדיקות עד אותו קומיט השתמשו ב setTimeout עם זמן המתנה של 0, אבל חלקן כנראה נכשלו מדי פעם ובאותו קומיט כל הבדיקות שכללו setTimeout עלו לזמן המתנה 100 מילי שניות.

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

  it('should provide succinct object to then', function (done) {
    axios('/foo').then(function (response) {
      expect(typeof response).toEqual('object');
      expect(response.data.hello).toEqual('world');
      expect(response.status).toEqual(200);
      expect(response.headers['content-type']).toEqual('application/json');
      expect(response.config.url).toEqual('/foo');
      done();
    });

    getAjaxRequest().then(function (request) {
      request.respondWith({
        status: 200,
        responseText: '{"hello":"world"}'
      });
    });
  });

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

אבל יש שתי בעיות בגישה כזאת:

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

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

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

ואיך זה נשבר?

25/11/2022

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

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

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

סיכום כל הארגומנטים עם shift

24/11/2022

הפקודה shift ב bash "מזיזה" את הארגומנטים מקום אחד שמאלה, כך ש $1 נמחק, $2 הופך ל $1, $3 הופך ל $2 וכך הלאה. הסקריפט הבא מציג דוגמת שימוש פשוטה ב shift שפשוט מדפיסה את שני הארגומנטים הראשונים שקיבלה:

#!/bin/bash

echo $1
shift
echo $1

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

#!/bin/bash

[[ $# == 0 ]] && exit 0

echo $1
shift
exec $0 "$@"

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

#!/bin/bash

if [[ $# == 1 ]]
then
        echo $1
        exit 0
fi

(( sum = $1 + $2 ))

shift 2

exec $0 $sum "$@"

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

while getopts "c:o:l" opt; do
  case $opt in
    c)
      COUNT=$OPTARG;;
    o)
      OUTFILE=$OPTARG;;
    l)
      PADDING=1;;
  esac
done

shift $((OPTIND-1))

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

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

#!/bin/bash

for arg
do
  (( sum += arg ))
done

echo $sum

קריאה עם read מ Pipe ב Bash

23/11/2022

הפקודה read קוראת שורה מהקלט הסטנדרטי, שוברת אותה למילים (עם תו ההפרדה במשתנה IFS) ושומרת את התוצאה למשתנה אחד או יותר שאת שמו היא מקבלת בהפעלה.

לכן הקוד הבא קורא את השורה הראשונה מהקובץ /etc/shells ושומר אותו למשתנה בשם first_line:

read first_line < /etc/shells

והקוד הבא קורא רק את המילה הראשונה מאותו קובץ ושומר אותה למשתנה first_word:

read first_word rest < /etc/shells

קל לשכוח שההתנהגות של read בשילוב | אינה אינטואיטיבית, וזה לא בגלל read. כל פעם שאנחנו מפעילים פקודות בתוך Pipeline, באש פותח תת-מעטפת (Sub Shell) ומבצע את הפקודות בתוכה. הבעיה היא שמשתנים שמוגדרים באותו Sub Shell יימחקו כשה Sub Shell יסתיים, כלומר ביציאה מה Pipeline. הפקודה הזאת לדוגמה לא לוקחת את המילה הראשונה מהפלט של wc /etc/shells לתוך המשתנה lines:

wc /etc/shells | read lines

כלומר היא כן, אבל המשתנה נשמר בתוך Sub Shell ונמחק איך שהשורה נגמרת. בשביל ש read תוכל לשמור משתנה ל Shell הנוכחי שלי היא חייבת להיות מופעלת ממנה, כלומר לא מצד ימין של סימן Pipe. במצב כזה אני צריך להשתמש בפקודת <() כדי להריץ פקודה ולשמור את הפלט שלה לתוך File Handle, ואז בעזרת הפניית קלט רגילה אני יכול להעביר את השורה הראשונה מאותו File Handle ל read:

read lines words chars rest < <(wc /etc/passwd)

ללמוד מהטובים ביותר

22/11/2022

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

האמונה הזאת מסבכת אותנו בשני מקומות:

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

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

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

דינו מאפשר הרצת חבילות גם מ npm

21/11/2022

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

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

import express from "npm:express@4.18.2";

function twice(x: number) {
  return x * 2;
}

const app = express();

app.get("/", (req, res) => {
  res.send("Welcome to the Dinosaur API!");
});

app.get("/twice", (req, res) => {
  const n = Number(req.query.n || 0);
  res.send(`${n} * 2 = ${twice(n)}`);
});

app.listen(8000);

ובשביל להריץ, אם יש לכם כבר דינו 1.28 ומעלה מותקן מפעילים:

$ deno run --allow-net --allow-read --allow-env main.ts

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

ביצועים ושפות מודרניות

20/11/2022

אלגוריתם Fisher-Yates לערבוב מערך מציע לבצע את הצעדים הבאים:

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

זה אלגוריתם אלגנטי וקל למימוש בכל שפה, לדוגמה ב JavaScript:

function shuffle(arr) {
  let end = arr.length;
  while (end >= 0) {
    const nextIndex = Math.floor(Math.random() * end);
    arr.push(arr[nextIndex]);
    arr.splice(nextIndex, 1);
    end -= 1;
  }
  return arr;
}

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

function shuffle(arr) {
  for (let i=arr.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * i);
    const item = arr[i];
    arr[i] = arr[j];
    arr[j] = item;
  }

  return arr;
}

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

הודעות שגיאה מטעות

19/11/2022

שגיאה בתוכנה הדפיסה לי את ההודעה הבאה ללוג:

Permissions should be u=rwx (0700).

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

$ ls -l b.txt
-rw-rw-r-- 1 ubuntu ubuntu 0 Nov 16 12:39 b.txt

ומריץ את הפקודה:

$ chmod u=rwx b.txt

ההרשאות עדיין יהיו לא מתאימות:

$ ls -l b.txt
-rwxrw-r-- 1 ubuntu ubuntu 0 Nov 16 12:39 b.txt

למעשה אפשר להסיק מהודעת השגיאה שתי פקודות שונות להריץ - האחת, chmod u=rwx, תשנה רק את ההרשאות לבעלים של הקובץ; השניה, chmod 0700, באמת תוריד גם את ההרשאות מכל האחרים.

הודעת שגיאה טובה יותר היתה מציגה שתי אפשרויות שקולות, למשל היה עדיף לכתוב:

Permissions should be a=,u=rwx (0700).

או אפילו:

Permissions should be u=rwx,g=,o= (0700).

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

למה את קוראת "טעות"?

18/11/2022

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

  1. האם קוד גרוע הוא קוד שלא עובד? או שגם קוד עובד יכול להיות גרוע?

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

  3. האם קוד גרוע קשור למה שמסביבו? האם קוד יכול להיות טוב אם אין לו בדיקות? אם אין לו תיעוד?

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

  5. האם קוד גרוע קשור ל Best Practices ולסגנון כתיבה? קוד גרוע הוא קוד שלא כתוב לפי סטנדרטים מסוימים? איזה סטנדרטים אלה? איזה סוג קוד קשה לכם לקרוא?

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

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

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

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