JavaScript tiene muchas características útiles que la mayoría de los desarrolladores conocen. Al mismo tiempo, hay algunas gemas ocultas que pueden resolver problemas realmente desafiantes si eres consciente de ellos.

La metaprogramación en JavaScript es un concepto con el que muchos de nosotros puede que no estemos familiarizados. En este artículo, aprenderemos sobre la metaprogramación y cómo es útil para nosotros.

Con ES6 (ECMAScript 2015), tenemos soporte para los objetos Reflect y Proxy que nos permiten hacer la Metaprogramación con facilidad. En este artículo, aprenderemos a usarlos con ejemplos.

¿Qué es la metaprogramación?

¡La metaprogramación es nada menos que la magia de la programación! ¿Qué tal si escribimos un programa que lea, modifique, analice e incluso genere un programa? ¿No suena eso como algo mágico y poderoso?

elwIjsjlSeV2c9VBF07ZDHmurJ5_NdeIJ0bDOSNpNai644OhE90gDbGlyOnL4xea5D7S6s9M17V3w4h3zgpr8Q9sn3Ke8BuzPJySs4JI6J0v0jvgX6eSdalnFdULzTWh85IjQMFGjYX-ymmAOA
Metaprogramacion es magia

Así es como describiría la metaprogramación como un desarrollador que la usa todo el tiempo:

La metaprogramación es una técnica de programación en la que los programas informáticos tienen la capacidad de tratar otros programas como sus datos. Esto significa que un programa puede ser diseñado para leer, generar, analizar o transformar otros programas, e incluso modificarse a sí mismo mientras se ejecuta.

En pocas palabras, la metaprogramación implica escribir un código que puede

  • Generar el código
  • Manipular las construcciones del lenguaje en el tiempo de ejecución. Este fenómeno se conoce como Metaprogramación Reflexiva o Reflexión.

¿Qué es la reflexión en la metaprogramación?

La reflexión es una rama de la metaprogramación. La reflexión tiene tres sub-ramas:

  • Introspección: El código es capaz de inspeccionarse a sí mismo. Se utiliza para descubrir información de muy bajo nivel sobre el código.
  • Auto-modificación: Como el nombre sugiere, el código es capaz de modificarse a sí mismo.
  • Intercesión: Actuar en nombre de un tercero. Esto se puede lograr envolviendo, atrapando, interceptando.

ES6 nos proporciona el objeto Reflect (alias, Reflect API) para lograr la Introspección. El objeto Proxy del ES6 nos ayuda con la Intercesión. No hablaremos mucho de la Auto-Modificación ya que queremos alejarnos de ella tanto como sea posible.

¡Espera un segundo! Sólo para ser claros, la metaprogramación no fue introducida en el ES6. Más bien, ha estado disponible en el lenguaje desde su inicio. El ES6 sólo lo hizo mucho más fácil de usar.

La era pre-ES6 de la metaprogramación

¿Recuerdas a eval? Echemos un vistazo a cómo se usaba:

const blog = {
    nombre: 'freeCodeCamp'
}
console.log('Antes de eval:', blog);

const clave = 'autor';
const valor = 'Tapas';
testEval = () => eval(`blog.${clave} = '${valor}'`);

// Llama a la funcion
testEval();

console.log('Despues de la magia de eval:', blog);

Como pueden notar, eval ayudó con la generación de código adicional. En este caso, el objeto blog ha sido modificado con una propiedad adicional en el momento de la ejecución.

Antes de eval: {nombre: freeCodeCamp}
Despues de la magia de eval: {nombre: "freeCodeCamp", autor: "Tapas"}

Introspección

Antes de la inclusión del objeto Reflect en el ES6, podíamos hacer introspección. Aquí hay un ejemplo de lectura de la estructura del programa:

var usuarios = {
    'Tom': 32,
    'Bill': 50,
    'Sam': 65
};

Object.keys(usuarios).forEach(nombre => {
    const edad = usuarios[nombre];
    console.log(`El usuario ${nombre} tiene ${edad}!`);
});

Aquí estamos leyendo la estructura del objeto usuarios y registrando el valor clave en una sentencia.

El usuario Tom tiene 32!
El usuario Bill tiene 50!
El userio Sam tiene 65!

Auto-modificación

Tomemos un objeto blog que tiene un método para modificarse a sí mismo:

var blog = {
    nombre: 'freeCodeCamp',
    autoModificar: function(clave, valor) {blog[clave] = valor}
}

El objeto blog puede modificarse a sí mismo haciendo esto:

blog.autoModificar('autor', 'Tapas');

Intercesión

La intercesión se trata de actuar en nombre de algo más cambiando la semántica del lenguaje. El método Object.defineProperty() puede cambiar la semántica de un objeto:

var sol = {};

Object.defineProperty(sol, 'aparece', {
    value: true,
    configurable: false,
    writable: false,
    enumerable: false
});

console.log('el sol aparece', sol.aparece);
sun.rises = false;
console.log('el sol aparece', sol.aparece);

Salida,

el sol aparece true
el sol aparece true

Como ves, el objeto sol fue creado como un objeto normal y luego la semántica ha sido cambiada para que no sea escribible.

Ahora vamos a entender mejor los objetos Reflect y Proxy con sus respectivos usos.

La API de Reflect

En el ES6, Reflect es un nuevo Global Object (como las matemáticas) que proporciona una serie de funciones de utilidad, muchas de las cuales parecen superponerse con los métodos del ES5 definidos en el global Object.

Todas estas funciones son funciones de introspección en las que se pueden consultar algunos detalles internos acerca del programa en tiempo de ejecución.

Aquí está la lista de métodos disponibles del objeto Reflect. Por favor, visite esta página para ver más detalles de cada uno de estos métodos.

// Métodos del objeto Reflect

Reflect.apply()
Reflect.construct()
Reflect.get()
Reflect.has()
Reflect.ownKeys()
Reflect.set()
Reflect.setPrototypeOf()
Reflect.defineProperty()
Reflect.deleteProperty()
Reflect.getOwnPropertyDescriptor()
Reflect.getPrototypeOf()
Reflect.isExtensible()

Pero espera, aquí hay una pregunta: ¿Por qué necesitamos un nuevo objeto de la API cuando estos podrían existir ya o podrían ser añadidos a Object o Function?

¿Confundido? Vamos a tratar de entender esto

Todo en un mismo namespace (dominio)

JavaScript ya tenía soporte para la reflexión de objetos. Pero estas APIs no estaban organizadas bajo un solo namespace. Desde ES6 están ahora dentro de Reflect.

A diferencia de la mayoría de los objetos globales, Reflect no es un constructor. No se puede utilizar con el operador new o invocar el objeto Reflect como una función. Todas las propiedades y métodos de Reflect son estáticas (static) como el objeto math.

Fácil de usar

Los métodos de introspección de Object lanzan una excepción cuando no logran completar la operación. Esto es una carga añadida para el usuario (programador) al manejar esa excepción en el código.

Puede que prefieras manejarlo como un booleano (true|false) en lugar de usar el manejo de excepciones. El objeto Reflect te ayuda a hacerlo así.

Aquí hay un ejemplo con Object.defineProperty:

 try {
        Object.defineProperty(obj, name, desc);
        // la propiedad se ha definido exitosamente
    } catch (e) {
        // posible fallo y algo tiene que hacer al respecto
    }

Y con la API de Reflect:

if (Reflect.defineProperty(obj, name, desc)) {
  // existosamente
} else {
 // fallo (y mucho mejor)
}

La impresión de la operación de First-Class

Podemos encontrar la existencia de una propiedad para un objeto como (prop in obj). Si necesitamos utilizarla varias veces en nuestro código, debemos envolver explícitamente esta operación en una función y pasarla como un valor de primera clase.

En el ES6, ya los teníamos como parte de la API de Reflect como función de primera clase. Por ejemplo, Reflect.has(obj, prop) es el equivalente funcional de (prop in obj).

Veamos otro ejemplo: Borrar una propiedad del objeto.

const obj = { bar: true, baz: false};

// borrar object[key]
function borrarPropiedad(objeto, propiedad) {
    delete objeto[propiedad];
}
borrarPropiedad(obj, 'bar');

Con la API de Reflect:

// Con la Api de Reflect
Reflect.deleteProperty(obj, 'bar');

Una forma más fiable de utilizar el método apply()

En ES5, podemos usar el método apply() para llamar a una función con un valor dado y pasarle un arreglo como argumento.

Function.prototype.apply.call(func, obj, arr);
// o...
func.apply(obj, arr);

Esto es menos fiable porque func podría ser un objeto que habría definido su propio método apply.

En el ES6 tenemos una forma más fiable y elegante de resolver esto:

Reflect.apply(func, obj, arr);

En este caso, obtendremos un TypeError si func no se puede invocar. Además, Reflect.apply() es menos expresivo y más fácil de entender.

Ayudando a otros tipos de reflection

Veremos lo que esto significa en un momento cuando aprendamos sobre el objeto Proxy. Los métodos de la API de Reflect pueden utilizarse con Proxy en muchos casos de uso.

El Objeto Proxy

El objeto Proxy de ES6 ayuda en la intercession.

El objeto proxy define comportamientos personalizados para operaciones fundamentales (por ejemplo, búsqueda de propiedades, asignación, enumeración, invocación de funciones, etc.).

Aquí hay algunos términos útiles que debes recordar y usar:

  • El objetivo (target): Un objeto que el proxy virtualiza.
  • El manejador (handler): Un objeto marcador de posición que contiene trampas (traps).
  • La trampa (trap): Métodos que permiten el acceso de la propiedad al objeto objetivo.

Está perfectamente bien si aún no lo entiendes por la descripción anterior. Lo entenderemos a través del código y los ejemplos enseguida.

La sintaxis para crear un objeto Proxy es la siguiente:

let proxy = new Proxy(target, handler);

Existen muchas proxy traps (funciones de manejo) disponibles para acceder y personalizar un objeto objetivo. Aquí está la lista de ellas. Puedes leer una descripción más detallada de las traps aquí.

handler.apply()
handler.construct()
handler.get()
handler.has()
handler.ownKeys()
handler.set()
handler.setPrototypeOf()
handler.getPrototypeOf()
handler.defineProperty()
handler.deleteProperty()
handler.getOwnPropertyDescriptor()
handler.preventExtensions()
handler.isExtensible()

Observe que cada una de las traps tiene un mapeo con los métodos del objeto Reflect. Esto significa que puedes usar Reflect y Proxy juntos en muchos casos de uso.

Cómo obtener los valores de propiedad de los objetos no disponibles

Veamos un ejemplo de un objeto empleado e intentemos imprimir algunas de sus propiedades:

const empleado = {
    nombre: 'Tapas',
    apellido: 'Adhikary'
};

console.log(empleado.nombre);
console.log(empleado.apellido);
console.log(empleado.org);
console.log(empleado.nombreCompleto);

El resultado esperado es el siguiente:

Tapas
Adhikary
undefined
undefined

Ahora usemos el objeto Proxy para añadir un comportamiento personalizado al objeto empleado.

Paso 1: Crear un handler que use una trap get

Usaremos una trap llamada get que nos permite obtener el valor de una propiedad. Aquí está nuestro handler:

let handler = {
    get: function(objetivo, propiedad) {        

        if(propiedad === 'nombreCompleto' ) {
            return `${objetivo.nombre} ${objetivo.apellido}`;
        }

        return propiedad in objetivo ?
            objetivo[propiedad] :
                `No existe la propiedad, '${propiedad}'!`

    }
};

El manejador anterior ayuda a crear el valor de la propiedad nombreCompleto. También añade un mejor mensaje de error cuando falta una propiedad del objeto.

Paso 2: Crear un Objeto Proxy

Así como tenemos el objeto empleado objetivo y el handler, podremos crear un objeto Proxy como este:

let proxy = new Proxy(empleado, handler);

Paso 3: Acceder a las propiedades del objeto Proxy

Ahora podemos acceder a las propiedades del objeto empleado usando el objeto proxy, así:

console.log(proxy.nombre);
console.log(proxy.apellido);
console.log(proxy.org);
console.log(proxy.nombreCompleto);

La salida será:

Tapas
Adhikary
No existe la propiedad, 'org'!
Tapas Adhikary

¡Noten cómo hemos cambiado mágicamente las cosas para el objeto empleado!

Proxy para la validación de los valores

Vamos a crear un objeto proxy para validar un valor entero.

Paso 1: Crear un handler que utilice una trap set

El handler se ve así:

const validador = {
    set: function(objeto, propiedad, valor) {
        if (propiedad === 'edad') {
            if(!Number.isInteger(valor)) {
                throw new TypeError('la edad debe ser un valor entero!');
            }
            if(valor < 0) {
                throw new TypeError('Esto no tiene sentido, la edad no puede ser negativa!');
            }
        }
    }
};

Paso 2: Crear un Objeto Proxy

Crear un objeto proxy como este:

let proxy = new Proxy(empleado, validador);

Paso 3: Asignar un valor no entero a una propiedad, digamos, la edad

Intenta hacer esto:

proxy.edad = 'Estoy probando un error garrafal'; // valor tipo string

La salida será así:

TypeError: la edad debe ser un valor entero!
    at Object.set (E:\Projects\KOSS\metaprogramming\js-mtprog\proxy\userSetProxy.js:28:23)
    at Object.<anonymous> (E:\Projects\KOSS\metaprogramming\js-mtprog\proxy\userSetProxy.js:40:7)
    at Module._compile (module.js:652:30)
    at Object.Module._extensions..js (module.js:663:10)
    at Module.load (module.js:565:32)
    at tryModuleLoad (module.js:505:12)
    at Function.Module._load (module.js:497:3)
    at Function.Module.runMain (module.js:693:10)
    at startup (bootstrap_node.js:188:16)
    at bootstrap_node.js:609:3

Del mismo modo, al intentar hacer esto:

p.edad = -1; // resultará en un error

Cómo usar Proxy y Reflect en conjunto

Este es un ejemplo de un handler en el que utilizamos métodos de la API de Reflect:

const empleado = {
    nombre: 'Tapas',
    apellido: 'Adhikary'
};

let logHandler = {
    get: function(objetivo, propiedad) {        
        console.log("Log: ", objetivo[propiedad]);
        
        // // Usar el método get del objeto Reflect
        return Reflect.get(objetivo, propiedad);
    }
};

let func = () => {
    let p = new Proxy(empleado, logHandler);
    p.nombre;
    p.apellido;
};

func();

Unos pocos casos más de uso de Proxy

Hay varios otros casos de uso en los que se puede utilizar este concepto.

  • Para proteger el ID de un objeto de la eliminación  (trap: borrarPropiedad)
  • Para rastrear los accesos a la propiedad (trap: get, set)
  • Para data vinculación (binding) (trap: set)
  • Con referencias revocables
  • Para manipular el comportamiento del operador in

... y muchos más.

Dificultades de la metaprogramación

Si bien el concepto de Metaprogramación nos da mucho poder, la magia de la misma puede ir por el camino equivocado a veces.

black_magic
Be careful of the other side of the magic

Ten cuidado de:

  • ¡Demasiada magia! Asegúrate de entenderla antes de aplicarla.
  • Posibles impactos de rendimiento cuando estás haciendo posible lo imposible
  • Podría ser visto como una contra-depuración

En resumen

Para resumir,

  • Reflect y Proxy son grandes incorporaciones en JavaScript para ayudar con la metaprogramación.
  • Muchas situaciones complejas pueden ser manejadas con este tipo de ayuda.
  • También hay que tener en cuenta los inconvenientes.
  • Los Symbols (Símbolos) de ES6 también pueden ser usados con sus clases y objetos existentes para cambiar su comportamiento.

Espero que este artículo le haya parecido útil. Todo el código fuente utilizado en este artículo se puede encontrar en mi repositorio GitHub.

Por favor, comparta el artículo para que otros puedan leerlo también. Puedes escribirme en Twitter (@tapasadhikary) con comentarios, o siéntete libre de seguirme.

Traducido del artículo de Daniel Borowski - The 10 Most Popular Coding Challenge Websites.