Crystal 语言是否值得看好?

Crystal 很大程度上继承了 Ruby 的语法,但放弃了动态类型而改用了类型推断,并且可以被编译为高效的机器码。 Official site: T…
关注者
278
被浏览
106,384

18 个回答

回答都是好久以前的了,不知道是否还会有新人关注。不过无所谓了,将来肯定会有人看到的把。这个答案在草稿中续更多次,终于写完了。。。


一个绝好的 Ruby 替代品

Crystal 是一群热爱 Ruby 的人创建的,最初的编译器是由 Ruby 实现的(指对 LLVM 绑定的前端)。早期的 Crystal 是高度兼容 Ruby 语法(和语义)的,以至于编译器用 Crystal 重写的时候只需要进行少量修改便能成功移植。不过这也是早在五年前就完成的历史了。

虽然现在的 Crystal 有了些许自己的新语法,不过无论走多远,Ruby 程序员的亲切感都是不会丢失的。毕竟它的宣传语至今还是这么写的:

Fast as C, slick as Ruby

因为 Ruby 真的太慢,从最开始用 Crystal 重写编译器的进程其实就是在同步进行的。如果不这么做,得继续忍受比现在慢十几倍的编译速度上的苦难。当然我接触 Crystal 也几个星期,没有那种经历。不过这也是为什么他们要尽早的放弃 Ruby 的原因。

而在 TechEmpower 的测试中上 Crystal 上榜了两个,让我比较吃惊的是甚至高过了 Elixir 的 Phoenix(如果有时间我会更新有关 Elixir 的内容)。

Ruby 的王牌强项是做 Web 应用,专写增删改查业务的那种。受 Ruby 和 Ruby 生态影响的衍生语言,其 Web 开发能力都很强,自然也包括 Crystal。这货目前有两个相当完善的框架:AmberLucky,二者各有着重点,其中 Amber 就是典型的类似于 ROR/Django 的那种大而全的一站式开发框架。官方团队维护的 Kermal 关注量是最高的,但走的是 Sinatra 的路线。

所以我说了,Crystal 可以作为 Ruby 的绝好替代。至少在当前生态还算不上多全面的情况下把拿手的 Web 应用开发领域还算是继承下来了。

应用场景受限

Crystal 在多门语言上取过经,包括 Ruby/Rust 和 Go。注意 Rust 取经主要在编译器后端的方面,跟语言设计无关,跟 GC 更加无关。

在理论上如果它真的能做到结合上述语言的优点,即便是有 GC,应用场景也是非常丰富的。能在替代 Ruby 的同时保持超高的执行效率,替代 Go 的同时具有相当灵活、优雅和高效的开发体验。能像 Rust 那样编译到各种平台上(甚至是纯静态)。

不过很遗憾的是,当前的 Crystal 只做到了第一点。它无法替代 Go,因为它目前还未实现真正的并行。同样屏蔽了线程细节,但无法像 Go 那样在底层调度上将 Goroutine 分配到多线程上执行。是的,Crystal 目前的 Fiber 是单线程的,即使它提供跟 Go 几乎一样的并发编程模型。

它也无法做到 Rust 的兼容性。因为 Crystal 目前依赖了例如 libssl 和 libevent 这样的 C 库来作为其基础。并且它当前还不能支持 Windows,仅能在 Alpine 平台上静态编译。虽然理论上它能和 Rust 产出同样平台兼容性的二进制,但实际上还远远达不到。

这些限制导致了例如:

  1. 不适合开发客户端,仅靠单线程就能满足的客户端类型是少见的,CPU 密集计算功能太常见
  2. 无法进入嵌入式领域,因为平台和交叉编译的支持都不健全
  3. 即便是服务端应用也无法充分利用 CPU,需要单机多进程部署

所以,它的优点目前还真的是仅仅做到了替代 Ruby/Python 之类的脚本语言。当然我认为它同 Go 竞争也就一步之遥,但这一步却是一个大而艰难的计划。

我说的竞争当然指语言方面的,跟生态无关。Go 几乎要成为的大众语言了,再厉害的小众语言都无法与之相比。

而并行对于 Crystal 属于必须跨过的一个坎(实际上在 Web 领域无法并行的语言很多),编译方面属于基础建设。这两点完善后应用场景将瞬间扩大,足以和 Go 同台较量。

语言亮点

具备 Ruby 的语法和灵活性

为什么要学 Ruby 的语法?因为 Ruby 的语法以灵活、优雅著称。不会产生多少脏活累活,样板代码。当然,一门静态语言如何跟动态语言一样灵活?

Crystal 使用了一种特殊的方式,创造出了一种联合类型,配合相对其它语言更加高级的全局的类型推断,相当程度的提高了静态语言的灵活性。

def foo(bar)
  bar
end

foo "123"
foo 123

上面的 foo 函数没有指定返回类型,也没有指定参数的类型。在两次调用上,分别传递了不同的类型,看上去简直跟动态语言没区别。

当然,它是无法“动态”的。毕竟大多静态语言不允许发起纯的动态调用,那么编译器便可以将对方法的所有调用进行分析,并得到类型。所以类型的推断,不仅是定义方决定的,还有使用方。

为何联合类型?在 Crystal 中联合类型其实就是薛定谔的猫。在你不去试探类型的情况下,你永远不知道联合类型( A | B ) 持有者的类型究竟是 A 还是 B。

def foo(i)
  if i > 10
    "Too big"
  else
    i
  end
end

f1 = foo 99 # (Int32 | String)

上面的 foo 方法返回值便是联合类型 (Int32 | String)。此时的返回值是由方法内部的 if 表达式决定的。因为 i 与 Int32 类型的 10 进行了比较,所以 i 可能是 Int32,又由于另一个分支中返回了 String 类型,所以便产生了 Int32 和 String 的联合类型。

也就是说,Crystal 其灵活体现在类型不受(单个)限制。我一个方法无数种分支,返回各种乱七八糟的类型都可以,因为最终会被联合起来。而类型推断此时只是一个必要的辅助。联合类型在我看来是一个相当重要的创新,这种类型系统机制是 Crystal 灵活性体现的基石。

当然,仍需强调的是 Crystal 的类型肯定是静态且安全的,包括局部变量。写一个极具误导性的代码:

a = 1
echo_a = ->{ a }

a = "hello"
echo_a.call

# hello

上面是一个闭包引用了前一个 a,之后 a 被赋值为了 String 类型。而且结果还真是输出了 hello,所以并不是变量的重新分配,那么局部变量类型是可变的?

当然不是。因为经过类型推断后的 a 已经是联合类型 (Int32 | String) 了,而不是引用时的 Int32 或改变后的 String。和实例变量(即 Class 的属性成员)的差异是最好理解这一点的方式:

class Person
  property age
  @age = 18

  def initialize
  end
end

p1 = Person.new

p1.age = "18" # 编译错误

可以看到 age 属性无法赋值一个 String。因为在定义 Class 的时候推断出来的是 Int32 类型。Class 的属性的类型无法通过实例的使用方来决定,而局部变量的赋值却是可以的。要想让 age 属性可以赋值为 String,需要在 Class 内部推断或直接声明为联合类型:

class Person
  property age : (Int32 | String)
  @age = 18

  def initialize
  end
end

p1 = Person.new

p1.age = "18"
p1.age = 18

使用联合类型也有一堆的方法,但大致上原理是相同的。在判断成功的代码块中将类型隐式转换:

def foo(i)
  if i > 10
    "Too big"
  else
    i
  end
end

result = foo 99

if result.is_a?(Int32)
  result * 10
end

if result.is_a?(String)
  msg = "Invalid argument, reason: {result}"
  raise Exception.new(msg)
end

可以看到在两个 if 中,都被直接当作 Int32 或 String 使用。毕竟在那种作用域中再转换类型就是多余的。这跟 Kotlin 处理 null 是一个模式。

当然还有更多的例如通过分支/返回值等形式的方法或语法,原理是一样的就不介绍了。简而言之,Crystal 的方法类型随意返回,参数类型不需要定义,局部变量类型随意变化,足够“动态”。虽然在使用的时候一定要确定类型,但提供了相对应的辅助方法和语法以及转换机制,在保证类型安全的基础上提高了便利性。(不过在少数情况下是需要指定类型的)

便捷的 C 绑定

在 Crystal 中也提供了指针、结构体和函数,枚举也和 C 相同。能够在不写一行 C 代码的情况下无缝使用 C 的库。

一般来讲,你需要做的就是将 C 库中所需的函数的签名在 Crystal 中定义一次,类型上有 Crystal 提供的对应版本,或者通过重写方法让自己的类型对应 C 库中的类型。又或者直接使用 libc 中的类型(Crystal 中可以直接使用 libc,不需要任何声明)。

在这之前你需要通过类似注解(@[Link])的东西进行链接。完整的例子:

@[Link("MagickWand")]
lib LibMagickWand
  struct MagickWand
    # ...
  end

  enum MagickBooleanType
    # ...
  end

  enum FilterTypes
    # ...
  end

  fun MagickResizeImage(
    MagickWand*, columns : LibC::SizeT, rows : LibC::SizeT, filter : FilterTypes
  ) : MagickBooleanType
end

上面是使用著名的 C 库 MagickWand 的一个例子。主要以 Crystal 的方式定义了 MagickResizeImage 函数的签名。之后就能直接使用这个函数了,例如 FilterTypes/MagickBooleanType 这类枚举以及 MagickWand 结构都是自行重新定义的,包括 LibC::SizeT 类型也能和 Crystal 自己的类型无缝转换。

调用这个函数就是如此简单:

wand = pointerof(MagickWand.new)
LibMagickWand.MagickResizeImage(wand, 100, 100, LibMagickWand::FilterTypes::Lanczos2Filter)

包括回调等各种情况,都不需要一行 C 代码。可能是 Crystal 的基础库也离不开 C 的关系,所以他们将 C 绑定做得非常完善。我个人非常喜欢这一点,因为 C 的库成熟性能又高。大大的弥补了 Crystal 自身语言的生态不足的情况。

简单易用的并发模型

这个部分我就懒得上代码了。Crystal 的并发模型和 Go 的几乎一模一样,通道也分为有缓冲通道和无缓冲通道。因为兼容 Ruby 语法的关系,仍然使用 spawn 关键字,当然并不是 spawn 一个线程或进程,而是一个 Fiber。

只要稍微空闲一会儿(例如显式的 sleep)或者手动 Fiber.yield 就会让调度器切换其它 Fiber 运行。不需要 async/await 或者 Future 之类的东西,毕竟协程可以在方法内部任意地方挂起和恢复。

我个人测试 Kemal 框架有接近 2w 的 rps,如果多进程可以将吞吐数倍的提高。

和 Go 的对比:

在单线程(将 Go 的线程手动限制为 1)的情况下,即 fiber/goroutine 和线程都是 N:1 的情况下,Crystal 有略高于 Go 的性能。多进程的 Crystal 能一定程度上接近不锁线程的 Go,但仍然比不上。不过更重要的是多进程的模式开发复杂度会提高,也会增加诸多限制。但这目前仍然是值得考虑的一种方式。

结论

即使我写了很大篇幅的 Crystal 的不足,但是我的观点仍然很明确,当然是看好的。它的不足的成因主要还是发展不够快,虽然在小众语言中,算不错的了。但是作为新语言跟 Go 和 Rust 相比,社区的力量实在太小了,所以它在一些核心领域的建设滞后很久,也没有具体的计划和时间,导致应用场景受限。

要记住,Crystal 还是一门发展中语言就是了。

动态

好消息是在最近就有关于多线程调度的进展:github.com/crystal-lang

当然不用着急,因为即使实现,到实用仍然还需要一定的时间。

不过它在 Bountysource 上仍然是每个月收到捐款排行第一的存在(Nim 语言只有它的 1/4)。并且有固定的赞助商,虽然据我的调查,赞助数额并不算多大,但足以稳健发展。

像 Rust 那样激进是不可能的,自行看一下 Rust 的提交数量就知道了,超越了相当多的语言,简直是打了鸡血。

但是,看看 Ruby 就知道什么就发展不动了。既然它现阶段足以替代 Ruby,而 Ruby 的进展又越来越慢了。所以,我认为,Ruby 程序员非常有必要认识一下 Crystal。

另外我建议 Ruby 程序员不要接触 Elixir,因为这两门语言有非常巨大的根本上的差异。如果可能的话,我还会写上有关 Elixir 的相关对比。

待更……


附上我的一些 Crystal 项目:

因为事有点多,忙不过来。有时间肯定会好好搞搞这些项目。但是 Crystal 的使用经历还是让我非常满意的。开发体验一级棒 (*‘ v`*)

Beautiful Syntax, Faster Than Go

它和 Mirah, JRuby, Groovy 等等的区别是: 它不需要 JVM 这个平台, 没有字节码和解释器. 很容易在速度上超过这些 JVM 语言.

同样是编译成 LLVM IR 再到机器码, 和 Ruby Motion 的区别是: Ruby Motion 依赖 ObjC 运行时, 而 Crystal 不依赖

除了编译外, 和 Elixir 的区别还有: 它的语法和 Ruby 很相似, 所以 Crystal 编译器可以直接用 Crystal 和 Ruby 的交集写, 然后用 Ruby 执行编译自己, 就轻松完成了 bootstrap. 它的语法对熟悉 Ruby 的人更有亲和力, 所以 Matz 率先捐了 $500.

所以你看, 虽然现在还在 Alpha 阶段, 还是很有存在意义的.

---------

Crystal 还有一些与众不同的特性:

面向对象类型推导

在一些静态类型系统上结合 FP 和面向对象的尝试中, 例如 Scala 中, 推导仅局限于方法内部. 而 Crystal 没有这个限制, 它会尽量修改类型定义以满足使用需要

举个栗子:

class Person
  getter name

  def initialize @name
  end
end

daisy = Person.new "Daisy Johnson"
chappie = Person.new 22

puts daisy.inspect
puts chappie.inspect

使用 hierarchy 命令查看类图

$ crystal hierarchy person.cr
...
+- class Person (24 bytes)
     |      @name : (String | Int32) (16 bytes)
...

可以看到 Person 的 name 属性的类型自动变成了 String | Int32

if 表达式类型推导

和 Ruby 一样, Crystal 的 if 语句有返回值, 是表达式. 但 if 表达式在很多静态语言中的使用却不那么便利, 一个巨大问题就是编译器不能根据条件去做推断, 例如:

val a = if (args.length > 0) { "string" } else { 42 } // a 的类型是 Any
if (a.isInstanceOf[String]) {
    println(a.length) // 编译错误, Any.length 没定义, 得手动强转
}

而 Crystal 的类型推断会考虑条件中的 is_a? 和 responds_to? 信息, 减少了繁琐的强制转换:

a = ARGV.size > 0 ? "string" : 42
if a.is_a? String
  # 这里 a 的类型是 String
  puts a.size
else
  # 这里 a 的类型是 Int32
  puts a / 2
end

Nil 的处理

在静态类型语言中, 有的编译器默认允许 nil/null, 那么就会抛出 NPE (NullPointerException), 有的编译器会在类型上禁止一些 nil/null 的存在, 并鼓励用 Maybe/Option 去装箱/拆箱. 前一种方法很容易出错, 后一种方法使用繁琐 (尤其在不支持 do notation 的语言中). 而 Crystal 结合数据流分析, 对 nil 值的处理更加简单优雅. 文档里的例子如下:

a = some_condition ? nil : 3
# a is Int32 or Nil

if a
  # Since the only way to get here is if a is truthy,
  # a can't be nil. So here a is Int32.
  a.abs
end

不过为了线程安全, 非局部变量不推荐这么做

if @a
  @a.abs # 如果编译器分析出 @a 可以赋值 nil, 就会出编译错误
end

上面的代码可以加锁, 或者改写成局部变量判断的方式

if a = @a
  a.abs # 别的线程就改不了局部变量啦
end

和 Ruby 一样, 由于 Nil 上可以定义方法, 所有值都自然变成 Maybe Monad 了, 上面的代码也可以改成用 try 去处理

@a.try do |a|
  a.abs
end

在 responds_to 的情形, 会出现这类代码 pattern

if (a = @a).responds_to? :size
  a.size
end

不那么静态

从上面可以看到, 和一般的静态语言相比, Crystal 的一个局部变量的类型并不是固定的

a = 32 # 现在 a 的类型是 Int32
a.abs
a = "foo" # 现在 a 的类型是 String
a.length

在 while 循环中, 编译器会做更复杂的事情, 尽量不让类型成为阻碍编程的限制, 以下例子出自 Crystal 博客:

a = 1
while some_condition
  a             # here a is Int32 or String or Bool
  if some_other_condition
    a = "hello" # we next, so in the next iteration a can be String
    next
  end
  a = false     # here a is Bool
end
a               # here a is Int32 or String or Bool


--- (未完待续)