Programando en Ruby

La Guía de los "Programadores Pragmáticos"

Containers, Blocks, and Iterators



Una caja de música con una sóla canción no es muy popular (excepto en bares realmente espantosos), así que pronto tendremos que empezar a pensar en producir un catálogo de canciones disponibles y una lista de reproducción de canciones pendientes de ser reproducidas. Ambos son contenedores: objetos que contienen referencias a uno o más objetos.

Tanto el catálogo como la lista de reproducción necesitan un conjunto parecido de métodos: añadir y eliminar canciones, devolver una lista, etc. Ésta puede realizar tareas adicionales, como insertar publicidad de vez en cuando o mantener una lista de tiempo de reproducción acumulado, pero nos preocuparemos de esas cosas más adelante. Mientras tanto, parece buena idea desarrollar algún tipo de clase genérica SongList, cuyas especializaciones podrían ser catálogos y listas de reproducción.

Containers

Antes de comenzar a implementar, necesitaremos planear cómo vamos a almacenar la listas de canciones dentro de un objeto de la clase SongList. Tenemos las siguientes opciones obvias: usar alguno de los tipos de Ruby Hash o Array o, por contra, crear nuestra propia estructura de lista. Por pereza, de momento, vamos a echar un vistazo a arrays y hashes y escogeremos una de ellas.

Arrays

La clase Array contiene una colección de referencias a objetos. Cada referencia ocupa una posición en el array, identificado por un índice entero no negativo.

Puedes crear arrays implícitamente utilizando literales o explícitamente creando un objeto Array. Un array literal es, simplemente, una lista de objetos escritos entre corchetes.

a = [ 3.14159, "pie", 99 ]
a.type » Array
a.length » 3
a[0] » 3.14159
a[1] » "pie"
a[2] » 99
a[3] » nil
b = Array.new
b.type » Array
b.length » 0
b[0] = "second"
b[1] = "array"
b » ["second", "array"]

Los arrays son indexados usando el operador []. Como la mayoría de operadores de Ruby, se trata de un método (de la clase Array) y, por lo tanto, puede ser redefinido en sus subclases. Como muestra el ejemplo, los índices de los arrays comienzan en cero. Indexar un array con un único entero devuelve el objeto situado en esa posición o nil si dicha posición está vacía. Indexar un array con un entero negativo hace que se cuente a partir del final. Esto se ilustra en la Figura 4.1 en la página 35.

Figura no disponible...

a = [ 1, 3, 5, 7, 9 ]
a[-1] » 9
a[-2] » 7
a[-99] » nil

Incluso pueden indexarse arrays por medio de una pareja de números [comienzo, cantidad]. Esto devolverá un nuevo array compuesto por referencias a cantidad objetos empezando en la posición comienzo.

a = [ 1, 3, 5, 7, 9 ]
a[1, 3] » [3, 5, 7]
a[3, 1] » [7]
a[-3, 2] » [5, 7]

Finalmente, puedes indexar arrays utilizando rangos, separando las posiciones de comienzo y final por dos o tres puntos. Usando sólo dos puntos, se incluye también la posición final, mientras que si se opta por los tres puntos, ésta queda fuera.

a = [ 1, 3, 5, 7, 9 ]
a[1..3] » [3, 5, 7]
a[1...3] » [3, 5]
a[3..3] » [7]
a[-3..-1] » [5, 7, 9]

El operador [] tiene relación directa con el operador []=, que permite poner elementos en el array. Si se utiliza un sólo entero como índice, el elemento en la posición indicada es reemplazado por lo que se encuentre en la derecha de la asignación. Cualquier hueco que se produzca será rellenado con nil.

a = [ 1, 3, 5, 7, 9 ] » [1, 3, 5, 7, 9]
a[1] = 'bat' » [1, "bat", 5, 7, 9]
a[-3] = 'cat' » [1, "bat", "cat", 7, 9]
a[3] = [ 9, 8 ] » [1, "bat", "cat", [9, 8], 9]
a[6] = 99 » [1, "bat", "cat", [9, 8], 9, nil, 99]

Si el índice de []= está formado por dos números (comienzo y longitud) o un intervalo, se reemplazan esos elementos del array original por lo que se encuentre a la derecha de la asignación. Si la longitud es cero, se insertará esa parte derecha antes de la posición de comienzo, sin eliminar ningún elemento. Si el lado derecho es un array en sí mismo, se utilizan sus elementos en el reemplazo. El tamaño del array se ajusta automáticamente si el índice implica número diferente de elementos a los que hay en la parte derecha de la asignación.

a = [ 1, 3, 5, 7, 9 ] » [1, 3, 5, 7, 9]
a[2, 2] = 'cat' » [1, 3, "cat", 9]
a[2, 0] = 'dog' » [1, 3, "dog", "cat", 9]
a[1, 1] = [ 9, 8, 7 ] » [1, 9, 8, 7, "dog", "cat", 9]
a[0..3] = [] » ["dog", "cat", 9]
a[5] = 99 » ["dog", "cat", 9, nil, nil, 99]

Los arrays cuentan con muchos otros métodos útiles. Gracias a ellos, puedes tratar a los arrays como pilas, conjuntos, colas, FIXME dequeues y ``fifos''. Una completa lista de métodos de arrays se encuentra a partir de la página 278.

Hashes

Los hashes (conocidos también como arrays asociativos o diccionarios) son parecidos a los arrays, en el sentido de que se trata de colecciones de referencias a objetos.

Sin embargo, mientras se utilizan enteros para indexar los arrays, es posible indexar un hash usando objetos de cualquier tipo: cadenas, expresiones regulares, etc. Cuando almacenas un valor en un hash, especificas dos objetos---la clave y el valor. Puedes recuperar el valor indexando el hash con la misma clave. Los valores en un hash pueden ser objetos de cualquier tipo. El siguiente ejemplo utiliza hash literales: una lista de pares clave => valor delimitados por llaves.

h = { 'dog' => 'canine', 'cat' => 'feline', 'donkey' => 'asinine' }
h.length » 3
h['dog'] » "canine"
h['cow'] = 'bovine'
h[12]    = 'dodecine'
h['cat'] = 99
h » {"cow"=>"bovine", "cat"=>99, 12=>"dodecine", "donkey"=>"asinine", "dog"=>"canine"}

Comparado con los arrays, los hashes tienen una ventaja significativa: pueden usar cualquier objeto como índice. Sin embargo, cuentan también con una importante desventaja: sus elementos no están ordenados, así que no puedes usarlos fácilmente como si de pilas o colas se tratara.

Los hashes son una de las estructuras de datos más utilizadas en Ruby. En la página 317 comienza una completa lista de sus métodos.

Implementing a SongList Container

Después de este pequeño inciso en arrays y hashes, ya estamos preparados para implementar la clase SongList. Vamos a crear una lista básica de métodos que pueden resultar necesarios en nuestra SongList. Querremos añadir más según vayamos avanzando, pero de momento bastará con estos.

append( aSong ) » list
Append the given song to the list.
deleteFirst() » aSong
Remove the first song from the list, returning that song.
deleteLast() » aSong
Remove the last song from the list, returning that song.
[ anIndex } » aSong
Return the song identified by anIndex, which may be an integer index or a song title.

Esta lista de métodos nos da algunas pistas sobre cómo debe ser la implementación. La habilidades de añadir canciones y eliminarlas tanto del principio como del final, aconsejan usar una "doble cola"---una cola con dos finales---que, como sabemos, puede ser implementada utilizando un Array. Del mismo modo, esta clase ofrecerá la posibilidad de devoler una canción en base a un índice entero.

Sin embargo, también debería ser posible acceder a las canciones por su título, lo que sugeriría el uso de un hash con el título como clave y la canción como valor. ¿Podríamos entonces utilizar un hash? Posiblemente, pero hay algunos problemas. Para empezar, un hash no tiene orden, así que seguramente deberíamos usar un array auxiliar para mantener el orden de la lista de canciones. Y aún existe un problema mayor: los hashes no son capaces de asociar múltiples claves a un mismo valor. Esto podría ser un inconveniente para una lista de reproducción, ya que existe la posibilidad de que se repita alguna canción para que suene más de una vez. Así que, por ahora, nos apañaremos con un array de canciones, buscando entre los títulos cuando lo necesitemos. Si este se convirtiera en un problema para el rendimiento, siempre podríamos añadir algún tipo de búsqueda basada en un hash más adelante.

Comenzaremos nuestra clase con el método básico initialize, que crea el Array que usaremos para guardar las canciones y almacenar las referencias a éstas en la variable de instancia @songs.

class SongList
  def initialize
    @songs = Array.new
  end
end

El método SongList#append añade una canción al final del array @songs. Además, devuelve self, una referencia al propio objeto SongList. Esta es una convención muy útil, ya que permite encadenar múltiples llamadas a append, como veremos posteriormente en un ejemplo.

class SongList
  def append(aSong)
    @songs.push(aSong)
    self
  end
end

Ahora añadiremos los métodos deleteFirst y deleteLast, implementados de forma trivial utilizando Array#shift y Array#pop , respectivamente.

class SongList
  def deleteFirst
    @songs.shift
  end
  def deleteLast
    @songs.pop
  end
end

Llegados a este punto, parece buena idea hacer una prueba rápida. Primero, añadiremos cuatro canciones a la lista. Como demostración, aprovecharemos el hecho de que append devuelva el objeto SongList para encadenar las llamadas a este método, como se mencionó anteriormente.

list = SongList.new
list.
  append(Song.new('title1', 'artist1', 1)).
  append(Song.new('title2', 'artist2', 2)).
  append(Song.new('title3', 'artist3', 3)).
  append(Song.new('title4', 'artist4', 4))

Entonces, comprobaremos que las canciones son eliminadas del comienzo y el final de la lista correctamente, y que se devuelve nil cuando se vacía la lista.

list.deleteFirst » Song: title1--artist1 (1)
list.deleteFirst » Song: title2--artist2 (2)
list.deleteLast » Song: title4--artist4 (4)
list.deleteLast » Song: title3--artist3 (3)
list.deleteLast » nil

Hasta ahora todo va bien. Nuestro próximo método es [], que accede a los elementos por medio de un índice. Si se trata de un número (lo que comprobaremos utilizando Object#kind_of? ), simplemente devolveremos el elemento que se encuentre en esa posición.

class SongList
  def [](key)
    if key.kind_of?(Integer)
      @songs[key]
    else
      # ...
    end
  end
end

De nuevo, la prueba es trivial.

list[0] » Song: title1--artist1 (1)
list[2] » Song: title3--artist3 (3)
list[9] » nil

Ahora necesitamos añadir la posibilidad de buscar una canción por su título. Esto implicará recorrer las canciones de la lista, comprobando el título de cada una de ellas. Para hacerlo, necesitamos primero echar un vistazo a una de las mejores características de Ruby: los iteradores.

Bloques e Iteradores

Así que nuestro próximo problema con SongList es implementar el método [], que toma una cadena y busca una canción con ese título. Esto parece algo bastante directo: tenemos un array de canciones, así que simplemente recorremos todos los elementos buscando la coincidencia en el título.

class SongList
  def [](key)
    if key.kind_of?(Integer)
      return @songs[key]
    else
      for i in 0...@songs.length
        return @songs[i] if key == @songs[i].name
      end
    end
    return nil
  end
end

Esto funciona y resulta familiar: un bucle for iterando sobre un array. ¿Qué podría ser más natural?

Pues he aquí que existe algo más natural aún. En cierto modo, nuestro bucle for ``intima'' con el array; le pregunta por su longitud y entonces va recuperando valores hasta que encuentra lo que busca. ¿Por qué no, simplemente, pedirle al array que haga esta comparación a todos sus miembros? Esto es, ni más ni menos, lo que hace el método find de la clase Array.

class SongList
  def [](key)
    if key.kind_of?(Integer)
      result = @songs[key]
    else
      result = @songs.find { |aSong| key == aSong.name }
    end
    return result
  end
end

El código podríamos hacerlo más pequeño aún si utilizamos una sentencia if.

class SongList
  def [](key)
    return @songs[key] if key.kind_of?(Integer)
    return @songs.find { |aSong| aSong.name == key }
  end
end

El método find es un iterador---un método que invoca un bloque de código repetidamente. Los iteradores y los bloques de código están entre las funcionalidades más características de Ruby, así que vamos a detenernos profundizar en ellos (y, de paso, explicaremos qué es lo que hace exactamente esa línea de código de nuestro método []).

Implementing Iterators

Un iterador de Ruby no es más que un método capaz de invocar a un bloque de código. A primera vista, un bloque en Ruby tiene el mismo aspecto que un bloque en C, Java o Perl. Desafortunadamente, las apariencias engañan---un bloque en Ruby es un modo de agrupar sentencias, pero no en el sentido convencional.

Para empezar, un bloque puede aparecer sólamente en el código adyacente a la invocación de un método; el bloque se escribe empezando en la misma línea que el último parámetro del método. Además, el código del bloque no se ejecuta cuando se encuentra. En su lugar, Ruby recuerda el contexto en el que aparece el bloque (las variables locales, el objeto, etc.) y entonces entra en el método. Y es aquí donde comienza la magia.

Dentro del método, el bloque puede ser invocado, como si se tratase de un método, utilizando la sentencia yield. Cuando se ejecuta un yield, se invoca al código contenido en el bloque. Cuando se sale del bloque, la ejecución continúa por la sentencia que sucede al yield. [A los entusiastas de los lenguajes de programación les gustará saber que la palabra reservada yield fue escogida imitando a la función yield del lenguaje CLU de Liskov's. Dicho lenguaje tiene unos 20 años de antiguedad y muchas de sus funcionalidades no se encuentran aún muy extendidas.] Empecemos con un ejemplo sencillo.

def threeTimes
  yield
  yield
  yield
end
threeTimes { puts "Hello" }
produces:
Hello
Hello
Hello

El bloque (código entre llaves) se asocia con la llamada al método threeTimes. Dentro de este método, se invoca a yield tres veces por fila. Cada vez que se ejecuta el código contenido en el bloque se imprime un saludo. Sin embargo, lo que hace a los bloques más interesantes es la posibilidad de poder pasarles parámetros y que te devuelvan valores. Por ejemplo, podríamos escribir función sencilla que devolviera los primeros miembros de la serie de Fibonacci.[La serie básica de Fibonacci consiste en una secuencia de enteros que comienza con dos "unos", de modo que los siguientes términos se calculan a partir de la suma de los dos anteriores. En ocasiones, las series se utilizan en algoritmos de ordenación y análisis de fenómenos naturales.]

def fibUpTo(max)
  i1, i2 = 1, 1        # parallel assignment
  while i1 <= max
    yield i1
    i1, i2 = i2, i1+i2
  end
end
fibUpTo(1000) { |f| print f, " " }
produces:
1 1 2 3 5 8 13 21 34 55 89 144 233 377 610 987

En este ejemplo, la sentencia yield tiene un parámetro. Este valor se pasa al bloque asociado. En la definición del bloque, la lista de argumentos aparece entre barras verticales. En esta caso, la variable f recibe el valor que se le pasó a yield, así que el bloque imprime los sucesivos miembros de la serie (Puede verse incluso un ejemplo de asignación paralela en este fragmento de código. Volveremos sobre ese tema en la página 75). A pesar de que es normal pasar sólo un valor al bloque, no es obligatorio: puede tomar cualquier número de argumentos. Pero, ¿qué ocurre si el bloque espera un número de parámetros distinto del que se le pasa al yield? Por asombrosa casualidad, aquí entran en juego las reglas de asignación paralela (con un ligero cambio: los múltiples parámetros que se pasan a yield se convierten en un array si el bloque tiene tan sólo un argumento).

Los parámetros que se pasan a un bloque pueden ser variables locales ya existentes; si es así, los nuevos valores de las variables serán conservados cuando la ejecución de bloque haya terminado. Es posible que este sea un comportamiento inesperado, pero se puede obtener cierta ganancia de rendimiento al reutilizar variables que ya existen.[Para más información acerca de éste y otros FIXME ``gotchas'', ver la lista que comienza en la página 127; más información acerca de rendimiento en la 128.]

Un bloque puede, incluso, devolver un valor al método. El valor de la última expresión evaluada en el bloque se entrega al método como el valor del yield. Así es como el método find, utilizado por la clase Array, funciona.[El método find se encuentra definido en el módulo Enumerable, que está "combinado" (mix-in) con la clase Array.] Su implementación podría ser algo parecido a lo siguiente:

class Array
  def find
    for i in 0...size
      value = self[i]
      return value if yield(value)
    end
    return nil
  end
end
[1, 3, 5, 7, 9].find {|v| v*v > 30 } » 7

Esto pasa los sucesivos elementos del array al bloque asociado. Si el bloque devuelve true, el método retorna el elemento correspondiente. Si no coincide ningún elemento, el método devuelve nil. El ejemplo ilustra el beneficio de esta aproximación mediante iteradores. La clase Array hace lo que mejor sabe: acceder a los elementos, dejando al código de la aplicación concentrarse en sus propias necesidades (en este caso, encontrar una entrada que cumpla con algún criterio matemático).

Algunos iteradores son comunes a muchos tipos de colecciones de Ruby. find ya lo hemos visto con anterioridad. Los otros dos métodos comunes son each y collect. each es, probablemente, el iterador más simple---se limita a hacer un yield de los elementos de una colección.

[ 1, 3, 5 ].each { |i| puts i }
produces:
1
3
5

El iterador each tiene un lugar especial en Ruby; en la página 85 describiremos su uso como base del bucle for, y a partir de la 102 veremos cómo, definiendo un método each, puedes añadir funcionalidad a tus clases de forma sencilla.

El otro iterador común es collect, que toma un elemento de la colección y se lo pasa al bloque. Los resultados que el bloque devuelve se usan para construir un nuevo array. Por ejemplo:

["H", "A", "L"].collect { |x| x.succ } » ["I", "B", "M"]

Ruby comparado con C++ y Java

No merece la pena desperdiciar un párrafo comparando la aproximación de Ruby a los iteradores con las de C++ o Java. En el caso de Ruby, el iterador es simplemente un método, como cualquier otro, que llama a yield cada vez que genera un nuevo valor. FIXME The thing that uses the iterator is simply a block of code associated with this method. No hay necesidad de crear clases de apoyo para llevar el estado del iterador, como en Java y C++. En este y otros aspectos, Ruby es un lenguaje transparente. Cuando escribes un programa en Ruby, te concentras en que haga lo que tiene que hacer, y no en desarrollar una base en la que apoyar el propio lenguaje.

Los iteradores no sólo pueden acceder a los datos contenidos en arrays y hashes. Como se vio en el ejemplo de la serie de Fibonacci, un iterador puede devolver valores derivados. La entrada/salida de Ruby hace uso de esta capacidad, implementando un iterador interfaz que devuelve sucesivas líneas (o bytes) en un flujo de E/S.

f = File.open("testfile")
f.each do |line|
  print line
end
f.close
produces:
This is line one
This is line two
This is line three
And so on...

Echemos un vistazo a la implementación de un iterador más. El lenguaje Smalltalk permite iteradores sobre colecciones. Si pides a un programador en Smalltalk que sume los elementos de un array, seguramente hará uso de la función inject.

sumOfValues              "Smalltalk method"
    ^self values
          inject: 0
          into: [ :sum :element | sum + element value]

El método inject funciona de este modo: la primera vez que se llama al bloque asociado, sum se inicializa al valor del parámetro de inject (cero en este caso) y element al del primer elemento del array. La segunda y sucesivas llamadas, sum toma el valor que devolvió el bloque en la llamada anterior. De este modo, sum puede utilizarse como acumulador. El valor final de inject es el devuelto por el bloque la última vez que se le llamó.

Ruby no tiene un método inject, pero es fácil escribirlo. Ahora vamos a añadírselo a la clase Array, pero más adelante, en la página 100, veremos cómo hacerlo mucho más general.

class Array
  def inject(n)
     each { |value| n = yield(n, value) }
     n
  end
  def sum
    inject(0) { |n, value| n + value }
  end
  def product
    inject(1) { |n, value| n * value }
  end
end
[ 1, 2, 3, 4, 5 ].sum » 15
[ 1, 2, 3, 4, 5 ].product » 120

Aunque a menudo los bloques se utilizan en combinación con los iteradores, pueden tener otros usos. Veamos algunos de ellos.

Bloques para Transacciones

Los bloques pueden usarse para definir una porción de código que debe ejecutarse bajo algún tipo de control transaccional. Por ejemplo, a menudo necesitarás abrir un fichero y hacer algo con su contenido. Y, por supuesto, querrás asegurarte de que se cierra cuando acabas. Aunque puedes hacer esto de forma convencional, hay un argumento para hacer al fichero responsable de cerrarse él mismo. Puedes hacerlo con bloques. Una implementación ingenua (ignorando el manejo de errores) podría tener el siguiente aspecto.

class File
  def File.openAndProcess(*args)
    f = File.open(*args)
    yield f
    f.close()
  end
end

File.openAndProcess("testfile", "r") do |aFile|   print while aFile.gets end
produces:
This is line one
This is line two
This is line three
And so on...

Este pequeño ejemplo ilustra algunas técnicas. El método openAndProcess es un método de clase---puede ser invocado independientemente de cualquier objeto File. Le hemos puesto los mismos argumentos que el método convencional File.open , pero realmente no nos preocupamos acerca de qué parámetros se trata. En su lugar, hemos especificado los argumentos como *args, lo que significa ``recoge los parámetros pasados al método y mételos en un array''. Entonces, llamamos a File.open pasándole *args como parámetro. Esto expande el array dando lugar a los parámetros individuales. El resultado es que openAndProcess pasa, de forma transparente, los parámetros que recibe a File.open .

Una vez que el fichero ha sido abierto, openAndProcess llama yield, pasando el objeto del fichero abierto al bloque. Cuando se vuelve del bloque, el fichero se cierra. De este modo, la responsabilidad de cerrar un fichero abierto ha sido trasladada del usuario de los objetos de ficheros a los ficheros en sí mismos.

Finalmente, este ejemplo usa do...end para definir un bloque. La única diferencia entre esta notación y las llaves es la precedencia: do...end tiene menor peso que ``{...}''. Discutiremos su impacto en la página 234.

Tener ficheros que manejen su ciclo de vida por sí mismo es tan útil que la clase File que viene con Ruby implementan esta característica. Si File.open tiene un bloque asociado, éste será invocado con un objeto fichero que se cerrará al terminar la ejecución del bloque. Este resulta interesante ya que File.open tiene dos comportamientos distintos: si se lo llama con un bloque, lo ejecuta y cierra el fichero. Si no hay bloque alguno, devuelve el objeto fichero. Esto es posible gracias al método Kernel::block_given? , que devuelve true si el método actual tiene un bloque asociado. Haciendo uso de este método, podrías implementar File.open (de nuevo ignorando el manejo de errores) de la forma siguiente.

class File
  def File.myOpen(*args)
    aFile = File.new(*args)
    # If there's a block, pass in the file and close
    # the file when it returns
    if block_given?
      yield aFile
      aFile.close
      aFile = nil
    end
    return aFile
  end
end

FIXME Blocks Can Be Closures

Volvamos a nuestra máquina de discos por un momento (¿la recuerdas?). En algún punto, tendremos que trabajar con el código que gestione la interfaz de usuario---los botones que la gente pulsa para seleccionar canciones y controlar la máquina. Necesitaremos asociar acciones a esos botones: pulsar STOP para parar la música. Salta a la vista que los bloques de Ruby son una forma apropiada de hacerlo. Empecemos asumiendo que la gente que hizo el hardware implementó una extensión para Ruby que nos da una clase básica que representa botones. (Hablaremos acerca de extender Ruby a partir de la página 169.)

bStart = Button.new("Start")
bPause = Button.new("Pause")
# ...

¿Qué ocurre cuando el usuario pulsa uno de nuestros botones? En la clase Button, el hardware invocará a una función "folks rigged" FIXME. La forma más obvia de añadir funcionalidad a estos botones es crear subclases de Button que implementen sus propios métodos buttonPressed. What happens when the user presses one of our buttons? In the Button class, the hardware folks rigged things so that a callback method, buttonPressed, will be invoked. The obvious way of adding functionality to these buttons is to create subclasses of Button and have each subclass implement its own buttonPressed method.

class StartButton < Button
  def initialize
    super("Start")       # invoke Button's initialize
  end
  def buttonPressed
    # do start actions...
  end
end

bStart = StartButton.new

Este enfoque presenta dos problemas. El primero es que puede generar un montón de subclases. El cambio de la interfaz de Button podría implicar un montón de trabajo de mantenimiento. El segundo problema es que las acciones realizadas cuando se pulsa un botón están expresadas al nivel equivocado; realmente no se trata de funcionalidades de los botones, sino de la máquina de discos que hace uso de ellos. Ambos problemas pueden arreglarse haciendo empleando bloques.

class JukeboxButton < Button
  def initialize(label, &action)
    super(label)
    @action = action
  end
  def buttonPressed
    @action.call(self)
  end
end

bStart = JukeboxButton.new("Start") { songList.start } bPause = JukeboxButton.new("Pause") { songList.pause }

La clave está en el segundo parámetro del método de JukeboxButton#initialize. Si el último argumento en la definición de un método tiene como prefijo un ``ampersand'' (como &action), Ruby busca un bloque de código en el lugar en el que se haga la llamada. Este bloque se convierte a un objeto de la clase Proc, se asigna al parámetro precedido del ``ampersand'' y puede tratarse como cualquier otra variable. En nuestro ejemplo, lo hemos asignado a @action. Al llamar a buttonPressed, se usa el método Proc#call para invocar al bloque.

Entonces, ¿qué tenemos exactamente cuando creamos un objeto Proc? Lo interesante es que es más que un trozo de código. Todo el contexto en el que se define el bloque está asociado con él: el valor de self y los métodos, variables y constantes del ámbito. Y ahí está parte de la magia de Ruby: el bloque puede seguir usando la información de su ámbito original incluso si el entorno en el que ha sido definido ha desaparecido. En otros lenguajes, esta característica se conoce como closure.

Echemos un vistazo a un ejemplo artificial, donde se usa el método proc que convierte el bloque en un objeto Proc.

def nTimes(aThing)
  return proc { |n| aThing * n }
end
p1 = nTimes(23)
p1.call(3) » 69
p1.call(4) » 92
p2 = nTimes("Hello ")
p2.call(3) » "Hello Hello Hello "

El método nTimes devuelve un objeto Proc que hace referencia al parámetro aThing. Incluso en el caso de que el parámetro se encuentre fuera del ámbito en el momento en que se llama al bloque, permanecerá accesible para el bloque.


Extraído del libro "Programming Ruby - The Pragmatic Programmer's Guide"
Copyright © 2000 Addison Wesley Longman, Inc. liberado bajo los términos de la Open Publication License V1.0.
La referencia está disponible en: download.