hash_cache, generando cachés dinámicas para las funciones más rebeldes

Una práctica común en programación consiste en tener precalculados los valores devuelto por una función relativamente costosa en términos de uso de CPU. Recuerdo que esto se usaba mucho para tener precalculados los valore de las funciones trigonométricas en las viejas demos VGA de hace más de una década. Por supuesto, esto sólo es posible si conocemos de antemano todos los posibles valore de entrada de la función.

Cuando esto no es posible, una posibilidad es cachear los resultados de la función en un array, de forma que una vez que emos calculado un valor, sucesivas consultas de la función para ese mismo valor no ejecutan la función, sino que consultan su valor en una tabla.

Rails da soporte para ello, y de una manera muy sencilla que veremos en este artículo.

Tomemos, por ejemplo, el clásico ejemplo de la función que evalúa un factorial

1
2
3
4
5
6
 class Prueba
    def factorial(n)
           return n * factorial(n-1) unless n==1 
        1
    end
 end

Como siempre, podemos lanzar script/console en un proyecto Rails, escribir la definición anterior de la clase y probar:

 >> p=Prueba.new
 => #
 >> p.factorial(15)
 => 1307674368000

Si quisiéramos establecer una caché para ir almacenando los resultados que obtenemos de esta función para no calcular cada valor más de una vez podríamos hacer algo así como lo siguiente (si habeis tecleado el ejemplo anterior, es deseable salir y volver a entrar en script/console)

1
2
3
4
5
6
7
8
 class Prueba
    class << self; include ActiveSupport::CachingTools::HashCaching; end
    def factorial(n)
        return n * factorial(n-1) unless n==1
        1
    end
    hash_cache :factorial
 end

¿Qué hace el método hash_cache? Pues genera al vuelo una nueva función para nuestra clase, que tendrá el sufijo _cache (factorial_cache, en nuestro caso) y que se encarga, transparentemente, de buscar en un hash privado, usando como clave el parámetro de la función, si existe un valor previamente devuelto, en cuyo caso lo devuelve, y si o es así, invoca a la función original (factorial) y almacena el valor devuelto en la cache. Lo más interesante de todo esto es que funciona para cualquier número de parámetros que tenga la función que deseemos cachear.

En nuestro caso, el código de la función factorial_cache generado automáticamente por hash_cache sería

1
2
3
 def factorial_cache()
    @factorial_cache ||= Hash.new {|h1, v1| h1[v1] = factorial(v1) } 
 end

Es decir, factorial_cache se comportará como un hash que recibe como clave el valor de la función, si este elemento o se encuentra, se evalúa la función solicitada. (Para funciones multivariable, el esquema se complica algo pero el funcionamiento es básicamente el mismo)

Así:

 >>p=Prueba.new
 >>p.factorial(60)   # funcionamiento tradicional

 >>p.factorial_cache[60]  # primera invocación, se crea el elemento de la cache
 >>p.factorial_cache[15]  # ídem para otro valor

 >> p.factorial_cache
 => {60=>8320987112741390144276341183223364380754172606361245952449277696409600000000000000, 15=>1307674368000}

Posteriores invocaciones a p.factorial_cache con los valores 60 y 15, no volverán a evaluar la función factorial, sino que devolverán los valores que ya se encuentran almacenados en la cache.

Pero, ¿realmente funciona esto?

Comprobémoslo. Basta con crearnos una aplicación RoR, vacía, por ejemplo hashtest y generar un controlador para hacer la prueba:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class TestController < ApplicationController
  class << self; include ActiveSupport::CachingTools::HashCaching; end
  
  def test1
    @crono = Time.now

    1.upto(1000) do
      factorial(300)
    end
    @total = Time.now - @crono  
  end
  
  def test2
    @crono = Time.now

    1.upto(1000) do
      factorial_cache[300]
    end
    @total = Time.now - @crono
    
  end

  def factorial (n)
    return n*factorial(n-1) unless n==1
    1
  end

  hash_cache :factorial
end

En las vistas test1.rhtml y test2.rhtml simplemente mostramos la variable total que marca el número de segundos transcurridos en la ejecución del cuerpo del controlador. Los resultados cantan: la acción test1, que usa la función, tarda en nuestro equipo del orden de un segundo y medio, mientras que la acción test2, que utiliza la caché, tarda ¡menos de una décima!

¿Cómo hace Rails esta magia?

Pues basándose en la magia de Ruby. Veamos el código de la función hash_cache, definida en /usr/local/lib/ruby/gems/1.8/gems/activesupport-1.3.1/lib/active_support/caching_tools (para mi instalación de Rails y Ruby). Simplificando el código del método, se nos queda así

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
 def hash_cache(method_name, options = {})
     selector = options[:as] || "#{method_name}_cache"
     method = self.instance_method(method_name)

     args = []
     code = "def #{selector}(); @#{selector} ||= "

     (1..method.arity).each do |n|
       args << "v#{n}"
       code << "Hash.new {|h#{n}, v#{n}| h#{n}[v#{n}] = "
     end

     code << "#{method_name}(#{args * ', '}) #{'}' * method.arity} end"

     class_eval code
 end

Se deja como ejercicio para el lector (y para el autor de este blog también) comprender en su totalidad qué hace este código de Ruby. Como aliciente, diremos que si realmente entendemos su funcionamiento, estaremos realmente adentrándonos en el Tao de Ruby…

blog comments powered by Disqus