10 Νοε, 2021

Ασκοπες Abstraction Τεχνικες

Code thinking

Πριν λίγες ημέρες, είχα μια πολύ εποικοδομητική συζήτηση με ένα συνεργάτη μου σχετικά με την υλοποίηση ενός νέου feature σε ένα project μας.

Θα εξηγήσω γενικά περί τίνος πρόκειται, και έπειτα θα δώσουμε δυο πιθανές λύσεις, με την μια από αυτές να είναι ιδανικότερη.

Το πρόβλημα

Ας πούμε ότι έχουμε φτιάξει ένα widget που από εδώ και στο εξής θα ονομάζουμε OurWidget. Αυτό το widget εκτελεί πολλές διαδικασίες (εν ονόματι tasks), και μια απο αυτές είναι να υποστηρίζει και να "χρησιμοποιεί" άλλα 3rd party widgets και τα SDKs τους.

Ποιο 3rd party widget πρέπει να χρησιμοποιήσει το OurWidget ορίζεται από μια μεταβλητή widgetType η οποία δίδεται ως είσοδος στην εφαρμογή.

Για παράδειγμα, ας υποθέσουμε ότι το OurWidget πρέπει να εκτελέσει ένα task openWidget το οποίο απλώς ανοίγει το widget. Και ας υποθέσουμε επίσης ότι το widgetType είναι FacebookWidget. Οπότε, το task openWidget πρέπει ουσιαστικά να ανοίξει το FacebookWidget.

Τα βήματα που πρέπει να εκτελέσει το OurWidget ώστε να επιτύχει αυτό το task είναι:

  1. Τρέξε κάποιο boilerplate κώδικα που είναι ο ίδιος για όλα τα widgets.
  2. Αρχικοποίησε το FacebookWidget SDK.
  3. Τρέξε boilerplate κώδικα για να προετοιμάσεις το open task.
  4. Τελικά άνοιξε το FacebookWidget χρησιμοποιώντας την αντίστοιχη συνάρτηση από το SDK, π.χ FB.openWidget().

Εάν το widgetType ήταν GoogleWidget τότε ο παραπάνω αλγόριθμος θα ήταν (παρατηρήστε πώς τα βήματα 1 και 3 είναι τα ίδια):

  1. Τρέξε κάποιο boilerplate κώδικα που είναι ο ίδιος για όλα τα widgets
  2. Αρχικοποίησε το GoogleWidget SDK.
  3. Τρέξε boilerplate κώδικα για να προετοιμάσεις το open task.
  4. Τελικά άνοιξε το GoogleWidget χρησιμοποιώντας την αντίστοιχη συνάρτηση από το SDK, π.χ Google.openWidget().

Φυσικά, θέλουμε το τέλειο μας OurWidget να υποστηρίξει όσο το δυνατό περισσότερα 3rd party widgets, όπως το InstagramWidget, το ViberWidget, το WhateverWidget κλπ. Τελικά όμως δεν ξερουμε πόσα πολλά widgets θα υποστηρίξουμε οπότε η λύση μας πρέπει να είναι εύρωστη και επεκτάσιμη ώστε να μπορούμε εύκολα να προσθέσουμε, να αφαιρέσουμε ή να τροποποιήσουμε widgets στο μέλλον.

Διαισθητικά, χρειάζεται να υποστηρίξουμε N tasks για M widgets (τα N και Μ δεν τα γνωρίζουμε από πριν και πρόκειται να αλλάξουν στο μέλλον). Παρακάτω φαίνεται ένα απλό διάγραμμα που επιδεικνύει όλη την λογική:

Schema

Σκέψη με Abstract Όρους

Προσωπικά σιχαίνομαι τον επαναλαμβανόμενο κώδικα. Όταν γράφω κώδικα και εντοπίζω μια "επαναληπτικότητα" ή ακόμη και όταν διαισθάνομαι ότι πρόκειται να γράψω κάτι το οποίο έχω ήδη γράψει τότε ξεκινάω και κάνω refactor ώστε να αποφύγω αυτή την "επαναληπτικότητα". Με αυτόν τον τρόπο έχω την αυτοπεποίθηση ότι όταν θέλω να αλλάξω κάτι στο μέλλον δε θα χρειαστεί να το αλλάξω σε πάρα πολλά σημεία!

Η πρώτη μου υλοποίηση

Ας κοιτάξουμε καλύτερα τους δυο αλγορίθμους που παρουσιάσαμε πριν, σχετικά με το openWidget task:

FacebookWidget (widgetType="facebook")

  1. Τρέξε κάποιο boilerplate κώδικα που είναι ο ίδιος για όλα τα widgets.
  2. Αρχικοποίησε το FacebookWidget SDK.
  3. Τρέξε boilerplate κώδικα για να προετοιμάσεις το open task.
  4. Τελικά άνοιξε το FacebookWidget χρησιμοποιώντας την αντίστοιχη συνάρτηση από το SDK, π.χ FB.openWidget().

GoogleWidget (widgetType="google")

  1. Τρέξε κάποιο boilerplate κώδικα που είναι ο ίδιος για όλα τα widgets
  2. Αρχικοποίησε το GoogleWidget SDK.
  3. Τρέξε boilerplate κώδικα για να προετοιμάσεις το open task.
  4. Τελικά άνοιξε το GoogleWidget χρησιμοποιώντας την αντίστοιχη συνάρτηση από το SDK, π.χ Google.openWidget().

Το βλέπετε και εσείς έτσι; Τα βήματα 1 και 3 είναι ακριβώς τα ίδια για τα δυο διαφορετικά widgets και θα είναι τα ίδια για οποιαδήποτε μελλοντικά widgets. Οπότε το μυαλό μου σκέφτεται "Να'τη η επαναληπτικότητα, πρέπει να το κάνω πιο abstract!"

Μια abstract υλοποίηση:

import FB from 'facebook';
import Google from 'google';

const Tasks = {
    init(widgetType) {
        // ... 5 boilerplate lines for init ...
        switch (widgetType){
            case "Facebook":
                FB.init();
            case "Google":
                Google.init();
            default:
                break;
        }
    }
    open(widgetType) {
        // ... 5 boilerplate lines for open ...
         switch (widgetType){
            case "Facebook":
                FB.openWidget();
            case "Google":
                Google.openWidget();
            default:
                break;
        }
    }
    close(widgetType) {
        // ... 5 boilerplate lines for close ...
        switch (widgetType){
            case "Facebook":
                FB.closeWidget();
            case "Google":
                Google.closeWidget();
            default:
                break;
        }
    }
}

// And then compose the tasks and the widgets

const {open, close} = Tasks;

const createWidget = (widgetType) => ({
        init: () => init(widgetType),
        open: () => open(widgetType), 
        close: () => close(widgetType)
    })

const Facebook = createWidget('Facebook');
const Google = createWidget('Google');

// So for the Open Facebook Task:
Facebook.init();
Facebook.open();

// And for the Open Google Task:
Google.init();
Google.open(); 

// same for other tasks added...

Και τώρα είμαι χαρούμενος! Κατάφερα να αποφύγω επαναλαμβανόμενο κώδικα, να κάνω την υλοποίηση abstract και έξυπνη και να μπορεί να υποστηρίζει όλα τα widgets του κόσμου! Εάν χρειαστεί να υποστηρίξουμε ένα νέο widget απλώς θα προσθέσουμε ένα νέο case στο switch block για το συγκεκριμένο task.

Ή... μήπως όχι;

Πρόωωρο Abstraction

Μετά την συζήτηση με τον συνεργάτη μου όπου με υπερηφάνεια του παρουσίασα την abstract υλοποίηση μου, μου επισήμανε ότι αυτό το abstraction αν και έξυπνο μπορεί να εμφανίσει προβλήματα συντηρησιμότητας και επεκτασιμότητας στο μέλλον. Για αυτό το λόγο θα ήταν καλύτερο να προσεγγίσουμε το πρόβλημα με ένα λιγότερο "abstract" και πιο "isolated" τρόπο.

Αρχικά ήμουν διστακτικός καθώς δε μπορούσα να πιστέψω πώς μια isolated υλοποίηση με τόσο επαναλαμβανόμενο κώδικα (φανταστείτε N*M όπου N tasks και M widgets) θα ήταν πιο επεκτάσιμη και συντηρήσιμη στο μέλλον.

Αλλά τώρα καταλαβαίνω...

Code duplication is better than an immature abstraction.

Ή αλλιώς, είναι καλύτερα να γράψεις έναν κώδικα που επαναλαμβάνεται και αλλού, παρά να κάνεις ένα πρόωωρο και "ανώριμο" abstraction.

Και στη περίπτωση μου, αυτή η υλοποίηση ήταν μάλλον ένα όντως "βιαστικό" και πρόωωρο abstraction.

Υπάρχουν πολλοί λόγοι που αυτό το abstraction είναι ακατάλληλο, λόγοι βέβαια που είναι υποθετικοί ("εάν αυτό", "εάν εκείνο" κλπ) αλλά και πάλι πρέπει να παρθούν υπόψιν ώστε να διασφαλίσουμε την επεκτασιμότητα του project.

Για παράδειγμα, τι θα γινόταν στην περίπτωση που για να λειτουργήσει το "open" task στο FacebookWidget ο χρήστης θα έπρεπε να κάνει login ή μια πράξη που δεν έχει να κάνει με τίποτα με το openWidget καθώς είναι πολύ συγκεκριμένη πράξη του Facebook; Η συνάρτηση open θα έπρεπε να γίνει πιο περίπλοκη για να μπορεί να υποστηρίξει κάτι τέτοιο.

Επίσης το testing αυτών των components θα ήταν πιο περίπλοκο καθώς θα υπήρχαν cases όπου π.χ η συνάρτηση open αποτυγχάνει και δε ξέρουμε γιατί - θα μπορούσε να έχει "σπάσει" σε πολλά σημεία.

Μια Λιγότερο Abstract Προσέγγιση

Για να ολοκληρώσουμε αυτό το feature πρέπει να κάνουμε την υλοποίηση όσο πιο απομονωμένη γίνεται αποφεύγοντας όλα αυτά τα περίπλοκα abstractions.

Μια απλή λύση σε αυτό το πρόβλημα φαίνεται παρακάτω:

import FB from 'fb';
import Google from 'google';

const Facebook = {
    init() {
        /// ... 5 lines same for all init functions
        FB.init();
    }
    open() {
        // ... 5 lines same for all widgets open functions ...
        Fb.openWidget();
    }
    close() {
        Fb.closeWidget();
        // ... 5  lines same for all widgets close functions ...
    }
}

const Google = {
    init() {
        /// ... 5 lines same for all init functions
        Google.init();
    }
    open() {
        // ... 5 lines same for all widgets open functions ...
        Google.openWidget();
    }
    close() {
        Google.closeWidget();
        // ... 5  lines same for all widgets close functions ...
    }
}

Και κάπως έτσι θυσιάσαμε επαναληψιμότητα κώδικα για συντηρησιμότητα. Τώρα ο κώδικας μας είναι σε καλύτερη μοίρα ώστε να μπορεί πιο εύκολα να ανταπεξέλθει σε νέα requirements και αλλαγές (συγκεκριμένες για κάθε widget) στο μέλλον. Επίσης το testing αυτών των components θα είναι ευκολότερο καθώς είναι απομονωμένα.

Λέγοντας αυτά, αξίζει να σημειώσω ότι ακόμη πιστεύω πως ο abstract κώδικας και η σκέψη με αφηρημένες τεχνικές είναι πολύ σημαντικός και αρκετά θεμελιώδης στον προγραμματισμό. Αλλά ακόμη πιο σημαντικό είναι να γνωρίζουμε πότε και που να εφαρμόζουμε αυτές τις τεχνικές.

Τα λέμε αργότερα! 😃

Loading Comments...