Exercism 上的练习都很小,是人为构造出来的,而且往往看似微不足道。很容易让人觉得,经验丰富的开发者根本没什么可向它们学习的。然而,解决这些人为设计的问题,能促使你去学习和运用这门语言中你可能还没探索过的部分。这些新学到的东西,能让你更高效、也更有表现力地解决现实世界里的问题。
并行字母频率是Exercism 的 Elixir 学习路径上的一道中等难度的练习,里面蕴含着多得惊人的有趣知识。解决这道练习的一个核心挑战,是处理来自多种语言的字母,因为其中一个测试用例是德语的,包含英文字母表以外的字符。如果你大部分时间都在为英语使用者开发应用,这可能是你第一次遇到这样的需求。从这道练习中学到的东西,对任何编写多语言或非英语应用的人都有明显的好处,但它也能在很多其他方面帮上忙,比如更健壮的用户名和密码校验。
要想顺利解出这道练习,你需要实现一个函数Frequency.frequency/2,用来统计一组字符串中的字母频率,这些字符串可以是任何语言:
iex> Frequency.frequency(["Freude", "schöner", "Götterfunken"], workers)
%{
"c" => 1,
"d" => 1,
"e" => 5,
...
"ö" => 2
}
我们先从这个函数需要解决的根本问题入手,一步步搭出完整的实现。
在 Elixir 中判断一个字符是不是字母
你会怎么用 Elixir 判断"a"是不是一个字母?
我想大多数人会用一个像/[a-z]/这样的正则表达式:
iex> String.match?("a", ~r/^[a-z]$/)
true
那"A"呢?
加上i这个(不区分大小写)修饰符大概是最简单的做法:
iex> String.match?("A", ~r/^[a-z]$/i)
true
好,那"ö"呢?
我最初接触这个问题时,并不确定最好的办法是什么。/[a-z]/i肯定行不通:
iex> String.match?("ö", ~r/^[a-z]$/i)
false
判断"ö"是不是字母,是解决这道 Exercism 练习的关键环节,因为测试中的一篇文本是德语的:
# parallel_letter_frequency_test.exs
...
# Poem by Friedrich Schiller. The corresponding music is the European Anthem.
@ode_an_die_freude """
Freude schöner Götterfunken
...
"""
也许你可以用正则表达式来检查某个字符不是特殊字符,但这样的表达式很可能又长、又不优雅,还很脆弱。你怎么能确定自己已经覆盖了所有可能作为输入传给函数的特殊字符呢?我相信有更好的办法。
Elixir 中的 Unicode 正则表达式
解决这个问题更好的办法,是使用 Elixir 的Regex模块里的u修饰符:
unicode(
u):启用\p这类 Unicode 专属模式,并改变\w、\W、\s等修饰符,使它们也能匹配 Unicode。
事实证明,u修饰符,特别是其中的\p模式,是一个非常优雅的解决方案。\p模式能让你匹配任意一个Unicode 字符类别中的字素(也就是单个 Unicode 字符的另一种叫法)。它不仅包括Ll(字母,小写)和Sc(符号,货币)这类具体类别,也包括L(字母)和S(符号)这样的父类别。
借助\p{L}模式,你可以匹配_任意_一门Unicode 覆盖的人类语言里的_任意_字母,不分大小写。这能实现一些相当强大的匹配。
英语里的基本拉丁字符,用起来和平时一样:
iex> String.match?("a", ~r/^\p{L}$/u)
true
iex> String.match?("A", ~r/^\p{L}$/u)
true
iex> String.match?("ö", ~r/^\p{L}$/u)
true
iex> String.match?("Á", ~r/^\p{L}$/u)
true
我们再确认一下,它并不是对任何字符都返回匹配。来看几个看起来像字母、其实不是的字符:
iex> String.match?("$", ~r/^\p{L}$/u)
false
iex> String.match?("@", ~r/^\p{L}$/u)
false
很不错,不过还记得我说的是_任意_语言吗?毫无压力:
# Chinese character for "you"
iex> String.match?("你", ~r/^\p{L}$/u)
true
# Cyrillic capital letter "zhe"
iex> String.match?("Ж", ~r/^\p{L}$/u)
true
把 Unicode 匹配应用到当前问题上
现在我们有了一个能帮我们判断某个字素是不是字母的工具,就可以用它来解决问题了。Frequency.frequency/2函数的一个初步实现大概长这样:
def frequency(texts, _workers) do
texts
|> get_all_graphemes()
|> count_letters()
end
defp get_all_graphemes(texts) do
texts
|> Enum.join()
|> String.graphemes()
end
count_letters/1要做的事,就是套用我们前面找到的String.match?(grapheme, ~r/^\p{L}$/u)模式,给graphemes列表里每个字母的计数加一。下面这个示例实现,来自我对这道 Exercism 练习的解答:
defp count_letters(graphemes) do
Enum.reduce(graphemes, %{}, fn grapheme, acc ->
if String.match?(grapheme, ~r/^\p{L}$/u) do
downcased_letter = String.downcase(grapheme)
Map.update(acc, downcased_letter, 1, fn count -> count + 1 end)
else
acc
end
end)
end
这个函数接收一个由字素组成的列表,比如["a", "A", "ö", "$"],返回一个映射:只统计字母并忽略大小写,也就是%{"a" => 2, "ö" => 1}。考虑到这个函数能处理任何语言的输入,我觉得这 9 行代码相当强大。
结语
事实证明,只要了解了 Unicode 匹配,匹配非英语字母就变得相当简单,而且幸运的是,它是 Elixir 的Regex模块的核心功能。在做这道 Exercism 练习之前,我对这个功能几乎一无所知,但现在我会把它视为自己 Elixir 工具箱里不可或缺的一部分。
这个新工具有很多种用法。我一下子能想到的几个是:更健壮地校验密码和用户名,甚至判断一个输入字符串是不是合法的货币字符串,而不必手动列出所有可能的货币符号:
iex> currency_string_regex = ~r/\p{Sc}\d+\.\d{2}/u
~r/\p{Sc}\d+\.\d{2}/u
iex> ["$1.00", "£1.00", "¥1.00", "€1.00", "&1.00"] \
...> |> Enum.filter(&String.match?(&1, currency_string_regex))
["$1.00", "£1.00", "¥1.00", "€1.00"]