Voy a contarte una historia.
Hace unos meses estaba trabajando en un nuevo tipo de prueba A/B para Nelio A/B Testing cuando me di cuenta de que algo raro estaba pasando. Una de las funciones que acababa de implementar estaba ralentizando un montón nuestro plugin, impactando de forma muy negativa en su rendimiento. Y, claro, había que entender qué pasaba y arreglarlo.
La función en si misma no tenía, aparentemente, mayor misterio:
function is_product_under_test( $experiment_id, $product_id ) {
$control = nab_get_alternative( $experiment_id, 'control' );
return nab_some(
fn( $selection ) => do_products_match( $selection, $product_id ),
nab_get( $control, 'productSelections', [] )
);
}Lenguaje del código: PHP (php)En principio, parece que lo único que esta función hace es comprobar si un cireto producto de WooCommerce $product_id forma parte de una prueba $experiment_id. Pero, claro, para responder a esta pregunta debemos tirar del hilo y comprobar la configuración de la prueba que hizo el usuario:
- O bien todos los productos están bajo test
- O bien lo están solo algunos. En tal caso, el usuario debió indicar:
- o bien una lista de identificadores de producto
- o bien una lista de categorías y/o etiquetas a las que debería pertenecer
Si te fijas en el código anterior, verás que usamos una función auxiliar llamada do_products_match, que precisamente realiza las comprobaciones que te acabo de describir:
function do_products_match( $selection, $product_id ) {
if ( 'all-products' === $selection['type'] ) {
return true;
}
$subset = $selection['value'];
switch ( $subset['type'] ) {
case 'product-ids':
return do_products_match_by_id( $subset, $product_id );
case 'product-taxonomies':
return nab_every(
fn( $taxsel ) =>
do_products_match_by_taxonomy( $taxsel, $product_id ),
$subset['value']
);
default:
return false;
}
}Lenguaje del código: PHP (php)¿Ves? Si $selection es de tipo all-products, la función siempre devuelve true. Por otro lado, si se trata de una $selection que aplica a un subconjunto de productos, entramos en el detalle según el tipo de sub-selección que tenemos.
En el caso de productos por ID, lo que hay que hacer es cargar todos los ID relevantes (recuerda que WooCommerce tiene productos variables y tal que debemos tener en cuenta) y simplemente comparamos listas de IDs:
function do_products_match_by_id( $selection, $product_ids ) {
$actual_pids = add_parent_products( $product_ids );
$tracked_pids = $selection['productIds'];
$matching_pids = array_intersect( $actual_pids, $tracked_pids );
$excluded = ! empty( $selection['excluded'] );
$mode = $selection['mode'];
if ( $excluded ) {
return 'and' === $mode
? empty( $matching_pids )
: count( $matching_pids ) < $tracked_pids;
}//end if
$mode = $selection['mode'];
return 'and' === $mode
? count( $tracked_pids ) === count( $matching_pids )
: ! empty( $tracked_pids );
}//end do_products_match_by_id()Lenguaje del código: PHP (php)En el caso de las taxonomías producto y/o etiqueta, cargamos todos los términos asociados al producto que nos pasan y los cotejamos con la $selection del usuario:
function do_products_match_by_taxonomy( $selection, $product_ids ) {
$actual_pids = add_parent_products( $product_ids );
$tracked_terms = $selection['termIds'];
$actual_terms = get_all_terms( $selection['taxonomy'], $actual_pids );
$matching_terms = array_intersect( $actual_terms, $tracked_terms );
$excluded = ! empty( $selection['excluded'] );
$mode = $selection['mode'];
if ( $excluded ) {
return 'and' === $mode
? empty( $matching_terms )
: count( $matching_terms ) < $tracked_terms;
}//end if
$mode = $selection['mode'];
return 'and' === $mode
? count( $matching_terms ) === count( $tracked_terms )
: ! empty( $matching_terms );
}//end do_products_match_by_taxonomy()Lenguaje del código: PHP (php)Así que, ya ves, algo que puedes más o menos explicar en un par de frases y que se puede abstraer muy buen en una función simple, se complica bastante cuando bajas al detalle y miras todas las funciones involucradas.
Lógicamente, estas comprobaciones llevan un cierto tiempo. Por suerte, el coste de las mismas es insignificante. Salvo, claro está, que estés evaluando la misma función con los mismos parámetros un montón de veces seguidas.
Que es precisamente lo que estaba haciendo yo 🙃
¿Que por qué? Pues porque necesitaba realizar esa comprobación durante un montón de filtros y acciones de WooCommerce:
woocommerce_product_titlewoocommerce_order_item_get_namewoocommerce_short_descriptionwoocommerce_product_get_image_idwoocommerce_product_get_gallery_image_idswoocommerce_product_get_pricewoocommerce_product_get_regular_price- etc
Y es que cada vez que WooCommerce tenía que hacer algo con un producto (y si había un filtro/acción vinculada a esa acción), no me quedaba otra que verificar si el producto estaba bajo test y, en tal caso, aplicar los cambios que correspondieran según el test A/B.
Así que, vale, tengo una función que implica una cierta complejidad y que estoy llamando un montón de veces con los mismos argumentos, lo cual acaba generando un cuello de botella en mi plugin. ¿Cómo lo arreglo?
La respuesta está, como no podía ser de otra forma, en la entrada de hoy: se arregla usando memoización.
Memoización
La memoización es una técnica de optimización que permite acelerar un programa usando una cache en donde almacenamos los resultados de cálculos difíciles de computar para poder reusarlos cuando los necesitemos sin tener que recalcularlos de nuevo.
Si estás pensando: «oye, esto se parece mucho a un plugin de cache, ¿no?», estás en lo correcto. Un plugin de cache funciona tal que así: dado una cierta URL, partimos de la premisa de que generar la página asociada a esa URL es algo complejo y que lleva tiempo. Así que el plugin de cache lo que hace es guardar una copia de la página resultante para que las peticiones sucesivas se puedan resolver con la página ya generada sin tener que volver a crearla.
La memoización usa exactamente los mismos dos principios:
- Cache. Cuando una función memoizada se llama por primera vez, se calcula el resultado y se guarda (o «memoíza») en una estructura de datos tipo diccionario o cache o similar.
- Reutilizar. Cuando esa función se vuelve a invocar, se usa el resultado que hay en la cache y nos ahorramos el coste de tener que generar el resultado nuevamente.
Ejemplo
Volvamos a mi historia de antes. La función cara que es candidata a ser memoizada era esta:
function is_product_under_test( $experiment_id, $product_id ) {
$control = nab_get_alternative( $experiment_id, 'control' );
return nab_some(
fn( $selection ) => do_products_match( $selection, $product_id ),
nab_get( $control, 'productSelections', [] )
);
}Lenguaje del código: PHP (php)Nuestro objetivo es guardar de algun forma el resultado para reaprovecharlo en el futuro. ¿Cómo podemos implementar una cache dentro de una función PHP para memorizar los resultados de la misma y evitar recálculos computacionalmente complejos? Pues, lo creas o no, con unas pocas líneas de código y una variable estática:
function is_product_under_test( $experiment_id, $product_id ) {
static $cache = array();
$cache_key = "{$experiment_id}:{$product_id}";
if ( isset( $cache[ $cache_key ] ) ) {
return $cache[ $cache_key ];
}
$control = nab_get_alternative( $experiment_id, 'control' );
$cache[ $cache_key ] = nab_some(
fn( $selection ) => do_products_match( $selection, $product_id ),
nab_get( $control, 'productSelections', [] )
);
return $cache[ $cache_key ];
}Lenguaje del código: PHP (php)Basta con definir un diccionario static (yo lo he llamado $cache) junto a una $cache_key generada a partir de los argumentos de entrada con la que indexar el resultado y listo. Si ya tenemos el resultado en $cache, lo devolvemos. Si no, lo calculamos, lo guardamos y lo devolvemos. ¡Así de fácil!

Nelio A/B Testing
Pruebas A/B nativas en WordPress
Usa tu editor de páginas favorito en WordPress para crear variaciones y lanza pruebas A/B con solo un par de clics. No se necesita saber nada de programación para que funcione.
Limitaciones y Consideraciones
La memoización es una técnica simple y eficaz para optimizar tu plugin. Especialmente si tu plugin, como el mío, tiene alguna función complicada que se invoca múltiples veces. Pero, ojo, memoizar funciones no siempre es posible. Algunas cosas que deberías tener en cuenta y ponderar antes de aplicarla incluyen:
- Tipos de los argumentos. Se supone que para memoizar una función debes cachear resultados en función de los parámetros de entrada. Es decir, debes poder generar una clave de indexación a partir de los mismos. Es por ello que argumentos simples e inmutables (como strings o enteros) son ideales para ello, mientras que argumentos más complejos pueden darte problemas.
- Idempotencia. La memoización también parte de la premisa de que tu función siempre genera el mismo resultado, dada la misma entrada de datos. Si la función depende de estado externo, es probable que los resultados que obtienes no sean correctos porque se estaría devolviendo un resultado cacheado que no refleja el valor actual de ese estado externo. Por ejemplo, en el caso que te he comentado hoy podríamos discutir si acceder a las categorías y etiquetas de un producto forma parte de ese «estado externo»… pero en este caso no lo considero un problema porque los resultados se cachean mientras se está atendiendo a una petición HTTP concreta; al tratarse de ejecuciones que duran un intervalo de tiempo breve, podemos considerar ese estado externo como constante durante dicha ejecución.
- Efectos secundarios fallidos. Las funciones que deben realizar efectos secundarios, como escribir en un log, modificar variables globales, etc) pueden no funcionar como es debido cuando están memoizadas. Esto puede suceder cuando dichos efectos secundarios dejan de ejecutarse cuando se devuelve directamente el resultado que hay cacheado en la propia función.
La memoización, pues, es ideal en estos casos:
- Cuando tienes funciones puras (misma entrada, misma salida)
- Puedes usar los argumentos para indexar los resultados.
- No hay efectos secundarios.
Espero que te haya gustado la entrada de hoy y, si lo ha hecho, compártela con tu equipo.
Imagen destacada de Fredy Jacob en Unsplash.




Deja una respuesta