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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

ואז אני נזכר-

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

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

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

new Array(4).fill(10).reduce((acc, val) => acc + val);

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

const data = new Array(4).fill(10);
data[0] += 10;

data.reduce((acc, val) => acc + val);

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

data.reduce((acc, val) => acc + val.value, 0)

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

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

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

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

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

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

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

(ותודה לריק היקי על ההשראה ולויטאלי ששיכנע אותי להקשיב לו).

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

.
├── foo
│   └── bar
│       └── buz
└── package.json

ובקובץ package.json התוכן הבא:

{
  "name": "npmdemo",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo Running from $(realpath .)"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}

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

 ~/tmp/npmdemo/foo/bar/buz  npm test

> npmdemo@1.0.0 test /home/ynon/tmp/npmdemo
> echo Running from $(realpath .)

Running from /home/ynon/tmp/npmdemo

והסקריפט ירוץ מהתיקיה הראשית של הפרויקט, בלי שאכפת למישהו שאני הייתי בתיקיית foo/bar/buz כשהרצתי את הפקודה.

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

בשביל זה בדיוק npm מגדיר משתנה סביבה בשם INIT_CWD שכולל את הנתיב המלא לתיקיה ממנה הרצתי את פקודת ה npm. בשביל לראות את זה אעדכן את package.json לתוכן הבא:

{
  "name": "npmdemo",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo Running from $(realpath .)",
    "test-here": "cd $INIT_CWD; echo Running from $(realpath .)"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}

ועכשיו בהרצה אני מקבל:

 ~/tmp/npmdemo/foo/bar/buz  npm run test

> npmdemo@1.0.0 test /home/ynon/tmp/npmdemo
> echo Running from $(realpath .)

Running from /home/ynon/tmp/npmdemo
 ~/tmp/npmdemo/foo/bar/buz  npm run test-here

> npmdemo@1.0.0 test-here /home/ynon/tmp/npmdemo
> cd $INIT_CWD; echo Running from $(realpath .)

Running from /home/ynon/tmp/npmdemo/foo/bar/buz

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

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

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

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

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

נ.ב. גירסה יותר עדינה שעושים גם מתכנתים יחסית אחראיים נשמעת ככה: אני צריך להוסיף מנגנון Drag & Drop למערכת, אני לא יודע כלום על DnD אז אחפש ברשת ספריה קיימת של DnD לריאקט, אני רואה שיש הרבה התלהבות מ React DnD אז אשלב אותה אצלי בפרויקט. זו בדיוק אותה הטעות כמו לשלב את Vue במערכת רק בגלל שרציתם ללמוד Vue. רק בגלל שאתה צריך מנגנון Drag and Drop לא אומר שאתה צריך דווקא את ספריית React DnD.

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

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

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

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

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

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

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

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

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

והפונקציה Object.getPrototypeOf מחזירה את הפרוטוטייפ של אוביקט מסוים.

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

class A {
  hello() {
    console.log('hello world');
  }
}

const a = new A();

const prototypeOfA = Object.getPrototypeOf(a);

// This works
a.hello();

delete(prototypeOfA.hello);

// But this doesn't
a.hello();

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

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

class A {
  hello() {
    console.log('hello world');
  }
}

const a = new A();

const prototypeOfA = Object.getPrototypeOf(a);

// This works
a.hello();

delete(a.hello);

// And this works too
a.hello();

אפשר לעשות משחקים יותר מתוחכמים עם Object.create:

const a = { one: '1' };
const b = Object.create(a);
const c = Object.create(b);

// All 3 work:
console.log(a.one);
console.log(b.one);
console.log(c.one);

b.two = '2';

// undefined
console.log(a.two);

// these two work:
console.log(b.two);
console.log(c.two);

c.three = '3';
// undefined
console.log(a.three);
console.log(b.three);

// but this works
console.log(c.three);

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

עכשיו לשאלה - איך לקבל רשימה של שמות כל הפונקציות מאוביקט JavaScript?

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

למזלנו ב JavaScript יש פונקציה בשם Object.getOwnPropertyNames שמחזירה את רשימת כל המאפיינים של אוביקט מסוים. שילוב שלה עם Object.getPrototypeOf יפתור לנו את הבעיה עם לולאה פשוטה:

function getAllMethodNames(obj) {
  let res = [];
  let p = obj;

  while (p !== Object.getPrototypeOf({})) {
    const ownKeys = Object.getOwnPropertyNames(p);
    const ownMethods = ownKeys.filter(n => n !== 'constructor').filter(n => typeof p[n] === 'function');
    res = [...res, ...ownMethods];
    p = Object.getPrototypeOf(p);
  }

  return res;
}

וכך אני יכול להשתמש בפונקציה כדי לקבל את שמות כל הפונקציות במחלקה מסוימת:

class A {
  hello() {
    console.log('Hello world');
  }
}

class B extends A {
  byebye() {
    console.log('bye');
  }
}


const a = new A();
const b = new B();

console.log(getAllMethodNames(a));
console.log(getAllMethodNames(b));

הקוד ידפיס תחילה את hello, בגלל שזו הפונקציה היחידה במחלקה A, ואז בשורה חדשה את hello ו byebye שהן שתי הפונקציות שאפשר להפעיל על האוביקט b מהמחלקה B.

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

יהיו מתכנתים שיבחרו להתחיל מה UI ויבנו קודם את ה HTML/CSS (או יקנו תבנית מתאימה)

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

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

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

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

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

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