• בלוג
  • קריאה מודרכת בפונקציה groupBy מ lodash

קריאה מודרכת בפונקציה groupBy מ lodash

16/01/2022

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

1. נקודת ההתחלה - הקובץ groupBy

בכניסה למאגר של lodash אנחנו שמים לב קודם כל שהספריה מחולקת לקבצים וכל פונקציה נמצאת בקובץ משלה. הקובץ package.json מספר לנו שהקובץ הראשי בספריה נקרא lodash.js:

{
  "name": "lodash",
  "version": "5.0.0",
  "license": "MIT",
  "private": true,
  "main": "lodash.js",
  "engines": {
    "node": ">=4.0.0"
  },
  "sideEffects": false,
  "scripts": {
    "style": "eslint *.js .internal/**/*.js",
    "test": "mocha -r esm test/*.test.js",
    "validate": "npm run style && npm run test"
  },
  "devDependencies": {
    "mocha": "^5.2.0",
    "eslint": "^7.16.0",
    "eslint-plugin-import": "^2.22.1",
    "lodash": "4.17.20",
    "esm": "^3.2.25"
  }
}

אבל בחיפוש בתיקיית הפרויקט אנחנו מגלים ש lodash.js אינו חלק מקוד המקור. ב Readme מספרים לנו שכדי לבנות את lodash.js נצטרך להתקין ספריה אחרת שנקראת lodash-cli. אולי ביום אחר יהיה מעניין להיכנס ולקרוא את הקוד של lodash-cli ולהבין איך היא מייצרת את הקובץ הראשי. כרגע אני רוצה להתמקד בפונקציה groupBy. מאחר ויש בתיקיה קובץ שמתאים לכל פונקציה של lodash אני מחפש את הקובץ שנקרא groupBy.js:

import baseAssignValue from './.internal/baseAssignValue.js'
import reduce from './reduce.js'

/** Used to check objects for own properties. */
const hasOwnProperty = Object.prototype.hasOwnProperty

/**
 * Creates an object composed of keys generated from the results of running
 * each element of `collection` thru `iteratee`. The order of grouped values
 * is determined by the order they occur in `collection`. The corresponding
 * value of each key is an array of elements responsible for generating the
 * key. The iteratee is invoked with one argument: (value).
 *
 * @since 0.1.0
 * @category Collection
 * @param {Array|Object} collection The collection to iterate over.
 * @param {Function} iteratee The iteratee to transform keys.
 * @returns {Object} Returns the composed aggregate object.
 * @example
 *
 * groupBy([6.1, 4.2, 6.3], Math.floor)
 * // => { '4': [4.2], '6': [6.1, 6.3] }
 */
function groupBy(collection, iteratee) {
  return reduce(collection, (result, value, key) => {
    key = iteratee(value)
    if (hasOwnProperty.call(result, key)) {
      result[key].push(value)
    } else {
      baseAssignValue(result, key, [value])
    }
    return result
  }, {})
}

export default groupBy

2. מה אפשר ללמוד מההערות

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

/** Used to check objects for own properties. */
const hasOwnProperty = Object.prototype.hasOwnProperty

לבד השורה הזאת נראית תמוהה- הרי אנחנו יודעים מה Object.prototype.hasOwnProperty עושה ב JavaScript. אבל אם נחפש בעוד קבצים בספריה נגלה שכל פעם שמוגדר const גלובאלי בקובץ יש מעליו הערה שמסבירה מה תפקידו. לדוגמה בקובץ clone.js אנחנו מוצאים את:

/** Used to compose bitmasks for cloning. */
const CLONE_SYMBOLS_FLAG = 4

וב conforms.js אני מוצא את:

/** Used to compose bitmasks for cloning. */
const CLONE_DEEP_FLAG = 1

וב isArrayBuffer.js יש לי את:

/* Node.js helper references. */
const nodeIsArrayBuffer = nodeTypes && nodeTypes.isArrayBuffer

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

3. שאלות מקריאת המימוש

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

function groupBy(collection, iteratee) {
  return reduce(collection, (result, value, key) => {
    key = iteratee(value)
    if (hasOwnProperty.call(result, key)) {
      result[key].push(value)
    } else {
      baseAssignValue(result, key, [value])
    }
    return result
  }, {})
}

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

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

  1. הפונקציה reduce מקבלת פונקציה ומפעילה אותה רק עם שני פרמטרים: accumulator ו value. למה בקוד של groupBy מעבירים גם פרמטר שלישי בשם key?

  2. למה להשתמש ב hasOwnProperty בתור פונקציה גלובאלית ולא להפעיל אותו מתוך result? כלומר למה לא לכתוב result.hasOwnProperty(key)

  3. מה זה baseAssignValue ? למה לא להשתמש פשוט בכתיב הסוגריים המרובעים כדי לכתוב את הערך?

4. מאיפה הגיע ה key?

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

key = iteratee(value)

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

var groupBy = createAggregator(function(result, value, key) {
  if (hasOwnProperty.call(result, key)) {
    result[key].push(value);
  } else {
    baseAssignValue(result, key, [value]);
  }
});

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

5. מה זה baseAssignValue?

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

result[key] = [value];

הנה מה שהם עושים במקום:

function baseAssignValue(object, key, value) {
  if (key == '__proto__') {
    Object.defineProperty(object, key, {
      'configurable': true,
      'enumerable': true,
      'value': value,
      'writable': true
    })
  } else {
    object[key] = value
  }
}

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

6. ומה לגבי hasOwnProperty?

שאלה השלישית לסקירה זו נוגעת ל hasOwnProperty - למה להשתמש ב Object.prototype.hasOwnProperty ולא להפעיל את הפונקציה על אוביקט ה result שיש לנו ביד?

אם הגעתם עד פה בקריאה אני מקווה שהתשובה כבר ברורה: גם hasOwnProperty יכול להיות שם של מפתח בו אמור להיות מתויג אחד הערכים, ואם זה המצב אז result.hasOwnProperty יחזיר את רשימת הערכים שמתאימים למפתח זה במקום את הפונקציה Object.prototype.hasOwnProperty. ואגב בחזרה לשורת ההערה שראינו בראש הקובץ, אם היו שואלים אותי הייתי מציע להחליף אותה למשהו שרומז למקרה הזה, לדוגמה:

/** Saving hasOwnProperty in a global variable allows us to call it even if one of the value's key will be the string "hasOwnProperty" */
const hasOwnProperty = Object.prototype.hasOwnProperty

7. נ.ב. מה קרה לנקודה פסיק?

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

commit 6cb3460fcefe66cb96e55b82c6febd2153c992cc
Author: John-David Dalton <john.david.dalton@gmail.com>
Date:   Sat Feb 4 23:50:10 2017 -0800

    Remove semicolons.

בפברואר 2017 ג'ון דייויד דלטון החליט להיפטר מכל הנקודות פסיק ב lodash. עכשיו הסיפור של ה semicolons ב JavaScript לא חדש. עוד ב 2012 היה ויכוח ציבורי גדול בנושא והוא נמשך עד היום. לדוגמה בתבנית פרויקט create-react-app חדש שתיצרו תמצאו נקודה פסיק בסוף כל שורה אבל בתבנית פרויקט React שתיצרו עם vite לא תמצאו את הנקודות-פסיקים. לודאש לוקחת את הצד שמתנגד לנקודה פסיק, וזה בסדר גם אם זו לא כוס התה שלי.