Julia ---- 为Julia做一下辩解

我写这篇文章的主要目的就是为了给我喜欢的Julia语言一辩,并且指出人们对Julia语言的几个常见的误区。

预警:文章非常长,所以需要希望入坑的人有耐心阅读

文章内容

1.常见误区

2.重新认识Julia

3.结尾

正文

即使我不说大家也知道,在知乎的Julia的这个话题下的相关话题和讨论寥寥无几,不仅如此,还充斥着很多大量的负面评论,除了少量非常客观的分析是在谈论语言本身以外(这些人在英文的julialang论坛上也非常活跃,所以我还是知道这几个人的),其他的很多都是一些感叹式的评论(有说好的也有说不好的)。更加糟糕的是,因为本来了解的人就很少,所以一个回答下的答案往往跨越了数年之久,很多答案早就过时至极(譬如那些来自2012-2014年回答,那个时候Julia才刚刚发行了2年),很多问题早就解决了(当然还有一些问题还没有解决,但哪个编程语言是完全没有问题呢?)。

每个人畅所欲言发表自己的感想,这本来也就没有什么问题,因为编程语言就是一种工具,要是这个工具对自己当前的事情帮助不大,自然也就相性不好,而且Julia本来目标就很明确,主要用于做数值计算,一开始也就没打算取悦所有的程序员。因此有的人说好有的人说不好也就很正常,毕竟这个世界很大,每个人领域不同,需求也就不同,也就喜好不同。但是没有根据地diss一个语言,以偏概全,却是不可以的(如果一个人不了解一个东西的话,就应该使用商讨的语气,而不是毫无根据地作出一个错误的断言)。我收集了对Julia的几种常见的观点(有对的有不对的),然后一一进行分析。


一.Julia的名字

这个名字本来就毫无意义,但是Julia下的回答出现了大量毫无意义的这种灌水的回答,这种没营养的东西我就不多说了。而且用人名命名的语言也不是没有先例(例如Haskell,纪念数学家Curry Haskell)。

二.Julia的语法

我发现,当我们谈论起编程语言的时候,编程语言的语法(譬如关于Python缩进,Go语言花括号这种争论)。而且都是在这种细枝末节上,就和大头端和小头端的争论一样无止无休(还有类似的争论是用花括号还是用缩进还是用end语句块)。我不是说编程语言的语法不重要,我想说的是,编程语言的语法是要和它的语义以及编程范式相契合的(一定程度上是为了方便),比如说Haskell高阶函数用的多,所以大量使用currying与偏应用,可以节约很多括号使用。这个方法虽好,但是它在别的地方就会不方便了。同样的是Lisp中的一大堆括号,写起来很繁琐,可是这也成就了lisp强大的宏的特性。

本质上,Julia的语法是和Ruby一致的,而不是Matlab(当然它们也很像)。Julia的语法实际上完全就是Ruby的框架,除了个别关键字不一样(class->struct,define->function,还有匿名函数的lambda)。do-end,begin-end,一切表达式都是值...都是和Ruby一致的(知道Exlir语言的人也比较一下,其实Julia和Exlir有很多相似处)。所以Julia的语法的内核是Ruby,只不过表面上披着Matlab的外衣。

具体说来,很多人都说Julia的语法非常花里胡哨。虽然Julia确实借鉴了很多编程语言,但是我个人认为Julia的语法是很一致的,看起来花,其实是因为用了很多符号的缘故,所以没学过的人看不懂(或者说其实大家有符号恐惧症),例如我选了一段Julia中Base库中的代码:

function eltype(::Type{<:AbstractDict{K,V}}) where {K,V}
    if @isdefined(K)
        if @isdefined(V)
            return Pair{K,V}
        else
            return Pair{K}
        end
    elseif @isdefined(V)
        return Pair{k,V} where k
    else
        return Pair
    end
end

有一大堆奇怪的东西比如说@,{},<:,where(其实还有更多类似的符号),看起来好似天书,但其实我发现别的编程语言貌似也不会更加简单,例如Java

class Unsound9 {
static class Type<A> {
class Constraint<B extends A> extends Type<B> {}
<B> Constraint<? super B> bad() { return null; }
<B> A coerce(B b) {
return pair(this.<B>bad(), b).value;
}
}
static class Sum<T> {
Type<T> type;
T value;
Sum(Type<T> t, T v) { type = t; value = v; }
}
static <T> Sum<T> pair(Type<T> type, T value) {
return new Sum<T>(type, value);
}
static <T,U> U coerce(T t) {
Type<U> type = new Type<U>();
return type.<T>coerce(t);
}
public static void main(String[] args) {
String zero = Unsound9.<Integer,String>coerce(0);
}
}

我觉得问题的根源在于Julia的编程范式很丰富,而且Julia的类型系统(往往一般写程序的人不太注意到)和其他的主流编程语言有一些不一样,在很多地方都用了非常学术的符号来表示其他语言用文字表达的东西(比如<:和上面的extend差不多一样,{}就是Java中的<>表示泛型),但是一般人看起来就是一大堆符号堆起来的程序,我总结了一些“花里胡哨”的语法放在这里:

1.{}用来表示类型的参数化(泛型),比如说Vector{Int}表示一个整数的向量,在上面的Java(以及C++)中用<>表示,例如List<Int>

2.[]表示数组

3.()可以表示元组(和Haskell,Python一致),如(1,2,3),也可以表示函数调用,如sum([1,2,3])

3.A<:B表示类型子类化(A是B的子类,a::B表示a的类型是B)

4.where引导类型变量,例如Array{T,3} where T表示一个3维数组,数组元素的类型为某个待定的T

5.一个点 . ,如A.a,表示A有个属性为a;. 还可以用来表示broadcast,可以理解为就是map,例如说,你有一个矩阵A,sin.(A)表示将A中元素分别求正弦后得到的新矩阵,等价于map(sin,A)

6.@表示宏调用(Julia本来也可以不用@标记宏,但是为了大家方便阅读,最后还是强制要求用@标记)

对比一下上表,大多数人(我指的是那些做数据科学的,不是程序员)很多应该是完全没有概念。大家也注意到有很多符号是和类型系统相关的,Julia作为一个动态语言,其类型系统非常丰富(而偏偏很多人又不关心,或者没有太在意这件事情),而且Julia的类型系统又有些独特(虽然看起来和OOP一样,但是和面向对象的系统还是有不一样的)。其实类型系统的相关概念上不难理解,很快也能上手,问题是很多人一开始没有接触到,所以一下子一大堆新概念上来就有些头晕了。所以其实Java的那段代码一样很复杂,一样用了泛型,继承,wildtype,coerce,翻译成上面的数学符号几乎就是一样的了。

只不过很多类型系统的这些特性,在传统编程语言中,包括Python,是非常排斥这些东西的,所以这些东西一向就没有深入人心,也有一些人尽量避免使用。但是这些在Julia中熟练运用类型系统完全是常态。我在后面也会提到为什么Julia和别的语言很不一样,其中一个就是Julia的类型系统是很独特的。

三.Julia的性能

这个时候官网上的Benchmark的图片总是会被拿出来批斗一番。其实人家都说的很清楚了,这个测试测的就是各个编译器的优化能力,不是最优化的算法。在这个意义上这个Benchmark还是比较公正的。

我发现很多人一谈论其性能来,就好像性能是完全不重要的东西。另外一方面,即使人们很关心性能,但是大家往往都觉得这件事情实在是一件非常简单的事情我们只要能够XX(举例而言XX=线程,好的库)就可以变快。

为什么会存在这些看法?很大一部分原因是因为大家普遍使用的动态编程语言(Ruby,Python)好像都还很快,所以为什么要去追求性能?但真实情况是性能始终很重要,只不过那个来优化的人不是库的使用者。所有这些动态编程语言,都在背地中做了大量的优化(往往是某个大公司在背后撑腰),例如Ruby换上了虚拟机,Javascript的V8引擎,都是很多编译器设计者投入大量时间进行的优化,更加不要说为各个库写C/C++扩展的开发者了。所以性能从来就不是不重要,只不过大部分人已经享受了这种好处罢了而已。

即使认识到了编程语言很重要,但是很多人没有意识到编程语言优化提升性能的复杂性所在。程序优化是极其复杂的问题,不是简单的多线程或者多进程就能解决的问题(而且很多语言都还没有正确支持这些机制,为什么还有人觉得Python的多线程只不过是拿掉一个GIL这么简单,而且拿掉GIL也还没有解决所有问题)。而且不同的领域有着不同优化需求,有的问题是IO繁重的任务,有的是计算密集的任务。像Julia是面向科学计算的,主要问题是计算密集,必须用真的多核多线程加速,而不是一个单核多线程(单核多线程可以加速IO繁重的程序,因为在等待IO的时候可以切换线程做一些计算,但是对计算密集型帮助不大,甚至有反作用,在这里我们先不考虑超线程)。编译器优化程序要求编程语言的各个组成部分互相配合,一个看似不起眼的特性可能就会对优化造成阻碍。优化这件事情要编程语言和程序员共同参与,不是说一个人费心费力的到处标注了类型,程序自然就会变快了,要是编译器不能自己推导出类型,加上的这些类型标注也毫无意义。

另外也很多人不相信Julia的性能有官网上说的这么好,一个动态语言怎么会这么快(其实这毫无道理,C++还有一个基于LLVM的REPL,这还不一样是动态了)。这都什么年代了,怎么还有人觉得C(或者C++)语言性能就一定天下第一(写了Blas等线性代数库的Fortan都没有出来争论呢)。OK,即使C确实是天下第一,Julia只要能赶上95%的速度就已经足够了(其实性能这种东西非常取决于各个编译器的优化,对于一个简单的斐波那契数列,不同编译器产生的优化也水平不同,所以说Julia Benchmark才要这么设计)。

性能也不是要无节制的追求,例如错误处理,像C语言里面一般通过返回-1或者空指针等值表示出错了,但Julia里面有的地方抛出一个错误,要用try来catch,这种方法当然就不如C快(Julia有很多IO函数都是这么做的),

最后,不要因为自己的情况就说这个东西没有”实际用途“,或者科学生态圈早就已经饱和这些话。现在还有researcher在想办法写各种高性能的数值计算的代码,要是早就饱和了,那这些搞研究的人岂不是可以全部下岗了?

四.Julia生态圈

确实没有Python和C++大,必须承认。但是就像是我之前已经说过的一样,对某些人的领域生态圈匮乏,但是对别的人就不一定是这样。例如JuMP.jl,DifferentialEquations.jl,Flux.jl等等。生态圈没有Python成熟也没有Python多(或者说Matlab),但是有这么几个库苟活整个语言,起码也就还不至于死掉。

而且说实在的,其实Julia整个生态圈也不算太小,很多功能已经有了,只不过都很难发现而已,有的时候要实现的功能要通过调用几个很小的库串联在一起实现(Julia的库很多都很小,所以star分散了以后也很少),很多时候库的作者把库写的很通用,所以对于一个具体问题,很多时候很难想到要这么写罢了。。。

由于精力限制,现在社区的主要方向是探索有自己特色的库,而不是原样复制别的地方库,相似的功能,如果没有性能上的要求,完全就可以Pycall调用Python实现.(或者其它编程语言),例如说画图库,我看有的人也还在用PyPlot画图。而很多人到Julia里做的第一件事就是找原来自己用过的库的代替品。。。当然就不怎么找的到了。这其实对于目前的使用者而言也是一个门槛。但其实发展编程语言独特生态圈这件事情是很重要的,Julia目前要做的事是设计更多崭新的有特色的库来证明自己,而非成为Python或者Matlab的复制品。

五.Julia,R,Matlab,Python的关系

很多人都以为Julia发明来的目的是为了取代另外三个语言,当然其实也确实有这么一点意味在其中,但是事情的真相是,Julia主要是为了用来减轻Fortan/C++开发的负担。

原因很简单,Julia从设计之初就是为了减轻那些做科学计算的人码代码的负担(写Fortan/C++说的轻松,实际上是头大至极),所以主要目标就是面向那些做数值计算的开发者,直到现在也还是在大力吸引开发者(你完全可以理解为简化的C++,方便开发)。而大多数Python,Matlab,R语言用户都不是开发者,吸引到Julia社区根本没有任何实质的好处,又不能提供库而且开源社区又不能收费,所以一开始假定Julia是挖别人的墙角是不对的,要挖墙角也是挖开发者的墙角,和一般人关系不大。Julia刚出吸引了很多数值计算者的注意,所以2012-2014短短两年,即使整个语言都还要自己build的时候,就有开发者积极开发库了(2015年就有了JuMP,这才3年),足以说明了Julia着实解决了当时某些人(起码是某些researcher)的痛点。而且后来即使一些开发者离开了Julia,但是Julia的一些设计他们借鉴到了在别的数值计算开源项目(我记不清楚了是Pytorch还是Tensorflow了)。

所以Julia(目前为止)都不曾想取代别的语言,相反人家还积极给R,Python做接口,这样别的使用者也就能调用Julia的库。当然如果在做相关research的时候,做了一个很好的库(例如Flux.jl),有着不少神奇的功能,然后还吸引了不少用户,那就不能怪别人碰瓷了。

六:文档问题

Julia有很多库是researcher做研究的代码,研究者的代码,自然就难用于生产环境,这些研究者自己用用,不写文档也就可以谅解。而且已经说了目前社区是以开发者为主的,文档写的点到为止,可以继续开发就好了。要是期望像Python一样手把手来教小白用户,除非是一些常用的库(例如微分方程库,画图库),目前是不可能的。就我现在看到的各种情况来说,很多库的文档有了很大的进步,很多包随着不断完善也都有越来越多的教程(也有的配有notebook)

七.启动太慢

这个没办法,编译器用的LLVM,所以每次要JIT都要花费时间(由于Julia采用方法特化机制,julia jit的量非常大),现在我们也只能忍耐,官方好歹出了precompile可以节约大家的一些时间。

总之我就写了这么几点,主要是写了一下我在知乎上看到了一些东西,然后我逐条反驳一下。

接下来我想要简要的介绍一下Julia(重新认识Julia),我想说明的是为什么Julia非常独特。为什么在二十一世纪的时候我们仍旧有必要发明一门新的编程语言。为什么在数值计算这一个古老的领域(要知道当时计算机发明出来很大一部分原因是用来做计算的)Julia仍然能够占有一席之地。


对于Julia最大的误解在于认为Julia只不过是简单的剽窃了别的语言的创意,所以只不过是众多语言的大杂烩罢了,这个认识是不准确的(诚然,官方自己也要背一些锅,因为那篇why we create Julia里面的内容)。

任何初学者在用Julia写了几个简单的程序以后,都很容易得出结论,这不过又是一个XX(XX=Matlab,Python,C++)等。例如:

if x < y
    println("x is less than y")
elseif x > y
    println("x is greater than y")
else
    println("x is equal to y")
end

看起来和Matlab完全就没有区别(除了没有分号以外)。

就这些常见的程序构造而言(控制流,数值计算),其实大部分语言除了表面的语法区别,没有实质上的不同,而Julia真正有趣的地方,在于它有趣的地方是一般人无法注意到的地方,观察这个式子:

1+1.5 #Int+Float
1+1 #Int+Int
1.5+1.5 #Float+Float

看起来是一个很普通的式子,其实不是,因为实际上涉及到了多重派发机制(很多人应该都知道),加法是普通的函数(只不过是个中缀的),在三组不同的类型上各自调用了不同的加法(所以对应着不同的机器码)。可是,在大多数编程语言中我们也能观察到类似的现象,因为往往基本数字类型和加号被编译器特殊处理,所以这些基本运算也能表现出多态性,另外一个例子就是Julia的线性代数的库:

A = [1,2,3]
B = [3,4,5]
A*B'

只不过是两个数组的点乘,看起来平淡无奇,但是有几点值得我们注意

  1. A,B是真的一维数组,而不是一个n*1的或者1*n的二维数组
  2. B的转置是lazy的,而且返回一个新的类型,这个类型不和B相同,因此B'!=B
  3. B必须转置,A*B是错的(因为维数不匹配)
julia> B'
1×3 LinearAlgebra.Adjoint{Int64,Array{Int64,1}}:
 3  4  5

julia> B==B'
false

用过Matlab的人应该看的出区别,为什么要大费周章的设计这些复杂的类型?做出的答案反正不也都一样吗(而且从数学上说,一维的向量同构于一个1*n或者n*1矩阵)

答案很简单,一致性与优化。

如果用一个二维矩阵来表示一维向量(行向量与列向量),那么做点乘的时候就要返回1*1的数组(它仅仅同构于一个数,但是它还是数组),从计算机角度看,他们的类型是不一样的,要是我们返回一个数,那么函数就会返回多个类型,这对性能会产生影响(除非我们在编译器中hack,然后用一些奇妙的优化,但是这种方法是不可持续的),同样的,向量的转置应该区分于向量本身(特别要注意的情况是1*1的数组,因为如果我们用二维矩阵来表示就会得到a'==a,这再次表明不能简单地用二维矩阵来表示一维向量),所以我们才需要一个Adjoint type。

对于使用者而已,似乎完全感受不到在Julia中做线性代数有什么本质区别,就是一些加减乘除罢了(对于Matlab使用者应该很熟悉),可是Julia在背后做了很多的工作(花了几年时间讨论整个线性代数系统的语义)确保整个系统是一致的符合直觉的,同样的还有对IEEE浮点数的支持,考虑了各种corner case(例如sin(Inf)应该是多少,支持浮点数标准不是一件简单的事情)。

所以仅仅从表面上观察这个语言平淡无奇,因为很多类似功能别的语言都有呀,要是说优化,很多语言不也可以做吗(当然不方便而已)?可是这些功能很多是用原生库自己实现的(也有部分线性代数的库函数包装了Blas),而且它们用到了很多高级的语言特性,只不过被巧妙地隐藏起来了。实质上内核里Julia是很不一样的。

 


 

首先让我们来看看Julialang的文档中对Julia的简介:

  • Free and open source (MIT licensed)
  • User-defined types are as fast and compact as built-ins
  • No need to vectorize code for performance; devectorized code is fast
  • Designed for parallelism and distributed computation
  • Lightweight "green" threading (coroutines)
  • Unobtrusive yet powerful type system
  • Elegant and extensible conversions and promotions for numeric and other types
  • Efficient support for Unicode, including but not limited to UTF-8
  • Call C functions directly (no wrappers or special APIs needed)
  • Powerful shell-like capabilities for managing other processes
  • Lisp-like macros and other metaprogramming facilities

虽然这里的特性很多,实际上部分特性其实还没有完全实现(比如说线程,一直都是experiment的),不管怎么样,让我们逐条分析一下。

不是CS的人看了以后觉得有很多花里胡哨的术语(看起来就像是推销的人说了一大堆名词),先说说简单的:

一.Efficient support for Unicode, including but not limited to UTF-8

对Unicode的支持,注意这不仅仅是简单的可以用Unicode字符串,变量和函数名都是可以用Unicode的,而且很多专有的数学符号,例如克罗内克积都是可以中置使用的,还支持一定的Latex符号。也有另外一些字符串库来实现类似的功能(要是不喜欢你可以直接调用perl,因为Julia底层里面包括了perl的一个字符串处理库。。)

二.Powerful shell-like capabilities for managing other processes

这个就是一个方便用的shell,可以直接用run(process) 来执行命令行中的命令(用julia call linux的一些命令还是很不错的)

三.Lisp-like macros and other metaprogramming facilities

继承于Lisp的宏,宏是生成代码的函数,Julia有end的重大原因就是为了支持宏(很多动态语言中没有宏,这是让Julia创始人困惑的事情),有了宏,Julia可以直接用@time exp测时,以及用@assert做断言(有的东西总是需要宏来做的,例如说对表达式测时间,如果用time(exp)是没有用的,因为exp在调用前就已经求值完了,不用宏就只能用包装在函数里面或者特别提供关键字)。

另外Julia对于宏也有自己的创造,Julia中有生成函数(一种特殊函数),本质上是一个返回表达式的函数,然后表达式会插入函数所在处执行了。这个东西创造出来是为了利用LLVM的多层次编译优势(运行时动态生成优化代码),主要用于优化(而且被大量使用)例如在文档中的例子:

julia> @generated function bar(x)
           if x <: Integer
               return :(x ^ 2)
           else
               return :(x)
           end
       end
bar (generic function with 1 method)

julia> bar(4)
16

julia> bar("baz")
"baz"

这个bar函数根据输入的类型不同会产生不同的函数(都是编译的),如果是整数,就会求其平方,否则直接返回x(实际的例子比这个复杂很多,所以就不好举例子了)

简而言之,宏是一个很方便的东西,正确使用可以帮助用户少写很多代码(也可以做一些优化,不过貌似不多)

四.Unobtrusive yet powerful type system

Elegant and extensible conversions and promotions for numeric and other types

User-defined types are as fast and compact as built-ins

这三点都是在说类型系统,我很想强调一下这两点,因为Julia的类型系统实在是很有趣。

Julia的类型系统是导致很多人困惑的根源(因为我也曾经困惑过)

首先我们要说一下什么是类型系统,这件事情没有这么简单。静态类型系统和动态类型系统的区别完全没有我们想的这么简单。

一个静态类型系统是按照一定规则,给一个程序中的表达式及其子表达式赋予类型的规则。注意这里的要点是,我们必须给表达式赋予类型,例如:

1+1

1+1,作为一个表达式的类型是Int,从静态类型系统的角度看,这不是因为1+1等于2,而是因为1是Int,+是Int,Int->Int的函数,所以1+1整个表达式的类型是2.

那么什么是类型呢,严格地说类型可以是任何东西,完全不限,类型系统也只要我们用某种方式赋予表达式类型,这个类型和方式可以非常的荒谬,例如说,把所有的表达式都标记为一个叫做Any的类型--这就是动态类型语言,又叫Uni-type(单类型)系统。当然出于实用考虑,类型系统要设计的好一些。

那么等等,动态类型语言明明就有很多类型,1不就是整数类型吗?"aaa"不就是字符串类型吗?这个类型不是我们说的静态类型语言中的类型,严格的说这个类型要叫做标记(tag),他们是标记在值上的,只有值有类型,而表达式没有类型。

举几个例子,众所周知C语言中有类型,这个类型就是静态类型,所以我们才会说变量有类型,因为变量也要算表达式一部分,而C语言没有tag(或者说只有一种tag,叫做Any),因为在运行时C语言擦除了类型(你不能在运行时问一个东西的标记是多少,因为编译器早就把这个信息丢弃了),其他编程语言:

Java:有静态类型也有标记(因为有一些动态行为需要JVM)

Python:没有静态类型而只有标记

那么很多人会问这个标记和静态类型有什么关系。没有必然联系,但是很多类型系统设计的时候都设计成了相关的。静态类型推出的类型可以与实际值的类型不符合(但是这样是不好的)。最好的例子是面向对象中的继承,A是B的子类,所以一个类型为B的变量,实际上可以在运行时标记为B(A!=B)。

所有不能被赋予类型的程序都被判定为类型错误的。静态类型系统和编译与否无关,也和是否有静态类型错误关系不大,因为这个错误是可以动态抛出的,插入一个assert就好。重点在于,如果一个程序被静态类型系统判定为类型错误的话,那么它就一定不能执行(与我们想的相反,一个静态类型系统满足的最低要求是不能阻止那些本可以运行的程序被判定为类型错误,也就是说不要错判可以执行的程序)。例如:

function f(x::Int,y::Float)
  if rand()>0
     return x
  else
     return y
  end
end

这个函数会根据不同的随机数返回不同类型的x,y,在C语言中,如果没有用一个联合体包装起来,那么等价语义的程序是非法的。但是大家很容易看出,这个程序在动态语言中可以执行,因此,假设我们给动态语言强行配上一个C语言的类型系统,那么上述程序就会被判定为非法的(但是实际可以执行),不过你也可能会说,我们完全可以用更好的类型系统,比C语言的会更加强力,这样就可以允许这样的程序存在了。实际上Java就是一个很好的例子,正如前面所说,Java同时有静态类型系统和tag系统,但是类型系统推导出的类型往往比程序的tag更加粗(比如说继承),这样的系统是不准确的,但并不违反静态类型系统的定义。例如这个代码(来自于Java is unsound)

List<Integer> ints = Arrays.asList(1);
List raw = ints;
List<String> strs = raw;
String one = strs.get(0);

(这个程序现在应该已经不合法了)把一个整数赋值给了一个字符串,注意,这个程序是静态类型正确但是tag错误,因为根据类型系统的规则,类型系统允许这样的程序写出,只不过实际执行的时候,一个整数无法给一字符串赋值(内存等原因),所以产生了一个运行错误。具体的问题往往很复杂,很多静态语言的语义都依赖于类型系统,而运行时是无类型的,所以谈论什么程序 "实际上"可执行是很难的。

这和Julia有什么关系?

很多人不理解为什么Julia要设计为一个动态类型语言,这一点相对好理解,就是方便,而且不是一边的方便,数值计算往往有个做测试用的REPL,这个时候支持动态类型是很有必要的(包括测试各种代码,做一些简单的计算,用过Matlab和Python的人都知道)。但是更多人应该不明白的是为什么一个高性能的编程语言要设计成动态类型的,而且据传言还能match C语言的速度。

看了上面的讨论我们就知道,静态类型其实没有我们想的这么好(但不是不好,很多安全的程序还是要用一个静态类型系统来check的),本来这个东西就是发明来证明数学公理系统的正确性的,它不一定和程序性能有直接关联。只不过是大多数采用了静态类型系统的(C家族)语言恰好又支持了很多接近机器底层的操作(内存分配,内存布局可控制声明,类型与这些东西配合的很好),但是还有别的编程语言也采用了静态类型系统,性能不一定就见的好(例如Haskell,虽然优化很棒,但是就平均比起C来,写出性能好的Haskell还是不简单的,还有Java,Java有很多动态行为实际上和优化是背道而驰的)。

Julia是动态语言,因为Julia没有一套规则来给每个表达式一个类型(注意,是每个表达式,仅仅标记部分表达式是不算的),而且Julia有一个tag系统。但是这并不能说明任何绝对事情。实际上,Julia为了类型系统更加强力的表达能力(或者说tag系统),放弃了成为一个静态类型系统(我要说一下,一个静态类型系统可表达的类型越丰富,那么做起类型检查就越困难,特别是如果不强制大家每个变量都声明类型的话,编译器就要求解一些类型限制来确定类型,例如说Haskell的类型系统就是很巧妙的一个可以任意推导类型的系统)。取而代之的是,Julia可以用启发式算法给部分表达式(起码所有写的像C语言的简单类型的表达式)推出一个类型(这叫做type inference),所以很多人会奇怪为什么Julia不是一个静态类型系统,因为好像:

julia> @code_typed sum([1,2,3])
CodeInfo(
1 ── %1  = Base.identity::typeof(identity)
│    %2  = Base.add_sum::typeof(Base.add_sum)
│    %3  = Base.arraysize(a, 1)::Int64
│    %4  = Base.slt_int(%3, 0)::Bool
│    %5  = Base.ifelse(%4, 0, %3)::Int64
│    %6  = Base.sub_int(%5, 0)::Int64
│    %7  = (%6 === 0)::Bool
└───       goto #3 if not %7
2 ──       goto #11
3 ── %10 = (%6 === 1)::Bool
└───       goto #5 if not %10
4 ── %12 = Base.arrayref(false, a, 1)::Int64
└───       goto #11
5 ── %14 = Base.slt_int(%6, 16)::Bool
└───       goto #10 if not %14
6 ── %16 = Base.arrayref(false, a, 1)::Int64
│    %17 = Base.arrayref(false, a, 2)::Int64
└─── %18 = Base.add_int(%16, %17)::Int64
7 ┄─ %19 = φ (#6 => 2, #8 => %25)::Int64
│    %20 = φ (#6 => %18, #8 => %27)::Int64
│    %21 = Base.slt_int(%5, 0)::Bool
│    %22 = Base.ifelse(%21, 0, %5)::Int64
│    %23 = Base.slt_int(%19, %22)::Bool
└───       goto #9 if not %23
8 ── %25 = Base.add_int(%19, 1)::Int64
│    %26 = Base.arrayref(false, a, %25)::Int64
│    %27 = Base.add_int(%20, %26)::Int64
#,,,我截断了输出

编译器确实给了一些类型在表达式后面,编译器不做类型检查,而仅仅用类型信息进行优化,即使编译器知道类型肯定是错误的,它也不会抛出错误,而会推到运行时抛错,例如:

#f(x) = 1::Float64 把一个整数断言为浮点数,这是错的
code_typed f(2)
CodeInfo(
1 ─     Core.typeassert(1, Main.Float64)::Union{}
│       π (1, Union{})
└──     $(Expr(:unreachable))::Union{}
) => Union{}

编译器推出了整个程序是绝对错误的,因此是Unreachable的,但是它没有出一个编译错误,而是运行时才出错。

Julia类型系统的能力完全就不弱于别的编程语言,这个类型系统的光芒被其动态类型的表象掩盖,正如Julia创始人所描述的一样,要让用户感觉到整个语言还是动态的,但在内部又可以做一些静态的优化。Julia是动态类型中的异类,因为它支持一套非常独特的类型系统,以适合数值计算中的多层次的抽象,具体说来有:

1.用struct和primitive自定义类型(如果结构体的每个域都是具体类型,那么这个结构体可以和C的一一对应)

2.基于子类化和抽象类型的类型树(和1相关)

3.泛型(参数化类型),除了类型以外,部分值是可以用来参数化类型的(比如说数组的维数是用整数参数化的,这是很不同寻常的)

4.Union联合体,联合体仅仅为类型,不能用来构造,编译器在推导类型的时候可以产生一个Union,比如说返回不同类型的值的时候(你肯定会奇怪问什么不像C语言一样弄成一个可以构造出来的,这是有原因的,后面再说)

5.多重派发——实现ad hoc多态

6.类型本身也为值,可以用于做计算

所有不能用于直接构造的值都叫做抽象类型,反之叫做具体类型。和面向对象系统不一样,面向对象的抽象类型是可以构造的,例:Wolf是Animal的子类,两者都是可以构造出来的(也有一些特殊情况,但是理论上是可以的),因此在Julia中,值只有具体类型,不可能有抽象类型,只有抽象类型能被子类化,具体类型不可以,具体说来,就是上述类型树中,只有叶子节点才能被构造出来,其他的节点是不可构造的。

这是很有趣的一个地方,这表明,Julia的tag系统全都是具体类型组成的,那抽象类型有什么用呢?抽象类型用来确定子类化关系,以做多重派发以实现ad hoc多态(并且只有这个用处)

尽管和Haskell一样用a::B表示a类型是B,尽管Julia好像也写类型,例如

function f(x::AbstractFloat,y::AbstractFloat)
  if x>0
    y
  else
    2*y
  end
end

看上去这个函数接受两个抽象的浮点数!难道这样不会影响效率吗?现在我们知道,抽象的浮点数是抽象类型,是根本不能构造的,实际情况在调用f时,总是用具体类型来调用f(例如f(1.0,2.0) ),然后f会被specialize(特化),也就是说,f会对每一个类型组合产生一个编译后的函数,因此f实际上是很多函数的集合!在f上标记的类型只不过是用来声明不同的行为(若用不同的类型调用,则我们可以有不同函数体的f),例如:

function f(x)
  x+1
end
function f(x::Int64)
  x*2
end

f(1)会调用第二个函数,f("rfs")会调用第一个函数,注意尽管第一个f没有标记类型,f一样会对每一个类型产生一个编译后函数。

区分出具体类型和抽象类型是Julia优化中很重要的一个因素,因为只有这样才能做到一类型一内存布局,实际上像C语言(简单lambda类型)就是符合这个设计的。子类化只能继承行为,而不能继承数据。整个系统都是面向方法设计的(或者说接口),而非对象。所以编程范式是很不一样的,很多人不习惯的原因大多来源于此。

实际上这些特性并不是高高悬挂在上用来吹着好听的,实际上Julia自身以及其众多库都大量运用了这些特性,所以一般人看起来这些程序都非常抽象,这是一个Base库中函数(用了带变量的与参数子类化的参数化类型):

function getindex(r::StepRangeLen{T}, s::OrdinalRange{<:Integer}) where {T}
    @_inline_meta
    @boundscheck checkbounds(r, s)
    # Find closest approach to offset by s
    ind = LinearIndices(s)
    offset = max(min(1 + round(Int, (r.offset - first(s))/step(s)), last(ind)), first(ind))
    ref = _getindex_hiprec(r, first(s) + (offset-1)*step(s))
    return StepRangeLen{T}(ref, r.step*step(s), length(s), offset)
end

一般的Julia程序都写得非常的泛用,往往充满着大量的函数调用(多重派发),每个函数都是自动多态的,例如最常提到的,索引数组的时候,不用:

for i in 1:length(A)
  A[i]
end  #不推荐

#而是
for i in eachindex(A)
 A[i]
end

用eachindex获得索引,这样对于使用任意索引数组也能够成功迭代(例如0开始的数组)

所以Julia类型系统不是为了安全设计的,标记类型与安全性关系不大。Julia放弃静态类型系统的很大一个原因就是为了”不安全“。考虑这样一个程序:

function f(x)
  if x>0
    x+1
  else
    x*2
  end
end

这个函数是不可能检查类型的,因为我们根本不可能知道x的类型是什么(准确的说,可以是任何值),它等价于Haskell的这样一个泛型程序:

f::a->?
f x = if x>0 then x+1 else x*2 

但是这不太对,我们不知道+和*是怎么回事(这些是要定义的)我们需要type class来限定这个a,使得a可以加和乘

f::(Addable a,Multable a)=>a->a

这个办法在复杂的函数上很快就不work了,因为会用很多函数,这样我们就会用一大堆类型类。更何况同名函数不一定要返回相同的类型,所以在Haskell中做类似的事情是要打开一些扩展的(这不仅仅是一个扩展,这会破坏原有类型系统,实质上变得和Julia的类型系统没什么区别)

总之Julia的类型系统确实很不起眼,但是作为一个动态类型的语言完全就不输给Rust之类的语言。所以能和C语言一样快有什么奇怪的(更何况所有基本类型和部分结构体都和C兼容)?

五.Designed for parallelism and distributed computation

Lightweight "green" threading (coroutines)

做并发并行的支持Julia一开始设计的时候就内化于心了,即使如此,很多设计还是在不停的改来改去,现在在1.3中才刚刚确定了最后的对线程的支持。

我在这里要解释一下,以免大家误解。Julia很早就支持了Task(绿色线程),但是Task是单线程的,用来实现Julia中的IO,还有一些异步程序也可以用Task实现的(用@async宏),也支持了分布式计算,用Distributed库。但是对线程的支持却始终被标记为experimental的(而且部分时候还有一些性能问题),这里的线程的编程模型是共享内存编程。

问题不在于不支持线程,而在于这个支持始终让Julia设计者很不满意(别忘了Julia是动态语言,支持线程涉及到很多静态语言中没有的问题,所以支持线程是不简单的事情)。首先是线程和IO不能在一起工(Julia IO 调用了一个C库,所以Julia只有有限的操控权),其次是这个线程支持实在是太low level,原来也有high level的构造,但是几乎都删除了(实验性的),因为发现设计的不好,不可组合。而且Task一直都是单线程的,让大家很不高兴。所以一直等了很久(好几年)来把线程整合到IO和Task中(这还涉及到重新设计Base库中函数,使之线程安全,以及编译器的支持)。见这篇文章:

https://julialang.org/blog/2019/07/multithreading-zh-cn​julialang.org

 

支持共享内存编程(线程)比用进程以及异步IO困难的多,Julia还可以做得更好。

六.优化

我觉得这个东西本来就没有必要多说,因为已经有很多开发者说了N遍,为什么Julia能这么快,我之前也分析过,类型系统在Julia中起到了很大的帮助,基于LLVM的类型特化的JIT能够产生高效的代码,生成函数和宏可以起到锦上添花的作用,另外整个程序的优化流程(左边),有多层的优化,在进入到LLVM优化以前Julia就已经做了不少高级优化减轻后端负担,而这些高级优化的可能性来源于语言本身良好的设计。

而且Julia并不只是追求快,正确性和通用性也是重要的组成部分(不准确的浮点数运算可以很快,通过重排计算),我想再说一个很重要的事情,Julia编译器团队始终坚持的一点,就是优化的时候要有Compiler's freedom

什么意思呢?直译就是”编译器的自由“,写程序的人不应该根据编译器的具体实现来编程,而是应该仅仅根据所提供的程序的构造语义编程,说简单些,写程序的人不能够通过破坏程序的抽象来写程序(有一些例外情况,比如说要和别的语言交互的时候)。

比如说不能把一个结构体struct转换成一个bit string(位字符串)。乍一看这根本就不合理,结构体底层不就是一大堆字节排列在一片内存中吗?可是从集合论的角度看,结构体本来就只不过是一个乘积类型,例如:

struct A
  x::Int
  y::Float
end

A = Int * Float(笛卡尔积),没有任何规定Int必须和Float放在一起(当然确实放在一起更好),程序员不能利用这个特性(更加不能用一个指针加一来取后面的Float)。编写程序的人可以知道这些实现,但不应该依靠他们写程序。编译器有它的自由,所以对于上面这个结构体,编译器完全可以随意在内存移动(注意struct声明的结构体都是不可变的结构体,域不能改动),可以放在栈或堆上,只要怎么好就怎么放,甚至可以有多个拷贝。

保持编译器的自由可以导致编译器做更加激进的优化。只要保持语义不变,编译器怎么优化都可以(类似的就是javascript的V8优化)

七.flexible

Julia倒腾了这么久,就是为了结合flexible和性能。

为什么要折腾来折腾去,就是为了使函数能够泛用同时还保持高性能。为什么Julia的函数要写的泛用?很多人很容易想到的是可以节约一些代码,但是更加重要的是很多情况下泛用的函数可以给我们一些意外的惊喜,例如说Julia的微分方程的库Differential.jl和一个物理学的不确定量的库Uncertainty.jl结合在一起,可以求解初始条件有不确定量的微分方程。

Julia的库之所以要写的通用,是为了激发未来不同库(这些库可能是来自不同领域的不同researcher的作品)互相组合的可能性,每个库都保持开放,对代码做最小的可能性限制,提供开箱即用的可能性(out of box)

可组合性也是很重要的一点,我举个例子好了,以随机数生成为例:

julia> rand(Int64,3)  
3-element Array{Int64,1}:
 -2649612475503480631
 -1339104474732605748
  7365259649972224143

生成3个整数随机数,让我们来看看,怎么生成三个浮点数:

julia> rand(Float64,3)
3-element Array{Float64,1}:
 0.3861987959240578
 0.20948171556616169
 0.6257081229187069

我们只要变换类型就好了。。。

类似的我们可以一一写出:

rand(1:10,3) #在1,2,3,4,5,6,7,8,9,10中有放回随机取数3个
rand(1.0:0.5:3.0,3) #1.0,1.5,2.0,2.5,3.0中有放回取数3个
rand(Complex{Float64},3) #三个复数

我们还可以导入Distributions.jl获得更多分布:

julia> using Distributions
julia> rand(Gamma(1,2),4)
4-element Array{Float64,1}:
 9.026842772260245
 0.8963993164954357
 1.3921690234327555
 0.9756503782568936

也是一模一样的用rand+类型。根本不用多余的函数(randfloat,randcomplex,randgamma等等)

Julia有很多非常小的库都采用了这种设计,所以很多情况下大家都没有发现自己要找的东西其实别人都已经解决了,只不过有些隐蔽而已。。。


还有很多可以说的,以后可能可以补上。

总而言之,Julia已经是一门成熟的语言了,但是Julia给大家的总是不成熟的印象,因为比起别的语言,Julia还有很多不确定的地方,Julia走的这条动态语言优化之路本来就非常崎岖,这条路不同于那些用于服务器语言或者脚本的语言,可以用并发代替并行提高性能,或者用一些非常特殊的优化,仅仅优化语言的某个方面也能有可观的性能提升。更何况大家对Julia的期望不是一般的高(我想要Matlab+Python+Javascript+C+...)人们想要做非常多的事情,有太多的地方还可以提高,比如说编译到Webassembly上,编译到GPU上,在HPC上执行,Debugger...可是人手却相对少。即便如此,自Julia1.0发布一年以来,Julia已经做出了很多成就,但是前方道路还很遥远。正如Julia创始人说的一样:

We are greedy.
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章