logo一言堂

Erlang 中的字符串

在所有编程环境中,字符串都是非常常见常用的数据结构,在Erlang也是一样。但是,主要字符串经验来自perl和c的我一头撞进了Erlang的世界,撞出了满头的包。这里我总结一下我过去一个月的经验教训,希望对读者和未来的我有所帮助。

Erlang中的各种字符串

在大多数编程环境中,字符串只有一种,或者最多少数几种。但是在erlang中,字符串的表达方式有很多种,甚至可以说无穷种。

单链表字符串

Erlang是一种函数式语言(FP). FP和OOP的区别我之前已经写过。Erlang深受函数式语言的鼻祖Lisp影响,最基本的数据结构是单链表。所以,最传统的字符串表达方式是单链表:[$h, $e, $l, $l, $o] ,简化写法就是 "hello". 每一个字符用一个整数表示,放在一个单链表(不是数组)里。这个链表的头是包含字符h, 和一个指针指向下一节,这下一节包含字符e, 和指针指向再下一节,直到最后,尾指针指向空表nil. 如果是ASCII可以表示的字符,则这个整数小于128,用一个字节表示。复杂字符用unicode表示,其数值大于255.

单链表字符串是最方便处理,也是最方便用常量表示的字符串,erlang核心库函数大多要求单链表字符串,例如文件名什么的。

二进制字符串

单链表字符串有个最大的毛病,就是占内存。以上述"hello"字符串来说,包含5个小节,每个小节包含一个整数,就是八个字节,和一个指针,又是八个字节。加在一起有89个字节,还不包括一些其他代价。在c语言中,同样的字符串能被6个字节表示,即五个字符和一个\0空字符。这样看起来不是太浪费了一点?

二进制字符串 (binary) 就是为了解决空间效率问题。Binary在这里其实应该理解成无结构字符串。在erlang常量写法中,表示为 <<"hello">>. 如果要表示复杂字符,例如汉字,则要加编码标记如: <<"周溱"/utf8>> . 它的意思是字符串 "周溱" 用utf8编码,写成紧密的一堆字节。这和其他编程语言就非常类似了。erlang只需要维护内存分配的元数据,例如长度等,就可以用最空间紧凑的方式表示任意字符串。

erlang核心库函数中做字符串处理的部分,例如stringre模块,都在支持单链表字符串的基础上,还支持二进制字符串。

高级字符串

二进制字符串虽然空间紧凑,但和单链表字符串比起来,用函数式语言处理相对不方便。例如,我们要把两个字符串接起来,如 "hello" ++ "world", 用单链表只需要做链表操作,而用二进制字符串则必须做内存分配和内存拷贝。假如binary长的话,这代价不小。所以,还有高级货。在erlang中,广义上字符串的定义是:

  1. 整数,代表单个字符
  2. Binary,代表编码后的一段数据
  3. 单链表,其中每节都是字符串

注意,这个定义是递归的。所以,"hello" 是字符串,<<"hello">>是字符串,["he", <<"llo">>]也是字符串,等等。同一个字符串,可以有无穷种表示方法,甚至空串也有无穷种表示方法!erlang核心库函数中做字符串处理的部分,例如stringre模块,其实支持的是高级字符串,这些模块中函数的返回结果,有可能不是简单串,完全取决于你输入的是什么,输出是怎么方便怎么来。

所以说,如果你不是自己构造的字符串,你不可以用erlang模式匹配或者==来判断,而必须使用string:equal/2, string:is_empty/1 等等库函数来做判断。否则就是隐藏很深的bug.

iodata

iodata不是字符串,但和字符串深度相关。它的定义同样是递归的:

  1. 整数,代表单个字节
  2. Binary,代表一段任意数据
  3. 单链表,其中每节都是iodata

和字符串很像,对吧?但是一个字符串未必是iodata,因为可能包含大于255的整数字符,一个iodata也未必是字符串,因为可能包含unicode中不包含的非法字符。erlang中所有输出函数支持的其实是iodata,而不是字符串。因为输入输出凭什么一定是unicode合法字符串呢?

规范化处理

当然,erlang提供标准库函数来把各种奇形怪状的字符串规范化成字符链表 unicode:characters_to_list/1,或单一二进制串 unicode:characters_to_binary/1. 这函数名字有点长。有时候你可能会倾向使用 lists:flatten/1,但这也是bug:因为它不能展开binary:

1> lists:flatten(["he", "llo"]).
"hello"
2> lists:flatten(["he", <<"llo">>]).
[104,101,<<"llo">>]

规范化处理其实也是有相当代价的,所以要谨慎使用。

怎么办

为了防止程序员精神错乱,我强烈建议在程序中遵守一定的规则:

不要使用大于127的零散字符

就是说,"abc" 没问题。但 "周溱" 有问题,一定要写成复杂一点的 <<"周溱"/utf8>> . 只要你遵守这条规则,字符串就成为了iodata的子集,因为你总会有一天试图把处理后的字符串当作 iodata 来用的,到时候你的程序就会崩溃。其实,任何零散字符对于输出来说很不经济,但少量ASCII字符无伤大雅;零散长字符就不仅是效率的问题了。

松输入,紧输出

Be conservative in what you send, and liberal in what you accept. -- Jon Postel

你的程序提供的接口不要对输入做过多假定。假如你的输入是字符串,那么只要你保证使用 unicode, stringre 模块中的函数来处理,那你一定是安全的。假如你的输入是网络或文件,则erlang库函数可以保证读出来的是不定长度的binary,你把这些bianry放在链表里当字符串也没错。

但你程序的输出接口越规范越好。假如你的程序直接面向网络或文件输出,那么只要你能确保没有大于127的零散字符,就不用规范化处理。其他所有情况,请规范化成binary,这也就是Elixir的实际规范。

总结

Erlang有35年的历史,这历史带给erlang无以伦比的强壮性,和一定的历史包袱。elixir是erlang的简化增强版,例如在字符串处理和输入输出上大大方便了程序员的工作。在大多数情况下我们应该使用elixir。但是elixir程序员必须了解erlang,读懂erlang, 然后写一点erlang,这样才能更深入理解erlang/elixir体系的所以然,才能更好武装自己。