尝试学一下 Julia,但是这份笔记的时间跨度可能很长,有空就写一点吧。

需要说明的是,这个系列不是零基础的 Julia 笔记,而是直接从 Python 和 MATLAB 到 Julia 的迁移学习,并且笔记是围绕主题进行的,不是逐渐展开的。

介绍

Julia是一门新兴的科学计算语言,它的设计目标非常宏大:希望 Julia 可以同时拥有 Python/MATLAB 一样的易用性和 C/C++/Fortran 一样的运算效率。

Julia有很多与众不同的特点:

  • 从设计定位来说,Julia 具有后发优势,是为 JIT 编译而量身定制的语言,目的是解决现在泾渭分明的动态语言和静态语言之间,优劣不能兼顾,因而必须组合使用的问题。
  • 从计算效率的角度,虽然和Python在使用时很相似,但是Julia的计算效率却比Python高很多。Python的科学计算模块Numpy等必须依赖于C/C++编写的底层库,但是Julia自身就可以提供足够的科学计算能力(主要是通过预编译实现的)。
  • 从语法设计的角度,Python并没有关注于科学计算编程中的便利性,Julia和MATLAB、Fortran、R语言的定位类似,充分为科学计算的效率和便利性考虑,提供了足够多的语法糖。这些语法糖其实有利有弊,一个典型的特征是索引从0还是从1开始,选择从1开始的语言具有明显的科学计算定位。
  • 作为一种新兴语言,采用了很多较新颖的语法设计,比如抛弃了传统的面向对象编程,仅支持C风格的结构体等。

但是Julia还是有很多争议的,比如:

  • Julia的发展时间太短了,主要的语法特性并不稳定;
  • 各种基础库不如其它语言全面和健壮,甚至有的基础库还存在低级问题;
  • 用户群体相比其他语言太小,相应的教程和社区讨论相对缺乏;
  • 第三方库不够丰富,由于跨语言调用比较方便,出现了大量依赖 Python 包的趋势。

Julia最理想的应用场景如下:

  • 使用与 Python/MATLAB 相当的简单方式编写 demo 代码;
  • 对现有代码进行简单优化,就可以达到与 C/C++/Fortran 相当的性能。

在整个过程中,不需要从动态语言 Python/MATLAB 迁移到静态语言 C/C++/Fortran 的代码重写或者跨语言组合方案,完全可以通过 Julia 自身来实现。

综合考虑,还是简单学习一下吧。

语法概述

Julia 的语法非常像 Python(Numpy) 和 MATLAB 的缝合怪,主要呈现如下特征:

  • 由于设计目标并不是 Python 那样的通用语言,而是类似 MATLAB 那样的科学计算语言:
    • 相对于 Python,Julia 提供很多科学计算的语法糖,使得语句相比 Python 更加简洁
    • 相对于 MATLAB,Julia 没有语法设计上的奇葩历史包袱,语法更加合理
  • 虽然仍然属于动态语言,但是 Julia 为了实现高效运算,在语法设计上相比于 Python/MATLAB 更加 “静态” 化,对于类型系统的处理更加严格规范。

为了避免混淆,在学习 Julia 之前非常有必要了解一下它们的主要语法差异(下面对于 Python 的讨论默认包含 Numpy)。

关于语法结构:

  • Julia 使用 end 标记结束,与 MATLAB 风格类似,Python 的语法结构非常依赖代码缩进,Julia 对代码缩进并不敏感,不需要游标卡尺。
  • Julia 使用 # 表示注释,与 Python 相同;MATLAB 则使用 % 表示注释
  • Julia 没有 Python 的 pass 关键词。
  • Julia 的条件语句中使用 elseif,与 MATLAB 一致,而 Python 使用 elif
  • Julia 的语句不鼓励使用 ; 结束,但是使用 ; 不会报错,只是影响输出(在 REPL 中直接执行语句时,如果没有 ; 会展示结果;但是在脚本中执行语句时,始终不会展示结果);MATLAB 不会区分 REPL 和脚本执行的情况,缺少 ; 总是会打印语句的结果,因此通常都会使用 ; 结尾。
  • Julia 不需要续行语法,不完整的表达式会自动续行,MATLAB 需要使用 ... 续行,Python 使用 \ 续行。

关于数组:

  • Julia 提供的 Vector{Any} 数组比较高效,Python 的原生列表灵活但非常缓慢,Numpy 数组则比较类似。
  • Julia 与 MATLAB/Fortran 一样采用列优先排序,Numpy 主要采用行优先排序。
  • Julia 有真正的一维数组,MATLAB 只有矩阵伪装的数组:尺寸为 (1,N)(N,1) 的向量。
  • Julia 使用 a[i,j] 访问数组元素,与 Numpy 类似,而 MATLAB 使用 a(i,j)
  • Julia 中的数组尺寸相对固定,不允许在赋值过程中自动增长数组,MATLAB 则允许动态增长。
  • Julia 使用 * 可以直接进行矩阵乘法,而 Python 需要使用 @
  • Julia 使用 ' 表示转置或共轭转置,而 Python 则需使用 .T
  • Julia 建议使用 filterfilter! 对数组的一部分进行处理,而 MATLAB 和 Numpy 通常使用逻辑索引。

关于索引和切片:

  • Julia 索引从 1 开始,与 MATLAB 一致,而 Python 索引从 0 开始。
  • Julia 支持使用 begin 表示第一个元素的索引,使用 end 表示最后一个元素的索引,MATLAB 仅提供 end,Python 则使用负数索引表示,例如 -1 表示最后一个。
  • Julia 的切片语法为 start:step:stop,步长在中间,范围是严格闭区间。Python 切片语法是 start:stop:step,步长在最后,范围是左闭右开。
  • Julia 的切片语法比较严格,不允许缺省起点终点,例如a[2:end]不可以省略end
  • Numpy 的高级索引非常奇怪(不是张量积),Julia 在类似情况采用了与 MATLAB 相似的处理。
  • Julia 通过切片提取数组的部分元素时,默认会进行拷贝,也可以显式标记为视图,而 Numpy 的情况比较复杂,可能是拷贝或视图。
  • Julia 的切片(a:ba:b:c)会构造实际的特殊对象,MATLAB 的切片则会对应完整向量,Python 只有在特定代码中才支持切片的语法。

关于数据类型:

  • Julia 对于整数和浮点数等的区分比较严格,在实际运算时可能自动转换,也可能需要显式转换,比 Python 的类型处理更严格,MATLAB 几乎不需要关注数据类型,绝大多数情况下使用默认的浮点数即可。
  • Julia 的整数类型通常是固定位数的,并不是 Python 那样的任意精度整数,这主要是为了运算效率。
  • Julia 使用 nothing 专门表示空值,与 Python 的 None 类似,MATLAB 则通常使用 []
  • Julia 可以使用与 Python 类似的语法进行类型标注,但是 Julia 的类型标注是强制的,而 Python 的类型注解只是参考性的,仅对代码提示和静态分析有意义。(不过 Julia 官方文档并不推荐用户手动进行大量的类型标注)

关于函数:

  • Julia 支持给函数参数提供默认值,Python 的默认值求值只发生在定义时,但是 Julia 的默认值求值会发生在每一次调用时,MATLAB 不支持参数默认值,需要通过 nargin 间接实现。
  • Julia 关于关键字参数,与 Python 不同,Julia 严格区分位置参数和关键字参数的使用,MATLAB 需要特殊实现才能使用关键字参数。
  • Julia 为所有函数提供了动态分派机制(类似于C++的虚函数),MATLAB 有类似的动态重载机制,Python 不支持函数重载。
  • Julia 即使无参数的函数调用也不允许省略括号,例如 rand()。只有MATLAB才会有省略括号的这种垃圾语法。
  • Julia 可以使用 return 关键字返回结果,或者将最后一个表达式自动返回;Python 使用 return 关键字返回结果,否则在缺省时只会返回 None。MATLAB 必须在函数定义时声明返回值,return 语句完全不携带返回值,只是提前结束。
  • Julia 可以用函数返回多个值组成的元组,与 Python 几乎一样,而 MATLAB 需要基于 nargout 实现返回多个值。
  • Julia 几乎舍弃了面向对象机制,结构体只能用于存储数据,函数会根据参数类型区分不同的实现,而 MATLAB 和 Python 都支持面向对象机制。

关于变量:

  • Julia 在赋值和传参过程中并不会发生拷贝,存在浅拷贝和视图等复杂机制,MATLAB 则采用值传递+懒拷贝机制。
  • 全局变量的存在对于运算效率的影响都是负面的,对于 Julia 同样如此。
  • Julia 对变量作用域的处理与 Python 类似,代码块可能会引入新的局部作用域,而 MATLAB 除了默认的全局工作区之外,几乎只有函数才可以开辟独立的工作区,在使用上非常不便。

关于字面量:

  • Julia 将整数字面量例如 42 视作合适位数的整数类型,Python 统一视作任意精度整数,MATLAB 则统一视作浮点数,除非显式声明为其他类型。
  • Julia 的复数单位为 im,MATLAB的复数单位为 ij,Python的复数单位只有 j
  • Julia 只支持双引号的字符串,单引号表示字符;Python 单引号和双引号均可,不区分字符和字符串;MATLAB 的单引号是字符数组,双引号则是字符串,两者属于不同类型,但是很多操作相互兼容。

关于运算符:

  • Julia 支持 += 等运算符,但是与 Numpy 数组不同,+= 并不是就地运算,a += b 始终是 a = a + b 的简写,MATLAB 完全不支持这类运算符。
  • Julia 有很多专门的逐点运算符,并且在语法上严格区分普通运算符和逐点运算符,比 MATLAB 和 Python 的处理都更加严格。
  • Julia 使用 ^ 表示指数运算,与 MATLAB 相同,而 Python 使用 **
  • Julia 使用 != 表示不等号,而 MATLAB 使用 ~=
  • Julia 使用 % 表示取余,Python 使用 % 表示模运算,两者在处理负数时存在差异。MATLAB 的 % 用于表示注释。
  • Julia 支持三元运算符 x > 0 ? 1 : -1,MATLAB 不支持,Python 则有类似的条件表达式 1 if x > 0 else -1
  • a^b^c 的结合规则细节:在 Julia 中被认为是 a^(b^c),而在 MATLAB 中被认为是 (a^b)^c

其他:

  • 关于布尔值:Julia 在 if 语句等只能使用显式的布尔值,或者将其它语句显式转换为布尔值,甚至不支持 0 和 1 的隐式转换,不支持其他非零值到布尔值的显式转换,而 MATLAB 和 Python 允许很多情况下到布尔值的隐式转换。
  • 关于 ans:在 REPL 中,Julia 使用 ans 存储上一条命令的结果,MATLAB 也使用 ans,Python 在 REPL 中使用 _。在非交互式模式中,Julia 和 Python 都不会专门存储上一条命令结果,而 MATLAB 总是会设置。
  • Julia 使用 _ 代表丢弃的值,MATLAB 使用 ~,而 Python 仍然使用 _
  • 关于垃圾回收:Julia 没有类似于 MATLAB 的 clear 函数可以手动清理对象,可以使用 A = nothing 来释放内存,但是实际行为仍然取决于底层的垃圾回收机制。
  • Julia 不建议像 Python 那样判断一个脚本是作为主脚本还是库文件被导入,最好对脚本和库文件有不一样的写法,而不是兼顾两者。

除此之外,Julia 还有很多参考其它语言的部分:

  • 完全支持 Unicode 字符作为变量名,包括中文和其它各种特殊符号,考虑到很多 Unicode 字符的形态非常相似,Julia 也进行了对应的优化处理;(但是考虑到编程习惯,除了数学公式中常见的希腊字母等,仍然不建议其他情况使用非ASCII字符作为变量名)
  • 支持名称为@开头的宏,并且这里的宏并不是类似于C语言中的简单文本替换,而是更复杂的基于 AST 的代码转换;(Python/MATLAB 没有编译的概念,因此没有宏,而且 Python 设计者明确反对使用宏,认为会降低代码可读性)
  • 按照约定,如果在函数名称的结尾有!(这是函数名称的一部分),表示这个函数会修改参数,例如push!
  • 与 Python 和 MATLAB 不同,Julia 支持固定类型的“常量”,使用 const 修饰,此时仍然允许改变这个变量的具体值,但是不允许改变类型。

如果考虑底层实现,那么 Julia 与 MATLAB/Python 实际上几乎没有什么共同点,反而更应该把 Julia 和 C/C++ 进行对比:C++ 有明确分开的编译期和运行期,Julia 则将两者混合到了一起(在后台偷偷地编译),这导致用户始终需要编译器才能运行 Julia 脚本,而 C++ 在运行阶段是完全不依赖编译器的。

Julia 使用了一个基于 LLVM 的 JIT 编译器,编译行为对用户基本上是透明的,用户在基本使用时无需考虑这些因素,但是在考虑代码的效率优化以及高性能计算时需要考虑这部分因素。

例如 C++ 的模板实例化完全发生在编译期,而 Julia 则会在函数首次调用时(注意不是函数定义时)针对不同的类型进行实例化,也就是在后台偷偷编译,这当然会带来非常明显的性能开销,在下一次遇到相同类型时就不需要编译了,此时的速度才可以与 C/C++ 相当。

Julia 与 MATLAB/Python 在向量化方面存在截然不同的表现:

  • MATLAB 和 Python 的 for 循环非常慢,通常必须使用向量化语法,进而调用底层代码来加速;
  • Julia 直接使用 for 循环已经足够高效,虽然也支持向量化,但是向量化并不会带来明显的性能差异,甚至可能表现得更加糟糕,因为可能产生额外的数据拷贝,对于 Julia 来说,向量化的意义只是写法更加简洁。

对于这部分只是简单了解,因此没有详细展开。

基本语法

注释

Julia 和 Python 一样使用 # 表示注释,支持多行注释:使用 #= 开始,使用 =# 结束,但是并不常见。

标识符

标识符可以使用字母,下划线或数字组成,对大小写敏感,数字不能在开头。

Julia 实际和 Python 3 一样,可以使用除了内部标识符和运算符之外的绝大多数 Unicode 字符作为标识符,不需要局限于英文字母,而且某些语义相同、形态相似的 Unicode 字符会被视作相同字符。

不建议使用英文和数字下划线之外的标识符。从 Julia 的文档可以看出,Unicode 字符支持给语法解析带来了很多困难,所以实在不理解设计者为什么非要维护这个糟糕的语法糖。

变量

在入门阶段,可以认为 Julia 的变量机制和 Python 基本类似,因此不再展开。

Julia 有内置常量或函数,比如 pians,甚至允许我们覆盖内置的定义,例如重新赋值 pi=3,但是前提是在此之前我们从未使用过它,否则赋值会报错。

在交互式环境中,上一次的表达式结果会自动存储在特殊的内置变量 ans 中,但是如果我们在第一次就对其赋值,那么 ans 的默认功能就会失效。

常量

与 Python 不同,Julia 支持定义固定类型的常量,例如

1
2
3
4
5
6
7
8
9
const a = 1;

a = 1; # 无实质更改,不会报错或提示

a = 2; # 不改变类型,但是改变值,会发出警告
# WARNING: redefinition of constant Main.a. This may fail, cause incorrect answers, or produce other errors.

a = 3.0; # 改变类型,会报错
# ERROR: invalid redefinition of constant Main.a

注意:

  • 在定义 const 变量之前,必须确保它不是一个已经定义的变量,否则定义会报错。
  • 虽然修改 const 变量的值可能不会报错,但是仍然不建议修改,因为与 C/C++ 类似,常量的修改不符合语义,可能在部分代码的编译中直接固定了常量的值,后续对常量的修改可能不会生效,进而导致产生不符合预期的结果。
  • const a, b = 1, 2 会同时创建两个常量。

命名规范

虽然 Julia 对标识符的限制很少,但是 Julia 文档还是提供了一份命名规范:

  • 变量使用小写加下划线
  • 常量使用全大写
  • 函数和宏的名称使用小写加下划线
  • 对于会修改输入变量的函数,名称以!结尾,这些函数又叫做 “mutating” 或 “in-place” 函数
  • 类型和模块名称使用大驼峰

但是 Julia 不鼓励大量使用下划线,除非缺少下划线时的名称难以理解。

shell/REPL

需要介绍一下 Julia 的 shell/REPL 中的相关操作,它内置了多个模式,可以根据当前的提示符来区分:

  • julia> 表示 Julia 的普通模式,以 REPL 形式运行 Julia 代码,这是默认模式,其它特殊模式都可以通过 backspace 退回到普通模式
  • help?> 表示 Julia 的帮助模式,普通模式按?进入,例如在普通模式可以输入?sqrt查询相关内容,在读取?时自动进入帮助模式,在展示帮助信息后自动退出
  • (@v1.10) pkg> 表示 Julia 的包管理模式,普通模式按]进入,Julia 提供了一整套包管理的命令,暂不讨论
  • shell> 表示 Julia 的 shell 模式,普通模式按;进入,注意这里的 shell 模式并不是 bash/pwsh,而是非常受限的,有很多 shell 的内置命令都不能使用。

在各种模式下(包括执行脚本)都需要注意当前的工作目录:

  • 在普通模式,可以使用 pwd() 获取当前工作目录,使用 cd() 可以改变当前工作目录,在 Shell 模式则可以使用常见的目录切换命令。
  • 与 MATLAB 类似,对于 julia 脚本,最好在开头把工作目录设置为脚本所在目录(使用 cd(@__DIR__),这样脚本中的文件路径都是相对于脚本所在位置的,保证脚本可靠运行。

Julia 的这种分模式的处理显然吸取了 MATLAB 虚拟终端的设计失败教训。

关于特殊符号的输入:

  • 有时需要使用一些 Unicode 字符,在 REPL 中通常直接使用 LaTeX 代码(例如\div)加上 tab键就可以切换(在 Jupyter Notebook 中也支持)。
  • 如果不知道特殊符号应该如何输入,可以在 REPL 中输入 ? 进入帮助模式,然后粘贴对应的 Unicode 字符,就可以获得关于这个字符的输入方法。

exit()ctrl+d 可以退出 REPL。

输入输出基础

简单介绍一下基本的输入输出操作。

print()println() 可以向控制台输出内容,区别是后者自带一个回车。

1
2
print("hello,world!\n")
println("hello,world!")

也可以直接输出多个字符串,中间不会加入分隔符

1
print("a","b") # ab

Julia 支持和 Python 的 f-string 类似的格式化输出方式,在字符串中可以使用 $ 插入变量或表达式,有歧义时需要加小括号,例如

1
2
3
4
5
a = 1;
print("a=$a") # a=1

b = 2;
print("$(a+b)") # 3

readline() 可以从控制台获取一行输入数据,得到的是字符串数据,例如

1
s = readline()

通常需要对输入数据进行类型转换,可以使用 parse() 函数,例如

1
2
s = readline()      # "123"
x = parse(Int, s) # 123

日志打印

Julia 内置了一些信息输出的宏,包括 @show@debug@info@warn@error,例如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
julia> @show "hi"
"hi" = "hi"
"hi"

julia> @debug "hi"

julia> @info "hi"
[ Info: hi

julia> @warn "hi"
┌ Warning: hi
└ @ Main REPL[12]:1

julia> @error "hi"
┌ Error: hi
└ @ Main REPL[13]:1

注意这里 @debug 的信息默认不会显示出来。

更完整的日志系统由 Logging 这个包提供支持。