BrianDamaged.org

AOL keyword: lame

Awaiting further reDECORATING

2019-02-09

Phew — we’ve sure been talking about decorators a lot lately! We’ve already covered decorator basics and fancy metaprogramming techniques. We even started writing a library around these concepts. What else is there even left to talk about?

Hmm… well, by now, I hope you’ve started applying some of these techniques in your own code. If you have, then you’ve probably already discovered that there is still plenty of territory to explore. So, rather than providing more guidance, I’m instead going to wrap up this series by discussing an idea that I’m still exploring.

You’ve probably noticed that most of the decorators we’ve demonstrated so far have been using the async keyword. For instance:

const LogsErrors = (
  (message)=>
    (next)=>
      async function(...args) {
        try {
          return await next.apply(this, args);
        } catch(err) {
          console.log(message, err);
          throw err;
        }
      }
);

This works great if we’re decorating another async function… but, what if we want to introduce a logging behavior to a non-async function? Well… um… I guess we have to write another decorator for this case?

const LogsErrorsSync = (
  (message)=>
    (next)=>
      function(...args) {
        try {
          return next.apply(this, args);
        } catch(err) {
          console.log(message, err);
          throw err;
        }
      }
);

Okay, I s’pose that works… but, now I’m concerned. Does this suggest that we’ll need to implement both a synchronous and asynchronous decorator for each behavior that we want to introduce? That certainly seems tedious! Sure, it’s still a lot better than what we started with, but maintaining 2 separate implementations of each behavior seems error-prone at best!

I’ve been considering a few different ways to tackle this. Currently, I’m leaning towards an approach that would leverage code generation. For instance, imagine something like this:

// Generates both the synchronous and asynchronous
// decorator implementations based upon a shared
// definition.
const {asyncFunc, syncFunc} = generatePair(`
  (message)=>
    (next)=>
      ASYNC function(...args) {
        try {
          return AWAIT next.apply(this, args);
        } catch(err) {
          console.log(message, err);
          throw err;
        }
      }
`);

Now we only need to define the logic for the behavior once. Afterwards, this definition will be compiled twice: once for the asynchronous case, and once for the synchronous case. The only difference between these two cases is the manner in which the ASYNC and AWAIT placeholders get interpreted:

  • Asynchronous case:

    • "ASYNC" is replaced with "async"
    • "AWAIT" is replaced with "await"
  • Synchronous case:

    • "ASYNC" is replaced with ""
    • "AWAIT" is replaced with ""

Of course, this draft of the solution is still far from perfect. For starters, the string replacement can backfire in many scenarios, such as:

const {asyncFunc, syncFunc} = generatePair(`
  (next)=>
    ASYNC function(...args) {
      // The word "AWAITING" contains the word "AWAIT".
      // Therefore, generatePair will replace it by mistake!
      console.log("I'M EAGERLY AWAITING THE NEXT BLOG POST!!!");

      return AWAIT next.apply(this, args);
    }
`);

We could potentially tackle this via escaping of some kind. But, what if we instead just provided actual placeholder objects? These objects could then be inserted into the appropriate places via expression interpolation. Something like:

const {asyncFunc, syncFunc} = generatePair(
  ({ASYNC, AWAIT})=> `
    (next)=>
      ${ASYNC} function(...args) {
        // Notice that there isn't any expression interpolation
        // here.  Therefore, "AWAITING" remains "AWAITING".
        console.log("I'M EAGERLY AWAITING THE NEXT BLOG POST!!!");

        return ${AWAIT} next.apply(this, args);
      }
  `
);

Behind the scenes, the generatePair(..) function would probably look something like this:

function generatePair(templateFunc) {
  // Step 1: Generate the code using the appropriate
  // replacements for the ASYNC/AWAIT placeholders.
  const asyncDef = templateFunc({
    ASYNC: "async",
    AWAIT: "await",
  });

  const syncDef = templateFunc({
    ASYNC: "",
    AWAIT: "",
  });

  // Step 2: Evaluate ("compile") the generated code
  return {
    asyncFunc: eval(asyncDef),
    syncFunc: eval(syncDef),
  };
}

Huzzah! We did it! Everything is perfect now, right?

… maybe?

I have a hunch that this technique still only solves half of the problem. In particular, what if we are creating a decorator that is actually a specialization of another decorator? For example:

// Note: to keep things simple, we'll start off by
//       considering only the async implementation.


// This decorator generalizes the pattern of "processing
// an error before re-throwing it".
const TapsErrorsWith = (
  (tap)=>
    (next)=>
      async function(...args) {
        try {
          return await next.apply(this, ...args);
        } catch(error) {
          tap({
            args: args,
            error: error,
          });
          throw(err);
        }
      }
);


// Guess what?  `LogsErrors(..)` is just a specialization
// of that pattern!
const LogsErrors = (
  (message)=>
    TapsErrorWith(function({error}) {
      console.log(message, error);
    })
);

But… this definition of LogsErrors(..) explicitly relies upon TapsErrorsWith(..), which returns a decorator for async functions. So, in order to create the synchronous version of this same decorator, we would need to do this:

const LogsErrorsSync = (
  (message)=>
    TapsErrorWithSync(function({error}) {
      console.log(message, error);
    })
);

Sigh… so, once again, we’re forced to maintain 2 separate implementations for exactly the same behavior.

This ain't right.  This ain't fair.

Maybe we can still fix this? Let’s start w/ a brute-force approach:

// We know that there are 2 separate implementations
// for the "Taps Errors" behaviors...
const Tappers = [
  TapsErrorsWith, TapsErrorsWithSync,
];

// The higher-level definition for the "Logs Errors"
// behavior remains consistent regardless of the
// lower-level "Taps Errors" implementation.  So, let's
// apply the same definition to each implementation:
const Loggers = Tappers.map((T)=> (
  (message)=>
    T(function({error}) {
      console.log(message, error);
    })
));

// Unpack the result into the desired variables.
const [
  LogsErrors, LogsErrorsSync,
] = Loggers;

Next, we… wait. We might actually be done already. Woo!

Okay, just kidding. There’s still some work to be done, but I think we’ve at least hammered out the key concepts. In short, we’ve identified some techniques that allow us to generate both the synchronous and asynchronous implementations of a behavioral pattern. This allows us to focus on specifying the desired behaviors rather than the specific implementations.

More importantly, we’ve just cut our implementation efforts in half. Boom!