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:
- Sintaxis similar a la de Ruby
- Tipado estático con inferencia
- Fácil integración con C
- Evaluación y generación de código en tiempo de compilación (vía macros)
- 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