Press "Enter" to skip to content

《迎接茱莉娅:一封邀请函》

热情地向Python爱好者、科学计算巫师和数据科学家们扩展

Julia是一种通用、动态、高性能和高级的即时编译编程语言。它是一种相对较新的语言,其主要1.0版本仅在2018年发布。在这个故事中,我们旨在展示,如果你对数据科学、科学计算感兴趣,或者只是一个热衷于Python的用户,那么将这种语言添加到你的工具箱绝对是值得的。也许这是你曾经接触过的最美丽的编程语言。

由作者使用DALLE 2生成的带有紫色、绿色和红色行星的数字艺术

在这个故事中,我们将展示Julia的思想高度以及学习它的价值所在。完成后,我们强烈推荐您查看下一个故事从Python到Julia:终极指南,以便从Python轻松过渡到Julia。

目录

· Julia是高级语言基本语法优雅的数学语法· Julia速度快基准两种语言的问题Julia即时编译· Julia解决了表达问题表达问题多分派抽象类型和具体类型· Julia功能齐全数组支持字符串支持多线程与C代码轻松集成标准库· Julia是通用语言介绍自动化和脚本编写· Julia可大量扩展介绍· 总结

Daniele Levis Pelusi的照片,摄影者:Daniele Levis Pelusi

朱莉娅是高级的

介绍可能已经让您觉得这将像Python一样是一种通用、动态和高级的语言。为了验证这一点,让我们尝试了解一下基本的Julia代码相对于Python是什么样子。

基本语法

考虑以下Python中的猜数字游戏:

import randomdef guessing_game(max):    random_number = random.randint(1, max)    print(f"猜一个1到{max}之间的数字")    while True:        user_input = input()        guess = int(user_input)        if guess < random_number:            print("太小了")        elif guess > random_number:            print("太大了")        else:            print("猜对了!")            break        guessing_game(100)

以下是在Julia中的等效代码:

function guessing_game(max::Integer)    random_number = rand(1:100)      println("猜一个1到$max之间的数字")    while true        user_input::String = readline()        guess = parse(Int, user_input)        if guess < random_number            println("太小了")        elseif guess > random_number            println("太大了")        else            println("猜对了!")            break        end    endendguessing_game(100)

这里的主要区别是Julia不假设任何缩进或要求冒号,而是需要显式的“end”来结束if条件、循环和函数等作用域。如果您来自Matlab或Fortran,这应该让您感到舒适。

您可能已经注意到的另一个区别是Julia在变量声明、函数参数(以及返回类型,尽管很少使用)中自然支持类型注释。它们总是可选的,但通常用于类型断言,让编译器在方法重载为多种类型提供相同方法实例时选择正确的方法实例,并在某些变量和结构声明的情况下,带来性能优势。

数学的优雅语法

# 优雅的表达式x = 2z = 2y + 3x - 5# 官方的Unicode支持α,β,γ = 1, 2, π/2# 单行函数f(r) = π*r^2f'(3)  # 导数(使用Flux.jl包)# 列向量就是一个列v₁ = [1      2      3      4]  v₂ = [1 2 3 4]# 转置println(v1' == v2)# 这实际上是一个3x3矩阵M⁽ⁱ⁾ = [1 2 3        4 5 7        7 8 9]# 显式建模缺失X = [1, 2, missing, 3, missing]

Julia相对于Python的一个严重优势是数学语法的支持。在乘以变量时无需使用*运算符,支持latex符号作为变量名(可能需要使用VSCode扩展将\pi转换为π,v\_1转换为v₁等),并且矩阵在代码定义中保持了布局。

例如,如果您要为神经网络实现梯度下降。

在Python中,您可能会这样写:

import numpy as np# 神经网络中的梯度下降J_del_B_n = [np.zeros(b) for b in B_n]J_del_W_n = [np.zeros(W) for W in W_n]for (x, y) in zip(x_batch, y_batch):    J_del_B_n_s, J_del_W_n_s = backprop(x, y)    J_del_B_n = [J_del_b + J_del_b_s for J_del_b,                 J_del_b_s in zip(J_del_B_n, J_del_B_n_s)]    J_del_W_n = [J_del_W + J_del_W_s for J_del_W,                 J_del_W_s in zip(J_del_W_n, J_del_W_n_s)]d = len(x_batch)W_n = [(1 - lambda_val * alpha / d) * W - lambda_val /       d * J_del_W for W, J_del_W in zip(W_n, J_del_W_n)]B_n = [(1 - lambda_val * alpha / d) * b - lambda_val /       d * J_del_b for b, J_del_b in zip(B_n, J_del_B_n)]

将这段HTML代码翻译成中文(结果中保留HTML代码):

# 在神经网络中使用梯度下降მJⳆმBₙ = [Bₙ中的零元素为b的列表]მJⳆმWₙ = [Wₙ中的零元素为W的列表]for (x, y) in zip(x_batch, y_batch)    მJⳆმBₙₛ, მJⳆმWₙₛ = backprop(x, y)    მJⳆმBₙ = [მJⳆმb + მJⳆმbₛ for მJⳆმb, მJⳆმbₛ in zip(მJⳆმBₙ, მJⳆმBₙₛ)]      მJⳆმWₙ = [მJⳆმW + მJⳆმWₛ for მJⳆმW, მJⳆმWₛ in zip(მJⳆმWₙ, მJⳆმWₙₛ)]d = len(x_batch)Wₙ = [(1 - λ*α/d)* W - λ/d * მJⳆმW for W, მJⳆმW in zip(Wₙ, მJⳆმWₙ)]Bₙ = [(1 - λ*α/d)* b - λ/d * მJⳆმb for b, მJⳆმb in zip(Bₙ, მJⳆმBₙ)]

你可以尝试在Python中写这样的代码,但编辑器通常会在Unicode变量周围放置黄色方块(或无法对其进行突出显示),你的代码可能无法与Pickle等第三方包一起使用。

Photo by Solaiman Hossen on Unsplash

Julia 很快

Julia可以被视为Python的理想编程语言的又一个主要原因是,与Python、Ruby和其他高级语言不同,它在高级语言的基础上不会牺牲速度。事实上,它可以与低级语言(如C和C++)一样快。

基准测试

以下是Julia的性能及其他语言在流行的性能基准上的表现:

Julia Microbenchmarks: Image via JuliaLang under MIT license

两种编程语言的问题

Julia的性能带来的一个相关问题是解决了“两种编程语言的问题”:

  • 研究代码(例如机器学习模型)通常是用高级语言(如Python)编写的,因为它是高级的且交互式的;因此,更容易专注于科学研究(减少了代码问题)和进行更多的探索。
  • 一旦研究代码完成,必须将其重写为低级语言(如C)以便在生产环境中使用。

这里的问题是同一段代码必须以多种语言重写。这通常很困难且容易出错;如果研究代码在部署后进行修改,在最坏的情况下,所有代码都必须再次使用低级语言进行重写。

解决这个问题的一种方法是用低级语言(如C)编写性能关键的库(如Numpy),然后可以用Python函数封装它们,这些函数在内部调用C函数,可用于研究和生产,而不必担心性能。实际上,这种方式非常有限,因为:

  • 对于新开发者来说,这使得他们很难为他们编写的新颖科学方法做出贡献或合作,因为他们可能需要将这些方法重写为低级语言,如C语言,以提高性能,然后再在高级库中使用。
  • 科学计算领域对于高级语言的开发者可能会施加一些有趣的限制。例如,明确写出for循环可能会受到严重的限制。

Julia通过具有高级、交互式和相当快速的特性解决了双语言问题,甚至可用于生产环境。

Julia是即时编译的

关于Julia的性能有一个小注解。因为Julia是即时编译的,所以任何Julia代码的首次运行将需要更长的时间来完成。在此期间,每个函数代码都将被转换为特定变量类型所推断的本地代码(即处理器可以解释的代码)。一旦完成,它将缓存编译后的表示,以便如果再次以相同类型的不同输入调用该函数,则立即进行解释。

进一步解释一下,对于一个具有N个参数的函数,可能有指数级数量的可能本地代码表示;对于N个参数的每个可能组合类型,都有一个对应的表示。Julia将在首次运行代码时编译函数到对应于代码推断的类型的表示。一旦完成,进一步调用函数将毫不费力。请注意,它并不一定使用类型注解(类型注解是可选的,有其他用途),类型可以从输入的运行时值推断出来。

这不是一个问题,因为研究代码或在服务器上运行的代码只需要初始编译一次,一旦完成后,后续的运行(真实的API调用或进一步的实验)将非常快速。

Thom Milkovic 在 Unsplash 上的照片

Julia解决了表达问题

表达问题

表达问题是关于能够定义一个数据抽象,它在其表示(即支持的类型)和其行为(即支持的方法)方面是可扩展的。也就是说,解决表达问题的解决方案允许:

  • 添加适用于现有操作新类型
  • 添加适用于现有类型新操作

而不违反开放封闭原则(或引起其他问题)。这意味着应该能够添加新类型而无需修改任何现有操作的代码,并且应该能够添加新操作而无需修改任何现有类型的代码。

像Python这样的许多编程语言都是面向对象的,并且无法解决表达问题。

假设我们有以下数据抽象:

# 基类class Shape:    def __init__(self, color):        pass    def area(self):        pass# 子类class Circle(Shape):    def __init__(self, radius):        super().__init__()        self.radius = radius    def area(self):        return 3.14 * self.radius * self.radius

很容易添加新类型使其适用于现有方法。只需继承自Shape基类即可,不需要修改任何现有代码:

class Rectangle(Shape):    def __init__(self, width, height):        super().__init__()        self.width = width        self.height = height    def area(self):        return self.width * self.height

与此同时,很难为现有类型添加新操作。如果我们想要添加一个周长方法perimeter,则必须修改基类和到目前为止实现的每个子类。

这个问题的一个后果是,如果包x由作者X维护,它最初支持一组操作Sx,如果另一组操作Sy对于另一组开发人员Y有帮助,他们必须能够修改X的包以添加这些方法。实际上,开发人员Y只需自己创建另一个包,可能复制来自包x中的代码来实现这种类型,因为作者X可能不太愿意增加维护的代码量,并且Sy可能是不同类型方法的一个不同类型的方法,不需要存在于同一个包中。

<!–另一方面,由于可以很容易地为现有操作添加新类型,如果开发人员Y只想定义一个新类型,该类型实现了X实现的类型的操作,那么他们可以很容易地做到这一点,甚至不需要修改x包或在其中复制任何代码。只需导入该类型,然后继承自它。

多重调度

为了解决表达问题,允许不同包之间进行大规模集成,Julia完全摒弃了传统的面向对象编程。Julia使用抽象类型定义、结构(抽象类型的自定义类型实例)和方法以及一种称为多重调度的技术,我们将看到,它完美地解决了表达问题。

来看一下之前的一个等价表达:

### 形状抽象类型(接口)abstract type Shape endfunction area(self::Shape)  end### 圆形类型(实现接口)struct Circle <: Shape    radius::Float64endfunction area(circle::Circle)    return 3.14 * circle.radius^2end

在这里,我们定义了一个抽象类型”Shape”。它的抽象性意味着它不能被实例化;然而,其他类型(类)可以对其进行子类型化(继承)。然后,我们定义了一个圆形类型,作为”Shape”抽象类型的子类型,并且我们定义了”area”方法,并指定输入必须是”Circle”类型。通过这样做,我们可以执行以下操作:

c = Circle(3.0)println(area(c))

这将打印出28.26。虽然c同时满足area定义,因为它也是一个Shape,但第二个定义更具体,因此编译器选择该定义进行调用。

与基于类的面向对象编程类似,我们可以轻松添加另一种类型“矩形”而不需更改现有代码:

struct Rectangle <: Shape    length::Float64    width::Float64endfunction area(rect::Rectangle)    return rect.length * rect.widthend

现在当我们执行以下操作:

rect = Rectangle(3.0, 6.0)println(area(rect))

我们得到18.0。这就是多重调度的作用;根据运行时参数的类型,动态调度了正确的方法实例。如果你来自C或C++背景,那么这一定会让你想起函数重载。区别在于函数重载不是动态的,它依赖于编译时找到的类型。因此,你可以构思出行为不同的示例。

更重要的是,与基于类的面向对象编程不同,我们可以在不需要修改文件的情况下为ShapeCircleRectangle中的任何一个添加方法。如果上述所有文件都在我的包中,而你希望添加一组产生几何形状动画和3D可视化的方法(我不关心),那么你所需要做的就是导入我的包。现在你可以访问ShapeCircleRectangle类型,并且可以编写新函数,然后在你自己的”ShapeVisuals”包中导出它们。

### 接口定义function animate(self::Shape)  endfunction ThreeDify(self::Shape)  end### Circle定义function animate(self::Circle)  ...endfunction ThreeDify(self::Circle)  ...end### Rectangle定义function animate(self::Rectangle)  ...endfunction ThreeDify(self::Rectangle)  ...end

当你仔细考虑时,你会发现这与你所熟悉的面向对象编程之间的主要区别在于它遵循了模式func(obj, args)而不是obj.func(args)。作为一个额外的好处,它还使得像func(obj1, obj2, args)这样的操作变得轻而易举。另一个区别是它不会将方法和数据封装在一起,也不会对它们施加任何保护;也许,这对于开发人员已经足够成熟并且代码已经经过审查的情况来说是无关紧要的措施。

抽象类型和具体类型

事实上,您现在知道抽象类型只是一种不能从中实例化值,但其他类型可以作为子类型的类型,为讨论Julia的类型系统铺平了道路。请记住,使用语法var::type来在声明变量时进行类型注解是可选的,如函数的参数或返回值。

Julia中的任何类型都是抽象类型,就如我们上面定义的那样,或者是具体类型。具体类型是您可以像我们上面定义的自定义类型一样实例化的类型。

Julia对数字采用以下层次化类型系统:

Julia微基准测试:图片通过Julia的优化和学习在MIT许可下

如果您的函数接受一个参数并对任何数字进行操作,您将使用func(x::Number)。只有当传递了非数字值(如字符串)时,才会引发错误。同时,如果它只适用于任何浮点数,您将使用func(x::AbstractFloat)。如果输入是BigFloat、Float64、Floar32或Floar16类型,不会引发错误。由于存在多分派,您还可以定义函数的另一个实例func(x::Integer)来处理给定数字是整数时的情况。

类似地,Julia还为其他抽象类型(如AbstractString)拥有层次化类型系统,但它们要简单得多。

Paul Melki的照片,来源于Unsplash

如果您仔细考虑一下,Python只提供了最基本的功能。例如,如果您只使用Python而没有像Numpy这样的流行软件包,那么在数据科学和科学计算中您几乎无法做任何事情。该领域中的绝大多数其他软件包也严重依赖于Numpy。它们都使用并假设“Numpy”数组类型(而不是默认的Python列表类型),就好像它是语言的一部分。

Julia不是这样的。它提供了许多重要功能的即时支持,包括:

数组支持

Julia提供了与Numpy类似的数组支持,包括广播和矢量化支持。例如,以下是流行的Numpy操作与您在Julia中如何编写它们的对比:

#> 1. 创建一个NumPy数组### Pythonarr = np.array([[1, 2, 3],                [4, 5, 6],                [7, 8, 9]])### Juliaarr = [1 2 3       4 5 6       7 8 9]#> 2. 获取数组的形状### Pythonshape = arr.shape### Juliashape = size(arr)#> 3. 调整数组形状### Pythonreshaped_arr = arr.reshape(3, 3)### Juliareshaped_arr = reshape(arr, (3, 3))#> 4. 通过索引访问元素### Pythonelement = arr[1, 2]### Juliaelement = arr[1, 2]#> 5. 执行逐元素算术运算### Pythonmultiplication = arr * 3### Juliamultiplication = arr .* 3# 6. 数组连接### Pythonarr1 = np.array([[1, 2, 3]])arr2 = np.array([[4, 5, 6]])concatenated_arr = np.concatenate((arr1, arr2), axis=0)### Juliaarr1 = [1 2 3]arr2 = [4 5 6]concatenated_arr = vcat(arr1, arr2)#> 7. 布尔屏蔽### Pythonmask = arr > 5masked_arr = arr[mask]### Juliamask = arr .> 5masked_arr = arr[mask]#> 8. 计算数组元素的和### Pythonmean_value = arr.sum()### Juliamean_value = sum(arr)

字符串支持

Julia还提供了对字符串和正则表达式的广泛支持:

name = "Alice"
age = 13
## 字符串拼接
greeting = "你好," * name * "!"
## 字符串插值
message2 = "明年,你将年满 $(age + 1) 岁。"
## 正则表达式
text = "这里有一些邮箱地址:alice123@gmail.com"
# 定义邮箱正则表达式
email_pattern = r"[\w.-]+@[\w.-]+\.\w+"
# 匹配邮箱
email_addresses = match(email_pattern, text)
"aby" > "abc"       # true

字符串比较时,按字典顺序(一般的字母表顺序)后面出现的字符串被认为比前面出现的字符串更大。可以证明,在Perl等高级字符串处理语言中可进行的大部分操作也可以在Julia中完成。

多线程

Python不支持真正的并行多线程,这是因为它带有全局解释器锁(GIL)。这导致解释器无法同时运行多个线程,这是一种过于简单的解决方案,用来确保线程安全。只能在多个线程之间切换(例如,如果服务器线程正在等待网络请求,解释器可以切换到另一个线程)。

幸运的是,在被Python调用的C程序中轻松释放此锁的困难决定了Numpy的可能性。然而,如果你有一个巨大的计算for循环,就不能编写Python代码以并行方式执行它来加快计算速度。对于Python来说,令人沮丧的现实是,适用于大型数据结构(如矩阵)的大多数数学运算都是可并行化的。

与此同时,在Julia中,真正的并行多线程被原生支持,使用起来非常简单:

# 多线程之前
for i in eachindex(x)
    y[i] = a * x[i] + y[i]
end
# 多线程之后
Threads.@threads for i in eachindex(x)
    y[i] = a * x[i] + y[i]
end

运行代码时,您可以指定在系统中可用的线程数。

与C代码的轻松集成

从Julia调用C代码的过程得到了官方支持,并且比Python更高效、更容易。要调用下面的函数,

#include <stdio.h>int add(int a, int b) {    return a + b;}

调用此函数在Julia中的主要步骤是编写

# 指定函数、返回类型、参数类型和输入。类型前加上 "C"result = ccall(add, Cint, (Cint, Cint), 5, 3)

Python实现这一点要麻烦得多,效率可能更低。特别是因为映射Julia类型和结构到C中更容易。

这的一个重要后果是可以在Julia中运行绝大多数可以输出C代码对象的语言。通常,这些语言有相应的外部知名包。例如,要调用Python代码,可以使用以下方法:

using PyCallnp = pyimport("numpy")# 在Python中创建一个NumPy数组py_array = np.array([1, 2, 3, 4, 5])# 对NumPy数组执行一些操作py_mean = np.mean(py_array)py_sum = np.sum(py_array)py_max = np.max(py_array)

除了安装包外,几乎不需要进行其他设置。使用类似的包也可以调用Fortran、C++、R、Java、Mathematica、Matlab、Node.js等语言编写的函数。

另一方面,可以从Python调用Julia,尽管不像调用Python那样优雅。这可能已经在加速函数的同时避免实现C代码。

标准库

Julia预装了一套包(必须显式加载)。其中包括StatisticsLinearAlgebra包、用于访问互联网的Downloads包,以及用于分布式计算(如Hadoop)的Distribued包,还有用于性能分析(优化代码)的Profile包,以及用于单元测试的Tests包和用于包管理的Pkg包,以及许多其他包。

我必须说,我是一个热衷于使用Python并在Python中开发过多个包的用户。Python中的第三方包”Setuptools”与Julia中的Pkg没有可比性,Julia的安装和使用都要更加简洁和易用。我始终无法理解为什么Python没有自己的包管理和测试工具。这些在编程语言中是基本需求。

Photo by Tom M on Unsplash

Julia是通用语言

介绍

如果你以前接触过Julia,那你可能会认为Julia是一种特定领域的语言,其中科学计算是该领域。确实,Julia的设计旨在表达和高效地进行科学计算,但这并不意味着它不是一种通用语言。它是一种专为科学计算而设计的通用语言。语言的通用性程度是有所区别的。例如,Julia可用于数据科学和机器学习、Web开发、自动化和脚本编写、机器人技术等领域,但目前还没有像Python中的Pygame那样支持游戏开发的成熟包。即使Julia中的Genie.jlFlask非常接近,但它可能仍无法与Django等功能更全面的框架媲美。简而言之,即使Julia目前不像你希望的那样通用,但它是建立在这样的思想下,并预计会逐渐实现这一点。

自动化和脚本编写

刚提到Julia可用于自动化和脚本编写,值得指出的是它还具有优雅的类似命令行的语法。

例如,你可以在Julia中执行以下一组文件系统和进程操作:

# 创建目录mkdir("my_directory")# 更改当前工作目录cd("my_directory")# 列出当前目录中的文件println(readdir())# 删除目录rm("my_directory"; recursive=true)# 检查文件是否存在if isfile("my_file.txt")    println("文件存在。")else    println("文件不存在。")end# 在Julia中运行简单的shell命令run(`echo "Hello, Julia!"`)# 捕获shell命令的输出结果result = read(`ls`, String)println("当前目录的内容:$result")

注意与在终端中输入的命令的相似性。

An Alternative to Starry Night Digital Art — Generated by author using DALLE 2

Julia可以广泛扩展

介绍

LISP编程语言中一个美妙的特性是它是同源的。也就是说,代码可以像数据一样被处理,因此,普通开发人员可以通过代码为语言添加新的功能和语义。Julia也是设计成同源的。例如,记得我说过Julia只支持多分派吗。看起来有人开发了一个名为ObjectOriented.jl的包,允许开发人员在Julia中编写面向对象的程序。再举一个例子,如果你创建了新的类型,很容易重载基础函数和运算符(它们只是函数),以使其适用于你的新类型。

Julia对宏的支持是这种扩展性可能性的一个主要原因。宏可以看作是在程序的解析阶段返回要执行的代码的函数。假设你定义了以下宏:

macro add_seven(x)    quote        $x + 7    endend

类似于函数,这使您能够以以下方式调用它:

x = 5@add_seven x

返回结果为12。在幕后发生的是,在解析时间(编译之前),宏执行,返回代码5 + 7,在编译时间进行评估后变为12。可以将宏看作是动态执行CTRL+H(查找和替换)操作的方式。

对于另一个实际用例,假设您有一个包含10个有用方法的程序包,并且您想要为该程序包添加一个新接口,这意味着您必须为每个方法编写10个结构体。假设根据相应的函数编写任何一个结构体都是系统化的,那么您只需要编写一个循环遍历这10个函数以为这10个结构体生成代码的宏。实际上,您编写的代码将等效于以一种通用方式编写一个结构体,因此可以节省时间。

宏的存在使得更多的魔法成为可能。例如,如果您回忆上面的内容,我们能够使用Threads.@threads宏将for循环多线程化。要测量函数调用的执行时间,只需执行@time func(),如果您使用了<benchmarktools包,那么@benchmark func()将多次调用该函数以返回时间的统计数据,甚至是一个小的图表。如果您了解memoization是什么,可以使用简单的@memoize宏将其应用于任何函数。无需以任何方式修改函数。还有@code_native func(),它将显示函数生成的本地代码,还有其他的宏在编译过程中显示代码的其他表示形式。</benchmarktools

总结

事实证明,我们所讨论的所有语言功能最初都是 Julia 的计划的一部分。正如 Julia 的网站上所述,这是语言的<vision:

“我们希望一种开源的语言,具有宽松的许可证。我们希望具有C的速度和Ruby的动态性。我们希望一种具有像Lisp这样真正的宏的语言,但具有明显的、熟悉的数学符号,例如Matlab。我们希望像Python一样适用于一般编程,像R一样方便统计,像Perl一样自然处理字符串,像Matlab一样强大的线性代数,像shell一样擅长将程序粘合在一起。某种程度上,它应该易于学习,但能够令最严肃的黑客们满意。我们希望它是交互式的,我们希望它是编译的。”

阅读完这个故事,您应该能够反思下面的每一个在愿景中提到的词。

希望通过阅读这篇文章,您能更多了解Julia语言,并考虑学习该语言。下次再见,再见。

</vision:

Leave a Reply

Your email address will not be published. Required fields are marked *