事不宜迟,我先画了一张现代中日韩汉字音的声母对应简图,可以直观地感受到三者的关系:
虽然乍一看有些凌乱,但大多数声母的对应关系都是有规律可循的;尤其是日语吴音和韩语汉字音,不考虑清浊和送气与否的话,可以说是对应得相当整齐了。两处分歧是近代朝鲜语的变化导致的:
另外还有一点不自然的地方是日语的は行并不对应韩语的ㅎ或是汉语拼音的 h,这是因为日语发生过「は行転呼」这一重大语音变化:奈良时代以前的は行读如现代日语的ぱ行,然后经过一系列演变,一直到江户时代才变成现在的发音。所以当时は行对应于ㅂ/ㅍ,相对而言か行才更接近ㅎ。
这下问题来了,明明日本和朝鲜半岛都是从中国借入的汉字,为什么从声母对应关系来看汉语普通话反而差得更远呢?原因很简单——日本和朝鲜半岛借入的主要是中古汉语,而中古汉语到现代汉语的变化要远大于日语和韩语的变化。
汉字传入朝鲜半岛的时间有待考证,有上古音说、切韵音说、唐代长安音说、宋代开封音说等;而汉字读音传入日本的时间跨度较大,至少可以分为三波:
所以吴音和汉音代表了不同时期不同地点的中古汉语,而唐音代表了近代汉语的官话。因为唐音比较罕见而且相对而言不成体系,所以前面的对应图没有包括唐音。那么中古汉语到底听起来是什么样?又如何能把中日韩汉字音联系起来呢?
中古汉语最重要的参考资料是当时的韵书和韵图,韵书之首是隋朝成书的《切韵》。《切韵》以韵目为纲,并没有标明声母,因此流传最广的中古声母系统是宋朝韵图所使用的三十六字母。按照传统音韵学的术语体系,三十六字母及其可能的拟音排列如下:
全清 | 次清 | 全浊 | 次浊 | 全清 | 全浊 | |
---|---|---|---|---|---|---|
重唇音 | 幫 /p/ | 滂 /pʰ/ | 並 /b/ | 明 /m/ | ||
轻唇音 | 非 /pf/ | 敷 /pfʰ/ | 奉 /bv/ | 微 /ɱ/ | ||
舌头音 | 端 /t/ | 透 /tʰ/ | 定 /d/ | 泥 /n/ | ||
舌上音 | 知 /ȶ/ | 徹 /ȶʰ/ | 澄 /ȡ/ | 娘 /ȵ/ | ||
齿头音 | 精 /ts/ | 清 /tsʰ/ | 從 /dz/ | 心 /s/ | 邪 /z/ | |
正齿音 | 照 /tɕ/ | 穿 /tɕʰ/ | 牀 /dʑ/ | 審 /ɕ/ | 禪 /ʑ/ | |
牙音 | 見 /k/ | 溪 /kʰ/ | 群 /g/ | 疑 /ŋ/ | ||
喉音 | 影 /ʔ/ | 喻 /j/ | 曉 /x/ | 匣 /ɣ/ | ||
半舌音 | 來 /l/ | |||||
半齿音 | 日 /ȵʑ/ |
表头的「全清」指清不送气阻音,「次清」指清送气阻音,「全浊」指浊阻音,「次浊」指响音。
不过晚清学者通过系联法发现,中古后期的三十六字母与中古前期的《切韵》声母系统并不完全吻合。后世学者通过更现代的音韵学研究方法,得出了更接近中古前期汉语的声母系统,譬如韵典网采用的三十八声母与三十六字母的区别在于:
第一项「古无轻唇音」可以解释在中日韩汉字音对应关系中,为何汉语拼音声母为 f 的字(非敷奉)会跟声母为 b/p 的字(帮滂並)归为一类,都对应日语的は/ば和韩语的ㅂ/ㅍ。
如果我们列出中古汉语与日语声母的对应表,我们会惊喜地发现对应关系整齐了不少:
全清 | 次清 | 全浊 | 次浊 | 全清 | 全浊 | |
---|---|---|---|---|---|---|
唇音 | 幫 /p/ 非 /pf/ |
滂 /pʰ/ 敷 /pfʰ/ |
並 /b/ 奉 /bv/ |
明 /m/ 微 /ɱ/ |
||
吴音 | は行 /h/ | ば行 /b/ | ま行 /m/ | |||
汉音 | は行 /h/ | ば行 /b/ ま行 /m/ |
||||
舌音 | 端 /t/ 知 /ȶ/ |
透 /tʰ/ 徹 /ȶʰ/ |
定 /d/ 澄 /ȡ/ |
泥 /n/ 娘 /ȵ/ |
||
吴音 | た行 /t/ | だ行 /d/ | な行 /n/ | |||
汉音 | た行 /t/ | だ行 /d/ な行 /n/ |
||||
齿音 | 精 /ts/ 照 /tɕ/ |
清 /tsʰ/ 穿 /tɕʰ/ |
從 /dz/ 牀 /dʑ/ |
心 /s/ 審 /ɕ/ |
邪 /z/ 禪 /ʑ/ |
|
吴音 | さ行 /s/ | ざ行 /z/ | さ行 /s/ | ざ行 /z/ | ||
汉音 | さ行 /s/ | さ行 /s/ | ||||
牙音 | 見 /k/ | 溪 /kʰ/ | 群 /g/ | 疑 /ŋ/ | ||
吴音 | か行 /k/ | が行 /g/ | ||||
汉音 | か行 /k/ | が行 /g/ | ||||
喉音 | 影 /ʔ/ | 喻 /j/ | 曉 /x/ | 匣 /ɣ/ | ||
吴音 | あ·や·わ行 /∅/ | あ·や·わ行 /∅/ | か行 /k/ | が行 /g/ わ行 /∅/ |
||
汉音 | あ·や·わ行 /∅/ | あ·や·わ行 /∅/ | か行 /k/ | |||
半舌音 | 來 /l/ | |||||
吴音 | ら行 /r/ | |||||
汉音 | ら行 /r/ | |||||
半齿音 | 日 /ȵʑ/ | |||||
吴音 | な行 /n/ | |||||
汉音 | ざ行 /z/ |
除了刚刚提到的「古无轻唇音」,日语汉字音还体现了「古无舌上音」,也就是说日语中知组字读如端组;日语同样也不区分齿头音和正齿音,即精组和照组声母混同。因此,上表合并了重唇音和轻唇音、舌头音和舌上音、齿头音和正齿音。
日语声母有清浊对立,但不存在送气与不送气的对立,所以吴音不区分全清和次清,但保留了全浊声母。然而后来的汉音对清浊的处理有些不同,应该是反映了从金陵雅音到唐代长安音的变化:
虽然明母字的汉音主要读ば行,但也有一部分字的汉音跟吴音一样读ま行,这些字几乎都是梗摄舒声字,例如「[明]{めい}」;类似的还有泥娘母梗摄舒声字,例如「[寧]{ねい}」。另外有一部分匣母合口字在日语吴音中读わ行,例如「[和]{わ}」「[惠]{ゑ}」。有趣的是,虽然大多数匣母合口字在现代汉语中声母是 h,但也有极个别的例外读 w,例如「[丸]{wán}」「[完]{wán}」。虽然日汉这两种例外的改读规律是相似的,但适用的匣母字并不重合:和、惠在汉语里声母是 h 而不是 w,丸、完在日语里吴音读「がん」而不是「わん」。
朝鲜王朝创制谚文的同时颁布了《东国正韵》,书中标示的汉字音与中古音有非常整齐的对应关系。然而这些汉字音只是编纂者们心目中的正音,朝鲜半岛自古以来实际使用的汉字音有很多讹变。下表基于《东国正韵》二十三声母的体系编排,但将正音划掉在旁边写上了实际的常见读音:
全清 | 次清 | 全浊 | 次浊 | 全清 | 全浊 | |
---|---|---|---|---|---|---|
唇音 | 彆 ㅂ /p/ | 漂 |
步 |
彌 ㅁ /m/ | ||
舌音 | 斗 ㄷ /t/ | 呑 ㅌ /tʰ/ | 覃 |
那 ㄴ /n/ | ||
齿音 | 即 ㅈ /tɕ/ | 侵 ㅊ /tɕʰ/ | 慈 |
戍 ㅅ /s/ | 邪 |
|
牙音 | 君 ㄱ /k/ | 快 |
虯 |
業 |
||
喉音 | 挹 |
欲 ㅇ | 虛 ㅎ /h/ | 洪 |
||
半舌音 | 閭 ㄹ /r/ | |||||
半齿音 | 穰 |
朝鲜跟日本一样不细分唇音、舌音、齿音,不过有时候仍然需要通过三十六字母才能区分:
前面说到日语声母缺少送气对立,而朝鲜语声母则缺少清浊对立。《东国正韵》将全浊字对应并书的 ㅃ/ㄸ/ㅉ/ㄲ/ㅆ/ㆅ,但这些声母并不会出现在实际读音中,除了「[喫]{끽}」「[雙]{쌍}」「[氏]{씨}」等极个别字。这些并书的声母现在用来表示硬音(日本或称浓音、中国或称紧音),并不是浊音而是不送气清音。另外快母大部分混入了君母、小部分混入了虚母,所以ㅋ在实际朝鲜汉字音中也是不存在的,除了「[快]{쾌}」本身等极个别字。
上表为了简单起见只在旁边标注了常见读音,但朝鲜汉字音还有很多需要注意的情况:
韩国的汉字音拼写还需要遵循「头音法则」,即词首出现流音时会发生音变:
虽然已经有了两张中古汉语和日韩汉字音的对照表,但世上应该没几个人能精通每个汉字的中古音,所以我们想要通过现代汉语来寻找日韩汉字的发音规律,还需要了解历史上的汉语语音变化。从中古汉语切韵音演变到现代汉语普通话的过程中,还有一段近代官话时期。明朝成书的《韵略易通》是反映汉语近代音的重要资料,它用当时的所有声母写了一首早梅诗:
[東]{d}[風]{f}[破]{p}[早]{z}[梅]{m},[向]{h}[暖]{n}[一]{∅}[枝]{zh}[開]{k}。[氷]{b}[雪]{s}[無]{v}[人]{r}[見]{g},[春]{ch}[從]{c}[天]{t}[上]{sh}[來]{l}。
经过数百年的演变,明初官话的二十声母系统相比宋人三十六字母已经简化了很多:
因为日韩本来就不太区分最后两项的声母,所以前三项是影响中日韩汉字音对应关系的主要因素。
观察早梅诗的声母可以发现,明初官话已经跟现在普通话相当接近,只是「[向]{h}」「[雪]{s}」「[無]{v}」「[見]{g}」四个字现在应该标成「[向]{x}」「[雪]{x}」「[無]{w}」「[見]{j}」才对。其中的「無」是微母字,它在明初大致读汉语拼音 f 的浊音 v,现在普通话里绝大部分字已经改读 w,即零声母的合口呼。剩下三个字则涉及尖团音的问题,即介音为 i/ü 的情况下 z/c/s 和 g/k/h 都改读了 j/q/x。
接下来四节重点介绍一下上述「浊音清化」「声母脱落」「知照合流」「尖团合流」四大汉语演变。
前面我们提到过韩语没有清浊对立,其实汉语官话中也没有清浊对立。中古汉语中的全浊字按照平仄分别归入了次清和全清,即「平送仄不送」。除了浊音清化,汉语官话的声调也发生了变化:
如果我们逆转一下从官话倒推中古汉语的话,可以得到下面这张表:
官话 | 中古汉语 |
---|---|
阴平全清 | 平声全清、 |
阴平次清 | 平声次清、 |
阴平次浊 | |
阳平全清 | |
阳平次清 | 平声全浊、 |
阳平次浊 | 平声次浊 |
上声全清 | 上声全清、 |
上声次清 | 上声次清、 |
上声次浊 | 上声次浊 |
去声全清 | 上声全浊、去声全浊、去声全清、 |
去声次清 | 去声次清、 |
去声次浊 | 去声次浊、 |
表中列出了普通话四声理论上的中古来源,把清入声的①②④划掉就是《中原音韵》的归派方式,把所有入声全都划掉就是江淮官话的情况。本来中古汉语中是平声字最多(多到韵书会把平声分上下两卷),但经过平分阴阳、全浊上归去、入派三声的演变,现在普通话变成去声字最多了。通过这张表我们还可以发现官话音系中有空缺:次浊声母没有阴平调、全清声母没有阳平调(考虑到入声归派的话要局限于阳声韵)。虽然这两种空缺在《中原音韵》上有所体现,但实际上现代汉语中有很多不符合规律的读音,比如中古次浊字读阴平在普通话中屡见不鲜。
所以如何通过现代汉语倒推中古清浊呢?如果你的母语是吴语或是老湘语,那么恭喜你可以直接根据母语读音区分清浊;如果你的母语是粤语,虽然粤语全浊声母也清化了,但仍然可以通过声调区分:阴平、阴上、阴去、上下阴入(粤拼 1/2/3 调)为清音,阳平、阳上、阳去、阳入(粤拼 4/5/6 调)为浊音;如果不幸的你跟我一样母语是官话,就很难通过声调准确区分清浊了,但万幸能分辨入声字的话那还是有办法的(适用于江淮官话):
话又说回来,中古汉语与日韩汉字音的清浊实际对应情况也是一团乱麻,但总体上的原则是:
普通话的零声母字(包括汉语拼音 y/w 开头的)基本对应中古的影、喻、疑、微四个声母:
这四种声母中只有影、喻母对应日韩两语的零声母,疑母对应韩语零声母但对应日语が行,微母对应韩语声母ㅁ和日语ま行或ば行。一个快速测试官话零声母字是不是日韩零声母的方法是,通过上节的声调法判断这个字在中古是不是清音,因为四种声母里只有影母是清音,所以理论上可以排除疑、微母的可能性,则其对应的日韩汉字音应该也是零声母。
如果你的母语是南方方言,那么应该有机会直接从普通话零声母字中分辨出疑、微母,比如吴语、闽语、客家话中疑母仍然保持独立。非官话方言都存在微母读如明母的现象,只是程度有所不同,其中闽南语和粤语这么读的比例在八成以上。
另外零声母中有且仅有一批读 er 的是日母字,很容易分辨,可以直接把它们的声母视为 r。
日语和韩语把知组归入了端组、把照组归入了精组,而汉语官话走了完全不同的道路——知组和照组合并,而且读音不同于端组或精组。合并后的这组声母就是普通话中的翘舌音,拼音记作 zh/ch/sh。比如「张」是知母字而「章」是照母字,古人可能不用问弓长张还是立早章就能判断一个人姓张还是姓章,而现在的福建人往往也能通过方言分辨。同样能区分知照的是日本人,日语中张读「ちょう」而章读「しょう」;但韩语因为中古三等字(有 i 介音)发生腭化的缘故无法区分知组三等和照组,导致张章都读「장」。下表列出了知照组字的一些读音规律(标注的是汉语拼音而不是国际音标):
中古汉语 | 泉州话 | 普通话 | 日语 | 韩语 |
---|---|---|---|---|
知组二等 | d·t | zh·ch | た・だ行 | ㄷ·ㅌ |
知组三等 | d·t | zh·ch | た・だ行 | ㅈ·ㅊ |
照组 | z·c·s·… | zh·ch·sh | さ・ざ行 | ㅈ·ㅊ·ㅅ |
在江淮官话和西南官话的很多地区,知照两组又继续跟精组合流,也就是所谓平翘舌音不分的现象。不过不区分精组和照组并不影响日韩汉字音的辨别,因为日韩本来就不分齿头音和正齿音。而在其他平翘对立的官话地区,也分三种对立模式:
总而言之,无论分不分平翘舌音,中国的大部分方言都不分知照;闽语是一个例外,从上面的泉州话读音可以看到知组读如端组,说明闽语的分化时间较早。
尖团音的区别源于中古汉语中精清从心邪五母和见溪群晓匣五母的对立,这些字都是齐齿呼或撮口呼(即介音为 i/ü),其中声母为精清从心邪的称为尖音、声母为见溪群晓匣的称为团音。尖团合流的现象在清朝就已出现,当时成书的《圆音正考》便是为了纠正满人尖团不分的问题,后来民国时期制定的老国音亦遵循《中原音韵》以来的体系保留了尖团对立,然而最终新国音以及普通话采用了不发尖音的北京音,于是尖音合流进了团音,汉语拼音记作 j/q/x。下面的表格简单总结了一下尖团声母在粤语、吴语、官话、日语和韩语中的读音(标注的是汉语拼音而不是国际音标):
中古汉语 | 广州话 | 苏州话 | 普通话 | 日语 | 韩语 |
---|---|---|---|---|---|
精母 | z | z | j | さ行 | ㅈ |
见母 | g | j | j | か行 | ㄱ |
清母 | c | c | q | さ行 | ㅊ |
溪母 | k | q | q | か行 | ㄱ |
心母 | s | s | x | さ行 | ㅅ |
晓母 | h | x | x | か行 | ㅎ |
粤语中的团音没有发生腭化,所以仍跟中古汉语一样读作软腭音 g/k/h,而不是龈腭音 j/q/x。尖团音的一个典型例子是清北的校名:清华大学的「清」字为尖音,而北京大学的「京」字为团音。当时所采用的邮政拼音恰好区分尖团,汉语拼音的 j/q/x 尖音写作 ts/ts/s、团音写作 k/k/h,所以清是 tsing 而京是 king,跟在粤语中的读音相似。在日语和韩语中,这两个字也有明显区别,其中清读作「しょう/せい/しん」「청」,而京读作「きょう/けい/きん」「경」。
塞音 (不送气) |
塞音 (送气) |
塞擦音 (不送气) |
塞擦音 (送气) |
擦音 (清) |
擦音 (浊) |
鼻音 (浊) |
边音 (浊) |
|
---|---|---|---|---|---|---|---|---|
双唇音 | b /p/ | p /pʰ/ | m /m/ | |||||
中古 | 幫並 | 滂並 | 明 | |||||
唇齿音 | f /f/ | |||||||
中古 | 非敷奉 | |||||||
舌尖前音 | z /ts/ | c /tsʰ/ | s /s/ | |||||
中古 | 精從 照 |
清從 穿 |
心邪 審 |
|||||
舌尖中音 | d /t/ | t /tʰ/ | n /n/ | l /l/ | ||||
中古 | 端定 | 透定 | 泥娘 | 來 | ||||
舌尖后音 | zh /tʂ/ | ch /tʂʰ/ | sh /ʂ/ | r /ʐ/ | ||||
中古 | 知澄 照牀 |
徹澄 穿牀禪 |
牀審禪 |
日 | ||||
舌面音 | j /tɕ/ | q /tɕʰ/ | x /ɕ/ | |||||
中古 | 精從 見群 |
清從 溪群 |
心邪 曉匣 |
|||||
舌根音 | g /k/ | k /kʰ/ | h /x/ | |||||
中古 | 見群 | 溪群 | 曉匣 | |||||
喉音 | ∅·y·w /ʔ/ | |||||||
中古 | 微疑影喻 |
根据汉语声母的演变规律,上表列出了普通话声母与宋人三十六字母的对应关系。绝大多数规律前面都有介绍,令人感到意外的应该是中古照组字在普通话中的奇怪变化:
这里的「色」字在北京话中还有白读 shǎi,而更常用的文读 sè 很可能是来源于南京官话。如果按照规律演变,这些字都应该像白读一样仍读翘舌音,比如「厕所」恐怕得叫 chìshǔ。除此之外,还有个别与零声母相关的例外字没有在上表中体现:
综上所述,用普通话准确推测日韩汉字音的声母还是比较困难的,但如果能辅以一门汉语方言,譬如既分尖团又分知照还能分辨疑微两母的闽南语,倒推声母会简单很多。
有人说粤语发音比官话更存古,诚然粤语对中古汉语的韵尾保留相当完整(详见后文韵母章节),但其声母跟官话一样经历了较大的变化,譬如浊音清化、非敷奉三母合流、知照精三组合流等等。粤语相比于普通话而言较为存古的地方在于区分尖团音、微母读如明母、疑母部分保留,但粤语对日母、溪母、晓母、匣母等的保留程度还不如普通话,更别提粤语介音的丢失和韵腹的巨变了。
下表列出了粤语各个声母的中古来源,为了简单起见发音部位标的是中古的五音,粤语拼写以香港语言学学会的粤拼方案为准:
塞音 (不送气) |
塞音 (送气) |
塞擦音 (不送气) |
塞擦音 (送气) |
擦音 (清) |
鼻音 (浊) |
近音 (浊) |
|
---|---|---|---|---|---|---|---|
唇音 | b /p/ | p /pʰ/ | f /f/ | m /m/ | |||
中古 | 幫並 | 滂並 | 非敷奉 溪曉匣 |
明微 | |||
舌音 | d /t/ | t /tʰ/ | n /n/ | l /l/ | |||
中古 | 端定 | 透定 | 泥娘 | 來 | |||
齿音 | z /ts/ | c /tsʰ/ | s /s/ | ||||
中古 | 知澄 精從邪 照牀 |
徹澄 清從邪 穿牀 |
心邪 牀審禪 |
||||
牙音 | g /k/ | k /kʰ/ | ng /ŋ/ | ||||
中古 | 見群 | 溪群 | 疑 | ||||
牙音 (唇化) | gw /kʷ/ | kw /kʷʰ/ | w /w/ | ||||
中古 | 見群 | 溪群 | 影喻匣 | ||||
喉音 | ∅ /ʔ/ | h /h/ | j /j/ | ||||
中古 | 影 | 溪曉匣 | 日疑 影喻匣 |
可以注意到粤语比普通话少了卷舌音 zh/ch/sh/r 和龈腭音 j/q/x 两组声母,但多了对应中古疑母的 ng 声母,另外 h 声母的粤语发音部位更像英语 /h/ 而不是普通话 /x/。粤语的一大特点是缺少介音,仅存的合口介音只会出现在零声母或 g/k 后面,因此粤拼方案单列了 gw/kw 两个唇化声母以简化韵母系统,在懒音中连这两个 w 都会脱落。粤语有一些不同于普通话的声母演变值得一提:
讲了这么多关于声母的故事,接下来该讲韵母了。《切韵》的修订版《大宋重修广韵》共有二百零六韵之多,传统上将它们笼统地归类为十六摄,按照大致读音可以分列如下:
∅ | -i | -u | -m / -p | -n / -t | -ŋ / -k | -uŋ / -uk | |
---|---|---|---|---|---|---|---|
a | 果・假 | 蟹 | 效 | 咸 | 山 | 宕・梗 | 江 |
ə | 遇 | 止 | 流 | 深 | 臻 | 曾 | 通 |
一摄包含多个韵目,一韵又可以对应多种现代中日韩读音,可以预见韵母对应情况将异常复杂:
普通话 | 日语 | 韩语 | |
---|---|---|---|
果摄 | o·e·uo | あ | 아·와 |
假摄 | a·e·ia·ie·ua | あ | 아·와 |
遇摄 | u·ü | お・よ・う・ゆ・ゆう | 오·어·여·우 |
蟹摄 | i·ie·ua·ai·ei·uai·ui | あい・えい | 애·예·위·왜 |
止摄 | i·ei·ui·er | い・うい | 의·이·아·위·유 |
效摄 | ao·iao | おう・よう | 오·요 |
流摄 | u·ou·iu | おう・ゆう | 우·유 |
咸摄 | an·ian | あん・えん | 암·엄·염 |
入声 | a·e·ia·ie | おう・よう | 압·업·엽 |
深摄 | en·in | いん | 음·임 |
入声 | i | ゆう | 읍·입 |
山摄 | an·ian·uan·üan | あん・えん | 안·언·연·완·원 |
入声 | a·o·e·ia·ie·ua·uo·üe | あつ・えつ | 알·얼·열·왈·월 |
臻摄 | en·in·ün·un | いん・うん・おん | 은·인·윤·운·온·안 |
入声 | o·i·u·ü | いつ・うつ・おつ | 을·일·율·울·올·얼 |
宕摄 | ang·iang·uang | おう・よう | 앙·왕 |
入声 | o·e·uo·üe | あく・やく | 악·왁 |
梗摄 | ong·eng·ing·iong | おう・えい | 앵·욍·영 |
入声 | o·e·i·uo | あく・えき | 액·왹·역 |
曾摄 | eng·ing | おう・よう | 응·잉 |
入声 | e·i·ü | おく・よく | 윽·억·익·욱·역·액 |
江摄 | ang·iang·uang | おう | 앙 |
入声 | o·uo·üe | あく | 악 |
通摄 | ong·eng·iong | おう・よう・ゆう | 옹·웅·용·융 |
入声 | u·ü | おく・うく・よく・ゆく・いく | 옥·욱·욕·육 |
既然我们几乎无法推测完整的韵母,那么我们可以聚焦于规则性强的部分——韵尾。虽然现在普通话的韵尾只剩下前鼻音 -n 和后鼻音 -ng 了,但熟悉粤语的同学应该能多举出一个鼻音韵尾 -m 和三个塞音韵尾 -p / -t / -k。在传统音韵学中,带鼻音韵尾的韵母统称为阳声韵,带塞音韵尾的统称为入声韵,剩下纯元音的叫作阴声韵。因为 -m 和 -p、-n 和 -t、-ng 和 -k 发音部位相同,所以传统上会把它们两两归入同一摄,例如阳声韵的阳(yang)和入声韵的药(yak)均列于宕摄,同摄入声在上表中单列一行。
事不宜迟,我们先来看三个阳声韵的例子(广州话使用的是粤拼方案,苏州话标注的是国际音标):
广州话 | 苏州话 | 普通话 | 日语 | 韩语 | |
---|---|---|---|---|---|
侵 | cam1 | /tsʰin/ | qīn | しん | 침 |
親 | can1 | /tsʰin/ | qīn | しん | 친 |
清 | cing1 | /tsʰin/ | qīng | せい | 청 |
可以看到韩语和粤语忠实地保留了三种不同的鼻音韵尾,而日语和普通话不区分 -m 和 -n,在吴语中这三种韵尾有混淆甚至脱落的现象,正所谓「吴人不辨清、亲、侵三韵」。稍加总结可以归纳出以下规律:
中古汉语 | 广州话 | 苏州话 | 普通话 | 日语 | 韩语 |
---|---|---|---|---|---|
-m | -m | 混淆/脱落 | -n | -ん | -ㅁ |
-n | -n | 混淆/脱落 | -n | -ん | -ㄴ |
-ng | -ng | 混淆/脱落 | -ng | 长元音 | -ㅇ |
中古汉语 -ng 韵尾在日语中也不一定都是长元音,比如「夢」的吴音是短元音「む」,说明吴音不如汉音那样有系统性;而唐音常常会把 -ng 对应到 -ん,比如前面已经提到过的「南[京]{きん}」,这应该是受到了中国南方口音的影响。
广州话 | 苏州话 | 普通话 | 日语 | 韩语 | |
---|---|---|---|---|---|
集合 | zaap6 hap6 | /ziəʔ ɦəʔ/ | jí hé | しゅうごう | 집합 |
出發 | ceot1 faat3 | /tsʰəʔ faʔ/ | chū fā | しゅっぱつ | 출발 |
一日 | jat1 jat6 | /iəʔ zəʔ/ | yī rì | いちにち | 일일 |
約束 | joek3 cuk1 | /iaʔ soʔ/ | yuē shù | やくそく | 약속 |
赤壁 | cik3 bik1 | /tsʰəʔ piəʔ/ | chì bì | せきへき | 적벽 |
这五个入声韵的例子非常具有代表性,能够完整体现日语入声字的特点。因为日语本身没有韵尾这个概念,所以入声字会多出一个音来模拟原本韵尾的辅音,比如「出發」「一日」中的「つ」「ち」、「約束」「赤壁」中的「く」「き」。其中「出」单独念「しゅつ」,后接「發」时发生促音便,恰好形成了一个类似汉语入声的发音。而「集合」的情况比较复杂,其历史假名遣写作「しふがふ」,「ふ」最早读作 pu,「は行転呼」之后改读 u,前面的元音也一起发生变化,最终现代假名遣写作「しゅうごう」。
跟阳声韵的情况类似,韩语和粤语保留了三种塞音韵尾的对立,不过以 -t 为韵尾的入声字在韩语中不是 -ㄷ 而是 -ㄹ。吴语虽然保留了入声但将三种韵尾合并成了喉塞韵尾 -ʔ,而普通话的入声已不复存在。入声韵的规律可以归纳如下:
中古汉语 | 广州话 | 苏州话 | 普通话 | 日语 | 韩语 |
---|---|---|---|---|---|
-p | -p | -ʔ | 脱落 | 长元音 | -ㅂ |
-t | -t | -ʔ | 脱落 | -つ/-ち | -ㄹ |
-k | -k | -ʔ | 脱落 | -く/-き | -ㄱ |
一般来说 -t 韵尾对应的吴音是 -ち、汉音是 -つ;而 -k 韵尾大多都对应 -く,除了部分吴音 -いき、汉音 -えき。另外,-p 韵尾也不总对应长元音,也会变成惯用音 -つ ,例如「[圧]{あつ}」「[接]{せつ}」「[立]{りつ}」等。
有人说现代汉语中与韩语韵尾最接近的方言应该是粤语,因为两者都很接近中古汉语。我恰恰是在香港学的韩语,我就明显感觉到香港同学记韵尾要比我快、比我准。我的母语江淮官话跟吴语一样,保留入声但既不分 -p / -t / -k 也不分 -m / -n / -ng,我在记韩语汉字音的时候真是吃尽了苦头。
看上去整理这些眼花缭乱的日韩译音没有什么实际意义,但日韩译音为重纽这个学术难题提供了新的线索。简而言之,重纽就是通过系联法确定的同音字却被《切韵》分出了两组不同的小韵;这个现象体现在后世的韵图上就是假四等,即理论上同音的三等字却被分列在了三等和四等的位置。晚清学者往往认为这两组字没什么区别,忽略了这个奇怪的现象,但实际上这两组字的日韩译音有可能不同。
例如「一」「乙」都是影母质韵开口三等入声字,但是它们被《切韵》分在了不同的小韵,被韵图分别列在四等和三等。「一」被称为质韵重纽A类,日语吴音读「いち」,韩语读「일」;「乙」被称为质韵重纽B类,日语吴音读「おつ」,韩语读「을」。日韩译音的不同体现了当时重纽字读音的细微差异,但具体区别在何需要上溯上古汉语有待进一步研究。
本文所采的中古声母拟音以及中古韵摄的日韩译音均参考《中国传统音韵学》课件,中古声母的日韩译音则主要参考维基百科,例证中汉字的音韵地位和方言读音参考了字统网、韵典网、汉字音典、粤语审音配词字库。除此之外,本文还参考了许多互联网资料,族繁不及备载,难免挂一漏万:
]]>OPLSS 是一年一度在俄勒冈大学举办的编程语言暑校,虽然名字叫暑校,但主办方叮嘱我们这实际上是一个学术会议,别叫美国边检找我们要 F-1 签证。说到签证,我办 B-1 签证的经历也算是一波三折:首先是 2022 年初香港爆发了第五波疫情导致美领馆暂停了签证预约,虽然在我收到 OPLSS 申请结果前不久美领馆恢复了服务,但上网预约的时候才发现最近的签证面谈已经排到 OPLSS 开幕之后了……既然都申请到了能覆盖食宿的奖学金我们也不想乖乖放弃,就在已经开始研究怎么去新加坡面签的时候,我刷新美签网站恰好发现美领馆放出了更多面签名额,大概是正好赶上了他们从有限服务转为正常服务,于是我立刻预约了最早的 5 月 2 日。那天上午本来应该八点开始,结果在里面干等了半个小时签证官们才姗姗来迟,轮到我签证官问了一系列常规问题便发给我一张黄纸,上面写着根据美国移民和国籍法 221(g) 条款我的签证申请未能通过,也就是俗称的「check」。跟黄纸一起给我的还有一份 DS-5535 表格,叫我填完跟其他材料一起交到另一个窗口去,我一看这表我要填挺久而且我也没准备在学证明和行程单,悲从中来,就直接回家了。正坐着叮叮车呢,突然美领馆一个电话打过来问我去哪儿了,我说我先回家准备材料过会儿再过来,她一时语塞然后让我别来了直接邮件联系吧,我就发邮件补交了材料。第二天另外两位 OPLSS 同伴去面签,结果没问几个问题就当场批了十年签,这下我更难过了。不过好在我也没伤心太久,5 月 19 日美领馆就通知我签证通过了,远远早于我的预期,我悬着的心终于可以放下了。拿到护照一看,不出所料,是一年签。
因为机票买得有点晚,而且香港当时还没放弃清零政策,飞到美国西海岸并没有什么实惠的选择。想着可以先去太平洋另一边的湾区看看,我最终买了国泰航空直飞旧金山的航班,单程就花了一万港元。同样是直飞十一二个小时,后来去新西兰往返我也才花了一万港元,由于清零政策执行期间港大不鼓励出境也不给报销,摸摸钱包还有点心疼的。
时间快进到 6 月 15 日凌晨,我坐上了前往美国的飞机,在穿越了国际日期变更线之后,我在 6 月 14 日夜晚抵达了旧金山机场。如果要问我对美国的初印象,我一定会回忆起那破破旧旧的 BART 以及列车上神神叨叨向我要钱的流浪汉。在去旧金山市区的路上眺望窗外,湾区的景色确实与香港大相径庭:远处是矮矮的山丘,从山上延伸到山下,大大小小的联排别墅平铺在眼前。
我在旧金山湾区的四天行程是那种一眼看出是第一次来的类型:第一天坐地面缆车去九曲花街,再走到渔人码头坐船欣赏金门大桥(海上风好大好冷),最后去唐人街尝了中餐馆(遍地在说粤语);第二天坐 MUNI 轻轨(怎么别人都没付钱?)去金门公园参观加州科学院(其实是个博物馆),顺便去日本茶园(据说是美国幸运饼干的起源)喝下午茶;第三天坐加州列车去帕罗奥图游览斯坦福校园;第四天浏览电脑历史博物馆(挺无聊的)和苹果新总部(在外围绕了一圈也没见哪儿能进园区)。
因为日程安排合不上,我的两位 OPLSS 同伴都还在香港,所以我联系了大学同学后两天借住在他家(的沙发上)。不过他在暑期实习有点忙,因此计划中还是我独自旅行,然而一个人玩到底还是有些寂寞,于是我在第三天去斯坦福的路上下定决心拨通了高中同学的电话。本来这种事情应该更早联系才对,但我迟迟不好意思麻烦更多同学,现在突然闯进别人的生活反而事与愿违了。没想到奇迹发生了,那天是工作日他竟然正好不上班,令人感动的是他还开车过来陪我逛了他的母校斯坦福。回去的路上,乘高中同学的车驰骋在一马平川的宽阔公路上,晴空万里之下远处的圣克鲁斯山脉依稀可辨,恍惚间自己也领会了同学们的美国梦。不论是高中同学租的联排别墅,还是大学同学租的单人公寓,都让我亲眼见识了硅谷人的奢华生活。
虽然早就听说美国大城市的治安臭名昭著,比如旧金山砸车窗盗窃相当猖獗,但这几天只在游客区和硅谷近郊活动,旅游体验还是不错的。旧金山的公共交通差强人意,而出了市区没车确实寸步难行,我全得仰赖同学接送。据说有些富人区甚至抵制公共交通,怕把流浪汉送到自己家门口……第一次来美国,还是独自出行,能如此顺利真得多谢拨冗接待我的同学们!
从圣何塞飞尤金,西南航空竟然是自由席,机上座位先到先得,真是一上来就给了我一点小小的美国震撼。因为航司不认可港科大同伴的智克威得疫苗,所以他临时改了航班,在打车去俄勒冈大学的路上,我们三位来自香港的手足才终于会合。入住宿舍之后,我们遇见了三位交大出身赴美留学的中国同胞,后来还有一位竺院院友加盟,接下来的两周里我们几乎都是一起行动,结下了深厚的友谊。
OPLSS 经历了 2020 年停办、2021 年改为线上之后,终于在 2022 年回到了俄勒冈小城尤金。跟旧金山和洛杉矶相比,尤金十分宁静安全,除了爆表的花粉量放倒了好几位参会者。好巧不巧,这年正好撞上推迟了一年、首次在美国举行的田径世锦赛,而我们的宿舍就在比赛场馆海沃德田径场旁边,暑校结束再过两周就是比赛日。除了声势浩大的世锦赛,OPLSS 第一周后半还和美国室外田径锦标赛重合了,不过这个比赛规模相对不大,对我们没有什么影响,除了食堂更挤了一些。在校园里还不时能见到俄勒冈大学的吉祥物,长着特别像唐老鸭,上网一查发现还真拿到了迪士尼公司的正式授权。为了筹备世锦赛,暑校的第二周校园开始封路了,我们被迫从法学院宽敞明亮的大教室(如图)换到了数学系拥挤闷热的小教室,不知道是不是还有长时间佩戴 N95 口罩的原因,我第二周上课昏昏欲睡。
言归正传,OPLSS 的讲师阵容可谓众星云集,尽管至少有一半的内容我没听懂,但还是收获颇丰:Thorsten Altenkirch 用 Agda 讲解 [依值类型]{dependent types},Jeremy Gibbons 探寻 [神奇态射在哪里]{Fantastic Morphisms and Where to Find Them},Pierre-Louis Curien 介绍 [博弈语义]{game semantics},Silvia Ghilezan 介绍 λ 演算相关的基础知识,Paul Downen 介绍 [抽象机语义]{abstract machine semantics} 和 [经典逻辑的可实现性]{classical realizability},Adam Chlipala 分享 Coq 实战经验,Steve Zdancewic 用软件基础的风格介绍 [交互树]{Interaction Trees},Sam Lindley 为 [代数效应]{algebraic effects} 传道,Stephanie Balzer 讲 [会话类型]{session types} 入门,Robert Harper 讲 [逻辑关系]{logical relations} 入门。而我最推荐的两门课要数 Frank Pfenning 的证明论入门和 Stephanie Weirich 的 [Π∀]{pi-forall} 语言实现:证明论入门涵盖了逻辑和谐(也就是局部可靠性和完备性)、柯里–霍华德同构、自然演绎和相继式演算的对比、切消定理、线性逻辑等等,属实是帮我恶补了数理逻辑的知识;Π∀ 则是一门小巧的依值类型语言,用 Haskell 实现双向类型检查、Π 类型和 ∀ 类型(区别是运行时是否擦除参数)、依值的模式匹配、相等命题等等,这门课跟我们组的研究最接近所以倍感亲切。
OPLSS 落幕之后,港大同伴有事先行回港了,而我和港科大同伴一起飞去洛杉矶开启了快乐的南加州之旅。因为也玩不出什么新花样,就还简单来个流水账加照片拼图吧:第一天我们去洛杉矶会展中心参加了号称北美最大的漫展 Anime Expo,然后坐公交车去市中心参观了小东京;第二天则是去圣莫尼卡海滩和比弗利山庄闲逛,感受美国独立日的气氛;第三天一整天畅玩好莱坞环球影城。
我们回港的航班坐的是大韩航空,不仅餐食是韩国传统的拌饭,而且起飞前播的安全宣传片是韩国男团 SuperM 一边唱跳 K-pop 一边讲安全须知,着实令人耳目一新。快乐的时光是短暂的,回到香港一落地等待着我们的就是七天的强制酒店隔离。回忆起这二十多天的美国之行,我最怀念的是在尤金最后一晚聚会 Texas Roadhouse 餐前的免费面包,好想再尝一次!
一转眼到了年底,这届 SPLASH 在新西兰第一大城市奥克兰举办,是有史以来首次来到亚太地区。因为囊括了 OOPSLA、APLAS 以及其他并设的大大小小的会议,SPLASH 同时征用了八间会议室,持续一周时间。港府在九月已经取消了入境人员的强制隔离,香港这里大家都选择了飞去奥克兰参会,其中我们实验室去了四位,港科大也去了四位;或者按照参会目的来分,四位发表 OOPSLA 论文,两位发表 APLAS 论文,两位参加 ACM 学生科研竞赛。这次也是我人生第一次在线下面对观众发表论文,说实话蛮紧张的。
比起 OPLSS 上结识新伙伴的新鲜感,SPLASH 上更多的是见到久仰大名的学术大咖的激动,比如十四亿中国人的 PL 引路人雾雨魔理沙、当年清华 FP 课的灵魂人物朱俸民和王程鹏、还有悠悠老师和宁宁学姐。学术会议作为一种社交活动,当然绝不仅仅是发表论文,拓宽学术圈的人脉也是重要的一环。不过因为老布没来,不太敢随便向大教授们搭话,我们多半是跟华人学生聊天,比如来自澳洲和星洲的同行们。比较有意思的是我在宿舍食堂遇见了来自东工大的日本同学们,五年前我在他们实验室交换留学过,虽然那时候他们还不在,但大概也算是我的同门师弟了。后来我们在和港科大的🦁️老师一起聚餐时(如图)还认识了来自新国大的李曼努老师,他是奥地利人但会讲汉语,不仅在厦门大学读了中国哲学硕士,还在杭州阿里云实习过,让我感到十分惊奇。奥克兰市中心的皇后街两侧遍地都是亚洲餐馆,说街上半数是亚洲人也毫不夸张;不过即使在天空塔附近也有不少店面空铺招租,疫情之下的不景气可见一斑。
为了免除近千美元的会议注册费(这次还额外免了住宿费),我申请当了学生志愿者,但没想到志愿者会忙到几乎每天都有活要干。我们这次每人要负责五段会议(每段一个半小时)、一次午餐、半天的注册台接待以及一天待机随叫随到(结果我第一天一大早被叫去了注册台 🤦),虽然错过了几篇想听的论文,但接待了来自天南海北的学术同行们,还与 OPLSS 上就见过的来自麦吉尔的 Breandan 重逢了。此外也很推荐刚刚开始或者即将攻读 PL 研究生、对自己未来生活感到迷茫的同学参加大会并设的 PLMW,这个研讨会的定番是美国东北大学的 Amal Ahmed 教授教大家如何优雅地度过自己的博士生涯,这届还有嘉宾座谈会以及分小组轮流向教授提问的车轮战环节。
因为有志愿者任务我错过了霍比屯的组队游,最后只去了奥克兰动物园和水族馆。既然来了新西兰,最大的愿望当然是看看国鸟鹬鸵(几维鸟),它们在动物园有一栋专门的暗室,只有在固定的喂食时间才能一睹真容。可惜的是我当时在现场没拍到照片,于是我尝试用 OpenAI 的 DALL·E 合成了一张(如图)。虽然细节上有好多破绽,以及莫名其妙出现了猕猴桃,但乍一看还真像回事儿。
如今社交逐步复常但疫情仍在继续,一周的会议跟生存游戏一样,我们眼睁睁地看着香港一起来的同学接二连三地倒下了,平时一起吃饭的小伙伴们越来越少。为了预防中招,SPLASH 开幕半个月前我就拉着室友打了复必泰加强针,可喜可贺的是我们双双挺过了会议的一周。然而在回程的飞机上,我左右邻座咳嗽声鼻涕声不断,更糟糕的是新西兰航空不要求佩戴口罩,于是我就这样和他们密切接触了十一个小时。我的落地核酸是阴性,然而两天后的强制核酸变成了阳性,我也赶在 2022 年结束之前体验了一把竹篙湾隔离之旅,香港队八个人最后的战绩是六阳二阴。
]]>英语里有个单词叫做 autological,是说一个词可以形容其本身,比方说「名词」本身就是个名词,「阳平」这两个字的声调本身就是阳平,而「多态」本身也很多态。编程语言中最常见的多态有四种:特设多态、参数多态、子类型多态和行多态。通俗来讲,只要是为不同类型的数据或操作提供了相同的名字就可以叫多态。
本文只是一篇科普性质的文章,所以基本上只以实际编程语言为例,不会用任何形式化的语言来描述这些类型系统。如果希望深入学习相关的理论知识,建议阅读 TAPL 或者 PFPL;Giuseppe Castagna 在韩国 SIGPL Summer School 2019 的特邀讲座也很值得一看。
「[特设多态]{ad-hoc polymorphism}」其实就是我们常说的「[重载]{overload}」,日语更通俗地叫它「[多重定義]{オーバーロード}」。称其为特设是因为这种多态并不像全称量化一样适用于所有类型,而是手动为某些特定类型提供不同的实现。
举例来说,C 语言不支持函数重载,于是绝对值函数搞出了 abs
(整数)、fabs
(浮点数)等不同的名字。多年后,支持函数重载的 C++ 则为同一个名字 abs
提供了各种参数类型的版本:
int abs(int x); // __Z3absi
long abs(long x); // __Z3absl
long long abs(long long x); // __Z3absx
float abs(float x); // __Z3absf
double abs(double x); // __Z3absd
long double abs(long double x); // __Z3abse
在实际的 C++ 编译器实现中,为了能在链接时对这些重载函数进行区分,会有一个「[命名粉碎]{name mangling}」的环节将它们重新命名为独一无二的符号,就像每行行末注释里的一样。
C++ 中还有大量的隐式类型转换,这也可以被视为特设的「[强制多态]{coercion polymorphism}」。这两种特设多态的结合给函数的静态派发带来了巨大的复杂度,以至于 C++ 有一份相当长的标准被称为「[重载决议]{overload resolution}」。通常来说,隐式类型转换是弱类型的标志,它在类型方面带来了相当大的不可预测性。
那么特设多态在函数式编程语言中的支持如何呢?ML 系的语言都不支持函数重载,其中最具代表性的 OCaml 甚至完全不支持运算符重载,导致整数加法 +
和浮点数加法 +.
等都不是同一个符号。还好 SML 和 F# 为运算符重载开了口子:SML 跟 Java 一样,预先重载了一些内建运算符,但不允许用户进行重载;而 F# 完全向用户开放了运算符重载。
Haskell 语言对于特设多态的解决方案则是「[类型类]{type class}」,这个方案最早由 Philip Wadler 和他的学生 Stephen Blott 在 POPL 1989 的论文中提出,可以视为对 SML eqtype
的扩展。eqtype
表示一个类型支持相等比较,而类型类将其推广到了任意操作。譬如我们可以定义一类数值类型,这类类型支持前面提到的 abs
函数:
class Num a where
abs :: a -> a
……
instance Num Int where
abs n = if n `geInt` 0 then n else negate n
……
这里 class
定义了 Num
类型类支持的函数及其类型,instance
则表明整数类型是 Num
类型类的实例,并提供了绝对值函数的具体定义。就像函数重载一样,我们也可以为其他类型定义这个类型类的实例。有了这种通用的解决方案,SML 中的 eqtype
相当于是 Haskell 中的一个特例——Eq
类型类。如今,Rust 的 trait
和 C++20 的 concept
都以各自的方式实现了类型类的功能,越来越多的语言设计者参悟了如何让特设多态不那么特设。
「[参数多态]{parametric polymorphism}」在函数式编程中简称多态,其引入的类型参数允许我们在函数定义中使用类型变量,而不必为各种具体的类型定义不同的版本。譬如以 Haskell 语言为例,我们可以为所有类型定义一个统一的恒等函数:
id :: a -> a
id x = x
id True {- True :: Bool -}
id 'a' {- 'a' :: Char -}
这里 Haskell 标准语法省略了类型参数的引入和消去,如果我们打开 GHC 的 ExplicitForAll 和 TypeApplications 语言扩展,我们可以看得更清楚一点:
id :: forall a. a -> a
id x = x
id @Bool True {- True :: Bool -}
id @Char 'a' {- 'a' :: Char -}
第一行类型声明中的 forall a
引入了一个隐式的类型参数,之所以用这个关键字是因为它对应于逻辑中的全称量化。不过有趣的是,与之对偶的存在量化在 Haskell 中复用了 forall
关键字,详见 GHC 的语言扩展 ExistentialQuantification。以 @
开头的就是显式指定的类型参数,当然,就算我们不写它们也能自动推断出来。
看到上文中的 Haskell 代码,大家可能会产生一个疑问:为什么类型参数默认是隐式的?要回答这个问题,就不得不提 Haskell 所采用的 Hindley–Milner 类型系统。HM 类型系统的最大好处是已有经过证明的算法(譬如 Algorithm W)可以做完整的类型推断,所以我们无须显式标注任何类型。不过 HM 类型系统也为此引入了对多态的两大限制:
鉴于第一条 Rank-1 限制,类型变量只可能是顶层的 forall
引入的,Haskell 就干脆默认隐式声明了。因为函数参数不能是多态类型,所以 HM 类型系统依赖于 let
表达式来引入多态类型的变量。也正因如此,HM 类型系统中的 let x = e1 in e2
和 (\x -> e2) e1
并不等价,因为前者 x
可以是多态类型,而后者不行。在类型推断中,let
会让 e2
每一处使用 x
的地方都各自独立地对类型变量进行实例化,这样就达到了多态的效果。不止是 Haskell,其他 ML 系语言(包括 SML、OCaml、F# 等)也都以 HM 类型系统为基础。
提到 HM 类型系统,另一个绕不过去的话题是 ML 系语言中臭名昭著的「[值限制]{value restriction}」。因为 let
的多态规则本质上是为变量的类型创建了多个实例,但如果这些实例因为副作用而有所联系时,就能一下子摧毁 ML 引以为傲的类型安全。这里以 OCaml 为例:
let r : 'a option ref = ref None
r := Some 48 (* r : int option ref *)
match !r with (* r : string option ref *)
| None -> ""
| Some str -> "HKG" ^ str
第二行和第三行的 r
指向相同的内存地址,但 ML 为它们的类型建立了不同的实例,导致第三行能够通过类型检查,却有可能触发运行时错误。为了让多态不破坏命令式代码的类型安全,ML 系语言目前普遍采用的解决方案是 Andrew Wright 在 1995 年提出的值限制:只有 let x =
右侧在语法上是值的时候才能拥有多态类型,否则一律按单态处理(OCaml 的实现会创建一个弱类型变量 '_weak
根据未来的信息来推断这个未知的单态类型)。这种限制简单粗暴地认为任何不是值的表达式都可能带有副作用,虽然实现起来简单,但会干扰一些纯函数的编写,比如多态函数的部分应用:
let rev = List.fold_left (fun acc x -> x :: acc) []
(* rev : '_weak1 list -> '_weak1 list *)
rev [1; 2; 3; 4]
(* rev : int list -> int list *)
rev ['a'; 'b'; 'c'; 'd']
(* Error: This expression has type char but an expression was expected of type int *)
当然,想要让 rev
恢复多态类型,我们可以显式加上它的参数 l
,也就是所谓的 eta-expansion。因为 fun l -> ……
在语法上是值,所以 rev
的类型就能推断为多态的 'a list -> 'a list
了。
另一方面,Haskell 是纯函数式语言,没有 OCaml 那样隐式的副作用,因此也没有值限制(不过乱用 unsafePerformIO
就能以相同的方式摧毁 Haskell 的类型安全)。Haskell 有个名字很像的限制叫做「[单态限制]{monomorphism restriction}」,但这个限制与类型安全和副作用都没有关系,而且基本上只要写了类型签名就能避免。单态限制在 GHC 中可以自由开关,如今在 GHCi 中是默认关闭的。
HM 类型系统只支持顶层的 Rank-1 多态,这对应于逻辑学中的「[前束范式]{prenex normal form}」。不少 Haskell 用户不满足于此,所以 GHC 提供了 RankNTypes 语言扩展支持 ->
内层的 forall
。因为 forall
出现在 ->
右侧等价于出现在外层,所以真正有趣的情形是 forall
出现在 ->
左侧:
hipoly :: (forall a. a -> a) -> (Bool, Char)
hipoly f = (f True, f 'a')
想要更强的表达能力也要付出相应的代价——Rank-3 及以上的完整类型推断已被证明是不可判定问题。不过在手动标注一些类型的前提下,GHC 仍然能够进行相当实用的类型推断,其算法在 Simon Peyton Jones 等人的 JFP 2007 论文中有详尽叙述。需要注意的是,就算开了扩展,hipoly
的类型签名也不能省略,因为 Haskell 的类型推断算法是基于 HM 的。
介绍完 Rank-N,我们再来看看 HM 类型系统的另一个限制——[直谓性]{predicativity}。这个词同样来自逻辑学,表示不允许自指,比如 type T = forall a. a
中的 a
不可以包含 T
自身。在直谓多态中,这意味着多态不是头等公民,我们不能使用多态类型来实例化类型变量。在 Haskell 中比较典型的例子是处理局部副作用时会用到的 Control.Monad.ST
:
($) :: (a -> b) -> a -> b
runST :: (forall s. ST s d) -> d
runST $ do { …… } -- a := (forall s. ST s d) -> d
在这个例子中,$
运算符的类型参数 a
得实例化成一个多态类型,这样的多态就不是直谓性的。不过好在 GHC 对 $
的类型检查进行了特殊处理,这样写并不会报错;如果想亲眼目睹类型错误,可以试试没有经过特殊处理的 id runST
。从另一个角度来看,直谓多态只支持在 ->
类型运算符里嵌套多态类型,比如 (forall a. a) -> ()
;而非直谓多态支持在任何多态类型里嵌套多态类型,比如 [forall a. a]
。过去十几年来,GHC 的 ImpredicativeTypes 扩展都不太好用,最近 GHC 9.2 基于 ICFP 2020 论文中的 Quick Look 算法更好地支持了非直谓多态。在成功突破 HM 类型系统的两大限制之后,经过 GHC 扩展的 Haskell 已经能完整表达比 Hindley–Milner 更强大的 Girard–Reynolds 多态演算了,也就是大名鼎鼎的 System F。
提到「[泛型]{generics}」这个词,首先需要说明一下这里是指面向对象编程中的泛型编程,而不是 Haskell 中的「[数据类型泛型编程]{datatype-generic programming}」。理论上,面向对象编程中的泛型就是上面讲的参数多态,但泛型在各种语言中的设计千差万别,实现也不尽相同。
泛型的两种主流实现方式是「[类型擦除]{type erasure}」和「[单态化]{monomorphization}」,前者以 Java 为代表,后者以 C++ 和 Go 为代表。值得一提的是,Java 5.0 和 Go 1.18 之前并没有泛型,它们的泛型特性都是在学术界的协助下追加的,这两项工作(名为 Featherweight Generic Java / Go)分别发表在 OOPSLA 1999 和 OOPSLA 2020 上。在 Java 中,泛型列表的两个实例 List<Integer>
和 List<Boolean>
都会翻译到 List<Object>
;而 Go 则会将 List[int]
和 List[bool]
翻译到两个不同的单态类型。虽然单态化会生成更多的代码,但它生成的代码比 Java 擦除类型的代码更高效,因为 Java 泛型的类型变量一定都是装箱了的(即 Object
的派生类),而 Go 可以用原始类型来实例化类型变量。另一方面,Java 的类型转换不支持类型变量,譬如 (a)x
;而 Go 支持等价的类型断言 x.(a)
。
C++ 的泛型是通过模板实现的,模板的实例化相当于泛型的单态化。不过 C++ 的模板比一般的泛型更为强大:模板的参数并不局限于类型参数、参数可以有默认值、模板支持特化等等。其中模板特化可以说是参数多态和特设多态的结合体:
template<typename T> void f(T x) { /* primary template */ }
template<> void f(int x) { /* specialization T := int */ }
在第一行的主模板中,我们可以定义默认的泛型实现;而对于一些需要特设实现的类型,我们可以像第二行一样对模板进行特化,譬如为整数类型写一个单独的定义。如果活用 C++ 模板的各种特性,我们甚至可以在编译期间进行任意计算,因为模板元编程已经被证明是图灵完备的。
众所周知,面向对象编程有三大特性:封装、继承和多态。而研究类型系统的学者会说,面向对象编程所需的类型系统区别于函数式演算的最大特征就是子类型。面向对象编程所说的第三大特性,学名正是「[子类型多态]{subtype polymorphism}」。如果 S 是 T 的子类型(即 S <: T),那么一个 T 类型的对象可以安全地被一个 S 类型的对象所代换;在此前提下,子类型多态是说一个类型为 T 的对象的成员函数既有可能调用 T 本身的实现,也有可能调用到 T 的子类型(如 S)的实现。
面向对象编程语言通常使用「[名义子类型]{nominal subtyping}」,也就是说子类型关系是通过名字显式声明的。在 C++、Java、C#、Swift 等语言中,定义类时可以声明它继承于什么,那么这个派生类不仅能复用基类的实现,而且成为了基类的子类型;反过来说,没有继承关系的类之间也不会有子类型关系。常见编程语言中的异类是 OCaml 和 TypeScript,它们使用的是学术界更青睐的「[结构子类型]{structural subtyping}」,也就是说子类型关系跟类型的名字没有任何关系,也不需要显式声明,而是由类型的实际结构通过一系列子类型规则决定的。在这些结构类型系统中,类名并不会同时充当对象的类型,这与主流的面向对象编程相去甚远,因此下面讨论的子类型多态均基于传统的名义类型系统。
之前提到的特设多态和参数多态,往往都是在编译期间静态实现的;而子类型多态需要获知一个对象的动态类型,所以通常在运行时实现。这里我们以 C++ 为例:
struct Animal {
virtual void say() = 0;
};
struct Fox : Animal {
void say() override;
};
void call(Animal *a) { a->say(); }
C++ 对于函数调用默认是静态绑定的,也就是说调用哪个成员函数完全取决于对象所标注的类型,比如 a->say()
就一定会调用 Animal::say()
。但实际上 a
可能是 Fox
的实例,我们在派生类定义了不同于基类的实现,比如 Fox::say()
。到底 a
是哪个类的实例我们在运行时才能知道,所以函数绑定就要延迟到运行时再进行,这就是所谓的「[动态派发]{dynamic dispatch}」。要让 C++ 进行动态派发,我们必须在基类的接口前面加上 virtual
关键字。这样一来,C++ 就会为每个实例附上「[虚函数表]{vtable}」,以记录各个虚函数实现的函数指针。值得一提的是,Rust 的 trait
对象也支持动态派发,但它没有把虚函数表存到实例里,而是使用「[胖指针]{fat pointer}」同时指向实例和虚函数表。而在 Smalltalk 这类基于「[消息传递]{message passing}」的动态编程语言中,对象的成员随时都能动态变更,因此所有消息(成员函数)都是动态传递(调用)的,我们甚至能自定义 messageNotUnderstand:
来动态处理未知消息。
C++ 也可以用「[奇异递归模板模式]{curiously recurring template pattern}」静态实现子类型多态,即用泛型模拟多态。直观上讲,类型参数在这里充当了虚函数的索引,构造派生类实例时其实际类型会被静态记录下来。因为不同的派生类继承于基类模板的不同实例,所以 Animal<T>::say()
中的静态类型转换会把函数派发给对应的派生类 T
:
template<typename T>
struct Animal {
void say() {
static_cast<T*>(this)->say();
}
};
struct Fox : Animal<Fox> {
void say();
};
template<typename T>
void call(Animal<T> *a) { a->say(); }
当然,这样静态模拟子类型多态会丧失一些表达能力,比如我们无法将这些派生类的对象装进同一个容器:Animal
是模板而不是具体的类,所以我们无法直接写 vector<Animal*>
;如果改成诸如 vector<Animal<T>*>
的形式,那显然就没法装下 T
以外的派生类的对象了。
「[行多态]{row polymorphism}」与子类型多态一样,是一种主要服务于对象(或记录)的多态形式。不同于子类型多态在实践中常常以名义子类型的形态出现,行多态理论上只适用于结构类型系统。目前学术界对于行多态的最佳实践莫衷一是,不同文献中的设计各异其趣,想了解「行的四种写法」可以移步游客账户的知乎文章。这里我们不去罗列理论,而是以实际的编程语言 PureScript 为例:
addFields :: forall (r :: Row Type). { foo :: Int, bar :: Int | r } -> Int
addFields o = o.foo + o.bar + 1
addFields { foo: 1, bar: 2, baz: 3 } -- r := ( baz :: Int )
addFields { foo: 1 } -- Type Error!
因为我们在第二行的函数定义中访问了记录的两个字段,所以 PureScript 会像第一行一样将其类型推断为 { foo :: Int, bar :: Int | r }
,这里的类型变量 r
代表一行类型,也就是该记录尚未知晓的剩余字段,相当于扮演了「[宽度子类型化]{width subtyping}」的角色。简而言之,所谓的行多态就是量化范围为「[行]{row}」的参数多态。
不过要注意:行多态不能完全取代子类型!单单使用行多态的一大缺陷是我们无法将不同类型的记录装进同一个容器,比如 [ { x: 1, y: 2 }, { x: 1, z: 3 } ]
,个中缘由跟静态实现子类型多态的时候几乎一样。反过来,对于下面基于行多态的记录更新操作:
incCount :: forall r. { count :: Int | r } -> { count :: Int | r }
incCount o = o { count = o.count + 1 }
(incCount { count: 0, uuid: "xxx" }).uuid -- "xxx"
如果我们直接把行多态去掉(删掉 forall r.
和 | r
),就算 PureScript 有子类型多态也无法通过类型检查,因为函数返回类型中 count
以外的字段都丢失了。这就需要 PureScript 进一步支持有界多态,然后我们把函数签名改成 forall a <: { count :: Int }. a -> a
才行。
除了行多态,其实还有别的方法能支持多态的对象,比如谢宁宁等人在 ECOOP 2020 的论文中证明「[互斥多态]{disjoint polymorphism}」能够模拟行多态和有界多态。互斥多态借助的利器是交集类型,这一想法可以追溯到 Benjamin Pierce 的博士毕业论文。Rust 之父 Graydon Hoare 对互斥交集类型也很关注,他曾在推特评论道:“Maybe John Reynolds really did almost solve everything at once with Forsythe, if we just manage to get its intersection types right.”
直觉上,大家一定觉得一门编程语言支持的特性越多越好;然而在类型系统领域,让不同特性和谐共处往往是十分艰巨的话题,甚至有些特性是相互矛盾的。上文提到的参数多态和子类型就是最典型的例子:虽然这两个概念单独考虑都不算太复杂,但在它们组合而成的 F-sub 系统中,子类型关系竟然是不可判定的。这时候,语言实现通常会牺牲完备性来换取可判定的算法;当然也可以通过像行多态一样改变编程语言的设计来巧妙地避开问题,这就得看语言设计者的知识水平了。
]]>虽然 PHP 早已是一门通用编程语言了,不过它最早是作为 HTML 模版引擎而出现的。从它现在的全称「超文本预处理器」也可以想象出,PHP 代码可以嵌入到 HTML 中,在用户请求该网页时,后端预先执行 PHP 代码并生成插入了运行结果的 HTML。下面便是一个最简单的例子:
<html>
<head>
<title>Personal Home Page</title>
</head>
<body>
<?php echo "Hello {$world}"; ?>
<?= "Hello {$world}" ?>
</body>
</html>
包裹在 <?php … ?>
标签里面的便是服务器要执行的 PHP 代码,直接 echo
一个表达式可以简写为 <?= … ?>
。像这种在 HTML 里用特殊标签插入后端脚本的做法,从互联网诞生开始就相当普遍,至今仍屡见不鲜,本文称其为「PHP 风格」。这些 PHP 风格的模板引擎大同小异,区别主要在于内嵌语言用什么、代码块用什么标签包裹。
几乎每一门后端编程语言都有自己的 PHP 风格的模板引擎,因为在 HTML 直接嵌入后端脚本对于后端开发者来说最容易上手,没有任何学习上的负担。这些模板引擎中较为知名的有:
JSP / eRuby / EJS 都继承了 ASP 的习惯,使用 <% … %>
来包裹脚本,还有 <%= … %>
渲染表达式结果等其他便利的标签。
PHP 风格的模板引擎虽然历史悠久,但在设计上有一个比较明显的问题:代码逻辑和 HTML 模板混杂在一起。因此,GitHub 的联合创始人 Chris Wanstrath 发明了广为人知的 Mustache。Mustache 的语法非常简洁,没有任何显式的控制流语句,完全由数据驱动,因而自称 logic-less。它不与任何编程语言耦合,几乎所有主流语言都有 Mustache 模板引擎的实现。
Mustache 有两种基本的标签形式:一种是像 {{variable}}
这样渲染变量的值,另一种则是像 {{#section}} … {{/section}}
这样的区块。根据键值的不同,区块隐含四种语义:
另外还有 {{^inverted}} … {{/inverted}}
与正常的区块相反,如果是假值或空列表则渲染一次,否则不渲染。下面是 Mustache 模板的一个典型用例:
<h1>{{header}}</h1>
{{#items}}
{{#first}}
<li><strong>{{name}}</strong></li>
{{/first}}
{{#link}}
<li><a href="{{url}}">{{name}}</a></li>
{{/link}}
{{/items}}
{{^items}}
<p>The list is empty.</p>
{{/items}}
假设我们的输入数据是用下面这个 JSON 表示的:
{
"header": "Colors",
"items": [
{"name": "Red", "first": true, "url": "#red"},
{"name": "Green", "link": true, "url": "#green"},
{"name": "Blue", "link": true, "url": "#blue"}
]
}
那么 Mustache 便会渲染出如下 HTML:
<h1>Colors</h1>
<li><strong>Red</strong></li>
<li><a href="#green">Green</a></li>
<li><a href="#blue">Blue</a></li>
虽然 Mustache 的设计小而美,但实际使用起来难免捉襟见肘。Handlebars 是对 Mustache 语言的扩展,最大的区别在于它引入了辅助函数。值得一提的是,其内置的 #if
#unless
#each
#with
辅助函数明确了 Mustache 区块的隐式语义,譬如前面例子中的:
{{#items}} … {{/items}}
可以显式写成 {{#each items}} … {{/each}}
;{{#first}} … {{/first}}
可以显式写成 {{#if first}} … {{/if}}
;{{^items}} … {{/items}}
可以显式写成 {{#unless items}} … {{/unless}}
。Mustache 完全去除了代码逻辑,而 Handlebars 又稍稍加回了一些;不过更多的模板引擎出于实用性考量,不吝于引入更多逻辑,但也不愿复杂到直接内嵌后端脚本,换句话说就是试图在 Mustache 和 PHP 风格之间寻找平衡。如果要给这些中庸的模板引擎选个代表,最早为人所知的应该是 Django Template Language(以下简称 DTL),实际上它的出现要早于 Mustache。
与先前的话术稍有不同,DTL 将渲染表达式的 {{ variable }}
称为变量,将控制流程的 {% tag %}
称为标签,其内置了二十多个标签,包括常用的 for
if
elif
else
等等。DTL 最大的特色是过滤器,譬如 {{ list | length }}
能够获取列表的长度、{{ text | escape | linebreaks }}
能先将文本转义再把换行符替换成 HTML 标签等等,大约有六十个过滤器内置其中。下面是一段 Django 模板语言的简单示例:
<h1>{% block title %}{% endblock %}</h1>
<ul>
{% for user in users %}
<li><a href="{{ user.url }}">{{ user.username }}</a></li>
{% endfor %}
</ul>
后来,Flask 的作者 Armin Ronacher 参考 DTL 的设计实现了独立于后端框架的 Jinja 模板引擎;而 Mozilla 提供了一个 JavaScript 上的实现 Nunjucks;Shopify 在 Ruby 上也有十分相似的 Liquid 模板引擎,并被用于 GitHub Pages 默认的静态站点生成器 Jekyll。
Go 语言标准库的模板也可以算是 Django 风格,但它没有 {% … %}
只有 {{ … }}
。比如前面 DTL 的 {% for user in users %} … {% endfor %}
写作 {{ range $user := .Users }} … {{ end }}
,而渲染变量和字段写作 {{ $variable }}
和 {{ .Field }}
,函数链式调用亦可用管道表达。
上述三种风格,其实都可以归类于往 HTML 里面插各种 HTML 语法以外的 <% … %>
{{ … }}
,那么还有没有别的方式嵌入动态内容呢?有一种有趣的设计叫做「模板属性语言」(TAL),也就是说我们把动态内容写在正常 HTML 标签的自定义属性里。TAL 最大的好处是简化了开发者和设计师的协作,因为 TAL 能直接加在设计原型上,加上之后仍然是照常显示的 HTML,不经后端渲染直接用浏览器打开也不会感知到动态代码的存在。最早提出 TAL 的是 Python 编写的 Zope 2,其模板引擎 Zope Page Templates 使用了一系列 tal:
属性来引入动态内容。
如今较为纯粹的例子是 Java 上的模板引擎 Thymeleaf,自称「自然模板」。下面是自然模板的一个示例,其中 th:text
会替换掉标签内的原有内容、th:each
会进行迭代:
<table>
<thead>
<tr>
<th th:text="#{msgs.headers.name}">Name</th>
<th th:text="#{msgs.headers.price}">Price</th>
</tr>
</thead>
<tbody>
<tr th:each="prod: ${allProducts}">
<td th:text="${prod.name}">Oranges</td>
<td th:text="${#numbers.formatDecimal(prod.price, 1, 2)}">0.99</td>
</tr>
</tbody>
</table>
既然能自定义 HTML 属性,那么可不可以自定义 HTML 标签呢?JSP 标准标签库(JSR-52: JSTL)便实践了这一想法,虽然自定义标签不再有自然模板的好处,但写起来会更方便不少。JSTL 定义了 <c:if test="${age >= 20}">
<fmt:message key="i18n">
<sql:query … >
<x:parse … >
等四类标签,在属性上还可以使用表达式语言(JSR-341: EL)来插入动态内容,就像前述 <c:if>
中的 ${age >= 20}
那样。
JSP 也允许用户定义 JSTL 以外的自定义标签,这不禁让我们联想起了如今的 Web Components:
class PopUpInfo extends HTMLElement {
constructor() {
super();
…… // write element functionality in here
}
}
customElements.define('popup-info', PopUpInfo);
以上代码便可以创建一个自定义标签 <popup-info>
,而该元素的行为和语义均可由用户自行决定。 React / Angular / Vue 等前端框架非常提倡这种可复用的组件,不过它们提供了更高层的抽象,让自定义组件更易写易用。
前面的模板语言说到底都还是 HTML 的超集,而 Haml 则完全抛弃了 HTML 原有的语法,走向了截然不同的方向。Haml 全称 HTML 抽象标记语言,由 Sass 之父 Hampton Catlin 发明。Haml 的写法有点像 CSS selector,譬如 %p.sample#welcome Hello, World!
会被渲染为 <p class="sample" id="welcome">Hello, World!</p>
。Haml 有 =
和 -
前缀分别用来渲染表达式结果和控制流程,另外它跟 Python / Haskell 一样采用了越位规则,也就是说以缩进来界定文档结构。
不过 Haml 需要在每个标签前面写 %
还是有点麻烦的,JavaScript 上的 Pug(原名 Jade)对其进行了一些语法上的改进,后来又出口转内销,Slim 把相似的语法带回了 Ruby:
doctype html
html
head
title Slim Examples
link rel="icon" type="image/png" href=file_path("favicon.png")
body
#content
p This example shows you what a basic Slim file looks like.
- if items.any?
table#items
- items.each do |item|
tr
td.name = item.name
td.price = item.price
- else
p No items found. Please add some inventory.
Thank you!
div id="footer"
= render "footer"
| Copyright © #{year} #{author}
说句题外话,我在用 Spring 写网站的时候曾经一度很困惑,过去不支持 Java 注解的时候,大家是如何忍受手写 XML 配置文件的呢?然而我开始写 Thymeleaf 模板的时候突然意识到,我自己对于手写 HTML 不也习以为常了吗?XML 配置文件正逐渐被 YAML / TOML 等新兴格式所取代,HTML 模板的未来又会如何呢?
虽然上述六种分类特意将 HTML 模板语言的范式孤立开来,但如今流行的前端框架往往集成了多种范式,譬如 Angular 和 Vue 都支持 Django 风格的插值和管道 {{ interpolation | pipe }}
,而写在 HTML 属性上的指令 <p *ngIf="true">
<p v-if="true">
则类似于模板属性语言,众所周知它们也都支持自定义标签的组件化开发。这里我有意忽略了 React 的 JSX:在 JSX 中 JS 反客为主,HTML 组件变成了 JS 代码的一部分,恕我不算它是 HTML 模板语言了。
总而言之,本文力求归纳了主流的 HTML 模板范式,但 Web 开发毕竟不是我的主业,行文难免有所疏漏,但愿不会贻笑大方。
]]>本文是我在《系统设计与实现》课程的热点话题阅读报告,内容来源于 Xiang (Jenny) Ren, et al. 发表在 SOSP 2019 的论文《An Analysis of Performance Evolution of Linux’s Core Operations》(下文简称 [Ren19])。
1991年9月17日,赫尔辛基大学的大四学生 Linus Torvalds 向 ftp.funet.fi 上传了自己课余时间编写的 Linux 0.01 源代码,由此揭开了开源操作系统的崭新篇章。如今,Linux 已成为最主流的服务器操作系统,TOP500 榜单中的超级计算机更是悉数采用。在高性能计算对 Linux 依赖越来越强的大背景下,[Ren19] 对近年来 Linux 内核的核心操作性能进行了系统性的评估,得到一个骇人听闻的结论:绝大多数内核操作的性能均有退化。不过值得庆幸的是,研究团队发现可以通过编译配置或是简单的补丁来禁用掉那些导致性能退化的内核改动。
[Ren19] 之所以选择对内核操作(包括 epoll
等系统调用以及上下文切换等)进行分析,是因为随着硬盘读写和网络设备速度的提升,今后服务器的性能瓶颈可能会是操作系统的内核操作。以前相关的操作系统性能研究大多着眼于不同处理器架构上的性能差异,而如今 x86-64 架构已经一统天下了,因此保持硬件参数不变对操作系统进行时间尺度的分析更具有现实意义。研究团队基于 Ubuntu 发行版的默认配置,选取了 Linux 内核 3.0 到 4.20 共 41 个版本进行了基准测试,它们的发布时间横跨 2011 年到 2018 年。为了确定哪些是实际场景中常用的系统调用,研究团队用 strace
命令统计了 Spark、Redis、PostgreSQL、Chromium 和 GCC 等典型应用的计算任务,从中选取了八组总用时最多的内核操作进行基准测试。
基准测试的最终结果如上图 (a) 所示:以 4.0 版本的内核为基准,除了 big-write
和 big-munmap
之外的所有内核操作都不同程度地变慢了,其中退步最大的 poll
甚至比之前慢了 136%。为了找出导致这些内核操作性能退化的原因,研究团队调查了 Linux 内核各版本之间的代码变化,最终确认了 11 处关键性改动,如上图 (b) 所示。这些严重影响了 Linux 内核性能的改动可以被归为三类:安全补丁、新增特性和错误配置。
不过在解释这些导致性能退化的原因之前,我们先盘点一下研究团队筛选出的内核操作都有哪些。
read
/write
:读写文件。为了测试不同规模文件的读写性能,文件大小定为了一、十、万页三个档次(1 页 = 4096 字节)。mmap
/munmap
:将文件映射到内存,或取消其映射。测试文件大小同上。fork
:创建一个跟自身一样的新进程。big-fork 在进程复制前映射了 12000 页文件。send
/recv
:使用 Berkeley sockets 进行本地进程间通信。select
/poll
/epoll
:均为 Reactor 模式的 I/O 多路复用(multiplexing)机制,select
和 poll
列入了 POSIX 标准但性能不够好,而新的 epoll
于 Linux 2.5.44 加入。mmap
上来的页,触发缺页让系统真正把文件从硬盘拷贝到内存。盘点了基准测试中的内核操作之后,让我们按照分类逐一解释导致 Linux 内核性能退化的原因。关于头两个补丁所涉及的幽灵和熔毁漏洞的详细介绍,可以参考我之前的文章。
第一个安全补丁针对的是 Spectre-V2 的分支目标注入,其为 Linux 内核编译配置加入了默认开启的 RETPOLINE
选项。该选项会向 GCC 添加 -mindirect-branch=thunk-extern
参数,从而绕过处理器对间接跳转指令的预测执行。该补丁让半数测试慢了 10% 以上,而影响较为严重的 select
一下子慢了 68%。研究团队对其中的原因进行了调查,发现 select
系列函数的代码有三处频繁执行的间接跳转,譬如其中一处位于 fs/select.c
(4.18 版本之前):
static int do_select(...)
{
for (;;) {
mask = (*f_op->poll)(f.file, wait);
}
}
我们可以看到这里的 f_op->poll
是个函数指针,因此该函数调用会被编译为间接跳转指令。因为我们通过编译选项愚弄了分支预测器,导致每次都会有三十多个时钟周期的延时,这也就是 select
系列函数变慢的原因。研究团队尝试用 if-else 枚举函数指针所有可能的值,将间接跳转改为了直接跳转,成功地将减速比从 68% 降到了 5.7%。
第二个安全补丁针对的是臭名昭著的 Meltdown 漏洞,也就是广为人知的内核页表隔离(KPTI)补丁。为了防止恶意程序读取任意内存数据,KPTI 分离了用户态和内核态的页表,用户态无法再访问绝大部分内核地址空间。KPTI 最大的开销来源于进出内核态需要切换页表和清空转译后备缓冲器(TLB),这包括页表指针寄存器(CR3)的两次写入和 TLB 的大量未命中。以前进出内核态的开销少于 100 个时钟周期,而 CR3 写入带来了 400 多个周期的开销,而后续 TLB 未命中的中断处理程序能为 big-read 带来 6000 个周期的额外开销。
为了改善这种情况,Linux 内核开发者利用英特尔处理器的上下文标识符(PCID)避免了每次清空 TLB:不同的 PCID 对应不同的地址空间,每个 TLB 条目可以附上 PCID 以同时管理多个地址空间的页表缓存。PCID 优化给 KPTI 带来了巨大的性能提升,能将小规模测试的减速比从 113% 从 47%,不过这无法优化掉 CR3 写入的开销,因为当前活跃的 PCID 仍需写入 CR3 寄存器。
如果对于性能有极致的追求,想要彻底关掉 KPTI,有两种办法:一是编译内核的时候就在配置中关掉 PAGE_TABLE_ISOLATION
,二是在内核启动参数中加上 nopti
。AMD 的用户则完全不必担心,因为 Meltdown 攻击完全不影响 AMD 处理器,所以 KPTI 已被自动禁用了。
Slab 分配器最初是为 SunOS 内核数据结构设计的内存分配器,如今被 Linux 和 FreeBSD 等操作系统广泛使用,譬如 fork
就在用它来分配 mm_struct
对象。Slab 分配器使用一个自由表(free list)串联未分配的内存区域,因为邻接的往往都是连续的内存地址,因此这种可预测性容易被用来进行缓冲区溢出攻击。从 Linux 4.7 开始,编译配置加入了 SLAB_FREELIST_RANDOM
选项,启用后会用 Fisher-Yates 随机排列算法打乱自由表的顺序。不过安全性也伴随着性能的代价,big-fork 变慢了 37%,big-select 系列函数平均变慢了 41%,这背后有两个原因:一是随机化自由表本身需要时间,二是不连续分配的内存破坏了访存局部性。
Linux 内核代码常常需要在内核空间和用户空间之间拷贝数据,这就需要用到 copy_from_user
和 copy_to_user
两个函数。如果内核开发者没有处理好相关调用,从用户空间拷贝了过多数据会导致缓冲区溢出,向用户空间拷贝了过多数据会造成内核空间数据泄漏。之前这两个函数只检查用户空间指针,从 Linux 4.8 引入的 Hardened Usercopy 补丁开始,内核空间指针也会进行非常严格的安全性检查,包括不允许为空指针、不允许指向 kmalloc
分配的零长度区域、不允许指向内核代码段、如果指向 Slab 则不允许超过 Slab 分配器分配的长度、如果涉及到栈则不允许超出当前进程的栈空间等等。这些繁琐的检查使得 select
/poll
测试变慢了将近 18%,不过 epoll
很少进行拷贝所以影响不大;而 read
虽然会向用户空间拷贝数据,但由于不是 Slab 所以也几乎不受影响。
控制组(cgroups)及其内存控制器早在 Linux 2.6.24 就引入了,这也是 LXC 和 Docker 等容器化技术的基础之一。不过即使在没有使用控制组功能时,内存控制器对内存使用额的监控工作仍然造成了 big-munmap 81% 的性能损失。直到 Linux 3.17,内核开发者才对其做了批处理的优化,将性能损失降到了 9%。
饱受争议的透明大页(transparent hugepage)也是影响访存性能的一大因素。众所周知,页是虚拟内存管理的最小单位,通常一页默认是 4KiB,但也可以手动设定为诸如 2MiB 的大页。不过由于手动管理页的大小比较麻烦,于是 Linux 等操作系统提供了透明大页功能,系统能够自动提升或下调页的大小。积极来讲,大页能够减少页表占用空间、降低缺页频率、并且能提高 TLB 命中率,对于大量使用内存的程序来说会有性能提升;然而另一方面,透明大页容易导致内部碎片化,低缺页率的代价是每次缺页加载时间显著增加,并且其后台进程也带来了额外开销。如今透明大页已经被默认禁用了,但禁用透明大页给极端的内存密集型测试 huge-read 带来了 83% 的性能退化。
Linux 3.15 新增的 fault around 策略旨在减少次要缺页(minor page fault)。如果当前请求的这一页没有页表项,但实际上已经装进页缓存了,只需通知 MMU 建立映射关系即可,则这种缺页被称为次要缺页。在遇到次要缺页时,Linux 不仅会处理当前页,还会帮前后的若干页都建立映射关系。当然,这种优化策略是建立在访存局部性的基础之上的,像 big-pagefault 这种极端的不满足局部性的测试,就出现了高达 54% 的性能退化。
Linux 4.6 新增的系统调用 userfaultfd
支持了在用户态处理指定范围内的缺页,这对于用户态的虚拟机监视器(VMM / Hypervisor)相当有帮助。譬如在进行虚拟机迁移之后,VMM 可以通过 userfaultfd
按需拷贝内存页,这种模式被称为 post-copy。不过 big-fork 由于这个新特性损失了 4% 的性能,因为在进程复制时需要检查父进程内存区域关联的用户空间缺页处理信息。
在 Ubuntu 发行版中,Linux 内核编译配置中的 CONTEXT_TRACKING_FORCE
选项曾被错误开启,这是在开发降低调度时钟滴答频率8(RSCT)功能时用来测试上下文追踪的调试选项。时钟滴答(tick)本质上就是定时器芯片产生的时钟中断,源源不绝的时钟中断为更新系统时间、执行进程调度提供了时机,但在处理器闲置时过于频繁的中断会增加功耗,而且在运行单个计算密集型程序时会造成干扰。因此,Linux 内核编译配置提供了三种 RSCT 选项(选项中的 HZ 意为每秒的时钟中断数,Linux 目前默认为 250):
HZ_PERIODIC
表示永不忽略时钟滴答;NO_HZ_IDLE
表示在处理器闲置时忽略时钟滴答,这是默认选项;NO_HZ_FULL
表示在处理器闲置或只有一个可执行的任务时忽略时钟滴答,建议只在进行实时计算或某些高性能计算任务时开启此选项。在开启了 RSCT 后,平时在时钟中断时做的工作就得挪到用户态和内核态切换的时候做,这些工作就被称为上下文追踪。这些工作包括统计在用户态和内核态的执行时间,以及处理 read-copy-update 同步机制注册的回调。强制上下文追踪会在所有处理器核心上都进行上下文追踪,不管有没有开启 RSCT,这导致前面的所有测试平均变慢了 50%。
Linux 3.9 为英特尔处理器的 Haswell 架构(研究团队在用)引入了一个补丁,让内核的驱动模块能够更加细粒度地控制处理器的功耗和闲置状态。不过这个补丁并没有移植到当时尚未停止维护的旧版本上,导致之前的版本容易陷入更深的闲置状态从而降频,打了补丁能将有效工作频率提高 31%。
Linux 3.14 又为英特尔处理器引入了一个补丁,能够识别其二级 TLB 的大小以对 munmap
的实现进行优化。munmap
时让 TLB 项失效有两种策略:一是就处理那些失效项,二是清空整个 TLB。在这个补丁之前,只有一级 TLB 的大小会被纳入考虑,导致只要超过一项就会清空整个 TLB,大大降低了之后页表缓存的命中率。
研究团队通过时间维度上的对比分析,揪出了 11 条造成 Linux 内核性能退化的原因,其中 88% 的影响是强制上下文追踪、熔毁补丁、粗粒度 CPU 闲置状态、幽灵补丁四项导致的。因为错误配置属于可以避免的人为错误,而新增特性对性能的影响面并不大,所以真正给 Linux 性能带来致命一击的就是幽灵系列漏洞的安全补丁。所以说,预测执行是一把双刃剑,想要绝对的安全就不得不放弃性能。
另外,正如研究团队所说,内核性能调优是个相当费时费力的工作。如果没有大量的精力投入到 Linux 这个快速迭代的庞然大物上,还是购买 RHEL 等高度调优的商业发行版更加划算。
]]>另一个常见的误解是:为什么 Matsumoto Yukihiro 被翻译成了松本行弘?为什么 Jang Won-young 被翻译成了张员瑛?首先要意识到英语不是日韩的母语,因此上面的罗马字也只是音译。实际上 Matz 的姓名本来就是汉字「松本行弘」,只是这四个字都用了日语训读,导致中日读音大相径庭。而韩国的情况更麻烦一点,因为他们现在几乎不用汉字了,所以姓名里的谚文对应哪个汉字要么靠猜要么询问本人。张员瑛的原名是「장원영」,一开始大家猜测其对应的汉字是「張元英」,不过很遗憾猜错了,后来官方宣布她的姓名汉字是「張員瑛」。当然日本人名也有要猜的时候,比如说「松山ケンイチ」「石原さとみ」(也都猜错过,哈哈哈哈)。不过如今对汉字如此执着的也只有中国了,日韩互译对方人名的时候并不会追溯到汉字,而是直接按照当地的读音来音译(即现地音):松本行弘在韩语里就叫 마츠모토 유키히로,张员瑛在日语里就叫 チャン・ウォニョン。
我去日本交流的时候,遇到的第一个难题就是我的名字在日本应该怎么叫。在登记在留卡的时候,外国人的姓名默认都用拉丁字母,也就是我护照上的拼音「SUN YAOZHU」。不过之后在办学籍或者银行账户的时候,还需要提供振假名,也就是姓名的读法,于是士大夫照着拼音帮我填了个读起来奇奇怪怪的「スン・ヤオズ」……其实日本对于姓名的读法是相当宽容的,完全遵照名从主人的原则,像「村山彩希」的名字念作「ゆいり」这种毫无根据的读法也不会提出异议。后来我自己使用的读法是「ソン・ヨウジュ」,是我姓名的汉字在日语中的音读,这也是日本对中国人名的正统处理方式(不过如今现地音越来越流行了)。
上面提到的音读与训读是汉字文化圈特有的读音现象,以日语为例,所谓音读就是保留汉字传入日本时的汉语读音(包括吴音、汉音、唐音等),而所谓训读就是用日本固有词汇的读音来念同义的汉字。换句话说,音读词都是以中国为源头的汉语词(不过后来日本也发明了相当多的和制汉语并回流了中国),训读词都是借用汉字表记的和语词。比如「人」字,在单独出现时用训读 ひと,这是日本固有的和语词,而在汉语词「[人間]{にんげん}」中读吴音、「[人類]{じんるい}」中读汉音。不仅如此,日语词汇中还有好多和汉混血儿,比如「[湯桶]{ゆとう}」前训读后音读,相反地「[重箱]{じゅうばこ}」前音读后训读,所以很多生僻词不注音的话日本人也是不会念的。现代韩语在这方面就简单得多,除了少数多音字之外汉字一字一音(都是音读),而且韩语跟汉语读音更为接近,其固有词不用汉字只用谚文表记;而越南语的汉字也几乎都读汉越音(即音读),极少有训读现象。
过去,中日朝越等国都有用文言文作为书面语言,因此各国受过高等教育的文人都能跨越语言障碍进行笔谈。然而随着各国语言文字的演进,文言文已全面被白话文所取代,越南和朝鲜在 20 世纪中叶先后废除了汉字,汉字在韩国也日渐式微,而且中国和日本各自简化了汉字,东亚汉字文化圈的联系已大不如从前了。下面先引用一张来自维基百科的图片来形象地展示一下汉字文化圈内存在的书写系统(黑色为汉语词、绿色为固有词、蓝色为外来词):
日语不仅读法复杂,其书写系统也相当复杂,有汉字(漢字)、平假名(ひらがな)、片假名(カタカナ)三套体系,假名只表音不表意,各有约五十音。汉字是从中国舶来的自不必说,而平假名和片假名则由汉字草书和偏旁演变而来,它们分别扮演不同的角色:汉字多用来表记实词、平假名多用来表记虚词,片假名多用来表记外来语。虽然日语中的汉字也完全可以被读音所对应的假名取代,但为了视觉上方便断句以及消除同音词的歧义,大多数人还是用汉字写实词的。
而朝鲜半岛过去跟日本相仿,是汉谚混写的,但朝鲜自 1948 年成立后便完全废除了汉字,而韩国也从上世纪末开始渐渐停止了汉字的使用,现在几乎只使用谚文。谚文在韩国现称韩㐎(한글),是一套非常有意思的表音系统,其诞生时间很晚,直到 15 世纪才发明出来。谚文由声母、韵母和韵尾三部分拼成,譬如 한 的声母是 ㅎ (h)、韵母是 ㅏ (a)、韵尾是 ㄴ (n),合起来就是 han。得益于这种模块化的组合方式,谚文理论上可以拼装出 19×21×28 = 11172 种音节,信息密度远高于其他表音文字。
在 19 世纪中叶越南被法国占领之前,越南语也是主要使用汉字的,他们把源自中国的汉字称为儒字,把汉语词称为汉越词。不过儒字并不能准确记录越南的固有词汇,于是他们基于儒字发明了喃字(𡨸喃)。这些字大部分都是形声字,譬如「[𡨸]{chữ}」,它借了「[宁]{trữ}」的音和「[字]{tự}」的意。这种为本土语言造字的现象有点像粤语字,「哋」「啲」等字在汉语官话中也没有,但在粤语地区广泛使用。然而后来在法属印度支那时期,越南语的罗马化方案渐渐流行起来,并于 1945 年北越独立后取代了儒字和喃字成为越南唯一官方文字,被称为国语字(chữ quốc ngữ)。
正如前面所介绍,日本仍在广泛使用汉字,韩国、朝鲜和越南曾经使用过汉字,而中国大陆、港澳、台湾、新加坡目前以汉语为官方语言,当然也在用汉字,那么各地汉字长得一样吗?答案是否定的。这个问题既涉及到汉字简化,也涉及到字形标准,甚至还涉及到日本当用汉字的问题。
中国最早推行汉字简化是在国民政府时期,中华民国教育部于 1935 年公布《第一批简体字表》,共计 324 字,但因考试院院长戴季陶坚决反对,最终未能实行。第二个尝试简化汉字的是日本,1946 年日本内阁发布了《当用汉字表》,共计 1850 字,其中一百余字采用新字体,也就是简化汉字。不过这份《当用汉字表》后来在 1981 年被《常用汉字表》所取代,2010 年最新版本共收录 2136 字,其中三百余字采用新字体,另外《人名用汉字表》和《表外汉字字体表》亦引入了一些新字体。中华人民共和国成立后,文字改革委员会重启了汉字简化工作,并于 1955 年发表了《汉字简化方案(草案)》,次年国务院通过决议确立了其规范汉字的地位,后来又于 1964 年推出了改进版本《简化字总表》。1977 年,文字改革委员会发表《第二次汉字简化方案(草案)》,尝试进一步简化汉字,不过最终遭到废止。因此,中国最新的汉字规范 2013 年《通用规范汉字表》仍沿用第一次汉字简化方案,共简化了 2461 字。新加坡则于 1969 年推出过自己的《简体字表》,但 1976 年开始完全转用中国的简化方案。
综上所述,中国大陆和新加坡采用同一套汉字简化方案,而日本用自己的新字体,剩下的港澳台韩朝越都没有官方推行汉字简化。中日两国的简化方案,既有简化相同的「國/国」,也有简化不同的「譯/译/訳」,既有中国简化日本没简的「東/东」,也有日本简化中国没简的「假/仮」,不过总体来说中国比日本简化了更多汉字。另外有一个跟繁简相近的概念是异体字,也就是长期存在的读音和意思相同、但字形不同的汉字。不同的地区会选择不同的异体字作为正字,譬如中国大陆以「够」为正字而港台以「夠」为正字,香港用「裏」而台湾用「裡」等等。日本还有一个更复杂的情况是《当用汉字表》引起的同音假借现象:1946 年的《当用汉字表》将出版物的汉字使用限制在了最常用的 1850 字内,致使大量表外汉字需要用同音汉字进行替代。日本国语审议会为此发表了《同音汉字转写》的报告供出版业参考,譬如「綜合」转写为「総合」、「智慧」转写为「知恵」等等。虽然当用汉字的限制已于 1981 年废除,但大量日语词汇的用字已经不可逆转地改变了。
各国的印刷字形标准也不尽相同。举前面那张图中的「圈」为例,这里中国大陆和台湾字形基本相同,除了台湾将捺改成了点,日本字形则把下边的「㔾」改成了「己」并且捺会贯穿上面一横,而韩国字形上边不是「丷」而是「ハ」。韩国字形与传统的康熙字典体最为接近,而中国大陆和台湾则有成文的新字形标准——《印刷通用汉字字形表》《常用国字标准字体表》,各有各的不同。
对于西方人来说,汉字已经是极大的障碍了。不仅如此,日语使用假名、韩语使用谚文,没背过字母表的人根本无法认读它们。因此日语和韩语都有各自的罗马化(拉丁字母转写)方案,就像汉语拼音和越南国语字一样。上面提到的 Matsumoto Yukihiro 和 Jang Won-young 就分别是 まつもと ゆきひろ 和 장원영 的罗马字。日韩的罗马字方案有很多种,日语常用的是平文式罗马字和训令式罗马字,而韩语常用的是马科恩-赖肖尔表记法和文观部2000年式。
日语平文式和训令式的主要区别在于 し/しゃ/じ/じゃ、ち/ちゃ/ぢ/ぢゃ、つ/づ、ふ 相关的表记,平文式记为 shi/sha/ji/ja、chi/cha/ji/ja、tsu/zu、fu,而训令式记为 si/sya/zi/zya、ti/tya/di/dya、tu/du、hu。换句话说,平文式更接近真实读音,而训令式更加规则。因此,虽然训令式是 ISO 3602 标准,但日常生活中还是平文式用得更多,譬如 Matsumoto Yukihiro 就是平文式罗马字。
韩语马赖式和文观部式的主要区别在于元音 ㅓ、ㅕ、ㅝ、ㅡ、ㅢ 和辅音 ㄱ、ㄷ、ㅂ、ㅈ、ㅉ 用在声母时的表记,马赖式记为 ŏ、yŏ、wŏ、ŭ、ŭi 和 k、t、p、ch、tch,而文观部式记为 eo、yeo、wo、eu、ui 和 g、d、b、j、jj。目前朝鲜官方使用稍作修改的马赖式,韩国官方使用文观部式,但韩国民众日常转写时并不一定遵循,譬如 Jang Won-young 就不是两种方案的任何一种。
这里顺便提一下汉语的罗马化方案,其中我们最熟悉的当然是已经成为 ISO 7098 标准的汉语拼音,然而汉语拼音在港澳台并不通行。首先,台湾跟中国大陆最不同的是他们使用注音符号(ㄅㄆㄇㄈ)而非拉丁字母来给汉字注音,所以台湾人并没有在课堂上学过如何拼注罗马拼音。台湾政府早期采用过国语罗马字和注音符号第二式,陈水扁时代则推行过通用拼音,马英九时代又开始推行汉语拼音,而民众大多基于威妥玛拼音来拼写自己的名字。威妥玛拼音早在 19 世纪中叶由英国驻华公使发明,相对比较符合英语使用者的习惯,譬如台北(Taipei)、台中(Taichung)、高雄(Kaohsiung)等都约定俗成地使用了威妥玛拼音,而没有遵循任何时期的政府规范。以威妥玛拼音为基础的邮政式拼音在中国大陆也留下了深远的影响,譬如北京(Peking)、苏州(Soochow)等,北大、苏大的英文名用的就是这套邮政式拼音。香港亦有数套粤语拼音方案并行,人名和地名一般使用香港政府粤语拼音,而香港语言学学会则在推行粤拼以求统一教育界的粤语拼音使用。
输入法也是汉字文化圈的特产,因为西方国家通过调整键盘布局就能键入各种表音文字,常用语言只有汉字才非得要输入法才能键入。因为中国大陆一般使用拼音输入法、台湾一般用注音输入法,所以我一直很好奇香港人是不是会用粤拼输入法。后来发现粤拼在粤语地区仍不普及,香港人一般用仓颉输入法或其简化版本速成输入法,都属于字形输入法。
日韩本身也有自己的键盘布局,用来快捷地键入假名和谚文。但对于我们用 QWERTY 键盘的外国人来说,就只能学习罗马字输入法了。macOS 自带的日语输入法还好,能兼容平文式和训令式罗马字,但韩语输入法就相当令人迷惑了。macOS 列出的韩语键盘共有五种:2-Set、3-Set、390 Sebulshik、[工振厅罗马字]{GongjinCheong Romaja}、HNC [罗马字]{Romaja}。前三个对应的是三种不同的韩语键盘布局,这里就不深究了,我也不吐槽苹果标新立异地把 Sebeolsik 拼成 Sebulshik 是什么心态了。后面两个明显是我们需要的罗马字输入法,但问题来了:这里的工振厅和 HNC 都是啥呢?
调查一番可以发现,工振厅是已于 1996 年撤销的韩国工业振兴厅,HNC 是韩国办公软件公司 Hancom (Haansoft)。从现存资料来看,工振厅并没有发布过罗马字方案,但我考证发现有韩国媒体报道过工振厅参与了韩朝双方关于 ISO/TR 11941 的谈判,不过双方并没有就最终草案达成一致(正因如此韩语罗马字尚无国际标准可循)。所以我认为工振厅罗马字就是指这份已撤回的国际标准草案的韩方版本,其与韩国现行的文观部罗马字的比较可以参见这份报告或者这篇文章。而 HNC 罗马字指的是 Hancom 的旧式罗马字方案,其辅音部分跟工振厅罗马字相同,但元音的罗马字较为简短,复合元音是由基本元音直接相加而成,具体差异见下表(工振厅罗马字中 y/i 和 w/u 两对字母没有区别,HNC 中 y/i 没有区别):
ㅐ | ㅒ | ㅓ | ㅔ | ㅕ | ㅖ | ㅘ | ㅙ | ㅚ | ㅝ | ㅞ | ㅟ | ㅡ | ㅢ | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
工振 | ae | yae | eo | e | yeo | ye | wa | wae | oe | weo | we | wi | eu | yi |
HNC | ai | yai | e | ei | ye | yei | oa | oai | oi | ue | uei | ui | w | wi |
另外附赠几个 macOS 日韩罗马字输入法的小贴士:
最后简单聊聊汉字文化圈的字符编码,管中窥豹地看看 Unicode 的汉字编码遇到了怎样的困难。在 Unicode 以前,西方世界最常用的字符集标准是 ISO/IEC 8859,它将 ASCII 从 7-bit 扩展到了 8-bit,定义了 15 种变体服务于以欧洲为主的常用表音文字。但这样的单字节编码显然是不够存放汉字的,于是东亚各国基于可变长的 EUC 编码各自制定了自己的字符集。
中国大陆的字符集标准经历了 GB 2312、GBK、GB 18030 三个阶段,现行的 GB 18030 单字节兼容 ASCII、双字节兼容 GBK、四字节则映射到了其余的 Unicode 码位;台湾的业界标准则是双字节的 Big5,香港则在此基础上制定了香港增补字符集。而日韩朝三国也有各自的工业标准 JIS X 0208、KS X 1001、KPS 9566,收录了本国所需的汉字以及假名或是谚文等,越南则有 TCVN 5773 收录喃字、TCVN 6056 收录儒字。看到这么多眼花缭乱的字符集标准,Unicode 的最大难题就是如何把各国标准中相同的汉字合并起来。因为 Unicode 也是国际标准 ISO/IEC 10646,所以国际标准化组织旗下有一个表意文字小组(ISO/IEC JTC1/SC2/WG2/IRG)来专门负责各国汉字的统一工作,现任召集人是香港理工大学的陆勤教授。
Unicode 的汉字部分被称为中日韩统一表意文字,而统一各国汉字的工作被称为认同。表意文字小组的工作曾有两项基本原则:汉字认同原则和字源分离原则。在汉字认同原则下,起源相同、字形相似的汉字会被赋予相同的码位,其不同字形则交由异体字选择器处理,或是由符合各地标准的字体自行渲染。而在字源分离原则下,如果某列为字源的字符集收录了同一汉字的不同字形,则 Unicode 也同时收录它们,以便与原字符集一一对应。这两项原则的本质对立导致了一些自相矛盾的决定,譬如「[戶]{U+6236}」「[户]{U+6237}」「[戸]{U+6238}」有三个码位,而「[房]{U+623F}」却只有一个。更有甚者,韩国工业标准 KS X 1001 对同一个汉字的不同读音赋予了独立的编码,譬如多音字「[樂]{U+6A02}」「[樂]{U+F914}」「[樂]{U+F95C}」「[樂]{U+F9BF}」分别对应 [악]{yuè}、[낙]{nè}、[락]{lè}、[요]{yào},不过由于后三个字位于兼容区,它们会在 Unicode 归一化时被转换成第一个字。字源分离原则破坏了 Unicode 只对字而不对字形或读音进行编码的大方针,因此扩展区汉字已经不再遵循了,字形的细微差异如今可以通过异体字选择器来指定。这项旷日持久的汉字认同工作还衍生了 Unihan 数据库,为每一个收录的汉字提供了在各国字源中的编码、各国字典中的索引、各地区的读音、英语释义、相关异体字等等,为汉字的国际标准化作出了巨大的贡献。
]]>原文标题:Megaparsec tutorial from IH book
原文链接:https://markkarpov.com/tutorial/megaparsec.html
本篇 Megaparsec 教程原本是为《中级 Haskell》一书写的一章。但由于这本书在过去的一年里没有什么进展,于是其他合著者同意将本文发表为一篇独立的教程,以飨读者。
ParsecT
和 Parsec
单子eof
耗尽输入try
控制回溯MonadParsec
类型类notFollowedBy
和 lookAhead
在上一章「例:编写自己的语法分析组合子」中编写的玩具性质的语法分析组合子并不适合实际使用,因此我们继续来看看 Haskell 生态圈中能够解决相同问题的库,并请留意它们各自的利弊权衡:
String
和 ByteString
的语法分析可以做到开箱即用,但 Text
则不行。parsec
的一个分支,在过去数年里保持着积极的开发。当前版本尝试在速度、灵活性和错误信息质量之间找到一个最佳平衡。因为是 parsec
的非官方继任者,使用过 parsec
或者读过其教程的用户一定会对它感到十分亲切。把上述语法解析库全部讲一遍是不现实的,因此本文聚焦 megaparsec
。更准确地说,我们将会讲解该库的版本 8.0,其于本书正式发行时应该已经取代旧版本成为主流版本了。
ParsecT
和 Parsec
单子ParsecT
是 megaparsec
中主要的语法分析单子变换和核心数据类型。ParsecT e s m a
各参数分别表示:
e
是用来表示错误信息的自定义组件的类型。如果我们不想做自定义(目前我们确实不想),那么用 Data.Void
模块中的 Void
就行了。s
是输入流的类型。megaparsec
对于 String
、严格或惰性的 Text
、严格或惰性的 ByteString
都是开箱即用的,当然自定义输入流也是可用的。m
是 ParsecT
单子变换的内部单子。a
是单子中的值,作为语法分析的结果。因为大多数时候 m
就是 Identity
,所以 Parsec
这个类型别名非常有用:
type Parsec e s a = ParsecT e s Identity a
简而言之,Parsec
就是没有单子变换的 ParsecT
。
我们还可以把 megaparsec
的单子变换类比于 MTL 单子变换和类型类。确实,我们还有 MonadParsec
类型类的用途与 MonadState
和 MonadReader
相近。我们会在后面的章节详细讨论 MonadParsec
。
说到类型别名,开始使用 megaparsec
的最佳方式就是为自己的语法分析器定义一个类型别名。这有两个好处:
Parser Int
,其中 Parser
是你的语法分析单子。没有签名,诸如 e
之类的参数会有歧义,这是多态 API 不利的一面。megaparsec
API 是多态的,但预计最终用户都会使用具体类型的语法分析单子,这样便可进行内联工作,并将大多数函数定义转储到接口文件,这让 GHC 能够生成非常高效的非多态代码。让我们定义一个类型别名(一般都叫 Parser
):
type Parser = Parsec Void Text
-- ^ ^
-- | |
-- Custom error component Type of input stream
在本文中出现的 Parser
假定为此类型,直到我们开始自定义语法分析错误为止。
我们之前说了 megaparsec
对于五种输入流类型是开箱即用的:String
、严格或惰性的 Text
、严格或惰性的 ByteString
。之所以可以这样,是因为在该库中这些类型都是 Stream
类型类的实例,其对所有可用作 megaparsec
语法分析器输入的数据类型做了功能上的抽象。
简化版本的 Stream
可以表示如下:
class Stream s where
type Token s :: *
type Tokens s :: *
take1_ :: s -> Maybe (Token s, s) -- aka uncons
tokensToChunk :: Proxy s -> [Token s] -> Tokens s
实际的 Stream
定义包含更多方法,但我们不用知道那些就能使用该库。
注意这个类型类关联了两个类型函数:
Token s
是单个词法单词的类型。一般来说是 Char
或者 Word8
,但对于自定义流来说也可能是其他类型。Tokens s
是流的「一大块」的类型,此概念是为性能考虑而引入的。确实常常有相当于单词列表 [Token s]
但又更加高效的表示方法,比如 Text
类型的输入流有 Tokens s ~ Text
,即 Text
的一大块就是 Text
。虽然类型等式 Tokens s ~ s
常常是成立的,但在自定义流中 Tokens s
和 s
可能不同,所以我们将这两个类型分开了。我们可以把所有默认的输入流列进一张表格:
s |
Token s |
Tokens s |
---|---|---|
String |
Char |
String |
strict Text |
Char |
strict Text |
lazy Text |
Char |
lazy Text |
strict ByteString |
Word8 |
strict ByteString |
lazy ByteString |
Word8 |
lazy ByteString |
我们得习惯 Token
和 Tokens
这两个类型函数,因为它们在 megaparsec
API 的类型声明中无处不在。
你可能会注意到,如果我们把所有默认输入流按照单词类型分类,可以得到两类:
Token s ~ Char
:String
、严格或惰性的 Text
;Token s ~ Word8
:严格或惰性的 ByteString
。因此用 megaparsec
就不需要为每种输入流类型都编写一个同样的语法分析器(比如用 attoparsec
就需要),但我们仍要为不同的单词类型编写不同的代码:
Text.Megaparsec.Char
模块;Text.Megaparsec.Byte
模块。这些模块包含两组相似的语法分析工具,例如:
Text.Megaparsec.Char |
Text.Megaparsec.Byte |
|
---|---|---|
newline |
(MonadParsec e s m, Token s ~ Char) => m (Token s) |
(MonadParsec e s m, Token s ~ Word8) => m (Token s) |
eol |
(MonadParsec e s m, Token s ~ Char) => m (Tokens s) |
(MonadParsec e s m, Token s ~ Word8) => m (Tokens s) |
为了更好地理解我们将使用的工具函数,我们先引入几个它们所依赖的原语。
第一个原语是 token
,相应地它让我们能够对 Token s
类型的值做语法分析:
token :: MonadParsec e s m
=> (Token s -> Maybe a)
-- ^ Matching function for the token to parse
-> Set (ErrorItem (Token s))
-- ^ Expected items (in case of an error)
-> m a
token
的第一个参数是要分析的单词的匹配函数,如果该函数返回了 Just
那么其值就会成为语法分析的结果,Nothing
则表明语法分析器不接受该单词并且原语会失败。
第二个参数是一个 Set
(来自 containers
包),它包含在失败的情况下所有可能显示给用户的 ErrorItem
。当我们讨论语法分析错误时会详细解说 ErrorItem
。
为了更好地理解 token
是怎样工作的,让我们看看 Text.Megaparsec
模块中适用于所有输入流类型的一些组合子的定义。satisfy
是其中一个相当常见的组合子,我们给它一个对匹配单词返回 True
的断言,它就会返回一个对应的语法分析器:
satisfy :: MonadParsec e s m
=> (Token s -> Bool) -- ^ Predicate to apply
-> m (Token s)
satisfy f = token testToken Set.empty
where
testToken x = if f x then Just x else Nothing
testToken
的工作就是把返回 Bool
值的 f
函数转换为 token
期待的返回 Maybe (Token s)
的函数。在 satisfy
中我们不知道想要匹配的确切单词序列,所以我们传了 Set.empty
作为第二个参数。
satisfy
看起来很好懂,让我们看看怎么使用它。我们需要一个能跑语法分析器的工具函数,megaparsec
提供了 parseTest
让我们在 GHCi 中测试。
首先,让我们启动 GHCi 并导入一些模块:
λ> import Text.Megaparsec
λ> import Text.Megaparsec.Char
λ> import Data.Text (Text)
λ> import Data.Void
我们接着添加 Parser
类型别名,以明确语法分析器的类型:
λ> type Parser = Parsec Void Text
我们还需要开启 OverloadedStrings
语言扩展,这样我们就能把字符串字面量用作 Text
类型的值:
λ> :set -XOverloadedStrings
λ> parseTest (satisfy (== 'a') :: Parser Char) ""
1:1:
|
1 | <empty line>
| ^
unexpected end of input
λ> parseTest (satisfy (== 'a') :: Parser Char) "a"
'a'
λ> parseTest (satisfy (== 'a') :: Parser Char) "b"
1:1:
|
1 | b
| ^
unexpected 'b'
λ> parseTest (satisfy (> 'c') :: Parser Char) "a"
1:1:
|
1 | a
| ^
unexpected 'a'
λ> parseTest (satisfy (> 'c') :: Parser Char) "d"
'd'
因为 satisfy
本身是多态的,所以 :: Parser Char
类型标注是必要的,否则 parseTest
无法得知 MonadParsec e s m
中的 e
和 s
是什么(在这里 m
假定为 Identity
)。如果我们使用的是一个事先存在的有类型签名的语法分析器,那么就不需要这些显式的类型标注了。
看起来是正常工作的。satisfy
有个问题是它没有在失败时告诉我们它期待什么单词,因为我们无法分析 satisfy
调用者提供的函数。另外还有一些不那么通用的组合子,但它们生成更有用的错误信息。例如 single
(还有在 Text.Megaparsec.Byte
和 Texy.Megaparsec.Char
中限定了类型的别名 char
)可以匹配一个特定的单词:
single :: MonadParsec e s m
=> Token s
-> m (Token s)
single t = token testToken expected
where
testToken x = if x == t then Just x else Nothing
expected = E.singleton (Tokens (t:|[]))
这里的 Tokens
数据类型构造器跟我们之前讨论的 Tokens
类型函数没有关系。实际上,Tokens
是 ErrorItem
的一个构造器,用来指定我们期望匹配的具体单词序列。
λ> parseTest (char 'a' :: Parser Char) "b"
1:1:
|
1 | b
| ^
unexpected 'b'
expecting 'a'
λ> parseTest (char 'a' :: Parser Char) "a"
'a'
我们现在可以定义之前表格中的 newline
了:
newline :: (MonadParsec e s m, Token s ~ Char) => m (Token s)
newline = single '\n'
第二个原语叫做 tokens
,它让我们能够语法分析 Tokens s
,即用来匹配输入的一大块:
tokens :: MonadParsec e s m
=> (Tokens s -> Tokens s -> Bool)
-- ^ Predicate to check equality of chunks
-> Tokens s
-- ^ Chunk of input to match against
-> m (Tokens s)
也有两个语法分析器是基于 tokens
定义的:
-- from "Text.Megaparsec":
chunk :: MonadParsec e s m
=> Tokens s
-> m (Tokens s)
chunk = tokens (==)
-- from "Text.Megaparsec.Char" and "Text.Megaparsec.Byte":
string' :: (MonadParsec e s m, CI.FoldCase (Tokens s))
=> Tokens s
-> m (Tokens s)
string' = tokens ((==) `on` CI.mk)
它们会匹配输入中固定的一大块,chunk
(在 Text.Megaparsec.Byte
和 Texy.Megaparsec.Char
中有限定了类型的别名 string
)区分大小写,而 string'
不区分。不区分大小写的匹配要用到 case-insensitive
包,并且加上了 FoldCase
约束。
让我们也来试试这些新的组合子:
λ> parseTest (string "foo" :: Parser Text) "foo"
"foo"
λ> parseTest (string "foo" :: Parser Text) "bar"
1:1:
|
1 | bar
| ^
unexpected "bar"
expecting "foo"
λ> parseTest (string' "foo" :: Parser Text) "FOO"
"FOO"
λ> parseTest (string' "foo" :: Parser Text) "FoO"
"FoO"
λ> parseTest (string' "foo" :: Parser Text) "FoZ"
1:1:
|
1 | FoZ
| ^
unexpected "FoZ"
expecting "foo"
好的,我们可以匹配单个单词和一大块输入了。下一步我们将要学习如何组合这些积木来编写更有趣的语法分析器。
最简单的组合语法分析器的方式是连续执行它们。ParsecT
和 Parsec
是单子,而单子绑定正好可以顺序执行语法分析器:
mySequence :: Parser (Char, Char, Char)
mySequence = do
a <- char 'a'
b <- char 'b'
c <- char 'c'
return (a, b, c)
我们来运行一下看看是不是按照预期工作:
λ> parseTest mySequence "abc"
('a','b','c')
λ> parseTest mySequence "bcd"
1:1:
|
1 | bcd
| ^
unexpected 'b'
expecting 'a'
λ> parseTest mySequence "adc"
1:2:
|
1 | adc
| ^
unexpected 'd'
expecting 'b'
因为所有单子亦是可应用函子,所以我们也可以使用可应用函子式的语法来顺序执行:
mySequence :: Parser (Char, Char, Char)
mySequence =
(,,) <$> char 'a'
<*> char 'b'
<*> char 'c'
第二种方法跟第一种运行结果完全相同,使用哪种风格通常取决于个人品味。单子风格可以说是更冗长但有时更清晰,而可应用函子风格通常更简洁。话说回来,显然单子风格的表达能力更强,因为单子比可应用函子更强大。
eof
耗尽输入可应用函子通常已经足够强大,足以做一些有趣的事情。如果配上拥有单位元且满足结合律的运算符,我们就得到了可应用函子上的单位半群,在 Haskell 中表示为 Alternative
类型类。parser-combinators 包提供了不少基于 Applicative
和 Alternative
概念的抽象组合子,Text.Megaparsec
模块重新导出了这些来自 Control.Applicative.Combinators
的组合子。
一个最常见的组合子是 many
,它允许我们将给定的语法分析器运行零次或多次:
λ> parseTest (many (char 'a') :: Parser [Char]) "aaa"
"aaa"
λ> parseTest (many (char 'a') :: Parser [Char]) "aabbb"
"aa"
第二个结果可能有点令人惊讶,语法分析器吃掉了匹配的 a
,但随后就停了下来。好吧,我们并没有交代在 many (char 'a')
之后要做些什么。
在大多数情况下,我们实际需要强制语法分析器吃掉整个输入,并报告语法分析错误,而不是害羞地默默中止。这就需要我们吃到输入结束。幸运的是,虽然输入结束只是一个概念,但有个 eof :: MonadParsec e s m => m ()
不吃任何单词,仅会在输入结束时成功。让我们把它加进去再试一次:
λ> parseTest (many (char 'a') <* eof :: Parser [Char]) "aabbb"
1:3:
|
1 | aabbb
| ^
unexpected 'b'
expecting 'a' or end of input
我们在语法分析器中没有提到 b
,所以它们肯定是预期之外的。
从现在开始我们将开发一个实际有用的语法分析器,它能处理下述形式的 URI:
scheme:[//[user:password@]host[:port]][/]path[?query][#fragment]
我们记住方括号 []
中的部分是可选的,不论它们出不出现 URI 都是合法的,[]
甚至可以进行嵌套。我们会完整支持该语法1。
让我们从 scheme
开始,我们仅仅接受已知的协议名,譬如 data
、file
、ftp
、http
、https
、irc
和 mailto
。
我们用 string
匹配固定的字符序列,用 Alternative
类型类中的 (<|>)
方法表示「选择」。代码如下:
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
module Main (main) where
import Control.Applicative
import Control.Monad
import Data.Text (Text)
import Data.Void
import Text.Megaparsec hiding (State)
import Text.Megaparsec.Char
import qualified Data.Text as T
import qualified Text.Megaparsec.Char.Lexer as L
type Parser = Parsec Void Text
pScheme :: Parser Text
pScheme = string "data"
<|> string "file"
<|> string "ftp"
<|> string "http"
<|> string "https"
<|> string "irc"
<|> string "mailto"
试着运行一下:
λ> parseTest pScheme ""
1:1:
|
1 | <empty line>
| ^
unexpected end of input
expecting "data", "file", "ftp", "http", "https", "irc", or "mailto"
λ> parseTest pScheme "dat"
1:1:
|
1 | dat
| ^
unexpected "dat"
expecting "data", "file", "ftp", "http", "https", "irc", or "mailto"
λ> parseTest pScheme "file"
"file"
λ> parseTest pScheme "irc"
"irc"
看起来不错,但 pScheme
的定义有点啰嗦。我们可以用 choice
组合子重写 pScheme
:
pScheme :: Parser Text
pScheme = choice
[ string "data"
, string "file"
, string "ftp"
, string "http"
, string "https"
, string "irc"
, string "mailto" ]
choice
只是 asum
的别名,后者会用 (<|>)
对列表元素进行折叠,所以 pScheme
的这两个定义其实是一样的,只是用 choice
更好看一些。
协议名后面是个冒号 :
。回忆一下,如果我们要接着对其他东西做语法分析,我们要用单子绑定或者 do
记法:
data Uri = Uri
{ uriScheme :: Text
} deriving (Eq, Show)
pUri :: Parser Uri
pUri = do
r <- pScheme
_ <- char ':'
return (Uri r)
如果我们运行一下 pUri
,我们会看到它现在要求协议名后面跟着一个冒号:
λ> parseTest pUri "irc"
1:4:
|
1 | irc
| ^
unexpected end of input
expecting ':'
λ> parseTest pUri "irc:"
Uri {uriScheme = "irc"}
但我们还没完成协议名的语法分析。一位优秀的 Haskell 程序员编写的类型,能让错误数据无处遁形。并不是任何 Text
值都代表合法的协议名,因此让我们定义一个表示协议名的数据类型,并让 pScheme
返回它:
data Scheme
= SchemeData
| SchemeFile
| SchemeFtp
| SchemeHttp
| SchemeHttps
| SchemeIrc
| SchemeMailto
deriving (Eq, Show)
pScheme :: Parser Scheme
pScheme = choice
[ SchemeData <$ string "data"
, SchemeFile <$ string "file"
, SchemeFtp <$ string "ftp"
, SchemeHttp <$ string "http"
, SchemeHttps <$ string "https"
, SchemeIrc <$ string "irc"
, SchemeMailto <$ string "mailto" ]
data Uri = Uri
{ uriScheme :: Scheme
} deriving (Eq, Show)
(<$)
运算符仅仅把左边的值放入函子上下文,而不管里面原来是什么。a <$ f
与 const a <$> f
等价,但对于一些函子来说更高效。
让我们再来试一下:
λ> parseTest pUri "https:"
1:5:
|
1 | https:
| ^
unexpected 's'
expecting ':'
唔……https
应该是个合法的协议名,你能看出哪里出错了吗?语法分析器逐个尝试这些选择,一旦 http
匹配就不会再往下试 https
了。解决方法就是把 SchemeHttps <$ string "https"
放到 schemeHttp <$ string "http"
上面去。一定要记住:顺序会影响选择!
现在 pUri
正常工作了:
λ> parseTest pUri "http:"
Uri {uriScheme = SchemeHttp}
λ> parseTest pUri "https:"
Uri {uriScheme = SchemeHttps}
λ> parseTest pUri "mailto:"
Uri {uriScheme = SchemeMailto}
λ> parseTest pUri "foo:"
1:1:
|
1 | foo:
| ^
unexpected "foo:"
expecting "data", "file", "ftp", "http", "https", "irc", or "mailto"
try
控制回溯下一步是处理 [//[user:password@]host[:port]]
,这里我们需要嵌套可选部分,因此让我们更新一下 Uri
类型:
data Uri = Uri
{ uriScheme :: Scheme
, uriAuthority :: Maybe Authority
} deriving (Eq, Show)
data Authority = Authority
{ authUser :: Maybe (Text, Text) -- (user, password)
, authHost :: Text
, authPort :: Maybe Int
} deriving (Eq, Show)
现在我们需要讨论一个重要概念,也就是回溯。回溯是指及时返回而不吃掉任何输入,这在处理分支是非常重要。下面是一个例子:
alternatives :: Parser (Char, Char)
alternatives = foo <|> bar
where
foo = (,) <$> char 'a' <*> char 'b'
bar = (,) <$> char 'a' <*> char 'c'
看起来很合理,我们来试一下:
λ> parseTest alternatives "ab"
('a','b')
λ> parseTest alternatives "ac"
1:2:
|
1 | ac
| ^
unexpected 'c'
expecting 'b'
发生了什么呢?最先尝试的 foo
的 char 'a'
部分成功了,所以输入流里的 a
被吃掉了。接着 char 'b'
没能匹配 c
,所以我们得到了这样的错误信息。重要的一点是,(<|>)
根本没有尝试 bar
,因为 foo
已经把一些输入吃掉了。
一方面这是为性能考虑,另一方面把 foo
剩下来的东西喂给 bar
也没什么意义。我们期望在 bar
运行的时候,输入流正处于 foo
开始的位置。megaparsec
并不会自动回溯(与 attoparsec
或是上一章中的玩具组合子不同),所以我们需要用 try
原语来显式表达我们想要回溯。如果 p
失败了,那么 try p
就会进行回溯,就像没有输入被吃掉一样(实际上它回溯了整个语法分析状态)。这就允许 (<|>)
尝试右边的选择了:
alternatives :: Parser (Char, Char)
alternatives = try foo <|> bar
where
foo = (,) <$> char 'a' <*> char 'b'
bar = (,) <$> char 'a' <*> char 'c'
λ> parseTest alternatives "ac"
('a','c')
所有会吃输入的原语(当然也有诸如 try
这样改变现有语法分析器行为的原语)的输入消耗是「原子性」的。也就是说,它们失败时会自动回溯,所以它们不会吃掉部分输入而中途失败。这就是为什么 pScheme
的所有选择能正常工作:string
是基于 tokens
定义的,而 tokens
是原语。我们要么匹配整个字符串,要么直接失败而不吃掉任何输入流。
回到 URI 的语法分析上,(<|>)
能够用来构建一个方便的 optional
组合子:
optional :: Alternative f => f a -> f (Maybe a)
optional p = (Just <$> p) <|> pure Nothing
如果 optional p
中的 p
匹配成功了,那么我们能得到包装在 Just
中的结果,否则返回 Nothing
。这就是我们想要的!但我们没必要自己定义 optional
,因为 Text.Megaparsec
帮我们重新导出了这个组合子。我们现在可以把它用在 pUri
上了:
pUri :: Parser Uri
pUri = do
uriScheme <- pScheme
void (char ':')
uriAuthority <- optional . try $ do -- (1)
void (string "//")
authUser <- optional . try $ do -- (2)
user <- T.pack <$> some alphaNumChar -- (3)
void (char ':')
password <- T.pack <$> some alphaNumChar
void (char '@')
return (user, password)
authHost <- T.pack <$> some (alphaNumChar <|> char '.')
authPort <- optional (char ':' *> L.decimal) -- (4)
return Authority {..} -- (5)
return Uri {..} -- (6)
我擅自让所有字母和数字都可用作用户名和密码,主机名也做了相似的简化。
有几点需要留意:
optional
的参数用 try
包起来,因为其参数是组合起来的语法分析器,而不是原语。some
和 many
很像,但要求至少匹配一次:some p = (:) <$> p <*> many p
。try
!这里如果 char ':'
成功了(它本身是基于 token
定义的,不需要 try
),我们知道紧接着一定是端口号,所以我们只需要 L.decimal
来匹配十进制数。在匹配完 :
之后,我们并不需要回溯。RecordWildCards
语言扩展组装了 Authority
和 Uri
的值。在 GHCi 中试试 pUri
,你会发现它能正常工作:
λ> parseTest (pUri <* eof) "https://mark:secret@example.com"
Uri
{ uriScheme = SchemeHttps
, uriAuthority = Just (Authority
{ authUser = Just ("mark","secret")
, authHost = "example.com"
, authPort = Nothing } ) }
λ> parseTest (pUri <* eof) "https://mark:secret@example.com:123"
Uri
{ uriScheme = SchemeHttps
, uriAuthority = Just (Authority
{ authUser = Just ("mark","secret")
, authHost = "example.com"
, authPort = Just 123 } ) }
λ> parseTest (pUri <* eof) "https://example.com:123"
Uri
{ uriScheme = SchemeHttps
, uriAuthority = Just (Authority
{ authUser = Nothing
, authHost = "example.com"
, authPort = Just 123 } ) }
λ> parseTest (pUri <* eof) "https://mark@example.com:123"
1:13:
|
1 | https://mark@example.com:123
| ^
unexpected '@'
expecting '.', ':', alphanumeric character, or end of input
不过,你可能会发现这样一个问题:
λ> parseTest (pUri <* eof) "https://mark:@example.com"
1:7:
|
1 | https://mark:@example.com
| ^
unexpected '/'
expecting end of input
这个语法分析错误的提示信息有待改进!怎么改进呢?弄清问题所在的最简单方法是用内置的 dbg
工具:
dbg :: (Stream s, ShowToken (Token s), ShowErrorComponent e, Show a)
=> String -- ^ Debugging label
-> ParsecT e s m a -- ^ Parser to debug
-> ParsecT e s m a -- ^ Parser that prints debugging messages
让我们把它加进 pUri
:
pUri :: Parser Uri
pUri = do
uriScheme <- dbg "scheme" pScheme
void (char ':')
uriAuthority <- dbg "auth" . optional . try $ do
void (string "//")
authUser <- dbg "user" . optional . try $ do
user <- T.pack <$> some alphaNumChar
void (char ':')
password <- T.pack <$> some alphaNumChar
void (char '@')
return (user, password)
authHost <- T.pack <$> dbg "host" (some (alphaNumChar <|> char '.'))
authPort <- dbg "port" $ optional (char ':' *> L.decimal)
return Authority {..}
return Uri {..}
然后再用刚才的输入运行 pUri
看看:
λ> parseTest (pUri <* eof) "https://mark:@example.com"
scheme> IN: "https://mark:@example.com"
scheme> MATCH (COK): "https"
scheme> VALUE: SchemeHttps
user> IN: "mark:@example.com"
user> MATCH (EOK): <EMPTY>
user> VALUE: Nothing
host> IN: "mark:@example.com"
host> MATCH (COK): "mark"
host> VALUE: "mark"
port> IN: ":@example.com"
port> MATCH (CERR): ':'
port> ERROR:
port> 1:14:
port> unexpected '@'
port> expecting integer
auth> IN: "//mark:@example.com"
auth> MATCH (EOK): <EMPTY>
auth> VALUE: Nothing
1:7:
|
1 | https://mark:@example.com
| ^
unexpected '/'
expecting end of input
我们可以看到内部到底发生什么了:
scheme
匹配成功了。user
失败了:虽然有个用户名 mark
,但 :
后面没有密码(我们这里要求密码非空)。虽然我们失败了,但 try
带我们回溯了。host
从 user
开始的位置运作,并尝试把输入解释成主机名。我们可以看到它成功了,并返回了主机名 mark
。port
开始运作。它看见了 :
,但发现后面没有数字,因此也失败了。auth
语法分析失败了(port
在 auth
里面失败了)。auth
语法分析器返回了 Nothing
,因为它什么都分析不出来。现在 eof
要求吃到输入结束,但现实并非如此,因此我们得到了最终的错误信息。我们怎么办呢?这是一个用 try
包住一大堆代码导致错误信息不可读的例子。让我们再看一下我们要处理的语法:
scheme:[//[user:password@]host[:port]][/]path[?query][#fragment]
我们在找什么?在找可以让我们确定特定分支的东西,就像我们看到 :
就肯定后面跟着端口号一样。如果仔细找的话,你会发现双斜杠是 //
是进入 Authority
部分的标志。因为我们是用原子性的语法分析器(string
)来匹配 //
的,它会自动回溯,而一旦匹配到 //
我们就能确定我们需要匹配到 Authority
部分。让我们把 pUri
的第一个 try
删掉吧:
pUri :: Parser Uri
pUri = do
uriScheme <- pScheme
void (char ':')
uriAuthority <- optional $ do -- removed 'try' on this line
void (string "//")
authUser <- optional . try $ do
user <- T.pack <$> some alphaNumChar
void (char ':')
password <- T.pack <$> some alphaNumChar
void (char '@')
return (user, password)
authHost <- T.pack <$> some (alphaNumChar <|> char '.')
authPort <- optional (char ':' *> L.decimal)
return Authority {..}
return Uri {..}
现在我们得到了更可读的错误信息:
λ> parseTest (pUri <* eof) "https://mark:@example.com"
1:14:
|
1 | https://mark:@example.com
| ^
unexpected '@'
expecting integer
虽然有点误导人,但这是个比较微妙的例子。里面有太多 optional
了。
有时完整列出我们期待的东西会有点长,记得我们用未知的协议名进行测试的时候吗?
λ> parseTest (pUri <* eof) "foo://example.com"
1:1:
|
1 | foo://example.com
| ^
unexpected "foo://"
expecting "data", "file", "ftp", "http", "https", "irc", or "mailto"
megaparsec
提供了对提示信息进行自定义的方法,即使用「标签」。我们可以这样使用 label
原语(它有个别名是 (<?>)
运算符):
pUri :: Parser Uri
pUri = do
uriScheme <- pScheme <?> "valid scheme"
-- the rest stays the same
λ> parseTest (pUri <* eof) "foo://example.com"
1:1:
|
1 | foo://example.com
| ^
unexpected "foo://"
expecting valid scheme
我们可以继续加入更多标签,以使错误信息更加可读:
pUri :: Parser Uri
pUri = do
uriScheme <- pScheme <?> "valid scheme"
void (char ':')
uriAuthority <- optional $ do
void (string "//")
authUser <- optional . try $ do
user <- T.pack <$> some alphaNumChar <?> "username"
void (char ':')
password <- T.pack <$> some alphaNumChar <?> "password"
void (char '@')
return (user, password)
authHost <- T.pack <$> some (alphaNumChar <|> char '.') <?> "hostname"
authPort <- optional (char ':' *> label "port number" L.decimal)
return Authority {..}
return Uri {..}
举个例子:
λ> parseTest (pUri <* eof) "https://mark:@example.com"
1:14:
|
1 | https://mark:@example.com
| ^
unexpected '@'
expecting port number
另一个原语叫做 hidden
。如果说 label
是在为提示信息进行重命名,那么 hidden
就是把它们直接移除了。做个比较:
λ> parseTest (many (char 'a') >> many (char 'b') >> eof :: Parser ()) "d"
1:1:
|
1 | d
| ^
unexpected 'd'
expecting 'a', 'b', or end of input
λ> parseTest (many (char 'a') >> hidden (many (char 'b')) >> eof :: Parser ()) "d"
1:1:
|
1 | d
| ^
unexpected 'd'
expecting 'a' or end of input
如果我们想让错误信息不那么啰嗦,hidden
会很有用。比如说,在对编程语言做语法分析时,最好丢弃「expecting white space」的提示信息,因为几乎所有单词后面都可以有空格。
【练习】我们把 pUri
的剩余部分留给读者完成,所有要用到的工具都已经讲解过了。
我们已经探索了如何构建语法分析器,但我们还没审视能让我们运行它们的函数,除了 parseTest
。
传统上来说,从你的程序运行语法分析器的「默认」函数一直是 parse
,但parse
其实是 runParser
的别名:
runParser
:: Parsec e s a -- ^ Parser to run
-> String -- ^ Name of source file
-> s -- ^ Input for parser
-> Either (ParseErrorBundle s e) a
第二个参数只是用来在错误信息中显示的文件名,megaparsec
并不会尝试去读这个文件,因为真正的输入是这个函数的第三个参数。
runParser
允许我们运行 Parsec
单子,我们已经知道,它就是没有单子变换的 ParsecT
:
type Parsec e s = ParsecT e s Identity
runParser
有三个姊妹:runParser'
、runParserT
和 runParserT'
。有后缀 T
的版本可以进行 ParsecT
单子变换,而有一撇的版本接受并返回语法分析器状态。让我们把它们列进一张表:
参数 | 运行 Parsec |
运行 ParsecT |
---|---|---|
输入和文件名 | runParser |
runParserT |
自定义初始状态 | runParser' |
runParserT' |
比如当你需要设置制表符宽度(默认是 8)的时候,自定义初始状态就很有用。举了例子,下面是 runParser'
的类型签名:
runParser'
:: Parsec e s a -- ^ Parser to run
-> State s -- ^ Initial state
-> (State s, Either (ParseErrorBundle s e) a)
手动修改 State
是该库的高级用法,我们不会在这里介绍。
MonadParsec
类型类megaparsec
中的所有工具都可用于 MonadParsec
类型类的任何实例。该类型类抽象了「组合子原语」,即所有 megaparsec
语法分析器的基本单元,这些组合子无法用其它组合子来表示。
将组合子原语定义为类型类,让 ParsecT
具体的主要单子变换得以包装在我们熟悉的 MTL 系单子变换中,从而实现在单子栈各层之间的不同交互。为了更好地理解其动机,请回忆一下单子栈各层的顺序很重要。如果我们这样组合 ReaderT
和 State
:
type MyStack a = ReaderT MyContext (State MyState) a
在外层,ReaderT
无法检查里面 m
层的内部结构。ReaderT
的 Monad
实例描述了绑定策略:
newtype ReaderT r m a = ReaderT { runReaderT :: r -> m a }
instance Monad m => Monad (ReaderT r m) where
m >>= k = ReaderT $ \r -> do
a <- runReaderT m r
runReaderT (k a) r
实际上,我们对 m
的唯一了解是它是 Monad
的一个实例,因此 m
的状态只能通过单子绑定传给 k
。总之,这就是 ReaderT
的 (>>=)
起到的典型作用。
Alternative
的 (<|>)
方法有着不同的作用:它「分裂」了状态,并且这两个语法分析分支不再有交集,因此在某种意义上我们可以回溯状态。也就是说,如果第一个分支被丢弃,那么它对状态的修改也会被丢弃,并不会影响到第二个分支(相当于我们在第一个分支失败时回溯了状态)。
举个例子,我们可以看看 ReaderT
的 Alternative
实例:
instance Alternative m => Alternative (ReaderT r m) where
empty = liftReaderT empty
ReaderT m <|> ReaderT n = ReaderT $ \r -> m r <|> n r
这很棒,因为 ReaderT
是个「无状态」的单子变换,并且很容易将实际工作委托给内部单子(在这里 m
的 Alternative
实例很有用),而无需组合 ReaderT
自身的单子状态(它并没有)。
现在我们来看看 State
,因为 State s a
只是 StateT s Identity a
的别名,我们应该看看 StateT s m
的 Alternative
实例:
instance (Functor m, Alternative m) => Alternative (StateT s m) where
empty = StateT $ \_ -> empty
StateT m <|> StateT n = StateT $ \s -> m s <|> n s
这里我们看到了状态 s
的分裂,正如我们看到了上下文 r
的共享。它们的区别是,表达式 m s
和 n s
会产生有状态的结果:除了单子中的值,它们还会在元组中返回新的状态。这里我们要么走 m s
要么走 n s
,自然实现了回溯。
ParsecT
又如何呢?让我们考虑像下面这样把 State
放进 ParsecT
:
type MyStack a = Parsec Void Text (State MyState) a
ParsecT
比 ReaderT
更复杂,所以它的 (<|>)
实现得做更多工作:
因此 ParsecT
的 Alternative
实例中的 (<|>)
实现无法将其工作委托给内部单子 State MyState
的 Alternative
实例,所以 MyState
不会分裂,我们也不能回溯。
让我们用一个例子来证明这一点:
{-# LANGUAGE OverloadedStrings #-}
module Main (main) where
import Control.Applicative
import Control.Monad.State.Strict
import Data.Text (Text)
import Data.Void
import Text.Megaparsec hiding (State)
type Parser = ParsecT Void Text (State String)
parser0 :: Parser String
parser0 = a <|> b
where
a = "foo" <$ put "branch A"
b = get <* put "branch B"
parser1 :: Parser String
parser1 = a <|> b
where
a = "foo" <$ put "branch A" <* empty
b = get <* put "branch B"
main :: IO ()
main = do
let run p = runState (runParserT p "" "") "initial"
(Right a0, s0) = run parser0
(Right a1, s1) = run parser1
putStrLn "Parser 0"
putStrLn ("Result: " ++ show a0)
putStrLn ("Final state: " ++ show s0)
putStrLn "Parser 1"
putStrLn ("Result: " ++ show a1)
putStrLn ("Final state: " ++ show s1)
这是程序的运行结果:
Parser 0
Result: "foo"
Final state: "branch A"
Parser 1
Result: "branch A"
Final state: "branch B"
我们可以看到 parser0
的分支 b
没有被尝试过。而 parser1
的最终结果(get
返回的值)显然来自分支 a
,即使它因为 empty
而失败了,成功的是分支 b
(empty
在这里表示立即失败,并且不会提供任何提示信息)。并没有发生回溯。
如果我们想要回溯自定义的状态怎么办呢?如果允许将 ParsecT
包装在 StateT
里面的话,就可以做到:
type MyStack a = StateT MyState (ParsecT Void Text Identity) a
现在我们在 MyStack
上用的 (<|>)
作用于 StateT
的实例:
StateT m <|> StateT n = StateT $ \s -> m s <|> n s
它会帮我们回溯状态,并会把剩下的工作委托给内部单子 ParsecT
的 Alternative
实例。这样的行为就是我们想要的:
{-# LANGUAGE OverloadedStrings #-}
module Main (main) where
import Control.Applicative
import Control.Monad.Identity
import Control.Monad.State.Strict
import Data.Text (Text)
import Data.Void
import Text.Megaparsec hiding (State)
type Parser = StateT String (ParsecT Void Text Identity)
parser :: Parser String
parser = a <|> b
where
a = "foo" <$ put "branch A" <* empty
b = get <* put "branch B"
main :: IO ()
main = do
let p = runStateT parser "initial"
Right (a, s) = runParser p "" ""
putStrLn ("Result: " ++ show a)
putStrLn ("Final state: " ++ show s)
程序输出为:
Result: "initial"
Final state: "branch B"
为了让这种方法可行,StateT
应当支持所有组合子原语,这样我们就能像 ParsecT
一样使用它们。换句话说,它们应当是 MonadParsec
的实例,就像它们不仅是 MonadState
的实例,还是 MonadWriter
的实例,只要它们的内部单子也是 MonadWriter
的实例:
instance MonadWriter w m => MonadWriter w (StateT s m) where …
实际上,我们可以将原语从 MonadParsec
的内部实例提升到 StateT
:
instance MonadParsec e s m => MonadParsec e s (StateT st m) where …
megaparsec
为所有 MTL 单子变换定义了 MonadParsec
的实例,这样用户就可以自由地在 ParsecT
中插入单子变换,或是把 ParsecT
包装在那些单子变换中,从而实现在单子栈各层之间的不同交互。
词法分析是将输入流转换为词法单词流的过程:整数、关键字、符号等等,它们比原始输入更加容易直接分析,或者可以送作生成的语法分析器的输入。词法分析可以用外部工具(如 alex
)单独一个流程去做,但 megaparsec
也提供了可以无缝衔接编写词法分析器的函数。
共有两个词法分析器模块:Text.Megaparsec.Char.Lexer
用来处理字符流,Text.Megaparsec.Byte.Lexer
用来处理字节流。因为我们的输入流是严格求值的 Text
,所以我们会用 Text.Megaparsec.Char.Lexer
,不过大多数函数在 Text.Megaparsec.Byte.Lexer
里面长得差不多。
我们要讨论的第一个话题是如何处理空格。在消耗空格的时候保持一致性比较好,即要么在单词前要么在后。megaparsec
的词法分析器模块遵循的策略是:假设单词前没有空格,消耗单词后的空格。
我们需要一种特殊的词法分析器来消耗空格,我们叫它空格消耗器。Text.Megaparsec.Char.Lexer
模块提供了构建通用的空格消耗器的工具:
space :: MonadParsec e s m
=> m () -- ^ A parser for space characters which does not accept empty input (e.g. 'space1')
-> m () -- ^ A parser for a line comment (e.g. 'skipLineComment')
-> m () -- ^ A parser for a block comment (e.g. 'skipBlockComment')
-> m ()
space
函数的文档挺好理解的,但还是让我们来举例说明吧:
{-# LANGUAGE OverloadedStrings #-}
module Main (main) where
import Data.Text (Text)
import Data.Void
import Text.Megaparsec
import Text.Megaparsec.Char
import qualified Text.Megaparsec.Char.Lexer as L -- (1)
type Parser = Parsec Void Text
sc :: Parser ()
sc = L.space
space1 -- (2)
(L.skipLineComment "//") -- (3)
(L.skipBlockComment "/*" "*/") -- (4)
Text.Megaparsec.Char.Lexer
应当限定导入,因为它包含会与 Text.Megaparsec.Char
等模块冲突的名字,比如 space
。L.space
的第一个参数是个挑选空格的词法分析器。要注意它不能接受空输入,否则 L.space
会陷入死循环。Text.Megaparsec.Char
里的 space1
完美符合要求。L.space
的第二个参数定义了如何跳过行注释,即以给定单词序列开始、以行末结束的注释。skipLineComment
可以帮我们轻松创建一个这样的词法分析器。L.space
的第三个参数定义了如何跳过块注释,即包裹在给定的始末单词序列中的注释。skipBlockComment
可以帮我们处理非嵌套的块注释,若要支持嵌套则可使用 skipBlockCommentNested
。操作上,L.space
会不停地轮流尝试以上三个词法分析器,直到三个都不再消耗空格。如果你的语法不包含注释,那么可以直接把 empty
作为第二或第三个参数送给 L.space
。empty
,作为 (<|>)
的单位元,仅仅会让 L.space
尝试下一个词法分析器。
有了空格消耗器 sc
,我们可以定义各种空格相关的工具:
lexeme :: Parser a -> Parser a
lexeme = L.lexeme sc
symbol :: Text -> Parser Text
symbol = L.symbol sc
lexeme
是对词汇分析器的一种包装,能用已给定的空格消耗器挑选出所有尾随空格;symbol
在内部使用 string
来匹配文本,类似地能够挑选出所有的尾随空格。稍后我们将看到它们如何协同工作,但在此之前我们需要引入更多来自 Text.Megaparsec.Char.Lexer
的工具。
对字符和字符串字面量进行词法分析比较微妙,因为有太多转义规则。简单起见,megaparsec
提供了 charLiteral
词法分析器:
charLiteral :: (MonadParsec e s m, Token s ~ Char) => m Char
charLiteral
的工作是根据 Haskell 报告中描述的字符字面量语法来对可能转义了的单个字符进行词法分析。但注意它不会管字面量两边的引号,这有两个原因:
charLiteral
也可以用来对字符串字面量进行词法分析。下面是基于 charLiteral
构建词法分析器的例子:
charLiteral :: Parser Char
charLiteral = between (char '\'') (char '\'') L.charLiteral
stringLiteral :: Parser String
stringLiteral = char '\"' *> manyTill L.charLiteral (char '\"')
L.charLiteral
改造成我们所需的字符字面量的词法分析器,只需要加上两边的引号。这里我们遵循 Haskell 语法用了单引号。between
组合子是这样定义的:between open close p = open *> p <* close
。stringLiteral
用 L.charLiteral
来对每个字符进行词法分析,两边则用双引号包裹。第二个函数也很有趣,因为它用了 manyTill
组合子:
manyTill :: Alternative m => m a -> m end -> m [a]
manyTill p end = go
where
go = ([] <$ end) <|> ((:) <$> p <*> go)
每一轮 manyTill
先尝试运行 end
词法分析器,如果失败了就运行 p
并把结果装进列表。也有 someTill
保证 p
至少成功一次。
最后,一个非常常见的需求是对数字进行词法分析。对于整数来说,有三种工具分别处理十进制、八进制和十六进制数:
decimal, octal, hexadecimal
:: (MonadParsec e s m, Token s ~ Char, Num a) => m a
使用起来很简单:
integer :: Parser Integer
integer = lexeme L.decimal
λ> parseTest (integer <* eof) "123 "
123
λ> parseTest (integer <* eof) "12a "
1:3:
|
1 | 12a
| ^
unexpected 'a'
expecting end of input or the rest of integer
scientific
接受整数和小数的语法,而 float
只接受小数。scientific
会返回 scientific
包的 Scientific
类型,而 float
的返回类型是多态的,可能会返回任何 RealFloat
的实例:
scientific :: (MonadParsec e s m, Token s ~ Char) => m Scientific
float :: (MonadParsec e s m, Token s ~ Char, RealFloat a) => m a
举个例子:
float :: Parser Double
float = lexeme L.float
λ> parseTest (float <* eof) "123"
1:4:
|
1 | 123
| ^
unexpected end of input
expecting '.', 'E', 'e', or digit
λ> parseTest (float <* eof) "123.45"
123.45
λ> parseTest (float <* eof) "123d"
1:4:
|
1 | 123d
| ^
unexpected 'd'
expecting '.', 'E', 'e', or digit
注意所有这些词法分析器都无法处理有符号数,要支持这个我们得把它们包装在 signed
组合子中:
signedInteger :: Parser Integer
signedInteger = L.signed sc integer
signedFloat :: Parser Double
signedFloat = L.signed sc float
signed
的第一个参数是空格消耗器,用来控制正负号和实际数字之间的空格。如果你不允许中间有空格,传 return ()
进去就行了。
notFollowedBy
和 lookAhead
除了 try
,还有另外两种原语可以对输入流进行前瞻,而不会实际挪动当前位置。
第一种是 notFollowedBy
:
notFollowedBy :: MonadParsec e s m => m a -> m ()
只有当其参数语法分析失败了它才会成功,并且不会吃掉任何输入或是修改当前状态。
作为 notFollowedBy
的例子,我们考虑一下关键字:
pKeyword :: Text -> Parser Text
pKeyword keyword = lexeme (string keyword)
这个语法分析器有个毛病:如果我们匹配到的只是标识符的前缀怎么办呢?这个情况下它显然不是关键字。因此我们必须用 notFollowedBy
排除这种情况:
pKeyword :: Text -> Parser Text
pKeyword keyword = lexeme (string keyword <* notFollowedBy alphaNumChar)
另一种原语是 lookAhead
:
lookAhead :: MonadParsec e s m => m a -> m a
如果 lookAhead
的参数 p
成功了,那么整个 lookAhead p
也会成功,但输入流和整个语法分析状态不会改变。
一个例子是对已分析的输入进行检查,要么失败要么成功地进行下去。这可以用下述代码表达:
withPredicate1
:: (a -> Bool) -- ^ The check to perform on parsed input
-> String -- ^ Message to print when the check fails
-> Parser a -- ^ Parser to run
-> Parser a -- ^ Resulting parser that performs the check
withPredicate1 f msg p = do
r <- lookAhead p
if f r
then p
else fail msg
这演示了 lookAhead
的一种用法,但我们还应注意,如果检查成功我们会进行两次语法分析,这不太好。我们可以改用 getOffset
函数解决这个问题:
withPredicate2
:: (a -> Bool) -- ^ The check to perform on parsed input
-> String -- ^ Message to print when the check fails
-> Parser a -- ^ Parser to run
-> Parser a -- ^ Resulting parser that performs the check
withPredicate2 f msg p = do
o <- getOffset
r <- p
if f r
then return r
else do
setOffset o
fail msg
在失败时,我们只需将输入流的偏移量设置回运行 p
之前的位置即可。但现在消耗量跟偏移量会不匹配,但在这里没有关系,因为我们调用 fail
立即结束了语法分析。但这在其它地方可能会出问题,我们将在后面的章节中看到如何改进。
「表达式」是指由一些项和应用于这些项的运算符组成的结构。运算符可以前置、中置、后置,可以左结合、右结合,可以有不同的优先级。这种构造的一个例子是学校里教的算术表达式:
a * (b + 2)
这里我们可以看到两种不同的项:变量(a
、b
)和整数(2
)。另外还有两种运算符:*
和 +
。
为表达式编写一个正确的语法分析器大概需要假以时日。为此,parser-combinators 包提供了 Control.Monad.Combinators.Expr
模块,它一共导出了两样东西:Operator
数据类型和 makeExprParser
工具函数。两者均文档齐全,所以本节我们不会复述文档,而是编写一个简单但功能完备的表达式语法分析器。
让我们先定义一个表示抽象语法树的数据结构:
data Expr
= Var String
| Int Int
| Negation Expr
| Sum Expr Expr
| Subtr Expr Expr
| Product Expr Expr
| Division Expr Expr
deriving (Eq, Ord, Show)
要用 makeExprParser
我们得给它一个项语法分析器和一个运算符表:
makeExprParser :: MonadParsec e s m
=> m a -- ^ Term parser
-> [[Operator m a]] -- ^ Operator table, see 'Operator'
-> m a -- ^ Resulting expression parser
让我们从项语法分析器开始。我们可以把项视为一个盒子,当处理结合性和优先级之类的东西时,表达式的语法分析算法会将其视为不可分割的整体。在我们例子中,有三类东西属于项:变量、整数和括号中的整个表达式。沿用前面几章节的定义,我们可以把项语法分析器定义为:
pVariable :: Parser Expr
pVariable = Var <$> lexeme
((:) <$> letterChar <*> many alphaNumChar <?> "variable")
pInteger :: Parser Expr
pInteger = Int <$> lexeme L.decimal
parens :: Parser a -> Parser a
parens = between (symbol "(") (symbol ")")
pTerm :: Parser Expr
pTerm = choice
[ parens pExpr
, pVariable
, pInteger
]
pExpr :: Parser Expr
pExpr = makeExprParser pTerm operatorTable
operatorTable :: [[Operator Parser Expr]]
operatorTable = undefined -- TODO
pVariable
、pInteger
和 parens
的定义应该没什么疑问。这里幸运的是我们不需要在 pTerm
中使用 try
,因为项的语法没有重叠之处:
(
,那紧接着肯定是一个表达式;最后,为了完成 pExpr
,我们需要定义 operatorTable
,从类型可以看出它是个嵌套列表。每个内层列表装着相同优先级的运算符,而整个外层列表以优先级降序排列。一组运算符的优先级越高,它们结合得就越紧。
data Operator m a -- N.B.
= InfixN (m (a -> a -> a)) -- ^ Non-associative infix
| InfixL (m (a -> a -> a)) -- ^ Left-associative infix
| InfixR (m (a -> a -> a)) -- ^ Right-associative infix
| Prefix (m (a -> a)) -- ^ Prefix
| Postfix (m (a -> a)) -- ^ Postfix
operatorTable :: [[Operator Parser Expr]]
operatorTable =
[ [ prefix "-" Negation
, prefix "+" id
]
, [ binary "*" Product
, binary "/" Division
]
, [ binary "+" Sum
, binary "-" Subtr
]
]
binary :: Text -> (Expr -> Expr -> Expr) -> Operator Parser Expr
binary name f = InfixL (f <$ symbol name)
prefix, postfix :: Text -> (Expr -> Expr) -> Operator Parser Expr
prefix name f = Prefix (f <$ symbol name)
postfix name f = Postfix (f <$ symbol name)
注意 binary
中 InfixL
接受的 Parser (Expr -> Expr -> Expr)
我们是怎么写的,相似的还有 prefix
和 postfix
中的 Parser (Expr -> Expr)
。也就是说,我们先运行 symbol name
然后返回一个函数,它会依次接受各项作为参数并返回 Expr
类型的结果。
准备好了,现在可以试试我们的语法分析器了!
λ> parseTest (pExpr <* eof) "a * (b + 2)"
Product (Var "a") (Sum (Var "b") (Int 2))
λ> parseTest (pExpr <* eof) "a * b + 2"
Sum (Product (Var "a") (Var "b")) (Int 2)
λ> parseTest (pExpr <* eof) "a * b / 2"
Division (Product (Var "a") (Var "b")) (Int 2)
λ> parseTest (pExpr <* eof) "a * (b $ 2)"
1:8:
|
1 | a * (b $ 2)
| ^
unexpected '$'
expecting ')' or operator
Control.Monad.Combinators.Expr
模块的文档里有一些提示,在不太标准的情况下很有用,最好也读一下。
Text.Megaparsec.Char.Lexer
模块还包含一些工具,在处理对缩进敏感的语法时很有用。我们会先综述一下可用的组合子,然后再把它们组装成一个对缩进敏感的语法分析器。
nonIndented
和 indentBlock
让我们从最简单的 nonIndented
开始:
nonIndented :: MonadParsec e s m
=> m () -- ^ How to consume indentation (white space)
-> m a -- ^ Inner parser
-> m a
它允许内部语法分析器吃掉所有没缩进的输入,这是缩进敏感语法分析背后模型的一部分。我们规定,未缩进的部分是顶层定义,而所有缩进的部分直接或间接地从属于顶层定义。在 megaparsec
中,我们不需要任何额外的状态来表达这个想法。因为缩进是相对的,所以我们的想法是显式地把参考单词和缩进单词都传给语法分析器,这样就能通过纯的语法分析器组合来定义对缩进敏感的语法。
那么我们应当如何为缩进块定义语法分析器呢?让我们看一眼 indentBlock
的签名:
indentBlock :: (MonadParsec e s m, Token s ~ Char)
=> m () -- ^ How to consume indentation (white space)
-> m (IndentOpt m a b) -- ^ How to parse “reference” token
-> m a
首先,我们指定如何吃掉缩进。要注意的是这里的空格消耗器必须也吃掉换行符,但正常来讲单词后面的换行符是不应该吃掉的。
如你所见,第二个参数允许我们对参考单词进行语法分析,并返回一个告诉 indentBlock
接下来做什么的数据结构。下面是几种选择:
data IndentOpt m a b
= IndentNone a
-- ^ Parse no indented tokens, just return the value
| IndentMany (Maybe Pos) ([b] -> m a) (m b)
-- ^ Parse many indented tokens (possibly zero), use given indentation level
-- (if 'Nothing', use level of the first indented token);
-- the second argument tells how to get the final result, and
-- the third argument describes how to parse an indented token
| IndentSome (Maybe Pos) ([b] -> m a) (m b)
-- ^ Just like 'IndentMany', but requires at least one indented token to be present
我们可以改变主意不对缩进单词进行语法分析,也可以处理许多缩进单词。我们可以让 indentBlock
检测首个缩进单词的缩进层级并使用它,也可以手动指定缩进层级。
让我们试着对一个简单的缩进列表进行语法分析,我们从导入部分开始:
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TupleSections #-}
module Main (main) where
import Control.Applicative
import Control.Monad (void)
import Data.Text (Text)
import Data.Void
import Text.Megaparsec
import Text.Megaparsec.Char
import qualified Text.Megaparsec.Char.Lexer as L
type Parser = Parsec Void Text
我们需要两种空格消耗器:一种 scn
会吃掉换行符,另一种 sc
不会(实际上在这里它只处理空格和制表符):
lineComment :: Parser ()
lineComment = L.skipLineComment "#"
scn :: Parser ()
scn = L.space space1 lineComment empty
sc :: Parser ()
sc = L.space (void $ some (char ' ' <|> char '\t')) lineComment empty
lexeme :: Parser a -> Parser a
lexeme = L.lexeme sc
为了好玩,我们还允许 #
开头的行注释。
pItemList
是顶层形式,它包括参考单词(表头)和缩进单词(表项):
pItemList :: Parser (String, [String]) -- header and list items
pItemList = L.nonIndented scn (L.indentBlock scn p)
where
p = do
header <- pItem
return (L.IndentMany Nothing (return . (header, )) pItem)
对于我们来讲,表项就是一串字母、数字和短横线组成的序列:
pItem :: Parser String
pItem = lexeme (some (alphaNumChar <|> char '-')) <?> "list item"
让我们将代码载入到 GHCi,用内置的 parseTest
试试:
λ> parseTest (pItemList <* eof) ""
1:1:
|
1 | <empty line>
| ^
unexpected end of input
expecting list item
λ> parseTest (pItemList <* eof) "something"
("something",[])
λ> parseTest (pItemList <* eof) " something"
1:3:
|
1 | something
| ^
incorrect indentation (got 3, should be equal to 1)
λ> parseTest (pItemList <* eof) "something\none\ntwo\nthree"
2:1:
|
2 | one
| ^
unexpected 'o'
expecting end of input
记住我们用的是 IndentMany
选项,所以空列表是可以的。另一方面,内置的 space
组合子已在错误信息中隐藏了「expecting more space」,所以现在的错误信息是完全合理的。
让我们继续试试:
λ> parseTest (pItemList <* eof) "something\n one\n two\n three"
3:5:
|
3 | two
| ^
incorrect indentation (got 5, should be equal to 3)
λ> parseTest (pItemList <* eof) "something\n one\n two\n three"
4:2:
|
4 | three
| ^
incorrect indentation (got 2, should be equal to 3)
λ> parseTest (pItemList <* eof) "something\n one\n two\n three"
("something",["one","two","three"])
让我们把 IndentMany
换成 IndentSome
,把 Nothing
换成 Just (mkPos 5)
(缩进层级从 1 开始数,所以这表示需要 4 个空格的缩进):
pItemList :: Parser (String, [String])
pItemList = L.nonIndented scn (L.indentBlock scn p)
where
p = do
header <- pItem
return (L.IndentSome (Just (mkPos 5)) (return . (header, )) pItem)
现在:
λ> parseTest (pItemList <* eof) "something\n"
2:1:
|
2 | <empty line>
| ^
incorrect indentation (got 1, should be greater than 1)
λ> parseTest (pItemList <* eof) "something\n one"
2:3:
|
2 | one
| ^
incorrect indentation (got 3, should be equal to 5)
λ> parseTest (pItemList <* eof) "something\n one"
("something",["one"])
第一条错误信息可能有点令人惊讶,但 megaparsec
知道列表里至少得有一项,所以它检查了缩进层级发现是 1,于是报告了错误。
让我们允许表项拥有子项,为此我们创建了一个新的语法分析器 pComplexItem
:
pComplexItem :: Parser (String, [String])
pComplexItem = L.indentBlock scn p
where
p = do
header <- pItem
return (L.IndentMany Nothing (return . (header, )) pItem)
pItemList :: Parser (String, [(String, [String])])
pItemList = L.nonIndented scn (L.indentBlock scn p)
where
p = do
header <- pItem
return (L.IndentSome Nothing (return . (header, )) pComplexItem)
如果我们把下面这样的列表喂进去:
first-chapter
paragraph-one
note-A # an important note here!
note-B
paragraph-two
note-1
note-2
paragraph-three
那我们的语法分析器会返回:
Right
( "first-chapter"
, [ ("paragraph-one", ["note-A","note-B"])
, ("paragraph-two", ["note-1","note-2"])
, ("paragraph-three", [])
]
)
以上演示了这个方法是如何扩展到嵌套缩进结构上的,我们并没有引入额外的状态。
「折行」可以包含多行元素,不过后续元素的缩进层级必须高于首个元素。
让我们来试用一下 lineFold
:
pComplexItem :: Parser (String, [String])
pComplexItem = L.indentBlock scn p
where
p = do
header <- pItem
return (L.IndentMany Nothing (return . (header, )) pLineFold)
pLineFold :: Parser String
pLineFold = L.lineFold scn $ \sc' ->
let ps = some (alphaNumChar <|> char '-') `sepBy1` try sc'
in unwords <$> ps <* scn -- (1)
lineFold
的工作方式是:我们先给它一个接受换行符的空格消耗器 scn
,然后它还回来一个特殊的空格消耗器 sc'
,让我们能够在回调中吃掉折行元素之间的空格。
为什么 (1) 处要用 try sc'
和 scn
呢?情况是这样的:
sc'
吃掉空格(也会吃换行符)之后,该列应该比起始列更大。sc'
会停下来。它失败时不会吃掉任何输入(感谢 try
),scn
会被用来挑选空格。sc'
已利用会吃换行符的空格消耗器来探测空格,所以它逻辑上也会在挑选尾随空格时吃掉换行符。这就是为什么我们在 (1) 处用 scn
而不用 sc
。【练习】我们语法分析器的最终版本留给读者做测试。你可以创建多个折行元素,语法分析之后它们会用一个空格拼接在一起。
让我们讨论一下怎么才能提高 megaparsec
语法分析器的性能。不过首先要指出,我们应当用性能分析和基准测试来验证我们的改进。这是我们在性能调优时检查是否有效的唯一方法。
这里有一些常见的建议:
Parsec
单子(回忆一下,这是使用 Identity
的 ParsecT
单子变换,非常轻量),请确保 transformers
库的版本不低于 0.5,megaparsec
的版本不低于 7.0。这两个库在上述版本均有关键性的性能提升,只要升级就能变快。Parsec
单子总是比基于 ParsecT
的单子变换更快。除非绝对必要,请避免使用 StateT
、WriterT
或者其它单子变换。往单子栈里加得越多,语法分析就越慢。type Parser = Parsec Void Text
。这样能让 GHC 更好地进行优化。INLINE
和 INLINEABLE
编译指令能让 GHC 把函数定义转储到接口文件,这有助于进行特化。takeWhileP
、takeWhile1P
和 takeP
。这篇博客解释了为什么它们这么快。satisfy
和 notChar
,而不要使用 oneOf
和 noneOf
。尽量上面大多数建议不需要进一步解释,但我觉得最好养成习惯使用这三个新的原语:takeWhileP
、takeWhile1P
和 takeP
。前两个尤其常见,能帮我们替换掉一些基于 many
和 some
的结构,它们更快并且会改而返回一大块输入流,也就是我们之前说的 Tokens s
类型。
举例来说,回忆一下我们对 URI 的用户名进行语法分析时用到下面的代码:
user <- T.pack <$> some alphaNumChar
我们可以把它替换为 takeWhile1P
:
user <- takeWhile1P (Just "alpha num character") isAlphaNum
-- ^ ^
-- | |
-- label for tokens we match against predicate
当我们对 ByteString
和 Text
进行语法分析时,这会比原来的方法快很多。顺便注意一下,我们能从 takeWhile1P
直接拿到 Text
,所以就不再需要 T.pack
了。
下面这些等式对于理解 takeWhileP
和 takeWhile1P
的 Maybe String
参数很有帮助:
takeWhileP (Just "foo") f = many (satisfy f <?> "foo")
takeWhileP Nothing f = many (satisfy f)
takeWhile1P (Just "foo") f = some (satisfy f <?> "foo")
takeWhile1P Nothing f = some (satisfy f)
到现在我们已经探索了 megaparsec
的大多数特性,是时候来学习一下语法分析错误了:它们如何定义、如何触发、如何在运行时处理它们。
ParseError
类型有如下定义:
data ParseError s e
= TrivialError Int (Maybe (ErrorItem (Token s))) (Set (ErrorItem (Token s)))
-- ^ Trivial errors, generated by Megaparsec's machinery.
-- The data constructor includes the offset of error, unexpected token (if any), and expected tokens.
| FancyError Int (Set (ErrorFancy e))
-- ^ Fancy, custom errors.
用中文来讲:ParseError
要么是个 TrivialError
要么是个 FancyError
,前者会提供偏移量信息、不期而遇的单词(一个或没有)和我们期待的单词集合(可能为空)。
ParseError s e
有下面两个类型参数:
s
是输入流的类型;e
是自定义错误的类型。ErrorItem
是这样定义的:
data ErrorItem t
= Tokens (NonEmpty t) -- ^ Non-empty stream of tokens
| Label (NonEmpty Char) -- ^ Label (cannot be empty)
| EndOfInput -- ^ End of input
还有 ErrorFancy
:
data ErrorFancy e
= ErrorFail String
-- ^ 'fail' has been used in parser monad
| ErrorIndentation Ordering Pos Pos
-- ^ Incorrect indentation error:
-- desired ordering between reference level and actual level,
-- reference indentation level,
-- actual indentation level
| ErrorCustom e
-- ^ Custom error data, can be conveniently disabled by indexing 'ErrorFancy' by 'Void'
ErrorFancy
包括两个 megaparsec
常见错误的数据构造器:
fail
函数会让语法分析器失败并报告任意 String
;最后,ErrorCustom
是允许将任意数据嵌入 ErrorFancy
类型的「扩展槽」。如果我们不需要在语法分析错误中使用自定义数据,我们可以把 Void
传给 ErrorFancy
。由于 Void
不接受非底类型的值,ErrorCustom
就相当于「取消」了,用抽象数据类型做类比的话,就是「与零的积」。
在旧版本中,ParseError
会直接被 parse
等函数返回,但版本 7.0 推迟了每个错误的行和列的计算,以及用于显示错误的相关行内容的获取。这能让语法分析更快,因为这些信息通常只有在语法分析失败时才有用。另一个旧版本的问题是,同时显示多个错误需要每次重新遍历输入来获取正确的行。
这个问题现在由 ParseErrorBundle
数据类型解决了:
-- | A non-empty collection of 'ParseError's equipped with 'PosState' that
-- allows to pretty-print the errors efficiently and correctly.
data ParseErrorBundle s e = ParseErrorBundle
{ bundleErrors :: NonEmpty (ParseError s e)
-- ^ A collection of 'ParseError's that is sorted by parse error offsets
, bundlePosState :: PosState s
-- ^ State that is used for line\/column calculation
}
所有运行语法分析的函数都会返回 ParseErrorBundle
,里面会有设置好的 bundlePosState
和 ParseError
。里面的 ParseError
列表可以由用户自行扩展,不过这样得由用户来保证它们仍按照偏移量有序排列。
让我们讨论一下触发语法分析错误的几种不同方式,最简单的是 fail
函数:
λ> parseTest (fail "I'm failing, help me!" :: Parser ()) ""
1:1:
|
1 | <empty line>
| ^
I'm failing, help me!
对于很多熟悉其它简单的语法分析库(比如 parsec
)的人来讲,这通常已经足够了。然而,除了向用户显示语法分析错误之外,我们还有可能需要分析或是处理它,这时候 String
就不是很方便了。
平凡的语法分析错误通常都是 megaparsec
生成的,但我们也能自己用 failure
组合子触发这样的错误:
failure :: MonadParsec e s m
=> Maybe (ErrorItem (Token s)) -- ^ Unexpected item (if any)
-> Set (ErrorItem (Token s)) -- ^ Expected items
-> m a
unfortunateParser :: Parser ()
unfortunateParser = failure (Just EndOfInput) (Set.fromList es)
where
es = [Tokens (NE.fromList "a"), Tokens (NE.fromList "b")]
λ> parseTest unfortunateParser ""
1:1:
|
1 | <empty line>
| ^
unexpected end of input
expecting 'a' or 'b'
跟基于 fail
的方法不同,平凡的错误很容易进行模型匹配,或是审视和修改。
对于花哨的错误,相应地我们有 fancyFailure
组合子:
fancyFailure :: MonadParsec e s m
=> Set (ErrorFancy e) -- ^ Fancy error components
-> m a
但对于 fancyFailure
,我们通常会去定义一个工具函数,而不是直接调用 fancyFailure
:
incorrectIndent :: MonadParsec e s m
=> Ordering -- ^ Desired ordering between reference level and actual level
-> Pos -- ^ Reference indentation level
-> Pos -- ^ Actual indentation level
-> m a
incorrectIndent ord ref actual = fancyFailure . E.singleton $
ErrorIndentation ord ref actual
作为添加自定义语法分析错误组件的例子,让我们创建这样一个特殊的语法分析错误,它会报告给定的 Text
值不是关键字。
首先,我们需要定义一个数据类型,其构造器代表我们想要支持的场景:
data Custom = NotKeyword Text
deriving (Eq, Show, Ord)
并告诉 megaparsec
如何显示这个错误:
instance ShowErrorComponent Custom where
showErrorComponent (NotKeyword txt) = T.unpack txt ++ " is not a keyword"
接下来我们更新一下我们的 Parser
别名:
type Parser = Parsec Custom Text
之后我们定义一个 notKeyword
工具函数:
notKeyword :: Text -> Parser a
notKeyword = customFailure . NotKeyword
其中 customFailure
是来自 Text.Megaparsec
模块的工具函数:
customFailure :: MonadParsec e s m => e -> m a
customFailure = fancyFailure . E.singleton . ErrorCustom
最后,让我们试一下:
λ> parseTest (notKeyword "foo" :: Parser ()) ""
1:1:
|
1 | <empty line>
| ^
foo is not a keyword
显示 ParseErrorBundle
可以用 errorBundlePretty
函数完成:
-- | Pretty-print a 'ParseErrorBundle'. All 'ParseError's in the bundle will
-- be pretty-printed in order together with the corresponding offending
-- lines by doing a single efficient pass over the input stream. The
-- rendered 'String' always ends with a newline.
errorBundlePretty
:: ( Stream s
, ShowErrorComponent e
)
=> ParseErrorBundle s e -- ^ Parse error bundle to display
-> String -- ^ Textual rendition of the bundle
99% 的情况下你只需要这么一个函数。
megaparsec
另一个有用的特性是它能够「接住」语法分析错误,并以某种方式改变它,然后再重新抛出错误,就像异常一样。这可以用 observing
原语实现:
-- | @'observing' p@ allows to “observe” failure of the @p@ parser, should
-- it happen, without actually ending parsing, but instead getting the
-- 'ParseError' in 'Left'. On success parsed value is returned in 'Right'
-- as usual. Note that this primitive just allows you to observe parse
-- errors as they happen, it does not backtrack or change how the @p@
-- parser works in any way.
observing :: MonadParsec e s m
=> m a -- ^ The parser to run
-> m (Either (ParseError (Token s) e) a)
下面是演示 observing
典型用法的完整程序:
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TypeApplications #-}
module Main (main) where
import Control.Applicative
import Data.List (intercalate)
import Data.Set (Set)
import Data.Text (Text)
import Data.Void
import Text.Megaparsec
import Text.Megaparsec.Char
import qualified Data.Set as Set
data Custom
= TrivialWithLocation
[String] -- position stack
(Maybe (ErrorItem Char))
(Set (ErrorItem Char))
| FancyWithLocation
[String] -- position stack
(ErrorFancy Void) -- Void, because we do not want to allow to nest Customs
deriving (Eq, Ord, Show)
instance ShowErrorComponent Custom where
showErrorComponent (TrivialWithLocation stack us es) =
parseErrorTextPretty (TrivialError @Char @Void undefined us es)
++ showPosStack stack
showErrorComponent (FancyWithLocation stack cs) =
parseErrorTextPretty (FancyError @Text @Void undefined (Set.singleton cs))
++ showPosStack stack
showPosStack :: [String] -> String
showPosStack = intercalate ", " . fmap ("in " ++)
type Parser = Parsec Custom Text
inside :: String -> Parser a -> Parser a
inside location p = do
r <- observing p
case r of
Left (TrivialError _ us es) ->
fancyFailure . Set.singleton . ErrorCustom $
TrivialWithLocation [location] us es
Left (FancyError _ xs) -> do
let f (ErrorFail msg) = ErrorCustom $
FancyWithLocation [location] (ErrorFail msg)
f (ErrorIndentation ord rlvl alvl) = ErrorCustom $
FancyWithLocation [location] (ErrorIndentation ord rlvl alvl)
f (ErrorCustom (TrivialWithLocation ps us es)) = ErrorCustom $
TrivialWithLocation (location:ps) us es
f (ErrorCustom (FancyWithLocation ps cs)) = ErrorCustom $
FancyWithLocation (location:ps) cs
fancyFailure (Set.map f xs)
Right x -> return x
myParser :: Parser String
myParser = some (char 'a') *> some (char 'b')
main :: IO ()
main = do
parseTest (inside "foo" myParser) "aaacc"
parseTest (inside "foo" $ inside "bar" myParser) "aaacc"
【练习】深入理解这个程序是如何工作的。
如果运行这个程序,会看到以下输出:
1:4:
|
1 | aaacc
| ^
unexpected 'c'
expecting 'a' or 'b'
in foo
1:4:
|
1 | aaacc
| ^
unexpected 'c'
expecting 'a' or 'b'
in foo, in bar
因此,这个特性可以用来给语法分析错误附加位置标签,或是定义能以某种方式处理该错误的「区域」。这种惯用法很有用,所以甚至有一个基于 observing
定义的工具函数 region
:
-- | Specify how to process 'ParseError's that happen inside of this
-- wrapper. This applies to both normal and delayed 'ParseError's.
--
-- As a side-effect of the implementation the inner computation will start
-- with empty collection of delayed errors and they will be updated and
-- “restored” on the way out of 'region'.
region :: MonadParsec e s m
=> (ParseError s e -> ParseError s e) -- ^ How to process 'ParseError's
-> m a -- ^ The “region” that the processing applies to
-> m a
region f m = do
r <- observing m
case r of
Left err -> parseError (f err) -- see the next section
Right x -> return x
【练习】用 region
重写之前程序中的 inside
函数。
region
的定义使用了 parseError
原语:
parseError :: MonadParsec e s m => ParseError s e -> m a
这是错误报告的基础原语,我们目前见到的所有其它函数都基于 parseError
定义的:
failure
:: MonadParsec e s m
=> Maybe (ErrorItem (Token s)) -- ^ Unexpected item (if any)
-> Set (ErrorItem (Token s)) -- ^ Expected items
-> m a
failure us ps = do
o <- getOffset
parseError (TrivialError o us ps)
fancyFailure
:: MonadParsec e s m
=> Set (ErrorFancy e) -- ^ Fancy error components
-> m a
fancyFailure xs = do
o <- getOffset
parseError (FancyError o xs)
parseError
可以让你设置错误的偏移量(也就是位置),而不必是输入流的当前位置。让我们回到很久之前的那个例子:
withPredicate2
:: (a -> Bool) -- ^ The check to perform on parsed input
-> String -- ^ Message to print when the check fails
-> Parser a -- ^ Parser to run
-> Parser a -- ^ Resulting parser that performs the check
withPredicate2 f msg p = do
o <- getOffset
r <- p
if f r
then return r
else do
setOffset o
fail msg
我们注意到 setOffset o
能让错误被正确定位,但它的副作用是会使语法分析状态失效,也就是说偏移量不再反映现实情况了。在更复杂的语法分析器中,这可能会是个现实的问题。举例来说,想象一下你用 observing
包住了 withPredicate2
,那么 fail
之后可能还会有代码运行。
有了 parseError
和 region
,我们能够正确地解决这个问题了:要么使用 parseError
来重设错误位置,要么直接用 region
:
withPredicate3
:: (a -> Bool) -- ^ The check to perform on parsed input
-> String -- ^ Message to print when the check fails
-> Parser a -- ^ Parser to run
-> Parser a -- ^ Resulting parser that performs the check
withPredicate3 f msg p = do
o <- getOffset
r <- p
if f r
then return r
else region (setErrorOffset o) (fail msg)
withPredicate4
:: (a -> Bool) -- ^ The check to perform on parsed input
-> String -- ^ Message to print when the check fails
-> Parser a -- ^ Parser to run
-> Parser a -- ^ Resulting parser that performs the check
withPredicate4 f msg p = do
o <- getOffset
r <- p
if f r
then return r
else parseError (FancyError o (Set.singleton (ErrorFail msg)))
最后,megaparsec
允许我们在一次运行过程中触发多个语法分析错误。这能帮助我们一次修复多处错误,而不需要运行好几次语法分析器。
拥有多错误语法分析器的前提条件是,它要能跳过一部分有问题的输入,并从一个已知没问题的位置继续进行语法分析。这部分工作要用 withRecovery
原语完成:
-- | @'withRecovery' r p@ allows continue parsing even if parser @p@
-- fails. In this case @r@ is called with the actual 'ParseError' as its
-- argument. Typical usage is to return a value signifying failure to
-- parse this particular object and to consume some part of the input up
-- to the point where the next object starts.
--
-- Note that if @r@ fails, original error message is reported as if
-- without 'withRecovery'. In no way recovering parser @r@ can influence
-- error messages.
withRecovery
:: (ParseError s e -> m a) -- ^ How to recover from failure
-> m a -- ^ Original parser
-> m a -- ^ Parser that can recover from failures
在 Megaparsec 8 之前,a
必须是包含成功和失败两种可能性的和类型,比如说 Either (ParseError s e) Result
。语法分析错误在收集后会加入 ParseErrorBundle
以进行显示。不必说,这些都是对用户不友好的高级用法。
Megaparsec 8 支持了「延迟错误」:
-- | Register a 'ParseError' for later reporting. This action does not end
-- parsing and has no effect except for adding the given 'ParseError' to the
-- collection of “delayed” 'ParseError's which will be taken into
-- consideration at the end of parsing. Only if this collection is empty
-- parser will succeed. This is the main way to report several parse errors
-- at once.
registerParseError :: MonadParsec e s m => ParseError s e -> m ()
-- | Like 'failure', but for delayed 'ParseError's.
registerFailure
:: MonadParsec e s m
=> Maybe (ErrorItem (Token s)) -- ^ Unexpected item (if any)
-> Set (ErrorItem (Token s)) -- ^ Expected items
-> m ()
-- | Like 'fancyFailure', but for delayed 'ParseError's.
registerFancyFailure
:: MonadParsec e s m
=> Set (ErrorFancy e) -- ^ Fancy error components
-> m ()
这些错误可以在 withRecovery
的错误处理回调中注册,所以结果类型会是 Maybe Result
。这样可以把延迟错误列入最后的 ParseErrorBundle
,并且在错误列表非空的情况让语法分析失败。
有了这些,我们希望编写多错误语法分析器的做法会在用户群中更加普遍。
对语法分析器进行测试是大多数人迟早要面对的事情,所以我们有义务提一下。最推荐的方式是使用 hspec-megaparsec 包,里面有一些效用期望,比如 shouldParse
、parseSatisfies
等等,能和 hspec
测试框架协同工作。
让我们从一个用例开始:
{-# LANGUAGE OverloadedStrings #-}
module Main (main) where
import Control.Applicative
import Data.Text (Text)
import Data.Void
import Test.Hspec
import Test.Hspec.Megaparsec
import Text.Megaparsec
import Text.Megaparsec.Char
type Parser = Parsec Void Text
myParser :: Parser String
myParser = some (char 'a')
main :: IO ()
main = hspec $
describe "myParser" $ do
it "returns correct result" $
parse myParser "" "aaa" `shouldParse` "aaa"
it "result of parsing satisfies what it should" $
parse myParser "" "aaaa" `parseSatisfies` ((== 4) . length)
shouldParse
接受 Either (ParseErrorBundle s e) a
,即语法分析的结果和一个用来进行比较的 a
类型的值,这可能是用得最多的工具函数。parseSatisfies
跟它很相似,但不是跟期待的结果比较是否相等,而是用任意断言检查结果。
其它简单的效用期望还有 shouldSucceedOn
和 shouldFailOn
(但很少用到它们):
it "should parse 'a's all right" $
parse myParser "" `shouldSucceedOn` "aaaa"
it "should fail on 'b's" $
parse myParser "" `shouldFailOn` "bbb"
在使用 megaparsec
时,我们想要让语法分析错误更加精确。为了测试语法分析错误我们可以使用 shouldFailWith
,用法如下:
it "fails on 'b's producing correct error message" $
parse myParser "" "bbb" `shouldFailWith`
TrivialError
0
(Just (Tokens ('b' :| [])))
(Set.singleton (Tokens ('a' :| [])))
像这样写出 TrivialError
挺让人厌烦的。ParseError
的定义包含了像 Set
和 NonEmpty
这样「不方便」的类型,就像我们上面见到的那样,写起来很麻烦。幸运的是,Test.Hspec.Megaparsec
也重新导出了 Text.Megaparsec.Error.Builder
模块,里面提供了更方便地构建 ParseError
的 API。让我们来看看 err
:
it "fails on 'b's producing correct error message" $
parse myParser "" "bbb" `shouldFailWith` err 0 (utok 'b' <> etok 'a')
err
的第一个参数是错误的偏移量(在出错之前我们吃掉了多少单词),这里它就是 0。utok
表示「不期而遇的单词」,类似地 etok
表示「我们期待的单词」。【练习】要构建花哨的错误,也有类似的工具函数叫做 errFancy
,请了解一下。
最后,还可以用 failsLeaving
和 succeedLeaving
来测试输入的哪部分在语法分析后还没被吃掉:
it "consumes all 'a's but does not touch 'b's" $
runParser' myParser (initialState "aaabbb") `succeedsLeaving` "bbb"
it "fails without consuming anything" $
runParser' myParser (initialState "bbbccc") `failsLeaving` "bbbccc"
这些函数应该用 runParser'
和 runParserT'
运行,因为它们支持自定义初始状态并且会返回最终状态(这就能检查输入流剩下的东西了):
runParser'
:: Parsec e s a -- ^ Parser to run
-> State s -- ^ Initial state
-> (State s, Either (ParseError (Token s) e) a)
runParserT' :: Monad m
=> ParsecT e s m a -- ^ Parser to run
-> State s -- ^ Initial state
-> m (State s, Either (ParseError (Token s) e) a)
initialState
函数接受输入流,返回该输入流构成的初始状态,而初始状态的其它记录字段会用默认值填充。
关于使用 hspec-megaparsec
,下述代码会是你的灵感来源:
hspec-megaparsec
编写的 Megaparsec 自己的测试套件;hspec-megaparsec
自带的玩具测试套件。megaparsec
能用来对任何输入流进行语法分析,只要它是 Stream
类型类的实例。这意味着它可以和 alex
之类的词法分析工具配合使用。
为了不偏离我们的主题,我们不会展示 alex
是如何生成单词流的,我们就假定输入是下述形式:
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE TypeFamilies #-}
module Main (main) where
import Data.List.NonEmpty (NonEmpty (..))
import Data.Proxy
import Data.Void
import Text.Megaparsec
import qualified Data.List as DL
import qualified Data.List.NonEmpty as NE
import qualified Data.Set as Set
data MyToken
= Int Int
| Plus
| Mul
| Div
| OpenParen
| CloseParen
deriving (Eq, Ord, Show)
为了报告语法分析错误,我们需要一种方式知道单词的起始位置、终止位置和长度,因此我们添加了 WithPos
:
data WithPos a = WithPos
{ startPos :: SourcePos
, endPos :: SourcePos
, tokenLength :: Int
, tokenVal :: a
} deriving (Eq, Ord, Show)
这下我们就有数据类型表示自己的流了:
data MyStream = MyStream
{ myStreamInput :: String -- for showing offending lines
, unMyStream :: [WithPos MyToken]
}
接下来,我们需要让 MyStream
成为 Stream
类型类的实例。这需要 TypeFamilies
语言扩展,因为我们想要定义关联类型函数 Token
和 Tokens
:
instance Stream MyStream where
type Token MyStream = WithPos MyToken
type Tokens MyStream = [WithPos MyToken]
-- …
Stream
的文档可以在 Text.Megaparsec.Stream
模块中找到。现在我们直接把剩下的方法定义完:
-- …
tokenToChunk Proxy x = [x]
tokensToChunk Proxy xs = xs
chunkToTokens Proxy = id
chunkLength Proxy = length
chunkEmpty Proxy = null
take1_ (MyStream _ []) = Nothing
take1_ (MyStream str (t:ts)) = Just
( t
, MyStream (drop (tokensLength pxy (t:|[])) str) ts
)
takeN_ n (MyStream str s)
| n <= 0 = Just ([], MyStream str s)
| null s = Nothing
| otherwise =
let (x, s') = splitAt n s
in case NE.nonEmpty x of
Nothing -> Just (x, MyStream str s')
Just nex -> Just (x, MyStream (drop (tokensLength pxy nex) str) s')
takeWhile_ f (MyStream str s) =
let (x, s') = DL.span f s
in case NE.nonEmpty x of
Nothing -> (x, MyStream str s')
Just nex -> (x, MyStream (drop (tokensLength pxy nex) str) s')
showTokens Proxy = DL.intercalate " "
. NE.toList
. fmap (showMyToken . tokenVal)
tokensLength Proxy xs = sum (tokenLength <$> xs)
reachOffset o PosState {..} =
( prefix ++ restOfLine
, PosState
{ pstateInput = MyStream
{ myStreamInput = postStr
, unMyStream = post
}
, pstateOffset = max pstateOffset o
, pstateSourcePos = newSourcePos
, pstateTabWidth = pstateTabWidth
, pstateLinePrefix = prefix
}
)
where
prefix =
if sameLine
then pstateLinePrefix ++ preStr
else preStr
sameLine = sourceLine newSourcePos == sourceLine pstateSourcePos
newSourcePos =
case post of
[] -> pstateSourcePos
(x:_) -> startPos x
(pre, post) = splitAt (o - pstateOffset) (unMyStream pstateInput)
(preStr, postStr) = splitAt tokensConsumed (myStreamInput pstateInput)
tokensConsumed =
case NE.nonEmpty pre of
Nothing -> 0
Just nePre -> tokensLength pxy nePre
restOfLine = takeWhile (/= '\n') postStr
pxy :: Proxy MyStream
pxy = Proxy
showMyToken :: MyToken -> String
showMyToken = \case
(Int n) -> show n
Plus -> "+"
Mul -> "*"
Div -> "/"
OpenParen -> "("
CloseParen -> ")"
更多关于 Stream
类型类的背景资料(以及为什么它长这样)可以在这篇博客中找到。
现在我们可以为自定义的流定义 Parser
了:
type Parser = Parsec Void MyStream
下一步是基于 token
和 tokens
两个原语,定义基本的语法分析器了。对于原生支持的流我们有 Text.Megaparsec.Byte
和 Text.Megaparsec.Char
模块,但要使用自定义的单词,我们需要自定义工具函数。
liftMyToken :: MyToken -> WithPos MyToken
liftMyToken myToken = WithPos pos pos 0 myToken
where
pos = initialPos ""
pToken :: MyToken -> Parser MyToken
pToken c = token test (Set.singleton . Tokens . nes . liftMyToken $ c)
where
test (WithPos _ _ _ x) =
if x == c
then Just x
else Nothing
nes x = x :| []
pInt :: Parser Int
pInt = token test Set.empty <?> "integer"
where
test (WithPos _ _ _ (Int n)) = Just n
test _ = Nothing
最后让我们写一个语法分析器测试一下加法表达式:
pSum = do
a <- pInt
_ <- pToken Plus
b <- pInt
return (a, b)
这里是一个样例输入:
exampleStream :: MyStream
exampleStream = MyStream
"5 + 6"
[ at 1 1 (Int 5)
, at 1 3 Div -- (1)
, at 1 5 (Int 6)
]
where
at l c = WithPos (at' l c) (at' l (c + 1)) 2
at' l c = SourcePos "" (mkPos l) (mkPos c)
让我们试一下:
λ> parseTest (pSum <* eof) exampleStream
(5,6)
如果我们把 (1) 处的 Plus
改成 Div
,我们也能得到正确的错误信息:
λ> parseTest (pSum <* eof) exampleStream
1:3:
|
1 | 5 + 6
| ^^
unexpected /
expecting +
换言之,我们拥有一个能够处理自定义流的功能完备的语法分析器了。
实际上有个 modern-uri 包,其 Megaparsec 语法分析器支持 RFC 3986 定义的 URI 格式,但它远比我们这里介绍的要复杂。 ↩
对「偶像」这个词的理解,一千个读者眼中就有一千个哈姆雷特。在日本虽然也有汉语词「偶像」,但只有崇拜对象之意;我们现在讨论的偶像则为「IDOL」的音译词「アイドル」。虽然「IDOL」仍然是偶像的意思,但经过音译的转换已经退化为了表音符号;因此在当代日语的语境中,「アイドル」并不会自然而然地跟崇拜对象之意关联起来,而是被赋予了新的含义:与粉丝分享成长过程、以其存在本身为魅力的人物(参见维基百科)。请允许我将这个语义下的偶像称为日系偶像,本文便是个人对日系偶像领域的国内外研究现状综述,其中也结合了我在东京一年的见学体验以及赴北上广参加偶像活动的经历。
日系偶像的魅力,简而言之我认为是参与感。首先偶像基本上都是素人出身,是未经加工的原石,在粉丝的陪伴下最后能否打磨成闪闪发光的钻石,本身就是一场大型养成游戏。在此基础上,秋元康还独辟蹊径打造了一整套偶像商法,事实证明这非常成功,乃至现今日本所有的偶像团体几乎都有秋元康的影子。田中秀臣教授在《AKB48 的格子裙经济学:粉丝效应中的新生与创意》中阐述了帮助 AKB48 走向成功的粉丝经济模式,综合其理论和我自己的体验,日系偶像有几个值得注意的特点:
剧场公演:AKB48 在秋叶原的唐吉诃德百货店八楼拥有专属剧场,Team A/K/B/4/8 五个队轮流公演,保证每周都有多场公演,这从演唱会时代粉丝被动调整日程安排,进化到了剧场公演时代粉丝主动自由挑选时间场次。同时剧场公演的票价还十分低廉,比如上海 SNH48 的普通座票只要 80 人民币。在高频率、低票价的剧场公演下,日本粉丝还发展出了自己独特的应援方式,也就是后面重点叙述的 CALL / MIX 等等,这极大提升了公演中粉丝的参与感。
握手会:这大概是日系偶像最具代表性的制度了,真正兑现了粉丝和偶像可以面对面的承诺。所谓握手会,就是通过购买唱片获取握手券,凭券即可赴会场跟偶像近距离握手聊天,其变种还有签名会、合影会等等。正因如此,粉丝们会大量购买同一张唱片来拿里面的券,近十年来 AKB48 GROUP 和坂道系列只要发了单曲就一定是当周的公信榜榜首。根据国际唱片业协会对 2017 年的数据统计,日本是全球第二大音乐市场,不过其实体唱片比例 72% 远超第一大市场美国的 15%,这说到底少不了握手会制度的功劳,虽然反过来日本偶像也被称为日音毒瘤。
总选举:AKB48 另一大代表性制度是通过粉丝投票来决定偶像们在歌曲中的站位,每年都会举办一次,这或许也是 PRODUCE 101 模式的起源。AKB48 总选举的投票券都是真金实银买来的,虽然各种官方会员也会送投票权,但数额的大头还是来自投票期间发行的那张单曲。自 2010 年以来,公信榜单曲年榜冠军就雷打不动一直是 AKB48 的投票单。而总选举的开票现场也会由富士电视台直播,算得上是非常隆重了,腾讯视频这几年也买下了国内的网络直播权。除此之外,AKB48 其实还有名为[重温时间最佳曲目100]{Request Hour Setlist Best 100}的歌曲票选活动,也是通过购买单曲来取得投票权;另外还有一年一度的猜拳大会,完全凭运气来决定单曲出道的人选。
地元化:虽然东京都市圈聚集了日本近 30% 的人口,但让其他地方的居民也能够在自己家门口见到小偶像则是进一步发展的必由之路。AKB48 小有名气之时,秋元康便开始在名古屋等地开设分团,如今更是印尼、泰国、菲律宾、越南、中国大陆和台湾遍地开花。最能体现地元化理念的其实还是 AKB48 Team 8:这个队定员 47 人,分别来自日本的 47 个都道府县,她们的口号也从本部的「[能见面的偶像]{会いに行けるアイドル}」转变为了「[去见你的偶像]{会いに行くアイドル}」,在日本全国各地巡回演出。顺带一提,Team 8 的构想大概是来源于 NHK 晨间剧《海女》中影射 AKB48 的 GMT47。
毕业制度:用毕业一词代指退团,据说最早来源于上世纪秋元康策划的小猫俱乐部。在日系偶像的养成模式下,成员们是不会一辈子待在团里的,总要迎来毕业、迈向人生的下一个阶段,或是成为歌手、或是成为演员、或是成为模特、或是宣布引退……在多期招募和毕业制度下,AKB48 这样的大型偶像团体历经十余年的发展,比起特定的人的集合更像是一套制度的实体化,正所谓铁打的秋元康流水的小偶像。
日本有主流偶像和地下偶像之分,前者经常能在大众媒体上见到,而后者鲜有人知、大多以小型剧场公演为中心进行活动。虽然这没有严格的界限,但常常会以[主流出道]{メジャーデビュー}作为分界点,即在日本唱片协会的 18 家会员公司发售过唱片。
以我粗浅的认识,如今广为人知的主流偶像可以说有这么几大势力:
Hello! Project:早安少女组、ANGERME、Juice=Juice、Country Girls、玉兰花工厂、山茶花工厂、BEYOOOOONDS。由淳君担任制作人,是 21 世纪日本女子偶像团体的开端,旗下组合经历了数次重组,早安少女组 1998 - 2007 连续十年登上过红白歌会。
AMUSE:Perfume、BABYMETAL 等等。Perfume 原为广岛地元偶像,上京后成为流行电音组合,2008 年开始每年都会登上红白歌会;BABYMETAL 原为樱花学院的重音部,现已成为世界知名的重金属乐团,多次在欧美巡演。虽然她们都是偶像出身,但现在的身份更像是主流歌手。
AKB48 GROUP:日本国内六团 [AKB]{Akihabara}48(东京・秋叶原)、[SKE]{Sakae}48(名古屋・荣)、[NMB]{Namba}48(大阪・难波)、[HKT]{Hakata}48(福冈・博多)、[NGT]{Niigata}48(新潟)、[STU]{Setouchi}48(濑户内),海外六团 [JKT]{Jakarta}48(雅加达)、[BNK]{Bangkok}48(曼谷)、[MNL]{Manila}48(马尼拉)、[SGO]{Saigon}48(西贡)、AKB48 Team [SH]{Shanghai}(上海)、AKB48 Team [TP]{Taipei}(台北)。均由秋元康担任制作人,AKB48 从地下偶像一步步成长为国民偶像,为日本偶像团体开创了制度的蓝本,开启了「偶像战国时代」。在 2014 年,曾经出现过 AKB48、SKE48、NMB48、HKT48 四个团同时登上红白歌会的奇观。
STARDUST PLANET:桃色幸运草Z、私立惠比寿中学、虎鱼组、彩虹章鱼烧等等。桃草大概是日本近十年来唯一能够撼动秋元康的偶像团体,于 2012 - 2014 连续三年登上过红白歌会的舞台。星尘近年来在名古屋、大阪、福冈、仙台都设了团,也在探索地元化拓展之路。
DEARSTAGE:电波组.inc、彩虹征服者等等。电波组诞生于秋叶原的女仆咖啡店 Dear Stage,慢慢发展为以御宅族为成员的偶像团体,2015 年曾两次出演 Music Station。虹控最初由 pixiv 创建,成员由插画师、编舞师、声优、Cosplayer 等组成,2017 年底宣布并入 DEARSTAGE。
坂道系列:乃木坂46、欅坂46、日向坂46、吉本坂46。亦由秋元康担任制作人,前三个团由索尼音乐主导运营,吉本坂则由吉本兴业运营。乃团最初以 AKB48 官方对手的名义成立,抛弃了剧场公演而改走演唱会路线,从 2015 年开始成为红白歌会的常客;欅坂则是摇滚风格的姐妹团体,在 2016 年创造了出道当年就登上红白歌会的历史;日向坂原称平假名欅坂(けやき坂46),今年刚刚宣告独立;吉本坂与上述三个团的画风完全不同,是吉本兴业旗下艺人组成的团体,性别不限,年龄不限。
WACK:BiS、BiSH 等等。由渡边淳之介担任制作人,松隈健太担任音乐制作,两团均为朋克系偶像。BiS 曾于 2014 年一度解散,后于 2016 年再度结成;BiSH 则于 2015 年结成,2017 年底登上过 Music Station,我觉得是目前最有潜力的偶像之一。
因为基本上只有上述主流偶像才有能力上电视(这里以 Music Station 为标准),或者办武道馆以上的大型演唱会,所以其他主流出道的偶像在大众心目中很可能仍然是地下偶像。
另外不得不提的一大类是声优偶像,这是偶像文化和二次元文化的交汇地带。光谱的一极是没有专属虚拟形象、只是招募了声优作为成员的偶像团体,譬如爱贝克思旗下的 i☆Ris、代代木动画学院旗下的 =LOVE 和 ≠ME;另一极则是以二次元作品中的虚拟形象为主体活动的偶像团体,譬如 THE IDOLM@STER 系列、Love Live! 系列和 BanG Dream! 系列;也有介于两者之间的,譬如秋元康担任制作人的 22/7。「家虎」等偶像 CALL / MIX 在中国的扩散便归功于部分 Aqours 粉丝把地下偶像的玩法引入了 Aqours 的演唱会,进而影响到了中国二次元圈。
国内日系偶像现在是 SNH48 GROUP 一家独大,其董事长是有日本留学背景的久游网创始人王子杰。SNH48 于 2013 年在上海浅水湾进行首次公演,现在已经开出了 [SNH]{Shanghai}48(上海)、[BEJ]{Beijing}48(北京)、[GNZ]{Guangzhou}48(广州)、[SHY]{Shenyang}48(沈阳・刚刚解散)、[CKG]{Chungking}48(重庆・刚刚解散)五个分团,分别在嘉兴路、悠唐、中泰、豫珑城、国瑞设有星梦剧院,这些地名常作为这些团的代称。因为合同纠纷 AKS 在 2016 年 6 月 9 日宣布 SNH48 不再是 AKB48 GROUP 的一部分,并在 2018 年成立了新的官方分团 AKB48 Team SH,丝芭则表示将继续独立走原创道路。
SNH48 GROUP 也独立举办一年一度的总决选和金曲大赏,而总决选两连冠的鞠婧祎现在已经被收编为丝芭旗下独立艺人,这跟 AKS 让成员外签到大手事务所的模式相当不同。这一差异的主因是丝芭会让成员入团前签专属艺人合约,8 年内即使退团也不得参与其他演艺活动,否则需要缴纳巨额违约金,这从赵嘉敏、李豆豆和陈怡馨的民事判决书就可见一斑。目前 SNH48 成立还未满 8 年,因此任何成员退团都几乎等同于引退,不像 AKB48 GROUP 的成员毕业只是人生新的开始。一个很直观的感受就是,很多成员无声无息地就走了,丝芭从来不发表毕业公告或是安排正式的毕业公演。
国内被普遍认可为日系偶像的团体还有上海的 Lunar、Idol School、ATF(这三家都参加过 @JAM in 上海),广州的 1931,香港的 Ariel Project 等等。其中 Lunar 的出道时间甚至早于 SNH48,脱胎于上海一家女仆咖啡厅,不过在 2017 年发生了丑闻和资本变动,运营重心迁移到了重庆新团 Lunar 雾队,但不到一年又杳无音信了。Idol School 由早安少女组毕业成员钱琳担任制作人,也是走剧场公演路线,不过近年陷入欠薪风波前途未卜。1931 由欢聚时代(YY)投资组建,在广州拥有专属剧场,遗憾的是在 2017 年底宣布停止运营。ATF 由心动网络投资组建,未设剧场而走坂道系列路线,亦于 2017 年底发布公告并停止活动。上述团体的不少成员参加了去年腾讯制作的《创造101》,但都没能进入最终出道名单。相较之下还是香港的 Star Creative Production 运营比较稳健,旗下的 Ariel Project 除了保持每月定期公演之外,至今已连续三年获邀出演 @JAM EXPO,并在日本成功发行了单曲。
一般来说,偶像公演有这么三种形式:[单独公演]{ワンマン}、[拼盘]{対バン}、[音乐节]{フェス}。因为大多数地下偶像都没有实力租巨蛋、武道馆这样的万人场,所以大多数情况下都在数百到千人左右的[小型剧场]{ライブハウス}举办,票价十分便宜,偶像与粉丝也不会有什么距离感,终演后还会有物贩和特典会,于是这些活动都会以[公演]{ライブ}而不是[演唱会]{コンサート}来代称。
除了 AKB48 GROUP 这类有专属剧场的,其他偶像团体大多都需要自己搜集情报来跑活动。一般都是去自己推的偶像的官网确认公演的日程安排,如果只是周末有闲暇想去打打尻跳跳高,也可以看看定期举办的偶像相关企划,比如「AKIBAカルチャーズ劇場」「東京アイドル劇場」「@JAM」「Girl’s Bomb!!」「アイドル甲子園」「MARQUEE祭」「MX IDOL PROJECT」「アイドルジェネレーション」「アイドル侍」「楽遊 IDOL PASS」「IDOL CONTENT EXPO」「TOKYO IDOL PROJECT」「IDORISE!! FESTIVAL」等等等等。
如果还没有特别心仪的偶像或是想要发掘地下偶像中的原石,大型偶像音乐节则是最佳去处。日本最大的偶像音乐节是富士电视台发起的「TOKYO IDOL FESTIVAL」,其会场遍布整个台场,最近三年每年出演的偶像都超过了两百组。因为仍然有很多没得到出演权的地下偶像希望报名,TIF 还举办了「TIF への扉」系列企划来进行甄选,富士电视台亦推出了指原莉乃的冠番「この指と〜まれ!」进行联动。其次要数「@JAM EXPO」和「アイドル横丁夏まつり!!」,这两场都在离东京不远的横滨举办,分别是在室内的[横滨体育馆]{横浜アリーナ}和露天的[横滨红砖仓库]{横浜赤レンガ倉庫}。
另外非常值得一去的是各所大学的学园祭,很多大学的偶像文化研究会都会邀请偶像来参加,而且原则上都是免费的。以 2018 年东京的学园祭为例,五月祭(东京大学本乡校区)邀请了 なんキニ!、FES☆TIVE、煌めき☆あんフォレント、lyrical school 等,駒場祭(东京大学驹场校区)邀请了 NEO JAPONISM、メイビーME、天晴れ!原宿、フィロソフィーのダンス 等,自主法政祭邀请了 STU48、アキシブproject 等,最强的是「早稲田アイドルフェスティバル!!! in 早稲田祭2018」邀请了包括 =LOVE 和 AKB48 在内的 29 组偶像。
如果想酣畅淋漓地享受偶像公演的乐趣,下面介绍的 CALL / MIX 则是解乏的良方。
所谓 CALL,就是附和歌曲的节奏或歌词进行声援。最简单而常见的 CALL 就是挥舞荧光棒、跟随歌曲的节奏喊 Hey / Hai / Oi,地下偶像粉丝则不怎么用荧光棒,更喜欢拍手或做肢体动作。虽然打 CALL 的时机可以用音乐理论知识分析,但我个人感觉还是去公演现场多体验几场学起来更快一些,到时候自然而然就对节拍产生条件反射了。
人名 CALL 非常好理解,就是在成员唱歌的时候喊她的名字。AKB48 剧场公演喊「超絶可愛い〇〇〇」比较常见,地下偶像公演喊「オーレーの〇〇〇」比较常见。
PPPH 是「パンパパン、ヒュー」的缩写,先在身体左侧拍一次手,再在右侧连拍两次手,最后跳起来喊一声「ヒュー」。虽然 PPPH 这个名字很常见,但相同的节奏下现在更流行的是 Oh-ing。
一开始大家是先上举荧光棒喊「オー」,再向前挥荧光棒喊「ハイ」;但地下偶像粉丝不怎么用荧光棒,就改成了先喊「オー」再拍手两次,这被称为「オーイング」(Oh-ing)。
在副歌的时候会打「オーフッフー 👏👏 フワフワ」,后面经常还会跟着「ハイセーノ、ハーイハイ、ハイハイハイハイ」,例如 まねきケチャ『冗談じゃないね』。
イエッタイガー!
「家」是「イエ」的谐音,「虎」是「タイガー」的意译,「家虎」现在已经成为地下偶像 CALL 的代名词。通常在副歌快要开始之前喊,大家经常会先重复「イエッ!」来预警,经典实战是 ベイビーレイズ JAPAN『夜明け Brand New Days』;有时后面还会接上「ファイボワイパー!」,比如 まねきケチャ『きみわずらい』。
高まるよ!高まるよ!高まる低まるビスマルク!
シジマール!アルシンド!カズダンス!ニーハイ!オーハイ!
缶チューハイ!ウーロンハイ!ナチュラルハイ!アイ・キャン・フライ!
パン、パン、パン、パン、ポケモンパン!
フレッシュブレッド、伊藤パン!
松たか子!松たか子!
ヤマザキ春のパンまつり!
这两个 CALL 没有上面几种那么普遍,也都是地下偶像 CALL。前者实战案例比如 天晴れ!原宿『アッパレルヤ!!』,后者比如 26時のマスカレイド『ハートサングラス』。如今这些 CALL 亦被国内聚聚引入了 SNH48 GROUP 的原创公演,因此在星梦剧院也能时常听到。
[世界]{せかい}の、[一番]{いちばん}、[可愛]{かわい}い、〇〇〇!
〇〇〇、最可爱、超绝可爱、〇〇〇!
L・O・V・E、Lovely、〇〇〇!
这是 SNH48 粉丝原创的 CALL,糅合了日语、汉语和英语,虽然从语法上来说「世界の」应该是「世界で」才对。这个 CALL 在 SNH48 GROUP 十分常见,实战案例比如剧场公演曲『恋爱捉迷藏』,最早这是 Team NII 唐安琪的 SOLO 曲。
所谓 MIX,就是在前奏和间奏发动的活跃气氛(但没有实际意义)的呼喊。通行的 MIX 分为英语、日语、阿伊努语三部分,阿伊努语是日本北海道原住民的语言,之所以出现这门语言是因为偶像 MIX 的布道师 園長 是北海道出身。如果前奏时间短就只打英语部分,等间奏再打日语和阿伊努语部分;如果前奏足够长就加上日语二连 MIX,或者再加上阿伊努语三连 MIX,亦或者打到日语「繊維」时再发动一次英语的 2.5 连 MIX。MIX 发展至今已有不少微妙的变化,下面以首次大规模投入使用的 AKB48 版本为基础进行介绍,不过 AKB48 Team 8 和 SNH48 原创曲的 MIX 更接近地下偶像。
あーよっしゃいくぞー!
タイガー!ファイヤー!サイバー!ファイバー!ダイバー!バイバー!ジャージャー!
地下偶像 MIX 通常将发动部分简化为「あー 👏👏 ジャージャー」或「👏👏👏👏👏 しゃーいくぞー」,最近还有虎火发动版本:
タイガーファイヤー!
サイバー!ファイバー!ダイバー!バイバー!ジャージャー!ファイボー!ワイパー!
あーもういっちょいくぞー!
[虎]{とら}![火]{ひ}![人造]{じんぞう}![繊維]{せんい}![海女]{あま}![振動]{しんどう}![化繊]{かせん}[飛]{とび}[除去]{じょきょ}!
与英语部分类似,地下偶像 MIX 会简化发动部分,并且只喊「化繊」不喊「飛除去」,最近还有虎火发动版本:
虎×12 虎火!
人造!繊維!海女!振動!化繊!飛!除去!
チャペ!アペ!カラ!キナ!ララ!トゥスケ!ミョーホントゥスケ!
因为很多歌曲的前奏和间奏不够长,所以阿伊努语部分相对罕见一些,而阿伊努语的另一个长版本就更罕见了:
チャペ!アペ!カラ!キナ!ララ!トゥスケ!ウィスゥペ!ケスィ!スィスゥパ!
上面介绍的三段 MIX 几乎在所有偶像歌曲中都通用,还有很多特殊的 MIX 会在特定的歌曲中出现,有名的比如 Cheeky Parade『BUNBUN NINE9’』中的「チキパ MIX」、虹のコンキスタドール『トライアングル・ドリーマー』中的「三角関数 MIX」、まねきケチャ『冗談じゃないね』中的「林修 MIX」、SNH48『春夏秋冬』中的「桃花庵 MIX」等等。这类特殊 MIX 中有一些脱颖而出,在近年得到了大规模应用,比如下面两个:由通行的三连 MIX 衍生出来的「可变三连 MIX」和另起炉灶的「混沌 MIX」。
人造ファイヤファイボワイパー!
タイガー!タイガー!タタタタタイガー!
チャペアペカラキナ!チャペアペカラキナ!
ミョーホントゥスケ!👏 ワイパー!
ファイヤー!ファイヤー!虎虎カラキナ!
チャペアペファーマー!海女海女ジャスパー!
虎タイガー!虎タイガー!
人造繊維イエッタイガー!
发动时间跟后面介绍的「ガチ恋口上」一模一样,属于比较进阶的 MIX 形式,实战案例比如 真っ白なキャンバス『SHOUT』、なんキニ!『僕を未来へ運ぶ列車』。
ワー!ワー!ワールドカオス!
[諸行]{しょぎょう}![木暮]{こぐれ}![時雨]{しぐれ}![神楽]{かぐら}![金剛山]{こんごうさん}![翔襲叉]{しょうしゅうしゃ}!
[黒雲]{こくうん}![無常]{むじょう}![世界混沌]{せかいこんとん}!
大喊数声「ワー」发动,因为经常是合着歌词一起喊,所以比起 MIX 其实更像 CALL,实战案例比如 26時のマスカレイド『ハートサングラス』『チャプチャパ』。
言いたいことがあるんだよ!
やっぱり〇〇〇はかわいいよ!
好き好き大好きやっぱ好き!
やっと見つけたお姫様!
俺が生まれてきた理由!
それはお前に出会うため!
俺と一緒に人生歩もう!
世界で一番愛してる!
ア!イ!シ!テ!ル!
最常见的口上是上面这个「ガチ恋口上」,翻译过来是真爱口述的意思,前奏、间奏、尾奏都有可能发动,其与 MIX 的不同在于口上的内容是有实际意义的。口上走出地下为大众所知的歌曲大概是 AKB48 Team 8 队歌『47の素敵な街へ』了,BEJ48 粉丝还在剧场公演曲『恋爱中的美人鱼』中喊出了中文版,堪称 MIX 本土化的典范:
有一些 心里话 想要说给你!
〇〇〇 就是你 最可爱的你!
喜欢你 喜欢你 就是喜欢你!
翻过山 越过海 你就是唯一!
有了你 生命里 全都是奇迹!
失去你 不再有 燃烧的意义!
让我们 再继续 绽放吧生命!
全世界所有人里我最喜欢你!
我!最!喜!欢!你!
地下偶像公演中,因为大家普遍不带荧光棒,所以肢体动作异常地多。虽然每首歌都有各自的套路,但仍有一个放之四海而皆准的做法,就是模仿台上偶像的动作,这被称为「振りコピ」。另外还有几个常见的动作,比如上升气流(ケチャ)和开花掌(咲きクラップ),前者是向前伸出双臂慢慢上举,后者是在面前击掌然后慢慢张开,两者一般都出现在节奏舒缓、伴奏音量下降以突出人声的部分,比如「落ちサビ」。前者之所以在日本叫做 Kecak,是因为这个动作特别像巴厘岛的传统舞蹈 Kecak。「推しジャンプ」也非常常见,就是你推的成员在唱歌的话就不停地跳,但注意有的地方是禁止这种行为的。还有一些行为是大多数公演都禁止的,建议见到了也不要模仿,比如托举(リフト)、跳水(ダイブ)、狂舞(モッシュ)等。
御宅艺(ヲタ芸)是在动画歌曲或偶像歌曲演唱途中,粉丝在台下跳的一系列独特的舞蹈,拿着大闪打的又叫荧光棒舞,空手打的又叫地下艺。因为很多演唱会或剧场公演都没有空间来打御宅艺,我本人也不会(笑),这里就不详细介绍了。有兴趣的话可以参考 B 站上夜瞑的攻略手册新手入门篇、第二篇、完结篇、技巧理论篇,無用男的地下艺教学。
偶像圈有一个常用语叫做「[厄介]{やっかい}」,这个词跟「[迷惑]{めいわく}」基本上是一个意思,在日语中都是指给别人添麻烦的行为,比如在公演中瞎搞。当然,不同场合的评价尺度不尽相同:在大众歌手的演唱会中,CALL 就已经有点厄介了;而在动画歌曲或坂道系列的演唱会中,CALL 不算厄介,MIX 才算;而在 AKB48 剧场公演中,MIX 也很正常,但以家虎为代表的地下偶像玩法还是比较厄介的;在会出警的地下偶像公演中,CALL / MIX 喊成什么样都没事,只要肢体动作别太过分就行;而一些地下偶像的野外公演,那跟摇滚现场就没区别了。
下面介绍几种厄介行为,供大家批判:
握手会制度是日系偶像的代表性标志,如果要判断一个团体是不是日系偶像,当今最行之有效的方法应该就是看她们握不握手了。
AKB48 GROUP 和坂道系列的制度相似,握手会分为「全国握手会」和「個別握手会/大握手会」,分别简称全握和个握。全握就像列车的自由席,只要购买对应唱片的初回限定盘即可参加,通常开场前还会有一场 MINI LIVE,但排队时间极长、握手时间极短;个握就像列车的指定席,需要事先在网上指定成员和时间段,每档都有限额所以要进行多轮抽选。
具体以 AKB48 为例,其单曲 CD 可以分为三类:
AKB48 的个握花样比较多,除了普通的握手还有「[签名会]{サイン会}」「[合影会]{2ショット写真会}」「[摄影会]{1ショット動画会}」等等,可以自由挑选喜欢的项目进行抽选。一些大握手会现场会同时举办「[特别舞台庆典]{スペシャルステージ祭り}」,通常是时长一刻钟的 MINI LIVE,和握手项目一样需要预约抽选。另外「[加推]{推し増し}」制度允许当天所有券都能在规定的时间段(每部 90 分钟的正中间 30 分钟)任选当天官方规定的成员(基本上是没卖完的成员),但只能握手不能参加特别的活动。根据以往经验,东京都市圈的场地一般都在神奈川县的[横滨国际平和会议场]{パシフィコ横浜}或千叶县的[幕张展览馆]{幕張メッセ},从东京都心出发都差不多一个小时到达。
中国国内的话,因为参加握手会的人数没有日本那么夸张,所以 SNH48 GROUP 没有分自由席和指定席两套制度,只要买了唱片就能凭券参加握手会,不过也时常有握手会规定部分人气成员需要网上预约时间段获取二维码。另外需要注意的是,有时丝芭会用「全握」一词表示每张券可以跟出席握手会的全体成员握手一次,相应地会把普通的握手叫做「单握」,这跟 AKB48 的全握和个握是不同的术语体系。丝芭官方商城每买 10 张(投票单要 20 张)唱片将随机赠送一张签名券或合影券,两券交替赠送;另有官方应援护照,凡参加官方活动即可敲章,比如北广集齐 100 个章可以跟全队 16 名成员合影等等。
地下偶像的话,当然办不起专场握手会,因此通常是在公演之后跟物贩一起举行特典会。不同的偶像团体规则不同,但大体上都是终演后运营出来摆摊卖特典券,最常见的特典是[拍立得]{チェキ}合影加签名。如果参加的是拼盘,有时候工作人员会在检票时问你是来看哪个团的,然后会给你发这个团的物贩优先购入券。
前面说了这么多,然而也不是每个人都有机会到现场参加活动,对于屏幕饭来说综艺便成了深入了解成员们的唯一途径。秋元康担任制作人的偶像团体基本上都能拿到不错的资源,在电视上播放的冠番也很多,比如在 NHK BS Premium 播出就有 AKB48 SHOW! 和 乃木坂46的学旅!。
在日本,只有 NHK 有资格进行全国放送,各地的民间放送局需要组建电视联播网才能覆盖全国,因此民放形成了五大电视网外加一批独立放送局的局面。譬如在东京,能够收到的地上放送共有八个台:① NHK 综合;② NHK 教育;[③ 未使用;]④ 日本电视台;⑤ 朝日电视台;⑥ TBS 电视台;⑦ 东京电视台;⑧ 富士电视台;⑨ 东京都会电视台(TOKYO MX,独立局)。除了依赖东京晴空塔的地上放送之外,还有放送卫星(BS)和通信卫星(CS)两种卫星放送方式,可以收看到额外的免费和付费电视频道。
以下是目前东京地区地上放送的偶像常规番组(日本标准时间,三十小时制):
最后的最后,祝正在大阪巨蛋举行毕业演唱会的西野七濑女士毕业快乐!
]]>去年肆虐了一年的幽灵系列漏洞似乎已经风平浪静了,但实际上它们对 CPU 微架构和系统软件领域依然有着长久而深远的影响。幽灵系列漏洞针对的并不是某个具体的硬件缺陷,而是将矛头对准了分支预测和乱序执行这两个现代 CPU 普遍采用的优化策略,并通过缓存旁路攻击完成对机密数据的任意读取,通用性极强,也极难做到全面的防御。本文将从幽灵系列漏洞的原理入手,介绍它们对现代计算机系统产生的影响和目前可行的对策。
幽灵系列漏洞截至目前至少已有十种变体被通用漏洞披露(CVE)数据库收录,亦有未被单独收录的 SpectreRSB(USENIX WOOT 2018)、ret2spec(ACM CCS 2018)等攻击被陆续发现。
漏洞编号 | 代号 | 正式名称 | |
---|---|---|---|
Variant 1 | CVE-2017-5753 | Spectre-V1 | Bounds Check Bypass |
Variant 2 | CVE-2017-5715 | Spectre-V2 | Branch Target Injection |
Variant 3 | CVE-2017-5754 | Meltdown | Rogue Data Cache Load |
Variant 3a | CVE-2018-3640 | Spectre-NG | Rogue System Register Read |
Variant 4 | CVE-2018-3639 | Spectre-NG | Speculative Store Bypass |
- | CVE-2018-3665 | LazyFP | Lazy FP State Restore |
- | CVE-2018-3693 | Spectre 1.1 | Bounds Check Bypass Store |
- | CVE-2018-3615 | Foreshadow | L1 Terminal Fault - SGX |
- | CVE-2018-3620 | Foreshadow-NG | L1 Terminal Fault - OS/SMM |
- | CVE-2018-3646 | Foreshadow-NG | L1 Terminal Fault - VMM |
熔毁漏洞又称幽灵变体三,是这一系列漏洞中最容易利用、也最为人所知的一个。它由来自 Google Project Zero、德国 Cyberus 技术有限公司和奥地利格拉茨科技大学的三个团队各自独立地发现,论文发表在 USENIX Security 2018 上。要解释清楚熔毁漏洞的原理,需要综合三方面的知识:虚拟内存、乱序执行、基于缓存的旁路攻击。
我们都知道,现代的操作系统都应用了虚拟内存(Virtual Memory)技术,也就是说每个进程都拥有自己的虚拟地址空间,操作系统会根据页表将这些虚拟地址映射到物理地址。虚拟地址空间通常划分为用户和内核两部分,应用程序只能访问各自的用户地址空间,而只有在内核态下才能触及内核地址空间。为了进行访问权限控制,页表项中会有一个 User/Supervisor 位用来指定用户态能否访问,起到了隔离用户空间和内核空间的作用。在 Linux 和 macOS 等主流操作系统中,为了方便系统的访存操作,整个物理内存会直接映射到一部分内核空间上。而熔毁漏洞的目标便是攻破上述安全防线,在用户态也能任意访问所有物理内存。
为了达到目的,熔毁漏洞选择了从处理器的微架构着手攻击。现代处理器普遍采用了指令级并行技术来最大程度地发挥计算性能,其中一个特性便是乱序执行(Out-of-Order Execution)。在支持乱序执行的处理器上,所有指令在译码后将发往保留站(Reservation Station),一旦操作数就绪指令即可执行,不管先来后到。不过为了保证程序的正确性,指令执行的结果将以取指顺序写回程序员可见的寄存器,这被称为顺序提交(In-Order Commit)。而处理器的错误检测和异常处理都在提交阶段进行,如果发生异常则清空流水线并恢复原来的状态。
旁路攻击又称侧信道攻击(Side-Channel Attack),指绕开对加密算法的理论分析,而利用其硬件实现泄露的信息来进行攻击,譬如用时、功耗、电磁辐射等。在熔毁漏洞的例子中,缓存充当了攻击的旁路,基于缓存的 Flush+Reload 攻击击溃了乱序执行的最后一道防线。简单来讲,Flush+Reload 攻击首先利用 clflush
等指令预先清空缓存,再等待受害程序进行访存操作,然后通过数据访问的用时来判断某段数据在此期间是否被受害程序访问过。因为访问过的数据会载入缓存,所以下一次访问的速度会是第一次的两倍以上。如果没有权限调用 clflush
等指令,也可以手动访问大量无关数据来达到清空缓存的目的,这种变体被称为 Evict+Reload 攻击。
现在我们已经集齐了三片拼图,是时候把它们组合起来了。首先我们构造这样一段代码:
char data = *(char*)0xffffffff81a000e0;
char tmp = array[data * 4096];
我们可以看到第一行访问了一个内核空间的地址,理论上会因无权访问而触发段错误,从而中止程序的运行。然而我们之前提到错误检查是在提交阶段才进行的,于是第二行的代码有很大概率会在读到 data
之后到触发异常之前的时间窗口内提前执行。虽然这种乱序执行最终不会对寄存器有任何可见的影响,但容易被忽略的一点是,array
的部分数据被载入了缓存。虽然我们无法读取缓存中的数据,但我们可以通过 Flush+Reload 攻击来判断是 array
的哪部分被载入了缓存,从而得知 data
的值是多少。比如我们可以根据下图的用时曲线推断出 data = 84
:
像这样在极短的时间窗口内留下副作用的指令,在论文中被称为暂态执行(Transient Execution),这也是幽灵系列漏洞的核心技术。因为不少主流操作系统都在内核空间中直接映射了物理内存,所以通过暂态执行和缓存旁路攻击能够提取物理内存中的所有数据,危害性极强。
厂商 | 产品 |
---|---|
Intel | 几乎所有在售的 CPU |
AMD | 未受影响 |
ARM | 仅 Cortex-A75 受到影响 |
IBM | z/Architecture 和 Power 架构均受影响 |
Apple | 所有在售的 Mac 和 iOS 设备 |
硬件:重新设计 CPU 以确保在发射读取指令之前进行权限检查,英特尔已于 Coffee Lake Refresh 及后续微架构中修补熔毁漏洞,但之前的 CPU 就只能软件修补了;
软件:各大操作系统都推出了内核页表隔离补丁来抵御熔毁漏洞:
说起 Linux 这次针对熔毁漏洞的内核页表隔离补丁,其背后还有一段不短的历史,最早要从 Linux 的内核地址空间布局开始说起。
最早 Linux 的内核映像在地址空间中的地址是固定的,这使黑客能够硬编码地址对 Linux 进行攻击。为了使这类攻击不容易奏效,Linux 3.14 引入了内核地址空间布局随机化(Kernel Address Space Layout Randomization),也就是说在每次系统启动时可以随机生成一个内核映像地址的偏移量,不过直到 Linux 4.12 开始 KASLR 才被默认开启。
虽然 KASLR 增加了攻击的难度,但不能杜绝黑客访问到内核映像。2017 年,格拉茨科技大学的研究人员提出了高效移除旁路的内核地址隔离(Kernel Address Isolation to have Side-channels Efficiently Removed)补丁来进一步加固,恰好这个补丁对后来的熔毁漏洞也十分有效。KAISER 提议内核态和用户态使用两张不同的页表,内核态的页表还跟原来一样,而用户态的页表中不再暴露内核地址空间,除了少量 x86 架构必需的部分。不过其缺点也很明显,切换页表和清空转译后备缓冲器(Translation Lookaside Buffer)带来了不少额外的性能开销。
在得知熔毁漏洞之后,Linux 社区开始着手从软件层面进行修补。开发团队在 KAISER 的基础上加入了一些优化,譬如支持进程上下文标识符(Process-Context Identifier)以避免清空页表缓存从而降低性能影响,并将其改名为内核页表隔离(Kernel Page-Table Isolation)最终并入了 Linux 4.15。
幽灵漏洞的影响范围比熔毁漏洞更加广泛,影响当下几乎所有计算机系统;不过幽灵漏洞相较而言更难利用,因为它需要被攻击的软件中包含特定形式的可利用代码。
幽灵漏洞的核心也是暂态执行,暂态执行除了前面叙述的乱序执行之外还有其他的触发方式,而幽灵的论文中便提到了两种,这两种均与分支预测有关。因为分支指令可能涉及内存读取,需要上百个时钟周期才能完成,因此现代处理器都设计了分支预测器来预先推测执行。一个解耦的分支预测器通常包含两个部分:
幽灵漏洞的步骤与熔毁漏洞类似,也是通过缓存旁路攻击来获取暂态执行泄漏的信息。在攻击之前,通常还会训练分支预测器,使其运行目标代码时会进行特定的预测执行;同时可以把条件判断所需的数据挤出缓存,以提高预测执行发生的概率。
我们设想系统调用或库中有这样一段代码:
if (x < len(array1))
y = array2[array1[x] * 4096];
其中 x
是一个外部传入的变量,所以条件语句进行了数组越界的检查。我们可以训练分支预测器让它暂态执行第二行的代码,则 array1[x]
可以访问任意数据,再对 array2
进行缓存旁路攻击即可。
Spectre-V1 影响几乎所有 CPU,且不仅可以在系统级编程语言中构造,在带即时编译优化的 JavaScript 引擎中亦可复现。因此 Mozilla 宣布从 Firefox 57 开始 performance.now()
的精度将降到 20µs,SharedArrayBuffer
将默认禁用。而英特尔等厂商未推出硬件解决方案,建议开发者从软件层面解决,譬如 ICC 新增 -mconditional-branch=pattern-fix
选项来自动插入 LFENCE
指令避免预测执行。
第二个变体则是针对间接跳转目标地址的预测,我们可以训练分支预测器对方法调用等进行错误的目标地址预测,使其暂态执行我们挑选的可利用代码,辅以缓存旁路攻击获取机密数据。
Spectre-V2 同样也影响几乎所有 CPU。英特尔发布了微码更新,引入了三种 Indirect Branch Control Mechanisms,可供对间接跳转预测进行限制。而谷歌工程师提出了 Retpoline,将间接跳转指令替换为返回指令,并将预测执行拖入死循环以缓解漏洞,ICC / GCC / Clang 等各大编译器均已提供支持。譬如 x86 (Intel Syntax) 中的 jmp rax
指令会被 Retpoline 替换为:
1: call set_up_target
capture_spec:
2: pause
3: jmp capture_spec
set_up_target:
4: mov [rsp], rax
5: ret
虽然处理器对于间接跳转目标地址的预测相对复杂,容易被投毒;但对于返回指令目标地址的预测是确定的,主要依赖一个后进先出的返回栈缓冲器(Return Stack Buffer)。在上面的例子中,指令 1 会将指令 2 的地址压入 RSB 中,并直接跳转到指令 4,指令 4 会将原来间接跳转的目标地址写入调用栈中返回地址的位置,于是下一行的返回指令 5 便完成了间接跳转的工作。另一方面,如果处理器进行了预测执行,在指令 5 处它会读取 RSB 并跳转到指令 2,接下来预测执行便陷入了死循环,直到处理器意识到预测并不正确。这样一来,Retpoline 便杜绝了目标地址预测被投毒的可能性。
在全世界计算机安全风雨飘摇的一年里,大家都在寻找更加安全的可信计算环境,而其中经常被提到的便是 SGX。SGX 全称软件保护扩展(Software Guard Extensions),是英特尔处理器的一组扩展指令集。SGX 能够在内存上创建飞地(Enclave),这块空间受到处理器的严格保护,OS / Hypervisor / BIOS 等系统软件亦无法访问,相当于一个硬件级别的沙盒。
可惜的是,SGX 也被熔毁漏洞的变体攻破了,这个变体被称为预兆漏洞。其流程与熔毁漏洞相似,但 SGX 的安全机制使攻击流程多了两步:
mprotect
函数将页表项的 Present 位设为无效,从而提前在传统页表检查时便抛出缺页。预兆漏洞影响英特尔所有支持 SGX 的 CPU,即 Skylake 及其后续微架构,Atom 系列除外。英特尔已经发布了微码更新,后续 CPU 也将进行硬件修复。
随着幽灵系列漏洞如雨后春笋般不断涌现,格拉茨科技大学的研究人员又撰写了论文对暂态执行攻击进行了系统性分类和梳理分析。首先论文以暂态执行的成因将攻击分为幽灵和熔毁两大类:前者是预测执行的误判,后者是乱序执行对异常的延时处理。
对于幽灵类的攻击,论文以预测执行所依赖的处理器元件进行分类:
对于熔毁类的攻击,论文首先以异常类型分类,如果利用的异常是缺页则再基于页表项的属性位进行二级分类:
RDMSR
等指令非法读取系统寄存器会触发一般保护错误(General Protection Fault,#GP),利用这个异常进行暂态执行的攻击是 Spectre-V3a;BOUND
指令来进行数组越界的检查,更新的还有英特尔的扩展指令集 MPX,它们都会在数组越界时触发越界错误(BOUND Range Exceeded,#BR),论文在英特尔和 AMD 的处理器上成功实施了基于该异常的攻击;论文中还分析了一些可能存在但实际上未能成功的攻击种类,在这里就不一一赘述了。
这篇文章是我在《B/S 体系软件设计》课程的中期报告(命题作文)。因为在开发求是潮手机站时有写过与后端 API 通信的部分,在其他项目中也不时要考虑 API 设计的问题,所以在这方面也有一些自己粗浅的体悟。
表现层状态转化(REST)是一种网络应用程序的架构风格,通常体现在客户端与服务端的通信方式上。不过 REST 与简单对象访问协议(SOAP)等不同,它并不是一种规范化的协议,而是直接基于 HTTP 实现的一种接口风格。它相比 SOAP 等协议而言更加简单自然,因此在网站接口设计上得到了广泛应用。REST 这个名字起得有点令人费解,这是 Roy Fielding 在其博士论文1中创造的名词,不过其思想也可以被解释为「HTTP 对象模型」,并且这些思想早已被用在 HTTP 和 URI 标准的设计上。因此,我们可以先从 HTTP 和 URI 谈起。
众所周知,超文本传输协议(HTTP)是大家浏览网页使用最频繁的协议,承载了互联网上传输的大部分数据量。我们使用浏览器访问网页,其实就是向网站所在的服务器发出一个 HTTP 请求,而我们收到的 HTTP 响应便是用超文本标记语言(HTML)表示的网页,最后通过浏览器的渲染引擎呈现在我们眼前。像这样,HTTP 提供了发布和接收 HTML 页面的方法,不过其功能并不局限于此,任何数据或者说网络资源都可以通过 HTTP 传输。HTTP 标准定义了若干请求方法23,用以表示对资源的不同操作方式:
以上请求方法多次提到了「资源」这个词,实际上统一资源标识符(URI)就是用于标识互联网资源的字符串,譬如网页便是资源的一种。URI 分为定位符(URL)和名称(URN)两类,前者 URL 就是我们俗称的网址,其格式如下4:
authority path
┌───────────────┴───────────────┐┌───┴────┐
abc://username:password@example.com:123/path/data?key=value#fragid1
└┬┘ └───────┬───────┘ └────┬────┘ └┬┘ └───┬───┘ └──┬──┘
scheme user information host port query fragment
我们有了 URI 和 HTTP 这两个基本概念,也就意味着我们对互联网上的资源及其操作有了具体的表达方法,这样一来 REST 便呼之欲出了。
RESTful 架构的核心便是对资源的抽象,这些资源通过 URI 标识,通过 HTTP 请求来进行操作。我们会预先定义一系列动作,让资源能够便捷地以文本形式来被访问和修改。RESTful 架构的另一个特点是,它是无状态的,因为 HTTP 请求本身就是无状态的。也就是说,服务器不会保存任何操作的上下文,每一次请求都必须提供足够的信息,这既简化了接口的设计,又提高了 RESTful 架构的可靠性。同时,RESTful 也继承了 HTTP 的安全性、幂等性(idempotence)等性质,在这里,「安全」表示 HTTP 请求方法是只读不写的,「幂等」表示相同的方法调用一次或是多次产生的效果相同23。
对资源的操作可以借用关系型数据库中 CRUD5 的概念分为四类:
CRUD | SQL | HTTP | 安全 | 幂等 |
---|---|---|---|---|
Create | INSERT | POST | ✕ | ✕ |
Read | SELECT | GET | ◯ | ◯ |
Update | UPDATE | PUT | ✕ | ◯ |
Delete | DELETE | DELETE | ✕ | ◯ |
从这张表可以看到,模型化后的资源亦可视为关系型数据库中的数据,HTTP 请求可以直接对应于 SQL 语句。不过在实际的 RESTful 后端实现中,我们可能会使用 ORM、NoSQL 等技术,因此并不一定会直接与 SQL 打交道。另外,RESTful 架构通常约定只有 POST 不是幂等的,因为资源创建一次和创建多次结果显然不一样;但剩下的查询、更新和删除,重复相同的请求应该永远得到相同的效果。
Ruby on Rails 是一个典型的 RESTful 框架,它提供了脚手架(scaffold)功能来快速创建一个资源,并生成对应的模板代码。我们以 users
这个资源为例,以下便是脚手架自动生成的路由6:
方法 | URL | 动作 | 作用 |
---|---|---|---|
GET | /users | index | 列出所有用户 |
GET | /users/new | new | 显示创建用户的页面 |
POST | /users | create | 创建新的用户 |
GET | /users/1 | show | 显示 ID 为 1 的用户 |
GET | /users/1/edit | edit | 显示 ID 为 1 的用户的编辑页面 |
PATCH / PUT | /users/1 | update | 更新 ID 为 1 的用户 |
DELETE | /users/1 | destroy | 删除 ID 为 1 的用户 |
我们可以从中发现一些基本原则:/users
表示用户总体,对其发出 GET 和 POST 请求分别意味着查询所有用户和添加新的用户;而 /users/:id
表示用户个体,对其发出 GET、PUT 和 DELETE 请求分别意味着查询、更新、删除该用户信息。另外,因为 POST 和 PUT 请求需要用户提供消息主体(message body),所以另有两个页面 /users/new
和 /users/:id/edit
来为添加新用户、更新用户信息两个操作提交表单。当然,如果是纯 API 项目就不需要这两个页面了。
对于每个请求,Rails 也提供了 respond_to
方法根据请求头的 Accept
字段来确定响应的格式。譬如正常的浏览器访问的请求头为 Accept: text/html
,控制器则正常渲染 HTML 页面;当客户端将请求头设为 Accept: application/json
时,控制器则直接返回相应的 JSON 数据。我们看到,Rails 的 RESTful 架构既适用于服务端渲染页面的传统网站,也可用来搭建一个纯 API 的后端应用。
Apache CouchDB 是一个用 Erlang 语言编写的面向文档的 NoSQL 数据库。它使用 JSON 作为存储格式,使用 HTTP 作为数据库的接口,这也是非常典型的 RESTful API。我们这里就不介绍其多版本并发控制等有趣的特性了,只看看它的 API7:
目的 | 方法 | URL | 请求主体 | 响应主体 |
---|---|---|---|---|
创建名为 docs 的数据库 | PUT | /docs | {“ok”: true} | |
再次创建同名数据库 | PUT | /docs | {“error”: “file_exists”, “reason”: “…”} | |
创建一个文档 | POST | /docs | {“title”: “couchdb”} | {“ok”: true, “id”: “5f3759df”, “rev”: “1-qaz”} |
查询一个文档 | GET | /docs/5f3759df | {“_id”: “5f3759df”, “_rev”: “1-qaz”, “title”: “couchdb”} | |
创建或更新一个文档 | PUT | /docs/5f3759df | {“_rev”: “1-qaz”, “title”: “couch”} | {“ok”: true, “id”: “5f3759df”, “rev”: “2-wsx”} |
删除一个文档 | DELETE | /docs/5f3759df?rev=2-wsx | {“ok”: true, “id”: “5f3759df”, “rev”: “2-wsx”} | |
删除 docs 数据库 | DELETE | /docs | {“ok”: true} |
传统的关系型数据库譬如 MySQL、PostgreSQL,都是基于 TCP/IP 构建自己的二进制协议来传输数据的,而 CouchDB 却基于更高层的 HTTP 来构建 RESTful API,传输的也是人类可读的 JSON 文本。其好处显而易见:不需要任何额外封装和第三方库便可直接为前端提供简单易用的接口,易于调试;但这也有非常明显的缺点:相比于底层的自定义协议,HTTP 的文本请求会比二进制占用更大的空间,性能更差。
说起来,如今也渐渐出现了一些将传统数据库包装成通用 RESTful API 服务器的实践,不少开发者也很喜欢这种开箱即用的数据库后端,譬如 PostgREST 之于 PostgreSQL。
因为 REST 和 HTTP 两者的想法高度重合,同时也自然地支持 API 与浏览器访问复用同一套 URL,所以 RESTful 架构一直是服务端渲染页面网站的最佳选择。不过随着移动设备的普及和前端技术的蓬勃发展,越来越多的网站选择了前后端分离的策略——后端只提供数据,一切显示工作由前端来完成,即前端成为了与客户端并列的独立应用。在剥离页面渲染的浪潮之下,后端 API 的设计又出现了新的技术,其中最引人注目的则是 GraphQL 和 gRPC。
GraphQL 是 Facebook 公司发明的数据查询语言,其已在 Facebook 内部投入使用,并于 2015 年正式公开。虽然它的名字长得非常像 SQL,但实际上它不是一种数据库的查询语言,而是能够取代 REST 的一种 API 查询语言。与 RESTful API 不同,GraphQL 并不根据资源的不同将 API 细分为多个 URL,它通常部署在一个固定的 URL 上,并由客户端通过一种查询语言自由指定需要查询的资源和属性。例如下面便是一个 GraphQL 的请求以及服务器对其的响应8:
{
hero {
name
friends {
name
}
}
}
{
"hero": {
"name": "Luke Skywalker",
"friends": [
{ "name": "Obi-Wan Kenobi" },
{ "name": "R2-D2" },
{ "name": "Han Solo" },
{ "name": "Leia Organa" }
]
}
}
我们可以看到查询语言的结构非常直观地对应了查询结果的 JSON,并且同一个请求中可以包含对多个对象的查询,所有属性亦是客户端自由指定的。另外,GraphQL 还定义了一种 schema 语言,这样在任何编程语言中都可以使用统一的形式来定义对象及其类型。
GraphQL 的出现解决了 RESTful API 的一些痛点,这些问题在 API 开放平台上尤为致命:
/users/1
这样的 API,客户端通常只能通过增加查询参数来自定义数据,譬如 /users/1?detail=false
代表不显示详细信息等等。不过,这样的自定义增加了后端开发的复杂度,同时也不够灵活。介于以上原因,现在有越来越多的企业开始试用 GraphQL,譬如 GitHub 已经从 REST API v3 升级到了 GraphQL API v4。Relay 和 Apollo Client 等开源框架也为前端或客户端提供了可靠的 GraphQL 集成,Apollo Server 甚至还能帮助开发者将服务端的 RESTful API 包装成 GraphQL API。
远程过程调用(RPC)并不是一个新鲜的事物,至少在 1980 年代 Sun 公司就为网络文件系统(NFS)开发出了开放网络运算远程过程调用(ONC RPC)协议。之后 XML-RPC、JSON-RPC 等协议也陆续出现,其中前者已经演变成了如今的 SOAP。gRPC 则是 Google 公司于 2015 年开源的一种 RPC 协议实现,其最大特点就是使用了 Google 早先公布的 Protocol Buffers 格式来序列化数据,并通过 HTTP/2 来传输数据。
顾名思义,RPC 提供了远程调用服务器程序的接口,常常用于服务器集群节点之间的通信,在 Java 等面向对象编程语言中也叫远程方法调用(RMI)。与 REST 和 GraphQL 以数据为中心的概念不同,RPC 着眼于远程程序间的互相调用,不过各种类型的数据作为过程的参数和返回值,亦可在服务端和客户端之间自由传递。例如 gRPC 通过 .proto
文件来定义服务和消息9:
service Greeter {
rpc SayHello (HelloRequest) returns (HelloReply) {}
}
message HelloRequest {
string name = 1;
}
message HelloReply {
string message = 1;
}
我们可以使用 gRPC 的 protoc
将上述文件编译到各种各样的服务端和客户端语言,之后只需要在服务端和客户端调用其生成的类和方法即可实现远程调用。我们可以发现 gRPC 有不少明显的好处:
虽然 gRPC 的设计与 REST 或 GraphQL 相当不同,但将资源的 URL 对应于方法的调用、HTTP 请求对应于输入参数、HTTP 响应对应于返回值,两种设计仍然可以实现同样的功能,因此 gRPC 也是客户端与服务端交互不错的选择。
本文从 HTTP 和 URI 标准入手,介绍了 REST 的思想及其基本架构,接着通过 Rails 和 CouchDB 两个实例具体地展示 RESTful API 的设计。最后通过跟 GraphQL 和 gRPC 两个相关技术的比较,阐述了 RESTful API 的优缺点,以便对接口设计有一个全面的认识。
Roy Fielding. Architectural Styles and the Design of Network-based Software Architectures. PhD dissertation, University of California, Irvine, 2000. Chapter 5: Representational State Transfer (REST) ↩
Network Working Group. Hypertext Transfer Protocol – HTTP/1.1 (RFC 2068). 1997. Chapter 9: Method Definitions ↩ ↩2
Internet Engineering Task Force. PATCH Method for HTTP (RFC 5789). 2010. https://tools.ietf.org/html/rfc5789 ↩ ↩2
Network Working Group. Uniform Resource Identifier (URI): Generic Syntax (RFC 3986). 2005. https://tools.ietf.org/html/rfc3986 ↩