Julia 编程基础(三):一文掌握 Julia 的变量与常量

阅读数:4903 2019 年 8 月 26 日 10:20

Julia编程基础(三):一文掌握Julia的变量与常量

本文是《Julia 编程基础》开源版本第 3 章:变量与常量。本书旨在帮助编程爱好者和专业程序员快速地熟悉 Julia 编程语言,并能够在夯实基础的前提下写出优雅、高效的程序。这一系列文章由 郝林 采用 CC BY-NC-ND 4.0 知识共享 署名 - 非商业性使用 - 禁止演绎 4.0 国际 许可协议)进行许可,请在转载之前仔细阅读上述许可协议。

本书的示例项目名为 Programs.jl,地址在这里。其中会包含本书所讲的大部分代码,但并不是那些代码的完全拷贝。这个示例项目中的代码旨在帮助本书读者更好地记忆和理解书中的要点。它们算是对书中代码的再整理和补充。

Julia 是一种可选类型化的编程语言。Julia 代码中的任何值都是有类型的。而一个区别在于,一个值的类型是由我们显式指定的,还是由 Julia 在自行推断之后附加上的。例如:

复制代码
julia> typeof(2020)
Int64
julia>

我调用了一个名为typeof的函数,并把2020作为参数值传给了它。2020本身是一个代表了整数值的字面量(literal)。虽然我没有明确指定这个值是什么类型的,但是 Julia 却可以推断出来。经过推断,Julia 认为它的类型是Int64——一个宽度为 64 个比特(bit)的有符号的整数类型。Int64本身是一个代表了类型的标识符,也可以称之为类型字面量。在一个 64 位的计算机系统当中,Julia 程序中的整数值的默认类型就是Int64。如果你使用的是 32 位的计算机系统,那么这里回显的内容就应该是Int32

我们在做运算的时候,不太可能只使用值本身去参与运算。因为总有一些中间结果需要被保存下来。就算我们使用计算器去做一些稍微复杂一点的算术运算,肯定也是如此。对于计算机系统而言,那些中间结果可以被暂存到某个内存地址上。当需要它的时候,我们再去这个地址上去取。

内存地址记忆起来会比较困难。所以,一个更好的方式是,使用一个代号(或者说标识符)去代表某个中间结果。或者说,把一个标识符与一个值绑定在一起,当我们输入这个标识符的时候就相当于输入了这个值。这种可变的绑定关系就被称为变量。这个标识符就被称为变量名。

3.1 变量的定义

变量不只能代表所谓的中间结果,而是可以代表任何值。当我们在 REPL 环境中定义一个变量的时候,它就会回显该变量名所代表的值。例如:

复制代码
julia> x = 2020
2020
julia>

在这之后,我们也可以输入这个变量名,以此让 REPL 环境回显它代表的那个值:

复制代码
julia> x
2020
julia>

然而,当我们输入y这个标识符的时候,会发现它无法回显某个值:

复制代码
julia> y
ERROR: UndefVarError: y not defined
julia>

这是因为y还没有被定义,Julia 并不知道它代表了什么。那什么是定义呢?更确切地说,什么是变量的定义呢?我们在前面说过,变量相当于一个标识符与值的绑定。那么,对这种绑定的描述就是变量的定义。在 Julia 中,变量的定义一般由标识符、赋值符号=和值字面量构成。就像我们在前面展示的那样。

3.2 变量的命名

3.2.1 一般规则

Julia 变量的名称是大小写敏感的。也就是说,yY并不是同一个标识符,它们可以代表不同的值。

变量名必须以大写的英文字母A-Z、小写的英文字母a-z、下划线_,或者代码点大于00A0的 Unicode 字符开头。代表数字的字符不能作为变量名的首字符,但是可以被包含在名称的后续部分中。当然,变量名中肯定不能夹杂空格符,以及像制表符、换行符这样的不可打印字符。

总之,大部分 Unicode 字符都可以作为变量名的一部分。即使你不知道什么是 Unicode 编码标准也没有关系。我们会在后面讨论字符和字符串的时候介绍它。

由此,Julia 允许我们把数学符合当做变量名,例如:

复制代码
julia> δ = 3
3
julia>

你可能会问:怎么才能输入δ?这又涉及到了 LaTeX 符号。简单来说,LaTeX 是一个排版系统,常被用来排版学术论文。因为这种论文中经常会出现复杂表格和数学公式,所以 LaTeX 自有一套方法去表现它们。我们没必要现在就去专门学习 LaTeX。你只要知道,如果需要输入数学符号的话,那么就可以利用 LaTeX 符号。

具体的做法是,先输入某个 LaTeX 符号(比如\delta)再敲击 Tab 键,随后对应的数学符号(比如δ)就可以显示出来了。如果你不知道一个数学符号对应的 LaTeX 符号是什么,那么可以在 REPL 环境的 help 模式下把那个数学符号复制、黏贴到提示符的后面,然后回车。比如这样:

复制代码
help?> δ
"δ" can be typed by \delta<tab>
search: δ
No documentation found.
δ is of type Int64.
Summary
≡≡≡≡≡≡≡≡≡
primitive type Int64 <: Signed
Supertype Hierarchy
≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡≡
Int64 <: Signed <: Integer <: Real <: Number <: Any
julia>

3.2.2 变量名与关键字

虽然变量命名的自由度很大,但还是有一些约束的。其中最重要的是,你不能使用 Julia 已有的单一的关键字作为变量名。更广泛地说,所有程序定义的名称都不能与任何一个单一的关键字等同。Julia 中单一的关键字目前一共有 29 个。我把它们分成了 7 个类别:

  • 表示值的关键字:falsetrue
  • 定义程序定义的关键字:constgloballocalfunctionmacrostruct
  • 定义(无名)代码块的关键字:begindoendletquote
  • 定义模块的关键字:baremodulemodule
  • 引入或导出的关键字:importusingexport
  • 控制流程的关键字:breakcontinueelseelseifforifreturnwhile
  • 处理错误的关键字:catchfinallytry

其中,程序定义指的是变量、常量、类型、有名函数、宏或者结构体。所有的程序定义都是有名称的,或者说可以由某个标识符指代。其中的有名函数和宏也可以被称为有名的代码块。

所谓的无名代码块,与有名的代码块一样也是有明显边界的一段代码,但是并没有一个标识符可以指代它们。注意,我把关键字end划分为了定义(无名)代码块的关键字。但实际上,我们在定义有名函数、宏、结构体和模块的时候也需要用到它。

另外,模块也是一个有名的代码块。并且,一个 Julia 程序的主模块(即入口程序所属的那个模块)就是它的最外层代码块。在 Julia 中,并没有一个可以囊括所有代码的全局代码块。这与很多主流的编程语言都是不同的。我们可以说,Julia 程序就是由一些具有依赖关系的模块组成的。换句话讲,Julia 程序中的代码要么在主模块中,要么在主模块依赖的那些模块中。我会在后面专门用一章来讲解模块。

关于以上这些关键字的用法,我们在后面也都会讲到。所以你现在只要知道它们不能被作为变量名就可以了。

3.2.3 变量名与作用域

我们在前面说过,Int64是一个代表了类型的标识符。又因为这个标识符的作用域相当于是全局的,所以我们设定的变量名就不能与它重复。更宽泛地讲,我们的变量名肯定不能与任何一个 Julia 预定义的类型的名称相同。

什么叫作用域呢?其含义是,标识符可以被其他代码直接引用的一个区域。一旦超出这个区域,这个标识符在默认情况下就是不可见的。比如,我们在第 1 章定义过一个叫做MyArgs的模块,并且其中有一个名叫get_parameter的函数。当时的情况是,我们无法在这个模块之外直接使用这个函数的本名来引用它。如果你翻回去看的话,就能见到我们的引用方式是MyArgs.get_parameter。这被称为(针对这个get_parameter函数的)限定名。

严格来讲,Julia 中没有任何一个标识符的作用域真正是全局的。但是,由于我们定义的所有模块都隐含了Core模块,所以在该模块中直接定义的那些标识符的作用域就相当于是全局的。Int64以及其他代表了某个类型的标识符都在其中。

因此,我们设定的变量名肯定不能与Core模块中那些基本的程序定义重名。

关于作用域,还有一些内容是我们必须要知道的。Julia 中的很多代码块都会自成一个作用域,比如模块就是这样。但由于这会涉及到一些我们还未曾讲到的重要知识,所以我把它们放到了流程控制的那一章。那里会有关于变量作用域的更多内容。

3.3 变量的类型

我们都知道,一个变量的值是可变的。但你可能不知道的是,在 Julia 中,变量的类型也是可以改变的。更确切地说,我们可以为同一个变量先后赋予不同类型的值。Julia 的变量实际上是没有类型的,只有值才有类型。但为了描述方便,我们仍然会说“变量的类型”。你要记住,它真正的意思是“变量中的值的类型”。下面举一个例子:

复制代码
julia> y = 2020
2050
julia> y = "2050"
"2050"
julia>

虽然我们还没有专门讲类型,但在这里可以先形成一个简单的认知。在上例中,我先把一个Int64类型的值2020赋给了变量y。紧接着,我又把一个String类型(也就是字符串类型)的值"2050"赋给了这个变量。注意,字符串类型的值一般都是由一对双引号包裹的。

显然,在第二次赋值之前和之后,变量y的类型是不同的。虽然 Julia 允许我们随意改变一个变量的类型,但是这样做往往会对程序的性能造成不小的负面影响。所以官方在大多数情况下还是不推荐这种做法的。我们可以利用语法规则来约束对变量类型的随意变更,或者说约束赋予变量的那些值的类型。更具体地讲,我们可以在编程的时候用附加类型标识符的方式让变量的类型固定下来,比如:y::Int64

操作符::可以将类型标识符附加到程序中的变量和表达式之后。下面是它的两个重要用途:

  1. 它可以用于类型标注,为编译器提供额外的类型信息,从而在某些情况下提高程序的性能。
  2. 它可以用于类型断言,判断某个值或者某个表达式的结果是否是某个类型的。

3.3.1 类型标注

当用于类型标注时,操作符::及其右侧的类型标识符就意味着这个变量将永远是某个类型的。我们赋予这个变量的每一个值都将被自动地转换为定义该变量时所声明的那个类型的值。例如,我们有这样一个函数:

复制代码
function get_uint32(x)
y::UInt32 = x
y
end

我先来简单地解释一下:函数的定义一般以关键字function开头,并以关键字end结尾,后者独占一行。在function右侧的是函数的名称,这两者之间需要用一个空格分隔。这里的函数名称是get_uint32。而紧挨在函数名称右侧的是由圆括号包裹的函数参数,这里唯一的函数参数是x

被夹在首行和尾行之间的是函数体,可以由若干个表达式组成。并且,如果没有显式指定,那么最后一个表达式的求值结果就将是这个函数的结果。在这里,y的值就是函数get_uint32的结果。这个函数所做的事情就是,把由参数x代表的那个值赋给了局部变量y,然后把y的值作为函数结果返回。

所谓的局部变量是指,没有直接定义在模块中而是定义在了模块包含的某个代码块中的那些变量。在上例中,我们在get_uint32函数中定义的参数x和变量y都属于局部变量。相对应的,全局变量就是直接定义在模块中的那些变量。更宽泛地讲,直接定义在模块中的变量、常量、类型、有名函数、宏和结构体可以被统称为全局程序定义。注意,这里的“全局”是针对模块而言的,而不是针对所有的代码。

言归正传。我们没有对参数x附加类型标注,所以原则上x代表的可以是任何一个类型的值。但我们把变量y的类型声明为了UInt32。也就是说,该变量的值必须是UInt32类型的。UInt32类型是一个宽度为 32 个比特(bit)的无符号的整数类型。如此一来,当我们把x的值赋给y时,就有可能引起自动的类型转换。例如:

复制代码
julia> get_uint32(2020)
0x000007e4
julia> typeof(get_uint32(2020))
UInt32
julia>

我们已经知道,整数值2020在默认情况下的类型是Int64。因此,我在调用get_uint32函数的时候把2020作为参数值传入,就一定会引起相应的类型转换。这次调用得到的结果值是0x000007e4,是一个用十六进制表示的整数值。在 Julia 中,无符号的整数值一般都是这样表示的。如果我们再把0x000007e4转换为有符号整数值的话,就会是原先的2020

复制代码
julia> Int32(0x000007e4)
2020
julia>

每一个整数类型都是有一个表示范围的。或者说,一个整数类型的值只能表示在一定范围之内的整数。比如,UInt32类型的值就无法表示负数。因此,如果我们把-2020传入get_uint32函数,就会引发一个错误:

复制代码
julia> get_uint32(-2020)
ERROR: InexactError: trunc(UInt32, -2020)
Stacktrace:
[1] throw_inexacterror(::Symbol, ::Any, ::Int64) at ./boot.jl:583
[2] checked_trunc_uint at ./boot.jl:613 [inlined]
[3] toUInt32 at ./boot.jl:697 [inlined]
[4] Type at ./boot.jl:737 [inlined]
[5] convert at ./number.jl:7 [inlined]
[6] get_uint32(::Int64) at ./REPL[36]:2
[7] top-level scope at none:0
julia>

从回显首行的错误信息可知,Julia 抛出了一个InexactError类型的错误。出现这类错误就意味着 Julia 无法把源值(这里是-2020)转换成目的类型(这里是UInt32)的值。另外,trunc是一个函数的名称。Julia 此次正是使用这个函数进行类型转换的。

当我们传入一个浮点数值、字符串值或者其他的UInt32类型无法表示的值的时候,情况也会是类似的。只不过错误的类型和具体信息可能会有所不同。

到了这里,你可能会疑惑:为什么我们讲变量的类型标注还需要定义一个函数呢?直接在 REPL 环境中演示不就好了吗?这实际上涉及到 Julia 语言的一个小缺陷。

在 Julia 程序中,我们无法为全局变量添加类型标注。

还记得吗?所谓的全局变量就是直接定义在模块中的那些变量。我们编写的任何 Julia 代码都会属于某个模块。即使我们没有显式地把它们包含在某个模块中,也会是如此。更具体地讲,我们在 REPL 环境中输入的代码默认都属于Main模块。这与我们直接在源码文件中写入 Julia 代码是一样的。正因为如此,我们才能在这样的环境中仅通过名称就可以引用到之前写入的程序定义。

由此可知,我们在 REPL 环境中直接定义附带类型标注的变量是行不通的,就像这样:

复制代码
julia> x::UInt32 = 2020
ERROR: syntax: type declarations on global variables are not yet supported
julia>

一个可以绕开这个缺陷的方法是,使用Ref{T}类型的常量作为替代。“Ref”是 Reference 的缩写,可以被翻译为“引用”。Ref{T}类型本身是一个参数化的类型,其中的那对花括号就是标志。而花括号中的T表示的就是类型参数(type parameter),它在这里指代被引用的值的类型。我们可以在实际应用中自行设定这个类型。示例如下:

复制代码
julia> const xref = Ref{UInt32}(2020)
Base.RefValue{UInt32}(0x000007e4)
julia>

我使用关键字const定义了一个名为xref的常量,并把一个Ref{UInt32}类型的值赋给了它。由这个类型的字面量可知,我规定xref引用的值必须是UInt32类型的。另外,在最右侧的圆括号中的2020就是xref初始引用的值。也就是说,xref的值中又引用了另外一个值,而后者才是我们真正需要的。

由于xref是一个常量,所以如果我们试图改变它的类型,就会引发一个错误。不过,我们仍然可以改变xref引用的值,比如:xref[] = 2050。这里的操作符[]就是用来读出或写入Ref{T}类型值所引用的值的。如此一来,我们就相当于拥有了一个具有固定类型的全局变量。关于常量的更多知识,我们到后面就会讲到。

不过无论怎样,这终归只是绕开缺陷,而不是修补缺陷。Julia 语言的官方团队已经在计划对此进行修补了。预计在之后的某个 1.x 版本,我们就可以直接定义带有类型标注的全局变量了。

3.3.2 类型断言

当用于类型断言时,操作符::可以被解读为“A 是否为 B 的一个实例”。其中 A 代表该操作符左侧的值,而 B 则代表操作符右侧的类型。例如:

复制代码
julia> "abc"::String
"abc"
julia> "abc"::Char
ERROR: TypeError: in typeassert, expected Char, got String
Stacktrace:
[1] top-level scope at none:0
julia>

我先利用操作符::判断值"abc"是否为String类型的一个实例,并得到回显"abc"。这就说明该类型断言成功了(或者说通过了)。注意,在这种情况下,若有必要 Julia 会对::左侧的值进行类型转换,把它转换为处于::右侧的那个类型的值。这是通过调用convert函数实现的。

之后,我又判断"abc"是否为Char类型的一个实例,并使得 Julia 报错。所以此类型断言失败(或者说未通过)。只用眼睛观察,我们就可以知道"abc"是一个字符串类型的值。而且,它并不是一个单一的字符。字符类型的值只能代表一个字符,并且需要由一对单引号包裹。

注意,像StringChar这样的类型都属于具体类型。Julia 中还有一种类型叫做抽象类型。它们的名称很多都有着明显且一致的前缀,比如:AbstractStringAbstractChar。在进行类型断言的时候,如果右侧的类型是一个具体类型,那么只有左侧的值是该类型的一个实例,断言才会成功。而如果右侧的类型是一个抽象类型,那么只要左侧的值是这个抽象类型的任意一个子类型的实例就可以使断言成功。又由于 Julia 中的抽象类型都是不能被实例化的,因此这个子类型也必然是一个具体类型。下面看一个例子:

复制代码
julia> "abc"::AbstractString
"abc"
julia> "abc"::AbstractChar
ERROR: TypeError: in typeassert, expected AbstractChar, got String
Stacktrace:
[1] top-level scope at none:0
julia>

因为StringAbstractString的子类型,所以第一个类型断言成功。但是,由于String并不是AbstractChar的子类型,因此第二个类型断言失败。

我们几乎可以把类型断言用在任何地方,只要其左侧的是一个值或是一个表达式就可以。但要注意,一旦断言失败,错误就会被抛出来。程序会因此中断,除非我们合理地处理了错误。如果我们对于某个类型断言非常没有把握,而且不想在断言失败时得到一个错误,那么可以使用isa函数作为替代。例如:

复制代码
julia> isa("abc", AbstractChar)
false
julia>

不过要注意,我们调用isa函数之后只能得到truefalse的结果。

3.4 常量

在 Julia 中,常量是一种特殊的变量。我们可以使用关键字const来定义一个常量:

复制代码
const A = 2020

当需要同时定义多个常量时,我们还可以使用平行赋值法:

复制代码
const W, U, V = 2020, 2030, 2050

在这里,常量WUV的值分别为202020302050

我们已经知道,为全局变量附加类型标注目前是行不通的。实际上,Julia 官方现在不建议我们在程序中使用全局变量。因为全局变量总是会给程序带来不小的性能负担。这正是用于全局变量的值及其类型都是可以被随时改变的。而全局常量可以让这个问题迎刃而解。所以,我们应该使用全局常量,而不是全局变量。这也是 Julia 中很重要的一条编码建议。

相反,在局部,我们应该使用局部变量,而不是局部常量。因为定义一个局部常量完全是没有必要的。Julia 一旦在局部碰到一个不变的变量就会把它优化成常量。你其实用不着专门去记这条编码建议。因为定义局部常量根本就不符合 Julia 的语法规则,例如:

复制代码
julia> function get_uint32(x)
const y::UInt32 = x
y
end
ERROR: syntax: unsupported `const` declaration on local variable around REPL[1]:2
julia>

不过,这个语法规则的制定原因我们还是应该了解的。

另外,与其他的一些编程语言不同,Julia 中的常量的值不仅可以是数值和字符串,还可以是像数组这样的可变对象。也正因为如此,Julia 常量只能保证名称与值之间的绑定是不可变的,但是并不能防止值本身的变化,比如数组中的某个元素值的改变。所以,我们尽量不要把本身可变的值赋给全局常量。

下面,我们来说 Julia 常量的最重要的 3 个特性。其中的一个比较反直觉的特性是:我们似乎可以改变一个常量的值。更明确地说,我们对常量的重新赋值只会引发一个警告,而不会得到一个错误。例如,我们看似可以对前面定义过的常量A进行重新赋值:

复制代码
julia> A = 2050
WARNING: redefining constant A
2050
julia>

根据警告的内容可知,Julia 称其为对常量的重新定义。这相当于放弃了以前的常量定义,而采用了新的定义。那么,常量的重新定义与我们之前说的重新赋值到底有什么不一样呢?我们可以通过下面的示例进行理解。

复制代码
julia> const C = 2020
2020
julia> f() = C + 30
f (generic function with 1 method)
julia> f()
2050

我先定义了一个名称为C、值为2020的常量,紧接着又用一种简易的方式定义了一个名称为f的函数。这个函数的功能很简单,即:在常量C代表的值之上再加30并将结果返回。现在,我们重新定义常量C

复制代码
julia> C = 2030
WARNING: redefining constant C
2030

然后,再次调用函数f

复制代码
julia> f()
2050
julia>

可以看到,调用f函数后得到的结果依然为2050。这是因为函数f使用的仍然是在它之前定义的那个常量C,而不是我们重新定义的常量C。我们可以想象,如果真有一种方法可以对常量C进行重新赋值的话,那么再次调用f函数肯定不会得到这样的结果。

因此,在 Julia 中,我们只能对常量进行重新定义,而不能进行重新赋值。正因为常量的重新定义所带来的效果有些反直觉,所以我们最好还是不要重新定义常量。为此,我们还应该让常量的名称看起来特别一些,比如全大写,以避免之后的误操作。我们还要注意程序输出中的此类警告,并及时纠正这种不好的做法。

Julia 常量的第二个重要特性是,如果我们在重新定义常量的时候试图赋予它一个不同类型的值,那么就会得到一个错误。例如:

复制代码
julia> C = "2020"
ERROR: invalid redefinition of constant C
Stacktrace:
[1] top-level scope at none:0
julia>

注意,这里报出的错误是“对常量C的重新定义无效”,而不是“不能改变常量C的类型”。所以,这里的规则是,在对常量进行重新定义时只能赋予一个与前值类型相同的值。

基于此,常量的第三个重要特性就比较好理解了。这个特性就是,如果在重新定义常量时赋予它相同的值,那么既不会引发警告也不会导致错误报告。比如:

复制代码
julia> const D = "2020"
"2020"
julia> D = "2020"
"2020"
julia> d = "2020"
"2020"
julia> D = d
"2020"
julia>

不过,需要注意,这只是针对不可变对象(或者说本身不可变的值)而言的。对于可变对象(比如数组),即使前后两个值看起来相同,Julia 也照样会发出警告。例如:

复制代码
julia> const E = ["2020"]
1-element Array{String,1}:
"2020"
julia> E = ["2020"]
WARNING: redefining constant E
1-element Array{String,1}:
"2020"
julia>

所以,还是那句话,我们尽量不要把本身可变的值赋给常量。

由以上特性可知,常量看似可以被当做类型固定的全局变量来使用。但实际上,对常量的重新定义会埋下隐患,而且由此引发的程序错误将会很难排查和定位。所以,我们可以使用常量来存储全局性的对象,但最好不要对它进行重新定义。另外,我们尽量不要把可变对象赋给常量。

3.5 小结

我们在这一章主要讲的是变量。我们首先从可以与变量绑定在一起的对象——值——讲起。在 Julia 中,任何值都是有类型的。我们在定义一个变量的时候,可以为它添加标注类型(涉及到操作符::),也可以让 Julia 自行推断其类型。我们推荐前者的做法,因为那样做会对程序的性能有好处。不过要注意,我们是无法为全局变量添加类型标注的。

代表变量的标识符、赋值符号=和代表值的字面量共同组成了变量的定义。它把一个值与一个标识符(或称变量名)绑定在了一起。此后,这个标识符就是那个值的代表。

Julia 对变量名有所限制,但是这种限制还是很宽松的。大部分 Unicode 字符都可以被包含在内。不过,变量名不能与 Julia 中任何一个单一的(或者说单词的)关键字重复,也不能与处在同一个作用域中的其他程序定义的名称重复。

一旦理解了变量,常量的概念就很好理解了。因为 Julia 的常量就是一种特殊的变量。只不过,常量只能被定义在全局,而不能被定义在局部。另外,我们需要特别注意对常量的重新定义。

除了上述知识,我们还在本章顺便说明了程序定义、代码块、作用域等概念的含义。你最好记住它们,因为我们在后面还会有所提及。

值、变量以及类型是 Julia 中最基础的概念。没有了它们,我们就无法编写 Julia 程序。别着急,我们马上就会讲到第三个基础概念——类型。

原文链接:

https://github.com/hyper0x/JuliaBasics/blob/master/book/ch03.md

系列文章:

Julia 编程基础(一):初识 Julia,除了性能堪比 C 语言还有哪些特性?

Julia 编程基础(二):开发 Julia 项目必须掌握的预备知识

评论

发布