本文是《Julia 编程基础》开源版本第六章:字符和字符串。本书旨在帮助编程爱好者和专业程序员快速地熟悉 Julia 编程语言,并能够在夯实基础的前提下写出优雅、高效的程序。这一系列文章由 郝林 采用 CC BY-NC-ND 4.0(知识共享 署名-非商业性使用-禁止演绎 4.0 国际 许可协议)进行许可,请在转载之前仔细阅读上述许可协议。
6.1 Unicode 字符
在讲 Unicode 字符之前,我们先来简要介绍一下 ASCII 编码。
ASCII 是 American Standard Code for Information Interchange 的缩写,可以翻译为美国信息交换标准代码。它是由美国国家标准学会(American National Standard Institute, 简称 ANSI)制定的标准的单字节字符编码方案,主要用途是基于文本的数据交换。这个方案起始于上个世纪 50 年代的后期,并在 1967 年定案。它最初是美国的国家标准,是不同计算机在相互通信时共同遵守的西文字符编码标准。ASCII 编码支持的所有字符的集合被称为 ASCII 编码集。
ASCII 编码使用 1 个字节来表示 1 个字符。其中的 7 位二进制数字用于表示大写和小写的英文字母、0
到9
的数字、各种英文标点符号,以及一些不可打印字符和控制字符。而字节的最高位则用于奇偶校验。这使得 ASCII 编码集中只能容纳 128 个字符。
我们之前提到的 Unicode 字符实际上指的是 Unicode 编码标准所支持的字符。Unicode 是一个针对书面字符和文本的通用字符编码标准。它定义了多语言文本数据在国际间交换的统一方式,并为全球化软件创建了基础。Unicode 编码标准以 ASCII 编码集作为出发点,并突破了 ASCII 只能对拉丁字母进行编码的限制。它提供了可以对世界上的所有语言中的所有文字进行编码的能力,其支持的字符已超过百万。此外,它还支持所有已知的转义序列和控制代码。
在计算机系统内部,抽象的字符被编码为数字。用于代表抽象字符的整数范围被称为代码空间(code space)。代码空间中的每一个特定整数都被称为代码点(code point)。当一个抽象字符被映射到(或者说被分配给)一个代码点时,这个代码点就可以被看成一个已编码的字符。
在 Unicode 编码标准中,代码空间由从0
到10FFFF
的十六进制整数组成。这就意味着,有 1114112 个代码点可以用于表示抽象字符。Unicode 编码标准的惯用法是使用十六进制形式来表示代码点的数值,并使用U+
作为前缀。比如,英文字母字符'a'
的 Unicode 代码点就是U+0061
。并且,一个受到支持的字符能且仅能由一个对应的 Unicode 代码点表示。
我们已经知道,在计算机系统中,整数可以由固定大小的代码单元(code unit)来表示。比如,8 个比特(也就是 1 个字节)、16 个比特或 32 个比特的代码单元,等等。在 Unicode 编码标准的模型中,编码格式用于确定怎样将代码空间中的每一个整数(或者说代码点)都表示成包含若干个代码单元的序列。Unicode 编码标准中存在多种编码格式。其中有一种编码格式叫做 UTF-8。UTF 是 Unicode Transformation Format 的缩写。
UTF-8 编码格式以单个字节为 1 个代码单元,并且完全兼容 ASCII 编码。换句话说,对于这种编码格式,Unicode 代码点U+000
到U+007F
的编码即为0x00
到0x7f
,并且它们所代表的含义与 ASCII 编码中的完全一致。另外,UTF-8 是一种宽度可变的编码格式。它会根据字符的不同,用 1 至 4 个代码单元来编码一个字符。比如,对于中文、日文和韩文中的一个字符,它会使用 3 个代码单元来表示。也就是说,对于这些 Unicode 字符,UTF-8 会把它们转换成宽度为 3 个字节的二进制数。至于它是怎样转换的,我们就不在此讨论了。
关于 Unicode 编码标准和 UTF-8 编码格式的更多知识,你可以参看 Unicode 官方网站中提供的文档。
总之,Unicode 编码标准帮助我们屏蔽掉了各种字符的复杂性,并且已被普遍认为是解决这一问题的终极标准。而 UTF-8 则是 Julia 所使用的编码格式。Julia 通过此编码格式支持所有的 Unicode 字符。
对于字符和字符串,Julia 通常都会采用 UTF-8 编码格式将它们转换成二进制数并进行存储。
6.2 字符
从表面上看,每一个字符都是一个独立且不可再分割的图形。但不要忘记,从存储的层面看,我们还可以把它们拆分成一个个代码单元,甚至一个个比特。
6.2.1 值的表示与操作
Julia 中的一个字符值只能容纳一个 Unicode 字符。并且,每个字符值都需要由一对单引号包裹。通过 REPL 环境,我们可以很方便地获知任何字符值的细节,例如:
其中,对于我们来说比较重要的是回显内容中的 Unicode 代码点。比如,字符'a'
的 Unicode 代码点是U+0061
。而ASCII/Unicode
的意思是,'a'
同时也是 ASCII 编码所支持的字符,而且 ASCII 和 UTF-8 对它编码之后产生的整数是相同的。至于括号中的代码点分类等信息,我们一般不用太关注。
除了在一对单引号之间直接插入一个 Unicode 字符,我们还可以用另外两种标准的方式来表示一个字符值。一种方式是,以\u
为前缀并后跟代表了某个 Unicode 代码点的十六进制数,最多 4 个数字。另一种方式与之类似,以\U
为前缀并后跟代表了某个 Unicode 代码点的十六进制数,最多 8 个数字。注意,对于后一种方式,实际上后跟 6 个十六进制数字就足够了。如果最左边的 2 个十六进制数字不是0
,那么它肯定就超出了 Unicode 的代码空间。下面是一些示例:
此外,我们还可以用'\xHH'
或'\OOO'
的形式表示一个可由 ASCII 编码的字符值。其中,HH
的意思是至多 2 个十六进制的数字,而OOO
的含义是至多 3 个八进制的数字。例如:
还有,那些经典的转义序列(escape sequence)也可以被用在这里。比如,'\t'
表示制表符,'\n'
表示换行符,等等。
在某些情况下,由于一些原因(如字面量冲突、含义冲突等)我们无法在代码中直接写出需要使用的字符。这时就要用到转义,也就是用多个字符的有序组合来代表原本需要的字符。这里的多个字符的有序组合就叫做转义序列。
那什么叫做经典的转义序列呢?它是指,针对于 ASCII 编码集中的那些不可打印字符的转义序列。这些转义序列最早是在 C 语言中被定义的,后来又被很多编程语言沿用。它们的字面量与原字符的编码值是无关的,但或多或少会与其含义存在一些关联。详见下表。
表 6-1 经典的转义序列
提示一下,该表中表示 ASCII 编码值的那些整数都是十六进制的。
由于转义序列都是以\
为前缀的,所以当我们想表示一个反斜杠的时候就需要在它的前面再加一个反斜杠,以说明后面的反斜杠代表的并不是转义序列的前缀,如:
回显内容中的005c
就是反斜杠的 ASCII 编码值。
另外,由于字符值都需要以单引号包裹,所以如果我们想表示单引号本身的话,那么也要用反斜杠转义一下:
回显内容中的0027
就是单引号的 ASCII 编码值。
最后,顺便提一下,比较操作符是支持字符值的。这种比较也是基于 Unicode 代码点的。比如:
另外,加法和减法也可以作用于字符值。例如,运算表达式'A' + 32
的求值结果就是'a'
。这表明字符'A'
与'a'
的 Unicode 代码点相差32
。
6.2.1 类型与转换
在 Julia 中,字符值的默认类型是Char
。Char
类型是一个宽度为 32 个比特的原语类型。显然,这个类型的值足够装下任何一个采用 UTF-8 编码的 Unicode 代码点。同时,它也是抽象类型AbstractChar
的子类型,还是 Julia 预定义的唯一的一个具体的字符类型。
从存储层面看,Char
类型与UInt32
类型几乎是相同的。因此我们可以说,字符值就相当于无符号的整数。我们可以很轻易地把一个字符值转换成一个整数值,反之亦然。示例如下:
但要注意,并不是所有的UInt32
类型的值都会代表一个有效的 Unicode 代码点。比如:
回显的括号中已有明确的提示,整数值0x11ffff
是一个无效的 Unicode 代码点。因为它太高了,超出了 Unicode 编码标准所定义的代码空间。对于这种有效性的判断,我们可以使用isvalid
函数:
此外,我们总是可以用codepoint
函数把一个字符值转换成一个整数值:
注意,对于用不同格式编码的字符值,codepoint
函数的结果值的类型可能会不同。但可以确定的是,它总会返回一个整数值。
6.3 字符串
虽然字符串通常会由一个个字符组成,但在 Julia 中,字符串与字符却是截然不同的两个概念。
6.3.1 值的表示
一个字符串值一般由一对双引号包裹,并可以包含零到多个字符:
我们也可以用三联双引号来包裹这类值。在这种情况下,我们输入的字符串可以跨越多个行。其中的换行都会以换行符的形式保留下来,但紧跟在第一个三联双引号后面的换行会被忽略。对于回车以及回车换行的组合也是如此。下面是一个示例:
注意,我在CN
的右边用 Tab 键输入了一个制表符,所以在回显内容的对应位置上存在一段空白。回显内容中最左边的☼
是一个由 Unicode 代码点\u263c
代表的字符。而在空白的右边,针对我输入的每一个换行都存在一个换行符\n
。另外,\125
也是一个转义序列。其中的125
是一个八进制的 ASCII 编码值。这个转义序列对应于大写字母U
。
你可能已经看到,我直接写入的转义序列\n
和\t
都被原封不动地保留了下来。这里的规则是,字符串值总是会原样保留那些经典的转义序列。对于我们之前提到的针对反斜杠的转义序列\\
也是如此。至于其他的转义序列,它们仍然会像以前那样被处理。
另外,在前面的多行字符串中,一些用于缩进的空白(包括空格和制表符)并没有被识别为字符串的一部分。这又是为什么呢?实际上,对于由三联双引号包裹的字符串值,Julia 会以缩进最少的那一行为基准来保留每一行中的前置空白。注意,第一行以及只包含空格和制表符的行并不会被作为基准。示例如下:
在这个多行字符串中,从Julia
到第二个三联双引号的 5 行里,它们的缩进都是一样的。所以,回显的字符串值中不存在任何的空格。但如果我们调整一下,相应的空格就会出现:
我们依然来看从Julia
到第二个三联双引号的 5 行。其中,最后一行的缩进是最少的,只有 9 个空格。所以,对于其他行的前置空格,都要被剪掉 9 个。而剩下的空格都会被原样地保留在字符串值中。下面是另一个例子:
显然,对于这个多行字符串,Julia 在考虑前置空白的保留问题时,是以Julia
那一行为基准的。
最后,对于由双引号包裹的字符串值,如果我们想在其中表示双引号本身,那么就要用反斜杠进行转义。比如,字符串值"\""
的实际内容是"
。但在由三联双引号包裹的值中,表示双引号却用不着转义。
6.3.2 类型之上的设定
字符串值的默认类型是String
。String
是抽象类型AbstractString
的子类型之一。Julia 对字符串的很多设定都是基于这个抽象类型展开的。
首先,一个字符串就是一个包含了若干个代码单元的序列。还记得吗?我们说过 UTF-8 的代码单元是 1 个字节。这可以通过调用codeunit
函数来验证:
这个函数可以接受一个AbstractString
类型的参数值,并返回它的代码单元的类型。上述字符串的代码单元类型是UInt8
,即宽度为 1 个字节的无符号整数类型。
其次,既然字符串是代码单元的序列,那么就应该可以抽取出其中的代码单元。事实也确实如此。这仍然需要用到codeunit
函数。我们可以把一个字符串值和某个有效的索引号同时传给它,比如:
调用表达式codeunit(comment1, 1)
的含义是,从comment1
代表的字符串值中抽取出第 1 个代码单元。这时,codeunit
函数会返回一个UInt8
类型的值,即:那个与给定索引号对应的代码单元。此外,还有一个名称与之很像的函数codeunits
。它可以返回一个由字符串值中的所有代码单元组成的序列。
我们都知道,字符串在底层都是由一个个字节组成的。而所谓的索引号,就是指字符串中的字节的序号。对于采用 UTF-8 编码的字符串来说,字节的序号就等于代码单元的序号。
那什么叫做有效的索引号呢?对于一个字符串值来说,有效的索引号是从1
开始的。1
就是有效索引号的下限。这与很多其他的编程语言中的设定都不同。更宽泛地讲,Julia 中的索引号一般都必须是正整数,而不能是0
。
那么,字符串值中的有效索引号的上限又是多少呢?在这里,我们可以通过调用ncodeunits
函数获取到它。例如:
因此,字符串comment1
的有效索引号的范围就是[1, 66]
。一旦索引号低于相应的下限或高于相应的上限,就会立即引发一个错误:
这就是第三个设定,即:对于一个字符串值,它的有效索引号一定大于或等于1
,且小于或等于其中字节的个数。
6.3.3 操作字符串
基于 Julia 对通用字符串的三个基本设定,我们可以用很多方式来操作字符串。
6.3.3.1 获取长度
关于获取一个字符串值的长度,我们已经知道ncodeunits
函数是可用的。这个函数会获取字符串值中的代码单元的数量(代码单元长度)。这对于采用 UTF-8 编码的字符串来说,就相当于获取其中字节的数量。但如果为了保险起见,我们可以用sizeof
函数来获取其中的字节个数(字节长度):
另外,若想得到一个字符串值中的字符的数量(字符长度),我们可以使用length
函数。例如:
这个函数还可以再接受两个代表索引号的参数。此时它计算的就是某个字符串片段中的字符的个数。示例如下:
可以看到,length
函数并没有把字符串片段中的不完整字符(或者说无效字符)计算在内。所谓的无效字符是指,根据既定的编码格式(如 UTF-8),无法被识别和转换为一个字符的若干连续字节。它们可能只是某个字符编码的一部分,也可能根本就无关于任何字符编码。
不过,如果一个字符串(片段)中只包含了无效字符的话,那么length
函数还是会把它算作一个字符的:
索引号11
和12
分别对应的是字符函
的后两个代码单元,而索引号13
对应的则是字符数
的第一个代码单元。这 3 个代码单元合在一起并不能形成一个有效的 Unicode 代码点。但由于这个字符串片段中只包含了这 3 个字节,所以length
函数认为其字符长度为1
,而不是0
。
这有时可能会让我们感到困惑。比如:
如果我们把ich1
和ich2
拼接在一起的话,就肯定会得到ch
代表的字符串值。从直觉上讲,它们的字符长度之间应该存在“和”的关系。但事实并非如此。如果遇到这样的情况,我们可以用isvalid
函数对这些字符串做一下有效性的判断。再结合length
函数的行为特点,这通常就可以为我们解惑了。
6.3.3.2 索引
我们可以用索引表达式从一个字符串值中抽取某个代码单元。例如:
comment1[1]
就是一个索引表达式。索引表达式通常由一个可索引对象以及一个由中括号包裹的索引号组成。这里的可索引对象和索引号都不仅限于字面量,还可以是标识符或表达式,只要最终能代表它们就可以了。显然,字符串值就是一种可索引对象。而这里的索引号的有效范围则依从于前面所述的基本设定。
66
是一个有效的索引号。然而,当我们用它对comment1
进行索引时,仍然会引发一个错误:
这是为什么呢?其原因是,只有在索引到某个字符的第一个代码单元时,索引表达式才能正确地获取这个字符。因为索引表达式的求值结果会是一个Char
类型的字符值,而只拿到一个 Unicode 代码点的某个部分是毫无意义的。这与codeunit
函数的行为截然不同。
还记得吗?对于 UTF-8 编码格式来说,一个中文字符需要用掉 3 个代码单元。在变量comment1
代表的字符串中,最后一个字符是型
。因此,索引号66
对应的应该是,表示该字符的那 3 个代码单元中的最后一个。让我们来验证一下:
果然,索引号64
对应的就是型
的第一个代码单元。这样的索引号也可以被称为有效字符的起始索引号,简称字符索引号。
可是,对于一个外来的字符串值,我们怎么知道其中的哪些索引号是字符索引号呢?难道需要逐个试错吗?幸好不用这样。我们可以使用isvalid
函数来做这样的判断:
另外,还有一些函数可以帮助我们更好地索引字符串值中的字符。比如,firstindex
函数会返回字符串值中的第一个字符索引号,通常就是1
。lastindex
函数会返回字符串值中的最后一个字符索引号。对于comment1
来说,它就是64
。我们还可以使用关键字end
来指代最后一个字符索引号。它可以被直接应用于索引表达式中。例如:
当需要更加精确的索引时,我们可以使用thisind
函数。示例如下:
这个函数接受两个参数。第一个参数代表被索引的字符串值(以下简称slogan1
),第二个参数代表索引号(以下简称ind
)。如果ind
对于slogan1
来说正好是某个字符索引号(也就是说它对应于某个字符的第一个代码单元),那么该函数就会直接将ind
的值返回。如果ind
属于其他的有效索引号,那么与它对应的代码单元肯定是某个字符(可称当前字符)的后续部分之一。在这种情况下,thisind
函数会向前寻找到当前字符的起始索引号,并将其返回。在上例中,调用表达式thisind(comment1, 12)
就属于这种情况。但无论怎样,这个函数的结果值的类型一定会是Int64
。
拥有类似功能的函数还有prevind
和nextind
。prevind
函数可以返回在当前字符之前的第n
个字符的起始索引号,而nextind
函数则可以返回在当前字符之后的第n
个字符的起始索引号。这里的n
默认是1
,并可以由函数的调用方给定。
特别提醒一下,我们虽然可以通过索引表达式访问到字符串值中的某个字符,但却不可以修改其中的任何字符。其根本原因是,Julia 中的字符串值都是不可修改的!
6.3.3.3 截取
我们可以通过两个索引号截取出一个字符串的某个片段:
不过要注意,只要有一个索引号不是字符索引号,这个索引表达式就会立即引发错误。示例如下:
这种(范围)索引表达式的求值结果会是一个String
类型的字符串值。即使结果中只包含一个字符也是如此。这与普通的(点)索引表达式有着明显的区别。
另外,范围索引表达式的结果值其实是一个复本,拷贝自源字符串中的某个片段。如果被拷贝的字符过多,那么有可能会对程序的性能产生一定的影响。为了应对这种情况,我们可以基于某个字符串片段创建一个子字符串,以避免其中字符的拷贝。具体的做法是,使用SubString
类型:
SubString
类型的构造函数可以接受三个参数,第一个参数代表源字符串,后两个参数都代表索引号。该函数会生成一个视图,并基于此视图创建一个子字符串的值。你可以把这里的视图理解为一个窗口。这个窗口只能看到两个给定索引号之间的那些字符。
子字符串的值与字符串值看起来没有什么两样。而且,针对后者的操作也基本上都能应用于前者:
但是,SubString
类型的实例创建成本往往明显低于String
类型。所以,我们在截取字符串片段的时候应该优先使用它。
6.3.3.4 拼接
我们有时候需要把多个字符串拼接在一起。这时就可以使用string
函数:
此外,操作符*
也可以派上用场:
对于字符串值来说,*
的含义就不再是“乘以”了,而是“拼接”。这个操作符被用在这里可能会让你感到有些不适应。因为很多其他的编程语言都是用操作符+
来拼接字符串的。
Julia 语言的缔造者们是站在抽象代数的角度来看待这一问题的。在抽象代数中,+
通常被用在那些满足交换律的运算中,而*
常常被用在不满足交换律的运算中。对于字符串拼接来说,"A"
拼接"B"
与"B"
拼接"A"
肯定不是一回事,一定会得到不同的结果。所以,操作符*
理应被用在这里。
倘若你不熟悉抽象代数也没有关系。你可以这样来理解:数值相加是基于数学逻辑的运算,而字符串值的拼接则是基于空间的合并。所以它们理应使用不同的操作符号来表达。
无论怎样,我们都应该记住:在 Julia 中,字符串拼接用的是操作符*
,而不是+
。并且,字符串拼接总会产生全新的字符串值。
6.3.3.5 插值
所谓的插值就是,在一个字符串值中动态地插入其他值。这需要把符号$
作为前缀。正因为$
在字符串中的作用特殊,所以才有了转义序列\$
,以表示$
字符本身。
还记得吗?我们其实在第 1 章就用过插值。那时的代码是这样的:
在这个字符串值中,$(name)
就是那个动态的部分,也被称为插值部分。其含义是动态插入由标识符name
代表的值。这部分可以被简写为$name
。不过,为了保证不引起歧义,我用圆括号把这个标识符包裹了起来,以明确区别于其他的静态字符。示例如下:
可以看到,随着我为变量name
绑定不同的值,println
函数打印出的内容也在动态的改变。
其实,跟随$
的并不仅限于标识符,还可以是任何的表达式。例如:
解释一下,这里的?
是一个三元操作符。因此,表达式isvalid(dup_chars) ? "Yes" : "No"
的含义就是,如果dup_chars
代表的字符串值只包含有效字符,那么就使用"Yes"
作为结果值,否则就使用"No"
作为结果值。
也许你已经发现了,在插值部分中,那些用于包裹字符串的双引号(比如"Yes"
中的双引号)并不需要被转义。这主要是因为,插值部分相当于镶嵌在字符串中的代码,代码中的双引号自然用不着再转义。但这有两个前提条件,一个是插值部分必须有圆括号包裹,即:形如$(...)
。另一个条件是其中的双引号必须成对的出现。
另外,插值部分中的求值结果不仅可以是字符串值(如前面的"Yes"
和"No"
),还可以是其他任何类型的值。实际上,它们的值总会由string
函数(还会涉及到print
函数和show
函数)转换为字符串值,或者说对象的规范文本表示形式。这种表示形式通常会以最简单的文本展示出对象的内部状态,并尽量避免暴露过多的细节。下面是一些例子:
6.3.3.6 搜索
我们可以利用一些函数在一个字符串值中搜索指定的字符串。我们可称前者为被搜索的字符串值,并称后者为目标字符串。
比如,函数findfirst
和findlast
,它们分别会以从前向后和从后向前的顺序去搜索目标字符串,并会在碰到第一个匹配的字符串时停下来,然后返回与之对应的索引号范围值,形如1:10
。在此类值中,冒号左边的正整数代表,目标字符串在被搜索的字符串值中的起始字符索引号。而冒号右边的正整数则代表,目标字符串在被搜索的字符串值中的末尾字符索引号。下面我们来看一个例子:
在这个调用表达式中,第二个参数值就是将要被搜索的字符串值,而第一个参数值则是我们给予的目标字符串。对于slogan1
代表的值来说,目标字符串的起始字符入
在其中的字符索引号是13
。相应的,它的末尾字符门
在其中的字符索引号是16
。所以,这里调用的结果就是13:16
。
类似的,函数findprev
和findnext
都会从给定的索引号开始搜索目标字符串。不同的是,前者会向前搜索,而后者会向后搜索。示例如下:
注意,在slogan2
中存在两个目标字符串。而且,我们给予这两个函数的第三个参数值都是19
,代表着中文逗号,
在slogan2
中的字符索引号。显然,在参数值完全相同的情况下,findprev
函数和findnext
函数返回的结果值是不同的。
从 Julia 的 1.3 版本开始,上述 4 个函数还可以直接用于搜索指定的单个字符。它们会在找到字符后返回与之对应的字符索引号。不过,在这之前,我们也可以做到这一点,但需要多敲一些代码,传入一个用来做条件判断的函数。比如:
注意,isequal('门')
原本是一个调用表达式,但在这里它代表的却是一个匿名的函数。它表示的条件是“字符必须等于'门'
”。
如果没有找到目标字符或字符串,那么这些函数就会返回nothing
。这个nothing
比较特殊,它是Nothing
类型的唯一实例,用于表示一个表达式没有实质的结果值,或者一个变量或字段没有值。注意,nothing
作为求值结果在 REPL 环境中是不显示的:
最后提一下,如果我们只想知道一个字符串值中是否存在某个字符或字符串,那么就可以使用occursin
函数。这个函数总是会返回一个Bool
类型的结果值。比如,调用表达式occursin('窗', slogan1)
的求值结果是false
。
6.3.3.7 比较
比较操作符也可应用于字符串值。我们在上一章说过,对于这类值,比较操作符会逐个字符地进行比较,并且忽略其底层编码。对于默认的字符串值,以及任何符合 Unicode 编码标准的字符串值(不论它们采用的是哪一个编码格式),比较操作符都会基于 Unicode 代码点对它们进行比较。如果字符串中只包含英文字母,那么你也可以认为它基于的是其中每个字符的字典顺序。例如:
字符串"Julia"
和"Julie"
的前 4 个字母都是相同的。但是,前者的最后一个字母a
在字典中比后者的最后一个字母e
更靠前。所以,前者小于后者。
对于字符串"Julia"
和"Julian"
,两者的前 5 个字母都相同,且前者算是后者的一个前缀。在这种情况下,后者肯定大于前者。再来看一个例子:
虽然"Michael"
比"Mike"
更长,但是它的第 3 个字母c
在字典中比"Mike"
的第 3 个字母k
更靠前,所以它是小于"Mike"
的。
不过要注意,大写的英文字母总是会小于小写的英文字母。因为前者的 Unicode 代码点肯定比后者的 Unicode 代码点要小。这与它们在 ASCII 编码集中的顺序也是相同的。比如,表达式"JuliA" < "Julia"
的求值结果一定是true
。
我们再看中英文混合的情况:
中文字符入
的 Unicode 代码点比基
的 Unicode 代码点要小。所以,"Julia 编程入门"
一定会小于"Julia 编程基础"
。
如果你有兴趣,还可以使用LegacyStrings.jl
程序包中的函数来生成采用 UTF-16 或 UTF-32 编码的字符串值,然后再比较(甚至混合比较)它们。比较结果肯定同样符合上述的规则。
6.4 非常规的字符串值
如果一个字符串值不仅包含了由双引号包裹的字符串,还包含了某个特定的前缀,那么我们就说这个字符串值是非常规的。
6.4.1 原始字符串
我们为了表示字符串值而输入的内容又被称为字符串字面量。在一般情况下,字符串值的实际内容会与我们为此输入的字符串字面量保持一致。例如:
除非其中包含了非经典的转义序列或者插值部分。注意,虽然我们输入的经典转义序列会被原样保留在字符串值中,但当该值被打印的时候这些转义序列还是会被转义。比如:
显然,当上面这个字符串值被打印时,在打印出的内容的最后有两个真正的换行。
如果我们想让一个字符串值被打印出的内容与我们为它输入的字符串字面量完全相同,那么就可以使用原始字符串的形式来表示它。在这种情况下,即使字符串字面量中包含了任意的转义序列和插值部分,这种一致性也是可以得到保障的。
原始字符串的形式是由前缀raw
和常规的字符串值组成的,如:raw"Julia\n\n"
。这种形式会生成常规的字符串值。但不同的是,我们输入的所有内容最终都会保持原样,包括$
和\
。示例如下:
不要被上面回显的内容所迷惑。其中的\\n
实际上就代表了内容\n
。这是因为,在常规的字符串值中,\n
是会被转义为真正的换行的。所以 Julia 在它的前面又加了一个\
,以表示第二个反斜杠代表的并不是转义序列的前缀。
我们把上面的字符串值打印出来看一下就清楚了:
总之,原始字符串的形式会让一个字符串值的最终输出与最初输入保持一致。为此,Julia 可能会对字符串值的内容稍加修改。
6.4.2 整数和浮点数
我们在上一章讲过,一个常规的字符串值再加上一个前缀big
就可以代表任意精度的(BigInt
类型的)整数值或者(BigFloat
类型的)浮点数值。但前提是,在两个双引号之间的必须是有效的整数字面量或者浮点数字面量。例如:
注意,如果要用科学计数法表达浮点数,那么我们只能使用字母e
,而不能用f
或p
,否则 Julia 就会报错。示例如下:
另外还要注意,虽然我们可以在这里使用三联双引号,但是并不建议这样做。因为这么写没有明显的好处,而且容易因失误而输入无效的字面量。比如:
6.4.3 版本号
我们已经知道,Julia 的版本号遵循 Semantic Versioning 规范。其一般形式是vX.Y.Z
。其中的X
代表主版本号(或称大版本号),Y
代表次版本号(或称小版本号),而Z
则代表修订版本号。并且,它们都只能是正整数或0
。
在 Julia 程序中,这样的版本号可以由一种非常规的字符串值表示。其形式是,以字母v
作为前缀,再加上一个内容符合上述规范的字符串字面量。比如,v"1.3.0"
,我们可以称之为版本号值。
在这样的版本号值中,次版本号和修订版本号都可以被省略,并且被省略的部分将会被视为0
。因此,v"1.3"
就相当于v"1.3.0"
,而v"1"
就相当于v"1.0.0"
,等等。
另一方面,我们还可以在版本号值中追加更多的信息,包括:预发布信息和构建信息。预发布信息实际上指的是那些非稳定版本的信息。比如,我们通常在正式发布稳定版本1.0.0
之前还会发布一系列用于测试或候选的非稳定版本。这些非稳定版本的信息肯定需要体现在对应的版本号中。此类信息可以是-alpha1
、-beta.2
等等。而构建信息表达的是程序构建时处于或针对的环境。它可以是程序构建的日期,也可以是程序当次构建所针对的计算平台(包括操作系统和计算架构),比如+20200101
、+win64
等等。
预发布信息的格式为,一个减号-
再加一个预发布标识,且减号可以被省略。其中的预发布标识可以包含一到多个小写的英文字母、0
到9
的数字、减号-
和英文点号.
。但是,英文点号不能作为开头或结尾,且多个英文点号不能相邻。另外,当最开始的减号被省略时,预发布标识中的第一个字符还不能是数字,否则就可能会引起歧义,从而导致版本号的识别错误。例如,预发布标识为alpha
、alpha1
、alpha.1
、-alpha.1
和1a
都是可以,但.1a
和1..a
却都是不合法的。又例如,当版本号值是v"1.0.01a"
时,修订版本号会被识别为01
,而预发布信息会被识别为a
。这与我们想表达的预发布信息(即1a
)并不相符。
按照一般的惯例,alpha
、beta
和rc
都常被用作预发布标识的前缀,并分别代表内部测试版、公共测试版和候选版。
构建信息的格式是,一个加号+
再加一个构建标识。构建标识同样可以包含一到多个小写的英文字母、0
到9
的数字、减号-
和英文点号.
,而且对英文点号的用法限制也和预发布标识是一样的。因此,我们在这里放置某种日期时间的简化表示、哈希序列以及计算平台的代号等都是没问题的。
除了上述的规范格式之外,Julia 中的版本号还可以包含两个特殊的标记。其中一个标记是单独的减号-
。它的存在有个前提条件,即:版本号中不能包含预发布信息和构建信息。在此条件下,我们可以用这个标记作为版本号的后缀,以指代某个特定版本的下限。例如,v"1.0.0-"
一定会比稳定版本v"1.0.0"
以及诸如1.0.0-alpha
和1.0.0-beta1
这样的非稳定版本都要小。
另一个特殊标记是单独的加号+
。它的存在也有一个前提条件,那就是:版本号中不能包含构建信息。在这个条件下,我们可以用这个标记作为版本号的后缀,以指代某个特定版本的上限。例如,v"1.0.0+"
一定会比v"1.0.0"
和v"1.0.0+win64"
都要大。
请注意,包含了这两个特殊标记(之一)的版本号无法表示任何具体的版本。但它们对于版本号的比较操作来说还是很有用的。另外,这两个特殊标记不能出现在同一个版本号值中。
版本号的比较
常量VERSION
代表着当前 Julia 语言的版本号。与其他的版本号值一样,它是VersionNumber
类型的。这个类型的值是可以被比较的。我们之前讲到的所有比较操作符都可以应用在它们身上。
不过,针对这类值的比较操作有些特殊。它不是单纯地按照数值顺序或字典顺序进行的。在比较此类值的时候,Julia 会先以数值顺序依次地比较它们的主版本号、次版本号和修订版本号。如果这三者都两两相等,那么 Julia 就会去比较它们的预发布信息。在其他部分都相等的情况下,有预发布信息的版本号值一定会比没有该信息的版本号值要小。
预发布信息会被其中的英文点号分割为多个单元。这些单元会以从左到右的顺序被成对地比较。对于每对单元,如果其中都只包含数字字符,那么 Julia 就会以数值顺序比较它们,如:v"1.0.0-alpha.9"
会小于v"1.0.0-alpha.11"
。否则,Julia 就会以 ASCII 编码集的顺序逐个字符地进行比较,如:v"1.0.0-alpha.a9"
会大于v"1.0.0-alpha.a11"
。一旦分辨出某对单元谁大谁小,也就可以确定两个预发布信息的大小了。但如果所有成对的单元都相等,那么就要看哪一个预发布信息拥有更少的单元了。在这时,更少的单元意味着更大的值。
版本号值中的构建信息也会在最后参与比较。它的比较规则与预发布信息的比较规则基本一致。唯一不同的是,在其他部分都相等的情况下,有构建信息的版本号值一定会比没有该信息的版本号值要大。
有了以上这些规则,再结合我们刚刚在前面说的那两个特殊标记,就有了下面的关系:
6.4.4 正则表达式
所谓的正则表达式(regular expressions),就是使用一系列的符号来表达字符串的特定模式的公式。它常常被用来检索或替换那些符合某个特定模式的字符串片段,又或是用于判断一个字符串是否符合某些特定的模式。注意,这远远要比在一个字符串中搜索某个固定的字符串片段要复杂得多。
Julia 的正则表达式其实是一个舶来品,传承自 Perl 语言。Perl 是一种用于编写脚本程序的编程语言,诞生于 1987 年。该语言内置的正则表达式引擎在功能上非常的强大,而且算是一个集大成者。它也因此一度成为了业界标准。
在底层,Julia 的正则表达式是由 PCRE 库支持的。PCRE 是 Perl Compatible Regular Expressions 的缩写。它使用了与 Perl 5 几乎相同的语法和语义来实现正则表达式的模式匹配。更确切地说,Julia 使用的是 PCRE 库的新实现,名为 PCRE2。这个新实现诞生于 2015 年,目前已经发展到了第10
个版本。
实际上,Julia 在识别由字符串值代表的版本号时就用到了正则表达式。我们可以利用函数match
和代表了正则表达式的常量Base.VERSION_REGEX
来判断一个版本号的格式是否符合规范。例如:
如果符合规范,那么match
函数就会返回一个RegexMatch
类型的值,否则它就会返回nothing
。根据 REPL 环境回显的内容可知,match
函数已经识别出了版本号"1.0.0-rc1+win64"
中的各个组成部分。
我在这里不想过多地介绍正则表达式的语法和用法。因为系统的介绍会占用非常大的篇幅,足以写成一本书了。实际上,目前市面上已经有不少介绍正则表达式的图书了。如果有必要,你可以挑选一本来阅读,也可以去参看 PCRE2 官方网站上的语法文档和模式文档。
我下面只从非常规字符串值的角度,说一下正则表达式的一般表示形式和基本操作。
这种非常规的字符串值由前缀r
和包含了正则表达式的字符串字面量组成,以下简称正则值。正则值的类型总是Regex
。比如,正则值r"^(\d+)$"
可以匹配只包含了一个或多个数字字符的单行字符串。又比如,正则值r"\+((?:[0-9a-z-]+\.)*[0-9a-z-]+)"
可用于匹配版本号中的构建信息。
我们现在来简单地拆解一下上面的第二个正则表达式。首先是转义序列\+
。这是在正则表达式中特有的转义序列。它表达的含义是,这里的加号+
只是一个普通的字符,而不是用于指示匹配次数的量词(quantifier)。类似的转义序列还有\.
、\*
、\(
等等。
紧随其后的是一个捕获组(capture group),即:由圆括号包裹的子表达式。它可以实现两个功能:分组和捕获。说明如下:
分组功能:可以把捕获组中的子表达式看成一个独立的整体。使它可以独立匹配字符串片段,并能成为一些符号(比如量词)的作用对象。比如,
(-|\+)?([0-9]+)+
可以匹配代表整数的字符串。其中,第 1 个捕获组可以独立匹配正负号,同时也是量词?
的作用对象并以此表示正负号可有可无。而第 2 个捕获组可以独立匹配数字字符,同时也是量词+
的作用对象并以此表示数字字符至少要有一个。捕获功能:可以提取出捕获组中的子表达式,以便在后续引用。比如,
(-|\+)?([0-9]+)+\.(\g<2>)+
可以匹配代表小数的字符串。其中,第 3 个捕获组中的\g<2>
的含义就是引用第 2 个捕获组中的子表达式,以表示小数部分的模式与整数部分的模式相同。
我们接着拆解可以匹配构建信息的那个正则表达式。在紧随转义序列\+
的那个捕获组中,还有两个独立的子表达式。
第一个子表达式是(?:[0-9a-z-]+\.)*
,是一个非捕获组(non-capture group)。非捕获组的含义是只有分组功能而没有捕获功能的组,一般以(?:
为前缀且以)
为后缀。在这个非捕获组中的[0-9a-z-]+
表示至少要有一个0
到9
的数字、小写英文字母或减号-
。而\.
则表示前者可以以英文点号.
为后缀。最后的量词*
表示这个非捕获组所表达的字符串片段可以有零个到多个。
如果你理解了第一个子表达式,那么再看第二个子表达式[0-9a-z-]+
肯定就毫无阻碍了。这两个子表达式合在一起就形成了外层捕获组的子表达式。它表示了构建信息本身的模式。再加上最左侧的转移序列\+
,这个正则表达式就可以识别出合法的构建信息并提取出构建信息本身了。就像下面这样:
在 REPL 环境的回显内容中,跟在RegexMatch
和(
后边的"+win64.20200101"
就是已被成功识别的构建信息。而1="win64.20200101"
则表示第 1 个捕获组匹配的字符串是"win64.20200101"
。
在 Julia 程序中,我们可以通过访问RegexMatch
类型值的一些字段来了解匹配结果的具体细节。这些字段有:
match
:代表匹配到的整个字符串。captures
:代表所有捕获组匹配到的字符串片段,会以字符串数组的形式表示,并以捕获组的序号为顺序。offset
:代表匹配到的整个字符串在被匹配的完整字符串中的偏移量,可以理解为前者在后者中的首个字符索引号。offsets
:代表所有捕获组匹配到的字符串片段在被匹配的完整字符串中的偏移量,会以整数数组的形式表示,并以捕获组的序号为顺序。regex
:代表匹配时所使用的正则值。
相关的示例如下:
除了match
函数,正则值还可以作为occursin
函数的第一个参数值,以及作为replace
函数的第二个参数值。
利用replace
函数和正则值,我们可以对字符串值的内容进行一些复杂的修改和替换(当然,这会生成新的字符串值,而原字符串值会保持不变)。比如:
以s
为前缀的非常规字符串值专门用于表示替换字符串(substitution string),以下简称替换值。替换值的类型总是SubstitutionString
。在这里,我用正则值、符号=>
和替换值组成了一个替换对,以表示:把与该正则值相匹配的字符串替换为该替换值中的内容。在这个替换值中,我们可以使用\g<n>
或\n
来引用正则值中的捕获组,其中的n
代表捕获组的序号。因此,我用"\1\2\3\4\5\6"
重新组织了源字符串值中的内容。
对于正则值,除了必要的前缀r
,我们还可以为它添加后缀i
、m
、s
和x
。这些后缀的含义如下:
i
:在进行模式匹配时不区分大小写。这会依从于相应编码标准中的规则。最简单的案例是,不区分某一个英文字母的大写和小写,把两者视为同一个字符。m
:将源字符串视为多行的字符串值。也就是说,修改原本指代字符串最前端的^
和指代字符串最后端的$
的含义,分别改为指代任何行的最前端和指代任何行的最后端。如此一来,我们就可以分别针对源字符串中的每一行做模式匹配了。s
:将源字符串视为单行的字符串值。也就是说,将原本指代了除换行符以外的任何字符的.
的含义改为可指代所有字符。这样我们就可以针对源字符串的全范围做模式匹配了,即使它拥有多个行也是如此。x
:允许我们在正则表达式中的某些位置上添加一些空白,甚至是换行。这可以提高正则表达式的(人类)可读性。
下面的示例有助于你理解这些后缀的含义。
最后,顺便说一下,我们可以使用三联双引号来包裹正则值中的字符串字面量。在某些情况下,这样做可以让正则表达式的内容更加清晰。比如:
可以看到,在用了三联双引号之后,我们就不需要再为正则表达式中的双引号做转义了。
6.4.5 字节数组
字节数组也可以由一种非常规的字符串值表示。但这样表示的字节数组是只读的。这种字节数组的类型是Base.CodeUnits{UInt8, String}
。例如:
我用字符串值b"abcdef"
生成了一个长度为6
的字节数组。这个字节数组中的每一个元素值都表示了"abcdef"
经编码后在对应字节上的存储内容。更确切地说,Julia 会先用 UTF-8 编码格式把字符串值中的内容转换成一个个字节,然后再把这些字节按照先后顺序保存到一个字节数组当中。
在这种非常规的字符串值中,我们可以使用任何有效的形式来表示一个 ASCII 编码值或者一个 Unicode 代码点。比如:
关于这些表示形式的细节,我们在前面已经讨论过了。我就不在此重复了。另外,我们还没有正式讲数组和它的类型,所以我在这里并不打算展开来说。你目前只需要知道,有这样一种非常规的字符串值,它能够表示只读的字节数组。
6.5 小结
我们在本章主要讲解了字符和字符串。这两者都可以表示处于 Unicode 代码空间中的字符。但不同的是,前者只能表示一个字符,而后者可以表示多个字符。
我们首先简要地介绍了 ASCII 编码和 Unicode 编码标准,并提及了后者中的一种编码格式:UTF-8。Julia 通常采用 UTF-8 编码格式把字符转换为由若干个字节承载的二进制数。
然后,我们讲述了 Julia 中的字符值。这包括它的表示与操作和它的类型与转换方法。多个字符可以组成一个字符串。所以我们紧接着又讲了字符串值的表示以及在其类型之上的设定。这些设定是我们操作字符串值的基础。我们可以对字符串值做的操作有,获取长度、索引、截取、拼接、插值,以及搜索和比较。
除了常规的字符串值,我们还可以利用简单的前缀编写非常规的字符串值,以表示某类特殊值。比如,原始字符串、任意精度的整数和浮点数、版本号、正则表达式,以及只读的字节数组。在某些场景下,这些特殊值是非常有用的。
字符和字符串是我们在 Julia 编程过程中非常常用的两类值。它们的表示方式颇多,且操作方法多样。我们往往需要根据具体情况对它们加以合理的运用。最后再强调一下,字符值和字符串值都是不可变的!
系列文章:
Julia编程基础(一):初识Julia,除了性能堪比C语言还有哪些特性?
Julia编程基础(二):开发Julia项目必须掌握的预备知识
Julia 编程基础(四):如何用三个关键词搞懂 Julia 类型系统
评论