• בלוג
  • ירושה? לא בבית ספרנו

ירושה? לא בבית ספרנו

23/09/2022

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

1. נקודת ההתחלה

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

class EscapingCircle {
    private el: HTMLDivElement;
    private x: number = 0;
    private y: number = 0;
    private colors = ['red', 'blue', 'green', 'purple', 'orange', 'pink', 'yellow'];
    constructor() {
        this.el = document.createElement('div');
        this.el.style.width = '20px';
        this.el.style.height = '20px';
        this.el.style.borderRadius = '20px';        
        this.el.style.background = this.colors[Math.floor(Math.random() * this.colors.length)];
        this.el.style.position = 'absolute';
        this.move();

        this.el.addEventListener('click', this.move.bind(this));

        document.body.appendChild(this.el);
    }

    move() {
        this.x = Math.floor(Math.random() * window.innerWidth);
        this.y = Math.floor(Math.random() * window.innerHeight);
        this.el.style.left = `${this.x - 10}px`;
        this.el.style.top = `${this.y - 10}px`;
    }

    remove() {
        document.body.removeChild(this.el);
    }
}

const c1 = new EscapingCircle();

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

2. אני יודע! אשתמש בירושה

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

  1. אני משנה את מאפיין הגישה של this.el מפרטי למוגן.

  2. אני יורש מהעיגול הבורח ומוסיף לבנאי שורה שמעדכנת את הסטייל של האלמנט לריבוע.

זה הקוד של הריבוע:

class EscapingSquare extends EscapingCircle {
    constructor() {
        super();
        this.el.style.borderRadius = "0";
    }
}

ובמחלקה של העיגול אני צריך לשנות את הגדרת שדה המידע this.el באופן הבא:

class EscapingCircle {
    protected el: HTMLDivElement;
    ...

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

3. דרישה חדשה

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

אין בעיה, אני מכיר את הספריה anime.js, אני רק צריך להוסיף אותה לפרויקט עם:

$ npm install --save animejs @types/animejs

ולעדכן את קוד העיגול כדי שיזוז עם אנימציה:

import anime from 'animejs/lib/anime.es.js';

class EscapingCircle {
    protected el: HTMLDivElement;
    private x: number = 0;
    private y: number = 0;
    private colors = ['red', 'blue', 'green', 'purple', 'orange', 'pink', 'yellow'];
    constructor() {
        this.el = document.createElement('div');
        this.el.style.width = '20px';
        this.el.style.height = '20px';
        this.el.style.borderRadius = '20px';        
        this.el.style.background = this.colors[Math.floor(Math.random() * this.colors.length)];
        this.el.style.position = 'absolute';
        this.move();

        this.el.addEventListener('click', this.move.bind(this));

        document.body.appendChild(this.el);
    }

    move() {
        this.x = Math.floor(Math.random() * window.innerWidth);
        this.y = Math.floor(Math.random() * window.innerHeight);
        anime({
            targets: this.el,
            translateX: this.x,
            translateY: this.y,
            duration: 800
          });
        }

    remove() {
        document.body.removeChild(this.el);
    }
}

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

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

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

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

4. מה אפשר לעשות במקום

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

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

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

function squarify(shape: EscapingCircle) {
    shape.el.style.borderRadius = "0";
    return shape;
}

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

class EscapingCircle {
    public el: HTMLDivElement;
    public x: number = 0;
    public y: number = 0;
    public colors = ['red', 'blue', 'green', 'purple', 'orange', 'pink', 'yellow'];
    ...

ובשביל ליצור ריבוע ועיגול אני יכול להפעיל:

const c1 = new EscapingCircle();
const r1 = squarify(new EscapingCircle());

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

function animate(shape: EscapingCircle) {
    shape.move = () => {
        shape.x = Math.floor(Math.random() * window.innerWidth);
        shape.y = Math.floor(Math.random() * window.innerHeight);
        anime({
            targets: shape.el,
            translateX: shape.x,
            translateY: shape.y,
            duration: 800
          });
    }
    return shape;
}

אבל זה עדיין לא עובד. במחלקה EscapingCircle הבנאי כבר קושר את האירועים בשורה:

this.el.addEventListener('click', this.move.bind(this));

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

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

class Circle {
    public el: HTMLDivElement;
    public x: number = 0;
    public y: number = 0;
    public colors = ['red', 'blue', 'green', 'purple', 'orange', 'pink', 'yellow'];

    constructor() {
        this.el = document.createElement('div');
        this.el.style.width = '20px';
        this.el.style.height = '20px';
        this.el.style.borderRadius = '20px';        
        this.el.style.background = this.colors[Math.floor(Math.random() * this.colors.length)];
        this.el.style.position = 'absolute';
        this.move();

        document.body.appendChild(this.el);
    }

    move = () => {
        this.x = Math.floor(Math.random() * window.innerWidth);
        this.y = Math.floor(Math.random() * window.innerHeight);
        this.el.style.left = `${this.x - 10}px`;
        this.el.style.top = `${this.y - 10}px`;
    }

    remove() {
        document.body.removeChild(this.el);
    }
}


function escaping(shape: Circle) {
    shape.el.addEventListener('click', shape.move.bind(shape));
    return shape;
}

function animate(shape: Circle) {
    shape.el.style.left = "0px";
    shape.el.style.top = "0px";

    shape.move = () => {
        shape.x = Math.floor(Math.random() * window.innerWidth);
        shape.y = Math.floor(Math.random() * window.innerHeight);
        anime({
            targets: shape.el,
            translateX: shape.x,
            translateY: shape.y,
            duration: 800
          });
    }
    return shape;
}

const c1 = escaping(new Circle());
const r1 = squarify(escaping(new Circle()));
const c3 = escaping(animate(new Circle()));

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