תפסו מחסה, הדקורטורים חוזרים!

03/03/2023

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

1. מהם דקורטורים

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

function logged(value, { kind, name }) {
  if (kind === "method") {
    return function (...args) {
      console.log(`starting ${name} with arguments ${args.join(", ")}`);
      const ret = value.call(this, ...args);
      console.log(`ending ${name}`);
      return ret;
    };
  }
}

class C {
  @logged
  m(arg) {}
}

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

2. אז מה רע בזה?

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

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

ספריית TypeORM משתמשת ב Decorators כדי לציין שמחלקה מסוימת היא Entity וצריכה להיות "מחוברת" לטבלה בבסיס הנתונים:

import { Entity, PrimaryGeneratedColumn, Column } from "typeorm"

@Entity()
export class User {
    @PrimaryGeneratedColumn()
    id: number

    @Column()
    firstName: string

    @Column()
    lastName: string

    @Column()
    age: number
}

אבל מבט למימוש מראה לנו שכל מה ש @Entity עושה זה לקרוא לפונקציה:

export function Entity(
    nameOrOptions?: string | EntityOptions,
    maybeOptions?: EntityOptions,
): ClassDecorator {
    const options =
        (ObjectUtils.isObject(nameOrOptions)
            ? (nameOrOptions as EntityOptions)
            : maybeOptions) || {}
    const name =
        typeof nameOrOptions === "string" ? nameOrOptions : options.name

    return function (target) {
        getMetadataArgsStorage().tables.push({
            target: target,
            name: name,
            type: "regular",
            orderBy: options.orderBy ? options.orderBy : undefined,
            engine: options.engine ? options.engine : undefined,
            database: options.database ? options.database : undefined,
            schema: options.schema ? options.schema : undefined,
            synchronize: options.synchronize,
            withoutRowid: options.withoutRowid,
        } as TableMetadataArgs)
    }
}

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

אפילו כשהיתה לכאורה הצדקה לשימוש בדקורטור כי אנחנו באמת רוצים להחליף את המימוש, התוצאה היתה לא טבעית ומבלבלת. ניקח את react-redux כדוגמה וניזכר שפעם כתבנו קוד כזה כדי לחבר קומפוננטת ריאקט ל Redux Store:

@connect(mapStateToProps, mapDispatchToProps)
export default class MyApp extends React.Component {
  // ...define your main app here
}

ולמרות שהשטרודל באמת מחליף את המימוש של הפונקציה בפונקציה אחרת (תוצאת הפעלת הפונקציה connect), הרבה יותר קל וברור לעשות את ההחלפה לבד ולייצא את התוצאה של connect:

class MyApp extends React.Component {
  // ...define your main app here
}

export default connect(mapStateToProps, mapDispatchToProps)(MyApp);

דוגמה שלישית מהספריה InversifyJS שהכריחה אותנו להשתמש בדקורטור בשביל להצהיר שמחלקה מסוימת ניתנת להזרקה:

@injectable()
class Katana implements Weapon {
    public hit() {
        return "cut!";
    }
}

אבל מבט במימוש מראה שוב שבסך הכל יש פה קריאה לפונקציה:

function injectable() {
  return function <T extends abstract new (...args: any) => unknown>(target: T) {

    if (Reflect.hasOwnMetadata(METADATA_KEY.PARAM_TYPES, target)) {
      throw new Error(ERRORS_MSGS.DUPLICATED_INJECTABLE_DECORATOR);
    }

    const types = Reflect.getMetadata(METADATA_KEY.DESIGN_PARAM_TYPES, target) || [];
    Reflect.defineMetadata(METADATA_KEY.PARAM_TYPES, types, target);

    return target;
  };
}

export { injectable };

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

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