Crystal, el sucesor de Ruby

Ruby es un lenguaje de programación muy popular creado por Yukihiro Matsumoto en el año 1993 porque no le terminaban de convencer lenguajes como Perl o Python y deseaba un scripting language con un genuino soporte a la POO. Sin duda creó un lenguaje que satisface a muchos programadores sin embargo, una de las recurrentes fricciones que se producen en el mundillo es la de cual es el sistema de tipos más adecuado.

Al usar Ruby duck typing, se complica o hace imposible realizar algunos análisis deseables en los programas escritos. Sin duda ésta es la razón de que los creadores de Crystal hayan optado por emular Ruby usando un sistema de tipos estático.

Objetivos de Crystal

Los objetivos marcados por los desarrolladores de Crystal son:

  1. Sintaxis similar a la de Ruby
  2. Tipado estático con inferencia
  3. Fácil integración con C
  4. Evaluación y generación de código en tiempo de compilación (vía macros)
  5. Generación de código nativo eficiente

En realidad y siempre en mi opinión, el tipado estático no es un requisito en si mismo, sino la forma de conseguir análisis del código y generación de código eficiente. Es el cambio de tipado dinámico (y duck) a estático donde está el reto, la dificultad y el éxito si logran hacerlo de forma práctica y eficiente.

Tipado estático e inferencia

Resulta un tanto curiosa la forma en que Crystal aplica las reglas de inferencia (parte 1 y parte 2) y las estructuras admitidas, seguramente debido al requisito de ajustarse en lo posible a la sintaxis de Ruby. Por ejemplo el siguiente código es válido:


a = 1       # a is Int32
a.abs       # ok, Int32 has a method 'abs'
a = "hello" # a is now String
a.size    # ok, String has a method 'size'

Esto obliga en general a que Crystal tenga que lidiar con uniones de tipos en tiempo de ejecución, generando estructuras como:


struct Int32OrString {
  int type_id;
  union {
    int int_value;
    string string_value;
  } data;
}

Aunque realiza cierta optimizaciones cuando reconoce que en ciertas ramas del código el tipo actual de cierto identificador es uno dentro de la unión, es decir, no siempre debe evaluar type_id en tiempo de ejecución.

Con esta capacidad, ahora Crystal puede realizar un análisis que Ruby no podía como por ejemplo en:


if n > 3
  a = 1
  a.abs
else
  a = "hello"
end
a.size

Donde puede intuirse que la intención del programador era devolver bien el valor absoluto de a bien la longitud de la cadena "hello" y a.size debe estar al final y dentro del bloque else (no fuera). Crystal advierte con un error que el método .size no existe para todos los tipos posibles en la unión actual (Int32 en este caso).

Por lo demás, parece que la inferencia únicamente analiza el AST de abajo a arriba, requiriendo en todo momento que los tipos estén definidos. Para el caso de genéricos en Crystal, deja libre el tipo hasta que quede definido en el uso y generando uniones de tipos en caso de ser preciso. Esto hace que, en principio, las capacidades del sistema de tipos esté al mismo nivel que otros como en Java o C#.

El tipo NoReturn

Como Crystal acarrea el tipo de cada expresión, usa el tipo NoReturn para indicar que nada es devuelto como en:


a = ...
if a == 1
  a = "hello"
  puts a.size
  raise "Ouch!"
else
  a = 2
end
a # here `a` can only ne `Int32`

Deduciendo que, de continuar con else, el tipo de a será Int32. Aunque de apariencia puntual, un buen uso de la inferencia de este tipo puede ser útil para la detección de edge cases que puede ser aprovechado por herramientas de análisis de cobertura y testing.

Filtrado de tipos

De forma reflexiva y dada la unión de tipos anterior, en determinados casos puede chequearse si el valor de la unión corresponde a un tipo o a otro como en los dos casos siguientes:


if a.is_a?(Int32)
  a.abs
end

if a.responds_to?(:abs)
  a.abs
end

En el primero se reflexiona sobre si a es un Int32 y en el segundo si el tipo que contiene a en ese momento admite el método abs.

Desafortunadamente, existen relaciones no resueltas en esta estrategia que hace que no sea consistente y deba parchearse:


a = some_condition ? 1 : nil
if !a
else
  a.abs # should be ok, but now gives error
end

Adicionalmente, dicha reflexión parece funcionar únicamente en variables locales y no en miembros de instancia y clausuras. Parece por tanto un uso muy limitado y obvio de reflexión.

Macros

Crystal posee un lenguaje propio de macros similar a otros lenguajes en que podríamos destacar la posibilidad de definir hooks cuando se producen ciertas situaciones al compilar, por ejemplo cuando se intenta invocar un método desconocido method_missing.


# macro `inherited`
class Parent
  macro inherited
    def {{@type.name.downcase.id}}
      1
    end
  end
end

class Child < Parent
end

Child.new.child #=> 1

# macro `method_missing`
macro method_missing(call)
  print "Got ", {{call.name.id.stringify}}, " with ", {{call.args.size}}, " arguments", '\n'
end

foo          # Prints: Got foo with 0 arguments
bar 'a', 'b' # Prints: Got bar with 2 arguments

En mi opinión, extender el lenguaje mediante macros o usarlas para generar estrategias "por encima" del lenguaje me parece muy arriesgado, si bien permite realizar análisis interesantes como quien, cuando y cómo hereda cierta clase.

Rendimiento

Uno de los aspectos importantes que parece desea resolver Crystal es el rendimiento, indicando que está cercano a C. No obstante, usando el propio ejemplo que ellos usan se aprecia un degradamiento importante. Comparando:


require "big"
require "option_parser"

def fib(n)
  a = BigInt.new(0)
  b = BigInt.new(1)
  n.times do
    a += b
    a, b = b, a
  end
  a
end

n = 10

OptionParser.parse! do |parser|
  parser.banner = "Usage: fib "
  parser.on("-n NUMBER", "Fib ordinal to print") { |o| n = Int64.new(o) }
end

puts (fib(n) % 1000000)

# [josejuan@centella crystal]$ crystal build test.cr && time -f "%E, %M" ./test -n 432101
# 396101
# 0:11.67, 4252

Con una sencilla implementación en Haskell


{-# LANGUAGE BangPatterns #-}
import System.Environment

fib n = f 0 0 1
  where f !k !a !b = if n ≡ k then a else f (k + 1) b (a + b)

main = getArgs ↪ print ∘ (`mod` 10⁶) ∘ fib ∘ read ∘ head

{-
[josejuan@centella centella]$ stack exec -- ghc -O3 ../crystal/fib.hs && time -f "%E, %M" ../crystal/fib 432101
396101
0:01.49, 5476
-}

Se obtiene mucha diferencia para un ejemplo tan sencillo, pues ambos lenguajes usan la misma librería para manipular números enteros grandes (GMP), aunque es posible que haya algún tipo de tunning que pueda hacerse en la versión Crystal o que aún tengan que mejorar.

Ecosistema y conclusiones

Aunque incipiente, Crystal cuenta con un repertorio de API suficiente para empezar a hacer algunos proyectos, cuenta con cierto soporte a concurrencia mediante channels y la documentación parece razonablemente cuidada. Los amantes de Ruby o phytonistas son firmes candidatos a darle una oportunidad a este lenguaje que promete mejorar un lenguaje como Ruby que, tras más de 20 años, sigue estando entre los lenguajes más populares.

Como lenguaje veo un popurrí de ideas y estrategias que quizás funcionen bien en la práctica y hagan que sea un lenguaje práctico y útil durante las próximas décadas, pero ninguna de ellas con una proyección global y que puedan crecer a largo plazo (obviamente excluyendo el hecho de usar un sistema de tipos estático).

En todo caso y como mínimo, me parece un valiente intento por dotar a un lenguaje dinámico (y duck para mas señas) de una estructura estática que permita realizar análisis del código en tiempo de compilación. De conseguirlo, se abre sin duda una gran cantidad de posibilidades para ampliar el tooling de sus programadores.

En Genbeta Dev | Aterrizando en la programación funcional

Ver todos los comentarios en https://www.genbeta.com

VER 0 Comentario

Portada de Genbeta