{{TOC}}
从本章开始,我们就要讲怎样把 Julia 代码组织为真正的程序了。经过良好组织的代码可以在不同的具体情况下执行不同的分支或流程、应对各种异常情况以保持足够的稳定,以及形成通用的函数、模块、程序包等以便自己重复使用甚至供他人使用,等等。
我们在前面编写的大部分程序都只能算是脚本程序。我们之前给出过脚本程序的简单定义,即:以普通文本的形式保存的、实现了一定的处理逻辑的计算机指令片段。这样的指令片段往往会被保存在一个或少数几个文件之中,并可以通过配套的工具(如bash
、python
、julia
等)来启动和执行。与真正的程序相比,脚本程序的最大特点就是简单。它们通常是由人们为了做演示或者执行简易的任务而编写出来的。
其实,我们在前面已经接触过了一些可以控制流程的代码。例如,我们多次定义过函数以及相应的衍生方法、使用过可以进行短路条件求值的操作符&&
、编写过可实现循环的for
语句和可实现条件判断的if
语句等。
简单地回顾一下,我们在上一个部分的开头讲过,所谓的代码块指的就是有着明显边界的代码片段。一些代码块会拥有自己的名称(或者说可指代它的标识符),如函数、模块等。我们称之为有名的代码块。与之相对应的是无名代码块。另外,Julia 中的很多代码块都会自成一个作用域,如for
语句、函数等。一旦我们学会了编写各种各样的代码块,就可以有能力写出实现较复杂功能、拥有一定规模的真正的程序了。
这里再说一下无名代码块,即:没有名称的代码块。像for
语句和if
语句就都属于无名代码块,只不过它们都不是最简单的那一种。请记住,虽然我们可以把无名代码块直接写在 REPL 环境或者源码文件中(就像之前那样),但是为了有效的组织和再次的使用,我们往往会在正式编写程序的时候把它们写在函数里。当然了,为了方便演示,我在后面依然会尽量利用 REPL 环境来编写和执行这些代码块。
下面,我们就从最简单、最直接的无名代码块讲起。
还记得吗?我在第一章的最开始就已经在使用英文分号;
来分隔多个并列的表达式了。例如:
julia> a = 5 * 8; b = a^2
1600
julia>
这时,Julia 会(从左到右)逐个地对这些表达式进行求值,并把最后一个表达式的结果值作为最终的求值结果。
这与我们让每一个表达式都独占一行好像并没有什么区别。但是,像上面这样的并列的表达式却可以被称为代码块。你可能会说,“它不应该是一个代码块,因为它没有明显的边界”。实际上,这样的并列表达式是有边界的,只不过它的边界可以被省略不写罢了。它的边界是一对圆括号。
并列表达式可以被作为一个整体出现在赋值符号=
的右边,例如:
julia> c1 = (a = 5 * 8; b = a^2)
1600
julia> c1
1600
julia>
这就相当于我们把这个并列表达式的最终结果值赋给了变量c1
。注意,这里的圆括号就不能被省略了,否则会引起歧义。
另外,我们还可以使用关键字begin
和end
对并列表达式进行包装,以形成边界更加清晰的代码块。示例如下:
julia> begin a = 5 * 8; b = a^2 end
1600
julia> c2 = begin a = 5 * 8; b = a^2 end
1600
julia> c2
1600
julia>
这样的代码块也被称为begin
代码块。在这里,begin
代码块的优势在于,当并列表达式中的子表达式过多时,它可以被分成多行来编写,而其边界会依然清晰。如:
julia> c2 = begin
a = 5 * 8
b = a^2
end
1600
julia>
虽然我们也可以让由圆括号包裹的并列表达式占据多行,但是这样的代码看起来就要简陋很多了:
julia> c2 = (a = 5 * 8;
b = a^2)
1600
julia>
最后,无论是纯粹的并列表达式,还是begin
代码块,我们都可以称之为复合表达式(compound expression)。它们的主体都只是多个子表达式的排列而已,并没有额外的处理逻辑在里面。这正是我说它们是最简单的主要原因。
我们在很早以前就已经见识过if
语句了,如:
# 假设在这之前已经定义了变量`name`,并为它赋予了某个值。
if name == ""
name = "handsome"
end
if
语句总是以if
关键字开头,并以end
关键字结尾。与if
关键字处于同一行的必须是一个结果类型为Bool
的表达式。我们通常称这样的表达式为条件表达式。在上面的这个例子中,name == ""
显然就是条件表达式。只有当它的结果值为true
时,if
语句中的子语句,即name = "handsome"
,才会被执行。否则,其中的子语句就会被跳过而不执行。这里的子语句的数量可多可少,也可以是零个。因此,我们也可以称之为子语句组,或if
子语句组。
当然了,Julia 中的if
语句远不止这么简单。我们也可以让if
语句在条件不满足的时候,即条件表达式的结果为false
时,执行指定的子语句组。比如这样:
if name == ""
name = "handsome"
else
name = "dear " * name
end
在else
关键字和end
关键字之间的就是当条件不满足时会执行的子语句。与if
子语句组一样,这里的子语句的数量也可以是任意的。我们可称之为else
子语句组。
另外,if
语句中的条件表达式可以不止一个。如果一条if
语句拥有多个条件表达式,那么它们就必须各自独占一个分支,比如:
if name == ""
name = "handsome"
elseif name == "Robert"
name = "my master"
else
name = "dear " * name
end
由关键字if
、elseif
和else
引领的分支可以被分别叫做if
分支、elseif
分支和else
分支。前两者都必须携带条件表达式,而后者则不能携带条件表达式。其中的elseif
分支可以有任意个,并且必须处于if
分支和else
分支之间。
顾名思义,elseif
分支属于一种备选分支。只有在前一个分支中的条件不满足时,它的条件表达式才会被求值。与if
分支一样,一旦elseif
分支中的条件满足,那么它包含的子语句就会被执行,而后续的分支则都会被跳过。从这个角度讲,else
分支可以被视为默认分支。因为只有在前面所有分支中的条件都不满足时,它包含的子语句才会被执行。
正因为如此,我们可以说,在if
语句里最多只会有一个分支被选中并执行。或者说,这些分支的执行是互斥的。而且,当默认分支不存在时,还可能会出现所有的分支都未被选中的情况。
最后,在编写if
语句的时候,我们还有几点需要特别的注意。
第一点,if
语句并不会自成一个作用域。换句话说,我们在其内部编写的那些有名称的程序定义都可以被外界的代码直接访问到。示例如下:
julia> name = "Robert"
"Robert"
julia> if name == ""
title = "handsome"
elseif name == "Robert"
title = "my master"
else
title = "dear " * name
end
"my master"
julia> title
"my master"
julia>
可以看到,title
是一个我在if
语句里面定义的变量。而且,在这条if
语句执行之后,我仍然可以在 REPL 环境中直接引用到这个变量。这就如同该变量被直接定义在了 REPL 环境中那样。更宽泛地讲,对于这种在if
语句里面编写的程序定义,其作用域并非它们所属的if
语句所占据的区域,而是包含了这条if
语句的那个作用域。
如果你还使用过其他的编程语言的话,那么就很可能会发觉 Julia 的if
语句在这方面与一些主流的编程语言并不相同。这也是我在此做出特别提示的主要原因。
第二点,你也许已经发现了,上例中的if
语句是有结果值的,即:"my master"
。实际上,在 Julia 中,if
语句也属于一种复合表达式。我们同样可以把它赋给一个变量,例如:
julia> result = if name == ""
title = "handsome"
elseif name == "Robert"
title = "my master"
else
title = "dear " * name
end
"my master"
julia> result
"my master"
julia>
这显然要比使用begin
代码块复杂一些,因为if
语句还会包含它自己的处理逻辑。if
语句的结果值总会是实际执行的那个分支中的最后一条子语句所呈现的结果。在上例中,这个结果就是变量title
所代表的值。
第三点,在if
语句的条件表达式中可以存在多个条件。这就会涉及到多个条件之间的连接方式及其判断结果的合并方式。我们之前使用过的代表逻辑与的操作符&&
就可以用于此处。示例如下:
# 假设在这之前已经定义了变量`action`和`weather`,并为它们赋予了值。
# 假设在这之前已经定义了变量`prompt`。
if action == "walk" && weather == "rain"
prompt = "Don't forget to bring an umbrella."
end
这条if
语句中的条件有两个,即:动作为“散步”和天气为“雨”。它们由操作符&&
相连。所以,这个条件表达式所代表的整体条件就是,动作为“散步”并且天气为“雨”。
但要注意,只要多个条件之间均由&&
相连,对条件的判断就会形成短路条件求值。更具体地说,Julia 会从左到右地依次判断条件表达式中的每一个条件,一旦它判断当前条件的结果为false
,它就会停下来并忽略掉对后续条件的判断,进而直接裁决该条件表达式的最终结果为false
。这就是“短路”一词所代表的求值行为。所以说,对于这样的条件表达式,只有其中所有条件的判断结果都为true
,它的最终结果才可能是true
。下面有一些示例可用于验证这个求值的过程:
julia> action = "walk"; weather = "rain"; prompt = "";
julia> is_walk(action) = (println("Check action (1)"); action == "walk")
is_walk (generic function with 1 method)
julia> is_rain(weather) = (println("Check weather (1)"); weather == "rain")
is_rain (generic function with 1 method)
julia> if is_walk(action) && is_rain(weather)
prompt = "Don't forget to bring an umbrella."
end
Check action (1)
Check weather (1)
"Don't forget to bring an umbrella."
julia> action = "sleep";
julia> if is_walk(action) && is_rain(weather)
prompt = "Don't forget to bring an umbrella."
end
Check action (1)
julia>
函数is_walk
和is_rain
包含的第一条语句都是打印语句。这样我们就可以知道这些函数是否被调用了。可以看到,在我把变量action
的值改为"sleep"
之后,那条if
语句中的第二个条件(即is_rain(weather)
)就不再有被求值的机会了。这是因为该语句中的第一个条件(即is_walk(action)
)的求值结果变成了false
,从而造成了“短路”。
我们再来看一个很不一样的例子:
julia> is_sleep(action) = (println("Check action (2)"); action == "sleep")
is_sleep (generic function with 1 method)
julia> is_sunny(action) = (println("Check weather (2)"); action == "sunny")
is_sunny (generic function with 1 method)
julia> if is_sleep(action) || is_sunny(weather)
prompt = "The idea looks good."
end
Check action (2)
"The idea looks good."
julia>
这条if
语句也包含了两个条件,即:动作为“睡觉”和天气为“晴”。但不同的是,这两个条件是由操作符||
相连的。所以,其整体条件就是,动作为“睡觉”或者天气为“晴”。
操作符||
同样可用于短路条件求值。但正如我刚刚所讲,它代表的是“或者”,而不是“并且”。Julia 仍然会从左到右地依次判断条件表达式中的每一个条件,但只要它发现当前条件的判断结果为true
,它就不会再继续做判断了,而是直接裁决该条件表达式的最终结果为true
。也就是说,对于这样的条件表达式,只有其中所有条件的判断结果都为false
,它的最终结果才可能是false
。
含有多个条件的条件表达式可以是很复杂的。这主要是因为其中可以同时出现&&
和||
。并且,表达式的编写者还可以利用圆括号改变条件求值的默认次序。下面是一个还算简单的示例:
julia> action = "drive"; weather = "rain"; road_condition = "bad"; prompt = ""
""
julia> if weather != "sunny" && (road_condition != "good" && (action == "ride" || action == "drive"))
prompt = "Please pay attention to traffic safety."
end
"Please pay attention to traffic safety."
julia>
在一般情况下,为了提高代码的可读性和逻辑的清晰性,我们往往会在条件表达式中添加一些必要的圆括号。即便这些圆括号对于条件的判断次序没有影响,也是如此。当然了,无论怎样,我们都不应该编写过长的条件表达式。如果你编写的条件表达式很长,那么很可能就说明你应该对它进行整理(或者说重构)了。
好了,我再复述一下我们需要特别注意的三点,即:if
语句不会自成一个作用域、if
语句属于一种复合表达式,if
语句中的条件表达式可以包含多个条件(实际上,所有的条件表达式都是如此)。希望你能够在编写if
语句的时候想起它们。
对于for
语句,我相信你已经不会感觉到陌生了。我们在前面使用for
语句迭代过不少的容器。例如:
julia> for e in [[1,2] [3,4] [5,6]]
print(e, " ")
end
1 2 3 4 5 6
julia>
又例如:
julia> for (k, v) in Dict([(1,"a"), (2,"b"), (3,"c")])
print("$(k)=>$(v) ")
end
2=>b 3=>c 1=>a
julia>
怎么样?想起来了吗?
当然,我们还可以使用for
语句迭代任何其他的可迭代对象,就像这样:
julia> for e in "Julia 编程"
print(e)
end
Julia 编程
julia> for e = 1:10
print("$(e) ")
end
1 2 3 4 5 6 7 8 9 10
julia>
关于for
语句可以迭代字符串就不用我多说了吧?它会依次地迭代出字符串中的每一个字符。
在这里的第二段代码中,我们迭代的是一个类型为UnitRange{Int64}
的可迭代对象。这类对象用于表达一种数值序列。这种序列中的任意两个相邻元素值的差总会是1
。我们在以前其实已经多次使用过这种序列。只不过,我还没有正式介绍过它。
如上所示,我使用值字面量的方式表示了这类对象。更具体地说,1:10
表示的是一个从1
开始、到10
结束且相邻值间隔为1
的数值序列。因此,我们可以把在英文冒号左侧的数值称为开始值,并把在它右侧的数值称为结束值。
此外,与UnitRange
相似的类型还有StepRange
和LinRange
。简要地说,前者的值用于表示等差序列,而后者的值则用于表示等分序列。例如:
julia> typeof(10:10:30)
StepRange{Int64,Int64}
julia> Array(10:10:30)
3-element Array{Int64,1}:
10
20
30
julia> LinRange(1.2, 2.6, 9)
9-element LinRange{Float64}:
1.2,1.375,1.55,1.725,1.9,2.075,2.25,2.425,2.6
julia>
让我们再把焦点放回到前一个例子中的第二段代码上。我们在之前也说过,符号=
在这里的含义并不是单纯的“赋值”,而是“每一次迭代均赋值”。它与关键字in
的含义是相同。在通常情况下,后者更加常用。不过,在嵌套着迭代多个对象的时候,我们常常使用的是=
而不是in
。示例如下:
julia> for x=1:2, y=10:10:30
println((x, y))
end
(1, 10)
(1, 20)
(1, 30)
(2, 10)
(2, 20)
(2, 30)
julia>
请注意看在关键字for
右边的那些代码。由这些代码可知,这是一个两层的嵌套循环。其中,左侧的x=1:2
代表着外层的循环,而右侧的y=10:10:30
则代表内层的循环。它们之间由英文逗号“,
”分隔。因此,x
和1:2
就分别是外层循环中的迭代变量和被迭代对象,而y
和10:10:30
则分别是内层循环中的迭代变量和被迭代对象。
在进行迭代的时候,for
语句会先迭代一次外层的对象,并把迭代出的值赋给外层的迭代变量。然后,它会暂停对外层对象的迭代,转而去迭代内层的对象,并把每一次迭代出的值都赋给内层的迭代变量。直到对内层对象从头到尾地迭代一遍(或者说遍历一次)之后,它才会再去迭代一次外层的对象。就像这样,迭代一次外层对象、遍历一次内层对象、再迭代一次外层对象、再遍历一次内层对象,交替往复。直至完整地遍历一次最外层的对象,这个嵌套的循环才算执行完毕。
对于这样的嵌套循环,我们说的“一次迭代”通常指的是在最内层对象上的某一次迭代。但是,不要忘了,这样的“一次迭代”的背后还体现着基于那些外层对象的某个迭代状态。比如,在上例中,当for
语句从内层的被迭代对象那里迭代出20
时,外层对象的迭代状态有两个可能。也就是说,与之对应的外层迭代变量的值可能是1
,也可能是2
。至于实际上是哪一个,就要看for
语句正在对内层对象进行第几次遍历了。
反过来讲,由于for
语句每迭代一次外层对象之后都会先遍历一次内层对象,所以当x
的值为1
时,y
的值就可能是10
、20
、30
中的某一个。当x
的值为2
时也是如此。这其实就是在对多个被迭代对象中的元素值进行穷举式的组合,或者说在求多个被迭代对象的笛卡尔积。
在这其中,还有一个需要我们特别注意的规则。对于这样拥有多个被迭代对象的单条for
语句,无论它有多少层嵌套的循环,每当“一次迭代”开始之际,Julia 都会为所有层次上的迭代变量进行赋值。即使这些迭代变量在此次将要被赋予的值与前一次被赋予的值一摸一样,也会是如此。请看下面的示例:
julia> for x=1:2, y=10:10:30
println((x, y))
x = 2
end
(1, 10)
(1, 20)
(1, 30)
(2, 10)
(2, 20)
(2, 30)
julia>
请注意,在这条for
语句里的子语句组中有这样一行代码,即:x = 2
。从表面上看,它会在每一次迭代快要结束的时候修改外层迭代变量x
的值。但事实上,这样做是不会奏效的。其原因就是,Julia 在这里总是会遵循我们刚刚阐述的那个规则。更具体地说,它会在这条for
语句的每一次迭代刚刚开始的时候,依据当前的迭代状态分别对x
和y
进行赋值(或重新赋值)。显然,这会使代码x = 2
所做的更改失效,尤其是在第二次迭代和第三次迭代执行的时候。
然而,当我们使用多条for
语句表达一个嵌套循环的时候,Julia 就不会这样做了。也就是说,在这种情况下,它不会在每一次内层迭代开始的时候再对外层的迭代变量进行赋值。相应的示例如下:
julia> for x in 1:2
for y in 10:10:30
println((x, y))
x = 2
end
end
(1, 10)
(2, 20)
(2, 30)
(2, 10)
(2, 20)
(2, 30)
julia>
我们可以看到,在这个执行结果中,第二个元组和第三个元组里的第一个元素值都变成了2
,而不是原先的1
。这就是代码x = 2
在这里的内层for
语句中所起到的作用。
也许我这样说会更便于你记忆:当嵌套的循环被合并在一起时,其中的迭代变量的值就必定不会受到任何干扰,它们只取决于对应的被迭代对象和当时的迭代状态。而当嵌套的循环是由多条for
语句松散地表达时,上述干扰就很容易发生。
被合并在一起的嵌套循环的另一大优势是,它可以让代码更加简洁。但它的劣势也比较明显,那就是for
语句中只能有一组子语句。如果我们想在多层的迭代之间做点什么的话,这样的for
语句就无能为力了。在这种情况下,我们还是需要使用松散的多条for
语句来表达。例如:
julia> for x in 1:5
if x % 2 == 0
continue
end
for y in 10:10:30
print("($x,$y) ")
end
end
(1,10) (1,20) (1,30) (3,10) (3,20) (3,30) (5,10) (5,20) (5,30)
julia>
在此示例中,外层循环用于对1:5
进行迭代,而内层循环被用来迭代10:10:30
。可以看到,这两层循环是各由一条for
语句来表示的。它们可以包含各自的子语句组。也正因为如此,我可以像上面那样去控制什么时候不遍历内层的被迭代对象。
我使用一条if
语句制定了一个小规则:当x
可以被2
整除时,不要遍历内层的对象,并直接对外层的对象进行下一次迭代。在这里,continue
起到了很重要的作用。
关键字continue
首先会让 Julia 放弃执行当次迭代中剩余的那些子语句。更明确地说,这些子语句处于直接包含这个continue
的那条for
语句之中,并且位于这个continue
的下方。在此示例中,处在这个位置上的子语句只有负责内层循环的那条for
语句。
紧接着,continue
还会让直接包含它的那条for
语句继续对它携带的被迭代对象进行下一次迭代。也就是说,continue
并不会让当前的循环结束,只是让它跳过一些子语句的执行而已。
正是由于那条if
语句和continue
,这个例子的结果中才没有包含整数2
和4
。你可以自己模拟一下这个例子的执行流程,并依次写下它应该打印出的内容,然后再回过头来与实际的执行结果对照一下,看看你是否已经完全理解了这些代码的含义。
现在,让我们再看一个很相似的例子:
julia> for x in 1:5
if x % 3 == 0
break
end
for y in 10:10:30
print("($x,$y) ")
end
end
(1,10) (1,20) (1,30) (2,10) (2,20) (2,30)
julia>
与前一段代码相比,这段代码只有if
语句有所不同。它体现了不同的规则,即:当x
可以被3
整除时,结束当前循环的执行。在这里起到关键作用的是break
。
关键字break
做起事来非常的干脆,它不会去管当前的循环进行到哪一步了,也不会理会当前迭代的执行状态如何,而是直接让 Julia 中断对当前的for
语句的执行。所以,我们在此示例的结果中才看不到整数3
、4
和5
。
我们在编写用于循环的代码时经常会碰到需要continue
或break
的处理流程。所以,你需要记住它们的作用和异同。另外还要注意,它们都只能对当前的那个循环起作用。比如说,当我们在内层循环使用它们时,只有内层的循环才会受到影响,而绝不会波及到外层的循环。相应的示例如下:
julia> for x in 1:5
for y in 10:10:30
if x % 3 == 0
break
end
print("($x,$y) ")
end
end
(1,10) (1,20) (1,30) (2,10) (2,20) (2,30) (4,10) (4,20) (4,30) (5,10) (5,20) (5,30)
julia>
在阅读了这段代码之后,你一定会发现我把那条if
语句搬到了内层的for
语句之中。这个改动看起来很小,但是它对流程的改变却不小。
当外层的迭代变量x
被赋予3
的时候,内层的循环在第一次迭代时就会发现if
语句的条件满足了。这时,break
语句就有了被执行的机会(break
和continue
都可以被看做是仅包含了一个关键字的语句)。它的执行会让当前的循环(也就是内层循环)的执行中止。
然而,外层的循环却不会受到任何的影响,它只会因当次迭代中没有更多的语句可执行而继续进行下一次迭代。在外层循环的下一次迭代执行时,x
的值就不再是3
了。所以那条break
语句就再也没有被执行的机会了。因此,在这个示例的结果中,只有整数3
不会出现。
请注意,对于合并在一起的嵌套循环,或者说拥有多个被迭代对象的单条for
语句,break
和continue
都会直接对整个嵌套循环起作用,而不区分它正处于循环的哪一层。这是理所当然的,不是吗?因为在这样的for
语句中确实也无法识别出循环的层次。
下面,我们再来讨论另外一个很重要的问题——for
语句的作用域。没错,每一条for
语句都会自成一个作用域。关于此,最直观的表现就是,for
语句所声明的迭代变量不能被该语句之外的代码引用到。请看下面的示例:
julia> for x in 1:5
print("$x ")
end
1 2 3 4 5
julia> x
ERROR: UndefVarError: x not defined
julia>
同理,像下面这样做也是不行的:
julia> for x in 1:5
for y in 10:10:30
print("($x,$y) ")
end
if y % 10 != 0
break
end
end
(1,10) (1,20) (1,30) ERROR: UndefVarError: y not defined
# 省略了一些回显的内容。
julia>
与前面的例子差不多,这也是两条嵌套在一起的for
语句。但不同的是,我把if
语句放到了内层的for
语句之后,并且试图在它的条件表达式中引用内层的迭代变量y
。你也看到了,这样做是不可以的。因为这超出了变量y
的作用域。
更宽泛地说,在任何一条for
语句中直接定义的变量的作用域都总会是这条for
语句所占据的区域。就拿上例来说,变量y
的作用域是内层的for
语句所代表的代码块,而变量x
的作用域则是外层的for
语句所代表的代码块。正因为如此,我们在内层的for
语句中是可以直接引用到外层的迭代变量的,但反过来却不行。
一个相关的问题是,如果内、外层作用域各自定义的变量存在重名的情况,那么会发生什么呢?我们可以用一个简单的例子来说明:
julia> for x in 1:3
for x in 10:10:30
print("($x) ")
end
end
(10) (20) (30) (10) (20) (30) (10) (20) (30)
julia>
在这里,内层和外层的迭代变量都叫x
。当内层for
语句中的代码引用x
时,它拿到的只是内层的迭代变量x
。也就是说,在寻常的情况下,这些内层作用域中的代码是无法引用到外层的重名变量的。
当然了,还存在不寻常的情况。这涉及到了关键字outer
。示例如下:
julia> for x in 1:3
for outer x in 10:10:30
print("($x) ")
end
print("[$x] ")
end
(10) (20) (30) [30] (10) (20) (30) [30] (10) (20) (30) [30]
julia>
请注意看,我把关键字outer
添加在了内层for
语句的第一行里,更具体的位置是迭代变量x
的左侧。如此一来,这个x
代表的就不再是一个在内层作用域中新定义的局部变量了,而是一个指代了那个外层的迭代变量的标识符。
从这个示例打印出的内容我们也可以看到,每当内层的for
语句执行结束之后,x
的值都会是30
。这是因为内层for
语句在每一次迭代开始时都在为外层的迭代变量x
赋值。它的最后一次迭代总会把30
赋给外层的迭代变量x
。
至此,outer
关键字在这里所起的作用也就很明朗了,即:让for
语句复用一个在外层作用域中定义的局部变量,并将其作为自己的迭代变量。
现在,让我们稍稍总结一下。我们可以使用for
语句实现循环,还可以用它依次地取出任何可迭代对象中的元素值。一条for
语句中可以有若干个被迭代对象和相应的迭代变量。当一条for
语句中同时存在多个被迭代对象时,我们可以说它实现了一个嵌套的循环。当然,我们也可以把这样的嵌套循环拆成多条for
语句。另外,我们还可以在for
语句中添加continue
语句和break
语句,以达到精细控制的目的。
这里的另一个重点是,每一条for
语句都会自成一个作用域。在一般情况下,for
语句中的迭代变量都是仅属于该语句的局部变量,它们在外界是无法被引用的。不过,outer
关键字可以对这种情况有所改变。正如前文所述。
与for
语句的用途相似,while
语句也可以被用来实现循环。不过,在代码的编写方面,这两者却截然不同。while
语句总是需要携带一个条件表达式。这个条件表达式非常关键,它可以控制当前的循环在什么时候开始,以及在什么时候结束。下面是一个简单的例子:
julia> num = 0;
julia> while num <= 9
global num += 1
print("$num ")
end
1 2 3 4 5 6 7 8 9 10
julia>
在解释这个例子之前,我先讲两个知识点。
第一个知识点很重要。我在前面讲变量和常量的时候说过,Julia 中其实没有任何一个标识符的作用域可以是真正全局的。对于 Julia,所谓的“全局”只是针对于某个模块而言的。Julia 里根本就不存在能够跨越多个模块的(更别提跨越全部模块的)纯粹的全局作用域。即使是被直接定义在Core
模块中的那些程序定义,也是由于 Julia 进行了特殊的处理,才使得它们的作用域看起来像是真正全局的。
在 Julia 程序的上下文中,如果一个变量、常量或者类型等被直接定义在了某个模块当中,那么它们就可以被称为全局程序定义,它们的作用域就是 Julia 所说的全局作用域。但是,你一定要清楚,这里的“全局”的真正含义。
第二知识点很简单。我在讲变量和常量的时候也说过,我们在 REPL 环境中输入的代码默认都属于Main
模块。因此,把这两点综合起来看,我们在 REPL 环境中直接定义的变量、常量、类型、结构体、有名函数等就都属于全局程序定义,而它们的作用域则都是全局作用域。
现在,基于这两点以及之后的结论,我们再来看上面的示例。
我先定义了一个全局的变量num
,并把0
赋给了它。在它下面,与while
关键字处于同一行的就是这条while
语句的条件表达式,即:num <= 9
。
在该while
语句刚开始执行的时候,Julia 会先对它的条件表达式进行求值。只要其求值结果为true
,此while
语句中的子语句就会被执行。在这之后,每当该while
语句中的子语句被完整地执行一遍,它的条件表达式就会被重新求值一次。如果其求值结果依然为true
,那么那些子语句就会被再次执行。这就是所谓的循环。直到这个条件表达式的求值结果变为false
,这条while
语句所代表的循环才会结束。
我们再来看这条while
语句中的子语句组。我先试图把变量num
的值加1
,之后又打印了该变量的值和必要的间距。在这里,我使用了一个我们还未曾用到过的关键字global
。
我们在非全局的作用域(或者说局部作用域)中使用global
,会让 Julia 认为在该关键字右边的标识符指代的是一个全局的变量。那为什么要这么做呢?其主要原因是,如果我们不在这里添加global
,那么 Julia 就会认为num
是一个新的局部变量。在这里,该局部变量的作用域就是while
语句所占据的区域。注意,这里出现了局部变量对同名的全局变量的遮蔽。如此一来,语句num += 1
就变得不合法了,并且 Julia 会对此报错。因为num += 1
就相当于num = num + 1
,而我们是不能在一个变量被定义之前就引用它的(Julia 在对num + 1
进行求值时,局部变量num
还不存在)。
即使我们把num += 1
替换成一条肯定合法的语句,如num = 10
,若不在这里添加global
也是不妥的。代码如下(看看就可以了,不要去尝试执行它):
julia> num = 0
0
julia> while num <= 9
num = 10
print("$num ")
end
因为,如此一来就会出现这种情况:虽然我们在while
语句的子语句组中定义了局部变量num
,但是在其条件表达式中引用的依然是全局变量num
。显然,我们为局部变量num
赋值并不会影响到全局变量num
。因此,这个循环会一直执行下去。倘若我们不采取任何的措施(如杀掉进程),那么它就永远不会结束。这就是一个简单的死循环!
这种行为是由上面这段代码的编写方式决定的。而且,我们只有在定义一个变量或者给一个变量赋值的时候才能添加像global
这样的关键字。所以这个问题没有其他更好的解决方案。我在前面使用的语句global num += 1
其实就是最优的。请记住,若想在局部的作用域中为全局的变量赋值,那么就一定要在该变量的左边添加global
。
既然我们讲到了global
这个关键字,那我就再说一下与之相对应的关键字local
。与global
正好相反,local
会让 Julia 认为处于该关键字右边的标识符指代的是一个在当前作用域之下的局部变量。
local
的适用场景没有global
广泛。不过,对于嵌套在一起的局部作用域而言,它还是很有用的。请看下面的示例:
julia> num = 0;
julia> while num < (10-1)
global num += 1
sign = num
while num % 2 != 0
sign = num + 1
global num += 1
end
print("$sign")
# print("(num=$num)")
print(" ")
end
2 4 6 8 10
julia>
这段代码包含了一个两层的循环。这两层循环都是用while
语句实现的。同时,它们也代表了两个嵌套在一起的局部作用域。
可以看到,循环中引用的num
仍然是一个全局变量。外层循环的条件是num
小于9
,而内层循环的条件是num
不能被2
整除。而且,无论是哪一层循环,都会在当前的条件满足的情况下对num
进行加1
的操作。
此外,我在外层的循环里还定义了一个局部的变量sign
。并且,我在内层的循环中还引用了这个局部变量,并对它进行了重新赋值。这个局部变量代表了我们在每一次外层迭代的最后将要打印的内容。你肯定也发现了,每当外层的迭代即将完成的时候,变量sign
的值都会与num
的值相等。其实,我在这里添加这个局部变量只是为了体现local
的用法和作用。
上面的这个双层循环的作用是打印出10
以内的所有正偶数。但是,如果我们在内层循环中的代码sign = num + 1
的左边添加一个关键字local
,那么情况就会明显不同。代码如下:
julia> num = 0;
julia> while num < (10-1)
global num += 1
sign = num
while num % 2 != 0
local sign = num + 1
global num += 1
end
print("$sign")
# print("(num=$num)")
print(" ")
end
1 3 5 7 9
julia>
可以看到,在我添加了local
之后,这段代码打印出了10
以内的所有正奇数。
让我们来一起分析一下原因。我刚刚说过,local
关键字的作用是,让 Julia 认为处于该关键字右边的标识符指代的是一个在当前作用域之下的局部变量。因此,在这个local
右边的sign
就会被视为一个在内层的while
语句中定义的局部变量。以下简称这个sign
为内层的sign
。显然,这个内层的sign
与在外层的while
语句中定义的那个sign
(以下简称外层的sign
)就不再是同一个变量了。又由于我对内层sign
的赋值肯定不会影响到外层sign
的值,所以外层迭代打印出的内容才都会是奇数。这就是local
关键字对这段代码的实际影响。
关键字local
可以把一个原本引用自外层局部作用域的变量变成一个仅属于当前作用域的新变量。这是对重名变量的另一种解法。但它可以解决的只是在多个嵌套在一起的局部作用域当中出现重名变量的问题。别忘了,在默认的情况下,Julia 会让同名的局部变量遮蔽掉那个对应的全局变量。所以,在这里并不会涉及到(也用不着涉及到)全局作用域。
反观关键字global
,它面向的则是在某个局部作用域和全局作用域当中出现重名变量的问题。它的添加会改变 Julia 的默认行为,让当前作用域下的标识符不再代表一个新的变量,而是代表同名的全局变量。
由于while
语句的编写特点,global
往往会在这种语句中经常出现。然而,local
在while
语句中出现的次数就明显少了许多。原因是,我们通常很少会编写拥有很多层的嵌套循环。即使编写了这样的代码,我们一般也不会写出重名的局部变量。因为这么做会大大降低代码的可读性,同时还会加重我们自己的心智负担。
最后,顺便说一下,我们在while
语句中也可以使用break
语句和continue
语句。而且,它们在这里的用法和作用与在for
语句中的没有什么两样。但特别的是,当我们仅仅把true
作为while
语句的条件表达式时,break
语句的加入就显得尤为重要了。例如:
julia> num = 0;
julia> while true
global num += 1
print("$num ")
if num >= 10
break
end
end
1 2 3 4 5 6 7 8 9 10
julia>
很显然,如果上面这条while
语句中没有break
语句,那么它就将形成一个死循环!在绝大多数情况下,这都不会是我们的意愿。实际上,对于几乎所有的while
语句,我们都应该考虑清楚并实现好它的结束机制。
现在总结一下。while
语句也可以被用来实现循环。它总是需要携带一个条件表达式,用于控制循环的启停。在这里,我们需要特别注意变量的作用域问题。由于我们经常需要在while
语句的条件表达式和子语句组中引用外界的变量,所以在编写它的时候还是需要考虑得更周到一些的。在必要的时候,我们可以借助关键字global
或local
来辅助控制变量的定义或引用。
Julia 的let
语句本身既不包含条件也没有循环,但它的功能却是独树一帜的。
let
语句能够自成一个作用域。通常,我们会让let
语句在开始处携带赋值语句,并以此定义相应的局部变量。另外,与其他的代码块一样,let
语句也可以包含子语句组。下面是一个简单的例子:
julia> x = "Python";
julia> let x = "Julia", y = "Golang"
println("$x, $y")
end
Julia, Golang
julia>
请注意其中的赋值语句。如果我们想在let
语句的开始处同时定义多个局部变量,那么可以在关键字let
的右边并列多条赋值语句,并用英文逗号分隔它们。你也许还记得我们在讲变量和常量的时候提到过的平行赋值法,如x, y = "Julia", "Golang"
。不过很可惜,平行赋值法不能被用在这里:
julia> let x, y = "Julia", "Golang"
println("$x, $y")
end
ERROR: syntax: invalid let syntax
# 省略了一些回显的内容。
julia>
你肯定也发现了,在前面那段代码中有两个名称为x
的变量。全局变量x
的值是Python
,而局部变量x
的值为Julia
。根据我们之前讲过的遮蔽规则,let
语句中的子语句在默认情况下只能引用到局部变量x
。因此,前面那条let
语句并没有打印出Python
。
然而,我们是可以让它打印出Python
的。只要稍微调整一下其中的赋值语句就可以了,就像这样:
julia> let x = x, y = "Golang"
println("$x, $y")
end
Python, Golang
julia>
你可能会觉得在let
关键字右边的赋值语句x = x
很奇怪。它难道是把x
的值赋给了x
自己吗?
实际上,并不是这样。在这个等号左边和右边的两个x
指代的并不是同一个变量。首先,我们需要知道,Julia 对赋值语句的解析是从右到左的。这很合理,因为它需要先知道这个值具体是什么,才能够判断该值是不是可以被赋给某个变量。尤其在对新变量赋值的时候,这一点尤为重要。
其次,与let
关键字处于同一行的赋值语句总是会在当前的作用域下创建新的局部变量。在这个等号左边的x
指代的就是一个新变量。然而,对等号右边的x
的求值会先于对此局部变量的创建。那时,在let
语句所代表的作用域下还没有名为x
的程序定义,所以等号右边的x
就会去指代那个在外面的全局变量x
。
因此,这里的x = x
其实就是在把全局变量x
的值赋给let
语句中的局部变量x
。你可别小看这条短短的赋值语句。let
语句中的这种赋值方式在 Julia 中是非常特别的。你可以思考一下,是否能够用别的形式完全实现上述代码的功能。你可以考虑利用关键字global
和local
,以及其他的代码块。
总之,let
语句是一种很纯粹的代码块。它会形成局部的作用域,并保证其中定义的局部变量不被外泄。它本身就可以携带赋值语句,但也可以不携带而由其子语句定义局部变量,如:
julia> let
y = "Golang"
println("$x, $y")
end
Python, Golang
julia>
let
语句本身携带的赋值语句一定会在当前的作用域下创建新的局部变量,即使这个局部变量与外界的变量重名也是如此。或者说,这里的赋值语句总是会执行“定义并赋值”的操作。正因为如此,这里才存在着一种特殊的赋值方式。就像我在前面讲过的那样。
你可能已经有所感悟,一个人是不可能不生病的。在一些时候,即便从表面上看没有什么明显的症状,也不能保证内里没有任何问题。可以这样说,疾病是人生的伙伴,如影随形。
对于计算机程序来讲,也是类似的。我们不能也不应该期望某人(包括我们自己)可以编写出不会出现任何错误的程序。同时,我们也不应该奢望可以解决掉程序中的所有错误。我们应该关注的是,当错误发生时,程序自身应该怎样去辨别、报告和处理。在程序自身无法处理的情况下,它应该怎样去记录,以便我们可以获知详尽的信息并据此找到有效且合理的解决方案。
对于任何的程序而言,错误的报告和处理都是一门学问。而且,几乎所有的编程语言都会非常的重视这一方面。它们的缔造者都会不遗余力地为程序开发者提供各种各样的辅助工具。反过来讲,不重视错误处理的编程语言是根本没有生存空间和发展的可能的。Julia 肯定是一门重视错误处理的编程语言。否则它也不可能走出 MIT 并发展到现在了。
在 Julia 语言里,程序错误被统称为异常(exception)。而且,与普通的数据一样,异常也需要由值来承载。我们以下称之为异常值。很显然,每一个异常值都会有类型。我们可以称之为异常类型。
Julia 中所有的异常类型都直接或间接地继承自Exception
类型。通过执行调用表达式subtypes(Exception)
,你会发现仅仅是Exception
的直接子类型就多达近 60 个。其中有不少我们之前见过的异常类型,比如:代表函数参数错误的ArgumentError
、代表索引越界错误的BoundsError
、代表类型转换错误的InexactError
,以及在字典中不存在指定键时报出的KeyError
、在衍生方法不存在时报出的MethodError
、在变量未定义时报出的UndefVarError
,等等。
可以看到,我们碰到过的这些异常类型的名称都是以Error
为后缀的。其实,这些类型所代表的异常都有一个共同的特点,那就是:它们都会因程序编写的不恰当或不正确而被引发。此外,一些异常类型的名称会以Exception
结尾。这些异常往往会因为一些真正的意外而被引发。比如,当有人在终端上强行地中断正在运行的程序时会引发InterruptException
。又比如,当数据中意外地出现缺失值(即missing
)时会引发MissingException
。另外,其中还有一个通用的异常类型,叫做ErrorException
。在你实在不知道用哪一个异常类型的时候,可以以它作为缺省的类型。
大多数异常类型都有对应的构造函数,而这些构造函数很多都是有参数的。以ErrorException
为例,它有一个名为msg
的字段,用于存放包含了错误描述的字符串。相应的,它的构造函数也有一个叫做msg
的参数。在我们调用这个构造函数并传入一个参数值之后,其结果值就会是这样的:
julia> ex = ErrorException("Something wrong!")
ErrorException("Something wrong!")
julia> ex.msg
"Something wrong!"
julia>
你可能会觉得 Julia 中异常的种类太多了,几乎无从记起。别担心,你无需像背课文那样把它们都记下来。学习编程其实也是一个试错的过程。所以,在你的程序真正地引发了某种异常之后,你再去了解相应的异常类型也不迟。只要我们能够吃一堑长一智,尽量在今后避免犯下同样的错误就好了。除此之外,我们需要更加关注的是,当碰到或引发了一个异常的时候应该怎么办。
绝大多数正式的程序都需要根据一些外界的输入,经过一定的处理过程,最后产生必要的输出。外界的输入除了可以提供指令、设定条件和约束、构建程序的运行环境之外,还可能会引入各种有可能引发异常的隐患。所以,我们的程序对输入的前期检查是非常有必要的。
我们常常把用于在前期检查输入的代码称为防卫语句。防卫语句并不拘泥于某种形式,而重在其防卫功能。下面是一个很简单的例子:
julia> # 用于打印某人的体重的函数。
function print_weight(kg::Int)
if kg <= 0
throw(DomainError(kg, "The argument is too small!"))
elseif kg > 500
throw(DomainError(kg, "The argument is too big!"))
end
println("$(kg) kg")
end
print_weight (generic function with 1 method)
julia> print_weight(100)
100 kg
julia> print_weight(-1)
ERROR: DomainError with -1:
The argument is too small!
Stacktrace:
[1] print_weight(::Int64) at ./REPL[1]:4
[2] top-level scope at REPL[3]:1
julia>
我先定义了一个叫做print_weight
的函数。这个函数的功能非常简单,只是打印一下某人的体重而已。它有一个参数,名称为kg
,类型为Int
。
针对这个函数的功能,我已经通过参数的类型对输入进行了一定的约束。但这显然还不够。所以我又添加了一条防卫语句,也就是处于该函数的函数体最上面的那条if
语句。其含义是,参数kg
的值既不能小于或等于0
也不能大于500
,否则就主动抛出一个异常。注意,这里的条件有两个。我分别为这两个条件创建了不同的异常值。
通常,当由于参数的值超出了有效的值域而需要抛出异常的时候,我们通常会使用DomainError
类型的值来表述异常。有两个构造函数可以产生此类型的异常值。其中的一个构造函数只有一个名为var
的参数,而另一个构造函数的参数除了var
还有msg
。顾名思义,参数var
应该被赋予的就是那个超出了值域的参数值,而参数msg
则应该被赋予关于此异常的描述信息。
我在上面的例子中使用的是拥有两个参数的构造函数DomainError
。因为单单给予print_weight
函数所接受的参数值还不足以说明问题。紧接着,我把刚刚创建的异常值传给了throw
函数。到了这里,异常就即将被抛出了。
严格来说,throw
并不是一个通常意义上的函数。它的不普通之处在于,它被调用之后会立即中断当前程序正在执行的正常流程。你应该也看到了,在我向print_weight
函数传入了超出值域的参数值之后,该函数并没有执行完它的正常流程(或者说没有打印出任何内容),而是直接使 REPL 环境显示出了一段异常提示信息。
在解释throw
函数都做了什么之前,我们先来认识一个概念——调用栈。调用栈是编程语言用来实时记录和控制应用程序的执行过程的一种辅助工具。它基于的是一种被称为栈的数据结构。你可能已经知道,栈其实也是一种容器,而且它是先进后出的。更具体地讲,调用其他代码的代码(以下简称调用代码)会先被放入调用栈,然后被调用的代码才会入栈。另一方面,在通常情况下,调用代码要等到被调用代码执行完毕之后才会继续执行。所以被调用代码会先出栈,然后才是调用代码。也就是说,出栈的顺序与入栈的顺序是完全相反的。此外,调用栈通常无法描绘出应用程序运行过程的全貌。因为只有正在执行的代码调用才可能会出现在调用栈中。
现在,让我们来一起看一下上例中的异常提示信息。通过查看其中的前两行内容我们可以知道,被引发的异常的类型是DomainError
,而引发的原因是参数值-1
太小了。它比参数kg
的有效值域中的最小值还要小。显然,这两行内容恰恰源自我在print_weight
函数中传给throw
函数的那个异常值。
接着往下看。紧挨在Stacktrace:
下面的、左边以序号开头的那几行内容就是 Julia 向我们展示的调用栈信息。注意,这里的信息是以出栈的顺序展示的。也就是说,与序号1
对应的是最后被调用的代码。
在包含了[1]
的这行内容当中,我们需要关注两个地方。第一个地方是左边的print_weight(::Int64)
。它是print_weight
函数的签名,由函数名称、参数列表以及可选的结果声明组成。更宽泛地说,它代表的是被调用代码的标识。第二个地方是右边的./REPL[1]:4
。它代表着被调用代码中抛出异常的语句的具体位置。在这里它显示了,那条语句处于当前的 REPL 环境所解析的第 1 段代码中的第 4 行,即:
throw(DomainError(kg, "The argument is too small!"))
相应的,包含了[2]
的内容告诉我们,在异常抛出时,调用print_weight
函数的那条语句处于当前 REPL 环境所解析的第 3 段代码中的第 1 行。这条语句正是print_weight(-1)
。而这行内容中的top-level scope
是在告诉我们,这条语句是顶层作用域中的代码。所谓的顶层作用域指的就是Main
模块所代表的作用域。已知,我们在 REPL 环境中直接写入的代码就都属于Main
模块。如果这些代码未被包含在更小的作用域里,那么我们就可以说它们是顶层作用域中的代码。上例中的print_weight(100)
和print_weight(-1)
就都是这样的代码,但是print_weight
函数中的代码却不是。
在查看了这些调用栈信息之后你可能会意识到,throw
函数不但会中断当前代码的执行,还会沿着调用栈的反方向(即与入栈顺序相反的方向)传播异常,直到碰到能够处理此异常的程序为止。对于上面的例子,REPL 环境本身会处理掉我们写入的代码所抛出的异常。也正因为如此,REPL 环境才能依然良好地运行着,并不会受到如此异常的影响。而且,我们可以看到,上例中的调用栈信息只有 2 行。这正说明此异常并没有被传播到 Julia 语言本身的程序当中。
如果我们把上述代码写入到一个源码文件中,并使用julia
命令来运行,那么就可以在异常抛出后获得更多的信息。实际上,我已经把几乎一模一样的代码写进了Programs
项目的src/ch11/exception/throw/main.jl
文件中。现在,我们在命令行中运行一下它,结果如下:
$ julia main.jl
100 kg
ERROR: LoadError: DomainError with 501:
The argument is too big!
Stacktrace:
[1] print_weight(::Int64) at /Users/haolin/Projects/Programs.jl/src/ch11/exception/throw/main.jl:11
[2] top-level scope at /Users/haolin/Projects/Programs.jl/src/ch11/exception/throw/main.jl:17
[3] include at ./boot.jl:328 [inlined]
[4] include_relative(::Module, ::String) at ./loading.jl:1105
[5] include(::Module, ::String) at ./Base.jl:31
[6] exec_options(::Base.JLOptions) at ./client.jl:287
[7] _start() at ./client.jl:460
in expression starting at /Users/haolin/Projects/Programs.jl/src/ch11/exception/throw/main.jl:17
这个异常的抛出是由于我传给print_weight
函数的参数值501
太大了。还记得吗?参数kg
的有效值域是(0, 500]
。注意,Julia 这次给出的调用栈信息有 7 行。
需要我们分清楚的是,在这 7 行内容当中,前 2 行是关于用户级代码的,而后 5 行则与语言级代码有关。这里所说的用户指的是使用 Julia 代码编写应用程序的我们。而所谓的语言级代码,指的就是在 Julia 语言内部用于驱使和维护应用程序运行的那些代码。
你现在可以同时打开对应的源码文件作为参照。第 1 行调用栈信息显示,抛出异常的那条语句处在源码文件中的第 11 行,即:
throw(DomainError(kg, "The argument is too big!"))
而第 2 行调用栈信息则显示,调用(包含了上述语句的)print_weight
函数的代码处在源码文件中的第 17 行,即:
print_weight(501)
同时,这条语句也是顶层作用域(即top-level scope
)中的代码。
再下面几行的调用栈信息对应的都是语言级的代码。你现在倒不用深究这些代码都代表了什么以及都具体做了些什么。你只要知道,在我们使用julia
命令运行源码文件的时候,Julia 是会先做一些准备工作的,如读取命令行参数、加载环境配置、导入Core
模块等等。
在上述异常提示信息的最后,有一行总结性的内容。它表明,在异常抛出时,最靠近调用栈深处(或者说底端)的代码调用处于源码文件main.jl
的第 17 行,也正是我们刚才提到的print_weight(501)
。
注意,由于我们使用julia
命令直接执行了源码文件中的代码,这里并没有像 REPL 环境那样的可以处理掉异常的中间程序,所以这里的异常就被传播到了 Julia 语言本身的程序当中,从而导致了我们的应用程序的崩溃(或者说中断并终止运行)。
至此,我一直在借着讲throw
函数的机会向你介绍与异常的抛出有关的重要概念,包括防卫语句、调用栈、异常的传播,还有顶层作用域、用户级代码、语言级代码和程序的崩溃。我们可以使用防卫语句对外界的输入进行前期检查,并在它们不符合预期的时候抛出异常。被抛出的异常会沿着调用栈的反方向(或者说向着调用代码的一方)传播,直到碰到能够处理它的程序为止。另外,在我们的应用程序中,顶层作用域可以说是用户级代码和语言级代码的分水岭。如果我们写在那里的代码还不能捕获并处理掉异常,那么异常就会继续向外传播。这要是在 REPL 环境中倒还好,但要是在源码文件中那就比较糟糕了。因为后一种情况就意味着程序会因异常的抛出而崩溃。
好了,一些基本的东西你应该已经都了解了。我们现在接着往下说。既然异常已经被抛出来了,那怎样才能处理掉呢?
一旦明白了异常是怎样被抛出来的,异常的处理就很好理解了。在使用 Julia 语言编写的应用程序中,没有任何代码可以自动地处理异常。我们需要使用try-catch
语句专门地去做异常的捕获和处理。
我估计你只看名字也能猜得出来,try-catch
语句是由两部分组成的。其中的try
子句用于包裹可能会引发异常的代码。如此一来,一旦其中的代码真的引发了异常,try
子句就会将该异常传递给catch
子句,以便后者去捕获这个异常。catch
子句可以携带一个代表了变量的标识符,用于绑定被捕获的那个异常值。示例如下:
julia> # 用于获取 BMI(身体质量指数)函数。
function get_bmi(weight::Int, height::Float64)::Float64
if weight <= 0 || weight > 500
throw(DomainError(weight, "Invalid weight! (range: (0, 500])"))
elseif height <= 0.0 || height > 3.0
throw(DomainError(height, "Invalid height! (range: (0.0, 3.0])"))
end
return weight / height^2
end
get_bmi (generic function with 1 method)
julia> try
bmi = get_bmi(0, 1.78)
catch e
println("WARNING: captured an exception: $e")
end
WARNING: captured an exception: DomainError(0, "Invalid weight! (range: (0, 500])")
julia>
函数get_bmi
可以计算并返回一个人的身体质量指数(BMI)。参数weight
代表体重,单位是公斤。参数height
代表身高,单位是米。
我们可以看到,这里的try
子句和catch
子句的拼接方式与if
语句中的if
分支和else
分支的拼接方式是一样的。它们都是紧挨在一起的,并且只在最后有一个end
。不过,这两种语句的处理逻辑就大相径庭了。
在这条try-catch
语句被执行之后,REPL 环境并没有显示任何的异常提示信息。这说明这段代码的执行成功完成了。并且,我们可以看到,对这段代码的执行使 REPL 环境打印出了一行表示了警告信息的内容。很显然,相应的打印语句正是在catch
子句当中的那一条语句。在细看这行警告信息之后,我们也可以确定,catch
子句捕获到的异常值恰恰代表了当我们调用get_bmi
函数并为它的weight
参数传入0
时应该抛出的那种异常。
这里有一点需要我们特别注意。try
子句中的正常流程依然会因异常的抛出而被中断。只不过,try
子句在异常即将被传播出去的时候将其拦下并传递给了catch
子句。这使得try-catch
语句重新获得了流程的控制权。可即使是这样,程序也不会再从抛出异常的那条语句那里继续执行下去了。我再向上例的代码中添加两行打印语句,你肯定就可以看出端倪了:
julia> try
println("Invoke `get_bmi` (before)")
bmi = get_bmi(0, 1.78)
println("Invoke `get_bmi` (after)")
catch e
println("WARNING: captured an exception: $e")
end
Invoke `get_bmi` (before)
WARNING: captured an exception: DomainError(0, "Invalid weight! (range: (0, 500])")
julia>
很显然,由于异常的发生,在try
子句中且在bmi = get_bmi(0, 1.78)
语句之后的代码并没有得到执行的机会。
一旦能够捕获到被抛出的异常,我们就可以去做相应的处理了。至于怎样处理,就要依据实际的场景和情况去做决定了。我们可以像上面那样仅仅打印出一行警告信息。我们也可以把异常值作为普通的结果值返回给调用方。如果确实有必要,我们还可以打印或返回一些有利于程序调试的东西。比如,通过调用Base.catch_stack
函数,我们可以获取到包含了异常调用栈信息的数组对象。又比如,通过调用catch_backtrace
函数,我们可以得到只包含了回溯信息的数组对象。
另外,我们也可以在做出简单的处理之后,把异常重新抛出去。这就需要用到rethrow
函数了。下面的例子是在一个新的 REPL 环境中执行的:
julia> # 用于获取 BMI(身体质量指数)函数。
function get_bmi(weight::Int, height::Float64)::Float64
if weight <= 0 || weight > 500
throw(DomainError(weight, "Invalid weight! (range: (0, 500])"))
elseif height <= 0.0 || height > 3.0
throw(DomainError(height, "Invalid height! (range: (0.0, 3.0])"))
end
return weight / height^2
end
get_bmi (generic function with 1 method)
julia> try
println("Invoke `get_bmi` (before)")
bmi = get_bmi(0, 1.78)
println("Invoke `get_bmi` (after)")
catch e
println("WARNING: captured an exception: $e")
println("Invoke `rethrow` (before)")
rethrow(e)
println("Invoke `rethrow` (after)")
end
Invoke `get_bmi` (before)
WARNING: captured an exception: DomainError(0, "Invalid weight! (range: (0, 500])")
Invoke `rethrow` (before)
ERROR: DomainError with 0:
Invalid weight! (range: (0, 500])
Stacktrace:
[1] get_bmi(::Int64, ::Float64) at ./REPL[1]:4
[2] top-level scope at REPL[2]:3
julia>
根据 REPL 环境输出的前几行内容,你应该已经可以分析出这段代码的执行流程了。try
子句中的调用语句get_bmi(0, 1.78)
抛出了异常,使得控制流直接从那里跳到了catch
子句。但由于其中的调用语句rethrow(e)
的存在,异常又被重新抛了出去。最后,REPL 环境捕获并处理掉了这个异常。
从后面的那几行异常提示信息我们可以看出,虽然这个异常是被catch
子句中的代码重新抛出来的,但它的各项信息都没有丝毫改变,包括引发异常的那个参数值、异常值本身的描述信息,以及调用栈信息中的所有细节,如同异常没有被捕获过一样。这就是rethrow
函数所起到的作用。
我们现在知道了,catch
子句在try-catch
语句当中起到了举足轻重的作用。怎样处理可能发生的异常,几乎完全取决于catch
子句以及其中的代码。不过,除了catch
子句,try
子句还可以后接finally
子句。而后者在某些方面更有用处。
finally
子句只能被编写在try
子句和catch
子句的下面。在这种情况下就形成了try-catch-finally
语句。而且,一旦后接了finally
子句,我们就可以不编写catch
子句,而直接把try
子句和finally
子句拼接在一起。但是,从功能上说,这只适用于特定的情况。我在后面会讲到。
为了方便讲解,我在下面会把以这几种形式编写出的代码统称为try
语句。因为无论怎样,try
子句总是要写的,而且总是会写在最上面。
finally
子句有一个特权,那就是:不论try
子句中的代码是否抛出了异常,在它里面的语句都一定会被执行。具体的执行时机是,在try
子句和catch
子句中的代码被执行完毕之后,且在try
语句的整体被执行完毕之前。即便其中的异常会被抛到外界,Julia 也会保证在这之前执行完finally
子句。显然,finally
子句在这方面与catch
子句截然不同。Julia 只会在异常真的被抛出时执行catch
子句。不过,finally
子句却不能像catch
子句那样捕获和处理异常。
正因为如此,finally
子句非常适合做一些善后的处理工作。比如,记录日志、检查并修正计算结果、释放不再需要的计算资源,等等。下面是一个没有抛出异常的例子:
julia> bmi = 0;
julia> try
global bmi = get_bmi(65, 1.78)
catch e
println("WARNING: captured an exception: $e")
finally
global bmi
println("BMI: $(bmi)")
end
BMI: 20.515086478979924
20.515086478979924
julia>
REPL 环境在最后回显的第一行内容是finally
子句中的打印语句打印出来的,而第二行内容表示的则是try
语句的结果值。没错,try
语句也属于一种复合表达式。显然,上面这条try
语句的结果值就是变量bmi
的值。这是由try
子句或catch
子句中的最后一条语句决定的。
你可以自行调整一下传给get_bmi
函数的参数值,让它们可以引发异常,然后看一看程序执行的结果会有什么不同。下面,我们将对这条try
语句进行另外一项调整——删除catch
子句:
julia> try
global bmi = 0
bmi = get_bmi(0, 1.78)
finally
global bmi
println("BMI: $(bmi)")
end
BMI: 0
ERROR: DomainError with 0:
Invalid weight! (range: (0, 500])
Stacktrace:
[1] get_bmi(::Int64, ::Float64) at ./REPL[1]:4
[2] top-level scope at REPL[5]:3
julia>
一旦删除了catch
子句,try
子句中抛出的异常就无法被捕获了。这个异常会继续向外传播。但即使是这样,在异常被传到外界之前,finally
子句仍然会被执行。REPL 环境在这里回显的内容就可以很好地证明这个过程。处于第一行的BMI: 0
表明finally
子句中的那条打印语句被执行了。但由于对get_bmi
函数的调用未能成功完成,所以变量bmi
的值依然是0
。在这之后的异常提示信息则说明,异常被传播到了 REPL 环境那里,且被后者捕获并处理掉了。
你若足够细心的话就一定会发现,我在前面的try
子句和finally
子句当中都在使用关键字global
去修饰标识符bmi
。你应该还记得,这是在局部作用域中引用全局变量时需要运用的编写手法。这说明try
子句和finally
子句都会自成一个作用域。不但如此,try
语句中的catch
子句也会形成一个局部作用域。
由此可以推断,try
语句中的各个子句肯定是无法访问到彼此定义的局部变量的。我们下面通过一个例子来验证这一点:
julia> try
bmi2 = get_bmi(0, 1.78)
catch e
try bmi2 catch e1 println("[INNER ERROR 1] $e1") end
finally
try bmi2 catch e2 println("[INNER ERROR 2] $e2") end
try e catch e3 println("[INNER ERROR 3] $e3") end
end
[INNER ERROR 1] UndefVarError(:bmi2)
[INNER ERROR 2] UndefVarError(:bmi2)
[INNER ERROR 3] UndefVarError(:e)
julia>
为了让代码更加整洁,我在其中使用了try
语句的简写形式,如:
try bmi2 catch e1 println("[INNER ERROR 1] $e1") end
我一共编写了三条像这样只占用一行的try
语句。它们的作用都是,在引用无法访问到的变量时及时地打印出提示信息。由 REPL 环境回显的内容可知,它们都奏效了。理所当然,我们在这些子语句中定义的局部变量在try
语句之外也都是不可见的。
好了,你现在应该已经对try
语句以及 Julia 应用程序中的异常足够熟悉了。让我们再一起简单地回顾一下。try
语句可由三个部分组成,即:用于包裹可能会引发异常的代码的try
子句、用于捕获和处理异常的catch
子句,以及无论是否有异常发生都会执行的finally
子句。try
子句可以后接catch
子句,也可以后接finally
子句,且至少要拼接一种子句。另外,这三种子句都会自成一个局部作用域。所以,如果你要在其中定义或引用变量,那就要多一份考虑了。
Julia 应用程序中的异常可能是由 Julia 语言抛出的,也可能是由应用程序中的某段代码自行抛出的。但无论怎样,Julia 中的异常都会由值来承载。我们称之为异常值。这些异常值的类型一定都是Exception
类型的某个子类型。我们在应用程序中的任何地方都可以使用throw
函数来抛出异常。并且,我们也可以在try
语句的catch
子句中使用rethrow
函数重新抛出已经捕获到的异常。在抛出异常的时候,我们应该仔细斟酌异常的类型和异常值的构造细节,以求尽量为异常的识别、定位和处理提供有利的条件。这也是我们在编写应用程序时必须要考虑的一个很重要的方面。
在本章,我们主要讲的是控制 Julia 程序的执行流程的基本方式。这包括,最简单的并列表达式和begin
代码块、可以在一定的条件下执行代码的if
语句、可以对一些对象进行迭代的for
语句、可以重复地执行某段代码的while
语句,以及比较纯粹但在局部变量的定义上很有特点的let
语句。还有,我们在最后详细阐释的try
语句。
这些代码块各自都有很鲜明的特点,并且大都也有自己的特殊编写规则。比如,虽然if
语句不会自成一个作用域,但我们若想在之后正常地访问到其中的变量,就要确保那个变量在每一个分支中都有定义。又比如,一条for
语句可以同时迭代多个对象,这与使用多条嵌套在一起的for
语句分别迭代多个对象存在着一些细节上的差异,并且各有千秋。还比如,我们需要在编写while
语句的时候特别注意死循环的问题,而且在大多数情况下都需要使用continue
语句和break
语句对它的执行流程进行干预。等等。
有了上述的这些代码块,再加上之前讲过的各种程序定义,我们就可以去编写相对高级一些的 Julia 程序了。但是这还不够。要想编写出完整度高、重用性强、模块化的应用程序,我们还必须学会编写函数。虽然我们在之前已经见过函数很多次了,并且也一起编写过一些函数,但是那并不成体系。在下一章,我会为你系统化地讲述 Julia 中的函数。