Tracks
/
Julia
Julia
/
Temario
/
Composición de funciones
Co

Composición de funciones en Julia

1 ejercicio

Acerca de Composición de funciones

Julia anima a quienes programan a poner la mayor cantidad de código posible dentro de funciones que se puedan compilar con JIT, y crear muchas funciones pequeñas es, por diseño, eficiente.

Eso suele dejar como resultado muchas funciones pequeñas y simples, que hay que combinar para llevar a cabo tareas no triviales.

Una estrategia obvia es anidar llamadas a funciones. El siguiente ejemplo es muy artificial, pero ilustra la idea.

julia> first.(titlecase.(reverse.(["my", "test", "strings"])))
3-element Vector{Char}:
 'Y': ASCII/Unicode U+0059 (category Lu: Letter, uppercase)
 'T': ASCII/Unicode U+0054 (category Lu: Letter, uppercase)
 'S': ASCII/Unicode U+0053 (category Lu: Letter, uppercase)

La desventaja de esta estrategia es que la legibilidad cae en picada a medida que el anidamiento se hace más profundo.

Necesitamos un enfoque más simple y más flexible.

Composición

Esta es la técnica predilecta de los matemáticos, y Julia copia la sintaxis matemática.

Se puede composed un número arbitrario de funciones con operadores ∘ (se escriben con \circ y luego tab). El resultado se puede usar como una sola función.

julia> compfunc = first ∘ titlecase ∘ reverse
first ∘ titlecase ∘ reverse

julia> compfunc.(["my", "test", "strings"])
3-element Vector{Char}:
 'Y': ASCII/Unicode U+0059 (category Lu: Letter, uppercase)
 'T': ASCII/Unicode U+0054 (category Lu: Letter, uppercase)
 'S': ASCII/Unicode U+0053 (category Lu: Letter, uppercase)

# alternative syntax, giving the same result
julia> (first ∘ titlecase ∘ reverse).(["my", "test", "strings"])

Un par de puntos a tener en cuenta:

  • Las funciones de partida aparecen en el mismo orden que al anidar, y se ejecutan de derecha a izquierda.
  • El broadcasting no es simple de usar al componer, pero se puede aplicar al llamar a la función compuesta.

Encadenamiento con pipes

Una alternativa podría considerarse el enfoque de los programadores, más que el de los matemáticos.

Pipelines se han usado durante mucho tiempo en los scripts de shell de Unix, y más recientemente se popularizaron en los lenguajes de programación convencionales (a veces se atribuye a F# haber sido pionero en adoptarlos).

El concepto básico es empezar con algunos datos y luego pasarlos por una secuencia de funciones para obtener el resultado.

El operador de pipe es |> (como en F# y en versiones recientes de R), aunque Julia también tiene una versión de broadcasting: .|>.

julia> ["my", "test", "strings"] .|> reverse .|> titlecase .|> first
3-element Vector{Char}:
 'Y': ASCII/Unicode U+0059 (category Lu: Letter, uppercase)
 'T': ASCII/Unicode U+0054 (category Lu: Letter, uppercase)
 'S': ASCII/Unicode U+0053 (category Lu: Letter, uppercase)

Ahora la ejecución es estrictamente de izquierda a derecha, y la salida de cada función fluye en la dirección de la flecha para convertirse en el argumento de la siguiente función.

Limitaciones, soluciones alternativas y otras opciones

No es casualidad que las funciones usadas para ilustrar la composición y el encadenamiento con pipes tomen todas un único argumento.

Algunos lenguajes puramente funcionales pasan el primer argumento a una función, pero permiten incluir otros.

En cambio, Julia solo espera nombres de funciones (o algo equivalente) en un pipe, sin argumentos adicionales.

Esto tiene razones técnicas importantes (relacionadas con el hecho de que el currying no forma parte estándar del diseño del lenguaje). Las muchas personas que no entienden el currying solo deben aceptar que esta limitación no es un simple descuido y que no es probable que cambie en futuras versiones de Julia.

Soluciones alternativas

Necesitamos funciones de un solo argumento que hagan lo que haga falta. Por suerte, definir funciones nuevas en Julia es fácil.

Lo más simple es usar una anonymous function. Por ejemplo, si tenemos un solo string como argumento y queremos dividirlo por guiones bajos:

julia> "my_test_strings" |> (s -> split(s, '_'))
3-element Vector{SubString{String}}:
 "my"
 "test"
 "strings"

Ese vector se podría pasar luego por un pipe a otras funciones, como antes.

En este caso, encerrar la función anónima entre paréntesis es opcional, pero en general es una forma útil de reducir la ambigüedad.

Del mismo modo, podríamos crear una función con nombre al principio del programa y reutilizarla cuando haga falta.

Closures quedan fuera del alcance de este concepto, pero quien las conozca de otros lenguajes reconocerá que ofrecen una forma más flexible de crear funciones de un solo argumento.

julia> function makesplit(sep)
           fs(str) = split(str, sep)
           fs
       end
makesplit (generic function with 1 method)

julia> f_us = makesplit('_')
(::var"#fs#32"{Char}) (generic function with 1 method)

julia> "my_test_strings" |> f_us
3-element Vector{SubString{String}}:
 "my"
 "test"
 "strings"

# alternatively:
julia> "my_test_strings" |> makesplit('_')
3-element Vector{SubString{String}}:
 "my"
 "test"
 "strings"

Una vez definida makesplit(), se puede usar para trabajar con cualquier separador. Fíjate que makesplit('_') es una llamada a función que se evalúa a otra función, la cual a su vez recibe su argumento del pipe.

Si esto te parece confuso, es normal al principio (pero se aclara con la práctica).

Otras opciones

Ha habido una larga discusión sobre cómo hacer los pipes más versátiles en el Julia base, pero las distintas propuestas son incompatibles entre sí y no se ha llegado a un acuerdo.

Mientras tanto, quienes usan Julia han tomado el camino habitual de crear varios paquetes instalables que cubren necesidades específicas. Ninguno funcionará dentro de Exercism, pero échales un vistazo si te interesan:

Editar en GitHub El enlace se abre en una ventana o pestaña nueva

Aprende Composición de funciones