<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://blog.yzsun.me/feed.xml" rel="self" type="application/atom+xml" /><link href="https://blog.yzsun.me/" rel="alternate" type="text/html" /><updated>2026-04-20T11:57:43+00:00</updated><id>https://blog.yzsun.me/feed.xml</id><title type="html">孙耀珠的博客</title><entry><title type="html">移居日本需要几步？</title><link href="https://blog.yzsun.me/japan-immigration/" rel="alternate" type="text/html" title="移居日本需要几步？" /><published>2025-04-21T00:00:00+00:00</published><updated>2025-04-21T00:00:00+00:00</updated><id>https://blog.yzsun.me/japan-immigration</id><content type="html" xml:base="https://blog.yzsun.me/japan-immigration/"><![CDATA[<p>八年前我还在读本科的时候，去东京工业大学（现・东京科学大学）交换留学过<a href="../tokyo-ichinen/">一年</a>，现在博士后又重返东京，真是感慨万千。虽然上次是留学签证而这次是工作签证，但大体流程都是一样的，之前办过的手续因为时间太久远很多细节都记不清了，这次还是浅浅用文字记录一下留个档吧。</p>

<ol id="markdown-toc">
  <li><a href="#在留资格" id="markdown-toc-在留资格">在留资格</a></li>
  <li><a href="#签证" id="markdown-toc-签证">签证</a></li>
  <li><a href="#租房" id="markdown-toc-租房">租房</a></li>
  <li><a href="#水电煤" id="markdown-toc-水电煤">水电煤</a></li>
  <li><a href="#入境" id="markdown-toc-入境">入境</a></li>
  <li><a href="#住民登录" id="markdown-toc-住民登录">住民登录</a></li>
  <li><a href="#手机卡" id="markdown-toc-手机卡">手机卡</a></li>
  <li><a href="#光纤宽带" id="markdown-toc-光纤宽带">光纤宽带</a></li>
  <li><a href="#银行账户" id="markdown-toc-银行账户">银行账户</a></li>
  <li><a href="#个人编号卡" id="markdown-toc-个人编号卡">个人编号卡</a></li>
</ol>

<h2 id="在留资格">在留资格</h2>

<p>赴日长期居留第一步是得到日本出入国在留管理厅的许可，这份许可的全称叫「在留資格認定証明書」，有点像美国留学的 I-20；因为它英文名叫「Certificate of Eligibility」，所以常常简称 CoE。提交申请通常是由日本单位完成的，所以填完申请表之后只能乖乖等待静候佳音。听说申请人背景比较敏感的话有可能收到「質問票」，这就不知道要等到猴年马月了。我还算比较幸运，从一月中旬到三月中旬等了两个月收到 CoE，因为申请的是电子版本所以打印出邮件就好了。</p>

<!--more-->

<h2 id="签证">签证</h2>

<p>收到 CoE 就可以去办签证了，跟旅行社代办的旅游签不同，长期签证需要找指定的代理机构办理，比如驻上海总领事馆有<a href="https://www.shanghai.cn.emb-japan.go.jp/itpr_zh/00_000897.html">指定名单</a>，江浙几乎每个地级市都有代理点。不过我去了本市的签证服务中心才知道他们要先送到位于南京的省外事办，再由南京送到上海领事馆，相当于转包了两层。令我无语的是，比在上海办贵整整两百块钱就算了（共收 559 人民币），还说预计办理时间是三周左右，因为他们每周只去南京一趟……不过还好最后两周就拿到了。因为工作签跟旅游签不能共存，我之前的三年多次签证被戳了大大的 CANCELLED。</p>

<h2 id="租房">租房</h2>

<p>收到 CoE 办理签证的同时，也可以开始申请租房了。为了头一两年的生活质量，我原本打算用旅游签证飞去东京看房现场签约的。然而租房中介告诉我没有在留卡的话人来了也没法签约，况且有不少放租的房子还有人在住没法参观，因此我还是选择了网上找房海外审查的路线。我是收到 CoE 的当天就跟提前联系的中介决定了房子、提交了申请，除了 CoE 还上传了工作单位发的录用证明书。从提交申请到审查通过就花了三天时间，到最后签完约一共花了半个月时间。</p>

<p>日本租房的头金跟之前在香港时不太一样，香港一般是押二付一加半个月租金作为中介费，而日本这次是押一付二加半个月中介费，再加一个月的礼金，对比香港相当于有一个月的押金变成礼金永远拿不回来了，还收了 33,000 日元换新钥匙。另外由于我租的是野村不动产管理的租赁公寓，必须找租赁担保公司（有专门面向外国人的 <a href="https://www.gtn.co.jp">GTN</a>），担保费的头款是半个月租金，之后每个月收 2,000 日元，费用包含了火灾保险。简而言之，我的租房头金差不多是五个月房租的钱。</p>

<p>日本的公寓分为[アパート]{apartment}和[マンション]{mansion}，可以类比于香港的唐楼和洋楼，如果怀念香港私人屋苑的话可以选[タワー]{tower}[マンション]{mansion}，如果怀念公屋的话还可以选团地。如果是留学生或者研究员也可以申请<a href="https://www.jasso.go.jp/ryugaku/kyoten/tiec/">东京国际交流馆</a>的宿舍，位于人工岛上，在台场南边的青海二丁目。<del>喜欢日本偶像的话一定要选B栋，因为这栋的窗户正对着一年一度的东京偶像节（TIF）主会场。</del></p>

<p>租房的时候应该会注意到，日本的地址表示方法跟全世界大多数地方都不一样，（除了京都和札幌之外）日本地址都不含道路名，而是以街区为基本单位。以东京为例，地址的格式通常为「東京都〇〇区〇〇町Ｘ丁目Ｘ番Ｘ号」，到「番」为止就能精确到一个街区，后面的「号」相当于门牌号。而邮政编码则通常精确到「町」，比如 〒160-0021 对应「東京都新宿区歌舞伎町」。</p>

<p>最后友情提醒一下，日本的房子基本上不提供家具，所以最好提前买好床，预约入住那天送到公寓。</p>

<h2 id="水电煤">水电煤</h2>

<p>因为水电煤开通也需要时间，所以我提前请租房中介帮忙打电话预约开通了。东京的自来水和污水归<a href="https://www.waterworks.metro.tokyo.lg.jp">东京都水道局</a>管，电力和煤气原来是由<a href="https://www.tepco.co.jp">东京电力公司</a>和<a href="https://www.tokyo-gas.co.jp">东京瓦斯公司</a>垄断，但电力和煤气自由化之后可以自行选择新电力、煤气公司，我是两者都选择了东京瓦斯（福岛核事故之后对东京电力实在是没什么好感）。水和电都是到时候直接可以用，但煤气开通需要本人在场等工作人员开阀。水电煤的费用默认是寄请求书来自己拿着去便利店缴费，等有了日本银行账户之后可以改成自动转账或者信用卡扣款。</p>

<h2 id="入境">入境</h2>

<p>提前处理好租房事宜之后，就可以安心赴日了。我到日本的时候已经过了三月底四月初的高峰期，所以入境队伍不算太长，就是注意入境审查的时候要走中长期在留者窗口，不要走给游客开的外国护照窗口。在入境窗口会拿到在留卡，但默认只有拼音没有汉字，要加汉字得自己去东京出入国在留管理局申请汉字氏名表记。这个管理局也位于人工岛上，附近是品川货柜码头，不通地铁，得从品川站坐公交过去。因为我的租房和劳动合同都已经遵照 CoE 用英文名签署了，所以暂时也不急着加汉字，打算明年在留卡更新的时候一并申请，正好也省了 1,600 日元的在留卡再交付手续费。</p>

<h2 id="住民登录">住民登录</h2>

<p>在日本入住之后第一件事是住民登录，届时会往在留卡背面登记住址，办手机卡、开银行账户等等都需要载有住址的在留卡。住民登录要去市区町村的政府大楼（日语称「役所」），而我所在的新宿区还设有数家办事处（日语称「事務所」或「出張所」）可以避免大排长队。虽然我去的高田马场的办事处人并不多，但我交完申请还是等了将近一个小时，不知道是我同时申请的印章登录比较花时间，还是他们寻找我八年前的住民记录比较花时间。</p>

<p>如果是留学生的话还要办理一下国保年金，而我是雇员所以是雇主处理社保。日本的养老保险（日语称「厚生年金保険」）费率目前是固定的 18.3%，健康保险则各地每年略有不同，大致在 10% 上下，这两项都是劳资双方对半缴费，再加上雇员承担 0.6% 的失业保险，每月工资总共会扣大约 15% 的社保。</p>

<h2 id="手机卡">手机卡</h2>

<p>日本传统上有三大运营商：<a href="https://www.docomo.ne.jp">NTT docomo</a>、<a href="https://www.softbank.jp/mobile/">SoftBank</a>、<a href="https://www.au.com/mobile/">au</a>，它们主推的无限流量套餐月租费含税在 7,315～7,458 日元上下，而 3GB 套餐月租费在 5,478～5,665 日元上下，如果没有折扣或者返现还是相当贵的。不过 SoftBank 旗下还有子品牌 <a href="https://www.ymobile.jp">Y!mobile</a>，au 母公司 KDDI 旗下还有 <a href="https://www.uqwimax.jp">UQ mobile</a>，这两家 30GB 套餐月租费都只要 4,000 日元左右。就在前几年，<a href="https://network.mobile.rakuten.co.jp">乐天</a>也加入战场成为第四大运营商。乐天的定位有点像中国第四大运营商——广电，用低价攻占市场，其套餐 20GB 之内月租费仅 2,178 日元、用到无限流量也只要 3,278 日元，不过听说乐天的信号覆盖不及三大运营商。</p>

<p>追求便宜的另一个选择是「格安SIM」，这种SIM卡的提供方往往是[虚拟运营商]{MVNO}，即自家没有基站而借用三大运营商的网络，因此很难保证高峰时段的网速。<a href="https://www.iijmio.jp">IIJmio</a> 和 <a href="https://mineo.jp">mineo</a> 大概是最有名的两家虚拟运营商，mineo 比较有意思的是它提供不限流量只限网速的套餐，比如 1.5Mbps 是 990 日元。我见过最实惠的是<a href="https://www.nihontsushin.com">日本通信</a> 20GB 套餐，月租费只要 1,200 日元。为了与虚拟运营商竞争，三大运营商现在也推出了自己的廉价子品牌：NTT docomo 有 <a href="https://irumo.docomo.ne.jp">irumo</a>，SoftBank 有 <a href="https://www.linemo.jp">LINEMO</a>，而 KDDI 有 <a href="https://povo.jp">povo</a>；这个 povo2.0 很有意思，跟香港的 3HK DIY 储值卡很像，没有月租费，可以自由选购各种时长的服务组合，称作<a href="https://povo.jp/topping-list">トッピング</a>，尤其适合不在日本的时候保号用。</p>

<h2 id="光纤宽带">光纤宽带</h2>

<p>办宽带的第一步是确认自家公寓拉了哪家的光纤，虽然听说索尼的「<a href="https://www.nuro.jp">NURO 光</a>」口碑最好，但我住的公寓办都办不了。大多数公寓包括我住的都是 NTT FLET’S 的光纤线路，这个线路能选的宽带运营商非常多。因为是跟手机卡一起办的，所以我选的 SoftBank 全家桶，月租费能优惠 1,100 日元。我在 BIC CAMERA 池袋本店还返了将近五万积分，正好够买他们家 ORIGINAL BASIC 牌冰箱加洗衣机（这两个都是海尔贴牌）的新生活应援套装。「<a href="https://www.softbank.jp/internet/sbhikari/">SoftBank 光</a>」面向公寓的千兆网是每月 4,730 日元（含路由器租赁费），我的公寓需要 NTT 上门施工，所以先寄来了个插着手机卡的 Wi-Fi 盒子（雅称 SoftBank Air）用来临时上网。然而令我崩溃的是，施工时我发现他们接的是电话线口，也就是说所谓的光纤只到楼，入户走的是 VDSL 百兆网……虽然延时和抖动比 SoftBank Air 好点，但网速其实并没有什么区别。</p>

<h2 id="银行账户">银行账户</h2>

<p>开银行账户需要日本手机号，所以得放在办手机卡的下一步；但细究起来手机和宽带套餐扣费其实也需要信用卡，所以消除死锁的方法是赴日前办好国际信用卡。</p>

<p>日本有三大银行，恰好都是「み」开头：三菱[日联]{UFJ}银行、三井住友银行、[瑞穗]{みずほ}银行。除此之外日本邮政旗下的[邮储]{ゆうちょ}银行（邮政储蓄的日语是「[郵]{ゆう}便[貯]{ちょ}金」简称「ゆうちょ」）对外国人开户比较友好，往往是留学生的第一家银行。八年前东工大帮我开过三井住友银行的非居住者账户（居住未满六个月且无工作），只给了我一本存折，既不能刷卡购物，也不能用网上银行……我这次还是选择了三井住友银行，因为我带着雇主发的劳动条件通知书，而且基本上能跟柜员用日语交流，所以还挺顺利地就办下来了全功能的账户。开户申请全程都是用平板电脑操作的，也没用到印章，次日就电邮通知了我开设好的银行账号。</p>

<p>日本的银行卡跟中国略有不同的一点是，日本会区分[提款卡]{cash card}和[扣账卡]{debit card}这两个概念，前者只能在 ATM 取钱，后者则用于刷卡消费，相当于不能透支的信用卡。中国的银行卡因为都接入了银联网络，所以相当于整合了提款卡和扣账卡的功能。日本近年来也开始推出这种一体型卡，在开户的时候最好告诉店员自己需要「デビット付きキャッシュカード」，否则仍有可能拿到单体型的提款卡。三井住友银行现在在推的 <a href="https://www.smbc.co.jp/kojin/olive/">Olive</a> 就是这种一体型卡，甚至还能通过手机应用把扣账模式切换成信用模式。</p>

<h2 id="个人编号卡">个人编号卡</h2>

<p>开完银行账户基本上就走完移居日本第一周的必经之路了，但还有个证件现在不是必需但以后迟早得办：个人编号卡，通称「[マイ]{My}[ナンバー]{Number}[カード]{Card}」，是日本政府从2016年开始推行的身份证。有了个人编号卡之后，很多行政手续都能通过 <a href="https://myna.go.jp">マイナポータル</a> 线上办理。从海外移居日本后三十天内可以申请<a href="https://www.kojinbango-card.go.jp/apprec/apply/express_apply/">特急发行</a>，原则上一周就能收到，不过这个得去政府大楼不能去办事处。</p>

<p>根据日本政府的<a href="https://www.gov-online.go.jp/article/202407/entry-6238.html">公告</a>，2024年12月2日起健康保险证已不再发行，取而代之的是「マイナ保険証」，也就是拿登录过的个人编号卡当健康保险证用。话虽如此，就算没有个人编号卡，也能收到健康保险资格确认书，目前资格确认书能继续当原来的健康保险证用。</p>

<p>根据警察厅交通局的<a href="https://www.npa.go.jp/bureau/traffic/r4kaisei_main.html">公告</a>，2025年3月24日起个人编号卡可以作为驾照使用，即「マイナ免許証」。</p>

<p>根据出入国在留管理厅的 <a href="https://www.moj.go.jp/isa/11_00051.html">Q&amp;A</a>，2026年6月21日前将推行在留卡和个人编号卡的一体化，一张「特定在留カード」即可实现两张卡的功能。因为个人编号卡不是强制的，所以办理此卡也不是在留者的义务。</p>

<p>另外根据<a href="https://www.digital.go.jp/councils/mynumber-card-renewal">会议资料</a>，日本数字厅考虑将于2026年引入第二代个人编号卡，感觉新版设计比现在好看多了！</p>

<p><img src="../images/japan-immigration.png" alt="" /></p>]]></content><author><name>孙耀珠</name></author><category term="杂谈" /><summary type="html"><![CDATA[八年前我还在读本科的时候，去东京工业大学（现・东京科学大学）交换留学过一年，现在博士后又重返东京，真是感慨万千。虽然上次是留学签证而这次是工作签证，但大体流程都是一样的，之前办过的手续因为时间太久远很多细节都记不清了，这次还是浅浅用文字记录一下留个档吧。 在留资格 在留资格 赴日长期居留第一步是得到日本出入国在留管理厅的许可，这份许可的全称叫「在留資格認定証明書」，有点像美国留学的 I-20；因为它英文名叫「Certificate of Eligibility」，所以常常简称 CoE。提交申请通常是由日本单位完成的，所以填完申请表之后只能乖乖等待静候佳音。听说申请人背景比较敏感的话有可能收到「質問票」，这就不知道要等到猴年马月了。我还算比较幸运，从一月中旬到三月中旬等了两个月收到 CoE，因为申请的是电子版本所以打印出邮件就好了。]]></summary></entry><entry><title type="html">久别重逢的乡音：中日韩汉字音</title><link href="https://blog.yzsun.me/cjk-phonology/" rel="alternate" type="text/html" title="久别重逢的乡音：中日韩汉字音" /><published>2023-12-01T00:00:00+00:00</published><updated>2023-12-01T00:00:00+00:00</updated><id>https://blog.yzsun.me/cjk-phonology</id><content type="html" xml:base="https://blog.yzsun.me/cjk-phonology/"><![CDATA[<p>最近学习了上海外国语大学朱磊老师的《<a href="https://icourse163.org/course/SHISU-1003361045">中国传统音韵学</a>》，拿到了成绩优秀的<a href="https://www.icourse163.org/cert/Authority.htm?certNo=V232334000003">认证证书</a>，课程内容让我受益匪浅。传统上韵书是为文人写诗押韵作参考用的，不过我更感兴趣的是韵书所揭示的中古汉语读音哺育了不少域外方音，比如日语和韩语的汉字音。「久别重逢的乡音」便是摘自课程最后一讲的标题，而我想反过来从普通话与日韩汉字音的对应关系出发，印证一下中古汉语的本源地位，顺便介绍一下从中古汉语切韵音到现代汉语普通话发生了哪些重大变化。这既是我的学习笔记，也算是之前《<a href="../cjkv/">跨越国境的汉字</a>》的续篇了。</p>

<!--more-->

<ul id="markdown-toc">
  <li><a href="#声母" id="markdown-toc-声母">声母</a>    <ul>
      <li><a href="#中古汉语" id="markdown-toc-中古汉语">中古汉语</a></li>
      <li><a href="#日语汉字音" id="markdown-toc-日语汉字音">日语汉字音</a></li>
      <li><a href="#韩语汉字音" id="markdown-toc-韩语汉字音">韩语汉字音</a></li>
    </ul>
  </li>
  <li><a href="#汉语演变" id="markdown-toc-汉语演变">汉语演变</a>    <ul>
      <li><a href="#其一浊音清化" id="markdown-toc-其一浊音清化">其一：浊音清化</a></li>
      <li><a href="#其二声母脱落" id="markdown-toc-其二声母脱落">其二：声母脱落</a></li>
      <li><a href="#其三知照合流" id="markdown-toc-其三知照合流">其三：知照合流</a></li>
      <li><a href="#其四尖团合流" id="markdown-toc-其四尖团合流">其四：尖团合流</a></li>
      <li><a href="#总集篇普通话" id="markdown-toc-总集篇普通话">总集篇：普通话</a></li>
      <li><a href="#番外篇粤语" id="markdown-toc-番外篇粤语">番外篇：粤语</a></li>
    </ul>
  </li>
  <li><a href="#韵母" id="markdown-toc-韵母">韵母</a>    <ul>
      <li><a href="#阳声韵" id="markdown-toc-阳声韵">阳声韵</a></li>
      <li><a href="#入声韵" id="markdown-toc-入声韵">入声韵</a></li>
      <li><a href="#番外篇重纽" id="markdown-toc-番外篇重纽">番外篇：重纽</a></li>
    </ul>
  </li>
  <li><a href="#参考资料" id="markdown-toc-参考资料">参考资料</a></li>
</ul>

<h2 id="声母">声母</h2>

<p>事不宜迟，我先画了一张现代中日韩汉字音的声母对应简图，可以直观地感受到三者的关系：</p>

<p><img src="../images/cjk-phonology.png" alt="" /></p>

<p>虽然乍一看有些凌乱，但大多数声母的对应关系都是有规律可循的；尤其是日语吴音和韩语汉字音，不考虑清浊和送气与否的话，可以说是对应得相当整齐了。两处分歧是近代朝鲜语的变化导致的：</p>

<ol>
  <li>本来以ㄷ/ㅌ为声母、以ㅣ为介音的字，腭化成了以ㅈ/ㅊ为声母的字；</li>
  <li>原本最后几列对应的四个声母ㅿ/ㆁ/ㆆ/ㅇ，全部归入零声母ㅇ。</li>
</ol>

<p>另外还有一点不自然的地方是日语的は行并不对应韩语的ㅎ或是汉语拼音的 h，这是因为日语发生过「は行転呼」这一重大语音变化：奈良时代以前的は行读如现代日语的ぱ行，然后经过一系列演变，一直到江户时代才变成现在的发音。所以当时は行对应于ㅂ/ㅍ，相对而言か行才更接近ㅎ。</p>

<p>这下问题来了，明明日本和朝鲜半岛都是从中国借入的汉字，为什么从声母对应关系来看汉语普通话反而差得更远呢？原因很简单——日本和朝鲜半岛借入的主要是中古汉语，而中古汉语到现代汉语的变化要远大于日语和韩语的变化。</p>

<p>汉字传入朝鲜半岛的时间有待考证，有上古音说、切韵音说、唐代长安音说、宋代开封音说等；而汉字读音传入日本的时间跨度较大，至少可以分为三波：</p>

<ol>
  <li><strong>吴音</strong>是从南朝传入的金陵雅音，比如「[京]{きょう}都」；</li>
  <li><strong>汉音</strong>是由遣唐使习得的长安音，比如「[京]{けい}阪神」；</li>
  <li><strong>唐音</strong>是明清时期传入的南京官话，比如「南[京]{きん}」。</li>
</ol>

<p>所以吴音和汉音代表了不同时期不同地点的中古汉语，而唐音代表了近代汉语的官话。因为唐音比较罕见而且相对而言不成体系，所以前面的对应图没有包括唐音。那么中古汉语到底听起来是什么样？又如何能把中日韩汉字音联系起来呢？</p>

<h3 id="中古汉语">中古汉语</h3>

<p>中古汉语最重要的参考资料是当时的韵书和韵图，韵书之首是隋朝成书的《切韵》。《切韵》以韵目为纲，并没有标明声母，因此流传最广的中古声母系统是宋朝韵图所使用的三十六字母。按照传统音韵学的术语体系，三十六字母及其可能的拟音排列如下：</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>全清</th>
      <th>次清</th>
      <th>全浊</th>
      <th>次浊</th>
      <th>全清</th>
      <th>全浊</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>重唇音</strong></td>
      <td>幫 /p/</td>
      <td>滂 /pʰ/</td>
      <td>並 /b/</td>
      <td>明 /m/</td>
      <td> </td>
      <td> </td>
    </tr>
    <tr>
      <td><strong>轻唇音</strong></td>
      <td>非 /pf/</td>
      <td>敷 /pfʰ/</td>
      <td>奉 /bv/</td>
      <td>微 /ɱ/</td>
      <td> </td>
      <td> </td>
    </tr>
    <tr>
      <td><strong>舌头音</strong></td>
      <td>端 /t/</td>
      <td>透 /tʰ/</td>
      <td>定 /d/</td>
      <td>泥 /n/</td>
      <td> </td>
      <td> </td>
    </tr>
    <tr>
      <td><strong>舌上音</strong></td>
      <td>知 /ȶ/</td>
      <td>徹 /ȶʰ/</td>
      <td>澄 /ȡ/</td>
      <td>娘 /ȵ/</td>
      <td> </td>
      <td> </td>
    </tr>
    <tr>
      <td><strong>齿头音</strong></td>
      <td>精 /ts/</td>
      <td>清 /tsʰ/</td>
      <td>從 /dz/</td>
      <td> </td>
      <td>心 /s/</td>
      <td>邪 /z/</td>
    </tr>
    <tr>
      <td><strong>正齿音</strong></td>
      <td>照 /tɕ/</td>
      <td>穿 /tɕʰ/</td>
      <td>牀 /dʑ/</td>
      <td> </td>
      <td>審 /ɕ/</td>
      <td>禪 /ʑ/</td>
    </tr>
    <tr>
      <td><strong>牙音</strong></td>
      <td>見 /k/</td>
      <td>溪 /kʰ/</td>
      <td>群 /g/</td>
      <td>疑 /ŋ/</td>
      <td> </td>
      <td> </td>
    </tr>
    <tr>
      <td><strong>喉音</strong></td>
      <td>影 /ʔ/</td>
      <td> </td>
      <td> </td>
      <td>喻 /j/</td>
      <td>曉 /x/</td>
      <td>匣 /ɣ/</td>
    </tr>
    <tr>
      <td><strong>半舌音</strong></td>
      <td> </td>
      <td> </td>
      <td> </td>
      <td>來 /l/</td>
      <td> </td>
      <td> </td>
    </tr>
    <tr>
      <td><strong>半齿音</strong></td>
      <td> </td>
      <td> </td>
      <td> </td>
      <td>日 /ȵʑ/</td>
      <td> </td>
      <td> </td>
    </tr>
  </tbody>
</table>

<p>表头的「全清」指清不送气阻音，「次清」指清送气阻音，「全浊」指浊阻音，「次浊」指响音。</p>

<p>不过晚清学者通过系联法发现，中古后期的三十六字母与中古前期的《切韵》声母系统并不完全吻合。后世学者通过更现代的音韵学研究方法，得出了更接近中古前期汉语的声母系统，譬如<a href="https://ytenx.org/kyonh/">韵典网</a>采用的三十八声母与三十六字母的区别在于：</p>

<ol>
  <li>根据中古前期汉语没有轻唇音的理论，非组被并入帮组；</li>
  <li>照组可以细分为庄组（照二）和章组（照三）；</li>
  <li>喻母可以细分为云母（喻三）和以母（喻四）。</li>
</ol>

<p>第一项「古无轻唇音」可以解释在中日韩汉字音对应关系中，为何汉语拼音声母为 f 的字（非敷奉）会跟声母为 b/p 的字（帮滂並）归为一类，都对应日语的は/ば和韩语的ㅂ/ㅍ。</p>

<h3 id="日语汉字音">日语汉字音</h3>

<p>如果我们列出中古汉语与日语声母的对应表，我们会惊喜地发现对应关系整齐了不少：</p>

<table>
  <thead>
    <tr>
      <th></th>
      <th>全清</th>
      <th>次清</th>
      <th>全浊</th>
      <th>次浊</th>
      <th>全清</th>
      <th>全浊</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>唇音</strong></td>
      <td>幫 /p/<br />非 /pf/</td>
      <td>滂 /pʰ/<br />敷 /pfʰ/</td>
      <td>並 /b/<br />奉 /bv/</td>
      <td>明 /m/<br />微 /ɱ/</td>
      <td></td>
      <td></td>
    </tr>
    <tr>
      <td>吴音</td>
      <td colspan="2">は行 /h/</td>
      <td>ば行 /b/</td>
      <td>ま行 /m/</td>
      <td></td>
      <td></td>
    </tr>
    <tr>
      <td>汉音</td>
      <td colspan="3">は行 /h/</td>
      <td>ば行 /b/<br />ま行 /m/</td>
      <td></td>
      <td></td>
    </tr>
    <tr>
      <td><strong>舌音</strong></td>
      <td>端 /t/<br />知 /ȶ/</td>
      <td>透 /tʰ/<br />徹 /ȶʰ/</td>
      <td>定 /d/<br />澄 /ȡ/</td>
      <td>泥 /n/<br />娘 /ȵ/</td>
      <td></td>
      <td></td>
    </tr>
    <tr>
      <td>吴音</td>
      <td colspan="2">た行 /t/</td>
      <td>だ行 /d/</td>
      <td>な行 /n/</td>
      <td></td>
      <td></td>
    </tr>
    <tr>
      <td>汉音</td>
      <td colspan="3">た行 /t/</td>
      <td>だ行 /d/<br />な行 /n/</td>
      <td></td>
      <td></td>
    </tr>
    <tr>
      <td><strong>齿音</strong></td>
      <td>精 /ts/<br />照 /tɕ/</td>
      <td>清 /tsʰ/<br />穿 /tɕʰ/</td>
      <td>從 /dz/<br />牀 /dʑ/</td>
      <td></td>
      <td>心 /s/<br />審 /ɕ/</td>
      <td>邪 /z/<br />禪 /ʑ/</td>
    </tr>
    <tr>
      <td>吴音</td>
      <td colspan="2">さ行 /s/</td>
      <td>ざ行 /z/</td>
      <td></td>
      <td>さ行 /s/</td>
      <td>ざ行 /z/</td>
    </tr>
    <tr>
      <td>汉音</td>
      <td colspan="3">さ行 /s/</td>
      <td></td>
      <td colspan="2">さ行 /s/</td>
    </tr>
    <tr>
      <td><strong>牙音</strong></td>
      <td>見 /k/</td>
      <td>溪 /kʰ/</td>
      <td>群 /g/</td>
      <td>疑 /ŋ/</td>
      <td></td>
      <td></td>
    </tr>
    <tr>
      <td>吴音</td>
      <td colspan="2">か行 /k/</td>
      <td colspan="2">が行 /g/</td>
      <td></td>
      <td></td>
    </tr>
    <tr>
      <td>汉音</td>
      <td colspan="3">か行 /k/</td>
      <td>が行 /g/</td>
      <td></td>
      <td></td>
    </tr>
    <tr>
      <td><strong>喉音</strong></td>
      <td>影 /ʔ/</td>
      <td></td>
      <td></td>
      <td>喻 /j/</td>
      <td>曉 /x/</td>
      <td>匣 /ɣ/</td>
    </tr>
    <tr>
      <td>吴音</td>
      <td>あ·や·わ行 /∅/</td>
      <td></td>
      <td></td>
      <td>あ·や·わ行 /∅/</td>
      <td>か行 /k/</td>
      <td>が行 /g/<br />わ行 /∅/</td>
    </tr>
    <tr>
      <td>汉音</td>
      <td>あ·や·わ行 /∅/</td>
      <td></td>
      <td></td>
      <td>あ·や·わ行 /∅/</td>
      <td colspan="2">か行 /k/</td>
    </tr>
    <tr>
      <td><strong>半舌音</strong></td>
      <td></td>
      <td></td>
      <td></td>
      <td>來 /l/</td>
      <td></td>
      <td></td>
    </tr>
    <tr>
      <td>吴音</td>
      <td></td>
      <td></td>
      <td></td>
      <td>ら行 /r/</td>
      <td></td>
      <td></td>
    </tr>
    <tr>
      <td>汉音</td>
      <td></td>
      <td></td>
      <td></td>
      <td>ら行 /r/</td>
      <td></td>
      <td></td>
    </tr>
    <tr>
      <td><strong>半齿音</strong></td>
      <td></td>
      <td></td>
      <td></td>
      <td>日 /ȵʑ/</td>
      <td></td>
      <td></td>
    </tr>
    <tr>
      <td>吴音</td>
      <td></td>
      <td></td>
      <td></td>
      <td>な行 /n/</td>
      <td></td>
      <td></td>
    </tr>
    <tr>
      <td>汉音</td>
      <td></td>
      <td></td>
      <td></td>
      <td>ざ行 /z/</td>
      <td></td>
      <td></td>
    </tr>
  </tbody>
</table>

<p>除了刚刚提到的「古无轻唇音」，日语汉字音还体现了「古无舌上音」，也就是说日语中知组字读如端组；日语同样也不区分齿头音和正齿音，即精组和照组声母混同。因此，上表合并了重唇音和轻唇音、舌头音和舌上音、齿头音和正齿音。</p>

<p>日语声母有清浊对立，但不存在送气与不送气的对立，所以吴音不区分全清和次清，但保留了全浊声母。然而后来的汉音对清浊的处理有些不同，应该是反映了从金陵雅音到唐代长安音的变化：</p>

<ul>
  <li>全浊声母清化，例如並奉 ば→は、定澄 だ→た、从邪床禅 ざ→さ、群匣 が→か；</li>
  <li>部分次浊声母从鼻音变为浊阻音，例如明微 ま→ば、泥娘 な→だ、日 な→ざ。</li>
</ul>

<p>虽然明母字的汉音主要读ば行，但也有一部分字的汉音跟吴音一样读ま行，这些字几乎都是梗摄舒声字，例如「[明]{めい}」；类似的还有泥娘母梗摄舒声字，例如「[寧]{ねい}」。另外有一部分匣母合口字在日语吴音中读わ行，例如「[和]{わ}」「[惠]{ゑ}」。有趣的是，虽然大多数匣母合口字在现代汉语中声母是 h，但也有极个别的例外读 w，例如「[丸]{wán}」「[完]{wán}」。虽然日汉这两种例外的改读规律是相似的，但适用的匣母字并不重合：和、惠在汉语里声母是 h 而不是 w，丸、完在日语里吴音读「がん」而不是「わん」。</p>

<h3 id="韩语汉字音">韩语汉字音</h3>

<p>朝鲜王朝创制谚文的同时颁布了《东国正韵》，书中标示的汉字音与中古音有非常整齐的对应关系。然而这些汉字音只是编纂者们心目中的正音，朝鲜半岛自古以来实际使用的汉字音有很多讹变。下表基于《东国正韵》二十三声母的体系编排，但将正音划掉在旁边写上了实际的常见读音：</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>全清</th>
      <th>次清</th>
      <th>全浊</th>
      <th>次浊</th>
      <th>全清</th>
      <th>全浊</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>唇音</strong></td>
      <td>彆 ㅂ /p/</td>
      <td>漂 <del>ㅍ /pʰ/</del> ㅂㅍ</td>
      <td>步 <del>ㅃ</del> ㅂ</td>
      <td>彌 ㅁ /m/</td>
      <td> </td>
      <td> </td>
    </tr>
    <tr>
      <td><strong>舌音</strong></td>
      <td>斗 ㄷ /t/</td>
      <td>呑 ㅌ /tʰ/</td>
      <td>覃 <del>ㄸ</del> ㄷ</td>
      <td>那 ㄴ /n/</td>
      <td> </td>
      <td> </td>
    </tr>
    <tr>
      <td><strong>齿音</strong></td>
      <td>即 ㅈ /tɕ/</td>
      <td>侵 ㅊ /tɕʰ/</td>
      <td>慈 <del>ㅉ</del> ㅈㅅ</td>
      <td> </td>
      <td>戍 ㅅ /s/</td>
      <td>邪 <del>ㅆ</del> ㅅ</td>
    </tr>
    <tr>
      <td><strong>牙音</strong></td>
      <td>君 ㄱ /k/</td>
      <td>快 <del>ㅋ /kʰ/</del> ㄱ</td>
      <td>虯 <del>ㄲ</del> ㄱ</td>
      <td>業 <del>ㆁ /ŋ/</del> ㅇ</td>
      <td> </td>
      <td> </td>
    </tr>
    <tr>
      <td><strong>喉音</strong></td>
      <td>挹 <del>ㆆ</del> ㅇ</td>
      <td> </td>
      <td> </td>
      <td>欲 ㅇ</td>
      <td>虛 ㅎ /h/</td>
      <td>洪 <del>ㆅ</del> ㅎ</td>
    </tr>
    <tr>
      <td><strong>半舌音</strong></td>
      <td> </td>
      <td> </td>
      <td> </td>
      <td>閭 ㄹ /r/</td>
      <td> </td>
      <td> </td>
    </tr>
    <tr>
      <td><strong>半齿音</strong></td>
      <td> </td>
      <td> </td>
      <td> </td>
      <td>穰 <del>ㅿ /z/</del> ㅇ</td>
      <td> </td>
      <td> </td>
    </tr>
  </tbody>
</table>

<p>朝鲜跟日本一样不细分唇音、舌音、齿音，不过有时候仍然需要通过三十六字母才能区分：</p>

<ul>
  <li>漂母对应的两个中古汉语声母中，原则上滂母读ㅍ、敷母读ㅂ；</li>
  <li>慈母对应的两个中古汉语声母中，原则上从母读ㅈ、床母读ㅅ。</li>
</ul>

<p>前面说到日语声母缺少送气对立，而朝鲜语声母则缺少清浊对立。《东国正韵》将全浊字对应并书的 ㅃ/ㄸ/ㅉ/ㄲ/ㅆ/ㆅ，但这些声母并不会出现在实际读音中，除了「[喫]{끽}」「[雙]{쌍}」「[氏]{씨}」等极个别字。这些并书的声母现在用来表示硬音（日本或称浓音、中国或称紧音），并不是浊音而是不送气清音。另外快母大部分混入了君母、小部分混入了虚母，所以ㅋ在实际朝鲜汉字音中也是不存在的，除了「[快]{쾌}」本身等极个别字。</p>

<p>上表为了简单起见只在旁边标注了常见读音，但朝鲜汉字音还有很多需要注意的情况：</p>

<ul>
  <li>端组四等字和知组三等字的声母会发生腭化，例如「[天地]{천지}」「[忠貞]{충정}」；</li>
  <li>送气不送气常常相混，例如帮母「[波]{파}」、滂母「[拍]{박}」、並母「[弊]{폐}」、非母「[廢]{폐}」、敷母「[豐]{풍}」、奉母「[乏]{핍}」、定母「[彈]{탄}」、澄母「[治]{치}」；</li>
  <li>ㄱ/ㅎ有时相混，例如见母「[革]{혁}」、溪母「[恢]{회}」、晓母「[喝]{갈}」、匣母「[暇]{가}」。</li>
</ul>

<p>韩国的汉字音拼写还需要遵循「头音法则」，即词首出现流音时会发生音变：</p>

<ul>
  <li>以ㄴ/ㄹ为声母、以ㅣ为介音的字变为零声母ㅇ，例如[李]{리}姓→[李]{이}姓、[梨花女大]{리화녀대}→[梨花女大]{이화여대}；</li>
  <li>以ㄹ为声母、没有ㅣ介音的字声母变为ㄴ，例如[盧]{로}姓→[盧]{노}姓、[勞動黨]{로동당}→[勞動黨]{노동당}。</li>
</ul>

<h2 id="汉语演变">汉语演变</h2>

<p>虽然已经有了两张中古汉语和日韩汉字音的对照表，但世上应该没几个人能精通每个汉字的中古音，所以我们想要通过现代汉语来寻找日韩汉字的发音规律，还需要了解历史上的汉语语音变化。从中古汉语切韵音演变到现代汉语普通话的过程中，还有一段近代官话时期。明朝成书的《韵略易通》是反映汉语近代音的重要资料，它用当时的所有声母写了一首早梅诗：</p>

<blockquote>
  <p>[東]{d}[風]{f}[破]{p}[早]{z}[梅]{m}，[向]{h}[暖]{n}[一]{∅}[枝]{zh}[開]{k}。[氷]{b}[雪]{s}[無]{v}[人]{r}[見]{g}，[春]{ch}[從]{c}[天]{t}[上]{sh}[來]{l}。</p>
</blockquote>

<p>经过数百年的演变，明初官话的二十声母系统相比宋人三十六字母已经简化了很多：</p>

<ol>
  <li>中古全浊声母演变成了对应的清声母；</li>
  <li>知组声母和照组声母合并为 zh/ch/sh；</li>
  <li>影母、喻母和疑母合并为零声母 ∅；</li>
  <li>非母、敷母和奉母合并为声母 f；</li>
  <li>泥母和娘母合并为声母 n。</li>
</ol>

<p>因为日韩本来就不太区分最后两项的声母，所以前三项是影响中日韩汉字音对应关系的主要因素。</p>

<p>观察早梅诗的声母可以发现，明初官话已经跟现在普通话相当接近，只是「[向]{h}」「[雪]{s}」「[無]{v}」「[見]{g}」四个字现在应该标成「[向]{x}」「[雪]{x}」「[無]{w}」「[見]{j}」才对。其中的「無」是微母字，它在明初大致读汉语拼音 f 的浊音 v，现在普通话里绝大部分字已经改读 w，即零声母的合口呼。剩下三个字则涉及尖团音的问题，即介音为 i/ü 的情况下 z/c/s 和 g/k/h 都改读了 j/q/x。</p>

<p>接下来四节重点介绍一下上述「浊音清化」「声母脱落」「知照合流」「尖团合流」四大汉语演变。</p>

<h3 id="其一浊音清化">其一：浊音清化</h3>

<p>前面我们提到过韩语没有清浊对立，其实汉语官话中也没有清浊对立，比如说「端」声母 d 的国际音标是不送气清音 /t/，而「透」声母 t 是送气清音 /tʰ/，现代汉语官话中已不存在浊音 /d/。中古汉语中的全浊字按照平仄分别归入了次清和全清，即「平送仄不送」，比如中古全浊仄声字「定」就归入了不送气清音。除了浊音清化，汉语官话的声调也发生了变化：</p>

<ul>
  <li>平声字按照清浊分别归入了阴平和阳平，即「平分阴阳」；</li>
  <li>上声字中全浊声母的改读去声，即「全浊上归去」，次浊和清上声字仍读上声；</li>
  <li>去声字仍读去声；</li>
  <li>入声字的归派依地区而不同，决定了官话内部分区：
    <ul>
      <li>《中原音韵》的归派很接近胶辽官话：全浊字大多归入阳平（全清），次浊字大多归入去声，清声母字大多归入上声，即「入派三声」；</li>
      <li>北京官话中浊声母字的归派同《中原音韵》，但清声母字可能归入任何一个声调；</li>
      <li>江淮官话则整体保留了入声。</li>
    </ul>
  </li>
</ul>

<p>如果我们逆转一下从官话倒推中古汉语的话，可以得到下面这张表：</p>

<table>
  <thead>
    <tr>
      <th>官话</th>
      <th>中古汉语</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>阴平全清</td>
      <td>平声全清、<del>入声全清①</del></td>
    </tr>
    <tr>
      <td>阴平次清</td>
      <td>平声次清、<del>入声次清①</del></td>
    </tr>
    <tr>
      <td>阴平次浊</td>
      <td> </td>
    </tr>
    <tr>
      <td>阳平全清</td>
      <td><del>入声全浊、入声全清②</del></td>
    </tr>
    <tr>
      <td>阳平次清</td>
      <td>平声全浊、<del>入声次清②</del></td>
    </tr>
    <tr>
      <td>阳平次浊</td>
      <td>平声次浊</td>
    </tr>
    <tr>
      <td>上声全清</td>
      <td>上声全清、<del>入声全清③</del></td>
    </tr>
    <tr>
      <td>上声次清</td>
      <td>上声次清、<del>入声次清③</del></td>
    </tr>
    <tr>
      <td>上声次浊</td>
      <td>上声次浊</td>
    </tr>
    <tr>
      <td>去声全清</td>
      <td>上声全浊、去声全浊、去声全清、<del>入声全清④</del></td>
    </tr>
    <tr>
      <td>去声次清</td>
      <td>去声次清、<del>入声次清④</del></td>
    </tr>
    <tr>
      <td>去声次浊</td>
      <td>去声次浊、<del>入声次浊</del></td>
    </tr>
  </tbody>
</table>

<p>表中列出了普通话四声理论上的中古来源，把清入声的①②④划掉就是《中原音韵》的归派方式，把所有入声全都划掉就是江淮官话的情况。本来中古汉语中是平声字最多（多到韵书会把平声分上下两卷），但经过平分阴阳、全浊上归去、入派三声的演变，现在普通话变成去声字最多了。通过这张表我们还可以发现官话音系中有空缺：次浊声母没有阴平调、全清声母没有阳平调（考虑到入声归派的话要局限于阳声韵）。虽然这两种空缺在《中原音韵》上有所体现，但实际上现代汉语中有很多不符合规律的读音，比如中古次浊字读阴平在普通话中屡见不鲜。</p>

<p>所以如何通过现代汉语倒推中古清浊呢？如果你的母语是吴语或是老湘语，那么恭喜你可以直接根据母语读音区分清浊；如果你的母语是粤语，虽然粤语全浊声母也清化了，但仍然可以通过声调区分：阴平、阴上、阴去、上下阴入（粤拼 1/2/3 调）为清音，阳平、阳上、阳去、阳入（粤拼 4/5/6 调）为浊音；如果不幸的你跟我一样母语是官话，就很难通过声调准确区分清浊了，但万幸能分辨入声字的话那还是有办法的（适用于江淮官话）：</p>

<ul>
  <li>阳平字在中古通常是浊声母；</li>
  <li>阴平、上声字在中古通常是清声母；</li>
  <li>去声、入声字若声母送气则中古通常是清声母，不送气则无法确定中古的清浊。</li>
</ul>

<p>话又说回来，中古汉语与日韩汉字音的清浊实际对应情况也是一团乱麻，但总体上的原则是：</p>

<ul>
  <li>在日语里，只有中古全浊字的吴音和次浊字的汉音是浊声母；</li>
  <li>在韩语里，主要是中古次清字（滂透彻清穿）对应送气声母（激音）。</li>
</ul>

<h3 id="其二声母脱落">其二：声母脱落</h3>

<p>普通话的零声母字（包括汉语拼音 y/w 开头的）基本对应中古的影、喻、疑、微四个声母：</p>

<ul>
  <li>影母在中古本来就是零声母，为了不与前字的韵尾发生连读所以通常拟音为喉塞音 /ʔ/；</li>
  <li>喻母可以细分为云母和以母：
    <ul>
      <li>云母通常认为在中古之前属于匣母（喻三归匣），不过其声母在中古后期已经脱落；</li>
      <li>以母有可能在中古之前属于定母（喻四归定），中古读音通常拟为近音 /j/；</li>
    </ul>
  </li>
  <li>疑母在中古是后鼻音 ng，于明初官话才开始脱落；</li>
  <li>微母在中古前期仍属于明母（古无轻唇音），历经中古前期 m、中古后期 ɱ、近代前期 v、近代后期 w，声母脱落成为了零声母的合口呼，除了「曼」等极个别字。</li>
</ul>

<p>这四种声母中只有影、喻母对应日韩两语的零声母，疑母对应韩语零声母但对应日语が行，微母对应韩语声母ㅁ和日语ま行或ば行。一个快速测试官话零声母字是不是日韩零声母的方法是，通过上节的声调法判断这个字在中古是不是清音，因为四种声母里只有影母是清音，所以理论上可以排除疑、微母的可能性，则其对应的日韩汉字音应该也是零声母。</p>

<p>如果你的母语是南方方言，那么应该有机会直接从普通话零声母字中分辨出疑、微母，比如吴语、闽语、客家话中疑母仍然保持独立。非官话方言都存在微母读如明母的现象，只是程度有所不同，其中闽南语和粤语这么读的比例在八成以上。</p>

<p>另外零声母中有且仅有一批读 er 的是日母字，很容易分辨，可以直接把它们的声母视为 r。</p>

<h3 id="其三知照合流">其三：知照合流</h3>

<p>日语和韩语把知组归入了端组、把照组归入了精组，而汉语官话走了完全不同的道路——知组和照组合并，而且读音不同于端组或精组。合并后的这组声母就是普通话中的翘舌音，拼音记作 zh/ch/sh。比如「张」是知母字而「章」是照母字，古人可能不用问弓长张还是立早章就能判断一个人姓张还是姓章，而现在的福建人往往也能通过方言分辨。同样能区分知照的是日本人，日语中张读「ちょう」而章读「しょう」；但韩语因为中古三等字（有 i 介音）发生腭化的缘故无法区分知组三等和照组，导致张章都读「장」。下表列出了知照组字的一些读音规律（标注的是汉语拼音而不是国际音标）：</p>

<table>
  <thead>
    <tr>
      <th>中古汉语</th>
      <th>泉州话</th>
      <th>普通话</th>
      <th>日语</th>
      <th>韩语</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>知组二等</td>
      <td>d·t</td>
      <td>zh·ch</td>
      <td>た・だ行</td>
      <td>ㄷ·ㅌ</td>
    </tr>
    <tr>
      <td>知组三等</td>
      <td>d·t</td>
      <td>zh·ch</td>
      <td>た・だ行</td>
      <td>ㅈ·ㅊ</td>
    </tr>
    <tr>
      <td>照组</td>
      <td>z·c·s·…</td>
      <td>zh·ch·sh</td>
      <td>さ・ざ行</td>
      <td>ㅈ·ㅊ·ㅅ</td>
    </tr>
  </tbody>
</table>

<p>在江淮官话和西南官话的很多地区，知照两组又继续跟精组合流，也就是所谓平翘舌音不分的现象。不过不区分精组和照组并不影响日韩汉字音的辨别，因为日韩本来就不分齿头音和正齿音。而在其他平翘对立的官话地区，也分三种对立模式：</p>

<ul>
  <li>济南型：精组平舌，知照两组翘舌，即普通话所属的类型；</li>
  <li>昌徐型：精组、知照组二等字平舌，知照组三等字翘舌；</li>
  <li>南京型：精组平舌，知照组三等字翘舌，剩下的有平有翘。</li>
</ul>

<p>总而言之，无论分不分平翘舌音，中国的大部分方言都不分知照；闽语是一个例外，从上面的泉州话读音可以看到知组读如端组，说明闽语的分化时间较早。</p>

<h3 id="其四尖团合流">其四：尖团合流</h3>

<p>尖团音的区别源于中古汉语中精清从心邪五母和见溪群晓匣五母的对立，这些字都是齐齿呼或撮口呼（即介音为 i/ü），其中声母为精清从心邪的称为尖音、声母为见溪群晓匣的称为团音。尖团合流的现象在清朝就已出现，当时成书的《圆音正考》便是为了纠正满人尖团不分的问题，后来民国时期制定的老国音亦遵循《中原音韵》以来的体系保留了尖团对立，然而最终新国音以及普通话采用了不发尖音的北京音，于是尖音合流进了团音，汉语拼音记作 j/q/x。下面的表格简单总结了一下尖团声母在粤语、吴语、官话、日语和韩语中的读音（标注的是汉语拼音而不是国际音标）：</p>

<table>
  <thead>
    <tr>
      <th>中古汉语</th>
      <th>广州话</th>
      <th>苏州话</th>
      <th>普通话</th>
      <th>日语</th>
      <th>韩语</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>精母</td>
      <td>z</td>
      <td>z</td>
      <td>j</td>
      <td>さ行</td>
      <td>ㅈ</td>
    </tr>
    <tr>
      <td>见母</td>
      <td>g</td>
      <td>j</td>
      <td>j</td>
      <td>か行</td>
      <td>ㄱ</td>
    </tr>
    <tr>
      <td>清母</td>
      <td>c</td>
      <td>c</td>
      <td>q</td>
      <td>さ行</td>
      <td>ㅊ</td>
    </tr>
    <tr>
      <td>溪母</td>
      <td>k</td>
      <td>q</td>
      <td>q</td>
      <td>か行</td>
      <td>ㄱ</td>
    </tr>
    <tr>
      <td>心母</td>
      <td>s</td>
      <td>s</td>
      <td>x</td>
      <td>さ行</td>
      <td>ㅅ</td>
    </tr>
    <tr>
      <td>晓母</td>
      <td>h</td>
      <td>x</td>
      <td>x</td>
      <td>か行</td>
      <td>ㅎ</td>
    </tr>
  </tbody>
</table>

<p>粤语中的团音没有发生腭化，所以仍跟中古汉语一样读作软腭音 g/k/h，而不是龈腭音 j/q/x。尖团音的一个典型例子是清北的校名：清华大学的「清」字为尖音，而北京大学的「京」字为团音。当时所采用的邮政拼音恰好区分尖团，汉语拼音的 j/q/x 尖音写作 ts/ts/s、团音写作 k/k/h，所以清是 tsing 而京是 king，跟在粤语中的读音相似。在日语和韩语中，这两个字也有明显区别，其中清读作「しょう／せい／しん」「청」，而京读作「きょう／けい／きん」「경」。</p>

<h3 id="总集篇普通话">总集篇：普通话</h3>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>塞音<br />(不送气)</th>
      <th>塞音<br />(送气)</th>
      <th>塞擦音<br />(不送气)</th>
      <th>塞擦音<br />(送气)</th>
      <th>擦音<br />(清)</th>
      <th>擦音<br />(浊)</th>
      <th>鼻音<br />(浊)</th>
      <th>边音<br />(浊)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>双唇音</strong></td>
      <td>b /p/</td>
      <td>p /pʰ/</td>
      <td> </td>
      <td> </td>
      <td> </td>
      <td> </td>
      <td>m /m/</td>
      <td> </td>
    </tr>
    <tr>
      <td>中古</td>
      <td>幫並</td>
      <td>滂並</td>
      <td> </td>
      <td> </td>
      <td> </td>
      <td> </td>
      <td>明</td>
      <td> </td>
    </tr>
    <tr>
      <td><strong>唇齿音</strong></td>
      <td> </td>
      <td> </td>
      <td> </td>
      <td> </td>
      <td>f /f/</td>
      <td> </td>
      <td> </td>
      <td> </td>
    </tr>
    <tr>
      <td>中古</td>
      <td> </td>
      <td> </td>
      <td> </td>
      <td> </td>
      <td>非敷奉</td>
      <td> </td>
      <td> </td>
      <td> </td>
    </tr>
    <tr>
      <td><strong>舌尖前音</strong></td>
      <td> </td>
      <td> </td>
      <td>z /ts/</td>
      <td>c /tsʰ/</td>
      <td>s /s/</td>
      <td> </td>
      <td> </td>
      <td> </td>
    </tr>
    <tr>
      <td>中古</td>
      <td> </td>
      <td> </td>
      <td>精從<br />照</td>
      <td>清從<br />穿</td>
      <td>心邪<br />審</td>
      <td> </td>
      <td> </td>
      <td> </td>
    </tr>
    <tr>
      <td><strong>舌尖中音</strong></td>
      <td>d /t/</td>
      <td>t /tʰ/</td>
      <td> </td>
      <td> </td>
      <td> </td>
      <td> </td>
      <td>n /n/</td>
      <td>l /l/</td>
    </tr>
    <tr>
      <td>中古</td>
      <td>端定</td>
      <td>透定</td>
      <td> </td>
      <td> </td>
      <td> </td>
      <td> </td>
      <td>泥娘</td>
      <td>來</td>
    </tr>
    <tr>
      <td><strong>舌尖后音</strong></td>
      <td> </td>
      <td> </td>
      <td>zh /tʂ/</td>
      <td>ch /tʂʰ/</td>
      <td>sh /ʂ/</td>
      <td>r /ʐ/</td>
      <td> </td>
      <td> </td>
    </tr>
    <tr>
      <td>中古</td>
      <td> </td>
      <td> </td>
      <td>知澄<br />照牀</td>
      <td>徹澄<br />穿牀禪</td>
      <td><br />牀審禪</td>
      <td>日</td>
      <td> </td>
      <td> </td>
    </tr>
    <tr>
      <td><strong>舌面音</strong></td>
      <td> </td>
      <td> </td>
      <td>j /tɕ/</td>
      <td>q /tɕʰ/</td>
      <td>x /ɕ/</td>
      <td> </td>
      <td> </td>
      <td> </td>
    </tr>
    <tr>
      <td>中古</td>
      <td> </td>
      <td> </td>
      <td>精從<br />見群</td>
      <td>清從<br />溪群</td>
      <td>心邪<br />曉匣</td>
      <td> </td>
      <td> </td>
      <td> </td>
    </tr>
    <tr>
      <td><strong>舌根音</strong></td>
      <td>g /k/</td>
      <td>k /kʰ/</td>
      <td> </td>
      <td> </td>
      <td>h /x/</td>
      <td> </td>
      <td> </td>
      <td> </td>
    </tr>
    <tr>
      <td>中古</td>
      <td>見群</td>
      <td>溪群</td>
      <td> </td>
      <td> </td>
      <td>曉匣</td>
      <td> </td>
      <td> </td>
      <td> </td>
    </tr>
    <tr>
      <td><strong>喉音</strong></td>
      <td>∅·y·w /ʔ/</td>
      <td> </td>
      <td> </td>
      <td> </td>
      <td> </td>
      <td> </td>
      <td> </td>
      <td> </td>
    </tr>
    <tr>
      <td>中古</td>
      <td>微疑影喻</td>
      <td> </td>
      <td> </td>
      <td> </td>
      <td> </td>
      <td> </td>
      <td> </td>
      <td> </td>
    </tr>
  </tbody>
</table>

<p>根据汉语声母的演变规律，上表列出了普通话声母与宋人三十六字母的对应关系。绝大多数规律前面都有介绍，令人感到意外的应该是中古照组字在普通话中的奇怪变化：</p>

<ul>
  <li>塞擦音床母有的变成擦音 sh，擦音禅母有的变成塞擦音 ch，这是因为床、禅母在历史上读浊塞擦音还是读浊擦音是一笔糊涂账，现在整体呈现平声字多数读塞擦音 ch、仄声字读擦音 sh 的规律，但也有很多例外。</li>
  <li>照组（基本上都是庄组）每个声母都有普通话读平舌音的，例如：
    <ul>
      <li>照母（庄母）：侧、责、邹、阻、诅；</li>
      <li>穿母（初母）：测、厕、册、策、篡；</li>
      <li>床母（崇母）：岑；</li>
      <li>审母（生母）：洒、色、涩、瑟、森、搜、缩、所；</li>
      <li>禅母（俟母）：俟。</li>
    </ul>
  </li>
</ul>

<p>这里的「色」字在北京话中还有白读 shǎi，而更常用的文读 sè 很可能是来源于南京官话。如果按照规律演变，这些字都应该像白读一样仍读翘舌音，比如「厕所」恐怕得叫 chìshǔ。除此之外，还有个别与零声母相关的例外字没有在上表中体现：</p>

<ul>
  <li>疑母：一部分字受到南方方言的影响现在不读零声母而读 n ，例如逆、凝、牛、虐等。</li>
  <li>云母：一部分字不读零声母而读如匣母，例如雄、熊读 xióng。</li>
  <li>以母：一部分普通话读 róng 的字属于不规则演变，例如容、融、荣（荣是混进来的云母字）；一部分读 ruì 的也是不规则演变，例如锐、睿。</li>
  <li>匣母：除了丸、完读零声母之外，还有肴、爻读 yáo，荧、萤读 yíng。</li>
</ul>

<p>综上所述，用普通话准确推测日韩汉字音的声母还是比较困难的，但如果能辅以一门汉语方言，譬如既分尖团又分知照还能分辨疑微两母的闽南语，倒推声母会简单很多。</p>

<h3 id="番外篇粤语">番外篇：粤语</h3>

<p>有人说粤语发音比官话更存古，诚然粤语对中古汉语的韵尾保留相当完整（详见后文韵母章节），但其声母跟官话一样经历了较大的变化，譬如浊音清化、非敷奉三母合流、知照精三组合流等等。粤语相比于普通话而言较为存古的地方在于区分尖团音、微母读如明母、疑母部分保留，但粤语对日母、溪母、晓母、匣母等的保留程度还不如普通话，更别提粤语介音的丢失和韵腹的巨变了。</p>

<p>下表列出了粤语各个声母的中古来源，为了简单起见发音部位标的是中古的五音，粤语拼写以香港语言学学会的粤拼方案为准：</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>塞音<br />(不送气)</th>
      <th>塞音<br />(送气)</th>
      <th>塞擦音<br />(不送气)</th>
      <th>塞擦音<br />(送气)</th>
      <th>擦音<br />(清)</th>
      <th>鼻音<br />(浊)</th>
      <th>近音<br />(浊)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>唇音</strong></td>
      <td>b /p/</td>
      <td>p /pʰ/</td>
      <td> </td>
      <td> </td>
      <td>f /f/</td>
      <td>m /m/</td>
      <td> </td>
    </tr>
    <tr>
      <td>中古</td>
      <td>幫並</td>
      <td>滂並</td>
      <td> </td>
      <td> </td>
      <td>非敷奉<br />溪曉匣</td>
      <td>明微</td>
      <td> </td>
    </tr>
    <tr>
      <td><strong>舌音</strong></td>
      <td>d /t/</td>
      <td>t /tʰ/</td>
      <td> </td>
      <td> </td>
      <td> </td>
      <td>n /n/</td>
      <td>l /l/</td>
    </tr>
    <tr>
      <td>中古</td>
      <td>端定</td>
      <td>透定</td>
      <td> </td>
      <td> </td>
      <td> </td>
      <td>泥娘</td>
      <td>來</td>
    </tr>
    <tr>
      <td><strong>齿音</strong></td>
      <td> </td>
      <td> </td>
      <td>z /ts/</td>
      <td>c /tsʰ/</td>
      <td>s /s/</td>
      <td> </td>
      <td> </td>
    </tr>
    <tr>
      <td>中古</td>
      <td> </td>
      <td> </td>
      <td>知澄<br />精從邪<br />照牀</td>
      <td>徹澄<br />清從邪<br />穿牀</td>
      <td><br />心邪<br />牀審禪</td>
      <td> </td>
      <td> </td>
    </tr>
    <tr>
      <td><strong>牙音</strong></td>
      <td>g /k/</td>
      <td>k /kʰ/</td>
      <td> </td>
      <td> </td>
      <td> </td>
      <td>ng /ŋ/</td>
      <td> </td>
    </tr>
    <tr>
      <td>中古</td>
      <td>見群</td>
      <td>溪群</td>
      <td> </td>
      <td> </td>
      <td> </td>
      <td>疑</td>
      <td> </td>
    </tr>
    <tr>
      <td><strong>牙音 (唇化)</strong></td>
      <td>gw /kʷ/</td>
      <td>kw /kʷʰ/</td>
      <td> </td>
      <td> </td>
      <td> </td>
      <td> </td>
      <td>w /w/</td>
    </tr>
    <tr>
      <td>中古</td>
      <td>見群</td>
      <td>溪群</td>
      <td> </td>
      <td> </td>
      <td> </td>
      <td> </td>
      <td>影喻匣</td>
    </tr>
    <tr>
      <td><strong>喉音</strong></td>
      <td>∅ /ʔ/</td>
      <td> </td>
      <td> </td>
      <td> </td>
      <td>h /h/</td>
      <td> </td>
      <td>j /j/</td>
    </tr>
    <tr>
      <td>中古</td>
      <td>影</td>
      <td> </td>
      <td> </td>
      <td> </td>
      <td>溪曉匣</td>
      <td> </td>
      <td>日疑<br />影喻匣</td>
    </tr>
  </tbody>
</table>

<p>可以注意到粤语比普通话少了卷舌音 zh/ch/sh/r 和龈腭音 j/q/x 两组声母，但多了对应中古疑母的 ng 声母，另外 h 声母的粤语发音部位更像英语 /h/ 而不是普通话 /x/。粤语的一大特点是缺少介音，仅存的合口介音只会出现在零声母或 g/k 后面，因此粤拼方案单列了 gw/kw 两个唇化声母以简化韵母系统，在懒音中连这两个 w 都会脱落。粤语有一些不同于普通话的声母演变值得一提：</p>

<ul>
  <li><strong>日母完全脱落</strong>：因为中古日母均为三等字，所以现在日母脱落为 j，例如「[二]{ji6}[日]{jat6}」。</li>
  <li><strong>疑母部分脱落</strong>：一二等字通常保留疑母，例如「[我]{ngo5}」；三四等字则通常并入日母，然后跟随日母一起脱落为 j，例如「[月]{jyut6}」；还有的字丢失了韵母，疑母元音化，例如「[五]{ng5}」。</li>
  <li><strong>溪母字擦音化</strong>：大量溪母字混入晓母，例如「[口]{hau2}[渴]{hot3}」「[客]{haak3}[气]{hei3}」。</li>
  <li><strong>晓母字轻唇化</strong>：大量晓母合口字（连带一些溪母合口字）混入非母，例如「[欢]{fun1}[快]{faai3}」。</li>
  <li><strong>匣母部分脱落</strong>：数量远多于普通话或日语，其中多数脱落为 w，例如「[和]{wo4}」「[惠]{wai6}」「[会]{wui6}[话]{waa6}」，少数脱落为 j，例如「[丸/完]{jyun4}」「[荧/萤]{jing4}」「[现]{jin6}[形]{jing4}」。</li>
  <li><strong>全浊声母清化</strong>：不同于普通话的平送仄不送，广州话基本遵循平上送、去入不送的原则，有些字还有全浊上归去的现象，改读去声时也不送气，例如定母上声字「淡」的白读 taam5 送气、文读 daam6 不送气。这些中古全浊字原则上都读阳声调，即粤拼中 4/5/6 调。</li>
</ul>

<h2 id="韵母">韵母</h2>

<p>讲了这么多关于声母的故事，接下来该讲韵母了。《切韵》的修订版《大宋重修广韵》共有二百零六韵之多，传统上将它们笼统地归类为十六摄，按照大致读音可以分列如下：</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>∅</th>
      <th>-i</th>
      <th>-u</th>
      <th>-m / -p</th>
      <th>-n / -t</th>
      <th>-ŋ / -k</th>
      <th>-uŋ / -uk</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>a</strong></td>
      <td>果・假</td>
      <td>蟹</td>
      <td>效</td>
      <td>咸</td>
      <td>山</td>
      <td>宕・梗</td>
      <td>江</td>
    </tr>
    <tr>
      <td><strong>ə</strong></td>
      <td>遇</td>
      <td>止</td>
      <td>流</td>
      <td>深</td>
      <td>臻</td>
      <td>曾</td>
      <td>通</td>
    </tr>
  </tbody>
</table>

<p>一摄包含多个韵目，一韵又可以对应多种现代中日韩读音，可以预见韵母对应情况将异常复杂：</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>普通话</th>
      <th>日语</th>
      <th>韩语</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>果摄</strong></td>
      <td>o·e·uo</td>
      <td>あ</td>
      <td>아·와</td>
    </tr>
    <tr>
      <td><strong>假摄</strong></td>
      <td>a·e·ia·ie·ua</td>
      <td>あ</td>
      <td>아·와</td>
    </tr>
    <tr>
      <td><strong>遇摄</strong></td>
      <td>u·ü</td>
      <td>お・よ・う・ゆ・ゆう</td>
      <td>오·어·여·우</td>
    </tr>
    <tr>
      <td><strong>蟹摄</strong></td>
      <td>i·ie·ua·ai·ei·uai·ui</td>
      <td>あい・えい</td>
      <td>애·예·위·왜</td>
    </tr>
    <tr>
      <td><strong>止摄</strong></td>
      <td>i·ei·ui·er</td>
      <td>い・うい</td>
      <td>의·이·아·위·유</td>
    </tr>
    <tr>
      <td><strong>效摄</strong></td>
      <td>ao·iao</td>
      <td>おう・よう</td>
      <td>오·요</td>
    </tr>
    <tr>
      <td><strong>流摄</strong></td>
      <td>u·ou·iu</td>
      <td>おう・ゆう</td>
      <td>우·유</td>
    </tr>
    <tr>
      <td><strong>咸摄</strong></td>
      <td>an·ian</td>
      <td>あん・えん</td>
      <td>암·엄·염</td>
    </tr>
    <tr>
      <td>入声</td>
      <td>a·e·ia·ie</td>
      <td>おう・よう</td>
      <td>압·업·엽</td>
    </tr>
    <tr>
      <td><strong>深摄</strong></td>
      <td>en·in</td>
      <td>いん</td>
      <td>음·임</td>
    </tr>
    <tr>
      <td>入声</td>
      <td>i</td>
      <td>ゆう</td>
      <td>읍·입</td>
    </tr>
    <tr>
      <td><strong>山摄</strong></td>
      <td>an·ian·uan·üan</td>
      <td>あん・えん</td>
      <td>안·언·연·완·원</td>
    </tr>
    <tr>
      <td>入声</td>
      <td>a·o·e·ia·ie·ua·uo·üe</td>
      <td>あつ・えつ</td>
      <td>알·얼·열·왈·월</td>
    </tr>
    <tr>
      <td><strong>臻摄</strong></td>
      <td>en·in·ün·un</td>
      <td>いん・うん・おん</td>
      <td>은·인·윤·운·온·안</td>
    </tr>
    <tr>
      <td>入声</td>
      <td>o·i·u·ü</td>
      <td>いつ・うつ・おつ</td>
      <td>을·일·율·울·올·얼</td>
    </tr>
    <tr>
      <td><strong>宕摄</strong></td>
      <td>ang·iang·uang</td>
      <td>おう・よう</td>
      <td>앙·왕</td>
    </tr>
    <tr>
      <td>入声</td>
      <td>o·e·uo·üe</td>
      <td>あく・やく</td>
      <td>악·왁</td>
    </tr>
    <tr>
      <td><strong>梗摄</strong></td>
      <td>ong·eng·ing·iong</td>
      <td>おう・えい</td>
      <td>앵·욍·영</td>
    </tr>
    <tr>
      <td>入声</td>
      <td>o·e·i·uo</td>
      <td>あく・えき</td>
      <td>액·왹·역</td>
    </tr>
    <tr>
      <td><strong>曾摄</strong></td>
      <td>eng·ing</td>
      <td>おう・よう</td>
      <td>응·잉</td>
    </tr>
    <tr>
      <td>入声</td>
      <td>e·i·ü</td>
      <td>おく・よく</td>
      <td>윽·억·익·욱·역·액</td>
    </tr>
    <tr>
      <td><strong>江摄</strong></td>
      <td>ang·iang·uang</td>
      <td>おう</td>
      <td>앙</td>
    </tr>
    <tr>
      <td>入声</td>
      <td>o·uo·üe</td>
      <td>あく</td>
      <td>악</td>
    </tr>
    <tr>
      <td><strong>通摄</strong></td>
      <td>ong·eng·iong</td>
      <td>おう・よう・ゆう</td>
      <td>옹·웅·용·융</td>
    </tr>
    <tr>
      <td>入声</td>
      <td>u·ü</td>
      <td>おく・うく・よく・ゆく・いく</td>
      <td>옥·욱·욕·육</td>
    </tr>
  </tbody>
</table>

<p>既然我们几乎无法推测完整的韵母，那么我们可以聚焦于规则性强的部分——韵尾。虽然现在普通话的韵尾只剩下前鼻音 -n 和后鼻音 -ng 了，但熟悉粤语的同学应该能多举出一个鼻音韵尾 -m 和三个塞音韵尾 -p / -t / -k。在传统音韵学中，带鼻音韵尾的韵母统称为阳声韵，带塞音韵尾的统称为入声韵，剩下纯元音的叫作阴声韵。因为 -m 和 -p、-n 和 -t、-ng 和 -k 发音部位相同，所以传统上会把它们两两归入同一摄，例如阳声韵的阳（yang）和入声韵的药（yak）均列于宕摄，同摄入声在上表中单列一行。</p>

<h3 id="阳声韵">阳声韵</h3>

<p>事不宜迟，我们先来看三个阳声韵的例子（广州话使用的是粤拼方案，苏州话标注的是国际音标）：</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>广州话</th>
      <th>苏州话</th>
      <th>普通话</th>
      <th>日语</th>
      <th>韩语</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>侵</td>
      <td>cam1</td>
      <td>/tsʰin/</td>
      <td>qīn</td>
      <td>しん</td>
      <td>침</td>
    </tr>
    <tr>
      <td>親</td>
      <td>can1</td>
      <td>/tsʰin/</td>
      <td>qīn</td>
      <td>しん</td>
      <td>친</td>
    </tr>
    <tr>
      <td>清</td>
      <td>cing1</td>
      <td>/tsʰin/</td>
      <td>qīng</td>
      <td>せい</td>
      <td>청</td>
    </tr>
  </tbody>
</table>

<p>可以看到韩语和粤语忠实地保留了三种不同的鼻音韵尾，而日语和普通话不区分 -m 和 -n，在吴语中这三种韵尾有混淆甚至脱落的现象，正所谓「吴人不辨清、亲、侵三韵」。稍加总结可以归纳出以下规律：</p>

<table>
  <thead>
    <tr>
      <th>中古汉语</th>
      <th>广州话</th>
      <th>苏州话</th>
      <th>普通话</th>
      <th>日语</th>
      <th>韩语</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>-m</td>
      <td>-m</td>
      <td>混淆／脱落</td>
      <td>-n</td>
      <td>-ん</td>
      <td>-ㅁ</td>
    </tr>
    <tr>
      <td>-n</td>
      <td>-n</td>
      <td>混淆／脱落</td>
      <td>-n</td>
      <td>-ん</td>
      <td>-ㄴ</td>
    </tr>
    <tr>
      <td>-ng</td>
      <td>-ng</td>
      <td>混淆／脱落</td>
      <td>-ng</td>
      <td>长元音</td>
      <td>-ㅇ</td>
    </tr>
  </tbody>
</table>

<p>中古汉语 -ng 韵尾在日语中也不一定都是长元音，比如「夢」的吴音是短元音「む」，说明吴音不如汉音那样有系统性；而唐音常常会把 -ng 对应到 -ん，比如前面已经提到过的「南[京]{きん}」，这应该是受到了中国南方口音的影响。</p>

<h3 id="入声韵">入声韵</h3>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>广州话</th>
      <th>苏州话</th>
      <th>普通话</th>
      <th>日语</th>
      <th>韩语</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>集合</td>
      <td>zaap6 hap6</td>
      <td>/ziəʔ ɦəʔ/</td>
      <td>jí hé</td>
      <td>しゅうごう</td>
      <td>집합</td>
    </tr>
    <tr>
      <td>出發</td>
      <td>ceot1 faat3</td>
      <td>/tsʰəʔ faʔ/</td>
      <td>chū fā</td>
      <td>しゅっぱつ</td>
      <td>출발</td>
    </tr>
    <tr>
      <td>一日</td>
      <td>jat1 jat6</td>
      <td>/iəʔ zəʔ/</td>
      <td>yī rì</td>
      <td>いちにち</td>
      <td>일일</td>
    </tr>
    <tr>
      <td>約束</td>
      <td>joek3 cuk1</td>
      <td>/iaʔ soʔ/</td>
      <td>yuē shù</td>
      <td>やくそく</td>
      <td>약속</td>
    </tr>
    <tr>
      <td>赤壁</td>
      <td>cik3 bik1</td>
      <td>/tsʰəʔ piəʔ/</td>
      <td>chì bì</td>
      <td>せきへき</td>
      <td>적벽</td>
    </tr>
  </tbody>
</table>

<p>这五个入声韵的例子非常具有代表性，能够完整体现日语入声字的特点。因为日语本身没有韵尾这个概念，所以入声字会多出一个音来模拟原本韵尾的辅音，比如「出發」「一日」中的「つ」「ち」、「約束」「赤壁」中的「く」「き」。其中「出」单独念「しゅつ」，后接「發」时发生促音便，恰好形成了一个类似汉语入声的发音。而「集合」的情况比较复杂，其历史假名遣写作「しふがふ」，「ふ」最早读作 pu，「は行転呼」之后改读 u，前面的元音也一起发生变化，最终现代假名遣写作「しゅうごう」。</p>

<p>跟阳声韵的情况类似，韩语和粤语保留了三种塞音韵尾的对立，不过以 -t 为韵尾的入声字在韩语中不是 -ㄷ 而是 -ㄹ。吴语虽然保留了入声但将三种韵尾合并成了喉塞韵尾 -ʔ，而普通话的入声已不复存在。入声韵的规律可以归纳如下：</p>

<table>
  <thead>
    <tr>
      <th>中古汉语</th>
      <th>广州话</th>
      <th>苏州话</th>
      <th>普通话</th>
      <th>日语</th>
      <th>韩语</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>-p</td>
      <td>-p</td>
      <td>-ʔ</td>
      <td>脱落</td>
      <td>长元音</td>
      <td>-ㅂ</td>
    </tr>
    <tr>
      <td>-t</td>
      <td>-t</td>
      <td>-ʔ</td>
      <td>脱落</td>
      <td>-つ／-ち</td>
      <td>-ㄹ</td>
    </tr>
    <tr>
      <td>-k</td>
      <td>-k</td>
      <td>-ʔ</td>
      <td>脱落</td>
      <td>-く／-き</td>
      <td>-ㄱ</td>
    </tr>
  </tbody>
</table>

<p>一般来说 -t 韵尾对应的吴音是 -ち、汉音是 -つ；而 -k 韵尾大多都对应 -く，除了部分吴音 -いき、汉音 -えき。另外，-p 韵尾也不总对应长元音，也会变成惯用音 -つ ，例如「[圧]{あつ}」「[接]{せつ}」「[立]{りつ}」等。</p>

<p>有人说现代汉语中与韩语韵尾最接近的方言应该是粤语，因为两者都很接近中古汉语。我恰恰是在香港学的韩语，我就明显感觉到香港同学记韵尾要比我快、比我准。我的母语江淮官话跟吴语一样，保留入声但既不分 -p / -t / -k 也不分 -m / -n / -ng，我在记韩语汉字音的时候真是吃尽了苦头。</p>

<h3 id="番外篇重纽">番外篇：重纽</h3>

<p>看上去整理这些眼花缭乱的日韩译音没有什么实际意义，但日韩译音为<a href="https://byvoid.com/zht/blog/kuangx-yonh-dryung-nriux/">重纽</a>这个学术难题提供了新的线索。简而言之，重纽就是通过系联法确定的同音字却被《切韵》分出了两组不同的小韵；这个现象体现在后世的韵图上就是假四等，即理论上同音的三等字却被分列在了三等和四等的位置。晚清学者往往认为这两组字没什么区别，忽略了这个奇怪的现象，但实际上这两组字的日韩译音有可能不同。</p>

<p>例如「一」「乙」都是影母质韵开口三等入声字，但是它们被《切韵》分在了不同的小韵，被韵图分别列在四等和三等。「一」被称为质韵重纽A类，日语吴音读「いち」，韩语读「일」；「乙」被称为质韵重纽B类，日语吴音读「おつ」，韩语读「을」。日韩译音的不同体现了当时重纽字读音的细微差异，但具体区别在何需要上溯上古汉语有待进一步研究。</p>

<h2 id="参考资料">参考资料</h2>

<p>本文所采的中古声母拟音以及中古韵摄的日韩译音均参考《中国传统音韵学》课件，中古声母的<a href="https://ja.wikipedia.org/wiki/音読み">日</a><a href="https://ko.wikipedia.org/wiki/한국_한자음">韩</a>译音则主要参考维基百科，例证中汉字的音韵地位和方言读音参考了<a href="https://zi.tools">字统网</a>、<a href="https://ytenx.org">韵典网</a>、<a href="https://mcpdict.sourceforge.io">汉字音典</a>、<a href="https://humanum.arts.cuhk.edu.hk/Lexis/lexi-can/">粤语审音配词字库</a>。除此之外，本文还参考了许多互联网资料，族繁不及备载，难免挂一漏万：</p>

<ul>
  <li><a href="http://www.for.aichi-pu.ac.jp/museum/pdf/oningaku.pdf">音韻学入門～中古音篇～ — 中村雅之</a></li>
  <li><a href="https://space.bilibili.com/3957493/channel/collectiondetail?sid=1174004">中古漢語語音敎程 — polyhedron古韻</a></li>
  <li><a href="https://zhuanlan.zhihu.com/p/106570805">如何利用汉语读音推断日语汉字音读 — shuyu cheng</a></li>
  <li><a href="https://www.zhihu.com/lives/937696958148743168">一小时入门韩语汉字音 — 王赟 Maigo</a></li>
  <li><a href="https://www.zhihu.com/question/27397379/answer/36488907">声母、韵母、声调对照表 —  王赟 Maigo</a></li>
  <li><a href="https://www.ilc.cuhk.edu.hk/workshop/Chinese/Cantonese/Romanization/">粵語拼音速遞 — 香港中文大學自學中心</a></li>
  <li><a href="https://gongjyuhok.hk/articles/1351">粵音學初基 — 港語學</a></li>
</ul>]]></content><author><name>孙耀珠</name></author><category term="杂谈" /><summary type="html"><![CDATA[最近学习了上海外国语大学朱磊老师的《中国传统音韵学》，拿到了成绩优秀的认证证书，课程内容让我受益匪浅。传统上韵书是为文人写诗押韵作参考用的，不过我更感兴趣的是韵书所揭示的中古汉语读音哺育了不少域外方音，比如日语和韩语的汉字音。「久别重逢的乡音」便是摘自课程最后一讲的标题，而我想反过来从普通话与日韩汉字音的对应关系出发，印证一下中古汉语的本源地位，顺便介绍一下从中古汉语切韵音到现代汉语普通话发生了哪些重大变化。这既是我的学习笔记，也算是之前《跨越国境的汉字》的续篇了。]]></summary></entry><entry><title type="html">跟住疫情去游学</title><link href="https://blog.yzsun.me/post-covid/" rel="alternate" type="text/html" title="跟住疫情去游学" /><published>2023-01-25T00:00:00+00:00</published><updated>2023-01-25T00:00:00+00:00</updated><id>https://blog.yzsun.me/post-covid</id><content type="html" xml:base="https://blog.yzsun.me/post-covid/"><![CDATA[<p>2019 年我初到香港，当时反送中运动愈演愈烈，尤其是亲历了十一之后港铁瘫痪、双十一前后港大校园被占领，我以为那已经是最糟糕的时代了。没想到 2020 年结束元旦假期从冲绳回香港，便听见机场广播呼吁旅客警惕武汉出现的不明肺炎，从此新冠疫情大幕拉起，我再也没有机会出过国。香港一直被定位为「<a href="https://www.brandhk.gov.hk/zh-cn/">亚洲国际都会</a>」，但在这与世隔绝的两年多里，这个名号怕是要拱手让人了。不过随着两岸三地先后放弃清零政策，现在终于有了与全世界重新连接的感觉，我去年也有幸踏着疫情的尾巴去美国和新西兰开了会，在这里浅浅纪念一下我的学术首航暨首次跨洲旅行。</p>

<!--more-->

<h2 id="pre-oplss22--旧金山湾区-">Pre-OPLSS’22 @ 旧金山湾区 🇺🇸</h2>

<p>OPLSS 是一年一度在俄勒冈大学举办的编程语言暑校，虽然名字叫暑校，但主办方叮嘱我们这实际上是一个学术会议，别叫美国边检找我们要 F-1 签证。说到签证，我办 B-1 签证的经历也算是一波三折：首先是 2022 年初香港爆发了第五波疫情导致美领馆暂停了签证预约，虽然在我收到 OPLSS 申请结果前不久美领馆恢复了服务，但上网预约的时候才发现最近的签证面谈已经排到 OPLSS 开幕之后了……既然都申请到了能覆盖食宿的奖学金我们也不想乖乖放弃，就在已经开始研究怎么去新加坡面签的时候，我刷新美签网站恰好发现美领馆放出了更多面签名额，大概是正好赶上了他们从有限服务转为正常服务，于是我立刻预约了最早的 5 月 2 日。那天上午本来应该八点开始，结果在里面干等了半个小时签证官们才姗姗来迟，轮到我签证官问了一系列常规问题便发给我一张黄纸，上面写着根据美国移民和国籍法 221(g) 条款我的签证申请未能通过，也就是俗称的「check」。跟黄纸一起给我的还有一份 DS-5535 表格，叫我填完跟其他材料一起交到另一个窗口去，我一看这表我要填挺久而且我也没准备在学证明和行程单，悲从中来，就直接回家了。正坐着叮叮车呢，突然美领馆一个电话打过来问我去哪儿了，我说我先回家准备材料过会儿再过来，她一时语塞然后让我别来了直接邮件联系吧，我就发邮件补交了材料。第二天另外两位 OPLSS 同伴去面签，结果没问几个问题就当场批了十年签，这下我更难过了。不过好在我也没伤心太久，5 月 19 日美领馆就通知我签证通过了，远远早于我的预期，我悬着的心终于可以放下了。拿到护照一看，不出所料，是一年签。</p>

<p>因为机票买得有点晚，而且香港当时还没放弃清零政策，飞到美国西海岸并没有什么实惠的选择。想着可以先去太平洋另一边的湾区看看，我最终买了国泰航空直飞旧金山的航班，单程就花了一万港元。同样是直飞十一二个小时，后来去新西兰往返我也才花了一万港元，由于清零政策执行期间港大不鼓励出境也不给报销，摸摸钱包还有点心疼的。</p>

<p>时间快进到 6 月 15 日凌晨，我坐上了前往美国的飞机，在穿越了国际日期变更线之后，我在 6 月 14 日夜晚抵达了旧金山机场。如果要问我对美国的初印象，我一定会回忆起那破破旧旧的 BART 以及列车上神神叨叨向我要钱的流浪汉。在去旧金山市区的路上眺望窗外，湾区的景色确实与香港大相径庭：远处是矮矮的山丘，从山上延伸到山下，大大小小的联排别墅平铺在眼前。</p>

<p><img src="../images/post-covid-00.jpg" alt="" /></p>

<p>我在旧金山湾区的四天行程是那种一眼看出是第一次来的类型：第一天坐地面缆车去九曲花街，再走到渔人码头坐船欣赏金门大桥（海上风好大好冷），最后去唐人街尝了中餐馆（遍地在说粤语）；第二天坐 MUNI 轻轨（怎么别人都没付钱？）去金门公园参观加州科学院（其实是个博物馆），顺便去日本茶园（据说是美国幸运饼干的起源）喝下午茶；第三天坐加州列车去帕罗奥图游览斯坦福校园；第四天浏览电脑历史博物馆（挺无聊的）和苹果新总部（在外围绕了一圈也没见哪儿能进园区）。</p>

<p><img src="../images/post-covid-01.jpg" alt="" /></p>

<p>因为日程安排合不上，我的两位 OPLSS 同伴都还在香港，所以我联系了大学同学后两天借住在他家（的沙发上）。不过他在暑期实习有点忙，因此计划中还是我独自旅行，然而一个人玩到底还是有些寂寞，于是我在第三天去斯坦福的路上下定决心拨通了高中同学的电话。本来这种事情应该更早联系才对，但我迟迟不好意思麻烦更多同学，现在突然闯进别人的生活反而事与愿违了。没想到奇迹发生了，那天是工作日他竟然正好不上班，令人感动的是他还开车过来陪我逛了他的母校斯坦福。回去的路上，乘高中同学的车驰骋在一马平川的宽阔公路上，晴空万里之下远处的圣克鲁斯山脉依稀可辨，恍惚间自己也领会了同学们的美国梦。不论是高中同学租的联排别墅，还是大学同学租的单人公寓，都让我亲眼见识了硅谷人的奢华生活。</p>

<p>虽然早就听说美国大城市的治安臭名昭著，比如旧金山砸车窗盗窃相当猖獗，但这几天只在游客区和硅谷近郊活动，旅游体验还是不错的。旧金山的公共交通差强人意，而出了市区没车确实寸步难行，我全得仰赖同学接送。据说有些富人区甚至抵制公共交通，怕把流浪汉送到自己家门口……第一次来美国，还是独自出行，能如此顺利真得多谢拨冗接待我的同学们！</p>

<h2 id="oplss22--尤金-">OPLSS’22 @ 尤金 🇺🇸</h2>

<p>从圣何塞飞尤金，西南航空竟然是自由席，机上座位先到先得，真是一上来就给了我一点小小的美国震撼。因为航司不认可港科大同伴的智克威得疫苗，所以他临时改了航班，在打车去俄勒冈大学的路上，我们三位来自香港的手足才终于会合。入住宿舍之后，我们遇见了三位交大出身赴美留学的中国同胞，后来还有一位竺院院友加盟，接下来的两周里我们几乎都是一起行动，结下了深厚的友谊。</p>

<p>OPLSS 经历了 2020 年停办、2021 年改为线上之后，终于在 2022 年回到了俄勒冈小城尤金。跟旧金山和洛杉矶相比，尤金十分宁静安全，除了爆表的花粉量放倒了好几位参会者。好巧不巧，这年正好撞上推迟了一年、首次在美国举行的田径世锦赛，而我们的宿舍就在比赛场馆海沃德田径场旁边，暑校结束再过两周就是比赛日。除了声势浩大的世锦赛，OPLSS 第一周后半还和美国室外田径锦标赛重合了，不过这个比赛规模相对不大，对我们没有什么影响，除了食堂更挤了一些。在校园里还不时能见到俄勒冈大学的吉祥物，长着特别像唐老鸭，上网一查发现还真拿到了迪士尼公司的正式授权。为了筹备世锦赛，暑校的第二周校园开始封路了，我们被迫从法学院宽敞明亮的大教室（如图）换到了数学系拥挤闷热的小教室，不知道是不是还有长时间佩戴 N95 口罩的原因，我第二周上课昏昏欲睡。</p>

<p><img src="../images/post-covid-02.jpg" alt="" /></p>

<p>言归正传，OPLSS 的讲师阵容可谓众星云集，尽管至少有一半的内容我没听懂，但还是收获颇丰：Thorsten Altenkirch 用 Agda 讲解 [依值类型]{dependent types}，Jeremy Gibbons 探寻 <a href="https://arxiv.org/pdf/2202.13633.pdf">[神奇态射在哪里]{Fantastic Morphisms and Where to Find Them}</a>，Pierre-Louis Curien 介绍 [博弈语义]{game semantics}，Silvia Ghilezan 介绍 λ 演算相关的基础知识，Paul Downen 介绍 [抽象机语义]{abstract machine semantics} 和 [经典逻辑的可实现性]{classical realizability}，Adam Chlipala 分享 Coq 实战经验，Steve Zdancewic 用软件基础的风格介绍 <a href="https://github.com/DeepSpec/InteractionTrees">[交互树]{Interaction Trees}</a>，Sam Lindley 为 [代数效应]{algebraic effects} 传道，Stephanie Balzer 讲 [会话类型]{session types} 入门，Robert Harper 讲 [逻辑关系]{logical relations} 入门。而我最推荐的两门课要数 Frank Pfenning 的证明论入门和 Stephanie Weirich 的 <a href="https://github.com/sweirich/pi-forall">[Π∀]{pi-forall}</a> 语言实现：证明论入门涵盖了逻辑和谐（也就是局部可靠性和完备性）、柯里–霍华德同构、自然演绎和相继式演算的对比、切消定理、线性逻辑等等，属实是帮我恶补了数理逻辑的知识；Π∀ 则是一门小巧的依值类型语言，用 Haskell 实现双向类型检查、Π 类型和 ∀ 类型（区别是运行时是否擦除参数）、依值的模式匹配、相等命题等等，这门课跟我们组的研究最接近所以倍感亲切。</p>

<h2 id="post-oplss22--洛杉矶-">Post-OPLSS’22 @ 洛杉矶 🇺🇸</h2>

<p>OPLSS 落幕之后，港大同伴有事先行回港了，而我和港科大同伴一起飞去洛杉矶开启了快乐的南加州之旅。<del>因为也玩不出什么新花样，就还简单来个流水账加照片拼图吧：</del>第一天我们去洛杉矶会展中心参加了号称北美最大的漫展 Anime Expo，然后坐公交车去市中心参观了小东京；第二天则是去圣莫尼卡海滩和比弗利山庄闲逛，感受美国独立日的气氛；第三天一整天畅玩好莱坞环球影城。</p>

<p><img src="../images/post-covid-03.jpg" alt="" /></p>

<p>我们回港的航班坐的是大韩航空，不仅餐食是韩国传统的拌饭，而且起飞前播的安全宣传片是韩国男团 SuperM 一边唱跳 K-pop 一边讲安全须知，着实令人耳目一新。快乐的时光是短暂的，回到香港一落地等待着我们的就是七天的强制酒店隔离。回忆起这二十多天的美国之行，我最怀念的是在尤金最后一晚聚会 Texas Roadhouse 餐前的免费面包，好想再尝一次！</p>

<h2 id="splash22--奥克兰-">SPLASH’22 @ 奥克兰 🇳🇿</h2>

<p>一转眼到了年底，这届 SPLASH 在新西兰第一大城市奥克兰举办，是有史以来首次来到亚太地区。因为囊括了 OOPSLA、APLAS 以及其他并设的大大小小的会议，SPLASH 同时征用了八间会议室，持续一周时间。港府在九月已经取消了入境人员的强制隔离，香港这里大家都选择了飞去奥克兰参会，其中我们实验室去了四位，港科大也去了四位；或者按照参会目的来分，四位发表 OOPSLA 论文，两位发表 APLAS 论文，两位参加 ACM 学生科研竞赛。这次也是我人生第一次在线下面对观众发表论文，说实话蛮紧张的。</p>

<p>比起 OPLSS 上结识新伙伴的新鲜感，SPLASH 上更多的是见到久仰大名的学术大咖的激动，比如十四亿中国人的 PL 引路人<a href="https://www.zhihu.com/people/marisa.moe">雾雨魔理沙</a>、当年<a href="https://fp19.paulz.me">清华 FP 课</a>的灵魂人物<a href="https://paulz.me">朱俸民</a>和<a href="https://wcphkust.github.io">王程鹏</a>、还有<a href="https://prg.is.titech.ac.jp/people/cong/">悠悠老师</a>和<a href="https://xnning.github.io">宁宁学姐</a>。学术会议作为一种社交活动，当然绝不仅仅是发表论文，拓宽学术圈的人脉也是重要的一环。不过因为老布没来，不太敢随便向大教授们搭话，我们多半是跟华人学生聊天，比如来自澳洲和星洲的同行们。比较有意思的是我在宿舍食堂遇见了来自东工大的日本同学们，五年前我在他们实验室交换留学过，虽然那时候他们还不在，但大概也算是我的同门师弟了。后来我们在和港科大的🦁️老师一起聚餐时（如图）还认识了来自新国大的李曼努老师，他是奥地利人但会讲汉语，不仅在厦门大学读了中国哲学硕士，还在杭州阿里云实习过，让我感到十分惊奇。奥克兰市中心的皇后街两侧遍地都是亚洲餐馆，说街上半数是亚洲人也毫不夸张；不过即使在天空塔附近也有不少店面空铺招租，疫情之下的不景气可见一斑。</p>

<p><img src="../images/post-covid-04.jpg" alt="" /></p>

<p>为了免除近千美元的会议注册费（这次还额外免了住宿费），我申请当了学生志愿者，但没想到志愿者会忙到几乎每天都有活要干。我们这次每人要负责五段会议（每段一个半小时）、一次午餐、半天的注册台接待以及一天待机随叫随到（结果我第一天一大早被叫去了注册台 🤦），虽然错过了几篇想听的论文，但接待了来自天南海北的学术同行们，还与 OPLSS 上就见过的来自麦吉尔的 Breandan 重逢了。此外也很推荐刚刚开始或者即将攻读 PL 研究生、对自己未来生活感到迷茫的同学参加大会并设的 PLMW，这个研讨会的定番是美国东北大学的 Amal Ahmed 教授教大家如何优雅地度过自己的博士生涯，这届还有嘉宾座谈会以及分小组轮流向教授提问的车轮战环节。</p>

<p>因为有志愿者任务我错过了霍比屯的组队游，最后只去了奥克兰动物园和水族馆。既然来了新西兰，最大的愿望当然是看看国鸟鹬鸵（几维鸟），它们在动物园有一栋专门的暗室，只有在固定的喂食时间才能一睹真容。可惜的是我当时在现场没拍到照片，于是我尝试用 OpenAI 的 DALL·E 合成了一张（如图）。虽然细节上有好多破绽，以及莫名其妙出现了猕猴桃，但乍一看还真像回事儿。</p>

<p><img src="../images/post-covid-05.jpg" alt="" /></p>

<p>如今社交逐步复常但疫情仍在继续，一周的会议跟生存游戏一样，我们眼睁睁地看着香港一起来的同学接二连三地倒下了，平时一起吃饭的小伙伴们越来越少。为了预防中招，SPLASH 开幕半个月前我就拉着室友打了复必泰加强针，可喜可贺的是我们双双挺过了会议的一周。然而在回程的飞机上，我左右邻座咳嗽声鼻涕声不断，更糟糕的是新西兰航空不要求佩戴口罩，于是我就这样和他们密切接触了十一个小时。我的落地核酸是阴性，然而两天后的强制核酸变成了阳性，我也赶在 2022 年结束之前体验了一把竹篙湾隔离之旅，香港队八个人最后的战绩是六阳二阴。</p>]]></content><author><name>孙耀珠</name></author><category term="游记" /><summary type="html"><![CDATA[2019 年我初到香港，当时反送中运动愈演愈烈，尤其是亲历了十一之后港铁瘫痪、双十一前后港大校园被占领，我以为那已经是最糟糕的时代了。没想到 2020 年结束元旦假期从冲绳回香港，便听见机场广播呼吁旅客警惕武汉出现的不明肺炎，从此新冠疫情大幕拉起，我再也没有机会出过国。香港一直被定位为「亚洲国际都会」，但在这与世隔绝的两年多里，这个名号怕是要拱手让人了。不过随着两岸三地先后放弃清零政策，现在终于有了与全世界重新连接的感觉，我去年也有幸踏着疫情的尾巴去美国和新西兰开了会，在这里浅浅纪念一下我的学术首航暨首次跨洲旅行。]]></summary></entry><entry><title type="html">多态的多态</title><link href="https://blog.yzsun.me/polymorphic-polymorphism/" rel="alternate" type="text/html" title="多态的多态" /><published>2021-12-31T00:00:00+00:00</published><updated>2021-12-31T00:00:00+00:00</updated><id>https://blog.yzsun.me/polymorphic-polymorphism</id><content type="html" xml:base="https://blog.yzsun.me/polymorphic-polymorphism/"><![CDATA[<p>尤记得本科的面向对象编程课有一道经典例题：C++ 的多态性体现在何处？标准答案着眼于 C++ 的虚函数解释了动态派发的机制。自那以后的很长一段时间里，我对多态的认识就固化在了子类型多态上；直到博士期间开始搞编程语言的研究，才发现学术界对多态的定义远不局限于此。</p>

<p>英语里有个单词叫做 <a href="https://en.wikipedia.org/wiki/Autological_word">autological</a>，是说一个词可以形容其本身，比方说「名词」本身就是个名词，「阳平」这两个字的声调本身就是阳平，而「多态」本身也很多态。编程语言中最常见的多态有四种：<strong>特设多态</strong>、<strong>参数多态</strong>、<strong>子类型多态</strong>和<strong>行多态</strong>。通俗来讲，只要是为不同类型的数据或操作提供了相同的名字就可以叫多态。</p>

<p>本文只是一篇科普性质的文章，所以基本上只以实际编程语言为例，不会用任何形式化的语言来描述这些类型系统。如果希望深入学习相关的理论知识，建议阅读 <a href="https://www.cis.upenn.edu/~bcpierce/tapl/">TAPL</a> 或者 <a href="https://www.cs.cmu.edu/~rwh/pfpl/">PFPL</a>；Giuseppe Castagna 在韩国 <a href="https://sigpl.or.kr/school/2019s/">SIGPL Summer School 2019</a> 的特邀讲座也很值得一看。</p>

<!--more-->

<ul id="markdown-toc">
  <li><a href="#特设多态" id="markdown-toc-特设多态">特设多态</a>    <ul>
      <li><a href="#类型类" id="markdown-toc-类型类">类型类</a></li>
    </ul>
  </li>
  <li><a href="#参数多态" id="markdown-toc-参数多态">参数多态</a>    <ul>
      <li><a href="#hindleymilner" id="markdown-toc-hindleymilner">Hindley–Milner</a></li>
      <li><a href="#值限制" id="markdown-toc-值限制">值限制</a></li>
      <li><a href="#rank-n" id="markdown-toc-rank-n">Rank-N</a></li>
      <li><a href="#非直谓性" id="markdown-toc-非直谓性">非直谓性</a></li>
      <li><a href="#实现" id="markdown-toc-实现">实现</a></li>
    </ul>
  </li>
  <li><a href="#子类型多态" id="markdown-toc-子类型多态">子类型多态</a>    <ul>
      <li><a href="#动态实现" id="markdown-toc-动态实现">动态实现</a></li>
      <li><a href="#静态实现" id="markdown-toc-静态实现">静态实现</a></li>
    </ul>
  </li>
  <li><a href="#行多态" id="markdown-toc-行多态">行多态</a></li>
  <li><a href="#结语" id="markdown-toc-结语">结语</a></li>
</ul>

<h2 id="特设多态">特设多态</h2>

<p>「[特设多态]{ad-hoc polymorphism}」其实就是我们常说的「[重载]{overload}」，日语更通俗地叫它「[多重定義]{オーバーロード}」。称其为特设是因为这种多态并不像全称量化一样适用于所有类型，而是手动为某些特定类型提供不同的实现。</p>

<p>举例来说，C 语言不支持函数重载，于是绝对值函数搞出了 <code class="language-plaintext highlighter-rouge">abs</code>（整数）、<code class="language-plaintext highlighter-rouge">fabs</code>（浮点数）等不同的名字。多年后，支持函数重载的 C++ 则为同一个名字 <code class="language-plaintext highlighter-rouge">abs</code> 提供了各种参数类型的版本：</p>

<div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">int</span>         <span class="nf">abs</span><span class="p">(</span><span class="kt">int</span>         <span class="n">x</span><span class="p">);</span>  <span class="c1">// __Z3absi</span>
<span class="kt">long</span>        <span class="nf">abs</span><span class="p">(</span><span class="kt">long</span>        <span class="n">x</span><span class="p">);</span>  <span class="c1">// __Z3absl</span>
<span class="kt">long</span> <span class="kt">long</span>   <span class="nf">abs</span><span class="p">(</span><span class="kt">long</span> <span class="kt">long</span>   <span class="n">x</span><span class="p">);</span>  <span class="c1">// __Z3absx</span>
<span class="kt">float</span>       <span class="nf">abs</span><span class="p">(</span><span class="kt">float</span>       <span class="n">x</span><span class="p">);</span>  <span class="c1">// __Z3absf</span>
<span class="kt">double</span>      <span class="nf">abs</span><span class="p">(</span><span class="kt">double</span>      <span class="n">x</span><span class="p">);</span>  <span class="c1">// __Z3absd</span>
<span class="kt">long</span> <span class="kt">double</span> <span class="nf">abs</span><span class="p">(</span><span class="kt">long</span> <span class="kt">double</span> <span class="n">x</span><span class="p">);</span>  <span class="c1">// __Z3abse</span>
</code></pre></div></div>

<p>在实际的 C++ 编译器实现中，为了能在链接时对这些重载函数进行区分，会有一个「[命名粉碎]{name mangling}」的环节将它们重新命名为独一无二的符号，就像每行行末注释里的一样。</p>

<p>C++ 中还有大量的隐式类型转换，这也可以被视为特设的「[强制多态]{coercion polymorphism}」。这两种特设多态的结合给函数的静态派发带来了巨大的复杂度，以至于 C++ 有一份相当长的标准被称为「<a href="https://en.cppreference.com/w/cpp/language/overload_resolution">[重载决议]{overload resolution}</a>」。通常来说，隐式类型转换是弱类型的标志，它在类型方面带来了相当大的不可预测性。</p>

<p>那么特设多态在函数式编程语言中的支持如何呢？ML 系的语言都不支持函数重载，其中最具代表性的 OCaml 甚至完全不支持运算符重载，导致整数加法 <code class="language-plaintext highlighter-rouge">+</code> 和浮点数加法 <code class="language-plaintext highlighter-rouge">+.</code> 等都不是同一个符号。还好 SML 和 F# 为运算符重载开了口子：SML 跟 Java 一样，预先重载了一些内建运算符，但不允许用户进行重载；而 F# 完全向用户开放了运算符重载。</p>

<h3 id="类型类">类型类</h3>

<p>Haskell 语言对于特设多态的解决方案则是「[类型类]{type class}」，这个方案最早由 Philip Wadler 和他的学生 Stephen Blott 在 <a href="https://doi.org/10.1145/75277.75283">POPL 1989</a> 的论文中提出，可以视为对 SML <code class="language-plaintext highlighter-rouge">eqtype</code> 的扩展。<code class="language-plaintext highlighter-rouge">eqtype</code> 表示一个类型支持相等比较，而类型类将其推广到了任意操作。譬如我们可以定义一类数值类型，这类类型支持前面提到的 <code class="language-plaintext highlighter-rouge">abs</code> 函数：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">class</span> <span class="kt">Num</span> <span class="n">a</span> <span class="kr">where</span>
  <span class="n">abs</span> <span class="o">::</span> <span class="n">a</span> <span class="o">-&gt;</span> <span class="n">a</span>
  <span class="err">……</span>
<span class="kr">instance</span> <span class="kt">Num</span> <span class="kt">Int</span> <span class="kr">where</span>
  <span class="n">abs</span> <span class="n">n</span> <span class="o">=</span> <span class="kr">if</span> <span class="n">n</span> <span class="p">`</span><span class="n">geInt</span><span class="p">`</span> <span class="mi">0</span> <span class="kr">then</span> <span class="n">n</span> <span class="kr">else</span> <span class="n">negate</span> <span class="n">n</span>
  <span class="err">……</span>
</code></pre></div></div>

<p>这里 <code class="language-plaintext highlighter-rouge">class</code> 定义了 <code class="language-plaintext highlighter-rouge">Num</code> 类型类支持的函数及其类型，<code class="language-plaintext highlighter-rouge">instance</code> 则表明整数类型是 <code class="language-plaintext highlighter-rouge">Num</code> 类型类的实例，并提供了绝对值函数的具体定义。就像函数重载一样，我们也可以为其他类型定义这个类型类的实例。有了这种通用的解决方案，SML 中的 <code class="language-plaintext highlighter-rouge">eqtype</code> 相当于是 Haskell 中的一个特例——<code class="language-plaintext highlighter-rouge">Eq</code> 类型类。如今，Rust 的 <a href="https://doc.rust-lang.org/book/ch10-02-traits.html"><code class="language-plaintext highlighter-rouge">trait</code></a> 和 C++20 的 <a href="https://en.cppreference.com/w/cpp/language/constraints"><code class="language-plaintext highlighter-rouge">concept</code></a> 都以各自的方式实现了类型类的功能，越来越多的语言设计者参悟了如何让特设多态不那么特设。</p>

<h2 id="参数多态">参数多态</h2>

<p>「[参数多态]{parametric polymorphism}」在函数式编程中简称多态，在面向对象编程中又称「[泛型]{generics}」。之所以叫参数多态是因为它引入了类型参数，譬如允许我们在函数定义中使用类型变量，而不必为各种具体的类型定义不同的版本。譬如以 Haskell 语言为例，我们可以为所有类型定义一个统一的恒等函数：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">id</span> <span class="o">::</span> <span class="n">a</span> <span class="o">-&gt;</span> <span class="n">a</span>
<span class="n">id</span> <span class="n">x</span> <span class="o">=</span> <span class="n">x</span>

<span class="n">id</span> <span class="kt">True</span>  <span class="cm">{- True :: Bool -}</span>
<span class="n">id</span> <span class="sc">'a'</span>   <span class="cm">{- 'a'  :: Char -}</span>
</code></pre></div></div>

<p>这里 Haskell 标准语法省略了类型参数的引入和消去，如果我们打开 GHC 的 <a href="https://downloads.haskell.org/~ghc/latest/docs/html/users_guide/exts/explicit_forall.html">ExplicitForAll</a> 和 <a href="https://downloads.haskell.org/~ghc/latest/docs/html/users_guide/exts/type_applications.html">TypeApplications</a> 语言扩展，我们可以看得更清楚一点：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">id</span> <span class="o">::</span> <span class="n">forall</span> <span class="n">a</span><span class="o">.</span> <span class="n">a</span> <span class="o">-&gt;</span> <span class="n">a</span>
<span class="n">id</span> <span class="n">x</span> <span class="o">=</span> <span class="n">x</span>

<span class="n">id</span> <span class="o">@</span><span class="kt">Bool</span> <span class="kt">True</span>  <span class="cm">{- True :: Bool -}</span>
<span class="n">id</span> <span class="o">@</span><span class="kt">Char</span> <span class="sc">'a'</span>   <span class="cm">{- 'a'  :: Char -}</span>
</code></pre></div></div>

<p>第一行类型声明中的 <code class="language-plaintext highlighter-rouge">forall a</code> 引入了一个隐式的类型参数，之所以用这个关键字是因为它对应于逻辑中的全称量化。不过有趣的是，与之对偶的存在量化在 Haskell 中复用了 <code class="language-plaintext highlighter-rouge">forall</code> 关键字，详见 GHC 的语言扩展 <a href="https://downloads.haskell.org/~ghc/latest/docs/html/users_guide/exts/existential_quantification.html">ExistentialQuantification</a>。以 <code class="language-plaintext highlighter-rouge">@</code> 开头的就是显式指定的类型参数，当然，就算我们不写它们也能自动推断出来。</p>

<h3 id="hindleymilner">Hindley–Milner</h3>

<p>看到上文中的 Haskell 代码，大家可能会产生一个疑问：为什么类型参数默认是隐式的？要回答这个问题，就不得不提 Haskell 所采用的 Hindley–Milner 类型系统。HM 类型系统的最大好处是已有经过证明的算法（譬如 Algorithm W）可以做完整的类型推断，所以我们无须显式标注任何类型。不过 HM 类型系统也为此引入了对多态的两大限制：</p>

<ul>
  <li>Rank-1：函数类型里面不可以嵌套多态类型。</li>
  <li>直谓性：类型变量不可以实例化为多态类型。</li>
</ul>

<p>鉴于第一条 Rank-1 限制，类型变量只可能是顶层的 <code class="language-plaintext highlighter-rouge">forall</code> 引入的，Haskell 就干脆默认隐式声明了。因为函数参数不能是多态类型，所以 HM 类型系统依赖于 <code class="language-plaintext highlighter-rouge">let</code> 表达式来引入多态类型的变量。也正因如此，HM 类型系统中的 <code class="language-plaintext highlighter-rouge">let x = e1 in e2</code> 和 <code class="language-plaintext highlighter-rouge">(\x -&gt; e2) e1</code> 并不等价，因为前者 <code class="language-plaintext highlighter-rouge">x</code> 可以是多态类型，而后者不行。在类型推断中，<code class="language-plaintext highlighter-rouge">let</code> 会让 <code class="language-plaintext highlighter-rouge">e2</code> 每一处使用 <code class="language-plaintext highlighter-rouge">x</code> 的地方都各自独立地对类型变量进行实例化，这样就达到了多态的效果。不止是 Haskell，其他 ML 系语言（包括 SML、OCaml、F# 等）也都以 HM 类型系统为基础。</p>

<h3 id="值限制">值限制</h3>

<p>提到 HM 类型系统，另一个绕不过去的话题是 ML 系语言中臭名昭著的「[值限制]{value restriction}」。因为 <code class="language-plaintext highlighter-rouge">let</code> 的多态规则本质上是为变量的类型创建了多个实例，但如果这些实例因为副作用而有所联系时，就能一下子摧毁 ML 引以为傲的类型安全。这里以 OCaml 为例：</p>

<div class="language-ocaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">let</span> <span class="n">r</span> <span class="o">:</span> <span class="k">'</span><span class="n">a</span> <span class="n">option</span> <span class="n">ref</span> <span class="o">=</span> <span class="n">ref</span> <span class="nc">None</span>
<span class="n">r</span> <span class="o">:=</span> <span class="nc">Some</span> <span class="mi">48</span>  <span class="c">(* r : int option ref *)</span>
<span class="k">match</span> <span class="o">!</span><span class="n">r</span> <span class="k">with</span> <span class="c">(* r : string option ref *)</span>
<span class="o">|</span> <span class="nc">None</span>     <span class="o">-&gt;</span> <span class="s2">""</span>
<span class="o">|</span> <span class="nc">Some</span> <span class="n">str</span> <span class="o">-&gt;</span> <span class="s2">"HKG"</span> <span class="o">^</span> <span class="n">str</span>
</code></pre></div></div>

<p>第二行和第三行的 <code class="language-plaintext highlighter-rouge">r</code> 指向相同的内存地址，但 ML 为它们的类型建立了不同的实例，导致第三行能够通过类型检查，却有可能触发运行时错误。为了让多态不破坏命令式代码的类型安全，ML 系语言目前普遍采用的解决方案是 Andrew Wright 在 1995 年提出的值限制：只有 <code class="language-plaintext highlighter-rouge">let x =</code> 右侧在语法上是值的时候才能拥有多态类型，否则一律按单态处理（OCaml 的实现会创建一个弱类型变量 <code class="language-plaintext highlighter-rouge">'_weak</code> 根据未来的信息来推断这个未知的单态类型）。这种限制简单粗暴地认为任何不是值的表达式都可能带有副作用，虽然实现起来简单，但会干扰一些纯函数的编写，比如多态函数的部分应用：</p>

<div class="language-ocaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">let</span> <span class="n">rev</span> <span class="o">=</span> <span class="nn">List</span><span class="p">.</span><span class="n">fold_left</span> <span class="p">(</span><span class="k">fun</span> <span class="n">acc</span> <span class="n">x</span> <span class="o">-&gt;</span> <span class="n">x</span> <span class="o">::</span> <span class="n">acc</span><span class="p">)</span> <span class="bp">[]</span>
<span class="c">(* rev : '_weak1 list -&gt; '_weak1 list *)</span>
<span class="n">rev</span> <span class="p">[</span><span class="mi">1</span><span class="p">;</span> <span class="mi">2</span><span class="p">;</span> <span class="mi">3</span><span class="p">;</span> <span class="mi">4</span><span class="p">]</span>
<span class="c">(* rev : int list -&gt; int list *)</span>
<span class="n">rev</span> <span class="p">[</span><span class="k">'</span><span class="n">a'</span><span class="p">;</span> <span class="k">'</span><span class="n">b'</span><span class="p">;</span> <span class="k">'</span><span class="n">c'</span><span class="p">;</span> <span class="k">'</span><span class="n">d'</span><span class="p">]</span>
<span class="c">(* Error: This expression has type char but an expression was expected of type int *)</span>
</code></pre></div></div>

<p>当然，想要让 <code class="language-plaintext highlighter-rouge">rev</code> 恢复多态类型，我们可以显式加上它的参数 <code class="language-plaintext highlighter-rouge">l</code>，也就是所谓的 eta-expansion。因为 <code class="language-plaintext highlighter-rouge">fun l -&gt; ……</code>  在语法上是值，所以 <code class="language-plaintext highlighter-rouge">rev</code> 的类型就能推断为多态的 <code class="language-plaintext highlighter-rouge">'a list -&gt; 'a list</code> 了。</p>

<p>另一方面，Haskell 是纯函数式语言，没有 OCaml 那样隐式的副作用，因此也没有值限制（不过乱用 <code class="language-plaintext highlighter-rouge">unsafePerformIO</code> 就能以相同的方式摧毁 Haskell 的类型安全）。Haskell 有个名字很像的限制叫做「<a href="https://www.haskell.org/onlinereport/haskell2010/haskellch4.html#x10-930004.5.5">[单态限制]{monomorphism restriction}</a>」，但这个限制与类型安全和副作用都没有关系，而且基本上只要写了类型签名就能避免。单态限制在 GHC 中可以自由开关，如今在 GHCi 中是默认关闭的。</p>

<h3 id="rank-n">Rank-N</h3>

<p>HM 类型系统只支持顶层的 Rank-1 多态，这对应于逻辑学中的「[前束范式]{prenex normal form}」。不少 Haskell 用户不满足于此，所以 GHC 提供了 <a href="https://downloads.haskell.org/~ghc/latest/docs/html/users_guide/exts/rank_polymorphism.html">RankNTypes</a> 语言扩展支持 <code class="language-plaintext highlighter-rouge">-&gt;</code> 内层的 <code class="language-plaintext highlighter-rouge">forall</code>。因为 <code class="language-plaintext highlighter-rouge">forall</code> 出现在 <code class="language-plaintext highlighter-rouge">-&gt;</code> 右侧等价于出现在外层，如 <code class="language-plaintext highlighter-rouge">() -&gt; forall a. a -&gt; a</code> 等价于 <code class="language-plaintext highlighter-rouge">forall a. () -&gt; a -&gt; a</code>，所以真正有趣的情形是 <code class="language-plaintext highlighter-rouge">forall</code> 出现在 <code class="language-plaintext highlighter-rouge">-&gt;</code> 左侧：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">hipoly</span> <span class="o">::</span> <span class="p">(</span><span class="n">forall</span> <span class="n">a</span><span class="o">.</span> <span class="n">a</span> <span class="o">-&gt;</span> <span class="n">a</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="p">(</span><span class="kt">Bool</span><span class="p">,</span> <span class="kt">Char</span><span class="p">)</span>
<span class="n">hipoly</span> <span class="n">f</span> <span class="o">=</span> <span class="p">(</span><span class="n">f</span> <span class="kt">True</span><span class="p">,</span> <span class="n">f</span> <span class="sc">'a'</span><span class="p">)</span>
</code></pre></div></div>

<p>想要更强的表达能力也要付出相应的代价——Rank-3 及以上的完整类型推断已被 A. J. Kfoury 和 J. B. Wells 在 <a href="https://doi.org/10.1145/182409.182456">LFP 1994</a> 的论文中证明是不可能的。不过在手动标注一些类型的前提下，GHC 仍然能够进行相当实用的类型推断，其算法在 Simon Peyton Jones 等人的 <a href="https://doi.org/10.1017/S0956796806006034">JFP 2007</a> 论文中有详尽叙述。需要注意的是，就算开了扩展，<code class="language-plaintext highlighter-rouge">hipoly</code> 的类型签名也不能省略，因为 Haskell 的类型推断算法默认函数参数以及模式匹配绑定的变量不是多态类型。</p>

<h3 id="非直谓性">非直谓性</h3>

<p>介绍完 Rank-N，我们再来看看 HM 类型系统的另一个限制——[直谓性]{predicativity}。这个词同样来自逻辑学，表示不允许自指，比如 <code class="language-plaintext highlighter-rouge">type T = forall a. a</code> 中的 <code class="language-plaintext highlighter-rouge">a</code> 不可以包含 <code class="language-plaintext highlighter-rouge">T</code> 自身。在直谓多态中，这意味着多态不是头等公民，我们不能使用多态类型来实例化类型变量。在 Haskell 中比较典型的例子是处理局部副作用时会用到的 <code class="language-plaintext highlighter-rouge">Control.Monad.ST</code>：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">(</span><span class="o">$</span><span class="p">)</span> <span class="o">::</span> <span class="p">(</span><span class="n">a</span> <span class="o">-&gt;</span> <span class="n">b</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">a</span> <span class="o">-&gt;</span> <span class="n">b</span>
<span class="n">runST</span> <span class="o">::</span> <span class="p">(</span><span class="n">forall</span> <span class="n">s</span><span class="o">.</span> <span class="kt">ST</span> <span class="n">s</span> <span class="n">d</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">d</span>

<span class="n">runST</span> <span class="o">$</span> <span class="kr">do</span> <span class="p">{</span> <span class="err">……</span> <span class="p">}</span>  <span class="c1">-- a := (forall s. ST s d) -&gt; d</span>
</code></pre></div></div>

<p>在这个例子中，<code class="language-plaintext highlighter-rouge">$</code> 运算符的类型参数 <code class="language-plaintext highlighter-rouge">a</code> 得实例化成一个多态类型，这样的多态就不是直谓性的。不过好在 GHC 对 <code class="language-plaintext highlighter-rouge">$</code> 的类型检查进行了特殊处理，这样写并不会报错；如果想亲眼目睹类型错误，可以试试没有经过特殊处理的 <code class="language-plaintext highlighter-rouge">id runST</code>。从另一个角度来看，直谓多态只支持在 <code class="language-plaintext highlighter-rouge">-&gt;</code> 类型运算符里嵌套多态类型，比如 <code class="language-plaintext highlighter-rouge">(forall a. a) -&gt; ()</code>；而非直谓多态支持在任何多态类型里嵌套多态类型，比如 <code class="language-plaintext highlighter-rouge">[forall a. a]</code>。过去二十年来，非直谓多态系统的类型推断算是相对活跃的研究课题；GHC 也见证了研究的进展——<a href="https://downloads.haskell.org/~ghc/latest/docs/html/users_guide/exts/impredicative_types.html">ImpredicativeTypes</a> 扩展以前是基于 <a href="https://doi.org/10.1145/1159803.1159838">ICFP 2006</a> 论文实现的，可惜不太可靠，而最近 GHC 9.2 基于 <a href="https://doi.org/10.1145/3408971">ICFP 2020</a> 论文中的 Quick Look 算法更好地支持了非直谓多态。在成功突破 HM 类型系统的两大限制之后，经过 GHC 扩展的 Haskell 已经能表达比 Hindley–Milner 更强大的 Girard–Reynolds 多态演算了，也就是大名鼎鼎的 System F。</p>

<h3 id="实现">实现</h3>

<p>虽然参数多态在函数式编程语言中大同小异，但泛型在面向对象编程语言中的设计千差万别，实现也不尽相同。泛型的两种主流实现方式是「[类型擦除]{type erasure}」和「[单态化]{monomorphization}」，前者以 <a href="https://openjdk.org/projects/valhalla/design-notes/in-defense-of-erasure">Java</a>、<a href="https://gitlab.haskell.org/ghc/ghc/-/wikis/dependent-haskell">Haskell</a> 为代表，后者以 <a href="https://en.cppreference.com/w/cpp/language/templates">C++</a>、<a href="https://davidtw.co/media/masters_dissertation.pdf">Rust</a>、<a href="https://github.com/golang/proposal/blob/master/design/generics-implementation-dictionaries-go1.18.md">Go</a> 为代表。值得一提的是，Java 5.0 和 Go 1.18 之前并没有泛型，它们的泛型特性都是在学术界的协助下追加的，这两项工作（名为 Featherweight Generic {Java,Go}）分别发表在 <a href="https://doi.org/10.1145/320385.320395">OOPSLA 1999</a> 和 <a href="https://doi.org/10.1145/3428217">OOPSLA 2020</a> 上。在 Java 中，泛型列表的两个实例 <code class="language-plaintext highlighter-rouge">List&lt;Integer&gt;</code> 和 <code class="language-plaintext highlighter-rouge">List&lt;Boolean&gt;</code> 都会翻译到 <code class="language-plaintext highlighter-rouge">List&lt;Object&gt;</code>；而 Go 则会将 <code class="language-plaintext highlighter-rouge">List[int]</code> 和 <code class="language-plaintext highlighter-rouge">List[bool]</code> 翻译到两个不同的单态类型。虽然单态化会生成更多的代码，但它生成的代码比 Java 擦除类型的代码更高效，因为 Java 泛型的类型变量一定都是装箱了的（即 <code class="language-plaintext highlighter-rouge">Object</code> 的派生类），而 Go 可以用原始类型来实例化类型变量。另一方面，Java 的类型转换不支持类型变量，譬如 <code class="language-plaintext highlighter-rouge">(a)x</code>；而 Go 支持等价的类型断言 <code class="language-plaintext highlighter-rouge">x.(a)</code>。</p>

<p>C++ 的泛型是通过模板实现的，模板的实例化相当于泛型的单态化。不过 C++ 的模板比一般的泛型更为强大：模板的参数并不局限于类型参数、参数可以有默认值、模板支持特化等等。其中模板特化可以说是参数多态和特设多态的结合体：</p>

<div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">template</span><span class="o">&lt;</span><span class="k">typename</span> <span class="nc">T</span><span class="p">&gt;</span> <span class="kt">void</span> <span class="nf">f</span><span class="p">(</span><span class="n">T</span> <span class="n">x</span><span class="p">)</span> <span class="p">{</span> <span class="cm">/* primary template */</span> <span class="p">}</span>
<span class="k">template</span><span class="o">&lt;</span><span class="p">&gt;</span> <span class="kt">void</span> <span class="nf">f</span><span class="p">(</span><span class="kt">int</span> <span class="n">x</span><span class="p">)</span> <span class="p">{</span> <span class="cm">/* specialization  T := int */</span> <span class="p">}</span>
</code></pre></div></div>

<p>在第一行的主模板中，我们可以定义默认的泛型实现；而对于一些需要特设实现的类型，我们可以像第二行一样对模板进行特化，譬如为整数类型写一个单独的定义。如果活用 C++ 模板的各种特性，我们甚至可以在编译期间进行任意计算，因为模板元编程已经被证明是图灵完备的。</p>

<h2 id="子类型多态">子类型多态</h2>

<p>众所周知，面向对象编程有三大特性：封装、继承和多态。而研究类型系统的学者会说，面向对象编程所需的类型系统区别于函数式演算的最大特征就是子类型。面向对象编程所说的第三大特性，学名正是「[子类型多态]{subtype polymorphism}」。如果 S 是 T 的子类型（即 S &lt;: T），那么一个 T 类型的对象可以安全地被一个 S 类型的对象所代换；在此前提下，子类型多态是说一个类型为 T 的对象的成员函数既有可能调用 T 本身的实现，也有可能调用到 T 的子类型（如 S）的实现。</p>

<p>面向对象编程语言通常使用「[名义子类型]{nominal subtyping}」，也就是说子类型关系是通过名字显式声明的。在 C++、Java、C#、Swift 等语言中，定义类时可以声明它继承于什么，那么这个派生类不仅能复用基类的实现，而且成为了基类的子类型；反过来说，没有继承关系的类之间也不会有子类型关系。常见编程语言中的异类是 OCaml 和 TypeScript，它们使用的是学术界更青睐的「[结构子类型]{structural subtyping}」，也就是说子类型关系跟类型的名字没有任何关系，也不需要显式声明，而是由类型的实际结构通过一系列子类型规则决定的。在这些结构类型系统中，类名并不会同时充当对象的类型，这与主流的面向对象编程相去甚远，因此下面讨论的子类型多态均基于传统的名义类型系统。</p>

<h3 id="动态实现">动态实现</h3>

<p>之前提到的特设多态和参数多态，往往都是在编译期间静态实现的；而子类型多态需要获知一个对象的动态类型，所以通常在运行时实现。这里我们以 C++ 为例：</p>

<div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">struct</span> <span class="nc">Animal</span> <span class="p">{</span>
  <span class="k">virtual</span> <span class="kt">void</span> <span class="n">say</span><span class="p">()</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>
<span class="p">};</span>
<span class="k">struct</span> <span class="nc">Fox</span> <span class="o">:</span> <span class="n">Animal</span> <span class="p">{</span>
  <span class="kt">void</span> <span class="n">say</span><span class="p">()</span> <span class="k">override</span><span class="p">;</span>
<span class="p">};</span>
<span class="kt">void</span> <span class="nf">call</span><span class="p">(</span><span class="n">Animal</span> <span class="o">*</span><span class="n">a</span><span class="p">)</span> <span class="p">{</span> <span class="n">a</span><span class="o">-&gt;</span><span class="n">say</span><span class="p">();</span> <span class="p">}</span>
</code></pre></div></div>

<p>C++ 对于函数调用<strong>默认</strong>是静态绑定的，也就是说调用哪个成员函数完全取决于对象所标注的类型，比如 <code class="language-plaintext highlighter-rouge">a-&gt;say()</code> 就一定会调用 <code class="language-plaintext highlighter-rouge">Animal::say()</code>。但实际上 <code class="language-plaintext highlighter-rouge">a</code> 可能是 <code class="language-plaintext highlighter-rouge">Fox</code> 的实例，我们在派生类定义了不同于基类的实现，比如 <code class="language-plaintext highlighter-rouge">Fox::say()</code>。到底 <code class="language-plaintext highlighter-rouge">a</code> 是哪个类的实例我们在运行时才能知道，所以函数绑定就要延迟到运行时再进行，这就是所谓的「[动态派发]{dynamic dispatch}」。要让 C++ 进行动态派发，我们必须在基类的接口前面加上 <code class="language-plaintext highlighter-rouge">virtual</code> 关键字。这样一来，C++ 就会为每个实例附上「[虚函数表]{vtable}」，以记录各个虚函数实现的函数指针。值得一提的是，Rust 的 <code class="language-plaintext highlighter-rouge">trait</code> 对象也支持动态派发，但它没有把虚函数表存到实例里，而是使用「[胖指针]{fat pointer}」同时指向实例和虚函数表。而在 Smalltalk 这类基于「[消息传递]{message passing}」的动态编程语言中，对象的成员随时都能动态变更，因此所有消息（成员函数）都是动态传递（调用）的，我们甚至能自定义 <code class="language-plaintext highlighter-rouge">messageNotUnderstand:</code> 来动态处理未知消息。</p>

<h3 id="静态实现">静态实现</h3>

<p>C++ 也可以用「[奇异递归模板模式]{curiously recurring template pattern}」静态实现子类型多态，即用泛型模拟多态。直观上讲，类型参数在这里充当了虚函数的索引，构造派生类实例时其实际类型会被静态记录下来。因为不同的派生类继承于基类模板的不同实例，所以 <code class="language-plaintext highlighter-rouge">Animal&lt;T&gt;::say()</code> 中的静态类型转换会把函数派发给对应的派生类 <code class="language-plaintext highlighter-rouge">T</code>：</p>

<div class="language-cpp highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">template</span><span class="o">&lt;</span><span class="k">typename</span> <span class="nc">T</span><span class="p">&gt;</span>
<span class="k">struct</span> <span class="nc">Animal</span> <span class="p">{</span>
  <span class="kt">void</span> <span class="n">say</span><span class="p">()</span> <span class="p">{</span>
    <span class="k">static_cast</span><span class="o">&lt;</span><span class="n">T</span><span class="o">*&gt;</span><span class="p">(</span><span class="k">this</span><span class="p">)</span><span class="o">-&gt;</span><span class="n">say</span><span class="p">();</span>
  <span class="p">}</span>
<span class="p">};</span>
<span class="k">struct</span> <span class="nc">Fox</span> <span class="o">:</span> <span class="n">Animal</span><span class="o">&lt;</span><span class="n">Fox</span><span class="o">&gt;</span> <span class="p">{</span>
  <span class="kt">void</span> <span class="n">say</span><span class="p">();</span>
<span class="p">};</span>
<span class="k">template</span><span class="o">&lt;</span><span class="k">typename</span> <span class="nc">T</span><span class="p">&gt;</span>
<span class="kt">void</span> <span class="nf">call</span><span class="p">(</span><span class="n">Animal</span><span class="o">&lt;</span><span class="n">T</span><span class="o">&gt;</span> <span class="o">*</span><span class="n">a</span><span class="p">)</span> <span class="p">{</span> <span class="n">a</span><span class="o">-&gt;</span><span class="n">say</span><span class="p">();</span> <span class="p">}</span>
</code></pre></div></div>

<p>当然，这样静态模拟子类型多态会丧失一些表达能力，比如我们无法将这些派生类的对象装进同一个容器：<code class="language-plaintext highlighter-rouge">Animal</code> 是模板而不是具体的类，所以我们无法直接写 <code class="language-plaintext highlighter-rouge">vector&lt;Animal*&gt;</code>；如果改成诸如 <code class="language-plaintext highlighter-rouge">vector&lt;Animal&lt;T&gt;*&gt;</code> 的形式，那显然就没法装下 <code class="language-plaintext highlighter-rouge">T</code> 以外的派生类的对象了。</p>

<h2 id="行多态">行多态</h2>

<p>「[行多态]{row polymorphism}」与子类型多态一样，是一种主要服务于对象（或记录）的多态形式。不同于子类型多态在实践中常常以名义子类型的形态出现，行多态理论上只适用于结构类型系统。目前学术界对于行多态的最佳实践莫衷一是，不同文献中的设计各异其趣，想了解「行的四种写法」可以移步游客账户的<a href="https://zhuanlan.zhihu.com/p/108627098">知乎文章</a>。这里我们不去罗列理论，而是以实际的编程语言 PureScript 为例：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">addFields</span> <span class="o">::</span> <span class="n">forall</span> <span class="p">(</span><span class="n">r</span> <span class="o">::</span> <span class="kt">Row</span> <span class="kt">Type</span><span class="p">)</span><span class="o">.</span> <span class="p">{</span> <span class="n">foo</span> <span class="o">::</span> <span class="kt">Int</span><span class="p">,</span> <span class="n">bar</span> <span class="o">::</span> <span class="kt">Int</span> <span class="o">|</span> <span class="n">r</span> <span class="p">}</span> <span class="o">-&gt;</span> <span class="kt">Int</span>
<span class="n">addFields</span> <span class="n">o</span> <span class="o">=</span> <span class="n">o</span><span class="o">.</span><span class="n">foo</span> <span class="o">+</span> <span class="n">o</span><span class="o">.</span><span class="n">bar</span> <span class="o">+</span> <span class="mi">1</span>

<span class="n">addFields</span> <span class="p">{</span> <span class="n">foo</span><span class="o">:</span> <span class="mi">1</span><span class="p">,</span> <span class="n">bar</span><span class="o">:</span> <span class="mi">2</span><span class="p">,</span> <span class="n">baz</span><span class="o">:</span> <span class="mi">3</span> <span class="p">}</span>  <span class="c1">-- r := ( baz :: Int )</span>
<span class="n">addFields</span> <span class="p">{</span> <span class="n">foo</span><span class="o">:</span> <span class="mi">1</span> <span class="p">}</span>                  <span class="c1">-- Type Error!</span>
</code></pre></div></div>

<p>因为我们在第二行的函数定义中访问了记录的两个字段，所以 PureScript 会像第一行一样将其类型推断为 <code class="language-plaintext highlighter-rouge">{ foo :: Int, bar :: Int | r }</code>，这里的类型变量 <code class="language-plaintext highlighter-rouge">r</code> 代表一行类型，也就是该记录尚未知晓的剩余字段，相当于扮演了「[宽度子类型化]{width subtyping}」的角色。简而言之，所谓的行多态就是量化范围为「[行]{row}」的参数多态。</p>

<p>不过要注意：行多态<strong>不能</strong>完全取代子类型！单单使用行多态的一大缺陷是我们无法将不同类型的记录装进同一个容器，比如 <code class="language-plaintext highlighter-rouge">[ { x: 1, y: 2 }, { x: 1, z: 3 } ]</code>，个中缘由跟静态实现子类型多态的时候几乎一样。反过来，对于下面基于行多态的记录更新操作：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">incCount</span> <span class="o">::</span> <span class="n">forall</span> <span class="n">r</span><span class="o">.</span> <span class="p">{</span> <span class="n">count</span> <span class="o">::</span> <span class="kt">Int</span> <span class="o">|</span> <span class="n">r</span> <span class="p">}</span> <span class="o">-&gt;</span> <span class="p">{</span> <span class="n">count</span> <span class="o">::</span> <span class="kt">Int</span> <span class="o">|</span> <span class="n">r</span> <span class="p">}</span>
<span class="n">incCount</span> <span class="n">o</span> <span class="o">=</span> <span class="n">o</span> <span class="p">{</span> <span class="n">count</span> <span class="o">=</span> <span class="n">o</span><span class="o">.</span><span class="n">count</span> <span class="o">+</span> <span class="mi">1</span> <span class="p">}</span>

<span class="p">(</span><span class="n">incCount</span> <span class="p">{</span> <span class="n">count</span><span class="o">:</span> <span class="mi">0</span><span class="p">,</span> <span class="n">uuid</span><span class="o">:</span> <span class="s">"xxx"</span> <span class="p">})</span><span class="o">.</span><span class="n">uuid</span>  <span class="c1">-- "xxx"</span>
</code></pre></div></div>

<p>如果我们直接把行多态去掉（删掉 <code class="language-plaintext highlighter-rouge">forall r.</code> 和 <code class="language-plaintext highlighter-rouge">| r</code>），就算 PureScript 有子类型多态也无法通过类型检查，因为函数返回类型中 <code class="language-plaintext highlighter-rouge">count</code> 以外的字段都丢失了。这就需要 PureScript 进一步支持有界多态，然后我们把函数签名改成 <code class="language-plaintext highlighter-rouge">forall a &lt;: { count :: Int }. a -&gt; a</code> 才行。</p>

<p>除了行多态，其实还有别的方法能支持多态的对象，比如谢宁宁等人在 <a href="https://doi.org/10.4230/LIPIcs.ECOOP.2020.27">ECOOP 2020</a> 的论文中证明「[互斥多态]{disjoint polymorphism}」能够模拟行多态和有界多态。互斥多态借助的利器是交集类型，这一想法可以追溯到 Benjamin Pierce 的博士毕业论文。Rust 之父 Graydon Hoare 对互斥交集类型也很关注，他曾在<a href="https://web.archive.org/web/20201114005825/https://twitter.com/graydon_pub/status/1327415381061902339">推特</a>评论道：“Maybe John Reynolds really did almost solve everything at once with Forsythe, if we just manage to get its intersection types right.”</p>

<h2 id="结语">结语</h2>

<p>直觉上，大家一定觉得一门编程语言支持的特性越多越好；然而在类型系统领域，让不同特性和谐共处往往是十分艰巨的话题，甚至有些特性是相互矛盾的。上文提到的参数多态和子类型就是最典型的例子：虽然这两个概念单独考虑都不算太复杂，但在它们组合而成的 F-sub 系统中，子类型关系竟然是不可判定的。这时候，语言实现通常会牺牲完备性来换取可判定的算法；当然也可以通过像行多态一样改变编程语言的设计来巧妙地避开问题，这就得看语言设计者的知识水平了。</p>]]></content><author><name>孙耀珠</name></author><category term="编程语言" /><summary type="html"><![CDATA[尤记得本科的面向对象编程课有一道经典例题：C++ 的多态性体现在何处？标准答案着眼于 C++ 的虚函数解释了动态派发的机制。自那以后的很长一段时间里，我对多态的认识就固化在了子类型多态上；直到博士期间开始搞编程语言的研究，才发现学术界对多态的定义远不局限于此。 英语里有个单词叫做 autological，是说一个词可以形容其本身，比方说「名词」本身就是个名词，「阳平」这两个字的声调本身就是阳平，而「多态」本身也很多态。编程语言中最常见的多态有四种：特设多态、参数多态、子类型多态和行多态。通俗来讲，只要是为不同类型的数据或操作提供了相同的名字就可以叫多态。 本文只是一篇科普性质的文章，所以基本上只以实际编程语言为例，不会用任何形式化的语言来描述这些类型系统。如果希望深入学习相关的理论知识，建议阅读 TAPL 或者 PFPL；Giuseppe Castagna 在韩国 SIGPL Summer School 2019 的特邀讲座也很值得一看。]]></summary></entry><entry><title type="html">HTML 模板语言纵览</title><link href="https://blog.yzsun.me/html-templating/" rel="alternate" type="text/html" title="HTML 模板语言纵览" /><published>2020-08-09T00:00:00+00:00</published><updated>2020-08-09T00:00:00+00:00</updated><id>https://blog.yzsun.me/html-templating</id><content type="html" xml:base="https://blog.yzsun.me/html-templating/"><![CDATA[<p>前端开发的本质，是把结构化的数据映射到 HTML。HTML 本身是静态的，因此模板引擎应运而生，接下了动态生成 HTML 的任务，直到近年来在前后端分离的浪潮下被面面俱到的前端框架所兼并。本文试图梳理出模板语言的主流范式，不过注意本文并非按照时间线编排，如果要还原历史的话，应该是 PHP (1995) → Zope 2 (1998) → JSTL (2002) → Django (2005) → Haml (2006) → Mustache (2009) → AngularJS (2010)。</p>

<!--more-->

<ul id="markdown-toc">
  <li><a href="#php-风格" id="markdown-toc-php-风格">PHP 风格</a></li>
  <li><a href="#mustache" id="markdown-toc-mustache">Mustache</a></li>
  <li><a href="#django-风格" id="markdown-toc-django-风格">Django 风格</a></li>
  <li><a href="#模板属性语言" id="markdown-toc-模板属性语言">模板属性语言</a></li>
  <li><a href="#标签库" id="markdown-toc-标签库">标签库</a></li>
  <li><a href="#haml" id="markdown-toc-haml">Haml</a></li>
  <li><a href="#结语" id="markdown-toc-结语">结语</a></li>
</ul>

<h2 id="php-风格">PHP 风格</h2>

<p>虽然 PHP 早已是一门通用编程语言了，不过它最早是作为 HTML 模版引擎而出现的。从它现在的全称「超文本预处理器」也可以想象出，PHP 代码可以嵌入到 HTML 中，在用户请求该网页时，后端预先执行 PHP 代码并生成插入了运行结果的 HTML。下面便是一个最简单的例子：</p>

<div class="language-php highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;html&gt;</span>
  <span class="nt">&lt;head&gt;</span>
    <span class="nt">&lt;title&gt;</span>Personal Home Page<span class="nt">&lt;/title&gt;</span>
  <span class="nt">&lt;/head&gt;</span>
  <span class="nt">&lt;body&gt;</span>
    <span class="cp">&lt;?php</span> <span class="k">echo</span> <span class="s2">"Hello </span><span class="si">{</span><span class="nv">$world</span><span class="si">}</span><span class="s2">"</span><span class="p">;</span> <span class="cp">?&gt;</span>
    <span class="cp">&lt;?=</span> <span class="s2">"Hello </span><span class="si">{</span><span class="nv">$world</span><span class="si">}</span><span class="s2">"</span> <span class="cp">?&gt;</span>
  <span class="nt">&lt;/body&gt;</span>
<span class="nt">&lt;/html&gt;</span>
</code></pre></div></div>

<p>包裹在 <code class="language-plaintext highlighter-rouge">&lt;?php … ?&gt;</code> 标签里面的便是服务器要执行的 PHP 代码，直接 <code class="language-plaintext highlighter-rouge">echo</code> 一个表达式可以简写为 <code class="language-plaintext highlighter-rouge">&lt;?= … ?&gt;</code>。像这种在 HTML 里用特殊标签插入后端脚本的做法，从互联网诞生开始就相当普遍，至今仍屡见不鲜，本文称其为「PHP 风格」。这些 PHP 风格的模板引擎大同小异，区别主要在于内嵌语言用什么、代码块用什么标签包裹。</p>

<p>几乎每一门后端编程语言都有自己的 PHP 风格的模板引擎，因为在 HTML 直接嵌入后端脚本对于后端开发者来说最容易上手，没有任何学习上的负担。这些模板引擎中较为知名的有：</p>

<ul>
  <li>微软公司推出的 <strong>ASP</strong>，默认脚本语言为 VBScript；</li>
  <li>昇阳公司推出的 <strong>JSP</strong>，相当于 ASP 的 Java 版本；</li>
  <li>Ruby on Rails 框架默认使用的 <strong>eRuby</strong>；</li>
  <li>JavaScript 也有类似的 <strong>EJS</strong>，博客框架 Hexo 在用。</li>
</ul>

<p>JSP / eRuby / EJS 都继承了 ASP 的习惯，使用 <code class="language-plaintext highlighter-rouge">&lt;% … %&gt;</code> 来包裹脚本，还有 <code class="language-plaintext highlighter-rouge">&lt;%= … %&gt;</code> 渲染表达式结果等其他便利的标签。</p>

<h2 id="mustache">Mustache</h2>

<p>PHP 风格的模板引擎虽然历史悠久，但在设计上有一个比较明显的问题：代码逻辑和 HTML 模板混杂在一起。因此，GitHub 的联合创始人 Chris Wanstrath 发明了广为人知的 Mustache。Mustache 的语法非常简洁，没有任何显式的控制流语句，完全由数据驱动，因而自称 logic-less。它不与任何编程语言耦合，几乎所有主流语言都有 Mustache 模板引擎的实现。</p>

<p>Mustache 有两种基本的标签形式：一种是像 <code class="language-plaintext highlighter-rouge">{{variable}}</code> 这样渲染变量的值，另一种则是像 <code class="language-plaintext highlighter-rouge">{{#section}} … {{/section}}</code> 这样的区块。根据键值的不同，区块隐含四种语义：</p>

<ul>
  <li>如果是假值或者空列表，就完全不渲染；</li>
  <li>如果既不是假值也不是列表，就会渲染一次；</li>
  <li>如果是非空列表，就渲染列表长度次；</li>
  <li>如果是函数，则会以区块包裹的原始文本调用该函数。</li>
</ul>

<p>另外还有 <code class="language-plaintext highlighter-rouge">{{^inverted}} … {{/inverted}}</code> 与正常的区块相反，如果是假值或空列表则渲染一次，否则不渲染。下面是 Mustache 模板的一个典型用例：</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;h1&gt;</span>{{header}}<span class="nt">&lt;/h1&gt;</span>
{{#items}}
  {{#first}}
    <span class="nt">&lt;li&gt;&lt;strong&gt;</span>{{name}}<span class="nt">&lt;/strong&gt;&lt;/li&gt;</span>
  {{/first}}
  {{#link}}
    <span class="nt">&lt;li&gt;&lt;a</span> <span class="na">href=</span><span class="s">"{{url}}"</span><span class="nt">&gt;</span>{{name}}<span class="nt">&lt;/a&gt;&lt;/li&gt;</span>
  {{/link}}
{{/items}}
{{^items}}
  <span class="nt">&lt;p&gt;</span>The list is empty.<span class="nt">&lt;/p&gt;</span>
{{/items}}
</code></pre></div></div>

<p>假设我们的输入数据是用下面这个 JSON 表示的：</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"header"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Colors"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"items"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="p">{</span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Red"</span><span class="p">,</span><span class="w"> </span><span class="nl">"first"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w"> </span><span class="nl">"url"</span><span class="p">:</span><span class="w"> </span><span class="s2">"#red"</span><span class="p">},</span><span class="w">
    </span><span class="p">{</span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Green"</span><span class="p">,</span><span class="w"> </span><span class="nl">"link"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w"> </span><span class="nl">"url"</span><span class="p">:</span><span class="w"> </span><span class="s2">"#green"</span><span class="p">},</span><span class="w">
    </span><span class="p">{</span><span class="nl">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Blue"</span><span class="p">,</span><span class="w"> </span><span class="nl">"link"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w"> </span><span class="nl">"url"</span><span class="p">:</span><span class="w"> </span><span class="s2">"#blue"</span><span class="p">}</span><span class="w">
  </span><span class="p">]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>那么 Mustache 便会渲染出如下 HTML：</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;h1&gt;</span>Colors<span class="nt">&lt;/h1&gt;</span>
<span class="nt">&lt;li&gt;&lt;strong&gt;</span>Red<span class="nt">&lt;/strong&gt;&lt;/li&gt;</span>
<span class="nt">&lt;li&gt;&lt;a</span> <span class="na">href=</span><span class="s">"#green"</span><span class="nt">&gt;</span>Green<span class="nt">&lt;/a&gt;&lt;/li&gt;</span>
<span class="nt">&lt;li&gt;&lt;a</span> <span class="na">href=</span><span class="s">"#blue"</span><span class="nt">&gt;</span>Blue<span class="nt">&lt;/a&gt;&lt;/li&gt;</span>
</code></pre></div></div>

<p>虽然 Mustache 的设计小而美，但实际使用起来难免捉襟见肘。<strong>Handlebars</strong> 是对 Mustache 语言的扩展，最大的区别在于它引入了辅助函数。值得一提的是，其内置的 <code class="language-plaintext highlighter-rouge">#if</code> <code class="language-plaintext highlighter-rouge">#unless</code> <code class="language-plaintext highlighter-rouge">#each</code> <code class="language-plaintext highlighter-rouge">#with</code> 辅助函数明确了 Mustache 区块的隐式语义，譬如前面例子中的：</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">{{#items}} … {{/items}}</code> 可以显式写成 <code class="language-plaintext highlighter-rouge">{{#each items}} … {{/each}}</code>；</li>
  <li><code class="language-plaintext highlighter-rouge">{{#first}} … {{/first}}</code> 可以显式写成 <code class="language-plaintext highlighter-rouge">{{#if first}} … {{/if}}</code>；</li>
  <li><code class="language-plaintext highlighter-rouge">{{^items}} … {{/items}}</code> 可以显式写成 <code class="language-plaintext highlighter-rouge">{{#unless items}} … {{/unless}}</code>。</li>
</ul>

<h2 id="django-风格">Django 风格</h2>

<p>Mustache 完全去除了代码逻辑，而 Handlebars 又稍稍加回了一些；不过更多的模板引擎出于实用性考量，不吝于引入更多逻辑，但也不愿复杂到直接内嵌后端脚本，换句话说就是试图在 Mustache 和 PHP 风格之间寻找平衡。如果要给这些中庸的模板引擎选个代表，最早为人所知的应该是 Django Template Language（以下简称 DTL），实际上它的出现要早于 Mustache。</p>

<p>与先前的话术稍有不同，DTL 将渲染表达式的 <code class="language-plaintext highlighter-rouge">{{ variable }}</code> 称为变量，将控制流程的 <code class="language-plaintext highlighter-rouge">{% tag %}</code> 称为标签，其内置了二十多个标签，包括常用的 <code class="language-plaintext highlighter-rouge">for</code> <code class="language-plaintext highlighter-rouge">if</code> <code class="language-plaintext highlighter-rouge">elif</code> <code class="language-plaintext highlighter-rouge">else</code> 等等。DTL 最大的特色是过滤器，譬如 <code class="language-plaintext highlighter-rouge">{{ list | length }}</code> 能够获取列表的长度、<code class="language-plaintext highlighter-rouge">{{ text | escape | linebreaks }}</code> 能先将文本转义再把换行符替换成 HTML 标签等等，大约有六十个过滤器内置其中。下面是一段 Django 模板语言的简单示例：</p>

<div class="language-django highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;h1&gt;</span><span class="cp">{%</span> <span class="k">block</span> <span class="nv">title</span> <span class="cp">%}{%</span> <span class="k">endblock</span> <span class="cp">%}</span><span class="nt">&lt;/h1&gt;</span>
<span class="nt">&lt;ul&gt;</span>
<span class="cp">{%</span> <span class="k">for</span> <span class="nv">user</span> <span class="ow">in</span> <span class="nv">users</span> <span class="cp">%}</span>
  <span class="nt">&lt;li&gt;&lt;a</span> <span class="na">href=</span><span class="s">"</span><span class="cp">{{</span> <span class="nv">user.url</span> <span class="cp">}}</span><span class="s">"</span><span class="nt">&gt;</span><span class="cp">{{</span> <span class="nv">user.username</span> <span class="cp">}}</span><span class="nt">&lt;/a&gt;&lt;/li&gt;</span>
<span class="cp">{%</span> <span class="k">endfor</span> <span class="cp">%}</span>
<span class="nt">&lt;/ul&gt;</span>
</code></pre></div></div>

<p>后来，Flask 的作者 Armin Ronacher 参考 DTL 的设计实现了独立于后端框架的 <strong>Jinja</strong> 模板引擎；而 Mozilla 提供了一个 JavaScript 上的实现 <strong>Nunjucks</strong>；Shopify 在 Ruby 上也有十分相似的 <strong>Liquid</strong> 模板引擎，并被用于 GitHub Pages 默认的静态站点生成器 Jekyll。</p>

<p>Go 语言标准库的模板也可以算是 Django 风格，但它没有 <code class="language-plaintext highlighter-rouge">{% … %}</code> 只有 <code class="language-plaintext highlighter-rouge">{{ … }}</code>。比如前面 DTL 的 <code class="language-plaintext highlighter-rouge">{% for user in users %} … {% endfor %}</code> 写作 <code class="language-plaintext highlighter-rouge">{{ range $user := .Users }} … {{ end }}</code>，而渲染变量和字段写作 <code class="language-plaintext highlighter-rouge">{{ $variable }}</code> 和 <code class="language-plaintext highlighter-rouge">{{ .Field }}</code>，函数链式调用亦可用管道表达。</p>

<h2 id="模板属性语言">模板属性语言</h2>

<p>上述三种风格，其实都可以归类于往 HTML 里面插各种 HTML 语法以外的 <code class="language-plaintext highlighter-rouge">&lt;% … %&gt;</code> <code class="language-plaintext highlighter-rouge">{{ … }}</code>，那么还有没有别的方式嵌入动态内容呢？有一种有趣的设计叫做「模板属性语言」(TAL)，也就是说我们把动态内容写在正常 HTML 标签的自定义属性里。TAL 最大的好处是简化了开发者和设计师的协作，因为 TAL 能直接加在设计原型上，加上之后仍然是照常显示的 HTML，不经后端渲染直接用浏览器打开也不会感知到动态代码的存在。最早提出 TAL 的是 Python 编写的 Zope 2，其模板引擎 <strong>Zope Page Templates</strong> 使用了一系列 <code class="language-plaintext highlighter-rouge">tal:</code> 属性来引入动态内容。</p>

<p>如今较为纯粹的例子是 Java 上的模板引擎 <strong>Thymeleaf</strong>，自称「自然模板」。下面是自然模板的一个示例，其中 <code class="language-plaintext highlighter-rouge">th:text</code> 会替换掉标签内的原有内容、<code class="language-plaintext highlighter-rouge">th:each</code> 会进行迭代：</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;table&gt;</span>
  <span class="nt">&lt;thead&gt;</span>
    <span class="nt">&lt;tr&gt;</span>
      <span class="nt">&lt;th</span> <span class="na">th:text=</span><span class="s">"#{msgs.headers.name}"</span><span class="nt">&gt;</span>Name<span class="nt">&lt;/th&gt;</span>
      <span class="nt">&lt;th</span> <span class="na">th:text=</span><span class="s">"#{msgs.headers.price}"</span><span class="nt">&gt;</span>Price<span class="nt">&lt;/th&gt;</span>
    <span class="nt">&lt;/tr&gt;</span>
  <span class="nt">&lt;/thead&gt;</span>
  <span class="nt">&lt;tbody&gt;</span>
    <span class="nt">&lt;tr</span> <span class="na">th:each=</span><span class="s">"prod: ${allProducts}"</span><span class="nt">&gt;</span>
      <span class="nt">&lt;td</span> <span class="na">th:text=</span><span class="s">"${prod.name}"</span><span class="nt">&gt;</span>Oranges<span class="nt">&lt;/td&gt;</span>
      <span class="nt">&lt;td</span> <span class="na">th:text=</span><span class="s">"${#numbers.formatDecimal(prod.price, 1, 2)}"</span><span class="nt">&gt;</span>0.99<span class="nt">&lt;/td&gt;</span>
    <span class="nt">&lt;/tr&gt;</span>
  <span class="nt">&lt;/tbody&gt;</span>
<span class="nt">&lt;/table&gt;</span>
</code></pre></div></div>

<h2 id="标签库">标签库</h2>

<p>既然能自定义 HTML 属性，那么可不可以自定义 HTML 标签呢？JSP 标准标签库（JSR-52: JSTL）便实践了这一想法，虽然自定义标签不再有自然模板的好处，但写起来会更方便不少。JSTL 定义了 <code class="language-plaintext highlighter-rouge">&lt;c:if test="${age &gt;= 20}"&gt;</code> <code class="language-plaintext highlighter-rouge">&lt;fmt:message key="i18n"&gt;</code> <code class="language-plaintext highlighter-rouge">&lt;sql:query … &gt;</code> <code class="language-plaintext highlighter-rouge">&lt;x:parse … &gt;</code> 等四类标签，在属性上还可以使用表达式语言（JSR-341: EL）来插入动态内容，就像前述 <code class="language-plaintext highlighter-rouge">&lt;c:if&gt;</code> 中的 <code class="language-plaintext highlighter-rouge">${age &gt;= 20}</code> 那样。</p>

<p>JSP 也允许用户定义 JSTL 以外的自定义标签，这不禁让我们联想起了如今的 <strong>Web Components</strong>：</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">class</span> <span class="nc">PopUpInfo</span> <span class="kd">extends</span> <span class="nc">HTMLElement</span> <span class="p">{</span>
  <span class="nf">constructor</span><span class="p">()</span> <span class="p">{</span>
    <span class="k">super</span><span class="p">();</span>
    <span class="err">……</span> <span class="c1">// write element functionality in here</span>
  <span class="p">}</span>
<span class="p">}</span>
<span class="nx">customElements</span><span class="p">.</span><span class="nf">define</span><span class="p">(</span><span class="dl">'</span><span class="s1">popup-info</span><span class="dl">'</span><span class="p">,</span> <span class="nx">PopUpInfo</span><span class="p">);</span>
</code></pre></div></div>

<p>以上代码便可以创建一个自定义标签 <code class="language-plaintext highlighter-rouge">&lt;popup-info&gt;</code>，而该元素的行为和语义均可由用户自行决定。 React / Angular / Vue 等前端框架非常提倡这种可复用的组件，不过它们提供了更高层的抽象，让自定义组件更易写易用。</p>

<h2 id="haml">Haml</h2>

<p>前面的模板语言说到底都还是 HTML 的超集，而 Haml 则完全抛弃了 HTML 原有的语法，走向了截然不同的方向。Haml 全称 HTML 抽象标记语言，由 Sass 之父 Hampton Catlin 发明。Haml 的写法有点像 CSS selector，譬如 <code class="language-plaintext highlighter-rouge">%p.sample#welcome Hello, World!</code> 会被渲染为 <code class="language-plaintext highlighter-rouge">&lt;p class="sample" id="welcome"&gt;Hello, World!&lt;/p&gt;</code>。Haml 有 <code class="language-plaintext highlighter-rouge">=</code> 和 <code class="language-plaintext highlighter-rouge">-</code> 前缀分别用来渲染表达式结果和控制流程，另外它跟 Python / Haskell 一样采用了越位规则，也就是说以缩进来界定文档结构。</p>

<p>不过 Haml 需要在每个标签前面写 <code class="language-plaintext highlighter-rouge">%</code> 还是有点麻烦的，JavaScript 上的 <strong>Pug</strong>（原名 Jade）对其进行了一些语法上的改进，后来又出口转内销，<strong>Slim</strong> 把相似的语法带回了 Ruby：</p>

<div class="language-slim highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nn">doctype</span><span class="w"> </span>html
<span class="nt">html</span>
  <span class="nt">head</span>
    <span class="nt">title</span><span class="w"> </span>Slim<span class="w"> </span>Examples
    <span class="nt">link</span><span class="w"> </span><span class="na">rel</span><span class="p">=</span><span class="s">"icon"</span><span class="w"> </span><span class="na">type</span><span class="p">=</span><span class="s">"image/png"</span><span class="w"> </span><span class="na">href</span><span class="p">=</span><span class="n">file_path</span><span class="p">(</span><span class="s2">"favicon.png"</span><span class="p">)</span>
  <span class="nt">body</span>
    <span class="nf">#content</span>
      <span class="nt">p</span><span class="w"> </span>This<span class="w"> </span>example<span class="w"> </span>shows<span class="w"> </span>you<span class="w"> </span>what<span class="w"> </span>a<span class="w"> </span>basic<span class="w"> </span>Slim<span class="w"> </span>file<span class="w"> </span>looks<span class="w"> </span>like<span class="nc">.</span>
      <span class="p">-</span> <span class="k">if</span> <span class="n">items</span><span class="p">.</span><span class="nf">any?</span>
        <span class="nt">table</span><span class="nf">#items</span>
          <span class="p">-</span> <span class="n">items</span><span class="p">.</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">item</span><span class="o">|</span>
            <span class="nt">tr</span>
              <span class="nt">td</span><span class="nc">.name</span><span class="w"> </span><span class="p">=</span> <span class="n">item</span><span class="p">.</span><span class="nf">name</span>
              <span class="nt">td</span><span class="nc">.price</span><span class="w"> </span><span class="p">=</span> <span class="n">item</span><span class="p">.</span><span class="nf">price</span>
      <span class="p">-</span> <span class="k">else</span>
        <span class="nt">p</span><span class="w"> </span>No<span class="w"> </span>items<span class="w"> </span>found<span class="nc">.</span><span class="w"> </span>Please<span class="w"> </span>add<span class="w"> </span>some<span class="w"> </span>inventory<span class="nc">.</span>
          <span class="nt">Thank</span><span class="w"> </span>you!
    <span class="nt">div</span><span class="w"> </span><span class="na">id</span><span class="p">=</span><span class="s">"footer"</span>
      <span class="p">=</span> <span class="n">render</span> <span class="s2">"footer"</span>
      <span class="p">|</span> Copyright <span class="ni">&amp;copy;</span> <span class="si">#{</span><span class="n">year</span><span class="si">}</span> <span class="si">#{</span><span class="n">author</span><span class="si">}</span>
</code></pre></div></div>

<p>说句题外话，我在用 Spring 写网站的时候曾经一度很困惑，过去不支持 Java 注解的时候，大家是如何忍受手写 XML 配置文件的呢？然而我开始写 Thymeleaf 模板的时候突然意识到，我自己对于手写 HTML 不也习以为常了吗？XML 配置文件正逐渐被 YAML / TOML 等新兴格式所取代，HTML 模板的未来又会如何呢？</p>

<h2 id="结语">结语</h2>

<p>虽然上述六种分类特意将 HTML 模板语言的范式孤立开来，但如今流行的前端框架往往集成了多种范式，譬如 Angular 和 Vue 都支持 Django 风格的插值和管道 <code class="language-plaintext highlighter-rouge">{{ interpolation | pipe }}</code>，而写在 HTML 属性上的指令 <code class="language-plaintext highlighter-rouge">&lt;p *ngIf="true"&gt;</code> <code class="language-plaintext highlighter-rouge">&lt;p v-if="true"&gt;</code> 则类似于模板属性语言，众所周知它们也都支持自定义标签的组件化开发。这里我有意忽略了 React 的 JSX：在 JSX 中 JS 反客为主，HTML 组件变成了 JS 代码的一部分，恕我不算它是 HTML 模板语言了。</p>

<p>总而言之，本文力求归纳了主流的 HTML 模板范式，但 Web 开发毕竟不是我的主业，行文难免有所疏漏，但愿不会贻笑大方。</p>]]></content><author><name>孙耀珠</name></author><category term="领域专用语言" /><summary type="html"><![CDATA[前端开发的本质，是把结构化的数据映射到 HTML。HTML 本身是静态的，因此模板引擎应运而生，接下了动态生成 HTML 的任务，直到近年来在前后端分离的浪潮下被面面俱到的前端框架所兼并。本文试图梳理出模板语言的主流范式，不过注意本文并非按照时间线编排，如果要还原历史的话，应该是 PHP (1995) → Zope 2 (1998) → JSTL (2002) → Django (2005) → Haml (2006) → Mustache (2009) → AngularJS (2010)。]]></summary></entry><entry><title type="html">Linux 内核性能演变</title><link href="https://blog.yzsun.me/linux-performance/" rel="alternate" type="text/html" title="Linux 内核性能演变" /><published>2020-04-10T00:00:00+00:00</published><updated>2020-04-10T00:00:00+00:00</updated><id>https://blog.yzsun.me/linux-performance</id><content type="html" xml:base="https://blog.yzsun.me/linux-performance/"><![CDATA[<blockquote>
  <p>本文是我在《系统设计与实现》课程的热点话题阅读报告，内容来源于 Xiang (Jenny) Ren, et al. 发表在 SOSP 2019 的论文《<a href="https://doi.org/10.1145/3341301.3359640">An Analysis of Performance Evolution of Linux’s Core Operations</a>》（下文简称 [<em>Ren19</em>]）。</p>
</blockquote>

<ul id="markdown-toc">
  <li><a href="#内核操作" id="markdown-toc-内核操作">内核操作</a></li>
  <li><a href="#安全补丁" id="markdown-toc-安全补丁">安全补丁</a>    <ul>
      <li><a href="#幽灵补丁" id="markdown-toc-幽灵补丁">幽灵补丁</a></li>
      <li><a href="#熔毁补丁" id="markdown-toc-熔毁补丁">熔毁补丁</a></li>
      <li><a href="#slab-自由表随机化" id="markdown-toc-slab-自由表随机化">Slab 自由表随机化</a></li>
      <li><a href="#用户空间拷贝强化检查" id="markdown-toc-用户空间拷贝强化检查">用户空间拷贝强化检查</a></li>
    </ul>
  </li>
  <li><a href="#新增特性" id="markdown-toc-新增特性">新增特性</a>    <ul>
      <li><a href="#控制组内存控制器" id="markdown-toc-控制组内存控制器">控制组内存控制器</a></li>
      <li><a href="#透明大页" id="markdown-toc-透明大页">透明大页</a></li>
      <li><a href="#缺页的局部性原理" id="markdown-toc-缺页的局部性原理">缺页的局部性原理</a></li>
      <li><a href="#用户空间缺页处理" id="markdown-toc-用户空间缺页处理">用户空间缺页处理</a></li>
    </ul>
  </li>
  <li><a href="#错误配置" id="markdown-toc-错误配置">错误配置</a>    <ul>
      <li><a href="#强制上下文追踪" id="markdown-toc-强制上下文追踪">强制上下文追踪</a></li>
      <li><a href="#cpu-闲置状态" id="markdown-toc-cpu-闲置状态">CPU 闲置状态</a></li>
      <li><a href="#tlb-大小识别" id="markdown-toc-tlb-大小识别">TLB 大小识别</a></li>
    </ul>
  </li>
  <li><a href="#结语" id="markdown-toc-结语">结语</a></li>
</ul>

<p>1991年9月17日，赫尔辛基大学的大四学生 Linus Torvalds 向 ftp.funet.fi 上传了自己课余时间编写的 Linux 0.01 源代码，由此揭开了开源操作系统的崭新篇章。如今，Linux 已成为最主流的服务器操作系统，TOP500 榜单中的超级计算机更是悉数采用。在高性能计算对 Linux 依赖越来越强的大背景下，[<em>Ren19</em>] 对近年来 Linux 内核的核心操作性能进行了系统性的评估，得到一个骇人听闻的结论：绝大多数内核操作的性能均有退化。不过值得庆幸的是，研究团队发现可以通过编译配置或是简单的补丁来禁用掉那些导致性能退化的内核改动。</p>

<!--more-->

<p>[<em>Ren19</em>] 之所以选择对内核操作（包括 <code class="language-plaintext highlighter-rouge">epoll</code> 等系统调用以及上下文切换等）进行分析，是因为随着硬盘读写和网络设备速度的提升，今后服务器的性能瓶颈可能会是操作系统的内核操作。以前相关的操作系统性能研究大多着眼于不同处理器架构上的性能差异，而如今 x86-64 架构已经一统天下了，因此保持硬件参数不变对操作系统进行时间尺度的分析更具有现实意义。研究团队基于 Ubuntu 发行版的默认配置，选取了 Linux 内核 3.0 到 4.20 共 41 个版本进行了基准测试，它们的发布时间横跨 2011 年到 2018 年。为了确定哪些是实际场景中常用的系统调用，研究团队用 <code class="language-plaintext highlighter-rouge">strace</code> 命令统计了 Spark、Redis、PostgreSQL、Chromium 和 GCC 等典型应用的计算任务，从中选取了八组总用时最多的内核操作进行基准测试。</p>

<p><img src="/images/linux-performance.png" alt="" /></p>

<p>基准测试的最终结果如上图 (a) 所示：以 4.0 版本的内核为基准，除了 <code class="language-plaintext highlighter-rouge">big-write</code> 和 <code class="language-plaintext highlighter-rouge">big-munmap</code> 之外的所有内核操作都不同程度地变慢了，其中退步最大的 <code class="language-plaintext highlighter-rouge">poll</code> 甚至比之前慢了 136%。为了找出导致这些内核操作性能退化的原因，研究团队调查了 Linux 内核各版本之间的代码变化，最终确认了 11 处关键性改动，如上图 (b) 所示。这些严重影响了 Linux 内核性能的改动可以被归为三类：安全补丁、新增特性和错误配置。</p>

<p>不过在解释这些导致性能退化的原因之前，我们先盘点一下研究团队筛选出的内核操作都有哪些。</p>

<h2 id="内核操作">内核操作</h2>

<ul>
  <li>上下文切换：当中断发生时，系统需要将当前进程的状态保存进进程控制块（PCB），以便处理器切换到下一个进程。该测试让两个进程通过管道不停地互相通信，以强制进行上下文切换。</li>
  <li><code class="language-plaintext highlighter-rouge">read</code>/<code class="language-plaintext highlighter-rouge">write</code>：读写文件。为了测试不同规模文件的读写性能，文件大小定为了一、十、万页三个档次（1 页 ＝ 4096 字节）。</li>
  <li><code class="language-plaintext highlighter-rouge">mmap</code>/<code class="language-plaintext highlighter-rouge">munmap</code>：将文件映射到内存，或取消其映射。测试文件大小同上。</li>
  <li><code class="language-plaintext highlighter-rouge">fork</code>：创建一个跟自身一样的新进程。big-fork 在进程复制前映射了 12000 页文件。</li>
  <li>线程创建：使用 POSIX threads 创建线程（本质上是 Linux 轻量级进程）。</li>
  <li><code class="language-plaintext highlighter-rouge">send</code>/<code class="language-plaintext highlighter-rouge">recv</code>：使用 Berkeley sockets 进行本地进程间通信。</li>
  <li><code class="language-plaintext highlighter-rouge">select</code>/<code class="language-plaintext highlighter-rouge">poll</code>/<code class="language-plaintext highlighter-rouge">epoll</code>：均为 Reactor 模式的 I/O 多路复用（multiplexing）机制，<code class="language-plaintext highlighter-rouge">select</code> 和 <code class="language-plaintext highlighter-rouge">poll</code> 列入了 POSIX 标准但性能不够好，而新的 <code class="language-plaintext highlighter-rouge">epoll</code> 于 Linux 2.5.44 加入。</li>
  <li>缺页：当进程试图访问其地址空间中的数据时，若内存管理单元（MMU）发现该虚拟地址尚未映射到内存，则会触发缺页中断。该测试会访问刚刚 <code class="language-plaintext highlighter-rouge">mmap</code> 上来的页，触发缺页让系统真正把文件从硬盘拷贝到内存。</li>
</ul>

<p>盘点了基准测试中的内核操作之后，让我们按照分类逐一解释导致 Linux 内核性能退化的原因。关于头两个补丁所涉及的幽灵和熔毁漏洞的详细介绍，可以参考<a href="/spectre-meltdown-foreshadow/">我之前的文章</a>。</p>

<h2 id="安全补丁">安全补丁</h2>

<h3 id="幽灵补丁">幽灵补丁<sup id="fnref:spectre"><a href="#fn:spectre" class="footnote" rel="footnote" role="doc-noteref">1</a></sup></h3>

<p>第一个安全补丁针对的是 Spectre-V2 的分支目标注入，其为 Linux 内核编译配置加入了默认开启的 <code class="language-plaintext highlighter-rouge">RETPOLINE</code> 选项。该选项会向 GCC 添加 <code class="language-plaintext highlighter-rouge">-mindirect-branch=thunk-extern</code> 参数，从而绕过处理器对间接跳转指令的预测执行。该补丁让半数测试慢了 10% 以上，而影响较为严重的 <code class="language-plaintext highlighter-rouge">select</code> 一下子慢了 68%。研究团队对其中的原因进行了调查，发现 <code class="language-plaintext highlighter-rouge">select</code> 系列函数的代码有三处频繁执行的间接跳转，譬如其中一处位于 <code class="language-plaintext highlighter-rouge">fs/select.c</code>（4.18 版本之前）：</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">static</span> <span class="kt">int</span> <span class="nf">do_select</span><span class="p">(...)</span>
<span class="p">{</span>
  <span class="k">for</span> <span class="p">(;;)</span> <span class="p">{</span>
    <span class="n">mask</span> <span class="o">=</span> <span class="p">(</span><span class="o">*</span><span class="n">f_op</span><span class="o">-&gt;</span><span class="n">poll</span><span class="p">)(</span><span class="n">f</span><span class="p">.</span><span class="n">file</span><span class="p">,</span> <span class="n">wait</span><span class="p">);</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>我们可以看到这里的 <code class="language-plaintext highlighter-rouge">f_op-&gt;poll</code> 是个函数指针，因此该函数调用会被编译为间接跳转指令。因为我们通过编译选项愚弄了分支预测器，导致每次都会有三十多个时钟周期的延时，这也就是 <code class="language-plaintext highlighter-rouge">select</code> 系列函数变慢的原因。研究团队尝试用 if-else 枚举函数指针所有可能的值，将间接跳转改为了直接跳转，成功地将减速比从 68% 降到了 5.7%。</p>

<h3 id="熔毁补丁">熔毁补丁<sup id="fnref:spectre:1"><a href="#fn:spectre" class="footnote" rel="footnote" role="doc-noteref">1</a></sup></h3>

<p>第二个安全补丁针对的是臭名昭著的 Meltdown 漏洞，也就是广为人知的内核页表隔离（KPTI）补丁。为了防止恶意程序读取任意内存数据，KPTI 分离了用户态和内核态的页表，用户态无法再访问绝大部分内核地址空间。KPTI 最大的开销来源于进出内核态需要切换页表和清空转译后备缓冲器（TLB），这包括页表指针寄存器（CR3）的两次写入和 TLB 的大量未命中。以前进出内核态的开销少于 100 个时钟周期，而 CR3 写入带来了 400 多个周期的开销，而后续 TLB 未命中的中断处理程序能为 big-read 带来 6000 个周期的额外开销。</p>

<p>为了改善这种情况，Linux 内核开发者利用英特尔处理器的上下文标识符（PCID）避免了每次清空 TLB：不同的 PCID 对应不同的地址空间，每个 TLB 条目可以附上 PCID 以同时管理多个地址空间的页表缓存。PCID 优化给 KPTI 带来了巨大的性能提升，能将小规模测试的减速比从 113% 从 47%，不过这无法优化掉 CR3 写入的开销，因为当前活跃的 PCID 仍需写入 CR3 寄存器。</p>

<p>如果对于性能有极致的追求，想要彻底关掉 KPTI，有两种办法：一是编译内核的时候就在配置中关掉 <code class="language-plaintext highlighter-rouge">PAGE_TABLE_ISOLATION</code>，二是在内核启动参数中加上 <code class="language-plaintext highlighter-rouge">nopti</code>。AMD 的用户则完全不必担心，因为 Meltdown 攻击完全不影响 AMD 处理器，所以 KPTI 已被自动禁用了。</p>

<h3 id="slab-自由表随机化">Slab 自由表随机化<sup id="fnref:slab"><a href="#fn:slab" class="footnote" rel="footnote" role="doc-noteref">2</a></sup></h3>

<p>Slab 分配器最初是为 SunOS 内核数据结构设计的内存分配器，如今被 Linux 和 FreeBSD 等操作系统广泛使用，譬如 <code class="language-plaintext highlighter-rouge">fork</code> 就在用它来分配 <code class="language-plaintext highlighter-rouge">mm_struct</code> 对象。Slab 分配器使用一个自由表（free list）串联未分配的内存区域，因为邻接的往往都是连续的内存地址，因此这种可预测性容易被用来进行缓冲区溢出攻击。从 Linux 4.7 开始，编译配置加入了 <code class="language-plaintext highlighter-rouge">SLAB_FREELIST_RANDOM</code> 选项，启用后会用 Fisher-Yates 随机排列算法打乱自由表的顺序。不过安全性也伴随着性能的代价，big-fork 变慢了 37%，big-select 系列函数平均变慢了 41%，这背后有两个原因：一是随机化自由表本身需要时间，二是不连续分配的内存破坏了访存局部性。</p>

<h3 id="用户空间拷贝强化检查">用户空间拷贝强化检查<sup id="fnref:usercopy"><a href="#fn:usercopy" class="footnote" rel="footnote" role="doc-noteref">3</a></sup></h3>

<p>Linux 内核代码常常需要在内核空间和用户空间之间拷贝数据，这就需要用到 <code class="language-plaintext highlighter-rouge">copy_from_user</code> 和 <code class="language-plaintext highlighter-rouge">copy_to_user</code> 两个函数。如果内核开发者没有处理好相关调用，从用户空间拷贝了过多数据会导致缓冲区溢出，向用户空间拷贝了过多数据会造成内核空间数据泄漏。之前这两个函数只检查用户空间指针，从 Linux 4.8 引入的 Hardened Usercopy 补丁开始，内核空间指针也会进行非常严格的安全性检查，包括不允许为空指针、不允许指向 <code class="language-plaintext highlighter-rouge">kmalloc</code> 分配的零长度区域、不允许指向内核代码段、如果指向 Slab 则不允许超过 Slab 分配器分配的长度、如果涉及到栈则不允许超出当前进程的栈空间等等。这些繁琐的检查使得 <code class="language-plaintext highlighter-rouge">select</code>/<code class="language-plaintext highlighter-rouge">poll</code> 测试变慢了将近 18%，不过 <code class="language-plaintext highlighter-rouge">epoll</code> 很少进行拷贝所以影响不大；而 <code class="language-plaintext highlighter-rouge">read</code> 虽然会向用户空间拷贝数据，但由于不是 Slab 所以也几乎不受影响。</p>

<h2 id="新增特性">新增特性</h2>

<h3 id="控制组内存控制器">控制组内存控制器<sup id="fnref:cgroups"><a href="#fn:cgroups" class="footnote" rel="footnote" role="doc-noteref">4</a></sup></h3>

<p>控制组（cgroups）及其内存控制器早在 Linux 2.6.24 就引入了，这也是 LXC 和 Docker 等容器化技术的基础之一。不过即使在没有使用控制组功能时，内存控制器对内存使用额的监控工作仍然造成了 big-munmap 81% 的性能损失。直到 Linux 3.17，内核开发者才对其做了批处理的优化，将性能损失降到了 9%。</p>

<h3 id="透明大页">透明大页<sup id="fnref:thp"><a href="#fn:thp" class="footnote" rel="footnote" role="doc-noteref">5</a></sup></h3>

<p>饱受争议的透明大页（transparent hugepage）也是影响访存性能的一大因素。众所周知，页是虚拟内存管理的最小单位，通常一页默认是 4KiB，但也可以手动设定为诸如 2MiB 的大页。不过由于手动管理页的大小比较麻烦，于是 Linux 等操作系统提供了透明大页功能，系统能够自动提升或下调页的大小。积极来讲，大页能够减少页表占用空间、降低缺页频率、并且能提高 TLB 命中率，对于大量使用内存的程序来说会有性能提升；然而另一方面，透明大页容易导致内部碎片化，低缺页率的代价是每次缺页加载时间显著增加，并且其后台进程也带来了额外开销。如今透明大页已经被默认禁用了，但禁用透明大页给极端的内存密集型测试 huge-read 带来了 83% 的性能退化。</p>

<h3 id="缺页的局部性原理">缺页的局部性原理<sup id="fnref:faultaround"><a href="#fn:faultaround" class="footnote" rel="footnote" role="doc-noteref">6</a></sup></h3>

<p>Linux 3.15 新增的 fault around 策略旨在减少次要缺页（minor page fault）。如果当前请求的这一页没有页表项，但实际上已经装进页缓存了，只需通知 MMU 建立映射关系即可，则这种缺页被称为次要缺页。在遇到次要缺页时，Linux 不仅会处理当前页，还会帮前后的若干页都建立映射关系。当然，这种优化策略是建立在访存局部性的基础之上的，像 big-pagefault 这种极端的不满足局部性的测试，就出现了高达 54% 的性能退化。</p>

<h3 id="用户空间缺页处理">用户空间缺页处理<sup id="fnref:userfaultfd"><a href="#fn:userfaultfd" class="footnote" rel="footnote" role="doc-noteref">7</a></sup></h3>

<p>Linux 4.6 新增的系统调用 <code class="language-plaintext highlighter-rouge">userfaultfd</code> 支持了在用户态处理指定范围内的缺页，这对于用户态的虚拟机监视器（VMM / Hypervisor）相当有帮助。譬如在进行虚拟机迁移之后，VMM 可以通过 <code class="language-plaintext highlighter-rouge">userfaultfd</code> 按需拷贝内存页，这种模式被称为 post-copy。不过 big-fork 由于这个新特性损失了 4% 的性能，因为在进程复制时需要检查父进程内存区域关联的用户空间缺页处理信息。</p>

<h2 id="错误配置">错误配置</h2>

<h3 id="强制上下文追踪">强制上下文追踪</h3>

<p>在 Ubuntu 发行版中，Linux 内核编译配置中的 <code class="language-plaintext highlighter-rouge">CONTEXT_TRACKING_FORCE</code> 选项曾被错误开启，这是在开发降低调度时钟滴答频率<sup id="fnref:rsct"><a href="#fn:rsct" class="footnote" rel="footnote" role="doc-noteref">8</a></sup>（RSCT）功能时用来测试上下文追踪的调试选项。时钟滴答（tick）本质上就是定时器芯片产生的时钟中断，源源不绝的时钟中断为更新系统时间、执行进程调度提供了时机，但在处理器闲置时过于频繁的中断会增加功耗，而且在运行单个计算密集型程序时会造成干扰。因此，Linux 内核编译配置提供了三种 RSCT 选项（选项中的 HZ 意为每秒的时钟中断数，Linux 目前默认为 250）：</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">HZ_PERIODIC</code> 表示永不忽略时钟滴答；</li>
  <li><code class="language-plaintext highlighter-rouge">NO_HZ_IDLE</code> 表示在处理器闲置时忽略时钟滴答，这是默认选项；</li>
  <li><code class="language-plaintext highlighter-rouge">NO_HZ_FULL</code> 表示在处理器闲置或只有一个可执行的任务时忽略时钟滴答，建议只在进行实时计算或某些高性能计算任务时开启此选项。</li>
</ul>

<p>在开启了 RSCT 后，平时在时钟中断时做的工作就得挪到用户态和内核态切换的时候做，这些工作就被称为上下文追踪。这些工作包括统计在用户态和内核态的执行时间，以及处理 read-copy-update 同步机制注册的回调。强制上下文追踪会在所有处理器核心上都进行上下文追踪，不管有没有开启 RSCT，这导致前面的所有测试平均变慢了 50%。</p>

<h3 id="cpu-闲置状态">CPU 闲置状态</h3>

<p>Linux 3.9 为英特尔处理器的 Haswell 架构（研究团队在用）引入了一个补丁，让内核的驱动模块能够更加细粒度地控制处理器的功耗和闲置状态。不过这个补丁并没有移植到当时尚未停止维护的旧版本上，导致之前的版本容易陷入更深的闲置状态从而降频，打了补丁能将有效工作频率提高 31%。</p>

<h3 id="tlb-大小识别">TLB 大小识别</h3>

<p>Linux 3.14 又为英特尔处理器引入了一个补丁，能够识别其二级 TLB 的大小以对 <code class="language-plaintext highlighter-rouge">munmap</code> 的实现进行优化。<code class="language-plaintext highlighter-rouge">munmap</code> 时让 TLB 项失效有两种策略：一是就处理那些失效项，二是清空整个 TLB。在这个补丁之前，只有一级 TLB 的大小会被纳入考虑，导致只要超过一项就会清空整个 TLB，大大降低了之后页表缓存的命中率。</p>

<h2 id="结语">结语</h2>

<p>研究团队通过时间维度上的对比分析，揪出了 11 条造成 Linux 内核性能退化的原因，其中 88% 的影响是强制上下文追踪、熔毁补丁、粗粒度 CPU 闲置状态、幽灵补丁四项导致的。因为错误配置属于可以避免的人为错误，而新增特性对性能的影响面并不大，所以真正给 Linux 性能带来致命一击的就是幽灵系列漏洞的安全补丁。所以说，预测执行是一把双刃剑，想要绝对的安全就不得不放弃性能。</p>

<p>另外，正如研究团队所说，内核性能调优是个相当费时费力的工作。如果没有大量的精力投入到 Linux 这个快速迭代的庞然大物上，还是购买 RHEL 等高度调优的商业发行版更加划算。</p>

<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:spectre">
      <p><a href="https://lwn.net/Articles/744287/">https://lwn.net/Articles/744287/</a> <a href="#fnref:spectre" class="reversefootnote" role="doc-backlink">&#8617;</a> <a href="#fnref:spectre:1" class="reversefootnote" role="doc-backlink">&#8617;<sup>2</sup></a></p>
    </li>
    <li id="fn:slab">
      <p><a href="https://lwn.net/Articles/685047/">https://lwn.net/Articles/685047/</a> <a href="#fnref:slab" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:usercopy">
      <p><a href="https://lwn.net/Articles/695991/">https://lwn.net/Articles/695991/</a> <a href="#fnref:usercopy" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:cgroups">
      <p><a href="https://lwn.net/Articles/256389/">https://lwn.net/Articles/256389/</a> <a href="#fnref:cgroups" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:thp">
      <p><a href="https://lwn.net/Articles/359158/">https://lwn.net/Articles/359158/</a> <a href="#fnref:thp" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:faultaround">
      <p><a href="https://lwn.net/Articles/588802/">https://lwn.net/Articles/588802/</a> <a href="#fnref:faultaround" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:userfaultfd">
      <p><a href="https://lwn.net/Articles/636226/">https://lwn.net/Articles/636226/</a> <a href="#fnref:userfaultfd" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
    <li id="fn:rsct">
      <p><a href="https://lwn.net/Articles/549580/">https://lwn.net/Articles/549580/</a> <a href="#fnref:rsct" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>孙耀珠</name></author><category term="文献阅读" /><summary type="html"><![CDATA[本文是我在《系统设计与实现》课程的热点话题阅读报告，内容来源于 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 内核的核心操作性能进行了系统性的评估，得到一个骇人听闻的结论：绝大多数内核操作的性能均有退化。不过值得庆幸的是，研究团队发现可以通过编译配置或是简单的补丁来禁用掉那些导致性能退化的内核改动。]]></summary></entry><entry><title type="html">跨越国境的汉字</title><link href="https://blog.yzsun.me/cjkv/" rel="alternate" type="text/html" title="跨越国境的汉字" /><published>2020-03-29T00:00:00+00:00</published><updated>2020-03-29T00:00:00+00:00</updated><id>https://blog.yzsun.me/cjkv</id><content type="html" xml:base="https://blog.yzsun.me/cjkv/"><![CDATA[<p>我身边的同学们大都只懂汉语和英语，聊天时偶尔会发现他们对日本、韩国、朝鲜和越南的汉字使用知之甚少。比如有人见到汉字就觉得是汉语，但实际上日韩朝越都在以自己的方式使用着汉字，比如很难说「大変面白」「本当上手」也算是规范汉语，虽然这些字我们都认识。</p>

<p>另一个常见的误解是：为什么 Matsumoto Yukihiro 被翻译成了松本行弘？为什么 Jang Won-young 被翻译成了张员瑛？首先要意识到英语不是日韩的母语，因此上面的罗马字也只是音译。实际上 Matz 的姓名本来就是汉字「松本行弘」，只是这四个字都用了日语训读，导致中日读音大相径庭。而韩国的情况更麻烦一点，因为他们现在几乎不用汉字了，所以姓名里的谚文对应哪个汉字要么靠猜要么询问本人。张员瑛的原名是「장원영」，一开始大家猜测其对应的汉字是「張元英」，不过很遗憾猜错了，后来官方宣布她的姓名汉字是「張員瑛」。当然日本人名也有要猜的时候，比如说「松山ケンイチ」「石原さとみ」（也都猜错过，哈哈哈哈）。不过如今对汉字如此执着的也只有中国了，日韩互译对方人名的时候并不会追溯到汉字，而是直接按照当地的读音来音译（即现地音）：松本行弘在韩语里就叫 마츠모토 유키히로，张员瑛在日语里就叫 チャン・ウォニョン。</p>

<!--more-->

<p>我去日本交流的时候，遇到的第一个难题就是我的名字在日本应该怎么叫。在登记在留卡的时候，外国人的姓名默认都用拉丁字母，也就是我护照上的拼音「SUN YAOZHU」。不过之后在办学籍或者银行账户的时候，还需要提供振假名，也就是姓名的读法，于是士大夫照着拼音帮我填了个读起来奇奇怪怪的「スン・ヤオズ」……其实日本对于姓名的读法是相当宽容的，完全遵照名从主人的原则，像「村山彩希」的名字念作「ゆいり」这种毫无根据的读法也不会提出异议。后来我自己使用的读法是「ソン・ヨウジュ」，是我姓名的汉字在日语中的音读，这也是日本对中国人名的正统处理方式（不过如今现地音越来越流行了）。</p>

<p>上面提到的音读与训读是汉字文化圈特有的读音现象，以日语为例，所谓音读就是保留汉字传入日本时的汉语读音（包括吴音、汉音、唐音等），而所谓训读就是用日本固有词汇的读音来念同义的汉字。换句话说，音读词都是以中国为源头的汉语词（不过后来日本也发明了相当多的和制汉语并回流了中国），训读词都是借用汉字表记的和语词。比如「人」字，在单独出现时用训读 ひと，这是日本固有的和语词，而在汉语词「[人間]{にんげん}」中读吴音、「[人類]{じんるい}」中读汉音。不仅如此，日语词汇中还有好多和汉混血儿，比如「[湯桶]{ゆとう}」前训读后音读，相反地「[重箱]{じゅうばこ}」前音读后训读，所以很多生僻词不注音的话日本人也是不会念的。现代韩语在这方面就简单得多，除了少数多音字之外汉字一字一音（都是音读），而且韩语跟汉语读音更为接近，其固有词不用汉字只用谚文表记；而越南语的汉字也几乎都读汉越音（即音读），极少有训读现象。</p>

<h2 id="书写系统">书写系统</h2>

<p>过去，中日朝越等国都有用文言文作为书面语言，因此各国受过高等教育的文人都能跨越语言障碍进行笔谈。然而随着各国语言文字的演进，文言文已全面被白话文所取代，越南和朝鲜在 20 世纪中叶先后废除了汉字，汉字在韩国也日渐式微，而且中国和日本各自简化了汉字，东亚汉字文化圈的联系已大不如从前了。下面先引用一张来自<a href="https://commons.wikimedia.org/wiki/File:漢字文化圈%EF%BC%8F汉字文化圈_·_한자_문화권_·_Vòng_văn_hóa_chữ_Hán_·_漢字文化圏.svg">维基百科</a>的图片来形象地展示一下汉字文化圈内存在的书写系统（黑色为汉语词、绿色为固有词、蓝色为外来词）：</p>

<p><img src="/images/cjkv.png" alt="" /></p>

<p>日语不仅读法复杂，其书写系统也相当复杂，有汉字（漢字）、平假名（ひらがな）、片假名（カタカナ）三套体系，假名只表音不表意，各有约五十音。汉字是从中国舶来的自不必说，而平假名和片假名则由汉字草书和偏旁演变而来，它们分别扮演不同的角色：汉字多用来表记实词、平假名多用来表记虚词，片假名多用来表记外来语。虽然日语中的汉字也完全可以被读音所对应的假名取代，但为了视觉上方便断句以及消除同音词的歧义，大多数人还是用汉字写实词的。</p>

<p>而朝鲜半岛过去跟日本相仿，是汉谚混写的，但朝鲜自 1948 年成立后便完全废除了汉字，而韩国也从上世纪末开始渐渐停止了汉字的使用，现在几乎只使用谚文。谚文在韩国现称韩㐎（한글），是一套非常有意思的表音系统，其诞生时间很晚，直到 15 世纪才发明出来。谚文由声母、韵母和韵尾三部分拼成，譬如 한 的声母是 ㅎ (h)、韵母是 ㅏ (a)、韵尾是 ㄴ (n)，合起来就是 han。得益于这种模块化的组合方式，谚文理论上可以拼装出 19×21×28 = 11172 种音节，信息密度远高于其他表音文字。</p>

<p>在 19 世纪中叶越南被法国占领之前，越南语也是主要使用汉字的，他们把源自中国的汉字称为儒字，把汉语词称为汉越词。不过儒字并不能准确记录越南的固有词汇，于是他们基于儒字发明了喃字（𡨸喃）。这些字大部分都是形声字，譬如「[𡨸]{chữ}」，它借了「[宁]{trữ}」的音和「[字]{tự}」的意。这种为本土语言造字的现象有点像粤语字，「哋」「啲」等字在汉语官话中也没有，但在粤语地区广泛使用。然而后来在法属印度支那时期，越南语的罗马化方案渐渐流行起来，并于 1945 年北越独立后取代了儒字和喃字成为越南唯一官方文字，被称为国语字（chữ quốc ngữ）。</p>

<h2 id="各地汉字">各地汉字</h2>

<p>正如前面所介绍，日本仍在广泛使用汉字，韩国、朝鲜和越南曾经使用过汉字，而中国大陆、港澳、台湾、新加坡目前以汉语为官方语言，当然也在用汉字，那么各地汉字长得一样吗？答案是否定的。这个问题既涉及到汉字简化，也涉及到字形标准，甚至还涉及到日本当用汉字的问题。</p>

<p>中国最早推行汉字简化是在国民政府时期，中华民国教育部于 1935 年公布《<a href="https://zh.wikisource.org/wiki/第一批简体字表">第一批简体字表</a>》，共计 324 字，但因考试院院长戴季陶坚决反对，最终未能实行。第二个尝试简化汉字的是日本，1946 年日本内阁发布了《<a href="https://www.bunka.go.jp/kokugo_nihongo/sisaku/joho/joho/kakuki/syusen/tosin02/index.html">当用汉字表</a>》，共计 1850 字，其中一百余字采用新字体，也就是简化汉字。不过这份《当用汉字表》后来在 1981 年被《<a href="https://www.bunka.go.jp/kokugo_nihongo/sisaku/joho/joho/kijun/naikaku/kanji/">常用汉字表</a>》所取代，2010 年最新版本共收录 2136 字，其中三百余字采用新字体，另外《人名用汉字表》和《表外汉字字体表》亦引入了一些新字体。中华人民共和国成立后，文字改革委员会重启了汉字简化工作，并于 1955 年发表了《<a href="https://zh.wikisource.org/wiki/漢字簡化方案">汉字简化方案</a>（草案）》，次年国务院通过决议确立了其规范汉字的地位，后来又于 1964 年推出了改进版本《<a href="https://zh.wikisource.org/wiki/简化字总表">简化字总表</a>》。1977 年，文字改革委员会发表《<a href="https://zh.wikipedia.org/wiki/二简字">第二次汉字简化方案</a>（草案）》，尝试进一步简化汉字，不过最终遭到废止。因此，中国最新的汉字规范 2013 年《<a href="https://zh.wikisource.org/wiki/通用规范汉字表">通用规范汉字表</a>》仍沿用第一次汉字简化方案，共简化了 2461 字。新加坡则于 1969 年推出过自己的《<a href="https://zh.wikipedia.org/wiki/新加坡漢字">简体字表</a>》，但 1976 年开始完全转用中国的简化方案。</p>

<p>综上所述，中国大陆和新加坡采用同一套汉字简化方案，而日本用自己的新字体，剩下的港澳台韩朝越都没有官方推行汉字简化。中日两国的简化方案，既有简化相同的「國／国」，也有简化不同的「譯／译／訳」，既有中国简化日本没简的「東／东」，也有日本简化中国没简的「假／仮」，不过总体来说中国比日本简化了更多汉字。另外有一个跟繁简相近的概念是异体字，也就是长期存在的读音和意思相同、但字形不同的汉字。不同的地区会选择不同的异体字作为正字，譬如中国大陆以「够」为正字而港台以「夠」为正字，香港用「裏」而台湾用「裡」等等。日本还有一个更复杂的情况是《当用汉字表》引起的同音假借现象：1946 年的《当用汉字表》将出版物的汉字使用限制在了最常用的 1850 字内，致使大量表外汉字需要用同音汉字进行替代。日本国语审议会为此发表了《<a href="https://www.bunka.go.jp/kokugo_nihongo/sisaku/joho/joho/kakuki/03/bukai03/03.html">同音汉字转写</a>》的报告供出版业参考，譬如「綜合」转写为「総合」、「智慧」转写为「知恵」等等。虽然当用汉字的限制已于 1981 年废除，但大量日语词汇的用字已经不可逆转地改变了。</p>

<p>各国的印刷字形标准也不尽相同。举前面那张图中的「圈」为例，这里中国大陆和台湾字形基本相同，除了台湾将捺改成了点，日本字形则把下边的「㔾」改成了「己」并且捺会贯穿上面一横，而韩国字形上边不是「丷」而是「ハ」。韩国字形与传统的康熙字典体最为接近，而中国大陆和台湾则有成文的新字形标准——《印刷通用汉字字形表》《常用国字标准字体表》，各有各的不同。</p>

<h2 id="罗马字">罗马字</h2>

<p>对于西方人来说，汉字已经是极大的障碍了。不仅如此，日语使用假名、韩语使用谚文，没背过字母表的人根本无法认读它们。因此日语和韩语都有各自的罗马化（拉丁字母转写）方案，就像汉语拼音和越南国语字一样。上面提到的 Matsumoto Yukihiro 和 Jang Won-young 就分别是 まつもと ゆきひろ 和 장원영 的罗马字。日韩的罗马字方案有很多种，日语常用的是<a href="https://zh.wikipedia.org/wiki/平文式罗马字">平文式罗马字</a>和<a href="https://zh.wikipedia.org/wiki/训令式罗马字">训令式罗马字</a>，而韩语常用的是<a href="https://zh.wikipedia.org/wiki/馬科恩-賴肖爾表記法">马科恩-赖肖尔表记法</a>和<a href="https://zh.wikipedia.org/wiki/文化观光部2000年式">文观部2000年式</a>。</p>

<p>日语平文式和训令式的主要区别在于 し/しゃ/じ/じゃ、ち/ちゃ/ぢ/ぢゃ、つ/づ、ふ 相关的表记，平文式记为 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 就是平文式罗马字。</p>

<p>韩语马赖式和文观部式的主要区别在于元音 ㅓ、ㅕ、ㅝ、ㅡ、ㅢ 和辅音 ㄱ、ㄷ、ㅂ、ㅈ、ㅉ 用在声母时的表记，马赖式记为 ŏ、yŏ、wŏ、ŭ、ŭi 和 k、t、p、ch、tch，而文观部式记为 eo、yeo、wo、eu、ui 和 g、d、b、j、jj。目前朝鲜官方使用稍作修改的马赖式，韩国官方使用文观部式，但韩国民众日常转写时并不一定遵循，譬如 Jang Won-young 就不是两种方案的任何一种。</p>

<p>这里顺便提一下汉语的罗马化方案，其中我们最熟悉的当然是已经成为 ISO 7098 标准的汉语拼音，然而汉语拼音在港澳台并不通行。首先，台湾跟中国大陆最不同的是他们使用注音符号（ㄅㄆㄇㄈ）而非拉丁字母来给汉字注音，所以台湾人并没有在课堂上学过如何拼注罗马拼音。台湾政府早期采用过<a href="https://zh.wikipedia.org/wiki/國語羅馬字">国语罗马字</a>和<a href="https://zh.wikipedia.org/wiki/國語注音符號第二式">注音符号第二式</a>，陈水扁时代则推行过<a href="https://zh.wikipedia.org/wiki/通用拼音">通用拼音</a>，马英九时代又开始推行汉语拼音，而民众大多基于<a href="https://zh.wikipedia.org/wiki/威妥瑪拼音">威妥玛拼音</a>来拼写自己的名字。威妥玛拼音早在 19 世纪中叶由英国驻华公使发明，相对比较符合英语使用者的习惯，譬如台北（Taipei）、台中（Taichung）、高雄（Kaohsiung）等都约定俗成地使用了威妥玛拼音，而没有遵循任何时期的政府规范。以威妥玛拼音为基础的<a href="https://zh.wikipedia.org/wiki/郵政式拼音">邮政式拼音</a>在中国大陆也留下了深远的影响，譬如北京（Peking）、苏州（Soochow）等，北大、苏大的英文名用的就是这套邮政式拼音。香港亦有数套粤语拼音方案并行，人名和地名一般使用<a href="https://zh.wikipedia.org/wiki/香港政府粵語拼音">香港政府粤语拼音</a>，而香港语言学学会则在推行<a href="https://zh.wikipedia.org/wiki/香港語言學學會粵語拼音方案">粤拼</a>以求统一教育界的粤语拼音使用。</p>

<h2 id="输入法">输入法</h2>

<p>输入法也是汉字文化圈的特产，因为西方国家通过调整键盘布局就能键入各种表音文字，常用语言只有汉字才非得要输入法才能键入。因为中国大陆一般使用拼音输入法、台湾一般用注音输入法，所以我一直很好奇香港人是不是会用粤拼输入法。后来发现粤拼在粤语地区仍不普及，香港人一般用仓颉输入法或其简化版本速成输入法，都属于字形输入法。</p>

<p>日韩本身也有自己的键盘布局，用来快捷地键入假名和谚文。但对于我们用 QWERTY 键盘的外国人来说，就只能学习罗马字输入法了。macOS 自带的日语输入法还好，能兼容平文式和训令式罗马字，但韩语输入法就相当令人迷惑了。macOS 列出的韩语键盘共有五种：2-Set、3-Set、390 Sebulshik、[工振厅罗马字]{GongjinCheong Romaja}、HNC [罗马字]{Romaja}。前三个对应的是三种不同的<a href="https://en.wikipedia.org/wiki/Keyboard_layout#Hangul_(for_Korean)">韩语键盘布局</a>，这里就不深究了，我也不吐槽苹果标新立异地把 Sebeolsik 拼成 Sebulshik 是什么心态了。后面两个明显是我们需要的罗马字输入法，但问题来了：这里的工振厅和 HNC 都是啥呢？</p>

<p>调查一番可以发现，工振厅是已于 1996 年撤销的韩国工业振兴厅，HNC 是韩国办公软件公司 Hancom (Haansoft)。从现存资料来看，工振厅并没有发布过罗马字方案，但我考证发现有韩国媒体报道过工振厅参与了韩朝双方关于 <a href="https://en.wikipedia.org/wiki/ISO/TR_11941">ISO/TR 11941</a> 的谈判，不过双方并没有就最终草案达成一致（正因如此韩语罗马字尚无国际标准可循）。所以我认为工振厅罗马字就是指这份已撤回的国际标准草案的韩方版本，其与韩国现行的文观部罗马字的比较可以参见<a href="http://www.eki.ee/wgrs/rom2_ko.pdf">这份报告</a>或者<a href="http://sori.org/hangul/romanizations.html">这篇文章</a>。而 HNC 罗马字指的是 Hancom 的旧式罗马字方案，其辅音部分跟工振厅罗马字相同，但元音的罗马字较为简短，复合元音是由基本元音直接相加而成，具体差异见下表（工振厅罗马字中 y/i 和 w/u 两对字母没有区别，HNC 中 y/i 没有区别）：</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>ㅐ</th>
      <th>ㅒ</th>
      <th>ㅓ</th>
      <th>ㅔ</th>
      <th>ㅕ</th>
      <th>ㅖ</th>
      <th>ㅘ</th>
      <th>ㅙ</th>
      <th>ㅚ</th>
      <th>ㅝ</th>
      <th>ㅞ</th>
      <th>ㅟ</th>
      <th>ㅡ</th>
      <th>ㅢ</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>工振</td>
      <td>ae</td>
      <td>yae</td>
      <td>eo</td>
      <td>e</td>
      <td>yeo</td>
      <td>ye</td>
      <td>wa</td>
      <td>wae</td>
      <td>oe</td>
      <td><em>weo</em></td>
      <td>we</td>
      <td>wi</td>
      <td>eu</td>
      <td><em>yi</em></td>
    </tr>
    <tr>
      <td>HNC</td>
      <td>ai</td>
      <td>yai</td>
      <td>e</td>
      <td>ei</td>
      <td>ye</td>
      <td>yei</td>
      <td>oa</td>
      <td>oai</td>
      <td>oi</td>
      <td>ue</td>
      <td>uei</td>
      <td>ui</td>
      <td>w</td>
      <td>wi</td>
    </tr>
  </tbody>
</table>

<p>另外附赠几个 macOS 日韩罗马字输入法的小贴士：</p>

<ul>
  <li>日语输入旧假名 ゐ/ヰ、ゑ/ヱ 可以敲 wyi、wye；</li>
  <li>日语输入小写假名可以加上前缀 l 或 x，譬如 ヶ 可以敲 lke 或 xke；</li>
  <li>日语中用于外来语的特殊拗音也有快速输入方式：ウォ who、ヴァ va、クァ qa/kwa、チェ che/tye、ツィ tsi、ティ thi、デュ dhu、トゥ twu、ファ fa/hwa；</li>
  <li>韩语输入硬音 ㄲ、ㄸ、ㅃ、ㅆ、ㅉ 不要双写而要按住 Shift 敲 g、d、b、s、j，另外按住 Shift 敲 a、e、o、u、w、i 可以快速输入 아、어、오、우、으、이（此处以 HNC 罗马字为例）；</li>
  <li>韩语输入汉字可以在输入谚文后按下 Option+Return 来选择候选字。</li>
</ul>

<h2 id="字符编码">字符编码</h2>

<p>最后简单聊聊汉字文化圈的字符编码，管中窥豹地看看 Unicode 的汉字编码遇到了怎样的困难。在 Unicode 以前，西方世界最常用的字符集标准是 ISO/IEC 8859，它将 ASCII 从 7-bit 扩展到了 8-bit，定义了 15 种变体服务于以欧洲为主的常用表音文字。但这样的单字节编码显然是不够存放汉字的，于是东亚各国基于可变长的 EUC 编码各自制定了自己的字符集。</p>

<p>中国大陆的字符集标准经历了 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）来专门负责各国汉字的统一工作，现任召集人是香港理工大学的陆勤教授。</p>

<p>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 只对字而不对字形或读音进行编码的大方针，因此扩展区汉字已经不再遵循了，字形的细微差异如今可以通过异体字选择器来指定。这项旷日持久的汉字认同工作还衍生了 <a href="https://www.unicode.org/charts/unihan.html">Unihan 数据库</a>，为每一个收录的汉字提供了在各国字源中的编码、各国字典中的索引、各地区的读音、英语释义、相关异体字等等，为汉字的国际标准化作出了巨大的贡献。</p>]]></content><author><name>孙耀珠</name></author><category term="杂谈" /><summary type="html"><![CDATA[我身边的同学们大都只懂汉语和英语，聊天时偶尔会发现他们对日本、韩国、朝鲜和越南的汉字使用知之甚少。比如有人见到汉字就觉得是汉语，但实际上日韩朝越都在以自己的方式使用着汉字，比如很难说「大変面白」「本当上手」也算是规范汉语，虽然这些字我们都认识。 另一个常见的误解是：为什么 Matsumoto Yukihiro 被翻译成了松本行弘？为什么 Jang Won-young 被翻译成了张员瑛？首先要意识到英语不是日韩的母语，因此上面的罗马字也只是音译。实际上 Matz 的姓名本来就是汉字「松本行弘」，只是这四个字都用了日语训读，导致中日读音大相径庭。而韩国的情况更麻烦一点，因为他们现在几乎不用汉字了，所以姓名里的谚文对应哪个汉字要么靠猜要么询问本人。张员瑛的原名是「장원영」，一开始大家猜测其对应的汉字是「張元英」，不过很遗憾猜错了，后来官方宣布她的姓名汉字是「張員瑛」。当然日本人名也有要猜的时候，比如说「松山ケンイチ」「石原さとみ」（也都猜错过，哈哈哈哈）。不过如今对汉字如此执着的也只有中国了，日韩互译对方人名的时候并不会追溯到汉字，而是直接按照当地的读音来音译（即现地音）：松本行弘在韩语里就叫 마츠모토 유키히로，张员瑛在日语里就叫 チャン・ウォニョン。]]></summary></entry><entry><title type="html">Megaparsec: Haskell 的语法分析组合子</title><link href="https://blog.yzsun.me/megaparsec/" rel="alternate" type="text/html" title="Megaparsec: Haskell 的语法分析组合子" /><published>2019-11-24T00:00:00+00:00</published><updated>2019-11-24T00:00:00+00:00</updated><id>https://blog.yzsun.me/megaparsec</id><content type="html" xml:base="https://blog.yzsun.me/megaparsec/"><![CDATA[<blockquote>
  <p>原文标题：Megaparsec tutorial from IH book<br />
原文链接：<a href="https://markkarpov.com/tutorial/megaparsec.html">https://markkarpov.com/tutorial/megaparsec.html</a></p>
</blockquote>

<!--more-->

<p>本篇 Megaparsec 教程原本是为《<a href="https://intermediatehaskell.com/">中级 Haskell</a>》一书写的一章。但由于这本书在过去的一年里没有什么进展，于是其他合著者同意将本文发表为一篇独立的教程，以飨读者。</p>

<ul id="markdown-toc">
  <li><a href="#parsect-和-parsec-单子" id="markdown-toc-parsect-和-parsec-单子"><code class="language-plaintext highlighter-rouge">ParsecT</code> 和 <code class="language-plaintext highlighter-rouge">Parsec</code> 单子</a></li>
  <li><a href="#字符和二进制流" id="markdown-toc-字符和二进制流">字符和二进制流</a></li>
  <li><a href="#单子式和可应用函子式语法" id="markdown-toc-单子式和可应用函子式语法">单子式和可应用函子式语法</a></li>
  <li><a href="#用-eof-耗尽输入" id="markdown-toc-用-eof-耗尽输入">用 <code class="language-plaintext highlighter-rouge">eof</code> 耗尽输入</a></li>
  <li><a href="#处理多种选择" id="markdown-toc-处理多种选择">处理多种选择</a></li>
  <li><a href="#用-try-控制回溯" id="markdown-toc-用-try-控制回溯">用 <code class="language-plaintext highlighter-rouge">try</code> 控制回溯</a></li>
  <li><a href="#调试语法分析器" id="markdown-toc-调试语法分析器">调试语法分析器</a></li>
  <li><a href="#标签和隐藏" id="markdown-toc-标签和隐藏">标签和隐藏</a></li>
  <li><a href="#运行语法分析器" id="markdown-toc-运行语法分析器">运行语法分析器</a></li>
  <li><a href="#monadparsec-类型类" id="markdown-toc-monadparsec-类型类"><code class="language-plaintext highlighter-rouge">MonadParsec</code> 类型类</a></li>
  <li><a href="#词法分析" id="markdown-toc-词法分析">词法分析</a>    <ul>
      <li><a href="#空格" id="markdown-toc-空格">空格</a></li>
      <li><a href="#字符和字符串字面量" id="markdown-toc-字符和字符串字面量">字符和字符串字面量</a></li>
      <li><a href="#数字" id="markdown-toc-数字">数字</a></li>
    </ul>
  </li>
  <li><a href="#notfollowedby-和-lookahead" id="markdown-toc-notfollowedby-和-lookahead"><code class="language-plaintext highlighter-rouge">notFollowedBy</code> 和 <code class="language-plaintext highlighter-rouge">lookAhead</code></a></li>
  <li><a href="#表达式的语法分析" id="markdown-toc-表达式的语法分析">表达式的语法分析</a></li>
  <li><a href="#缩进敏感的语法分析" id="markdown-toc-缩进敏感的语法分析">缩进敏感的语法分析</a>    <ul>
      <li><a href="#nonindented-和-indentblock" id="markdown-toc-nonindented-和-indentblock"><code class="language-plaintext highlighter-rouge">nonIndented</code> 和 <code class="language-plaintext highlighter-rouge">indentBlock</code></a></li>
      <li><a href="#简单的缩进列表" id="markdown-toc-简单的缩进列表">简单的缩进列表</a></li>
      <li><a href="#嵌套缩进列表" id="markdown-toc-嵌套缩进列表">嵌套缩进列表</a></li>
      <li><a href="#加入折行" id="markdown-toc-加入折行">加入折行</a></li>
    </ul>
  </li>
  <li><a href="#编写高效的语法分析器" id="markdown-toc-编写高效的语法分析器">编写高效的语法分析器</a></li>
  <li><a href="#语法分析错误" id="markdown-toc-语法分析错误">语法分析错误</a>    <ul>
      <li><a href="#错误的定义" id="markdown-toc-错误的定义">错误的定义</a></li>
      <li><a href="#如何触发错误" id="markdown-toc-如何触发错误">如何触发错误</a></li>
      <li><a href="#显示错误" id="markdown-toc-显示错误">显示错误</a></li>
      <li><a href="#在运行时接住错误" id="markdown-toc-在运行时接住错误">在运行时接住错误</a></li>
      <li><a href="#控制错误的位置" id="markdown-toc-控制错误的位置">控制错误的位置</a></li>
      <li><a href="#报告多个错误" id="markdown-toc-报告多个错误">报告多个错误</a></li>
    </ul>
  </li>
  <li><a href="#测试-megaparsec-语法分析器" id="markdown-toc-测试-megaparsec-语法分析器">测试 Megaparsec 语法分析器</a></li>
  <li><a href="#使用自定义输入流" id="markdown-toc-使用自定义输入流">使用自定义输入流</a></li>
</ul>

<p>在上一章「例：编写自己的语法分析组合子」中编写的玩具性质的语法分析组合子并不适合实际使用，因此我们继续来看看 Haskell 生态圈中能够解决相同问题的库，并请留意它们各自的利弊权衡：</p>

<ul>
  <li><a href="https://hackage.haskell.org/package/parsec">parsec</a> 过去一直是 Haskell 的「默认」语法分析库。该库比较关注错误信息的质量，但其测试覆盖率不高，并且目前处于仅维护的状态。</li>
  <li><a href="https://hackage.haskell.org/package/attoparsec">attoparsec</a> 是个健壮而高性能的语法分析库。它是在列的库中唯一一个完整支持增量语法分析的，其缺点是错误信息质量不佳、不支持单子变换、只支持部分输入流类型。</li>
  <li><a href="https://hackage.haskell.org/package/trifecta">trifecta</a> 错误信息的质量不错，但文档不足导致难以理解。对 <code class="language-plaintext highlighter-rouge">String</code> 和 <code class="language-plaintext highlighter-rouge">ByteString</code> 的语法分析可以做到开箱即用，但 <code class="language-plaintext highlighter-rouge">Text</code> 则不行。</li>
  <li><a href="https://hackage.haskell.org/package/megaparsec">megaparsec</a> 是 <code class="language-plaintext highlighter-rouge">parsec</code> 的一个分支，在过去数年里保持着积极的开发。当前版本尝试在速度、灵活性和错误信息质量之间找到一个最佳平衡。因为是 <code class="language-plaintext highlighter-rouge">parsec</code> 的非官方继任者，使用过 <code class="language-plaintext highlighter-rouge">parsec</code> 或者读过其教程的用户一定会对它感到十分亲切。</li>
</ul>

<p>把上述语法解析库全部讲一遍是不现实的，因此本文聚焦 <code class="language-plaintext highlighter-rouge">megaparsec</code>。更准确地说，我们将会讲解该库的版本 8.0，其于本书正式发行时应该已经取代旧版本成为主流版本了。</p>

<h2 id="parsect-和-parsec-单子"><code class="language-plaintext highlighter-rouge">ParsecT</code> 和 <code class="language-plaintext highlighter-rouge">Parsec</code> 单子</h2>

<p><code class="language-plaintext highlighter-rouge">ParsecT</code> 是 <code class="language-plaintext highlighter-rouge">megaparsec</code> 中主要的语法分析单子变换和核心数据类型。<code class="language-plaintext highlighter-rouge">ParsecT e s m a</code> 各参数分别表示：</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">e</code> 是用来表示错误信息的自定义组件的类型。如果我们不想做自定义（目前我们确实不想），那么用 <code class="language-plaintext highlighter-rouge">Data.Void</code> 模块中的 <code class="language-plaintext highlighter-rouge">Void</code> 就行了。</li>
  <li><code class="language-plaintext highlighter-rouge">s</code> 是输入流的类型。<code class="language-plaintext highlighter-rouge">megaparsec</code> 对于 <code class="language-plaintext highlighter-rouge">String</code>、严格或惰性的 <code class="language-plaintext highlighter-rouge">Text</code>、严格或惰性的 <code class="language-plaintext highlighter-rouge">ByteString</code> 都是开箱即用的，当然自定义输入流也是可用的。</li>
  <li><code class="language-plaintext highlighter-rouge">m</code> 是 <code class="language-plaintext highlighter-rouge">ParsecT</code> 单子变换的内部单子。</li>
  <li><code class="language-plaintext highlighter-rouge">a</code> 是单子中的值，作为语法分析的结果。</li>
</ul>

<p>因为大多数时候 <code class="language-plaintext highlighter-rouge">m</code> 就是 <code class="language-plaintext highlighter-rouge">Identity</code>，所以 <code class="language-plaintext highlighter-rouge">Parsec</code> 这个类型别名非常有用：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">type</span> <span class="kt">Parsec</span> <span class="n">e</span> <span class="n">s</span> <span class="n">a</span> <span class="o">=</span> <span class="kt">ParsecT</span> <span class="n">e</span> <span class="n">s</span> <span class="kt">Identity</span> <span class="n">a</span>
</code></pre></div></div>

<p>简而言之，<code class="language-plaintext highlighter-rouge">Parsec</code> 就是没有单子变换的 <code class="language-plaintext highlighter-rouge">ParsecT</code>。</p>

<p>我们还可以把 <code class="language-plaintext highlighter-rouge">megaparsec</code> 的单子变换类比于 MTL 单子变换和类型类。确实，我们还有 <code class="language-plaintext highlighter-rouge">MonadParsec</code> 类型类的用途与 <code class="language-plaintext highlighter-rouge">MonadState</code> 和 <code class="language-plaintext highlighter-rouge">MonadReader</code> 相近。我们会在<a href="#monadparsec-类型类">后面的章节</a>详细讨论 <code class="language-plaintext highlighter-rouge">MonadParsec</code>。</p>

<p>说到类型别名，开始使用 <code class="language-plaintext highlighter-rouge">megaparsec</code> 的最佳方式就是为自己的语法分析器定义一个类型别名。这有两个好处：</p>

<ul>
  <li>添加顶级签名会更容易，例如 <code class="language-plaintext highlighter-rouge">Parser Int</code>，其中 <code class="language-plaintext highlighter-rouge">Parser</code> 是你的语法分析单子。没有签名，诸如 <code class="language-plaintext highlighter-rouge">e</code> 之类的参数会有歧义，这是多态 API 不利的一面。</li>
  <li>使用固定所有类型变量的具体类型可以帮助 GHC 更好地进行优化，如果你的语法分析器保持多态则 GHC 难以开展优化工作。尽管 <code class="language-plaintext highlighter-rouge">megaparsec</code> API 是多态的，但预计最终用户都会使用具体类型的语法分析单子，这样便可进行内联工作，并将大多数函数定义转储到接口文件，这让 GHC 能够生成非常高效的非多态代码。</li>
</ul>

<p>让我们定义一个类型别名（一般都叫 <code class="language-plaintext highlighter-rouge">Parser</code>）：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">type</span> <span class="kt">Parser</span> <span class="o">=</span> <span class="kt">Parsec</span> <span class="kt">Void</span> <span class="kt">Text</span>
<span class="c1">--                   ^    ^</span>
<span class="cd">--                   |    |</span>
<span class="c1">-- Custom error component Type of input stream</span>
</code></pre></div></div>

<p>在本文中出现的 <code class="language-plaintext highlighter-rouge">Parser</code> 假定为此类型，直到我们开始自定义语法分析错误为止。</p>

<h2 id="字符和二进制流">字符和二进制流</h2>

<p>我们之前说了 <code class="language-plaintext highlighter-rouge">megaparsec</code> 对于五种输入流类型是开箱即用的：<code class="language-plaintext highlighter-rouge">String</code>、严格或惰性的 <code class="language-plaintext highlighter-rouge">Text</code>、严格或惰性的 <code class="language-plaintext highlighter-rouge">ByteString</code>。之所以可以这样，是因为在该库中这些类型都是 <code class="language-plaintext highlighter-rouge">Stream</code> 类型类的实例，其对所有可用作 <code class="language-plaintext highlighter-rouge">megaparsec</code> 语法分析器输入的数据类型做了功能上的抽象。</p>

<p>简化版本的 <code class="language-plaintext highlighter-rouge">Stream</code> 可以表示如下：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">class</span> <span class="kt">Stream</span> <span class="n">s</span> <span class="kr">where</span>
  <span class="kr">type</span> <span class="kt">Token</span>  <span class="n">s</span> <span class="o">::</span> <span class="o">*</span>
  <span class="kr">type</span> <span class="kt">Tokens</span> <span class="n">s</span> <span class="o">::</span> <span class="o">*</span>
  <span class="n">take1_</span> <span class="o">::</span> <span class="n">s</span> <span class="o">-&gt;</span> <span class="kt">Maybe</span> <span class="p">(</span><span class="kt">Token</span> <span class="n">s</span><span class="p">,</span> <span class="n">s</span><span class="p">)</span> <span class="c1">-- aka uncons</span>
  <span class="n">tokensToChunk</span> <span class="o">::</span> <span class="kt">Proxy</span> <span class="n">s</span> <span class="o">-&gt;</span> <span class="p">[</span><span class="kt">Token</span> <span class="n">s</span><span class="p">]</span> <span class="o">-&gt;</span> <span class="kt">Tokens</span> <span class="n">s</span>
</code></pre></div></div>

<p>实际的 <code class="language-plaintext highlighter-rouge">Stream</code> 定义包含更多方法，但我们不用知道那些就能使用该库。</p>

<p>注意这个类型类关联了两个类型函数：</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">Token s</code> 是单个词法单词的类型。一般来说是 <code class="language-plaintext highlighter-rouge">Char</code> 或者 <code class="language-plaintext highlighter-rouge">Word8</code>，但对于自定义流来说也可能是其他类型。</li>
  <li><code class="language-plaintext highlighter-rouge">Tokens s</code> 是流的「一大块」的类型，此概念是为性能考虑而引入的。确实常常有相当于单词列表 <code class="language-plaintext highlighter-rouge">[Token s]</code> 但又更加高效的表示方法，比如 <code class="language-plaintext highlighter-rouge">Text</code> 类型的输入流有 <code class="language-plaintext highlighter-rouge">Tokens s ~ Text</code>，即 <code class="language-plaintext highlighter-rouge">Text</code> 的一大块就是 <code class="language-plaintext highlighter-rouge">Text</code>。虽然类型等式 <code class="language-plaintext highlighter-rouge">Tokens s ~ s</code> 常常是成立的，但在自定义流中 <code class="language-plaintext highlighter-rouge">Tokens s</code> 和 <code class="language-plaintext highlighter-rouge">s</code> 可能不同，所以我们将这两个类型分开了。</li>
</ul>

<p>我们可以把所有默认的输入流列进一张表格：</p>

<table>
  <thead>
    <tr>
      <th><code class="language-plaintext highlighter-rouge">s</code></th>
      <th><code class="language-plaintext highlighter-rouge">Token s</code></th>
      <th><code class="language-plaintext highlighter-rouge">Tokens s</code></th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">String</code></td>
      <td><code class="language-plaintext highlighter-rouge">Char</code></td>
      <td><code class="language-plaintext highlighter-rouge">String</code></td>
    </tr>
    <tr>
      <td>strict <code class="language-plaintext highlighter-rouge">Text</code></td>
      <td><code class="language-plaintext highlighter-rouge">Char</code></td>
      <td>strict <code class="language-plaintext highlighter-rouge">Text</code></td>
    </tr>
    <tr>
      <td>lazy <code class="language-plaintext highlighter-rouge">Text</code></td>
      <td><code class="language-plaintext highlighter-rouge">Char</code></td>
      <td>lazy <code class="language-plaintext highlighter-rouge">Text</code></td>
    </tr>
    <tr>
      <td>strict <code class="language-plaintext highlighter-rouge">ByteString</code></td>
      <td><code class="language-plaintext highlighter-rouge">Word8</code></td>
      <td>strict <code class="language-plaintext highlighter-rouge">ByteString</code></td>
    </tr>
    <tr>
      <td>lazy <code class="language-plaintext highlighter-rouge">ByteString</code></td>
      <td><code class="language-plaintext highlighter-rouge">Word8</code></td>
      <td>lazy <code class="language-plaintext highlighter-rouge">ByteString</code></td>
    </tr>
  </tbody>
</table>

<p>我们得习惯 <code class="language-plaintext highlighter-rouge">Token</code> 和 <code class="language-plaintext highlighter-rouge">Tokens</code> 这两个类型函数，因为它们在 <code class="language-plaintext highlighter-rouge">megaparsec</code> API 的类型声明中无处不在。</p>

<p>你可能会注意到，如果我们把所有默认输入流按照单词类型分类，可以得到两类：</p>

<ul>
  <li>字符流，满足 <code class="language-plaintext highlighter-rouge">Token s ~ Char</code>：<code class="language-plaintext highlighter-rouge">String</code>、严格或惰性的 <code class="language-plaintext highlighter-rouge">Text</code>；</li>
  <li>二进制流，满足 <code class="language-plaintext highlighter-rouge">Token s ~ Word8</code>：严格或惰性的 <code class="language-plaintext highlighter-rouge">ByteString</code>。</li>
</ul>

<p>因此用 <code class="language-plaintext highlighter-rouge">megaparsec</code> 就不需要为每种输入流类型都编写一个同样的语法分析器（比如用 <code class="language-plaintext highlighter-rouge">attoparsec</code> 就需要），但我们仍要为不同的单词类型编写不同的代码：</p>

<ul>
  <li>使用字符流的组合子，要导入 <code class="language-plaintext highlighter-rouge">Text.Megaparsec.Char</code> 模块；</li>
  <li>使用二进制流的组合子，要导入 <code class="language-plaintext highlighter-rouge">Text.Megaparsec.Byte</code> 模块。</li>
</ul>

<p>这些模块包含两组相似的语法分析工具，例如：</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th><code class="language-plaintext highlighter-rouge">Text.Megaparsec.Char</code></th>
      <th><code class="language-plaintext highlighter-rouge">Text.Megaparsec.Byte</code></th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">newline</code></td>
      <td><code class="language-plaintext highlighter-rouge">(MonadParsec e s m, Token s ~ Char) =&gt; m (Token s)</code></td>
      <td><code class="language-plaintext highlighter-rouge">(MonadParsec e s m, Token s ~ Word8) =&gt; m (Token s)</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">eol</code></td>
      <td><code class="language-plaintext highlighter-rouge">(MonadParsec e s m, Token s ~ Char) =&gt; m (Tokens s)</code></td>
      <td><code class="language-plaintext highlighter-rouge">(MonadParsec e s m, Token s ~ Word8) =&gt; m (Tokens s)</code></td>
    </tr>
  </tbody>
</table>

<p>为了更好地理解我们将使用的工具函数，我们先引入几个它们所依赖的原语。</p>

<p>第一个原语是 <code class="language-plaintext highlighter-rouge">token</code>，相应地它让我们能够对 <code class="language-plaintext highlighter-rouge">Token s</code> 类型的值做语法分析：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">token</span> <span class="o">::</span> <span class="kt">MonadParsec</span> <span class="n">e</span> <span class="n">s</span> <span class="n">m</span>
  <span class="o">=&gt;</span> <span class="p">(</span><span class="kt">Token</span> <span class="n">s</span> <span class="o">-&gt;</span> <span class="kt">Maybe</span> <span class="n">a</span><span class="p">)</span>
    <span class="c1">-- ^ Matching function for the token to parse</span>
  <span class="o">-&gt;</span> <span class="kt">Set</span> <span class="p">(</span><span class="kt">ErrorItem</span> <span class="p">(</span><span class="kt">Token</span> <span class="n">s</span><span class="p">))</span>
    <span class="c1">-- ^ Expected items (in case of an error)</span>
  <span class="o">-&gt;</span> <span class="n">m</span> <span class="n">a</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">token</code> 的第一个参数是要分析的单词的匹配函数，如果该函数返回了 <code class="language-plaintext highlighter-rouge">Just</code> 那么其值就会成为语法分析的结果，<code class="language-plaintext highlighter-rouge">Nothing</code> 则表明语法分析器不接受该单词并且原语会失败。</p>

<p>第二个参数是一个 <code class="language-plaintext highlighter-rouge">Set</code>（来自 <code class="language-plaintext highlighter-rouge">containers</code> 包），它包含在失败的情况下所有可能显示给用户的 <code class="language-plaintext highlighter-rouge">ErrorItem</code>。当我们讨论语法分析错误时会详细解说 <code class="language-plaintext highlighter-rouge">ErrorItem</code>。</p>

<p>为了更好地理解 <code class="language-plaintext highlighter-rouge">token</code> 是怎样工作的，让我们看看 <code class="language-plaintext highlighter-rouge">Text.Megaparsec</code> 模块中适用于所有输入流类型的一些组合子的定义。<code class="language-plaintext highlighter-rouge">satisfy</code> 是其中一个相当常见的组合子，我们给它一个对匹配单词返回 <code class="language-plaintext highlighter-rouge">True</code> 的断言，它就会返回一个对应的语法分析器：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">satisfy</span> <span class="o">::</span> <span class="kt">MonadParsec</span> <span class="n">e</span> <span class="n">s</span> <span class="n">m</span>
  <span class="o">=&gt;</span> <span class="p">(</span><span class="kt">Token</span> <span class="n">s</span> <span class="o">-&gt;</span> <span class="kt">Bool</span><span class="p">)</span> <span class="c1">-- ^ Predicate to apply</span>
  <span class="o">-&gt;</span> <span class="n">m</span> <span class="p">(</span><span class="kt">Token</span> <span class="n">s</span><span class="p">)</span>
<span class="n">satisfy</span> <span class="n">f</span> <span class="o">=</span> <span class="n">token</span> <span class="n">testToken</span> <span class="kt">Set</span><span class="o">.</span><span class="n">empty</span>
  <span class="kr">where</span>
    <span class="n">testToken</span> <span class="n">x</span> <span class="o">=</span> <span class="kr">if</span> <span class="n">f</span> <span class="n">x</span> <span class="kr">then</span> <span class="kt">Just</span> <span class="n">x</span> <span class="kr">else</span> <span class="kt">Nothing</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">testToken</code> 的工作就是把返回 <code class="language-plaintext highlighter-rouge">Bool</code> 值的 <code class="language-plaintext highlighter-rouge">f</code> 函数转换为 <code class="language-plaintext highlighter-rouge">token</code> 期待的返回 <code class="language-plaintext highlighter-rouge">Maybe (Token s)</code> 的函数。在 <code class="language-plaintext highlighter-rouge">satisfy</code> 中我们不知道想要匹配的确切单词序列，所以我们传了 <code class="language-plaintext highlighter-rouge">Set.empty</code> 作为第二个参数。</p>

<p><code class="language-plaintext highlighter-rouge">satisfy</code> 看起来很好懂，让我们看看怎么使用它。我们需要一个能跑语法分析器的工具函数，<code class="language-plaintext highlighter-rouge">megaparsec</code> 提供了 <code class="language-plaintext highlighter-rouge">parseTest</code> 让我们在 GHCi 中测试。</p>

<p>首先，让我们启动 GHCi 并导入一些模块：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; import Text.Megaparsec
λ&gt; import Text.Megaparsec.Char
λ&gt; import Data.Text (Text)
λ&gt; import Data.Void
</code></pre></div></div>

<p>我们接着添加 <code class="language-plaintext highlighter-rouge">Parser</code> 类型别名，以明确语法分析器的类型：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; type Parser = Parsec Void Text
</code></pre></div></div>

<p>我们还需要开启 <code class="language-plaintext highlighter-rouge">OverloadedStrings</code> 语言扩展，这样我们就能把字符串字面量用作 <code class="language-plaintext highlighter-rouge">Text</code> 类型的值：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; :set -XOverloadedStrings
</code></pre></div></div>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest (satisfy (== 'a') :: Parser Char) ""
1:1:
  |
1 | &lt;empty line&gt;
  | ^
unexpected end of input
</code></pre></div></div>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest (satisfy (== 'a') :: Parser Char) "a"
'a'
</code></pre></div></div>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest (satisfy (== 'a') :: Parser Char) "b"
1:1:
  |
1 | b
  | ^
unexpected 'b'
</code></pre></div></div>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest (satisfy (&gt; 'c') :: Parser Char) "a"
1:1:
  |
1 | a
  | ^
unexpected 'a'
</code></pre></div></div>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest (satisfy (&gt; 'c') :: Parser Char) "d"
'd'
</code></pre></div></div>

<p>因为 <code class="language-plaintext highlighter-rouge">satisfy</code> 本身是多态的，所以 <code class="language-plaintext highlighter-rouge">:: Parser Char</code> 类型标注是必要的，否则 <code class="language-plaintext highlighter-rouge">parseTest</code> 无法得知 <code class="language-plaintext highlighter-rouge">MonadParsec e s m</code> 中的 <code class="language-plaintext highlighter-rouge">e</code> 和 <code class="language-plaintext highlighter-rouge">s</code> 是什么（在这里 <code class="language-plaintext highlighter-rouge">m</code> 假定为 <code class="language-plaintext highlighter-rouge">Identity</code>）。如果我们使用的是一个事先存在的有类型签名的语法分析器，那么就不需要这些显式的类型标注了。</p>

<p>看起来是正常工作的。<code class="language-plaintext highlighter-rouge">satisfy</code> 有个问题是它没有在失败时告诉我们它期待什么单词，因为我们无法分析 <code class="language-plaintext highlighter-rouge">satisfy</code> 调用者提供的函数。另外还有一些不那么通用的组合子，但它们生成更有用的错误信息。例如 <code class="language-plaintext highlighter-rouge">single</code>（还有在 <code class="language-plaintext highlighter-rouge">Text.Megaparsec.Byte</code> 和 <code class="language-plaintext highlighter-rouge">Texy.Megaparsec.Char</code> 中限定了类型的别名 <code class="language-plaintext highlighter-rouge">char</code>）可以匹配一个特定的单词：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">single</span> <span class="o">::</span> <span class="kt">MonadParsec</span> <span class="n">e</span> <span class="n">s</span> <span class="n">m</span>
  <span class="o">=&gt;</span> <span class="kt">Token</span> <span class="n">s</span>
  <span class="o">-&gt;</span> <span class="n">m</span> <span class="p">(</span><span class="kt">Token</span> <span class="n">s</span><span class="p">)</span>
<span class="n">single</span> <span class="n">t</span> <span class="o">=</span> <span class="n">token</span> <span class="n">testToken</span> <span class="n">expected</span>
  <span class="kr">where</span>
    <span class="n">testToken</span> <span class="n">x</span> <span class="o">=</span> <span class="kr">if</span> <span class="n">x</span> <span class="o">==</span> <span class="n">t</span> <span class="kr">then</span> <span class="kt">Just</span> <span class="n">x</span> <span class="kr">else</span> <span class="kt">Nothing</span>
    <span class="n">expected</span>    <span class="o">=</span> <span class="kt">E</span><span class="o">.</span><span class="n">singleton</span> <span class="p">(</span><span class="kt">Tokens</span> <span class="p">(</span><span class="n">t</span><span class="o">:|</span><span class="kt">[]</span><span class="p">))</span>
</code></pre></div></div>

<p>这里的 <code class="language-plaintext highlighter-rouge">Tokens</code> 数据类型构造器跟我们之前讨论的 <code class="language-plaintext highlighter-rouge">Tokens</code> 类型函数没有关系。实际上，<code class="language-plaintext highlighter-rouge">Tokens</code> 是 <code class="language-plaintext highlighter-rouge">ErrorItem</code> 的一个构造器，用来指定我们期望匹配的具体单词序列。</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest (char 'a' :: Parser Char) "b"
1:1:
  |
1 | b
  | ^
unexpected 'b'
expecting 'a'
</code></pre></div></div>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest (char 'a' :: Parser Char) "a"
'a'
</code></pre></div></div>

<p>我们现在可以定义之前表格中的 <code class="language-plaintext highlighter-rouge">newline</code> 了：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">newline</span> <span class="o">::</span> <span class="p">(</span><span class="kt">MonadParsec</span> <span class="n">e</span> <span class="n">s</span> <span class="n">m</span><span class="p">,</span> <span class="kt">Token</span> <span class="n">s</span> <span class="o">~</span> <span class="kt">Char</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="n">m</span> <span class="p">(</span><span class="kt">Token</span> <span class="n">s</span><span class="p">)</span>
<span class="n">newline</span> <span class="o">=</span> <span class="n">single</span> <span class="sc">'</span><span class="se">\n</span><span class="sc">'</span>
</code></pre></div></div>

<p>第二个原语叫做 <code class="language-plaintext highlighter-rouge">tokens</code>，它让我们能够语法分析 <code class="language-plaintext highlighter-rouge">Tokens s</code>，即用来匹配输入的一大块：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">tokens</span> <span class="o">::</span> <span class="kt">MonadParsec</span> <span class="n">e</span> <span class="n">s</span> <span class="n">m</span>
  <span class="o">=&gt;</span> <span class="p">(</span><span class="kt">Tokens</span> <span class="n">s</span> <span class="o">-&gt;</span> <span class="kt">Tokens</span> <span class="n">s</span> <span class="o">-&gt;</span> <span class="kt">Bool</span><span class="p">)</span>
    <span class="c1">-- ^ Predicate to check equality of chunks</span>
  <span class="o">-&gt;</span> <span class="kt">Tokens</span> <span class="n">s</span>
    <span class="c1">-- ^ Chunk of input to match against</span>
  <span class="o">-&gt;</span> <span class="n">m</span> <span class="p">(</span><span class="kt">Tokens</span> <span class="n">s</span><span class="p">)</span>
</code></pre></div></div>

<p>也有两个语法分析器是基于 <code class="language-plaintext highlighter-rouge">tokens</code> 定义的：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- from "Text.Megaparsec":</span>
<span class="n">chunk</span> <span class="o">::</span> <span class="kt">MonadParsec</span> <span class="n">e</span> <span class="n">s</span> <span class="n">m</span>
  <span class="o">=&gt;</span> <span class="kt">Tokens</span> <span class="n">s</span>
  <span class="o">-&gt;</span> <span class="n">m</span> <span class="p">(</span><span class="kt">Tokens</span> <span class="n">s</span><span class="p">)</span>
<span class="n">chunk</span> <span class="o">=</span> <span class="n">tokens</span> <span class="p">(</span><span class="o">==</span><span class="p">)</span>
</code></pre></div></div>
<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- from "Text.Megaparsec.Char" and "Text.Megaparsec.Byte":</span>
<span class="n">string'</span> <span class="o">::</span> <span class="p">(</span><span class="kt">MonadParsec</span> <span class="n">e</span> <span class="n">s</span> <span class="n">m</span><span class="p">,</span> <span class="kt">CI</span><span class="o">.</span><span class="kt">FoldCase</span> <span class="p">(</span><span class="kt">Tokens</span> <span class="n">s</span><span class="p">))</span>
  <span class="o">=&gt;</span> <span class="kt">Tokens</span> <span class="n">s</span>
  <span class="o">-&gt;</span> <span class="n">m</span> <span class="p">(</span><span class="kt">Tokens</span> <span class="n">s</span><span class="p">)</span>
<span class="n">string'</span> <span class="o">=</span> <span class="n">tokens</span> <span class="p">((</span><span class="o">==</span><span class="p">)</span> <span class="p">`</span><span class="n">on</span><span class="p">`</span> <span class="kt">CI</span><span class="o">.</span><span class="n">mk</span><span class="p">)</span>
</code></pre></div></div>

<p>它们会匹配输入中固定的一大块，<code class="language-plaintext highlighter-rouge">chunk</code>（在 <code class="language-plaintext highlighter-rouge">Text.Megaparsec.Byte</code> 和 <code class="language-plaintext highlighter-rouge">Texy.Megaparsec.Char</code> 中有限定了类型的别名 <code class="language-plaintext highlighter-rouge">string</code>）区分大小写，而 <code class="language-plaintext highlighter-rouge">string'</code> 不区分。不区分大小写的匹配要用到 <code class="language-plaintext highlighter-rouge">case-insensitive</code> 包，并且加上了 <code class="language-plaintext highlighter-rouge">FoldCase</code> 约束。</p>

<p>让我们也来试试这些新的组合子：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest (string "foo" :: Parser Text) "foo"
"foo"
</code></pre></div></div>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest (string "foo" :: Parser Text) "bar"
1:1:
  |
1 | bar
  | ^
unexpected "bar"
expecting "foo"
</code></pre></div></div>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest (string' "foo" :: Parser Text) "FOO"
"FOO"
</code></pre></div></div>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest (string' "foo" :: Parser Text) "FoO"
"FoO"
</code></pre></div></div>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest (string' "foo" :: Parser Text) "FoZ"
1:1:
  |
1 | FoZ
  | ^
unexpected "FoZ"
expecting "foo"
</code></pre></div></div>

<p>好的，我们可以匹配单个单词和一大块输入了。下一步我们将要学习如何组合这些积木来编写更有趣的语法分析器。</p>

<h2 id="单子式和可应用函子式语法">单子式和可应用函子式语法</h2>

<p>最简单的组合语法分析器的方式是连续执行它们。<code class="language-plaintext highlighter-rouge">ParsecT</code> 和 <code class="language-plaintext highlighter-rouge">Parsec</code> 是单子，而单子绑定正好可以顺序执行语法分析器：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">mySequence</span> <span class="o">::</span> <span class="kt">Parser</span> <span class="p">(</span><span class="kt">Char</span><span class="p">,</span> <span class="kt">Char</span><span class="p">,</span> <span class="kt">Char</span><span class="p">)</span>
<span class="n">mySequence</span> <span class="o">=</span> <span class="kr">do</span>
  <span class="n">a</span> <span class="o">&lt;-</span> <span class="n">char</span> <span class="sc">'a'</span>
  <span class="n">b</span> <span class="o">&lt;-</span> <span class="n">char</span> <span class="sc">'b'</span>
  <span class="n">c</span> <span class="o">&lt;-</span> <span class="n">char</span> <span class="sc">'c'</span>
  <span class="n">return</span> <span class="p">(</span><span class="n">a</span><span class="p">,</span> <span class="n">b</span><span class="p">,</span> <span class="n">c</span><span class="p">)</span>
</code></pre></div></div>

<p>我们来运行一下看看是不是按照预期工作：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest mySequence "abc"
('a','b','c')
</code></pre></div></div>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest mySequence "bcd"
1:1:
  |
1 | bcd
  | ^
unexpected 'b'
expecting 'a'
</code></pre></div></div>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest mySequence "adc"
1:2:
  |
1 | adc
  |  ^
unexpected 'd'
expecting 'b'
</code></pre></div></div>

<p>因为所有单子亦是可应用函子，所以我们也可以使用可应用函子式的语法来顺序执行：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">mySequence</span> <span class="o">::</span> <span class="kt">Parser</span> <span class="p">(</span><span class="kt">Char</span><span class="p">,</span> <span class="kt">Char</span><span class="p">,</span> <span class="kt">Char</span><span class="p">)</span>
<span class="n">mySequence</span> <span class="o">=</span>
  <span class="p">(,,)</span> <span class="o">&lt;$&gt;</span> <span class="n">char</span> <span class="sc">'a'</span>
       <span class="o">&lt;*&gt;</span> <span class="n">char</span> <span class="sc">'b'</span>
       <span class="o">&lt;*&gt;</span> <span class="n">char</span> <span class="sc">'c'</span>
</code></pre></div></div>

<p>第二种方法跟第一种运行结果完全相同，使用哪种风格通常取决于个人品味。单子风格可以说是更冗长但有时更清晰，而可应用函子风格通常更简洁。话说回来，显然单子风格的表达能力更强，因为单子比可应用函子更强大。</p>

<h2 id="用-eof-耗尽输入">用 <code class="language-plaintext highlighter-rouge">eof</code> 耗尽输入</h2>

<p>可应用函子通常已经足够强大，足以做一些有趣的事情。如果配上拥有单位元且满足结合律的运算符，我们就得到了可应用函子上的单位半群，在 Haskell 中表示为 <code class="language-plaintext highlighter-rouge">Alternative</code> 类型类。<a href="https://hackage.haskell.org/package/parser-combinators">parser-combinators</a> 包提供了不少基于 <code class="language-plaintext highlighter-rouge">Applicative</code> 和 <code class="language-plaintext highlighter-rouge">Alternative</code> 概念的抽象组合子，<code class="language-plaintext highlighter-rouge">Text.Megaparsec</code> 模块重新导出了这些来自 <code class="language-plaintext highlighter-rouge">Control.Applicative.Combinators</code> 的组合子。</p>

<p>一个最常见的组合子是 <code class="language-plaintext highlighter-rouge">many</code>，它允许我们将给定的语法分析器运行零次或多次：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest (many (char 'a') :: Parser [Char]) "aaa"
"aaa"
</code></pre></div></div>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest (many (char 'a') :: Parser [Char]) "aabbb"
"aa"
</code></pre></div></div>

<p>第二个结果可能有点令人惊讶，语法分析器吃掉了匹配的 <code class="language-plaintext highlighter-rouge">a</code>，但随后就停了下来。好吧，我们并没有交代在 <code class="language-plaintext highlighter-rouge">many (char 'a')</code> 之后要做些什么。</p>

<p>在大多数情况下，我们实际需要强制语法分析器吃掉整个输入，并报告语法分析错误，而不是害羞地默默中止。这就需要我们吃到输入结束。幸运的是，虽然输入结束只是一个概念，但有个 <code class="language-plaintext highlighter-rouge">eof :: MonadParsec e s m =&gt; m ()</code> 不吃任何单词，仅会在输入结束时成功。让我们把它加进去再试一次：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest (many (char 'a') &lt;* eof :: Parser [Char]) "aabbb"
1:3:
  |
1 | aabbb
  |   ^
unexpected 'b'
expecting 'a' or end of input
</code></pre></div></div>

<p>我们在语法分析器中没有提到 <code class="language-plaintext highlighter-rouge">b</code>，所以它们肯定是预期之外的。</p>

<h2 id="处理多种选择">处理多种选择</h2>

<p>从现在开始我们将开发一个实际有用的语法分析器，它能处理下述形式的 URI：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>scheme:[//[user:password@]host[:port]][/]path[?query][#fragment]
</code></pre></div></div>

<p>我们记住方括号 <code class="language-plaintext highlighter-rouge">[]</code> 中的部分是可选的，不论它们出不出现 URI 都是合法的，<code class="language-plaintext highlighter-rouge">[]</code> 甚至可以进行嵌套。我们会完整支持该语法<sup id="fnref:modern-uri"><a href="#fn:modern-uri" class="footnote" rel="footnote" role="doc-noteref">1</a></sup>。</p>

<p>让我们从 <code class="language-plaintext highlighter-rouge">scheme</code> 开始，我们仅仅接受已知的协议名，譬如 <code class="language-plaintext highlighter-rouge">data</code>、<code class="language-plaintext highlighter-rouge">file</code>、<code class="language-plaintext highlighter-rouge">ftp</code>、<code class="language-plaintext highlighter-rouge">http</code>、<code class="language-plaintext highlighter-rouge">https</code>、<code class="language-plaintext highlighter-rouge">irc</code> 和 <code class="language-plaintext highlighter-rouge">mailto</code>。</p>

<p>我们用 <code class="language-plaintext highlighter-rouge">string</code> 匹配固定的字符序列，用 <code class="language-plaintext highlighter-rouge">Alternative</code> 类型类中的 <code class="language-plaintext highlighter-rouge">(&lt;|&gt;)</code> 方法表示「选择」。代码如下：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">{-# LANGUAGE OverloadedStrings #-}</span>
<span class="cp">{-# LANGUAGE RecordWildCards   #-}</span>

<span class="kr">module</span> <span class="nn">Main</span> <span class="p">(</span><span class="nf">main</span><span class="p">)</span> <span class="kr">where</span>

<span class="kr">import</span> <span class="nn">Control.Applicative</span>
<span class="kr">import</span> <span class="nn">Control.Monad</span>
<span class="kr">import</span> <span class="nn">Data.Text</span> <span class="p">(</span><span class="kt">Text</span><span class="p">)</span>
<span class="kr">import</span> <span class="nn">Data.Void</span>
<span class="kr">import</span> <span class="nn">Text.Megaparsec</span> <span class="k">hiding</span> <span class="p">(</span><span class="kt">State</span><span class="p">)</span>
<span class="kr">import</span> <span class="nn">Text.Megaparsec.Char</span>
<span class="kr">import</span> <span class="k">qualified</span> <span class="nn">Data.Text</span> <span class="k">as</span> <span class="n">T</span>
<span class="kr">import</span> <span class="k">qualified</span> <span class="nn">Text.Megaparsec.Char.Lexer</span> <span class="k">as</span> <span class="n">L</span>

<span class="kr">type</span> <span class="kt">Parser</span> <span class="o">=</span> <span class="kt">Parsec</span> <span class="kt">Void</span> <span class="kt">Text</span>

<span class="n">pScheme</span> <span class="o">::</span> <span class="kt">Parser</span> <span class="kt">Text</span>
<span class="n">pScheme</span> <span class="o">=</span> <span class="n">string</span> <span class="s">"data"</span>
  <span class="o">&lt;|&gt;</span> <span class="n">string</span> <span class="s">"file"</span>
  <span class="o">&lt;|&gt;</span> <span class="n">string</span> <span class="s">"ftp"</span>
  <span class="o">&lt;|&gt;</span> <span class="n">string</span> <span class="s">"http"</span>
  <span class="o">&lt;|&gt;</span> <span class="n">string</span> <span class="s">"https"</span>
  <span class="o">&lt;|&gt;</span> <span class="n">string</span> <span class="s">"irc"</span>
  <span class="o">&lt;|&gt;</span> <span class="n">string</span> <span class="s">"mailto"</span>
</code></pre></div></div>

<p>试着运行一下：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest pScheme ""
1:1:
  |
1 | &lt;empty line&gt;
  | ^
unexpected end of input
expecting "data", "file", "ftp", "http", "https", "irc", or "mailto"
</code></pre></div></div>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest pScheme "dat"
1:1:
  |
1 | dat
  | ^
unexpected "dat"
expecting "data", "file", "ftp", "http", "https", "irc", or "mailto"
</code></pre></div></div>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest pScheme "file"
"file"
</code></pre></div></div>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest pScheme "irc"
"irc"
</code></pre></div></div>

<p>看起来不错，但 <code class="language-plaintext highlighter-rouge">pScheme</code> 的定义有点啰嗦。我们可以用 <code class="language-plaintext highlighter-rouge">choice</code> 组合子重写 <code class="language-plaintext highlighter-rouge">pScheme</code>：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">pScheme</span> <span class="o">::</span> <span class="kt">Parser</span> <span class="kt">Text</span>
<span class="n">pScheme</span> <span class="o">=</span> <span class="n">choice</span>
  <span class="p">[</span> <span class="n">string</span> <span class="s">"data"</span>
  <span class="p">,</span> <span class="n">string</span> <span class="s">"file"</span>
  <span class="p">,</span> <span class="n">string</span> <span class="s">"ftp"</span>
  <span class="p">,</span> <span class="n">string</span> <span class="s">"http"</span>
  <span class="p">,</span> <span class="n">string</span> <span class="s">"https"</span>
  <span class="p">,</span> <span class="n">string</span> <span class="s">"irc"</span>
  <span class="p">,</span> <span class="n">string</span> <span class="s">"mailto"</span> <span class="p">]</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">choice</code> 只是 <code class="language-plaintext highlighter-rouge">asum</code> 的别名，后者会用 <code class="language-plaintext highlighter-rouge">(&lt;|&gt;)</code> 对列表元素进行折叠，所以 <code class="language-plaintext highlighter-rouge">pScheme</code> 的这两个定义其实是一样的，只是用 <code class="language-plaintext highlighter-rouge">choice</code> 更好看一些。</p>

<p>协议名后面是个冒号 <code class="language-plaintext highlighter-rouge">:</code>。回忆一下，如果我们要接着对其他东西做语法分析，我们要用单子绑定或者 <code class="language-plaintext highlighter-rouge">do</code> 记法：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">data</span> <span class="kt">Uri</span> <span class="o">=</span> <span class="kt">Uri</span>
  <span class="p">{</span> <span class="n">uriScheme</span> <span class="o">::</span> <span class="kt">Text</span>
  <span class="p">}</span> <span class="kr">deriving</span> <span class="p">(</span><span class="kt">Eq</span><span class="p">,</span> <span class="kt">Show</span><span class="p">)</span>

<span class="n">pUri</span> <span class="o">::</span> <span class="kt">Parser</span> <span class="kt">Uri</span>
<span class="n">pUri</span> <span class="o">=</span> <span class="kr">do</span>
  <span class="n">r</span> <span class="o">&lt;-</span> <span class="n">pScheme</span>
  <span class="kr">_</span> <span class="o">&lt;-</span> <span class="n">char</span> <span class="sc">':'</span>
  <span class="n">return</span> <span class="p">(</span><span class="kt">Uri</span> <span class="n">r</span><span class="p">)</span>
</code></pre></div></div>

<p>如果我们运行一下 <code class="language-plaintext highlighter-rouge">pUri</code>，我们会看到它现在要求协议名后面跟着一个冒号：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest pUri "irc"
1:4:
  |
1 | irc
  |    ^
unexpected end of input
expecting ':'
</code></pre></div></div>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest pUri "irc:"
Uri {uriScheme = "irc"}
</code></pre></div></div>

<p>但我们还没完成协议名的语法分析。一位优秀的 Haskell 程序员编写的类型，能让错误数据无处遁形。并不是任何 <code class="language-plaintext highlighter-rouge">Text</code> 值都代表合法的协议名，因此让我们定义一个表示协议名的数据类型，并让 <code class="language-plaintext highlighter-rouge">pScheme</code> 返回它：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">data</span> <span class="kt">Scheme</span>
  <span class="o">=</span> <span class="kt">SchemeData</span>
  <span class="o">|</span> <span class="kt">SchemeFile</span>
  <span class="o">|</span> <span class="kt">SchemeFtp</span>
  <span class="o">|</span> <span class="kt">SchemeHttp</span>
  <span class="o">|</span> <span class="kt">SchemeHttps</span>
  <span class="o">|</span> <span class="kt">SchemeIrc</span>
  <span class="o">|</span> <span class="kt">SchemeMailto</span>
  <span class="kr">deriving</span> <span class="p">(</span><span class="kt">Eq</span><span class="p">,</span> <span class="kt">Show</span><span class="p">)</span>

<span class="n">pScheme</span> <span class="o">::</span> <span class="kt">Parser</span> <span class="kt">Scheme</span>
<span class="n">pScheme</span> <span class="o">=</span> <span class="n">choice</span>
  <span class="p">[</span> <span class="kt">SchemeData</span>   <span class="o">&lt;$</span> <span class="n">string</span> <span class="s">"data"</span>
  <span class="p">,</span> <span class="kt">SchemeFile</span>   <span class="o">&lt;$</span> <span class="n">string</span> <span class="s">"file"</span>
  <span class="p">,</span> <span class="kt">SchemeFtp</span>    <span class="o">&lt;$</span> <span class="n">string</span> <span class="s">"ftp"</span>
  <span class="p">,</span> <span class="kt">SchemeHttp</span>   <span class="o">&lt;$</span> <span class="n">string</span> <span class="s">"http"</span>
  <span class="p">,</span> <span class="kt">SchemeHttps</span>  <span class="o">&lt;$</span> <span class="n">string</span> <span class="s">"https"</span>
  <span class="p">,</span> <span class="kt">SchemeIrc</span>    <span class="o">&lt;$</span> <span class="n">string</span> <span class="s">"irc"</span>
  <span class="p">,</span> <span class="kt">SchemeMailto</span> <span class="o">&lt;$</span> <span class="n">string</span> <span class="s">"mailto"</span> <span class="p">]</span>

<span class="kr">data</span> <span class="kt">Uri</span> <span class="o">=</span> <span class="kt">Uri</span>
  <span class="p">{</span> <span class="n">uriScheme</span> <span class="o">::</span> <span class="kt">Scheme</span>
  <span class="p">}</span> <span class="kr">deriving</span> <span class="p">(</span><span class="kt">Eq</span><span class="p">,</span> <span class="kt">Show</span><span class="p">)</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">(&lt;$)</code> 运算符仅仅把左边的值放入函子上下文，而不管里面原来是什么。<code class="language-plaintext highlighter-rouge">a &lt;$ f</code> 与 <code class="language-plaintext highlighter-rouge">const a &lt;$&gt; f</code> 等价，但对于一些函子来说更高效。</p>

<p>让我们再来试一下：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest pUri "https:"
1:5:
  |
1 | https:
  |     ^
unexpected 's'
expecting ':'
</code></pre></div></div>

<p>唔……<code class="language-plaintext highlighter-rouge">https</code> 应该是个合法的协议名，你能看出哪里出错了吗？语法分析器逐个尝试这些选择，一旦 <code class="language-plaintext highlighter-rouge">http</code> 匹配就不会再往下试 <code class="language-plaintext highlighter-rouge">https</code> 了。解决方法就是把 <code class="language-plaintext highlighter-rouge">SchemeHttps &lt;$ string "https"</code> 放到 <code class="language-plaintext highlighter-rouge">schemeHttp &lt;$ string "http"</code> 上面去。一定要记住：顺序会影响选择！</p>

<p>现在 <code class="language-plaintext highlighter-rouge">pUri</code> 正常工作了：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest pUri "http:"
Uri {uriScheme = SchemeHttp}
</code></pre></div></div>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest pUri "https:"
Uri {uriScheme = SchemeHttps}
</code></pre></div></div>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest pUri "mailto:"
Uri {uriScheme = SchemeMailto}
</code></pre></div></div>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest pUri "foo:"
1:1:
  |
1 | foo:
  | ^
unexpected "foo:"
expecting "data", "file", "ftp", "http", "https", "irc", or "mailto"
</code></pre></div></div>

<h2 id="用-try-控制回溯">用 <code class="language-plaintext highlighter-rouge">try</code> 控制回溯</h2>

<p>下一步是处理 <code class="language-plaintext highlighter-rouge">[//[user:password@]host[:port]]</code>，这里我们需要嵌套可选部分，因此让我们更新一下 <code class="language-plaintext highlighter-rouge">Uri</code> 类型：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">data</span> <span class="kt">Uri</span> <span class="o">=</span> <span class="kt">Uri</span>
  <span class="p">{</span> <span class="n">uriScheme</span>    <span class="o">::</span> <span class="kt">Scheme</span>
  <span class="p">,</span> <span class="n">uriAuthority</span> <span class="o">::</span> <span class="kt">Maybe</span> <span class="kt">Authority</span>
  <span class="p">}</span> <span class="kr">deriving</span> <span class="p">(</span><span class="kt">Eq</span><span class="p">,</span> <span class="kt">Show</span><span class="p">)</span>

<span class="kr">data</span> <span class="kt">Authority</span> <span class="o">=</span> <span class="kt">Authority</span>
  <span class="p">{</span> <span class="n">authUser</span> <span class="o">::</span> <span class="kt">Maybe</span> <span class="p">(</span><span class="kt">Text</span><span class="p">,</span> <span class="kt">Text</span><span class="p">)</span> <span class="c1">-- (user, password)</span>
  <span class="p">,</span> <span class="n">authHost</span> <span class="o">::</span> <span class="kt">Text</span>
  <span class="p">,</span> <span class="n">authPort</span> <span class="o">::</span> <span class="kt">Maybe</span> <span class="kt">Int</span>
  <span class="p">}</span> <span class="kr">deriving</span> <span class="p">(</span><span class="kt">Eq</span><span class="p">,</span> <span class="kt">Show</span><span class="p">)</span>
</code></pre></div></div>

<p>现在我们需要讨论一个重要概念，也就是回溯。回溯是指及时返回而不吃掉任何输入，这在处理分支是非常重要。下面是一个例子：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">alternatives</span> <span class="o">::</span> <span class="kt">Parser</span> <span class="p">(</span><span class="kt">Char</span><span class="p">,</span> <span class="kt">Char</span><span class="p">)</span>
<span class="n">alternatives</span> <span class="o">=</span> <span class="n">foo</span> <span class="o">&lt;|&gt;</span> <span class="n">bar</span>
  <span class="kr">where</span>
    <span class="n">foo</span> <span class="o">=</span> <span class="p">(,)</span> <span class="o">&lt;$&gt;</span> <span class="n">char</span> <span class="sc">'a'</span> <span class="o">&lt;*&gt;</span> <span class="n">char</span> <span class="sc">'b'</span>
    <span class="n">bar</span> <span class="o">=</span> <span class="p">(,)</span> <span class="o">&lt;$&gt;</span> <span class="n">char</span> <span class="sc">'a'</span> <span class="o">&lt;*&gt;</span> <span class="n">char</span> <span class="sc">'c'</span>
</code></pre></div></div>

<p>看起来很合理，我们来试一下：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest alternatives "ab"
('a','b')
</code></pre></div></div>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest alternatives "ac"
1:2:
  |
1 | ac
  |  ^
unexpected 'c'
expecting 'b'
</code></pre></div></div>

<p>发生了什么呢？最先尝试的 <code class="language-plaintext highlighter-rouge">foo</code> 的 <code class="language-plaintext highlighter-rouge">char 'a'</code> 部分成功了，所以输入流里的 <code class="language-plaintext highlighter-rouge">a</code> 被吃掉了。接着 <code class="language-plaintext highlighter-rouge">char 'b'</code> 没能匹配 <code class="language-plaintext highlighter-rouge">c</code>，所以我们得到了这样的错误信息。重要的一点是，<code class="language-plaintext highlighter-rouge">(&lt;|&gt;)</code> 根本没有尝试 <code class="language-plaintext highlighter-rouge">bar</code>，因为 <code class="language-plaintext highlighter-rouge">foo</code> 已经把一些输入吃掉了。</p>

<p>一方面这是为性能考虑，另一方面把 <code class="language-plaintext highlighter-rouge">foo</code> 剩下来的东西喂给 <code class="language-plaintext highlighter-rouge">bar</code> 也没什么意义。我们期望在 <code class="language-plaintext highlighter-rouge">bar</code> 运行的时候，输入流正处于 <code class="language-plaintext highlighter-rouge">foo</code> 开始的位置。<code class="language-plaintext highlighter-rouge">megaparsec</code> 并不会自动回溯（与 <code class="language-plaintext highlighter-rouge">attoparsec</code> 或是上一章中的玩具组合子不同），所以我们需要用 <code class="language-plaintext highlighter-rouge">try</code> 原语来显式表达我们想要回溯。如果 <code class="language-plaintext highlighter-rouge">p</code> 失败了，那么 <code class="language-plaintext highlighter-rouge">try p</code> 就会进行回溯，就像没有输入被吃掉一样（实际上它回溯了整个语法分析状态）。这就允许 <code class="language-plaintext highlighter-rouge">(&lt;|&gt;)</code> 尝试右边的选择了：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">alternatives</span> <span class="o">::</span> <span class="kt">Parser</span> <span class="p">(</span><span class="kt">Char</span><span class="p">,</span> <span class="kt">Char</span><span class="p">)</span>
<span class="n">alternatives</span> <span class="o">=</span> <span class="n">try</span> <span class="n">foo</span> <span class="o">&lt;|&gt;</span> <span class="n">bar</span>
  <span class="kr">where</span>
    <span class="n">foo</span> <span class="o">=</span> <span class="p">(,)</span> <span class="o">&lt;$&gt;</span> <span class="n">char</span> <span class="sc">'a'</span> <span class="o">&lt;*&gt;</span> <span class="n">char</span> <span class="sc">'b'</span>
    <span class="n">bar</span> <span class="o">=</span> <span class="p">(,)</span> <span class="o">&lt;$&gt;</span> <span class="n">char</span> <span class="sc">'a'</span> <span class="o">&lt;*&gt;</span> <span class="n">char</span> <span class="sc">'c'</span>
</code></pre></div></div>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest alternatives "ac"
('a','c')
</code></pre></div></div>

<p>所有会吃输入的原语（当然也有诸如 <code class="language-plaintext highlighter-rouge">try</code> 这样改变现有语法分析器行为的原语）的输入消耗是「原子性」的。也就是说，它们失败时会自动回溯，所以它们不会吃掉部分输入而中途失败。这就是为什么 <code class="language-plaintext highlighter-rouge">pScheme</code> 的所有选择能正常工作：<code class="language-plaintext highlighter-rouge">string</code> 是基于 <code class="language-plaintext highlighter-rouge">tokens</code> 定义的，而 <code class="language-plaintext highlighter-rouge">tokens</code> 是原语。我们要么匹配整个字符串，要么直接失败而不吃掉任何输入流。</p>

<p>回到 URI 的语法分析上，<code class="language-plaintext highlighter-rouge">(&lt;|&gt;)</code> 能够用来构建一个方便的 <code class="language-plaintext highlighter-rouge">optional</code> 组合子：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">optional</span> <span class="o">::</span> <span class="kt">Alternative</span> <span class="n">f</span> <span class="o">=&gt;</span> <span class="n">f</span> <span class="n">a</span> <span class="o">-&gt;</span> <span class="n">f</span> <span class="p">(</span><span class="kt">Maybe</span> <span class="n">a</span><span class="p">)</span>
<span class="n">optional</span> <span class="n">p</span> <span class="o">=</span> <span class="p">(</span><span class="kt">Just</span> <span class="o">&lt;$&gt;</span> <span class="n">p</span><span class="p">)</span> <span class="o">&lt;|&gt;</span> <span class="n">pure</span> <span class="kt">Nothing</span>
</code></pre></div></div>

<p>如果 <code class="language-plaintext highlighter-rouge">optional p</code> 中的 <code class="language-plaintext highlighter-rouge">p</code> 匹配成功了，那么我们能得到包装在 <code class="language-plaintext highlighter-rouge">Just</code> 中的结果，否则返回 <code class="language-plaintext highlighter-rouge">Nothing</code>。这就是我们想要的！但我们没必要自己定义 <code class="language-plaintext highlighter-rouge">optional</code>，因为 <code class="language-plaintext highlighter-rouge">Text.Megaparsec</code> 帮我们重新导出了这个组合子。我们现在可以把它用在 <code class="language-plaintext highlighter-rouge">pUri</code> 上了：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">pUri</span> <span class="o">::</span> <span class="kt">Parser</span> <span class="kt">Uri</span>
<span class="n">pUri</span> <span class="o">=</span> <span class="kr">do</span>
  <span class="n">uriScheme</span> <span class="o">&lt;-</span> <span class="n">pScheme</span>
  <span class="n">void</span> <span class="p">(</span><span class="n">char</span> <span class="sc">':'</span><span class="p">)</span>
  <span class="n">uriAuthority</span> <span class="o">&lt;-</span> <span class="n">optional</span> <span class="o">.</span> <span class="n">try</span> <span class="o">$</span> <span class="kr">do</span>            <span class="c1">-- (1)</span>
    <span class="n">void</span> <span class="p">(</span><span class="n">string</span> <span class="s">"//"</span><span class="p">)</span>
    <span class="n">authUser</span> <span class="o">&lt;-</span> <span class="n">optional</span> <span class="o">.</span> <span class="n">try</span> <span class="o">$</span> <span class="kr">do</span>              <span class="c1">-- (2)</span>
      <span class="n">user</span> <span class="o">&lt;-</span> <span class="kt">T</span><span class="o">.</span><span class="n">pack</span> <span class="o">&lt;$&gt;</span> <span class="n">some</span> <span class="n">alphaNumChar</span>       <span class="c1">-- (3)</span>
      <span class="n">void</span> <span class="p">(</span><span class="n">char</span> <span class="sc">':'</span><span class="p">)</span>
      <span class="n">password</span> <span class="o">&lt;-</span> <span class="kt">T</span><span class="o">.</span><span class="n">pack</span> <span class="o">&lt;$&gt;</span> <span class="n">some</span> <span class="n">alphaNumChar</span>
      <span class="n">void</span> <span class="p">(</span><span class="n">char</span> <span class="sc">'@'</span><span class="p">)</span>
      <span class="n">return</span> <span class="p">(</span><span class="n">user</span><span class="p">,</span> <span class="n">password</span><span class="p">)</span>
    <span class="n">authHost</span> <span class="o">&lt;-</span> <span class="kt">T</span><span class="o">.</span><span class="n">pack</span> <span class="o">&lt;$&gt;</span> <span class="n">some</span> <span class="p">(</span><span class="n">alphaNumChar</span> <span class="o">&lt;|&gt;</span> <span class="n">char</span> <span class="sc">'.'</span><span class="p">)</span>
    <span class="n">authPort</span> <span class="o">&lt;-</span> <span class="n">optional</span> <span class="p">(</span><span class="n">char</span> <span class="sc">':'</span> <span class="o">*&gt;</span> <span class="kt">L</span><span class="o">.</span><span class="n">decimal</span><span class="p">)</span> <span class="c1">-- (4)</span>
    <span class="n">return</span> <span class="kt">Authority</span> <span class="p">{</span><span class="o">..</span><span class="p">}</span>                        <span class="c1">-- (5)</span>
  <span class="n">return</span> <span class="kt">Uri</span> <span class="p">{</span><span class="o">..</span><span class="p">}</span>                                <span class="c1">-- (6)</span>
</code></pre></div></div>

<p>我擅自让所有字母和数字都可用作用户名和密码，主机名也做了相似的简化。</p>

<p>有几点需要留意：</p>

<ul>
  <li>在 (1) 和 (2) 中我们需要把 <code class="language-plaintext highlighter-rouge">optional</code> 的参数用 <code class="language-plaintext highlighter-rouge">try</code> 包起来，因为其参数是组合起来的语法分析器，而不是原语。</li>
  <li>(3) <code class="language-plaintext highlighter-rouge">some</code> 和 <code class="language-plaintext highlighter-rouge">many</code> 很像，但要求至少匹配一次：<code class="language-plaintext highlighter-rouge">some p = (:) &lt;$&gt; p &lt;*&gt; many p</code>。</li>
  <li>(4) 如非必要请勿使用 <code class="language-plaintext highlighter-rouge">try</code>！这里如果 <code class="language-plaintext highlighter-rouge">char ':'</code> 成功了（它本身是基于 <code class="language-plaintext highlighter-rouge">token</code> 定义的，不需要 <code class="language-plaintext highlighter-rouge">try</code>），我们知道紧接着一定是端口号，所以我们只需要 <code class="language-plaintext highlighter-rouge">L.decimal</code> 来匹配十进制数。在匹配完 <code class="language-plaintext highlighter-rouge">:</code> 之后，我们并不需要回溯。</li>
  <li>在 (5) 和 (6) 中我们用 <code class="language-plaintext highlighter-rouge">RecordWildCards</code> 语言扩展组装了 <code class="language-plaintext highlighter-rouge">Authority</code> 和 <code class="language-plaintext highlighter-rouge">Uri</code> 的值。</li>
</ul>

<p>在 GHCi 中试试 <code class="language-plaintext highlighter-rouge">pUri</code>，你会发现它能正常工作：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest (pUri &lt;* eof) "https://mark:secret@example.com"
Uri
  { uriScheme = SchemeHttps
  , uriAuthority = Just (Authority
    { authUser = Just ("mark","secret")
    , authHost = "example.com"
    , authPort = Nothing } ) }
</code></pre></div></div>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest (pUri &lt;* eof) "https://mark:secret@example.com:123"
Uri
  { uriScheme = SchemeHttps
  , uriAuthority = Just (Authority
    { authUser = Just ("mark","secret")
    , authHost = "example.com"
    , authPort = Just 123 } ) }
</code></pre></div></div>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest (pUri &lt;* eof) "https://example.com:123"
Uri
  { uriScheme = SchemeHttps
  , uriAuthority = Just (Authority
    { authUser = Nothing
    , authHost = "example.com"
    , authPort = Just 123 } ) }
</code></pre></div></div>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest (pUri &lt;* eof) "https://mark@example.com:123"
1:13:
  |
1 | https://mark@example.com:123
  |             ^
unexpected '@'
expecting '.', ':', alphanumeric character, or end of input
</code></pre></div></div>

<h2 id="调试语法分析器">调试语法分析器</h2>

<p>不过，你可能会发现这样一个问题：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest (pUri &lt;* eof) "https://mark:@example.com"
1:7:
  |
1 | https://mark:@example.com
  |       ^
unexpected '/'
expecting end of input
</code></pre></div></div>

<p>这个语法分析错误的提示信息有待改进！怎么改进呢？弄清问题所在的最简单方法是用内置的 <code class="language-plaintext highlighter-rouge">dbg</code> 工具：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">dbg</span> <span class="o">::</span> <span class="p">(</span><span class="kt">Stream</span> <span class="n">s</span><span class="p">,</span> <span class="kt">ShowToken</span> <span class="p">(</span><span class="kt">Token</span> <span class="n">s</span><span class="p">),</span> <span class="kt">ShowErrorComponent</span> <span class="n">e</span><span class="p">,</span> <span class="kt">Show</span> <span class="n">a</span><span class="p">)</span>
  <span class="o">=&gt;</span> <span class="kt">String</span>            <span class="c1">-- ^ Debugging label</span>
  <span class="o">-&gt;</span> <span class="kt">ParsecT</span> <span class="n">e</span> <span class="n">s</span> <span class="n">m</span> <span class="n">a</span>   <span class="c1">-- ^ Parser to debug</span>
  <span class="o">-&gt;</span> <span class="kt">ParsecT</span> <span class="n">e</span> <span class="n">s</span> <span class="n">m</span> <span class="n">a</span>   <span class="c1">-- ^ Parser that prints debugging messages</span>
</code></pre></div></div>

<p>让我们把它加进 <code class="language-plaintext highlighter-rouge">pUri</code>：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">pUri</span> <span class="o">::</span> <span class="kt">Parser</span> <span class="kt">Uri</span>
<span class="n">pUri</span> <span class="o">=</span> <span class="kr">do</span>
  <span class="n">uriScheme</span> <span class="o">&lt;-</span> <span class="n">dbg</span> <span class="s">"scheme"</span> <span class="n">pScheme</span>
  <span class="n">void</span> <span class="p">(</span><span class="n">char</span> <span class="sc">':'</span><span class="p">)</span>
  <span class="n">uriAuthority</span> <span class="o">&lt;-</span> <span class="n">dbg</span> <span class="s">"auth"</span> <span class="o">.</span> <span class="n">optional</span> <span class="o">.</span> <span class="n">try</span> <span class="o">$</span> <span class="kr">do</span>
    <span class="n">void</span> <span class="p">(</span><span class="n">string</span> <span class="s">"//"</span><span class="p">)</span>
    <span class="n">authUser</span> <span class="o">&lt;-</span> <span class="n">dbg</span> <span class="s">"user"</span> <span class="o">.</span> <span class="n">optional</span> <span class="o">.</span> <span class="n">try</span> <span class="o">$</span> <span class="kr">do</span>
      <span class="n">user</span> <span class="o">&lt;-</span> <span class="kt">T</span><span class="o">.</span><span class="n">pack</span> <span class="o">&lt;$&gt;</span> <span class="n">some</span> <span class="n">alphaNumChar</span>
      <span class="n">void</span> <span class="p">(</span><span class="n">char</span> <span class="sc">':'</span><span class="p">)</span>
      <span class="n">password</span> <span class="o">&lt;-</span> <span class="kt">T</span><span class="o">.</span><span class="n">pack</span> <span class="o">&lt;$&gt;</span> <span class="n">some</span> <span class="n">alphaNumChar</span>
      <span class="n">void</span> <span class="p">(</span><span class="n">char</span> <span class="sc">'@'</span><span class="p">)</span>
      <span class="n">return</span> <span class="p">(</span><span class="n">user</span><span class="p">,</span> <span class="n">password</span><span class="p">)</span>
    <span class="n">authHost</span> <span class="o">&lt;-</span> <span class="kt">T</span><span class="o">.</span><span class="n">pack</span> <span class="o">&lt;$&gt;</span> <span class="n">dbg</span> <span class="s">"host"</span> <span class="p">(</span><span class="n">some</span> <span class="p">(</span><span class="n">alphaNumChar</span> <span class="o">&lt;|&gt;</span> <span class="n">char</span> <span class="sc">'.'</span><span class="p">))</span>
    <span class="n">authPort</span> <span class="o">&lt;-</span> <span class="n">dbg</span> <span class="s">"port"</span> <span class="o">$</span> <span class="n">optional</span> <span class="p">(</span><span class="n">char</span> <span class="sc">':'</span> <span class="o">*&gt;</span> <span class="kt">L</span><span class="o">.</span><span class="n">decimal</span><span class="p">)</span>
    <span class="n">return</span> <span class="kt">Authority</span> <span class="p">{</span><span class="o">..</span><span class="p">}</span>
  <span class="n">return</span> <span class="kt">Uri</span> <span class="p">{</span><span class="o">..</span><span class="p">}</span>
</code></pre></div></div>

<p>然后再用刚才的输入运行 <code class="language-plaintext highlighter-rouge">pUri</code> 看看：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest (pUri &lt;* eof) "https://mark:@example.com"
scheme&gt; IN: "https://mark:@example.com"
scheme&gt; MATCH (COK): "https"
scheme&gt; VALUE: SchemeHttps

user&gt; IN: "mark:@example.com"
user&gt; MATCH (EOK): &lt;EMPTY&gt;
user&gt; VALUE: Nothing

host&gt; IN: "mark:@example.com"
host&gt; MATCH (COK): "mark"
host&gt; VALUE: "mark"

port&gt; IN: ":@example.com"
port&gt; MATCH (CERR): ':'
port&gt; ERROR:
port&gt; 1:14:
port&gt; unexpected '@'
port&gt; expecting integer

auth&gt; IN: "//mark:@example.com"
auth&gt; MATCH (EOK): &lt;EMPTY&gt;
auth&gt; VALUE: Nothing

1:7:
  |
1 | https://mark:@example.com
  |       ^
unexpected '/'
expecting end of input
</code></pre></div></div>

<p>我们可以看到内部到底发生什么了：</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">scheme</code> 匹配成功了。</li>
  <li><code class="language-plaintext highlighter-rouge">user</code> 失败了：虽然有个用户名 <code class="language-plaintext highlighter-rouge">mark</code>，但 <code class="language-plaintext highlighter-rouge">:</code> 后面没有密码（我们这里要求密码非空）。虽然我们失败了，但 <code class="language-plaintext highlighter-rouge">try</code> 带我们回溯了。</li>
  <li><code class="language-plaintext highlighter-rouge">host</code> 从 <code class="language-plaintext highlighter-rouge">user</code> 开始的位置运作，并尝试把输入解释成主机名。我们可以看到它成功了，并返回了主机名 <code class="language-plaintext highlighter-rouge">mark</code>。</li>
  <li>主机名后面可以有端口号，所以 <code class="language-plaintext highlighter-rouge">port</code> 开始运作。它看见了 <code class="language-plaintext highlighter-rouge">:</code>，但发现后面没有数字，因此也失败了。</li>
  <li>因此整个 <code class="language-plaintext highlighter-rouge">auth</code> 语法分析失败了（<code class="language-plaintext highlighter-rouge">port</code> 在 <code class="language-plaintext highlighter-rouge">auth</code> 里面失败了）。</li>
  <li><code class="language-plaintext highlighter-rouge">auth</code> 语法分析器返回了 <code class="language-plaintext highlighter-rouge">Nothing</code>，因为它什么都分析不出来。现在 <code class="language-plaintext highlighter-rouge">eof</code> 要求吃到输入结束，但现实并非如此，因此我们得到了最终的错误信息。</li>
</ul>

<p>我们怎么办呢？这是一个用 <code class="language-plaintext highlighter-rouge">try</code> 包住一大堆代码导致错误信息不可读的例子。让我们再看一下我们要处理的语法：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>scheme:[//[user:password@]host[:port]][/]path[?query][#fragment]
</code></pre></div></div>

<p>我们在找什么？在找可以让我们确定特定分支的东西，就像我们看到 <code class="language-plaintext highlighter-rouge">:</code> 就肯定后面跟着端口号一样。如果仔细找的话，你会发现双斜杠是 <code class="language-plaintext highlighter-rouge">//</code> 是进入 <code class="language-plaintext highlighter-rouge">Authority</code> 部分的标志。因为我们是用原子性的语法分析器（<code class="language-plaintext highlighter-rouge">string</code>）来匹配 <code class="language-plaintext highlighter-rouge">//</code> 的，它会自动回溯，而一旦匹配到 <code class="language-plaintext highlighter-rouge">//</code> 我们就能确定我们需要匹配到 <code class="language-plaintext highlighter-rouge">Authority</code> 部分。让我们把 <code class="language-plaintext highlighter-rouge">pUri</code> 的第一个 <code class="language-plaintext highlighter-rouge">try</code> 删掉吧：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">pUri</span> <span class="o">::</span> <span class="kt">Parser</span> <span class="kt">Uri</span>
<span class="n">pUri</span> <span class="o">=</span> <span class="kr">do</span>
  <span class="n">uriScheme</span> <span class="o">&lt;-</span> <span class="n">pScheme</span>
  <span class="n">void</span> <span class="p">(</span><span class="n">char</span> <span class="sc">':'</span><span class="p">)</span>
  <span class="n">uriAuthority</span> <span class="o">&lt;-</span> <span class="n">optional</span> <span class="o">$</span> <span class="kr">do</span> <span class="c1">-- removed 'try' on this line</span>
    <span class="n">void</span> <span class="p">(</span><span class="n">string</span> <span class="s">"//"</span><span class="p">)</span>
    <span class="n">authUser</span> <span class="o">&lt;-</span> <span class="n">optional</span> <span class="o">.</span> <span class="n">try</span> <span class="o">$</span> <span class="kr">do</span>
      <span class="n">user</span> <span class="o">&lt;-</span> <span class="kt">T</span><span class="o">.</span><span class="n">pack</span> <span class="o">&lt;$&gt;</span> <span class="n">some</span> <span class="n">alphaNumChar</span>
      <span class="n">void</span> <span class="p">(</span><span class="n">char</span> <span class="sc">':'</span><span class="p">)</span>
      <span class="n">password</span> <span class="o">&lt;-</span> <span class="kt">T</span><span class="o">.</span><span class="n">pack</span> <span class="o">&lt;$&gt;</span> <span class="n">some</span> <span class="n">alphaNumChar</span>
      <span class="n">void</span> <span class="p">(</span><span class="n">char</span> <span class="sc">'@'</span><span class="p">)</span>
      <span class="n">return</span> <span class="p">(</span><span class="n">user</span><span class="p">,</span> <span class="n">password</span><span class="p">)</span>
    <span class="n">authHost</span> <span class="o">&lt;-</span> <span class="kt">T</span><span class="o">.</span><span class="n">pack</span> <span class="o">&lt;$&gt;</span> <span class="n">some</span> <span class="p">(</span><span class="n">alphaNumChar</span> <span class="o">&lt;|&gt;</span> <span class="n">char</span> <span class="sc">'.'</span><span class="p">)</span>
    <span class="n">authPort</span> <span class="o">&lt;-</span> <span class="n">optional</span> <span class="p">(</span><span class="n">char</span> <span class="sc">':'</span> <span class="o">*&gt;</span> <span class="kt">L</span><span class="o">.</span><span class="n">decimal</span><span class="p">)</span>
    <span class="n">return</span> <span class="kt">Authority</span> <span class="p">{</span><span class="o">..</span><span class="p">}</span>
  <span class="n">return</span> <span class="kt">Uri</span> <span class="p">{</span><span class="o">..</span><span class="p">}</span>
</code></pre></div></div>

<p>现在我们得到了更可读的错误信息：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest (pUri &lt;* eof) "https://mark:@example.com"
1:14:
  |
1 | https://mark:@example.com
  |              ^
unexpected '@'
expecting integer
</code></pre></div></div>

<p>虽然有点误导人，但这是个比较微妙的例子。里面有太多 <code class="language-plaintext highlighter-rouge">optional</code> 了。</p>

<h2 id="标签和隐藏">标签和隐藏</h2>

<p>有时完整列出我们期待的东西会有点长，记得我们用未知的协议名进行测试的时候吗？</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest (pUri &lt;* eof) "foo://example.com"
1:1:
  |
1 | foo://example.com
  | ^
unexpected "foo://"
expecting "data", "file", "ftp", "http", "https", "irc", or "mailto"
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">megaparsec</code> 提供了对提示信息进行自定义的方法，即使用「标签」。我们可以这样使用 <code class="language-plaintext highlighter-rouge">label</code> 原语（它有个别名是 <code class="language-plaintext highlighter-rouge">(&lt;?&gt;)</code> 运算符）：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">pUri</span> <span class="o">::</span> <span class="kt">Parser</span> <span class="kt">Uri</span>
<span class="n">pUri</span> <span class="o">=</span> <span class="kr">do</span>
  <span class="n">uriScheme</span> <span class="o">&lt;-</span> <span class="n">pScheme</span> <span class="o">&lt;?&gt;</span> <span class="s">"valid scheme"</span>
  <span class="c1">-- the rest stays the same</span>
</code></pre></div></div>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest (pUri &lt;* eof) "foo://example.com"
1:1:
  |
1 | foo://example.com
  | ^
unexpected "foo://"
expecting valid scheme
</code></pre></div></div>

<p>我们可以继续加入更多标签，以使错误信息更加可读：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">pUri</span> <span class="o">::</span> <span class="kt">Parser</span> <span class="kt">Uri</span>
<span class="n">pUri</span> <span class="o">=</span> <span class="kr">do</span>
  <span class="n">uriScheme</span> <span class="o">&lt;-</span> <span class="n">pScheme</span> <span class="o">&lt;?&gt;</span> <span class="s">"valid scheme"</span>
  <span class="n">void</span> <span class="p">(</span><span class="n">char</span> <span class="sc">':'</span><span class="p">)</span>
  <span class="n">uriAuthority</span> <span class="o">&lt;-</span> <span class="n">optional</span> <span class="o">$</span> <span class="kr">do</span>
    <span class="n">void</span> <span class="p">(</span><span class="n">string</span> <span class="s">"//"</span><span class="p">)</span>
    <span class="n">authUser</span> <span class="o">&lt;-</span> <span class="n">optional</span> <span class="o">.</span> <span class="n">try</span> <span class="o">$</span> <span class="kr">do</span>
      <span class="n">user</span> <span class="o">&lt;-</span> <span class="kt">T</span><span class="o">.</span><span class="n">pack</span> <span class="o">&lt;$&gt;</span> <span class="n">some</span> <span class="n">alphaNumChar</span> <span class="o">&lt;?&gt;</span> <span class="s">"username"</span>
      <span class="n">void</span> <span class="p">(</span><span class="n">char</span> <span class="sc">':'</span><span class="p">)</span>
      <span class="n">password</span> <span class="o">&lt;-</span> <span class="kt">T</span><span class="o">.</span><span class="n">pack</span> <span class="o">&lt;$&gt;</span> <span class="n">some</span> <span class="n">alphaNumChar</span> <span class="o">&lt;?&gt;</span> <span class="s">"password"</span>
      <span class="n">void</span> <span class="p">(</span><span class="n">char</span> <span class="sc">'@'</span><span class="p">)</span>
      <span class="n">return</span> <span class="p">(</span><span class="n">user</span><span class="p">,</span> <span class="n">password</span><span class="p">)</span>
    <span class="n">authHost</span> <span class="o">&lt;-</span> <span class="kt">T</span><span class="o">.</span><span class="n">pack</span> <span class="o">&lt;$&gt;</span> <span class="n">some</span> <span class="p">(</span><span class="n">alphaNumChar</span> <span class="o">&lt;|&gt;</span> <span class="n">char</span> <span class="sc">'.'</span><span class="p">)</span> <span class="o">&lt;?&gt;</span> <span class="s">"hostname"</span>
    <span class="n">authPort</span> <span class="o">&lt;-</span> <span class="n">optional</span> <span class="p">(</span><span class="n">char</span> <span class="sc">':'</span> <span class="o">*&gt;</span> <span class="n">label</span> <span class="s">"port number"</span> <span class="kt">L</span><span class="o">.</span><span class="n">decimal</span><span class="p">)</span>
    <span class="n">return</span> <span class="kt">Authority</span> <span class="p">{</span><span class="o">..</span><span class="p">}</span>
  <span class="n">return</span> <span class="kt">Uri</span> <span class="p">{</span><span class="o">..</span><span class="p">}</span>
</code></pre></div></div>

<p>举个例子：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest (pUri &lt;* eof) "https://mark:@example.com"
1:14:
  |
1 | https://mark:@example.com
  |              ^
unexpected '@'
expecting port number
</code></pre></div></div>

<p>另一个原语叫做 <code class="language-plaintext highlighter-rouge">hidden</code>。如果说 <code class="language-plaintext highlighter-rouge">label</code> 是在为提示信息进行重命名，那么 <code class="language-plaintext highlighter-rouge">hidden</code> 就是把它们直接移除了。做个比较：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest (many (char 'a') &gt;&gt; many (char 'b') &gt;&gt; eof :: Parser ()) "d"
1:1:
  |
1 | d
  | ^
unexpected 'd'
expecting 'a', 'b', or end of input
</code></pre></div></div>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest (many (char 'a') &gt;&gt; hidden (many (char 'b')) &gt;&gt; eof :: Parser ()) "d"
1:1:
  |
1 | d
  | ^
unexpected 'd'
expecting 'a' or end of input
</code></pre></div></div>

<p>如果我们想让错误信息不那么啰嗦，<code class="language-plaintext highlighter-rouge">hidden</code> 会很有用。比如说，在对编程语言做语法分析时，最好丢弃「expecting white space」的提示信息，因为几乎所有单词后面都可以有空格。</p>

<p>【练习】我们把 <code class="language-plaintext highlighter-rouge">pUri</code> 的剩余部分留给读者完成，所有要用到的工具都已经讲解过了。</p>

<h2 id="运行语法分析器">运行语法分析器</h2>

<p>我们已经探索了如何构建语法分析器，但我们还没审视能让我们运行它们的函数，除了 <code class="language-plaintext highlighter-rouge">parseTest</code>。</p>

<p>传统上来说，从你的程序运行语法分析器的「默认」函数一直是 <code class="language-plaintext highlighter-rouge">parse</code>，但<code class="language-plaintext highlighter-rouge">parse</code> 其实是 <code class="language-plaintext highlighter-rouge">runParser</code> 的别名：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">runParser</span>
  <span class="o">::</span> <span class="kt">Parsec</span> <span class="n">e</span> <span class="n">s</span> <span class="n">a</span> <span class="c1">-- ^ Parser to run</span>
  <span class="o">-&gt;</span> <span class="kt">String</span>       <span class="c1">-- ^ Name of source file</span>
  <span class="o">-&gt;</span> <span class="n">s</span>            <span class="c1">-- ^ Input for parser</span>
  <span class="o">-&gt;</span> <span class="kt">Either</span> <span class="p">(</span><span class="kt">ParseErrorBundle</span> <span class="n">s</span> <span class="n">e</span><span class="p">)</span> <span class="n">a</span>
</code></pre></div></div>

<p>第二个参数只是用来在错误信息中显示的文件名，<code class="language-plaintext highlighter-rouge">megaparsec</code> 并不会尝试去读这个文件，因为真正的输入是这个函数的第三个参数。</p>

<p><code class="language-plaintext highlighter-rouge">runParser</code> 允许我们运行 <code class="language-plaintext highlighter-rouge">Parsec</code> 单子，我们已经知道，它就是没有单子变换的 <code class="language-plaintext highlighter-rouge">ParsecT</code>：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">type</span> <span class="kt">Parsec</span> <span class="n">e</span> <span class="n">s</span> <span class="o">=</span> <span class="kt">ParsecT</span> <span class="n">e</span> <span class="n">s</span> <span class="kt">Identity</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">runParser</code> 有三个姊妹：<code class="language-plaintext highlighter-rouge">runParser'</code>、<code class="language-plaintext highlighter-rouge">runParserT</code> 和 <code class="language-plaintext highlighter-rouge">runParserT'</code>。有后缀 <code class="language-plaintext highlighter-rouge">T</code> 的版本可以进行 <code class="language-plaintext highlighter-rouge">ParsecT</code> 单子变换，而有一撇的版本接受并返回语法分析器状态。让我们把它们列进一张表：</p>

<table>
  <thead>
    <tr>
      <th>参数</th>
      <th>运行 <code class="language-plaintext highlighter-rouge">Parsec</code></th>
      <th>运行 <code class="language-plaintext highlighter-rouge">ParsecT</code></th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>输入和文件名</td>
      <td><code class="language-plaintext highlighter-rouge">runParser</code></td>
      <td><code class="language-plaintext highlighter-rouge">runParserT</code></td>
    </tr>
    <tr>
      <td>自定义初始状态</td>
      <td><code class="language-plaintext highlighter-rouge">runParser'</code></td>
      <td><code class="language-plaintext highlighter-rouge">runParserT'</code></td>
    </tr>
  </tbody>
</table>

<p>比如当你需要设置制表符宽度（默认是 8）的时候，自定义初始状态就很有用。举了例子，下面是 <code class="language-plaintext highlighter-rouge">runParser'</code> 的类型签名：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">runParser'</span>
  <span class="o">::</span> <span class="kt">Parsec</span> <span class="n">e</span> <span class="n">s</span> <span class="n">a</span> <span class="c1">-- ^ Parser to run</span>
  <span class="o">-&gt;</span> <span class="kt">State</span> <span class="n">s</span>      <span class="c1">-- ^ Initial state</span>
  <span class="o">-&gt;</span> <span class="p">(</span><span class="kt">State</span> <span class="n">s</span><span class="p">,</span> <span class="kt">Either</span> <span class="p">(</span><span class="kt">ParseErrorBundle</span> <span class="n">s</span> <span class="n">e</span><span class="p">)</span> <span class="n">a</span><span class="p">)</span>
</code></pre></div></div>

<p>手动修改 <code class="language-plaintext highlighter-rouge">State</code> 是该库的高级用法，我们不会在这里介绍。</p>

<h2 id="monadparsec-类型类"><code class="language-plaintext highlighter-rouge">MonadParsec</code> 类型类</h2>

<p><code class="language-plaintext highlighter-rouge">megaparsec</code> 中的所有工具都可用于 <code class="language-plaintext highlighter-rouge">MonadParsec</code> 类型类的任何实例。该类型类抽象了「组合子原语」，即所有 <code class="language-plaintext highlighter-rouge">megaparsec</code> 语法分析器的基本单元，这些组合子无法用其它组合子来表示。</p>

<p>将组合子原语定义为类型类，让 <code class="language-plaintext highlighter-rouge">ParsecT</code> 具体的主要单子变换得以包装在我们熟悉的 MTL 系单子变换中，从而实现在单子栈各层之间的不同交互。为了更好地理解其动机，请回忆一下单子栈各层的顺序很重要。如果我们这样组合 <code class="language-plaintext highlighter-rouge">ReaderT</code> 和 <code class="language-plaintext highlighter-rouge">State</code>：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">type</span> <span class="kt">MyStack</span> <span class="n">a</span> <span class="o">=</span> <span class="kt">ReaderT</span> <span class="kt">MyContext</span> <span class="p">(</span><span class="kt">State</span> <span class="kt">MyState</span><span class="p">)</span> <span class="n">a</span>
</code></pre></div></div>

<p>在外层，<code class="language-plaintext highlighter-rouge">ReaderT</code> 无法检查里面 <code class="language-plaintext highlighter-rouge">m</code> 层的内部结构。<code class="language-plaintext highlighter-rouge">ReaderT</code> 的 <code class="language-plaintext highlighter-rouge">Monad</code> 实例描述了绑定策略：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">newtype</span> <span class="kt">ReaderT</span> <span class="n">r</span> <span class="n">m</span> <span class="n">a</span> <span class="o">=</span> <span class="kt">ReaderT</span> <span class="p">{</span> <span class="n">runReaderT</span> <span class="o">::</span> <span class="n">r</span> <span class="o">-&gt;</span> <span class="n">m</span> <span class="n">a</span> <span class="p">}</span>

<span class="kr">instance</span> <span class="kt">Monad</span> <span class="n">m</span> <span class="o">=&gt;</span> <span class="kt">Monad</span> <span class="p">(</span><span class="kt">ReaderT</span> <span class="n">r</span> <span class="n">m</span><span class="p">)</span> <span class="kr">where</span>
  <span class="n">m</span> <span class="o">&gt;&gt;=</span> <span class="n">k</span> <span class="o">=</span> <span class="kt">ReaderT</span> <span class="o">$</span> <span class="nf">\</span><span class="n">r</span> <span class="o">-&gt;</span> <span class="kr">do</span>
    <span class="n">a</span> <span class="o">&lt;-</span> <span class="n">runReaderT</span> <span class="n">m</span> <span class="n">r</span>
    <span class="n">runReaderT</span> <span class="p">(</span><span class="n">k</span> <span class="n">a</span><span class="p">)</span> <span class="n">r</span>
</code></pre></div></div>

<p>实际上，我们对 <code class="language-plaintext highlighter-rouge">m</code> 的唯一了解是它是 <code class="language-plaintext highlighter-rouge">Monad</code> 的一个实例，因此 <code class="language-plaintext highlighter-rouge">m</code> 的状态只能通过单子绑定传给 <code class="language-plaintext highlighter-rouge">k</code>。总之，这就是 <code class="language-plaintext highlighter-rouge">ReaderT</code> 的 <code class="language-plaintext highlighter-rouge">(&gt;&gt;=)</code> 起到的典型作用。</p>

<p><code class="language-plaintext highlighter-rouge">Alternative</code> 的 <code class="language-plaintext highlighter-rouge">(&lt;|&gt;)</code> 方法有着不同的作用：它「分裂」了状态，并且这两个语法分析分支不再有交集，因此在某种意义上我们可以回溯状态。也就是说，如果第一个分支被丢弃，那么它对状态的修改也会被丢弃，并不会影响到第二个分支（相当于我们在第一个分支失败时回溯了状态）。</p>

<p>举个例子，我们可以看看 <code class="language-plaintext highlighter-rouge">ReaderT</code> 的 <code class="language-plaintext highlighter-rouge">Alternative</code> 实例：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">instance</span> <span class="kt">Alternative</span> <span class="n">m</span> <span class="o">=&gt;</span> <span class="kt">Alternative</span> <span class="p">(</span><span class="kt">ReaderT</span> <span class="n">r</span> <span class="n">m</span><span class="p">)</span> <span class="kr">where</span>
  <span class="n">empty</span> <span class="o">=</span> <span class="n">liftReaderT</span> <span class="n">empty</span>
  <span class="kt">ReaderT</span> <span class="n">m</span> <span class="o">&lt;|&gt;</span> <span class="kt">ReaderT</span> <span class="n">n</span> <span class="o">=</span> <span class="kt">ReaderT</span> <span class="o">$</span> <span class="nf">\</span><span class="n">r</span> <span class="o">-&gt;</span> <span class="n">m</span> <span class="n">r</span> <span class="o">&lt;|&gt;</span> <span class="n">n</span> <span class="n">r</span>
</code></pre></div></div>

<p>这很棒，因为 <code class="language-plaintext highlighter-rouge">ReaderT</code> 是个「无状态」的单子变换，并且很容易将实际工作委托给内部单子（在这里 <code class="language-plaintext highlighter-rouge">m</code> 的 <code class="language-plaintext highlighter-rouge">Alternative</code> 实例很有用），而无需组合 <code class="language-plaintext highlighter-rouge">ReaderT</code> 自身的单子状态（它并没有）。</p>

<p>现在我们来看看 <code class="language-plaintext highlighter-rouge">State</code>，因为 <code class="language-plaintext highlighter-rouge">State s a</code> 只是 <code class="language-plaintext highlighter-rouge">StateT s Identity a</code> 的别名，我们应该看看 <code class="language-plaintext highlighter-rouge">StateT s m</code> 的 <code class="language-plaintext highlighter-rouge">Alternative</code> 实例：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">instance</span> <span class="p">(</span><span class="kt">Functor</span> <span class="n">m</span><span class="p">,</span> <span class="kt">Alternative</span> <span class="n">m</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="kt">Alternative</span> <span class="p">(</span><span class="kt">StateT</span> <span class="n">s</span> <span class="n">m</span><span class="p">)</span> <span class="kr">where</span>
  <span class="n">empty</span> <span class="o">=</span> <span class="kt">StateT</span> <span class="o">$</span> <span class="nf">\</span><span class="kr">_</span> <span class="o">-&gt;</span> <span class="n">empty</span>
  <span class="kt">StateT</span> <span class="n">m</span> <span class="o">&lt;|&gt;</span> <span class="kt">StateT</span> <span class="n">n</span> <span class="o">=</span> <span class="kt">StateT</span> <span class="o">$</span> <span class="nf">\</span><span class="n">s</span> <span class="o">-&gt;</span> <span class="n">m</span> <span class="n">s</span> <span class="o">&lt;|&gt;</span> <span class="n">n</span> <span class="n">s</span>
</code></pre></div></div>

<p>这里我们看到了状态 <code class="language-plaintext highlighter-rouge">s</code> 的分裂，正如我们看到了上下文 <code class="language-plaintext highlighter-rouge">r</code> 的共享。它们的区别是，表达式 <code class="language-plaintext highlighter-rouge">m s</code> 和 <code class="language-plaintext highlighter-rouge">n s</code> 会产生有状态的结果：除了单子中的值，它们还会在元组中返回新的状态。这里我们要么走 <code class="language-plaintext highlighter-rouge">m s</code> 要么走 <code class="language-plaintext highlighter-rouge">n s</code>，自然实现了回溯。</p>

<p><code class="language-plaintext highlighter-rouge">ParsecT</code> 又如何呢？让我们考虑像下面这样把 <code class="language-plaintext highlighter-rouge">State</code> 放进 <code class="language-plaintext highlighter-rouge">ParsecT</code>：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">type</span> <span class="kt">MyStack</span> <span class="n">a</span> <span class="o">=</span> <span class="kt">Parsec</span> <span class="kt">Void</span> <span class="kt">Text</span> <span class="p">(</span><span class="kt">State</span> <span class="kt">MyState</span><span class="p">)</span> <span class="n">a</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">ParsecT</code> 比 <code class="language-plaintext highlighter-rouge">ReaderT</code> 更复杂，所以它的 <code class="language-plaintext highlighter-rouge">(&lt;|&gt;)</code> 实现得做更多工作：</p>

<ul>
  <li>管理语法分析器本身的状态；</li>
  <li>合并语法分析错误，如果有的话。</li>
</ul>

<p>因此 <code class="language-plaintext highlighter-rouge">ParsecT</code> 的 <code class="language-plaintext highlighter-rouge">Alternative</code> 实例中的 <code class="language-plaintext highlighter-rouge">(&lt;|&gt;)</code> 实现无法将其工作委托给内部单子 <code class="language-plaintext highlighter-rouge">State MyState</code> 的 <code class="language-plaintext highlighter-rouge">Alternative</code> 实例，所以 <code class="language-plaintext highlighter-rouge">MyState</code> 不会分裂，我们也不能回溯。</p>

<p>让我们用一个例子来证明这一点：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">{-# LANGUAGE OverloadedStrings #-}</span>

<span class="kr">module</span> <span class="nn">Main</span> <span class="p">(</span><span class="nf">main</span><span class="p">)</span> <span class="kr">where</span>

<span class="kr">import</span> <span class="nn">Control.Applicative</span>
<span class="kr">import</span> <span class="nn">Control.Monad.State.Strict</span>
<span class="kr">import</span> <span class="nn">Data.Text</span> <span class="p">(</span><span class="kt">Text</span><span class="p">)</span>
<span class="kr">import</span> <span class="nn">Data.Void</span>
<span class="kr">import</span> <span class="nn">Text.Megaparsec</span> <span class="k">hiding</span> <span class="p">(</span><span class="kt">State</span><span class="p">)</span>

<span class="kr">type</span> <span class="kt">Parser</span> <span class="o">=</span> <span class="kt">ParsecT</span> <span class="kt">Void</span> <span class="kt">Text</span> <span class="p">(</span><span class="kt">State</span> <span class="kt">String</span><span class="p">)</span>

<span class="n">parser0</span> <span class="o">::</span> <span class="kt">Parser</span> <span class="kt">String</span>
<span class="n">parser0</span> <span class="o">=</span> <span class="n">a</span> <span class="o">&lt;|&gt;</span> <span class="n">b</span>
  <span class="kr">where</span>
    <span class="n">a</span> <span class="o">=</span> <span class="s">"foo"</span> <span class="o">&lt;$</span> <span class="n">put</span> <span class="s">"branch A"</span>
    <span class="n">b</span> <span class="o">=</span> <span class="n">get</span>   <span class="o">&lt;*</span> <span class="n">put</span> <span class="s">"branch B"</span>

<span class="n">parser1</span> <span class="o">::</span> <span class="kt">Parser</span> <span class="kt">String</span>
<span class="n">parser1</span> <span class="o">=</span> <span class="n">a</span> <span class="o">&lt;|&gt;</span> <span class="n">b</span>
  <span class="kr">where</span>
    <span class="n">a</span> <span class="o">=</span> <span class="s">"foo"</span> <span class="o">&lt;$</span> <span class="n">put</span> <span class="s">"branch A"</span> <span class="o">&lt;*</span> <span class="n">empty</span>
    <span class="n">b</span> <span class="o">=</span> <span class="n">get</span>   <span class="o">&lt;*</span> <span class="n">put</span> <span class="s">"branch B"</span>

<span class="n">main</span> <span class="o">::</span> <span class="kt">IO</span> <span class="nb">()</span>
<span class="n">main</span> <span class="o">=</span> <span class="kr">do</span>
  <span class="kr">let</span> <span class="n">run</span> <span class="n">p</span>          <span class="o">=</span> <span class="n">runState</span> <span class="p">(</span><span class="n">runParserT</span> <span class="n">p</span> <span class="s">""</span> <span class="s">""</span><span class="p">)</span> <span class="s">"initial"</span>
      <span class="p">(</span><span class="kt">Right</span> <span class="n">a0</span><span class="p">,</span> <span class="n">s0</span><span class="p">)</span> <span class="o">=</span> <span class="n">run</span> <span class="n">parser0</span>
      <span class="p">(</span><span class="kt">Right</span> <span class="n">a1</span><span class="p">,</span> <span class="n">s1</span><span class="p">)</span> <span class="o">=</span> <span class="n">run</span> <span class="n">parser1</span>

  <span class="n">putStrLn</span>  <span class="s">"Parser 0"</span>
  <span class="n">putStrLn</span> <span class="p">(</span><span class="s">"Result:      "</span> <span class="o">++</span> <span class="n">show</span> <span class="n">a0</span><span class="p">)</span>
  <span class="n">putStrLn</span> <span class="p">(</span><span class="s">"Final state: "</span> <span class="o">++</span> <span class="n">show</span> <span class="n">s0</span><span class="p">)</span>

  <span class="n">putStrLn</span>  <span class="s">"Parser 1"</span>
  <span class="n">putStrLn</span> <span class="p">(</span><span class="s">"Result:      "</span> <span class="o">++</span> <span class="n">show</span> <span class="n">a1</span><span class="p">)</span>
  <span class="n">putStrLn</span> <span class="p">(</span><span class="s">"Final state: "</span> <span class="o">++</span> <span class="n">show</span> <span class="n">s1</span><span class="p">)</span>
</code></pre></div></div>

<p>这是程序的运行结果：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Parser 0
Result:      "foo"
Final state: "branch A"
Parser 1
Result:      "branch A"
Final state: "branch B"
</code></pre></div></div>

<p>我们可以看到 <code class="language-plaintext highlighter-rouge">parser0</code> 的分支 <code class="language-plaintext highlighter-rouge">b</code> 没有被尝试过。而 <code class="language-plaintext highlighter-rouge">parser1</code> 的最终结果（<code class="language-plaintext highlighter-rouge">get</code> 返回的值）显然来自分支 <code class="language-plaintext highlighter-rouge">a</code>，即使它因为 <code class="language-plaintext highlighter-rouge">empty</code> 而失败了，成功的是分支 <code class="language-plaintext highlighter-rouge">b</code>（<code class="language-plaintext highlighter-rouge">empty</code> 在这里表示立即失败，并且不会提供任何提示信息）。并没有发生回溯。</p>

<p>如果我们想要回溯自定义的状态怎么办呢？如果允许将 <code class="language-plaintext highlighter-rouge">ParsecT</code> 包装在 <code class="language-plaintext highlighter-rouge">StateT</code> 里面的话，就可以做到：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">type</span> <span class="kt">MyStack</span> <span class="n">a</span> <span class="o">=</span> <span class="kt">StateT</span> <span class="kt">MyState</span> <span class="p">(</span><span class="kt">ParsecT</span> <span class="kt">Void</span> <span class="kt">Text</span> <span class="kt">Identity</span><span class="p">)</span> <span class="n">a</span>
</code></pre></div></div>

<p>现在我们在 <code class="language-plaintext highlighter-rouge">MyStack</code> 上用的 <code class="language-plaintext highlighter-rouge">(&lt;|&gt;)</code> 作用于 <code class="language-plaintext highlighter-rouge">StateT</code> 的实例：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">StateT</span> <span class="n">m</span> <span class="o">&lt;|&gt;</span> <span class="kt">StateT</span> <span class="n">n</span> <span class="o">=</span> <span class="kt">StateT</span> <span class="o">$</span> <span class="nf">\</span><span class="n">s</span> <span class="o">-&gt;</span> <span class="n">m</span> <span class="n">s</span> <span class="o">&lt;|&gt;</span> <span class="n">n</span> <span class="n">s</span>
</code></pre></div></div>

<p>它会帮我们回溯状态，并会把剩下的工作委托给内部单子 <code class="language-plaintext highlighter-rouge">ParsecT</code> 的 <code class="language-plaintext highlighter-rouge">Alternative</code> 实例。这样的行为就是我们想要的：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">{-# LANGUAGE OverloadedStrings #-}</span>

<span class="kr">module</span> <span class="nn">Main</span> <span class="p">(</span><span class="nf">main</span><span class="p">)</span> <span class="kr">where</span>

<span class="kr">import</span> <span class="nn">Control.Applicative</span>
<span class="kr">import</span> <span class="nn">Control.Monad.Identity</span>
<span class="kr">import</span> <span class="nn">Control.Monad.State.Strict</span>
<span class="kr">import</span> <span class="nn">Data.Text</span> <span class="p">(</span><span class="kt">Text</span><span class="p">)</span>
<span class="kr">import</span> <span class="nn">Data.Void</span>
<span class="kr">import</span> <span class="nn">Text.Megaparsec</span> <span class="k">hiding</span> <span class="p">(</span><span class="kt">State</span><span class="p">)</span>

<span class="kr">type</span> <span class="kt">Parser</span> <span class="o">=</span> <span class="kt">StateT</span> <span class="kt">String</span> <span class="p">(</span><span class="kt">ParsecT</span> <span class="kt">Void</span> <span class="kt">Text</span> <span class="kt">Identity</span><span class="p">)</span>

<span class="n">parser</span> <span class="o">::</span> <span class="kt">Parser</span> <span class="kt">String</span>
<span class="n">parser</span> <span class="o">=</span> <span class="n">a</span> <span class="o">&lt;|&gt;</span> <span class="n">b</span>
  <span class="kr">where</span>
    <span class="n">a</span> <span class="o">=</span> <span class="s">"foo"</span> <span class="o">&lt;$</span> <span class="n">put</span> <span class="s">"branch A"</span> <span class="o">&lt;*</span> <span class="n">empty</span>
    <span class="n">b</span> <span class="o">=</span> <span class="n">get</span>   <span class="o">&lt;*</span> <span class="n">put</span> <span class="s">"branch B"</span>

<span class="n">main</span> <span class="o">::</span> <span class="kt">IO</span> <span class="nb">()</span>
<span class="n">main</span> <span class="o">=</span> <span class="kr">do</span>
  <span class="kr">let</span> <span class="n">p</span>            <span class="o">=</span> <span class="n">runStateT</span> <span class="n">parser</span> <span class="s">"initial"</span>
      <span class="kt">Right</span> <span class="p">(</span><span class="n">a</span><span class="p">,</span> <span class="n">s</span><span class="p">)</span> <span class="o">=</span> <span class="n">runParser</span> <span class="n">p</span> <span class="s">""</span> <span class="s">""</span>
  <span class="n">putStrLn</span> <span class="p">(</span><span class="s">"Result:      "</span> <span class="o">++</span> <span class="n">show</span> <span class="n">a</span><span class="p">)</span>
  <span class="n">putStrLn</span> <span class="p">(</span><span class="s">"Final state: "</span> <span class="o">++</span> <span class="n">show</span> <span class="n">s</span><span class="p">)</span>
</code></pre></div></div>

<p>程序输出为：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Result:      "initial"
Final state: "branch B"
</code></pre></div></div>

<p>为了让这种方法可行，<code class="language-plaintext highlighter-rouge">StateT</code> 应当支持所有组合子原语，这样我们就能像 <code class="language-plaintext highlighter-rouge">ParsecT</code> 一样使用它们。换句话说，它们应当是 <code class="language-plaintext highlighter-rouge">MonadParsec</code> 的实例，就像它们不仅是 <code class="language-plaintext highlighter-rouge">MonadState</code> 的实例，还是 <code class="language-plaintext highlighter-rouge">MonadWriter</code> 的实例，只要它们的内部单子也是 <code class="language-plaintext highlighter-rouge">MonadWriter</code> 的实例：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">instance</span> <span class="kt">MonadWriter</span> <span class="n">w</span> <span class="n">m</span> <span class="o">=&gt;</span> <span class="kt">MonadWriter</span> <span class="n">w</span> <span class="p">(</span><span class="kt">StateT</span> <span class="n">s</span> <span class="n">m</span><span class="p">)</span> <span class="kr">where</span> <span class="err">…</span>
</code></pre></div></div>

<p>实际上，我们可以将原语从 <code class="language-plaintext highlighter-rouge">MonadParsec</code> 的内部实例提升到 <code class="language-plaintext highlighter-rouge">StateT</code>：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">instance</span> <span class="kt">MonadParsec</span> <span class="n">e</span> <span class="n">s</span> <span class="n">m</span> <span class="o">=&gt;</span> <span class="kt">MonadParsec</span> <span class="n">e</span> <span class="n">s</span> <span class="p">(</span><span class="kt">StateT</span> <span class="n">st</span> <span class="n">m</span><span class="p">)</span> <span class="kr">where</span> <span class="err">…</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">megaparsec</code> 为所有 MTL 单子变换定义了 <code class="language-plaintext highlighter-rouge">MonadParsec</code> 的实例，这样用户就可以自由地在 <code class="language-plaintext highlighter-rouge">ParsecT</code> 中插入单子变换，或是把 <code class="language-plaintext highlighter-rouge">ParsecT</code> 包装在那些单子变换中，从而实现在单子栈各层之间的不同交互。</p>

<h2 id="词法分析">词法分析</h2>

<p>词法分析是将输入流转换为词法单词流的过程：整数、关键字、符号等等，它们比原始输入更加容易直接分析，或者可以送作生成的语法分析器的输入。词法分析可以用外部工具（如 <code class="language-plaintext highlighter-rouge">alex</code>）单独一个流程去做，但 <code class="language-plaintext highlighter-rouge">megaparsec</code> 也提供了可以无缝衔接编写词法分析器的函数。</p>

<p>共有两个词法分析器模块：<code class="language-plaintext highlighter-rouge">Text.Megaparsec.Char.Lexer</code> 用来处理字符流，<code class="language-plaintext highlighter-rouge">Text.Megaparsec.Byte.Lexer</code> 用来处理字节流。因为我们的输入流是严格求值的 <code class="language-plaintext highlighter-rouge">Text</code>，所以我们会用 <code class="language-plaintext highlighter-rouge">Text.Megaparsec.Char.Lexer</code>，不过大多数函数在 <code class="language-plaintext highlighter-rouge">Text.Megaparsec.Byte.Lexer</code> 里面长得差不多。</p>

<h3 id="空格">空格</h3>

<p>我们要讨论的第一个话题是如何处理空格。在消耗空格的时候保持一致性比较好，即要么在单词前要么在后。<code class="language-plaintext highlighter-rouge">megaparsec</code> 的词法分析器模块遵循的策略是：假设单词前没有空格，消耗单词后的空格。</p>

<p>我们需要一种特殊的词法分析器来消耗空格，我们叫它空格消耗器。<code class="language-plaintext highlighter-rouge">Text.Megaparsec.Char.Lexer</code> 模块提供了构建通用的空格消耗器的工具：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">space</span> <span class="o">::</span> <span class="kt">MonadParsec</span> <span class="n">e</span> <span class="n">s</span> <span class="n">m</span>
  <span class="o">=&gt;</span> <span class="n">m</span> <span class="nb">()</span> <span class="c1">-- ^ A parser for space characters which does not accept empty input (e.g. 'space1')</span>
  <span class="o">-&gt;</span> <span class="n">m</span> <span class="nb">()</span> <span class="c1">-- ^ A parser for a line comment (e.g. 'skipLineComment')</span>
  <span class="o">-&gt;</span> <span class="n">m</span> <span class="nb">()</span> <span class="c1">-- ^ A parser for a block comment (e.g. 'skipBlockComment')</span>
  <span class="o">-&gt;</span> <span class="n">m</span> <span class="nb">()</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">space</code> 函数的文档挺好理解的，但还是让我们来举例说明吧：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">{-# LANGUAGE OverloadedStrings #-}</span>

<span class="kr">module</span> <span class="nn">Main</span> <span class="p">(</span><span class="nf">main</span><span class="p">)</span> <span class="kr">where</span>

<span class="kr">import</span> <span class="nn">Data.Text</span> <span class="p">(</span><span class="kt">Text</span><span class="p">)</span>
<span class="kr">import</span> <span class="nn">Data.Void</span>
<span class="kr">import</span> <span class="nn">Text.Megaparsec</span>
<span class="kr">import</span> <span class="nn">Text.Megaparsec.Char</span>
<span class="kr">import</span> <span class="k">qualified</span> <span class="nn">Text.Megaparsec.Char.Lexer</span> <span class="k">as</span> <span class="n">L</span> <span class="c1">-- (1)</span>

<span class="kr">type</span> <span class="kt">Parser</span> <span class="o">=</span> <span class="kt">Parsec</span> <span class="kt">Void</span> <span class="kt">Text</span>

<span class="n">sc</span> <span class="o">::</span> <span class="kt">Parser</span> <span class="nb">()</span>
<span class="n">sc</span> <span class="o">=</span> <span class="kt">L</span><span class="o">.</span><span class="n">space</span>
  <span class="n">space1</span>                         <span class="c1">-- (2)</span>
  <span class="p">(</span><span class="kt">L</span><span class="o">.</span><span class="n">skipLineComment</span> <span class="s">"//"</span><span class="p">)</span>       <span class="c1">-- (3)</span>
  <span class="p">(</span><span class="kt">L</span><span class="o">.</span><span class="n">skipBlockComment</span> <span class="s">"/*"</span> <span class="s">"*/"</span><span class="p">)</span> <span class="c1">-- (4)</span>
</code></pre></div></div>

<ul>
  <li>(1) <code class="language-plaintext highlighter-rouge">Text.Megaparsec.Char.Lexer</code> 应当限定导入，因为它包含会与 <code class="language-plaintext highlighter-rouge">Text.Megaparsec.Char</code> 等模块冲突的名字，比如 <code class="language-plaintext highlighter-rouge">space</code>。</li>
  <li>(2) <code class="language-plaintext highlighter-rouge">L.space</code> 的第一个参数是个挑选空格的词法分析器。要注意它不能接受空输入，否则 <code class="language-plaintext highlighter-rouge">L.space</code> 会陷入死循环。<code class="language-plaintext highlighter-rouge">Text.Megaparsec.Char</code> 里的 <code class="language-plaintext highlighter-rouge">space1</code> 完美符合要求。</li>
  <li>(3) <code class="language-plaintext highlighter-rouge">L.space</code> 的第二个参数定义了如何跳过行注释，即以给定单词序列开始、以行末结束的注释。<code class="language-plaintext highlighter-rouge">skipLineComment</code> 可以帮我们轻松创建一个这样的词法分析器。</li>
  <li>(4) <code class="language-plaintext highlighter-rouge">L.space</code> 的第三个参数定义了如何跳过块注释，即包裹在给定的始末单词序列中的注释。<code class="language-plaintext highlighter-rouge">skipBlockComment</code> 可以帮我们处理非嵌套的块注释，若要支持嵌套则可使用 <code class="language-plaintext highlighter-rouge">skipBlockCommentNested</code>。</li>
</ul>

<p>操作上，<code class="language-plaintext highlighter-rouge">L.space</code> 会不停地轮流尝试以上三个词法分析器，直到三个都不再消耗空格。如果你的语法不包含注释，那么可以直接把 <code class="language-plaintext highlighter-rouge">empty</code> 作为第二或第三个参数送给 <code class="language-plaintext highlighter-rouge">L.space</code>。<code class="language-plaintext highlighter-rouge">empty</code>，作为 <code class="language-plaintext highlighter-rouge">(&lt;|&gt;)</code> 的单位元，仅仅会让 <code class="language-plaintext highlighter-rouge">L.space</code> 尝试下一个词法分析器。</p>

<p>有了空格消耗器 <code class="language-plaintext highlighter-rouge">sc</code>，我们可以定义各种空格相关的工具：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">lexeme</span> <span class="o">::</span> <span class="kt">Parser</span> <span class="n">a</span> <span class="o">-&gt;</span> <span class="kt">Parser</span> <span class="n">a</span>
<span class="n">lexeme</span> <span class="o">=</span> <span class="kt">L</span><span class="o">.</span><span class="n">lexeme</span> <span class="n">sc</span>

<span class="n">symbol</span> <span class="o">::</span> <span class="kt">Text</span> <span class="o">-&gt;</span> <span class="kt">Parser</span> <span class="kt">Text</span>
<span class="n">symbol</span> <span class="o">=</span> <span class="kt">L</span><span class="o">.</span><span class="n">symbol</span> <span class="n">sc</span>
</code></pre></div></div>

<ul>
  <li><code class="language-plaintext highlighter-rouge">lexeme</code> 是对词汇分析器的一种包装，能用已给定的空格消耗器挑选出所有尾随空格；</li>
  <li><code class="language-plaintext highlighter-rouge">symbol</code> 在内部使用 <code class="language-plaintext highlighter-rouge">string</code> 来匹配文本，类似地能够挑选出所有的尾随空格。</li>
</ul>

<p>稍后我们将看到它们如何协同工作，但在此之前我们需要引入更多来自 <code class="language-plaintext highlighter-rouge">Text.Megaparsec.Char.Lexer</code> 的工具。</p>

<h3 id="字符和字符串字面量">字符和字符串字面量</h3>

<p>对字符和字符串字面量进行词法分析比较微妙，因为有太多转义规则。简单起见，<code class="language-plaintext highlighter-rouge">megaparsec</code> 提供了 <code class="language-plaintext highlighter-rouge">charLiteral</code> 词法分析器：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">charLiteral</span> <span class="o">::</span> <span class="p">(</span><span class="kt">MonadParsec</span> <span class="n">e</span> <span class="n">s</span> <span class="n">m</span><span class="p">,</span> <span class="kt">Token</span> <span class="n">s</span> <span class="o">~</span> <span class="kt">Char</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="n">m</span> <span class="kt">Char</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">charLiteral</code> 的工作是根据 Haskell 报告中描述的字符字面量语法来对可能转义了的单个字符进行词法分析。但注意它不会管字面量两边的引号，这有两个原因：</p>

<ul>
  <li>用户可以控制字符字面量用什么作为引号；</li>
  <li><code class="language-plaintext highlighter-rouge">charLiteral</code> 也可以用来对字符串字面量进行词法分析。</li>
</ul>

<p>下面是基于 <code class="language-plaintext highlighter-rouge">charLiteral</code> 构建词法分析器的例子：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">charLiteral</span> <span class="o">::</span> <span class="kt">Parser</span> <span class="kt">Char</span>
<span class="n">charLiteral</span> <span class="o">=</span> <span class="n">between</span> <span class="p">(</span><span class="n">char</span> <span class="sc">'</span><span class="se">\'</span><span class="sc">'</span><span class="p">)</span> <span class="p">(</span><span class="n">char</span> <span class="sc">'</span><span class="se">\'</span><span class="sc">'</span><span class="p">)</span> <span class="kt">L</span><span class="o">.</span><span class="n">charLiteral</span>

<span class="n">stringLiteral</span> <span class="o">::</span> <span class="kt">Parser</span> <span class="kt">String</span>
<span class="n">stringLiteral</span> <span class="o">=</span> <span class="n">char</span> <span class="sc">'</span><span class="se">\"</span><span class="sc">'</span> <span class="o">*&gt;</span> <span class="n">manyTill</span> <span class="kt">L</span><span class="o">.</span><span class="n">charLiteral</span> <span class="p">(</span><span class="n">char</span> <span class="sc">'</span><span class="se">\"</span><span class="sc">'</span><span class="p">)</span>
</code></pre></div></div>

<ul>
  <li>要把 <code class="language-plaintext highlighter-rouge">L.charLiteral</code> 改造成我们所需的字符字面量的词法分析器，只需要加上两边的引号。这里我们遵循 Haskell 语法用了单引号。<code class="language-plaintext highlighter-rouge">between</code> 组合子是这样定义的：<code class="language-plaintext highlighter-rouge">between open close p = open *&gt; p &lt;* close</code>。</li>
  <li><code class="language-plaintext highlighter-rouge">stringLiteral</code> 用 <code class="language-plaintext highlighter-rouge">L.charLiteral</code> 来对每个字符进行词法分析，两边则用双引号包裹。</li>
</ul>

<p>第二个函数也很有趣，因为它用了 <code class="language-plaintext highlighter-rouge">manyTill</code> 组合子：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">manyTill</span> <span class="o">::</span> <span class="kt">Alternative</span> <span class="n">m</span> <span class="o">=&gt;</span> <span class="n">m</span> <span class="n">a</span> <span class="o">-&gt;</span> <span class="n">m</span> <span class="n">end</span> <span class="o">-&gt;</span> <span class="n">m</span> <span class="p">[</span><span class="n">a</span><span class="p">]</span>
<span class="n">manyTill</span> <span class="n">p</span> <span class="n">end</span> <span class="o">=</span> <span class="n">go</span>
  <span class="kr">where</span>
    <span class="n">go</span> <span class="o">=</span> <span class="p">(</span><span class="kt">[]</span> <span class="o">&lt;$</span> <span class="n">end</span><span class="p">)</span> <span class="o">&lt;|&gt;</span> <span class="p">((</span><span class="o">:</span><span class="p">)</span> <span class="o">&lt;$&gt;</span> <span class="n">p</span> <span class="o">&lt;*&gt;</span> <span class="n">go</span><span class="p">)</span>
</code></pre></div></div>

<p>每一轮 <code class="language-plaintext highlighter-rouge">manyTill</code> 先尝试运行 <code class="language-plaintext highlighter-rouge">end</code> 词法分析器，如果失败了就运行 <code class="language-plaintext highlighter-rouge">p</code> 并把结果装进列表。也有 <code class="language-plaintext highlighter-rouge">someTill</code> 保证 <code class="language-plaintext highlighter-rouge">p</code> 至少成功一次。</p>

<h3 id="数字">数字</h3>

<p>最后，一个非常常见的需求是对数字进行词法分析。对于整数来说，有三种工具分别处理十进制、八进制和十六进制数：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">decimal</span><span class="p">,</span> <span class="n">octal</span><span class="p">,</span> <span class="n">hexadecimal</span>
  <span class="o">::</span> <span class="p">(</span><span class="kt">MonadParsec</span> <span class="n">e</span> <span class="n">s</span> <span class="n">m</span><span class="p">,</span> <span class="kt">Token</span> <span class="n">s</span> <span class="o">~</span> <span class="kt">Char</span><span class="p">,</span> <span class="kt">Num</span> <span class="n">a</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="n">m</span> <span class="n">a</span>
</code></pre></div></div>

<p>使用起来很简单：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">integer</span> <span class="o">::</span> <span class="kt">Parser</span> <span class="kt">Integer</span>
<span class="n">integer</span> <span class="o">=</span> <span class="n">lexeme</span> <span class="kt">L</span><span class="o">.</span><span class="n">decimal</span>
</code></pre></div></div>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest (integer &lt;* eof) "123  "
123
</code></pre></div></div>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest (integer &lt;* eof) "12a  "
1:3:
  |
1 | 12a
  |   ^
unexpected 'a'
expecting end of input or the rest of integer
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">scientific</code> 接受整数和小数的语法，而 <code class="language-plaintext highlighter-rouge">float</code> 只接受小数。<code class="language-plaintext highlighter-rouge">scientific</code> 会返回 <code class="language-plaintext highlighter-rouge">scientific</code> 包的 <code class="language-plaintext highlighter-rouge">Scientific</code> 类型，而 <code class="language-plaintext highlighter-rouge">float</code> 的返回类型是多态的，可能会返回任何 <code class="language-plaintext highlighter-rouge">RealFloat</code> 的实例：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">scientific</span> <span class="o">::</span> <span class="p">(</span><span class="kt">MonadParsec</span> <span class="n">e</span> <span class="n">s</span> <span class="n">m</span><span class="p">,</span> <span class="kt">Token</span> <span class="n">s</span> <span class="o">~</span> <span class="kt">Char</span><span class="p">)</span>              <span class="o">=&gt;</span> <span class="n">m</span> <span class="kt">Scientific</span>
<span class="n">float</span>      <span class="o">::</span> <span class="p">(</span><span class="kt">MonadParsec</span> <span class="n">e</span> <span class="n">s</span> <span class="n">m</span><span class="p">,</span> <span class="kt">Token</span> <span class="n">s</span> <span class="o">~</span> <span class="kt">Char</span><span class="p">,</span> <span class="kt">RealFloat</span> <span class="n">a</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="n">m</span> <span class="n">a</span>
</code></pre></div></div>

<p>举个例子：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">float</span> <span class="o">::</span> <span class="kt">Parser</span> <span class="kt">Double</span>
<span class="n">float</span> <span class="o">=</span> <span class="n">lexeme</span> <span class="kt">L</span><span class="o">.</span><span class="n">float</span>
</code></pre></div></div>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest (float &lt;* eof) "123"
1:4:
  |
1 | 123
  |    ^
unexpected end of input
expecting '.', 'E', 'e', or digit
</code></pre></div></div>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest (float &lt;* eof) "123.45"
123.45
</code></pre></div></div>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest (float &lt;* eof) "123d"
1:4:
  |
1 | 123d
  |    ^
unexpected 'd'
expecting '.', 'E', 'e', or digit
</code></pre></div></div>

<p>注意所有这些词法分析器都无法处理有符号数，要支持这个我们得把它们包装在 <code class="language-plaintext highlighter-rouge">signed</code> 组合子中：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">signedInteger</span> <span class="o">::</span> <span class="kt">Parser</span> <span class="kt">Integer</span>
<span class="n">signedInteger</span> <span class="o">=</span> <span class="kt">L</span><span class="o">.</span><span class="n">signed</span> <span class="n">sc</span> <span class="n">integer</span>

<span class="n">signedFloat</span> <span class="o">::</span> <span class="kt">Parser</span> <span class="kt">Double</span>
<span class="n">signedFloat</span> <span class="o">=</span> <span class="kt">L</span><span class="o">.</span><span class="n">signed</span> <span class="n">sc</span> <span class="n">float</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">signed</code> 的第一个参数是空格消耗器，用来控制正负号和实际数字之间的空格。如果你不允许中间有空格，传 <code class="language-plaintext highlighter-rouge">return ()</code> 进去就行了。</p>

<h2 id="notfollowedby-和-lookahead"><code class="language-plaintext highlighter-rouge">notFollowedBy</code> 和 <code class="language-plaintext highlighter-rouge">lookAhead</code></h2>

<p>除了 <code class="language-plaintext highlighter-rouge">try</code>，还有另外两种原语可以对输入流进行前瞻，而不会实际挪动当前位置。</p>

<p>第一种是 <code class="language-plaintext highlighter-rouge">notFollowedBy</code>：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">notFollowedBy</span> <span class="o">::</span> <span class="kt">MonadParsec</span> <span class="n">e</span> <span class="n">s</span> <span class="n">m</span> <span class="o">=&gt;</span> <span class="n">m</span> <span class="n">a</span> <span class="o">-&gt;</span> <span class="n">m</span> <span class="nb">()</span>
</code></pre></div></div>

<p>只有当其参数语法分析失败了它才会成功，并且不会吃掉任何输入或是修改当前状态。</p>

<p>作为 <code class="language-plaintext highlighter-rouge">notFollowedBy</code> 的例子，我们考虑一下关键字：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">pKeyword</span> <span class="o">::</span> <span class="kt">Text</span> <span class="o">-&gt;</span> <span class="kt">Parser</span> <span class="kt">Text</span>
<span class="n">pKeyword</span> <span class="n">keyword</span> <span class="o">=</span> <span class="n">lexeme</span> <span class="p">(</span><span class="n">string</span> <span class="n">keyword</span><span class="p">)</span>
</code></pre></div></div>

<p>这个语法分析器有个毛病：如果我们匹配到的只是标识符的前缀怎么办呢？这个情况下它显然不是关键字。因此我们必须用 <code class="language-plaintext highlighter-rouge">notFollowedBy</code> 排除这种情况：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">pKeyword</span> <span class="o">::</span> <span class="kt">Text</span> <span class="o">-&gt;</span> <span class="kt">Parser</span> <span class="kt">Text</span>
<span class="n">pKeyword</span> <span class="n">keyword</span> <span class="o">=</span> <span class="n">lexeme</span> <span class="p">(</span><span class="n">string</span> <span class="n">keyword</span> <span class="o">&lt;*</span> <span class="n">notFollowedBy</span> <span class="n">alphaNumChar</span><span class="p">)</span>
</code></pre></div></div>

<p>另一种原语是 <code class="language-plaintext highlighter-rouge">lookAhead</code>：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">lookAhead</span> <span class="o">::</span> <span class="kt">MonadParsec</span> <span class="n">e</span> <span class="n">s</span> <span class="n">m</span> <span class="o">=&gt;</span> <span class="n">m</span> <span class="n">a</span> <span class="o">-&gt;</span> <span class="n">m</span> <span class="n">a</span>
</code></pre></div></div>

<p>如果 <code class="language-plaintext highlighter-rouge">lookAhead</code> 的参数 <code class="language-plaintext highlighter-rouge">p</code> 成功了，那么整个 <code class="language-plaintext highlighter-rouge">lookAhead p</code> 也会成功，但输入流和整个语法分析状态不会改变。</p>

<p>一个例子是对已分析的输入进行检查，要么失败要么成功地进行下去。这可以用下述代码表达：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">withPredicate1</span>
  <span class="o">::</span> <span class="p">(</span><span class="n">a</span> <span class="o">-&gt;</span> <span class="kt">Bool</span><span class="p">)</span>       <span class="c1">-- ^ The check to perform on parsed input</span>
  <span class="o">-&gt;</span> <span class="kt">String</span>            <span class="c1">-- ^ Message to print when the check fails</span>
  <span class="o">-&gt;</span> <span class="kt">Parser</span> <span class="n">a</span>          <span class="c1">-- ^ Parser to run</span>
  <span class="o">-&gt;</span> <span class="kt">Parser</span> <span class="n">a</span>          <span class="c1">-- ^ Resulting parser that performs the check</span>
<span class="n">withPredicate1</span> <span class="n">f</span> <span class="n">msg</span> <span class="n">p</span> <span class="o">=</span> <span class="kr">do</span>
  <span class="n">r</span> <span class="o">&lt;-</span> <span class="n">lookAhead</span> <span class="n">p</span>
  <span class="kr">if</span> <span class="n">f</span> <span class="n">r</span>
    <span class="kr">then</span> <span class="n">p</span>
    <span class="kr">else</span> <span class="n">fail</span> <span class="n">msg</span>
</code></pre></div></div>

<p>这演示了 <code class="language-plaintext highlighter-rouge">lookAhead</code> 的一种用法，但我们还应注意，如果检查成功我们会进行两次语法分析，这不太好。我们可以改用 <code class="language-plaintext highlighter-rouge">getOffset</code> 函数解决这个问题：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">withPredicate2</span>
  <span class="o">::</span> <span class="p">(</span><span class="n">a</span> <span class="o">-&gt;</span> <span class="kt">Bool</span><span class="p">)</span>       <span class="c1">-- ^ The check to perform on parsed input</span>
  <span class="o">-&gt;</span> <span class="kt">String</span>            <span class="c1">-- ^ Message to print when the check fails</span>
  <span class="o">-&gt;</span> <span class="kt">Parser</span> <span class="n">a</span>          <span class="c1">-- ^ Parser to run</span>
  <span class="o">-&gt;</span> <span class="kt">Parser</span> <span class="n">a</span>          <span class="c1">-- ^ Resulting parser that performs the check</span>
<span class="n">withPredicate2</span> <span class="n">f</span> <span class="n">msg</span> <span class="n">p</span> <span class="o">=</span> <span class="kr">do</span>
  <span class="n">o</span> <span class="o">&lt;-</span> <span class="n">getOffset</span>
  <span class="n">r</span> <span class="o">&lt;-</span> <span class="n">p</span>
  <span class="kr">if</span> <span class="n">f</span> <span class="n">r</span>
    <span class="kr">then</span> <span class="n">return</span> <span class="n">r</span>
    <span class="kr">else</span> <span class="kr">do</span>
      <span class="n">setOffset</span> <span class="n">o</span>
      <span class="n">fail</span> <span class="n">msg</span>
</code></pre></div></div>

<p>在失败时，我们只需将输入流的偏移量设置回运行 <code class="language-plaintext highlighter-rouge">p</code> 之前的位置即可。但现在消耗量跟偏移量会不匹配，但在这里没有关系，因为我们调用 <code class="language-plaintext highlighter-rouge">fail</code> 立即结束了语法分析。但这在其它地方可能会出问题，我们将在后面的章节中看到如何改进。</p>

<h2 id="表达式的语法分析">表达式的语法分析</h2>

<p>「表达式」是指由一些项和应用于这些项的运算符组成的结构。运算符可以前置、中置、后置，可以左结合、右结合，可以有不同的优先级。这种构造的一个例子是学校里教的算术表达式：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>a * (b + 2)
</code></pre></div></div>

<p>这里我们可以看到两种不同的项：变量（<code class="language-plaintext highlighter-rouge">a</code>、<code class="language-plaintext highlighter-rouge">b</code>）和整数（<code class="language-plaintext highlighter-rouge">2</code>）。另外还有两种运算符：<code class="language-plaintext highlighter-rouge">*</code> 和 <code class="language-plaintext highlighter-rouge">+</code>。</p>

<p>为表达式编写一个正确的语法分析器大概需要假以时日。为此，<a href="https://hackage.haskell.org/package/parser-combinators">parser-combinators</a> 包提供了 <code class="language-plaintext highlighter-rouge">Control.Monad.Combinators.Expr</code> 模块，它一共导出了两样东西：<code class="language-plaintext highlighter-rouge">Operator</code> 数据类型和 <code class="language-plaintext highlighter-rouge">makeExprParser</code> 工具函数。两者均文档齐全，所以本节我们不会复述文档，而是编写一个简单但功能完备的表达式语法分析器。</p>

<p>让我们先定义一个表示抽象语法树的数据结构：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">data</span> <span class="kt">Expr</span>
  <span class="o">=</span> <span class="kt">Var</span> <span class="kt">String</span>
  <span class="o">|</span> <span class="kt">Int</span> <span class="kt">Int</span>
  <span class="o">|</span> <span class="kt">Negation</span> <span class="kt">Expr</span>
  <span class="o">|</span> <span class="kt">Sum</span>      <span class="kt">Expr</span> <span class="kt">Expr</span>
  <span class="o">|</span> <span class="kt">Subtr</span>    <span class="kt">Expr</span> <span class="kt">Expr</span>
  <span class="o">|</span> <span class="kt">Product</span>  <span class="kt">Expr</span> <span class="kt">Expr</span>
  <span class="o">|</span> <span class="kt">Division</span> <span class="kt">Expr</span> <span class="kt">Expr</span>
  <span class="kr">deriving</span> <span class="p">(</span><span class="kt">Eq</span><span class="p">,</span> <span class="kt">Ord</span><span class="p">,</span> <span class="kt">Show</span><span class="p">)</span>
</code></pre></div></div>

<p>要用 <code class="language-plaintext highlighter-rouge">makeExprParser</code> 我们得给它一个项语法分析器和一个运算符表：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">makeExprParser</span> <span class="o">::</span> <span class="kt">MonadParsec</span> <span class="n">e</span> <span class="n">s</span> <span class="n">m</span>
  <span class="o">=&gt;</span> <span class="n">m</span> <span class="n">a</span>               <span class="c1">-- ^ Term parser</span>
  <span class="o">-&gt;</span> <span class="p">[[</span><span class="kt">Operator</span> <span class="n">m</span> <span class="n">a</span><span class="p">]]</span>  <span class="c1">-- ^ Operator table, see 'Operator'</span>
  <span class="o">-&gt;</span> <span class="n">m</span> <span class="n">a</span>               <span class="c1">-- ^ Resulting expression parser</span>
</code></pre></div></div>

<p>让我们从项语法分析器开始。我们可以把项视为一个盒子，当处理结合性和优先级之类的东西时，表达式的语法分析算法会将其视为不可分割的整体。在我们例子中，有三类东西属于项：变量、整数和括号中的整个表达式。沿用前面几章节的定义，我们可以把项语法分析器定义为：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">pVariable</span> <span class="o">::</span> <span class="kt">Parser</span> <span class="kt">Expr</span>
<span class="n">pVariable</span> <span class="o">=</span> <span class="kt">Var</span> <span class="o">&lt;$&gt;</span> <span class="n">lexeme</span>
  <span class="p">((</span><span class="o">:</span><span class="p">)</span> <span class="o">&lt;$&gt;</span> <span class="n">letterChar</span> <span class="o">&lt;*&gt;</span> <span class="n">many</span> <span class="n">alphaNumChar</span> <span class="o">&lt;?&gt;</span> <span class="s">"variable"</span><span class="p">)</span>

<span class="n">pInteger</span> <span class="o">::</span> <span class="kt">Parser</span> <span class="kt">Expr</span>
<span class="n">pInteger</span> <span class="o">=</span> <span class="kt">Int</span> <span class="o">&lt;$&gt;</span> <span class="n">lexeme</span> <span class="kt">L</span><span class="o">.</span><span class="n">decimal</span>

<span class="n">parens</span> <span class="o">::</span> <span class="kt">Parser</span> <span class="n">a</span> <span class="o">-&gt;</span> <span class="kt">Parser</span> <span class="n">a</span>
<span class="n">parens</span> <span class="o">=</span> <span class="n">between</span> <span class="p">(</span><span class="n">symbol</span> <span class="s">"("</span><span class="p">)</span> <span class="p">(</span><span class="n">symbol</span> <span class="s">")"</span><span class="p">)</span>

<span class="n">pTerm</span> <span class="o">::</span> <span class="kt">Parser</span> <span class="kt">Expr</span>
<span class="n">pTerm</span> <span class="o">=</span> <span class="n">choice</span>
  <span class="p">[</span> <span class="n">parens</span> <span class="n">pExpr</span>
  <span class="p">,</span> <span class="n">pVariable</span>
  <span class="p">,</span> <span class="n">pInteger</span>
  <span class="p">]</span>

<span class="n">pExpr</span> <span class="o">::</span> <span class="kt">Parser</span> <span class="kt">Expr</span>
<span class="n">pExpr</span> <span class="o">=</span> <span class="n">makeExprParser</span> <span class="n">pTerm</span> <span class="n">operatorTable</span>

<span class="n">operatorTable</span> <span class="o">::</span> <span class="p">[[</span><span class="kt">Operator</span> <span class="kt">Parser</span> <span class="kt">Expr</span><span class="p">]]</span>
<span class="n">operatorTable</span> <span class="o">=</span> <span class="n">undefined</span> <span class="c1">-- TODO</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">pVariable</code>、<code class="language-plaintext highlighter-rouge">pInteger</code> 和 <code class="language-plaintext highlighter-rouge">parens</code> 的定义应该没什么疑问。这里幸运的是我们不需要在 <code class="language-plaintext highlighter-rouge">pTerm</code> 中使用 <code class="language-plaintext highlighter-rouge">try</code>，因为项的语法没有重叠之处：</p>

<ul>
  <li>如果我们看到左括号 <code class="language-plaintext highlighter-rouge">(</code>，那紧接着肯定是一个表达式；</li>
  <li>如果我们看到一个字母，那肯定是标识符的开始；</li>
  <li>如果我们看到一个数字，那肯定是整数的开始。</li>
</ul>

<p>最后，为了完成 <code class="language-plaintext highlighter-rouge">pExpr</code>，我们需要定义 <code class="language-plaintext highlighter-rouge">operatorTable</code>，从类型可以看出它是个嵌套列表。每个内层列表装着相同优先级的运算符，而整个外层列表以优先级降序排列。一组运算符的优先级越高，它们结合得就越紧。</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">data</span> <span class="kt">Operator</span> <span class="n">m</span> <span class="n">a</span> <span class="c1">-- N.B.</span>
  <span class="o">=</span> <span class="kt">InfixN</span>  <span class="p">(</span><span class="n">m</span> <span class="p">(</span><span class="n">a</span> <span class="o">-&gt;</span> <span class="n">a</span> <span class="o">-&gt;</span> <span class="n">a</span><span class="p">))</span> <span class="c1">-- ^ Non-associative infix</span>
  <span class="o">|</span> <span class="kt">InfixL</span>  <span class="p">(</span><span class="n">m</span> <span class="p">(</span><span class="n">a</span> <span class="o">-&gt;</span> <span class="n">a</span> <span class="o">-&gt;</span> <span class="n">a</span><span class="p">))</span> <span class="c1">-- ^ Left-associative infix</span>
  <span class="o">|</span> <span class="kt">InfixR</span>  <span class="p">(</span><span class="n">m</span> <span class="p">(</span><span class="n">a</span> <span class="o">-&gt;</span> <span class="n">a</span> <span class="o">-&gt;</span> <span class="n">a</span><span class="p">))</span> <span class="c1">-- ^ Right-associative infix</span>
  <span class="o">|</span> <span class="kt">Prefix</span>  <span class="p">(</span><span class="n">m</span> <span class="p">(</span><span class="n">a</span> <span class="o">-&gt;</span> <span class="n">a</span><span class="p">))</span>      <span class="c1">-- ^ Prefix</span>
  <span class="o">|</span> <span class="kt">Postfix</span> <span class="p">(</span><span class="n">m</span> <span class="p">(</span><span class="n">a</span> <span class="o">-&gt;</span> <span class="n">a</span><span class="p">))</span>      <span class="c1">-- ^ Postfix</span>

<span class="n">operatorTable</span> <span class="o">::</span> <span class="p">[[</span><span class="kt">Operator</span> <span class="kt">Parser</span> <span class="kt">Expr</span><span class="p">]]</span>
<span class="n">operatorTable</span> <span class="o">=</span>
  <span class="p">[</span> <span class="p">[</span> <span class="n">prefix</span> <span class="s">"-"</span> <span class="kt">Negation</span>
    <span class="p">,</span> <span class="n">prefix</span> <span class="s">"+"</span> <span class="n">id</span>
    <span class="p">]</span>
  <span class="p">,</span> <span class="p">[</span> <span class="n">binary</span> <span class="s">"*"</span> <span class="kt">Product</span>
    <span class="p">,</span> <span class="n">binary</span> <span class="s">"/"</span> <span class="kt">Division</span>
    <span class="p">]</span>
  <span class="p">,</span> <span class="p">[</span> <span class="n">binary</span> <span class="s">"+"</span> <span class="kt">Sum</span>
    <span class="p">,</span> <span class="n">binary</span> <span class="s">"-"</span> <span class="kt">Subtr</span>
    <span class="p">]</span>
  <span class="p">]</span>

<span class="n">binary</span> <span class="o">::</span> <span class="kt">Text</span> <span class="o">-&gt;</span> <span class="p">(</span><span class="kt">Expr</span> <span class="o">-&gt;</span> <span class="kt">Expr</span> <span class="o">-&gt;</span> <span class="kt">Expr</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">Operator</span> <span class="kt">Parser</span> <span class="kt">Expr</span>
<span class="n">binary</span>  <span class="n">name</span> <span class="n">f</span> <span class="o">=</span> <span class="kt">InfixL</span>  <span class="p">(</span><span class="n">f</span> <span class="o">&lt;$</span> <span class="n">symbol</span> <span class="n">name</span><span class="p">)</span>

<span class="n">prefix</span><span class="p">,</span> <span class="n">postfix</span> <span class="o">::</span> <span class="kt">Text</span> <span class="o">-&gt;</span> <span class="p">(</span><span class="kt">Expr</span> <span class="o">-&gt;</span> <span class="kt">Expr</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">Operator</span> <span class="kt">Parser</span> <span class="kt">Expr</span>
<span class="n">prefix</span>  <span class="n">name</span> <span class="n">f</span> <span class="o">=</span> <span class="kt">Prefix</span>  <span class="p">(</span><span class="n">f</span> <span class="o">&lt;$</span> <span class="n">symbol</span> <span class="n">name</span><span class="p">)</span>
<span class="n">postfix</span> <span class="n">name</span> <span class="n">f</span> <span class="o">=</span> <span class="kt">Postfix</span> <span class="p">(</span><span class="n">f</span> <span class="o">&lt;$</span> <span class="n">symbol</span> <span class="n">name</span><span class="p">)</span>
</code></pre></div></div>

<p>注意 <code class="language-plaintext highlighter-rouge">binary</code> 中 <code class="language-plaintext highlighter-rouge">InfixL</code> 接受的 <code class="language-plaintext highlighter-rouge">Parser (Expr -&gt; Expr -&gt; Expr)</code> 我们是怎么写的，相似的还有 <code class="language-plaintext highlighter-rouge">prefix</code> 和 <code class="language-plaintext highlighter-rouge">postfix</code> 中的 <code class="language-plaintext highlighter-rouge">Parser (Expr -&gt; Expr)</code>。也就是说，我们先运行 <code class="language-plaintext highlighter-rouge">symbol name</code> 然后返回一个函数，它会依次接受各项作为参数并返回 <code class="language-plaintext highlighter-rouge">Expr</code> 类型的结果。</p>

<p>准备好了，现在可以试试我们的语法分析器了！</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest (pExpr &lt;* eof) "a * (b + 2)"
Product (Var "a") (Sum (Var "b") (Int 2))
</code></pre></div></div>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest (pExpr &lt;* eof) "a * b + 2"
Sum (Product (Var "a") (Var "b")) (Int 2)
</code></pre></div></div>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest (pExpr &lt;* eof) "a * b / 2"
Division (Product (Var "a") (Var "b")) (Int 2)
</code></pre></div></div>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest (pExpr &lt;* eof) "a * (b $ 2)"
1:8:
  |
1 | a * (b $ 2)
  |        ^
unexpected '$'
expecting ')' or operator
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">Control.Monad.Combinators.Expr</code> 模块的文档里有一些提示，在不太标准的情况下很有用，最好也读一下。</p>

<h2 id="缩进敏感的语法分析">缩进敏感的语法分析</h2>

<p><code class="language-plaintext highlighter-rouge">Text.Megaparsec.Char.Lexer</code> 模块还包含一些工具，在处理对缩进敏感的语法时很有用。我们会先综述一下可用的组合子，然后再把它们组装成一个对缩进敏感的语法分析器。</p>

<h3 id="nonindented-和-indentblock"><code class="language-plaintext highlighter-rouge">nonIndented</code> 和 <code class="language-plaintext highlighter-rouge">indentBlock</code></h3>

<p>让我们从最简单的 <code class="language-plaintext highlighter-rouge">nonIndented</code> 开始：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">nonIndented</span> <span class="o">::</span> <span class="kt">MonadParsec</span> <span class="n">e</span> <span class="n">s</span> <span class="n">m</span>
  <span class="o">=&gt;</span> <span class="n">m</span> <span class="nb">()</span>              <span class="c1">-- ^ How to consume indentation (white space)</span>
  <span class="o">-&gt;</span> <span class="n">m</span> <span class="n">a</span>               <span class="c1">-- ^ Inner parser</span>
  <span class="o">-&gt;</span> <span class="n">m</span> <span class="n">a</span>
</code></pre></div></div>

<p>它允许内部语法分析器吃掉所有没缩进的输入，这是缩进敏感语法分析背后模型的一部分。我们规定，未缩进的部分是顶层定义，而所有缩进的部分直接或间接地从属于顶层定义。在 <code class="language-plaintext highlighter-rouge">megaparsec</code> 中，我们不需要任何额外的状态来表达这个想法。因为缩进是相对的，所以我们的想法是显式地把参考单词和缩进单词都传给语法分析器，这样就能通过纯的语法分析器组合来定义对缩进敏感的语法。</p>

<p>那么我们应当如何为缩进块定义语法分析器呢？让我们看一眼 <code class="language-plaintext highlighter-rouge">indentBlock</code> 的签名：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">indentBlock</span> <span class="o">::</span> <span class="p">(</span><span class="kt">MonadParsec</span> <span class="n">e</span> <span class="n">s</span> <span class="n">m</span><span class="p">,</span> <span class="kt">Token</span> <span class="n">s</span> <span class="o">~</span> <span class="kt">Char</span><span class="p">)</span>
  <span class="o">=&gt;</span> <span class="n">m</span> <span class="nb">()</span>                <span class="c1">-- ^ How to consume indentation (white space)</span>
  <span class="o">-&gt;</span> <span class="n">m</span> <span class="p">(</span><span class="kt">IndentOpt</span> <span class="n">m</span> <span class="n">a</span> <span class="n">b</span><span class="p">)</span> <span class="c1">-- ^ How to parse “reference” token</span>
  <span class="o">-&gt;</span> <span class="n">m</span> <span class="n">a</span>
</code></pre></div></div>

<p>首先，我们指定如何吃掉缩进。要注意的是这里的空格消耗器必须也吃掉换行符，但正常来讲单词后面的换行符是不应该吃掉的。</p>

<p>如你所见，第二个参数允许我们对参考单词进行语法分析，并返回一个告诉 <code class="language-plaintext highlighter-rouge">indentBlock</code> 接下来做什么的数据结构。下面是几种选择：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">data</span> <span class="kt">IndentOpt</span> <span class="n">m</span> <span class="n">a</span> <span class="n">b</span>
  <span class="o">=</span> <span class="kt">IndentNone</span> <span class="n">a</span>
    <span class="c1">-- ^ Parse no indented tokens, just return the value</span>
  <span class="o">|</span> <span class="kt">IndentMany</span> <span class="p">(</span><span class="kt">Maybe</span> <span class="kt">Pos</span><span class="p">)</span> <span class="p">([</span><span class="n">b</span><span class="p">]</span> <span class="o">-&gt;</span> <span class="n">m</span> <span class="n">a</span><span class="p">)</span> <span class="p">(</span><span class="n">m</span> <span class="n">b</span><span class="p">)</span>
    <span class="c1">-- ^ Parse many indented tokens (possibly zero), use given indentation level</span>
    <span class="c1">-- (if 'Nothing', use level of the first indented token);</span>
    <span class="c1">-- the second argument tells how to get the final result, and</span>
    <span class="c1">-- the third argument describes how to parse an indented token</span>
  <span class="o">|</span> <span class="kt">IndentSome</span> <span class="p">(</span><span class="kt">Maybe</span> <span class="kt">Pos</span><span class="p">)</span> <span class="p">([</span><span class="n">b</span><span class="p">]</span> <span class="o">-&gt;</span> <span class="n">m</span> <span class="n">a</span><span class="p">)</span> <span class="p">(</span><span class="n">m</span> <span class="n">b</span><span class="p">)</span>
    <span class="c1">-- ^ Just like 'IndentMany', but requires at least one indented token to be present</span>
</code></pre></div></div>

<p>我们可以改变主意不对缩进单词进行语法分析，也可以处理许多缩进单词。我们可以让 <code class="language-plaintext highlighter-rouge">indentBlock</code> 检测首个缩进单词的缩进层级并使用它，也可以手动指定缩进层级。</p>

<h3 id="简单的缩进列表">简单的缩进列表</h3>

<p>让我们试着对一个简单的缩进列表进行语法分析，我们从导入部分开始：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">{-# LANGUAGE OverloadedStrings #-}</span>
<span class="cp">{-# LANGUAGE TupleSections     #-}</span>

<span class="kr">module</span> <span class="nn">Main</span> <span class="p">(</span><span class="nf">main</span><span class="p">)</span> <span class="kr">where</span>

<span class="kr">import</span> <span class="nn">Control.Applicative</span>
<span class="kr">import</span> <span class="nn">Control.Monad</span> <span class="p">(</span><span class="nf">void</span><span class="p">)</span>
<span class="kr">import</span> <span class="nn">Data.Text</span> <span class="p">(</span><span class="kt">Text</span><span class="p">)</span>
<span class="kr">import</span> <span class="nn">Data.Void</span>
<span class="kr">import</span> <span class="nn">Text.Megaparsec</span>
<span class="kr">import</span> <span class="nn">Text.Megaparsec.Char</span>
<span class="kr">import</span> <span class="k">qualified</span> <span class="nn">Text.Megaparsec.Char.Lexer</span> <span class="k">as</span> <span class="n">L</span>

<span class="kr">type</span> <span class="kt">Parser</span> <span class="o">=</span> <span class="kt">Parsec</span> <span class="kt">Void</span> <span class="kt">Text</span>
</code></pre></div></div>

<p>我们需要两种空格消耗器：一种 <code class="language-plaintext highlighter-rouge">scn</code> 会吃掉换行符，另一种 <code class="language-plaintext highlighter-rouge">sc</code> 不会（实际上在这里它只处理空格和制表符）：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">lineComment</span> <span class="o">::</span> <span class="kt">Parser</span> <span class="nb">()</span>
<span class="n">lineComment</span> <span class="o">=</span> <span class="kt">L</span><span class="o">.</span><span class="n">skipLineComment</span> <span class="s">"#"</span>

<span class="n">scn</span> <span class="o">::</span> <span class="kt">Parser</span> <span class="nb">()</span>
<span class="n">scn</span> <span class="o">=</span> <span class="kt">L</span><span class="o">.</span><span class="n">space</span> <span class="n">space1</span> <span class="n">lineComment</span> <span class="n">empty</span>

<span class="n">sc</span> <span class="o">::</span> <span class="kt">Parser</span> <span class="nb">()</span>
<span class="n">sc</span> <span class="o">=</span> <span class="kt">L</span><span class="o">.</span><span class="n">space</span> <span class="p">(</span><span class="n">void</span> <span class="o">$</span> <span class="n">some</span> <span class="p">(</span><span class="n">char</span> <span class="sc">' '</span> <span class="o">&lt;|&gt;</span> <span class="n">char</span> <span class="sc">'</span><span class="se">\t</span><span class="sc">'</span><span class="p">))</span> <span class="n">lineComment</span> <span class="n">empty</span>

<span class="n">lexeme</span> <span class="o">::</span> <span class="kt">Parser</span> <span class="n">a</span> <span class="o">-&gt;</span> <span class="kt">Parser</span> <span class="n">a</span>
<span class="n">lexeme</span> <span class="o">=</span> <span class="kt">L</span><span class="o">.</span><span class="n">lexeme</span> <span class="n">sc</span>
</code></pre></div></div>

<p>为了好玩，我们还允许 <code class="language-plaintext highlighter-rouge">#</code> 开头的行注释。</p>

<p><code class="language-plaintext highlighter-rouge">pItemList</code> 是顶层形式，它包括参考单词（表头）和缩进单词（表项）：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">pItemList</span> <span class="o">::</span> <span class="kt">Parser</span> <span class="p">(</span><span class="kt">String</span><span class="p">,</span> <span class="p">[</span><span class="kt">String</span><span class="p">])</span> <span class="c1">-- header and list items</span>
<span class="n">pItemList</span> <span class="o">=</span> <span class="kt">L</span><span class="o">.</span><span class="n">nonIndented</span> <span class="n">scn</span> <span class="p">(</span><span class="kt">L</span><span class="o">.</span><span class="n">indentBlock</span> <span class="n">scn</span> <span class="n">p</span><span class="p">)</span>
  <span class="kr">where</span>
    <span class="n">p</span> <span class="o">=</span> <span class="kr">do</span>
      <span class="n">header</span> <span class="o">&lt;-</span> <span class="n">pItem</span>
      <span class="n">return</span> <span class="p">(</span><span class="kt">L</span><span class="o">.</span><span class="kt">IndentMany</span> <span class="kt">Nothing</span> <span class="p">(</span><span class="n">return</span> <span class="o">.</span> <span class="p">(</span><span class="n">header</span><span class="p">,</span> <span class="p">))</span> <span class="n">pItem</span><span class="p">)</span>
</code></pre></div></div>

<p>对于我们来讲，表项就是一串字母、数字和短横线组成的序列：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">pItem</span> <span class="o">::</span> <span class="kt">Parser</span> <span class="kt">String</span>
<span class="n">pItem</span> <span class="o">=</span> <span class="n">lexeme</span> <span class="p">(</span><span class="n">some</span> <span class="p">(</span><span class="n">alphaNumChar</span> <span class="o">&lt;|&gt;</span> <span class="n">char</span> <span class="sc">'-'</span><span class="p">))</span> <span class="o">&lt;?&gt;</span> <span class="s">"list item"</span>
</code></pre></div></div>

<p>让我们将代码载入到 GHCi，用内置的 <code class="language-plaintext highlighter-rouge">parseTest</code> 试试：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest (pItemList &lt;* eof) ""
1:1:
  |
1 | &lt;empty line&gt;
  | ^
unexpected end of input
expecting list item
</code></pre></div></div>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest (pItemList &lt;* eof) "something"
("something",[])
</code></pre></div></div>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest (pItemList &lt;* eof) "  something"
1:3:
  |
1 |   something
  |   ^
incorrect indentation (got 3, should be equal to 1)
</code></pre></div></div>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest (pItemList &lt;* eof) "something\none\ntwo\nthree"
2:1:
  |
2 | one
  | ^
unexpected 'o'
expecting end of input
</code></pre></div></div>

<p>记住我们用的是 <code class="language-plaintext highlighter-rouge">IndentMany</code> 选项，所以空列表是可以的。另一方面，内置的 <code class="language-plaintext highlighter-rouge">space</code> 组合子已在错误信息中隐藏了「expecting more space」，所以现在的错误信息是完全合理的。</p>

<p>让我们继续试试：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest (pItemList &lt;* eof) "something\n  one\n    two\n  three"
3:5:
  |
3 |     two
  |     ^
incorrect indentation (got 5, should be equal to 3)
</code></pre></div></div>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest (pItemList &lt;* eof) "something\n  one\n  two\n three"
4:2:
  |
4 |  three
  |  ^
incorrect indentation (got 2, should be equal to 3)
</code></pre></div></div>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest (pItemList &lt;* eof) "something\n  one\n  two\n  three"
("something",["one","two","three"])
</code></pre></div></div>

<p>让我们把 <code class="language-plaintext highlighter-rouge">IndentMany</code> 换成 <code class="language-plaintext highlighter-rouge">IndentSome</code>，把 <code class="language-plaintext highlighter-rouge">Nothing</code> 换成 <code class="language-plaintext highlighter-rouge">Just (mkPos 5)</code>（缩进层级从 1 开始数，所以这表示需要 4 个空格的缩进）：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">pItemList</span> <span class="o">::</span> <span class="kt">Parser</span> <span class="p">(</span><span class="kt">String</span><span class="p">,</span> <span class="p">[</span><span class="kt">String</span><span class="p">])</span>
<span class="n">pItemList</span> <span class="o">=</span> <span class="kt">L</span><span class="o">.</span><span class="n">nonIndented</span> <span class="n">scn</span> <span class="p">(</span><span class="kt">L</span><span class="o">.</span><span class="n">indentBlock</span> <span class="n">scn</span> <span class="n">p</span><span class="p">)</span>
  <span class="kr">where</span>
    <span class="n">p</span> <span class="o">=</span> <span class="kr">do</span>
      <span class="n">header</span> <span class="o">&lt;-</span> <span class="n">pItem</span>
      <span class="n">return</span> <span class="p">(</span><span class="kt">L</span><span class="o">.</span><span class="kt">IndentSome</span> <span class="p">(</span><span class="kt">Just</span> <span class="p">(</span><span class="n">mkPos</span> <span class="mi">5</span><span class="p">))</span> <span class="p">(</span><span class="n">return</span> <span class="o">.</span> <span class="p">(</span><span class="n">header</span><span class="p">,</span> <span class="p">))</span> <span class="n">pItem</span><span class="p">)</span>
</code></pre></div></div>

<p>现在：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest (pItemList &lt;* eof) "something\n"
2:1:
  |
2 | &lt;empty line&gt;
  | ^
incorrect indentation (got 1, should be greater than 1)
</code></pre></div></div>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest (pItemList &lt;* eof) "something\n  one"
2:3:
  |
2 |   one
  |   ^
incorrect indentation (got 3, should be equal to 5)
</code></pre></div></div>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest (pItemList &lt;* eof) "something\n    one"
("something",["one"])
</code></pre></div></div>

<p>第一条错误信息可能有点令人惊讶，但 <code class="language-plaintext highlighter-rouge">megaparsec</code> 知道列表里至少得有一项，所以它检查了缩进层级发现是 1，于是报告了错误。</p>

<h3 id="嵌套缩进列表">嵌套缩进列表</h3>

<p>让我们允许表项拥有子项，为此我们创建了一个新的语法分析器 <code class="language-plaintext highlighter-rouge">pComplexItem</code>：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">pComplexItem</span> <span class="o">::</span> <span class="kt">Parser</span> <span class="p">(</span><span class="kt">String</span><span class="p">,</span> <span class="p">[</span><span class="kt">String</span><span class="p">])</span>
<span class="n">pComplexItem</span> <span class="o">=</span> <span class="kt">L</span><span class="o">.</span><span class="n">indentBlock</span> <span class="n">scn</span> <span class="n">p</span>
  <span class="kr">where</span>
    <span class="n">p</span> <span class="o">=</span> <span class="kr">do</span>
      <span class="n">header</span> <span class="o">&lt;-</span> <span class="n">pItem</span>
      <span class="n">return</span> <span class="p">(</span><span class="kt">L</span><span class="o">.</span><span class="kt">IndentMany</span> <span class="kt">Nothing</span> <span class="p">(</span><span class="n">return</span> <span class="o">.</span> <span class="p">(</span><span class="n">header</span><span class="p">,</span> <span class="p">))</span> <span class="n">pItem</span><span class="p">)</span>

<span class="n">pItemList</span> <span class="o">::</span> <span class="kt">Parser</span> <span class="p">(</span><span class="kt">String</span><span class="p">,</span> <span class="p">[(</span><span class="kt">String</span><span class="p">,</span> <span class="p">[</span><span class="kt">String</span><span class="p">])])</span>
<span class="n">pItemList</span> <span class="o">=</span> <span class="kt">L</span><span class="o">.</span><span class="n">nonIndented</span> <span class="n">scn</span> <span class="p">(</span><span class="kt">L</span><span class="o">.</span><span class="n">indentBlock</span> <span class="n">scn</span> <span class="n">p</span><span class="p">)</span>
  <span class="kr">where</span>
    <span class="n">p</span> <span class="o">=</span> <span class="kr">do</span>
      <span class="n">header</span> <span class="o">&lt;-</span> <span class="n">pItem</span>
      <span class="n">return</span> <span class="p">(</span><span class="kt">L</span><span class="o">.</span><span class="kt">IndentSome</span> <span class="kt">Nothing</span> <span class="p">(</span><span class="n">return</span> <span class="o">.</span> <span class="p">(</span><span class="n">header</span><span class="p">,</span> <span class="p">))</span> <span class="n">pComplexItem</span><span class="p">)</span>
</code></pre></div></div>

<p>如果我们把下面这样的列表喂进去：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>first-chapter
  paragraph-one
      note-A # an important note here!
      note-B
  paragraph-two
    note-1
    note-2
  paragraph-three
</code></pre></div></div>

<p>那我们的语法分析器会返回：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Right
  ( "first-chapter"
  , [ ("paragraph-one",   ["note-A","note-B"])
    , ("paragraph-two",   ["note-1","note-2"])
    , ("paragraph-three", [])
    ]
  )
</code></pre></div></div>

<p>以上演示了这个方法是如何扩展到嵌套缩进结构上的，我们并没有引入额外的状态。</p>

<h3 id="加入折行">加入折行</h3>

<p>「折行」可以包含多行元素，不过后续元素的缩进层级必须高于首个元素。</p>

<p>让我们来试用一下 <code class="language-plaintext highlighter-rouge">lineFold</code>：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">pComplexItem</span> <span class="o">::</span> <span class="kt">Parser</span> <span class="p">(</span><span class="kt">String</span><span class="p">,</span> <span class="p">[</span><span class="kt">String</span><span class="p">])</span>
<span class="n">pComplexItem</span> <span class="o">=</span> <span class="kt">L</span><span class="o">.</span><span class="n">indentBlock</span> <span class="n">scn</span> <span class="n">p</span>
  <span class="kr">where</span>
    <span class="n">p</span> <span class="o">=</span> <span class="kr">do</span>
      <span class="n">header</span> <span class="o">&lt;-</span> <span class="n">pItem</span>
      <span class="n">return</span> <span class="p">(</span><span class="kt">L</span><span class="o">.</span><span class="kt">IndentMany</span> <span class="kt">Nothing</span> <span class="p">(</span><span class="n">return</span> <span class="o">.</span> <span class="p">(</span><span class="n">header</span><span class="p">,</span> <span class="p">))</span> <span class="n">pLineFold</span><span class="p">)</span>

<span class="n">pLineFold</span> <span class="o">::</span> <span class="kt">Parser</span> <span class="kt">String</span>
<span class="n">pLineFold</span> <span class="o">=</span> <span class="kt">L</span><span class="o">.</span><span class="n">lineFold</span> <span class="n">scn</span> <span class="o">$</span> <span class="nf">\</span><span class="n">sc'</span> <span class="o">-&gt;</span>
  <span class="kr">let</span> <span class="n">ps</span> <span class="o">=</span> <span class="n">some</span> <span class="p">(</span><span class="n">alphaNumChar</span> <span class="o">&lt;|&gt;</span> <span class="n">char</span> <span class="sc">'-'</span><span class="p">)</span> <span class="p">`</span><span class="n">sepBy1</span><span class="p">`</span> <span class="n">try</span> <span class="n">sc'</span>
  <span class="kr">in</span> <span class="n">unwords</span> <span class="o">&lt;$&gt;</span> <span class="n">ps</span> <span class="o">&lt;*</span> <span class="n">scn</span> <span class="c1">-- (1)</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">lineFold</code> 的工作方式是：我们先给它一个接受换行符的空格消耗器 <code class="language-plaintext highlighter-rouge">scn</code>，然后它还回来一个特殊的空格消耗器 <code class="language-plaintext highlighter-rouge">sc'</code>，让我们能够在回调中吃掉折行元素之间的空格。</p>

<p>为什么 (1) 处要用 <code class="language-plaintext highlighter-rouge">try sc'</code> 和 <code class="language-plaintext highlighter-rouge">scn</code> 呢？情况是这样的：</p>

<ul>
  <li>折行元素只会比首个元素缩进更多。</li>
  <li><code class="language-plaintext highlighter-rouge">sc'</code> 吃掉空格（也会吃换行符）之后，该列应该比起始列更大。</li>
  <li>相反，如果吃掉空格后该列小于等于起始列，<code class="language-plaintext highlighter-rouge">sc'</code> 会停下来。它失败时不会吃掉任何输入（感谢 <code class="language-plaintext highlighter-rouge">try</code>），<code class="language-plaintext highlighter-rouge">scn</code> 会被用来挑选空格。</li>
  <li>前面使用的 <code class="language-plaintext highlighter-rouge">sc'</code> 已利用会吃换行符的空格消耗器来探测空格，所以它逻辑上也会在挑选尾随空格时吃掉换行符。这就是为什么我们在 (1) 处用 <code class="language-plaintext highlighter-rouge">scn</code> 而不用 <code class="language-plaintext highlighter-rouge">sc</code>。</li>
</ul>

<p>【练习】我们语法分析器的最终版本留给读者做测试。你可以创建多个折行元素，语法分析之后它们会用一个空格拼接在一起。</p>

<h2 id="编写高效的语法分析器">编写高效的语法分析器</h2>

<p>让我们讨论一下怎么才能提高 <code class="language-plaintext highlighter-rouge">megaparsec</code> 语法分析器的性能。不过首先要指出，我们应当用性能分析和基准测试来验证我们的改进。这是我们在性能调优时检查是否有效的唯一方法。</p>

<p>这里有一些常见的建议：</p>

<ul>
  <li>如果你的语法分析器用的是单子栈而非普通的 <code class="language-plaintext highlighter-rouge">Parsec</code> 单子（回忆一下，这是使用 <code class="language-plaintext highlighter-rouge">Identity</code> 的 <code class="language-plaintext highlighter-rouge">ParsecT</code> 单子变换，非常轻量），请确保 <code class="language-plaintext highlighter-rouge">transformers</code> 库的版本不低于 0.5，<code class="language-plaintext highlighter-rouge">megaparsec</code> 的版本不低于 7.0。这两个库在上述版本均有关键性的性能提升，只要升级就能变快。</li>
  <li><code class="language-plaintext highlighter-rouge">Parsec</code> 单子总是比基于 <code class="language-plaintext highlighter-rouge">ParsecT</code> 的单子变换更快。除非绝对必要，请避免使用 <code class="language-plaintext highlighter-rouge">StateT</code>、<code class="language-plaintext highlighter-rouge">WriterT</code> 或者其它单子变换。往单子栈里加得越多，语法分析就越慢。</li>
  <li>回溯是个代价高昂的操作。请避免构建冗长的选择链，其中的每个选择都有可能在失败前陷得很深。</li>
  <li>除非确实有理由，请避免让语法分析器保持多态。最好指定一下语法分析器的具体类型，比如 <code class="language-plaintext highlighter-rouge">type Parser = Parsec Void Text</code>。这样能让 GHC 更好地进行优化。</li>
  <li>尽可能内联（当然，是在合理的地方）。内联的巨大作用可能会令你难以置信，特别是对于那些很短的函数。这对跨模块使用的语法分析器尤其有用，因为 <code class="language-plaintext highlighter-rouge">INLINE</code> 和 <code class="language-plaintext highlighter-rouge">INLINEABLE</code> 编译指令能让 GHC 把函数定义转储到接口文件，这有助于进行特化。</li>
  <li>尽可能使用快速的原语，比如 <code class="language-plaintext highlighter-rouge">takeWhileP</code>、<code class="language-plaintext highlighter-rouge">takeWhile1P</code> 和 <code class="language-plaintext highlighter-rouge">takeP</code>。<a href="https://markkarpov.com/post/megaparsec-more-speed-more-power.html#there-is-hope">这篇博客</a>解释了为什么它们这么快。</li>
  <li>尽可能使用 <code class="language-plaintext highlighter-rouge">satisfy</code> 和 <code class="language-plaintext highlighter-rouge">notChar</code>，而不要使用 <code class="language-plaintext highlighter-rouge">oneOf</code> 和 <code class="language-plaintext highlighter-rouge">noneOf</code>。</li>
</ul>

<p>尽量上面大多数建议不需要进一步解释，但我觉得最好养成习惯使用这三个新的原语：<code class="language-plaintext highlighter-rouge">takeWhileP</code>、<code class="language-plaintext highlighter-rouge">takeWhile1P</code> 和 <code class="language-plaintext highlighter-rouge">takeP</code>。前两个尤其常见，能帮我们替换掉一些基于 <code class="language-plaintext highlighter-rouge">many</code> 和 <code class="language-plaintext highlighter-rouge">some</code> 的结构，它们更快并且会改而返回一大块输入流，也就是我们之前说的 <code class="language-plaintext highlighter-rouge">Tokens s</code> 类型。</p>

<p>举例来说，回忆一下我们对 URI 的用户名进行语法分析时用到下面的代码：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="n">user</span> <span class="o">&lt;-</span> <span class="kt">T</span><span class="o">.</span><span class="n">pack</span> <span class="o">&lt;$&gt;</span> <span class="n">some</span> <span class="n">alphaNumChar</span>
</code></pre></div></div>

<p>我们可以把它替换为 <code class="language-plaintext highlighter-rouge">takeWhile1P</code>：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>  <span class="n">user</span> <span class="o">&lt;-</span> <span class="n">takeWhile1P</span> <span class="p">(</span><span class="kt">Just</span> <span class="s">"alpha num character"</span><span class="p">)</span> <span class="n">isAlphaNum</span>
  <span class="c1">--                  ^                            ^</span>
  <span class="c1">--                  |                            |</span>
  <span class="c1">-- label for tokens we match against         predicate</span>
</code></pre></div></div>

<p>当我们对 <code class="language-plaintext highlighter-rouge">ByteString</code> 和 <code class="language-plaintext highlighter-rouge">Text</code> 进行语法分析时，这会比原来的方法快很多。顺便注意一下，我们能从 <code class="language-plaintext highlighter-rouge">takeWhile1P</code> 直接拿到 <code class="language-plaintext highlighter-rouge">Text</code>，所以就不再需要 <code class="language-plaintext highlighter-rouge">T.pack</code> 了。</p>

<p>下面这些等式对于理解 <code class="language-plaintext highlighter-rouge">takeWhileP</code> 和 <code class="language-plaintext highlighter-rouge">takeWhile1P</code> 的 <code class="language-plaintext highlighter-rouge">Maybe String</code> 参数很有帮助：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">takeWhileP</span>  <span class="p">(</span><span class="kt">Just</span> <span class="s">"foo"</span><span class="p">)</span> <span class="n">f</span> <span class="o">=</span> <span class="n">many</span> <span class="p">(</span><span class="n">satisfy</span> <span class="n">f</span> <span class="o">&lt;?&gt;</span> <span class="s">"foo"</span><span class="p">)</span>
<span class="n">takeWhileP</span>  <span class="kt">Nothing</span>      <span class="n">f</span> <span class="o">=</span> <span class="n">many</span> <span class="p">(</span><span class="n">satisfy</span> <span class="n">f</span><span class="p">)</span>
<span class="n">takeWhile1P</span> <span class="p">(</span><span class="kt">Just</span> <span class="s">"foo"</span><span class="p">)</span> <span class="n">f</span> <span class="o">=</span> <span class="n">some</span> <span class="p">(</span><span class="n">satisfy</span> <span class="n">f</span> <span class="o">&lt;?&gt;</span> <span class="s">"foo"</span><span class="p">)</span>
<span class="n">takeWhile1P</span> <span class="kt">Nothing</span>      <span class="n">f</span> <span class="o">=</span> <span class="n">some</span> <span class="p">(</span><span class="n">satisfy</span> <span class="n">f</span><span class="p">)</span>
</code></pre></div></div>

<h2 id="语法分析错误">语法分析错误</h2>

<p>到现在我们已经探索了 <code class="language-plaintext highlighter-rouge">megaparsec</code> 的大多数特性，是时候来学习一下语法分析错误了：它们如何定义、如何触发、如何在运行时处理它们。</p>

<h3 id="错误的定义">错误的定义</h3>

<p><code class="language-plaintext highlighter-rouge">ParseError</code> 类型有如下定义：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">data</span> <span class="kt">ParseError</span> <span class="n">s</span> <span class="n">e</span>
  <span class="o">=</span> <span class="kt">TrivialError</span> <span class="kt">Int</span> <span class="p">(</span><span class="kt">Maybe</span> <span class="p">(</span><span class="kt">ErrorItem</span> <span class="p">(</span><span class="kt">Token</span> <span class="n">s</span><span class="p">)))</span> <span class="p">(</span><span class="kt">Set</span> <span class="p">(</span><span class="kt">ErrorItem</span> <span class="p">(</span><span class="kt">Token</span> <span class="n">s</span><span class="p">)))</span>
    <span class="c1">-- ^ Trivial errors, generated by Megaparsec's machinery. </span>
    <span class="c1">-- The data constructor includes the offset of error, unexpected token (if any), and expected tokens.</span>
  <span class="o">|</span> <span class="kt">FancyError</span> <span class="kt">Int</span> <span class="p">(</span><span class="kt">Set</span> <span class="p">(</span><span class="kt">ErrorFancy</span> <span class="n">e</span><span class="p">))</span>
    <span class="c1">-- ^ Fancy, custom errors.</span>
</code></pre></div></div>

<p>用中文来讲：<code class="language-plaintext highlighter-rouge">ParseError</code> 要么是个 <code class="language-plaintext highlighter-rouge">TrivialError</code> 要么是个 <code class="language-plaintext highlighter-rouge">FancyError</code>，前者会提供偏移量信息、不期而遇的单词（一个或没有）和我们期待的单词集合（可能为空）。</p>

<p><code class="language-plaintext highlighter-rouge">ParseError s e</code> 有下面两个类型参数：</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">s</code> 是输入流的类型；</li>
  <li><code class="language-plaintext highlighter-rouge">e</code> 是自定义错误的类型。</li>
</ul>

<p><code class="language-plaintext highlighter-rouge">ErrorItem</code> 是这样定义的：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">data</span> <span class="kt">ErrorItem</span> <span class="n">t</span>
  <span class="o">=</span> <span class="kt">Tokens</span> <span class="p">(</span><span class="kt">NonEmpty</span> <span class="n">t</span><span class="p">)</span>      <span class="c1">-- ^ Non-empty stream of tokens</span>
  <span class="o">|</span> <span class="kt">Label</span> <span class="p">(</span><span class="kt">NonEmpty</span> <span class="kt">Char</span><span class="p">)</span>    <span class="c1">-- ^ Label (cannot be empty)</span>
  <span class="o">|</span> <span class="kt">EndOfInput</span>               <span class="c1">-- ^ End of input</span>
</code></pre></div></div>

<p>还有 <code class="language-plaintext highlighter-rouge">ErrorFancy</code>：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">data</span> <span class="kt">ErrorFancy</span> <span class="n">e</span>
  <span class="o">=</span> <span class="kt">ErrorFail</span> <span class="kt">String</span>
    <span class="c1">-- ^ 'fail' has been used in parser monad</span>
  <span class="o">|</span> <span class="kt">ErrorIndentation</span> <span class="kt">Ordering</span> <span class="kt">Pos</span> <span class="kt">Pos</span>
    <span class="c1">-- ^ Incorrect indentation error:</span>
    <span class="c1">--     desired ordering between reference level and actual level,</span>
    <span class="c1">--     reference indentation level,</span>
    <span class="c1">--     actual indentation level</span>
  <span class="o">|</span> <span class="kt">ErrorCustom</span> <span class="n">e</span>
    <span class="c1">-- ^ Custom error data, can be conveniently disabled by indexing 'ErrorFancy' by 'Void'</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">ErrorFancy</code> 包括两个 <code class="language-plaintext highlighter-rouge">megaparsec</code> 常见错误的数据构造器：</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">fail</code> 函数会让语法分析器失败并报告任意 <code class="language-plaintext highlighter-rouge">String</code>；</li>
  <li>因为该库原生支持对缩进敏感的语法，所以错误类型也能方便地存储缩进相关信息。</li>
</ul>

<p>最后，<code class="language-plaintext highlighter-rouge">ErrorCustom</code> 是允许将任意数据嵌入 <code class="language-plaintext highlighter-rouge">ErrorFancy</code> 类型的「扩展槽」。如果我们不需要在语法分析错误中使用自定义数据，我们可以把 <code class="language-plaintext highlighter-rouge">Void</code> 传给 <code class="language-plaintext highlighter-rouge">ErrorFancy</code>。由于 <code class="language-plaintext highlighter-rouge">Void</code> 不接受非底类型的值，<code class="language-plaintext highlighter-rouge">ErrorCustom</code> 就相当于「取消」了，用抽象数据类型做类比的话，就是「与零的积」。</p>

<p>在旧版本中，<code class="language-plaintext highlighter-rouge">ParseError</code> 会直接被 <code class="language-plaintext highlighter-rouge">parse</code> 等函数返回，但版本 7.0 推迟了每个错误的行和列的计算，以及用于显示错误的相关行内容的获取。这能让语法分析更快，因为这些信息通常只有在语法分析失败时才有用。另一个旧版本的问题是，同时显示多个错误需要每次重新遍历输入来获取正确的行。</p>

<p>这个问题现在由 <code class="language-plaintext highlighter-rouge">ParseErrorBundle</code> 数据类型解决了：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cd">-- | A non-empty collection of 'ParseError's equipped with 'PosState' that</span>
<span class="c1">-- allows to pretty-print the errors efficiently and correctly.</span>

<span class="kr">data</span> <span class="kt">ParseErrorBundle</span> <span class="n">s</span> <span class="n">e</span> <span class="o">=</span> <span class="kt">ParseErrorBundle</span>
  <span class="p">{</span> <span class="n">bundleErrors</span> <span class="o">::</span> <span class="kt">NonEmpty</span> <span class="p">(</span><span class="kt">ParseError</span> <span class="n">s</span> <span class="n">e</span><span class="p">)</span>
    <span class="c1">-- ^ A collection of 'ParseError's that is sorted by parse error offsets</span>
  <span class="p">,</span> <span class="n">bundlePosState</span> <span class="o">::</span> <span class="kt">PosState</span> <span class="n">s</span>
    <span class="c1">-- ^ State that is used for line\/column calculation</span>
  <span class="p">}</span>
</code></pre></div></div>

<p>所有运行语法分析的函数都会返回 <code class="language-plaintext highlighter-rouge">ParseErrorBundle</code>，里面会有设置好的 <code class="language-plaintext highlighter-rouge">bundlePosState</code> 和 <code class="language-plaintext highlighter-rouge">ParseError</code>。里面的 <code class="language-plaintext highlighter-rouge">ParseError</code> 列表可以由用户自行扩展，不过这样得由用户来保证它们仍按照偏移量有序排列。</p>

<h3 id="如何触发错误">如何触发错误</h3>

<p>让我们讨论一下触发语法分析错误的几种不同方式，最简单的是 <code class="language-plaintext highlighter-rouge">fail</code> 函数：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest (fail "I'm failing, help me!" :: Parser ()) ""
1:1:
  |
1 | &lt;empty line&gt;
  | ^
I'm failing, help me!
</code></pre></div></div>

<p>对于很多熟悉其它简单的语法分析库（比如 <code class="language-plaintext highlighter-rouge">parsec</code>）的人来讲，这通常已经足够了。然而，除了向用户显示语法分析错误之外，我们还有可能需要分析或是处理它，这时候 <code class="language-plaintext highlighter-rouge">String</code> 就不是很方便了。</p>

<p>平凡的语法分析错误通常都是 <code class="language-plaintext highlighter-rouge">megaparsec</code> 生成的，但我们也能自己用 <code class="language-plaintext highlighter-rouge">failure</code> 组合子触发这样的错误：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">failure</span> <span class="o">::</span> <span class="kt">MonadParsec</span> <span class="n">e</span> <span class="n">s</span> <span class="n">m</span>
  <span class="o">=&gt;</span> <span class="kt">Maybe</span> <span class="p">(</span><span class="kt">ErrorItem</span> <span class="p">(</span><span class="kt">Token</span> <span class="n">s</span><span class="p">))</span> <span class="c1">-- ^ Unexpected item (if any)</span>
  <span class="o">-&gt;</span> <span class="kt">Set</span> <span class="p">(</span><span class="kt">ErrorItem</span> <span class="p">(</span><span class="kt">Token</span> <span class="n">s</span><span class="p">))</span> <span class="c1">-- ^ Expected items</span>
  <span class="o">-&gt;</span> <span class="n">m</span> <span class="n">a</span>
<span class="n">unfortunateParser</span> <span class="o">::</span> <span class="kt">Parser</span> <span class="nb">()</span>
<span class="n">unfortunateParser</span> <span class="o">=</span> <span class="n">failure</span> <span class="p">(</span><span class="kt">Just</span> <span class="kt">EndOfInput</span><span class="p">)</span> <span class="p">(</span><span class="kt">Set</span><span class="o">.</span><span class="n">fromList</span> <span class="n">es</span><span class="p">)</span>
  <span class="kr">where</span>
    <span class="n">es</span> <span class="o">=</span> <span class="p">[</span><span class="kt">Tokens</span> <span class="p">(</span><span class="kt">NE</span><span class="o">.</span><span class="n">fromList</span> <span class="s">"a"</span><span class="p">),</span> <span class="kt">Tokens</span> <span class="p">(</span><span class="kt">NE</span><span class="o">.</span><span class="n">fromList</span> <span class="s">"b"</span><span class="p">)]</span>
</code></pre></div></div>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest unfortunateParser ""
1:1:
  |
1 | &lt;empty line&gt;
  | ^
unexpected end of input
expecting 'a' or 'b'
</code></pre></div></div>

<p>跟基于 <code class="language-plaintext highlighter-rouge">fail</code> 的方法不同，平凡的错误很容易进行模型匹配，或是审视和修改。</p>

<p>对于花哨的错误，相应地我们有 <code class="language-plaintext highlighter-rouge">fancyFailure</code> 组合子：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">fancyFailure</span> <span class="o">::</span> <span class="kt">MonadParsec</span> <span class="n">e</span> <span class="n">s</span> <span class="n">m</span>
  <span class="o">=&gt;</span> <span class="kt">Set</span> <span class="p">(</span><span class="kt">ErrorFancy</span> <span class="n">e</span><span class="p">)</span> <span class="c1">-- ^ Fancy error components</span>
  <span class="o">-&gt;</span> <span class="n">m</span> <span class="n">a</span>
</code></pre></div></div>

<p>但对于 <code class="language-plaintext highlighter-rouge">fancyFailure</code>，我们通常会去定义一个工具函数，而不是直接调用 <code class="language-plaintext highlighter-rouge">fancyFailure</code>：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">incorrectIndent</span> <span class="o">::</span> <span class="kt">MonadParsec</span> <span class="n">e</span> <span class="n">s</span> <span class="n">m</span>
  <span class="o">=&gt;</span> <span class="kt">Ordering</span>  <span class="c1">-- ^ Desired ordering between reference level and actual level</span>
  <span class="o">-&gt;</span> <span class="kt">Pos</span>               <span class="c1">-- ^ Reference indentation level</span>
  <span class="o">-&gt;</span> <span class="kt">Pos</span>               <span class="c1">-- ^ Actual indentation level</span>
  <span class="o">-&gt;</span> <span class="n">m</span> <span class="n">a</span>
<span class="n">incorrectIndent</span> <span class="n">ord</span> <span class="n">ref</span> <span class="n">actual</span> <span class="o">=</span> <span class="n">fancyFailure</span> <span class="o">.</span> <span class="kt">E</span><span class="o">.</span><span class="n">singleton</span> <span class="o">$</span>
  <span class="kt">ErrorIndentation</span> <span class="n">ord</span> <span class="n">ref</span> <span class="n">actual</span>
</code></pre></div></div>

<p>作为添加自定义语法分析错误组件的例子，让我们创建这样一个特殊的语法分析错误，它会报告给定的 <code class="language-plaintext highlighter-rouge">Text</code> 值不是关键字。</p>

<p>首先，我们需要定义一个数据类型，其构造器代表我们想要支持的场景：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">data</span> <span class="kt">Custom</span> <span class="o">=</span> <span class="kt">NotKeyword</span> <span class="kt">Text</span>
  <span class="kr">deriving</span> <span class="p">(</span><span class="kt">Eq</span><span class="p">,</span> <span class="kt">Show</span><span class="p">,</span> <span class="kt">Ord</span><span class="p">)</span>
</code></pre></div></div>

<p>并告诉 <code class="language-plaintext highlighter-rouge">megaparsec</code> 如何显示这个错误：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">instance</span> <span class="kt">ShowErrorComponent</span> <span class="kt">Custom</span> <span class="kr">where</span>
  <span class="n">showErrorComponent</span> <span class="p">(</span><span class="kt">NotKeyword</span> <span class="n">txt</span><span class="p">)</span> <span class="o">=</span> <span class="kt">T</span><span class="o">.</span><span class="n">unpack</span> <span class="n">txt</span> <span class="o">++</span> <span class="s">" is not a keyword"</span>
</code></pre></div></div>

<p>接下来我们更新一下我们的 <code class="language-plaintext highlighter-rouge">Parser</code> 别名：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">type</span> <span class="kt">Parser</span> <span class="o">=</span> <span class="kt">Parsec</span> <span class="kt">Custom</span> <span class="kt">Text</span>
</code></pre></div></div>

<p>之后我们定义一个 <code class="language-plaintext highlighter-rouge">notKeyword</code> 工具函数：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">notKeyword</span> <span class="o">::</span> <span class="kt">Text</span> <span class="o">-&gt;</span> <span class="kt">Parser</span> <span class="n">a</span>
<span class="n">notKeyword</span> <span class="o">=</span> <span class="n">customFailure</span> <span class="o">.</span> <span class="kt">NotKeyword</span>
</code></pre></div></div>

<p>其中 <code class="language-plaintext highlighter-rouge">customFailure</code> 是来自 <code class="language-plaintext highlighter-rouge">Text.Megaparsec</code> 模块的工具函数：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">customFailure</span> <span class="o">::</span> <span class="kt">MonadParsec</span> <span class="n">e</span> <span class="n">s</span> <span class="n">m</span> <span class="o">=&gt;</span> <span class="n">e</span> <span class="o">-&gt;</span> <span class="n">m</span> <span class="n">a</span>
<span class="n">customFailure</span> <span class="o">=</span> <span class="n">fancyFailure</span> <span class="o">.</span> <span class="kt">E</span><span class="o">.</span><span class="n">singleton</span> <span class="o">.</span> <span class="kt">ErrorCustom</span>
</code></pre></div></div>

<p>最后，让我们试一下：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest (notKeyword "foo" :: Parser ()) ""
1:1:
  |
1 | &lt;empty line&gt;
  | ^
foo is not a keyword
</code></pre></div></div>

<h3 id="显示错误">显示错误</h3>

<p>显示 <code class="language-plaintext highlighter-rouge">ParseErrorBundle</code> 可以用 <code class="language-plaintext highlighter-rouge">errorBundlePretty</code> 函数完成：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cd">-- | Pretty-print a 'ParseErrorBundle'. All 'ParseError's in the bundle will</span>
<span class="c1">-- be pretty-printed in order together with the corresponding offending</span>
<span class="c1">-- lines by doing a single efficient pass over the input stream. The</span>
<span class="c1">-- rendered 'String' always ends with a newline.</span>

<span class="n">errorBundlePretty</span>
  <span class="o">::</span> <span class="p">(</span> <span class="kt">Stream</span> <span class="n">s</span>
     <span class="p">,</span> <span class="kt">ShowErrorComponent</span> <span class="n">e</span>
     <span class="p">)</span>
  <span class="o">=&gt;</span> <span class="kt">ParseErrorBundle</span> <span class="n">s</span> <span class="n">e</span> <span class="c1">-- ^ Parse error bundle to display</span>
  <span class="o">-&gt;</span> <span class="kt">String</span>               <span class="c1">-- ^ Textual rendition of the bundle</span>
</code></pre></div></div>

<p>99% 的情况下你只需要这么一个函数。</p>

<h3 id="在运行时接住错误">在运行时接住错误</h3>

<p><code class="language-plaintext highlighter-rouge">megaparsec</code> 另一个有用的特性是它能够「接住」语法分析错误，并以某种方式改变它，然后再重新抛出错误，就像异常一样。这可以用 <code class="language-plaintext highlighter-rouge">observing</code> 原语实现：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cd">-- | @'observing' p@ allows to “observe” failure of the @p@ parser, should</span>
<span class="c1">-- it happen, without actually ending parsing, but instead getting the</span>
<span class="c1">-- 'ParseError' in 'Left'. On success parsed value is returned in 'Right'</span>
<span class="c1">-- as usual. Note that this primitive just allows you to observe parse</span>
<span class="c1">-- errors as they happen, it does not backtrack or change how the @p@</span>
<span class="c1">-- parser works in any way.</span>

<span class="n">observing</span> <span class="o">::</span> <span class="kt">MonadParsec</span> <span class="n">e</span> <span class="n">s</span> <span class="n">m</span>
  <span class="o">=&gt;</span> <span class="n">m</span> <span class="n">a</span>             <span class="c1">-- ^ The parser to run</span>
  <span class="o">-&gt;</span> <span class="n">m</span> <span class="p">(</span><span class="kt">Either</span> <span class="p">(</span><span class="kt">ParseError</span> <span class="p">(</span><span class="kt">Token</span> <span class="n">s</span><span class="p">)</span> <span class="n">e</span><span class="p">)</span> <span class="n">a</span><span class="p">)</span>
</code></pre></div></div>

<p>下面是演示 <code class="language-plaintext highlighter-rouge">observing</code> 典型用法的完整程序：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">{-# LANGUAGE OverloadedStrings #-}</span>
<span class="cp">{-# LANGUAGE TypeApplications  #-}</span>

<span class="kr">module</span> <span class="nn">Main</span> <span class="p">(</span><span class="nf">main</span><span class="p">)</span> <span class="kr">where</span>

<span class="kr">import</span> <span class="nn">Control.Applicative</span>
<span class="kr">import</span> <span class="nn">Data.List</span> <span class="p">(</span><span class="nf">intercalate</span><span class="p">)</span>
<span class="kr">import</span> <span class="nn">Data.Set</span> <span class="p">(</span><span class="kt">Set</span><span class="p">)</span>
<span class="kr">import</span> <span class="nn">Data.Text</span> <span class="p">(</span><span class="kt">Text</span><span class="p">)</span>
<span class="kr">import</span> <span class="nn">Data.Void</span>
<span class="kr">import</span> <span class="nn">Text.Megaparsec</span>
<span class="kr">import</span> <span class="nn">Text.Megaparsec.Char</span>
<span class="kr">import</span> <span class="k">qualified</span> <span class="nn">Data.Set</span> <span class="k">as</span> <span class="n">Set</span>

<span class="kr">data</span> <span class="kt">Custom</span>
  <span class="o">=</span> <span class="kt">TrivialWithLocation</span>
    <span class="p">[</span><span class="kt">String</span><span class="p">]</span> <span class="c1">-- position stack</span>
    <span class="p">(</span><span class="kt">Maybe</span> <span class="p">(</span><span class="kt">ErrorItem</span> <span class="kt">Char</span><span class="p">))</span>
    <span class="p">(</span><span class="kt">Set</span> <span class="p">(</span><span class="kt">ErrorItem</span> <span class="kt">Char</span><span class="p">))</span>
  <span class="o">|</span> <span class="kt">FancyWithLocation</span>
    <span class="p">[</span><span class="kt">String</span><span class="p">]</span> <span class="c1">-- position stack</span>
    <span class="p">(</span><span class="kt">ErrorFancy</span> <span class="kt">Void</span><span class="p">)</span> <span class="c1">-- Void, because we do not want to allow to nest Customs</span>
  <span class="kr">deriving</span> <span class="p">(</span><span class="kt">Eq</span><span class="p">,</span> <span class="kt">Ord</span><span class="p">,</span> <span class="kt">Show</span><span class="p">)</span>

<span class="kr">instance</span> <span class="kt">ShowErrorComponent</span> <span class="kt">Custom</span> <span class="kr">where</span>
  <span class="n">showErrorComponent</span> <span class="p">(</span><span class="kt">TrivialWithLocation</span> <span class="n">stack</span> <span class="n">us</span> <span class="n">es</span><span class="p">)</span> <span class="o">=</span>
    <span class="n">parseErrorTextPretty</span> <span class="p">(</span><span class="kt">TrivialError</span> <span class="o">@</span><span class="kt">Char</span> <span class="o">@</span><span class="kt">Void</span> <span class="n">undefined</span> <span class="n">us</span> <span class="n">es</span><span class="p">)</span>
      <span class="o">++</span> <span class="n">showPosStack</span> <span class="n">stack</span>
  <span class="n">showErrorComponent</span> <span class="p">(</span><span class="kt">FancyWithLocation</span> <span class="n">stack</span> <span class="n">cs</span><span class="p">)</span> <span class="o">=</span>
    <span class="n">parseErrorTextPretty</span> <span class="p">(</span><span class="kt">FancyError</span> <span class="o">@</span><span class="kt">Text</span> <span class="o">@</span><span class="kt">Void</span> <span class="n">undefined</span> <span class="p">(</span><span class="kt">Set</span><span class="o">.</span><span class="n">singleton</span> <span class="n">cs</span><span class="p">))</span>
      <span class="o">++</span> <span class="n">showPosStack</span> <span class="n">stack</span>

<span class="n">showPosStack</span> <span class="o">::</span> <span class="p">[</span><span class="kt">String</span><span class="p">]</span> <span class="o">-&gt;</span> <span class="kt">String</span>
<span class="n">showPosStack</span> <span class="o">=</span> <span class="n">intercalate</span> <span class="s">", "</span> <span class="o">.</span> <span class="n">fmap</span> <span class="p">(</span><span class="s">"in "</span> <span class="o">++</span><span class="p">)</span>

<span class="kr">type</span> <span class="kt">Parser</span> <span class="o">=</span> <span class="kt">Parsec</span> <span class="kt">Custom</span> <span class="kt">Text</span>

<span class="n">inside</span> <span class="o">::</span> <span class="kt">String</span> <span class="o">-&gt;</span> <span class="kt">Parser</span> <span class="n">a</span> <span class="o">-&gt;</span> <span class="kt">Parser</span> <span class="n">a</span>
<span class="n">inside</span> <span class="n">location</span> <span class="n">p</span> <span class="o">=</span> <span class="kr">do</span>
  <span class="n">r</span> <span class="o">&lt;-</span> <span class="n">observing</span> <span class="n">p</span>
  <span class="kr">case</span> <span class="n">r</span> <span class="kr">of</span>
    <span class="kt">Left</span> <span class="p">(</span><span class="kt">TrivialError</span> <span class="kr">_</span> <span class="n">us</span> <span class="n">es</span><span class="p">)</span> <span class="o">-&gt;</span>
      <span class="n">fancyFailure</span> <span class="o">.</span> <span class="kt">Set</span><span class="o">.</span><span class="n">singleton</span> <span class="o">.</span> <span class="kt">ErrorCustom</span> <span class="o">$</span>
        <span class="kt">TrivialWithLocation</span> <span class="p">[</span><span class="n">location</span><span class="p">]</span> <span class="n">us</span> <span class="n">es</span>
    <span class="kt">Left</span> <span class="p">(</span><span class="kt">FancyError</span> <span class="kr">_</span> <span class="n">xs</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kr">do</span>
      <span class="kr">let</span> <span class="n">f</span> <span class="p">(</span><span class="kt">ErrorFail</span> <span class="n">msg</span><span class="p">)</span> <span class="o">=</span> <span class="kt">ErrorCustom</span> <span class="o">$</span>
            <span class="kt">FancyWithLocation</span> <span class="p">[</span><span class="n">location</span><span class="p">]</span> <span class="p">(</span><span class="kt">ErrorFail</span> <span class="n">msg</span><span class="p">)</span>
          <span class="n">f</span> <span class="p">(</span><span class="kt">ErrorIndentation</span> <span class="n">ord</span> <span class="n">rlvl</span> <span class="n">alvl</span><span class="p">)</span> <span class="o">=</span> <span class="kt">ErrorCustom</span> <span class="o">$</span>
            <span class="kt">FancyWithLocation</span> <span class="p">[</span><span class="n">location</span><span class="p">]</span> <span class="p">(</span><span class="kt">ErrorIndentation</span> <span class="n">ord</span> <span class="n">rlvl</span> <span class="n">alvl</span><span class="p">)</span>
          <span class="n">f</span> <span class="p">(</span><span class="kt">ErrorCustom</span> <span class="p">(</span><span class="kt">TrivialWithLocation</span> <span class="n">ps</span> <span class="n">us</span> <span class="n">es</span><span class="p">))</span> <span class="o">=</span> <span class="kt">ErrorCustom</span> <span class="o">$</span>
            <span class="kt">TrivialWithLocation</span> <span class="p">(</span><span class="n">location</span><span class="o">:</span><span class="n">ps</span><span class="p">)</span> <span class="n">us</span> <span class="n">es</span>
          <span class="n">f</span> <span class="p">(</span><span class="kt">ErrorCustom</span> <span class="p">(</span><span class="kt">FancyWithLocation</span> <span class="n">ps</span> <span class="n">cs</span><span class="p">))</span> <span class="o">=</span> <span class="kt">ErrorCustom</span> <span class="o">$</span>
            <span class="kt">FancyWithLocation</span> <span class="p">(</span><span class="n">location</span><span class="o">:</span><span class="n">ps</span><span class="p">)</span> <span class="n">cs</span>
      <span class="n">fancyFailure</span> <span class="p">(</span><span class="kt">Set</span><span class="o">.</span><span class="n">map</span> <span class="n">f</span> <span class="n">xs</span><span class="p">)</span>
    <span class="kt">Right</span> <span class="n">x</span> <span class="o">-&gt;</span> <span class="n">return</span> <span class="n">x</span>

<span class="n">myParser</span> <span class="o">::</span> <span class="kt">Parser</span> <span class="kt">String</span>
<span class="n">myParser</span> <span class="o">=</span> <span class="n">some</span> <span class="p">(</span><span class="n">char</span> <span class="sc">'a'</span><span class="p">)</span> <span class="o">*&gt;</span> <span class="n">some</span> <span class="p">(</span><span class="n">char</span> <span class="sc">'b'</span><span class="p">)</span>

<span class="n">main</span> <span class="o">::</span> <span class="kt">IO</span> <span class="nb">()</span>
<span class="n">main</span> <span class="o">=</span> <span class="kr">do</span>
  <span class="n">parseTest</span> <span class="p">(</span><span class="n">inside</span> <span class="s">"foo"</span> <span class="n">myParser</span><span class="p">)</span> <span class="s">"aaacc"</span>
  <span class="n">parseTest</span> <span class="p">(</span><span class="n">inside</span> <span class="s">"foo"</span> <span class="o">$</span> <span class="n">inside</span> <span class="s">"bar"</span> <span class="n">myParser</span><span class="p">)</span> <span class="s">"aaacc"</span>
</code></pre></div></div>

<p>【练习】深入理解这个程序是如何工作的。</p>

<p>如果运行这个程序，会看到以下输出：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>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
</code></pre></div></div>

<p>因此，这个特性可以用来给语法分析错误附加位置标签，或是定义能以某种方式处理该错误的「区域」。这种惯用法很有用，所以甚至有一个基于 <code class="language-plaintext highlighter-rouge">observing</code> 定义的工具函数 <code class="language-plaintext highlighter-rouge">region</code>：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cd">-- | Specify how to process 'ParseError's that happen inside of this</span>
<span class="c1">-- wrapper. This applies to both normal and delayed 'ParseError's.</span>
<span class="c1">--</span>
<span class="c1">-- As a side-effect of the implementation the inner computation will start</span>
<span class="c1">-- with empty collection of delayed errors and they will be updated and</span>
<span class="c1">-- “restored” on the way out of 'region'.</span>

<span class="n">region</span> <span class="o">::</span> <span class="kt">MonadParsec</span> <span class="n">e</span> <span class="n">s</span> <span class="n">m</span>
  <span class="o">=&gt;</span> <span class="p">(</span><span class="kt">ParseError</span> <span class="n">s</span> <span class="n">e</span> <span class="o">-&gt;</span> <span class="kt">ParseError</span> <span class="n">s</span> <span class="n">e</span><span class="p">)</span> <span class="c1">-- ^ How to process 'ParseError's</span>
  <span class="o">-&gt;</span> <span class="n">m</span> <span class="n">a</span>                 <span class="c1">-- ^ The “region” that the processing applies to</span>
  <span class="o">-&gt;</span> <span class="n">m</span> <span class="n">a</span>
<span class="n">region</span> <span class="n">f</span> <span class="n">m</span> <span class="o">=</span> <span class="kr">do</span>
  <span class="n">r</span> <span class="o">&lt;-</span> <span class="n">observing</span> <span class="n">m</span>
  <span class="kr">case</span> <span class="n">r</span> <span class="kr">of</span>
    <span class="kt">Left</span> <span class="n">err</span> <span class="o">-&gt;</span> <span class="n">parseError</span> <span class="p">(</span><span class="n">f</span> <span class="n">err</span><span class="p">)</span> <span class="c1">-- see the next section</span>
    <span class="kt">Right</span> <span class="n">x</span> <span class="o">-&gt;</span> <span class="n">return</span> <span class="n">x</span>
</code></pre></div></div>

<p>【练习】用 <code class="language-plaintext highlighter-rouge">region</code> 重写之前程序中的 <code class="language-plaintext highlighter-rouge">inside</code> 函数。</p>

<h3 id="控制错误的位置">控制错误的位置</h3>

<p><code class="language-plaintext highlighter-rouge">region</code> 的定义使用了 <code class="language-plaintext highlighter-rouge">parseError</code> 原语：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">parseError</span> <span class="o">::</span> <span class="kt">MonadParsec</span> <span class="n">e</span> <span class="n">s</span> <span class="n">m</span> <span class="o">=&gt;</span> <span class="kt">ParseError</span> <span class="n">s</span> <span class="n">e</span> <span class="o">-&gt;</span> <span class="n">m</span> <span class="n">a</span>
</code></pre></div></div>

<p>这是错误报告的基础原语，我们目前见到的所有其它函数都基于 <code class="language-plaintext highlighter-rouge">parseError</code> 定义的：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">failure</span>
  <span class="o">::</span> <span class="kt">MonadParsec</span> <span class="n">e</span> <span class="n">s</span> <span class="n">m</span>
  <span class="o">=&gt;</span> <span class="kt">Maybe</span> <span class="p">(</span><span class="kt">ErrorItem</span> <span class="p">(</span><span class="kt">Token</span> <span class="n">s</span><span class="p">))</span> <span class="c1">-- ^ Unexpected item (if any)</span>
  <span class="o">-&gt;</span> <span class="kt">Set</span> <span class="p">(</span><span class="kt">ErrorItem</span> <span class="p">(</span><span class="kt">Token</span> <span class="n">s</span><span class="p">))</span> <span class="c1">-- ^ Expected items</span>
  <span class="o">-&gt;</span> <span class="n">m</span> <span class="n">a</span>
<span class="n">failure</span> <span class="n">us</span> <span class="n">ps</span> <span class="o">=</span> <span class="kr">do</span>
  <span class="n">o</span> <span class="o">&lt;-</span> <span class="n">getOffset</span>
  <span class="n">parseError</span> <span class="p">(</span><span class="kt">TrivialError</span> <span class="n">o</span> <span class="n">us</span> <span class="n">ps</span><span class="p">)</span>
</code></pre></div></div>
<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">fancyFailure</span>
  <span class="o">::</span> <span class="kt">MonadParsec</span> <span class="n">e</span> <span class="n">s</span> <span class="n">m</span>
  <span class="o">=&gt;</span> <span class="kt">Set</span> <span class="p">(</span><span class="kt">ErrorFancy</span> <span class="n">e</span><span class="p">)</span> <span class="c1">-- ^ Fancy error components</span>
  <span class="o">-&gt;</span> <span class="n">m</span> <span class="n">a</span>
<span class="n">fancyFailure</span> <span class="n">xs</span> <span class="o">=</span> <span class="kr">do</span>
  <span class="n">o</span> <span class="o">&lt;-</span> <span class="n">getOffset</span>
  <span class="n">parseError</span> <span class="p">(</span><span class="kt">FancyError</span> <span class="n">o</span> <span class="n">xs</span><span class="p">)</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">parseError</code> 可以让你设置错误的偏移量（也就是位置），而不必是输入流的当前位置。让我们回到很久之前的那个例子：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">withPredicate2</span>
  <span class="o">::</span> <span class="p">(</span><span class="n">a</span> <span class="o">-&gt;</span> <span class="kt">Bool</span><span class="p">)</span>       <span class="c1">-- ^ The check to perform on parsed input</span>
  <span class="o">-&gt;</span> <span class="kt">String</span>            <span class="c1">-- ^ Message to print when the check fails</span>
  <span class="o">-&gt;</span> <span class="kt">Parser</span> <span class="n">a</span>          <span class="c1">-- ^ Parser to run</span>
  <span class="o">-&gt;</span> <span class="kt">Parser</span> <span class="n">a</span>          <span class="c1">-- ^ Resulting parser that performs the check</span>
<span class="n">withPredicate2</span> <span class="n">f</span> <span class="n">msg</span> <span class="n">p</span> <span class="o">=</span> <span class="kr">do</span>
  <span class="n">o</span> <span class="o">&lt;-</span> <span class="n">getOffset</span>
  <span class="n">r</span> <span class="o">&lt;-</span> <span class="n">p</span>
  <span class="kr">if</span> <span class="n">f</span> <span class="n">r</span>
    <span class="kr">then</span> <span class="n">return</span> <span class="n">r</span>
    <span class="kr">else</span> <span class="kr">do</span>
      <span class="n">setOffset</span> <span class="n">o</span>
      <span class="n">fail</span> <span class="n">msg</span>
</code></pre></div></div>

<p>我们注意到 <code class="language-plaintext highlighter-rouge">setOffset o</code> 能让错误被正确定位，但它的副作用是会使语法分析状态失效，也就是说偏移量不再反映现实情况了。在更复杂的语法分析器中，这可能会是个现实的问题。举例来说，想象一下你用 <code class="language-plaintext highlighter-rouge">observing</code> 包住了 <code class="language-plaintext highlighter-rouge">withPredicate2</code>，那么 <code class="language-plaintext highlighter-rouge">fail</code> 之后可能还会有代码运行。</p>

<p>有了 <code class="language-plaintext highlighter-rouge">parseError</code> 和 <code class="language-plaintext highlighter-rouge">region</code>，我们能够正确地解决这个问题了：要么使用 <code class="language-plaintext highlighter-rouge">parseError</code> 来重设错误位置，要么直接用 <code class="language-plaintext highlighter-rouge">region</code>：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">withPredicate3</span>
  <span class="o">::</span> <span class="p">(</span><span class="n">a</span> <span class="o">-&gt;</span> <span class="kt">Bool</span><span class="p">)</span>       <span class="c1">-- ^ The check to perform on parsed input</span>
  <span class="o">-&gt;</span> <span class="kt">String</span>            <span class="c1">-- ^ Message to print when the check fails</span>
  <span class="o">-&gt;</span> <span class="kt">Parser</span> <span class="n">a</span>          <span class="c1">-- ^ Parser to run</span>
  <span class="o">-&gt;</span> <span class="kt">Parser</span> <span class="n">a</span>          <span class="c1">-- ^ Resulting parser that performs the check</span>
<span class="n">withPredicate3</span> <span class="n">f</span> <span class="n">msg</span> <span class="n">p</span> <span class="o">=</span> <span class="kr">do</span>
  <span class="n">o</span> <span class="o">&lt;-</span> <span class="n">getOffset</span>
  <span class="n">r</span> <span class="o">&lt;-</span> <span class="n">p</span>
  <span class="kr">if</span> <span class="n">f</span> <span class="n">r</span>
    <span class="kr">then</span> <span class="n">return</span> <span class="n">r</span>
    <span class="kr">else</span> <span class="n">region</span> <span class="p">(</span><span class="n">setErrorOffset</span> <span class="n">o</span><span class="p">)</span> <span class="p">(</span><span class="n">fail</span> <span class="n">msg</span><span class="p">)</span>
</code></pre></div></div>
<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">withPredicate4</span>
  <span class="o">::</span> <span class="p">(</span><span class="n">a</span> <span class="o">-&gt;</span> <span class="kt">Bool</span><span class="p">)</span>       <span class="c1">-- ^ The check to perform on parsed input</span>
  <span class="o">-&gt;</span> <span class="kt">String</span>            <span class="c1">-- ^ Message to print when the check fails</span>
  <span class="o">-&gt;</span> <span class="kt">Parser</span> <span class="n">a</span>          <span class="c1">-- ^ Parser to run</span>
  <span class="o">-&gt;</span> <span class="kt">Parser</span> <span class="n">a</span>          <span class="c1">-- ^ Resulting parser that performs the check</span>
<span class="n">withPredicate4</span> <span class="n">f</span> <span class="n">msg</span> <span class="n">p</span> <span class="o">=</span> <span class="kr">do</span>
  <span class="n">o</span> <span class="o">&lt;-</span> <span class="n">getOffset</span>
  <span class="n">r</span> <span class="o">&lt;-</span> <span class="n">p</span>
  <span class="kr">if</span> <span class="n">f</span> <span class="n">r</span>
    <span class="kr">then</span> <span class="n">return</span> <span class="n">r</span>
    <span class="kr">else</span> <span class="n">parseError</span> <span class="p">(</span><span class="kt">FancyError</span> <span class="n">o</span> <span class="p">(</span><span class="kt">Set</span><span class="o">.</span><span class="n">singleton</span> <span class="p">(</span><span class="kt">ErrorFail</span> <span class="n">msg</span><span class="p">)))</span>
</code></pre></div></div>

<h3 id="报告多个错误">报告多个错误</h3>

<p>最后，<code class="language-plaintext highlighter-rouge">megaparsec</code> 允许我们在一次运行过程中触发多个语法分析错误。这能帮助我们一次修复多处错误，而不需要运行好几次语法分析器。</p>

<p>拥有多错误语法分析器的前提条件是，它要能跳过一部分有问题的输入，并从一个已知没问题的位置继续进行语法分析。这部分工作要用 <code class="language-plaintext highlighter-rouge">withRecovery</code> 原语完成：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cd">-- | @'withRecovery' r p@ allows continue parsing even if parser @p@</span>
<span class="c1">-- fails. In this case @r@ is called with the actual 'ParseError' as its</span>
<span class="c1">-- argument. Typical usage is to return a value signifying failure to</span>
<span class="c1">-- parse this particular object and to consume some part of the input up</span>
<span class="c1">-- to the point where the next object starts.</span>
<span class="c1">--</span>
<span class="c1">-- Note that if @r@ fails, original error message is reported as if</span>
<span class="c1">-- without 'withRecovery'. In no way recovering parser @r@ can influence</span>
<span class="c1">-- error messages.</span>

<span class="n">withRecovery</span>
  <span class="o">::</span> <span class="p">(</span><span class="kt">ParseError</span> <span class="n">s</span> <span class="n">e</span> <span class="o">-&gt;</span> <span class="n">m</span> <span class="n">a</span><span class="p">)</span> <span class="c1">-- ^ How to recover from failure</span>
  <span class="o">-&gt;</span> <span class="n">m</span> <span class="n">a</span>             <span class="c1">-- ^ Original parser</span>
  <span class="o">-&gt;</span> <span class="n">m</span> <span class="n">a</span>             <span class="c1">-- ^ Parser that can recover from failures</span>
</code></pre></div></div>

<p>在 Megaparsec 8 之前，<code class="language-plaintext highlighter-rouge">a</code> 必须是包含成功和失败两种可能性的和类型，比如说 <code class="language-plaintext highlighter-rouge">Either (ParseError s e) Result</code>。语法分析错误在收集后会加入 <code class="language-plaintext highlighter-rouge">ParseErrorBundle</code> 以进行显示。不必说，这些都是对用户不友好的高级用法。</p>

<p>Megaparsec 8 支持了「延迟错误」：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cd">-- | Register a 'ParseError' for later reporting. This action does not end</span>
<span class="c1">-- parsing and has no effect except for adding the given 'ParseError' to the</span>
<span class="c1">-- collection of “delayed” 'ParseError's which will be taken into</span>
<span class="c1">-- consideration at the end of parsing. Only if this collection is empty</span>
<span class="c1">-- parser will succeed. This is the main way to report several parse errors</span>
<span class="c1">-- at once.</span>

<span class="n">registerParseError</span> <span class="o">::</span> <span class="kt">MonadParsec</span> <span class="n">e</span> <span class="n">s</span> <span class="n">m</span> <span class="o">=&gt;</span> <span class="kt">ParseError</span> <span class="n">s</span> <span class="n">e</span> <span class="o">-&gt;</span> <span class="n">m</span> <span class="nb">()</span>

<span class="cd">-- | Like 'failure', but for delayed 'ParseError's.</span>

<span class="n">registerFailure</span>
  <span class="o">::</span> <span class="kt">MonadParsec</span> <span class="n">e</span> <span class="n">s</span> <span class="n">m</span>
  <span class="o">=&gt;</span> <span class="kt">Maybe</span> <span class="p">(</span><span class="kt">ErrorItem</span> <span class="p">(</span><span class="kt">Token</span> <span class="n">s</span><span class="p">))</span> <span class="c1">-- ^ Unexpected item (if any)</span>
  <span class="o">-&gt;</span> <span class="kt">Set</span> <span class="p">(</span><span class="kt">ErrorItem</span> <span class="p">(</span><span class="kt">Token</span> <span class="n">s</span><span class="p">))</span> <span class="c1">-- ^ Expected items</span>
  <span class="o">-&gt;</span> <span class="n">m</span> <span class="nb">()</span>

<span class="cd">-- | Like 'fancyFailure', but for delayed 'ParseError's.</span>

<span class="n">registerFancyFailure</span>
  <span class="o">::</span> <span class="kt">MonadParsec</span> <span class="n">e</span> <span class="n">s</span> <span class="n">m</span>
  <span class="o">=&gt;</span> <span class="kt">Set</span> <span class="p">(</span><span class="kt">ErrorFancy</span> <span class="n">e</span><span class="p">)</span> <span class="c1">-- ^ Fancy error components</span>
  <span class="o">-&gt;</span> <span class="n">m</span> <span class="nb">()</span>
</code></pre></div></div>

<p>这些错误可以在 <code class="language-plaintext highlighter-rouge">withRecovery</code> 的错误处理回调中注册，所以结果类型会是 <code class="language-plaintext highlighter-rouge">Maybe Result</code>。这样可以把延迟错误列入最后的 <code class="language-plaintext highlighter-rouge">ParseErrorBundle</code>，并且在错误列表非空的情况让语法分析失败。</p>

<p>有了这些，我们希望编写多错误语法分析器的做法会在用户群中更加普遍。</p>

<h2 id="测试-megaparsec-语法分析器">测试 Megaparsec 语法分析器</h2>

<p>对语法分析器进行测试是大多数人迟早要面对的事情，所以我们有义务提一下。最推荐的方式是使用 <a href="https://hackage.haskell.org/package/hspec-megaparsec">hspec-megaparsec</a> 包，里面有一些效用期望，比如 <code class="language-plaintext highlighter-rouge">shouldParse</code>、<code class="language-plaintext highlighter-rouge">parseSatisfies</code> 等等，能和 <code class="language-plaintext highlighter-rouge">hspec</code> 测试框架协同工作。</p>

<p>让我们从一个用例开始：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">{-# LANGUAGE OverloadedStrings #-}</span>

<span class="kr">module</span> <span class="nn">Main</span> <span class="p">(</span><span class="nf">main</span><span class="p">)</span> <span class="kr">where</span>

<span class="kr">import</span> <span class="nn">Control.Applicative</span>
<span class="kr">import</span> <span class="nn">Data.Text</span> <span class="p">(</span><span class="kt">Text</span><span class="p">)</span>
<span class="kr">import</span> <span class="nn">Data.Void</span>
<span class="kr">import</span> <span class="nn">Test.Hspec</span>
<span class="kr">import</span> <span class="nn">Test.Hspec.Megaparsec</span>
<span class="kr">import</span> <span class="nn">Text.Megaparsec</span>
<span class="kr">import</span> <span class="nn">Text.Megaparsec.Char</span>

<span class="kr">type</span> <span class="kt">Parser</span> <span class="o">=</span> <span class="kt">Parsec</span> <span class="kt">Void</span> <span class="kt">Text</span>

<span class="n">myParser</span> <span class="o">::</span> <span class="kt">Parser</span> <span class="kt">String</span>
<span class="n">myParser</span> <span class="o">=</span> <span class="n">some</span> <span class="p">(</span><span class="n">char</span> <span class="sc">'a'</span><span class="p">)</span>

<span class="n">main</span> <span class="o">::</span> <span class="kt">IO</span> <span class="nb">()</span>
<span class="n">main</span> <span class="o">=</span> <span class="n">hspec</span> <span class="o">$</span>
  <span class="n">describe</span> <span class="s">"myParser"</span> <span class="o">$</span> <span class="kr">do</span>
    <span class="n">it</span> <span class="s">"returns correct result"</span> <span class="o">$</span>
      <span class="n">parse</span> <span class="n">myParser</span> <span class="s">""</span> <span class="s">"aaa"</span> <span class="p">`</span><span class="n">shouldParse</span><span class="p">`</span> <span class="s">"aaa"</span>
    <span class="n">it</span> <span class="s">"result of parsing satisfies what it should"</span> <span class="o">$</span>
      <span class="n">parse</span> <span class="n">myParser</span> <span class="s">""</span> <span class="s">"aaaa"</span> <span class="p">`</span><span class="n">parseSatisfies</span><span class="p">`</span> <span class="p">((</span><span class="o">==</span> <span class="mi">4</span><span class="p">)</span> <span class="o">.</span> <span class="n">length</span><span class="p">)</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">shouldParse</code> 接受 <code class="language-plaintext highlighter-rouge">Either (ParseErrorBundle s e) a</code>，即语法分析的结果和一个用来进行比较的 <code class="language-plaintext highlighter-rouge">a</code> 类型的值，这可能是用得最多的工具函数。<code class="language-plaintext highlighter-rouge">parseSatisfies</code> 跟它很相似，但不是跟期待的结果比较是否相等，而是用任意断言检查结果。</p>

<p>其它简单的效用期望还有 <code class="language-plaintext highlighter-rouge">shouldSucceedOn</code> 和 <code class="language-plaintext highlighter-rouge">shouldFailOn</code>（但很少用到它们）：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>    <span class="n">it</span> <span class="s">"should parse 'a's all right"</span> <span class="o">$</span>
      <span class="n">parse</span> <span class="n">myParser</span> <span class="s">""</span> <span class="p">`</span><span class="n">shouldSucceedOn</span><span class="p">`</span> <span class="s">"aaaa"</span>
    <span class="n">it</span> <span class="s">"should fail on 'b's"</span> <span class="o">$</span>
      <span class="n">parse</span> <span class="n">myParser</span> <span class="s">""</span> <span class="p">`</span><span class="n">shouldFailOn</span><span class="p">`</span> <span class="s">"bbb"</span>
</code></pre></div></div>

<p>在使用 <code class="language-plaintext highlighter-rouge">megaparsec</code> 时，我们想要让语法分析错误更加精确。为了测试语法分析错误我们可以使用 <code class="language-plaintext highlighter-rouge">shouldFailWith</code>，用法如下：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>    <span class="n">it</span> <span class="s">"fails on 'b's producing correct error message"</span> <span class="o">$</span>
      <span class="n">parse</span> <span class="n">myParser</span> <span class="s">""</span> <span class="s">"bbb"</span> <span class="p">`</span><span class="n">shouldFailWith</span><span class="p">`</span>
        <span class="kt">TrivialError</span>
          <span class="mi">0</span>
          <span class="p">(</span><span class="kt">Just</span> <span class="p">(</span><span class="kt">Tokens</span> <span class="p">(</span><span class="sc">'b'</span> <span class="o">:|</span> <span class="kt">[]</span><span class="p">)))</span>
          <span class="p">(</span><span class="kt">Set</span><span class="o">.</span><span class="n">singleton</span> <span class="p">(</span><span class="kt">Tokens</span> <span class="p">(</span><span class="sc">'a'</span> <span class="o">:|</span> <span class="kt">[]</span><span class="p">)))</span>
</code></pre></div></div>

<p>像这样写出 <code class="language-plaintext highlighter-rouge">TrivialError</code> 挺让人厌烦的。<code class="language-plaintext highlighter-rouge">ParseError</code> 的定义包含了像 <code class="language-plaintext highlighter-rouge">Set</code> 和 <code class="language-plaintext highlighter-rouge">NonEmpty</code> 这样「不方便」的类型，就像我们上面见到的那样，写起来很麻烦。幸运的是，<code class="language-plaintext highlighter-rouge">Test.Hspec.Megaparsec</code> 也重新导出了 <code class="language-plaintext highlighter-rouge">Text.Megaparsec.Error.Builder</code> 模块，里面提供了更方便地构建 <code class="language-plaintext highlighter-rouge">ParseError</code> 的 API。让我们来看看 <code class="language-plaintext highlighter-rouge">err</code>：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>    <span class="n">it</span> <span class="s">"fails on 'b's producing correct error message"</span> <span class="o">$</span>
      <span class="n">parse</span> <span class="n">myParser</span> <span class="s">""</span> <span class="s">"bbb"</span> <span class="p">`</span><span class="n">shouldFailWith</span><span class="p">`</span> <span class="n">err</span> <span class="mi">0</span> <span class="p">(</span><span class="n">utok</span> <span class="sc">'b'</span> <span class="o">&lt;&gt;</span> <span class="n">etok</span> <span class="sc">'a'</span><span class="p">)</span>
</code></pre></div></div>

<ul>
  <li><code class="language-plaintext highlighter-rouge">err</code> 的第一个参数是错误的偏移量（在出错之前我们吃掉了多少单词），这里它就是 0。</li>
  <li><code class="language-plaintext highlighter-rouge">utok</code> 表示「不期而遇的单词」，类似地 <code class="language-plaintext highlighter-rouge">etok</code> 表示「我们期待的单词」。</li>
</ul>

<p>【练习】要构建花哨的错误，也有类似的工具函数叫做 <code class="language-plaintext highlighter-rouge">errFancy</code>，请了解一下。</p>

<p>最后，还可以用 <code class="language-plaintext highlighter-rouge">failsLeaving</code> 和 <code class="language-plaintext highlighter-rouge">succeedLeaving</code> 来测试输入的哪部分在语法分析后还没被吃掉：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code>    <span class="n">it</span> <span class="s">"consumes all 'a's but does not touch 'b's"</span> <span class="o">$</span>
      <span class="n">runParser'</span> <span class="n">myParser</span> <span class="p">(</span><span class="n">initialState</span> <span class="s">"aaabbb"</span><span class="p">)</span> <span class="p">`</span><span class="n">succeedsLeaving</span><span class="p">`</span> <span class="s">"bbb"</span>
    <span class="n">it</span> <span class="s">"fails without consuming anything"</span> <span class="o">$</span>
      <span class="n">runParser'</span> <span class="n">myParser</span> <span class="p">(</span><span class="n">initialState</span> <span class="s">"bbbccc"</span><span class="p">)</span> <span class="p">`</span><span class="n">failsLeaving</span><span class="p">`</span> <span class="s">"bbbccc"</span>
</code></pre></div></div>

<p>这些函数应该用 <code class="language-plaintext highlighter-rouge">runParser'</code> 和 <code class="language-plaintext highlighter-rouge">runParserT'</code> 运行，因为它们支持自定义初始状态并且会返回最终状态（这就能检查输入流剩下的东西了）：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">runParser'</span>
  <span class="o">::</span> <span class="kt">Parsec</span> <span class="n">e</span> <span class="n">s</span> <span class="n">a</span>      <span class="c1">-- ^ Parser to run</span>
  <span class="o">-&gt;</span> <span class="kt">State</span> <span class="n">s</span>           <span class="c1">-- ^ Initial state</span>
  <span class="o">-&gt;</span> <span class="p">(</span><span class="kt">State</span> <span class="n">s</span><span class="p">,</span> <span class="kt">Either</span> <span class="p">(</span><span class="kt">ParseError</span> <span class="p">(</span><span class="kt">Token</span> <span class="n">s</span><span class="p">)</span> <span class="n">e</span><span class="p">)</span> <span class="n">a</span><span class="p">)</span>
</code></pre></div></div>
<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">runParserT'</span> <span class="o">::</span> <span class="kt">Monad</span> <span class="n">m</span>
  <span class="o">=&gt;</span> <span class="kt">ParsecT</span> <span class="n">e</span> <span class="n">s</span> <span class="n">m</span> <span class="n">a</span>   <span class="c1">-- ^ Parser to run</span>
  <span class="o">-&gt;</span> <span class="kt">State</span> <span class="n">s</span>           <span class="c1">-- ^ Initial state</span>
  <span class="o">-&gt;</span> <span class="n">m</span> <span class="p">(</span><span class="kt">State</span> <span class="n">s</span><span class="p">,</span> <span class="kt">Either</span> <span class="p">(</span><span class="kt">ParseError</span> <span class="p">(</span><span class="kt">Token</span> <span class="n">s</span><span class="p">)</span> <span class="n">e</span><span class="p">)</span> <span class="n">a</span><span class="p">)</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">initialState</code> 函数接受输入流，返回该输入流构成的初始状态，而初始状态的其它记录字段会用默认值填充。</p>

<p>关于使用 <code class="language-plaintext highlighter-rouge">hspec-megaparsec</code>，下述代码会是你的灵感来源：</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">hspec-megaparsec</code> 编写的 <a href="https://github.com/mrkkrp/megaparsec/tree/master/megaparsec-tests/tests">Megaparsec 自己的测试套件</a>；</li>
  <li><code class="language-plaintext highlighter-rouge">hspec-megaparsec</code> 自带的<a href="https://github.com/mrkkrp/hspec-megaparsec/blob/master/tests/Main.hs">玩具测试套件</a>。</li>
</ul>

<h2 id="使用自定义输入流">使用自定义输入流</h2>

<p><code class="language-plaintext highlighter-rouge">megaparsec</code> 能用来对任何输入流进行语法分析，只要它是 <code class="language-plaintext highlighter-rouge">Stream</code> 类型类的实例。这意味着它可以和 <code class="language-plaintext highlighter-rouge">alex</code> 之类的词法分析工具配合使用。</p>

<p>为了不偏离我们的主题，我们不会展示 <code class="language-plaintext highlighter-rouge">alex</code> 是如何生成单词流的，我们就假定输入是下述形式：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">{-# LANGUAGE LambdaCase        #-}</span>
<span class="cp">{-# LANGUAGE OverloadedStrings #-}</span>
<span class="cp">{-# LANGUAGE RecordWildCards   #-}</span>
<span class="cp">{-# LANGUAGE TypeFamilies      #-}</span>

<span class="kr">module</span> <span class="nn">Main</span> <span class="p">(</span><span class="nf">main</span><span class="p">)</span> <span class="kr">where</span>

<span class="kr">import</span> <span class="nn">Data.List.NonEmpty</span> <span class="p">(</span><span class="kt">NonEmpty</span> <span class="p">(</span><span class="o">..</span><span class="p">))</span>
<span class="kr">import</span> <span class="nn">Data.Proxy</span>
<span class="kr">import</span> <span class="nn">Data.Void</span>
<span class="kr">import</span> <span class="nn">Text.Megaparsec</span>
<span class="kr">import</span> <span class="k">qualified</span> <span class="nn">Data.List</span>          <span class="k">as</span> <span class="n">DL</span>
<span class="kr">import</span> <span class="k">qualified</span> <span class="nn">Data.List.NonEmpty</span> <span class="k">as</span> <span class="n">NE</span>
<span class="kr">import</span> <span class="k">qualified</span> <span class="nn">Data.Set</span>           <span class="k">as</span> <span class="n">Set</span>

<span class="kr">data</span> <span class="kt">MyToken</span>
  <span class="o">=</span> <span class="kt">Int</span> <span class="kt">Int</span>
  <span class="o">|</span> <span class="kt">Plus</span>
  <span class="o">|</span> <span class="kt">Mul</span>
  <span class="o">|</span> <span class="kt">Div</span>
  <span class="o">|</span> <span class="kt">OpenParen</span>
  <span class="o">|</span> <span class="kt">CloseParen</span>
  <span class="kr">deriving</span> <span class="p">(</span><span class="kt">Eq</span><span class="p">,</span> <span class="kt">Ord</span><span class="p">,</span> <span class="kt">Show</span><span class="p">)</span>
</code></pre></div></div>

<p>为了报告语法分析错误，我们需要一种方式知道单词的起始位置、终止位置和长度，因此我们添加了 <code class="language-plaintext highlighter-rouge">WithPos</code>：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">data</span> <span class="kt">WithPos</span> <span class="n">a</span> <span class="o">=</span> <span class="kt">WithPos</span>
  <span class="p">{</span> <span class="n">startPos</span> <span class="o">::</span> <span class="kt">SourcePos</span>
  <span class="p">,</span> <span class="n">endPos</span> <span class="o">::</span> <span class="kt">SourcePos</span>
  <span class="p">,</span> <span class="n">tokenLength</span> <span class="o">::</span> <span class="kt">Int</span>
  <span class="p">,</span> <span class="n">tokenVal</span> <span class="o">::</span> <span class="n">a</span>
  <span class="p">}</span> <span class="kr">deriving</span> <span class="p">(</span><span class="kt">Eq</span><span class="p">,</span> <span class="kt">Ord</span><span class="p">,</span> <span class="kt">Show</span><span class="p">)</span>
</code></pre></div></div>

<p>这下我们就有数据类型表示自己的流了：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">data</span> <span class="kt">MyStream</span> <span class="o">=</span> <span class="kt">MyStream</span>
  <span class="p">{</span> <span class="n">myStreamInput</span> <span class="o">::</span> <span class="kt">String</span> <span class="c1">-- for showing offending lines</span>
  <span class="p">,</span> <span class="n">unMyStream</span> <span class="o">::</span> <span class="p">[</span><span class="kt">WithPos</span> <span class="kt">MyToken</span><span class="p">]</span>
  <span class="p">}</span>
</code></pre></div></div>

<p>接下来，我们需要让 <code class="language-plaintext highlighter-rouge">MyStream</code> 成为 <code class="language-plaintext highlighter-rouge">Stream</code> 类型类的实例。这需要 <code class="language-plaintext highlighter-rouge">TypeFamilies</code> 语言扩展，因为我们想要定义关联类型函数 <code class="language-plaintext highlighter-rouge">Token</code> 和 <code class="language-plaintext highlighter-rouge">Tokens</code>：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">instance</span> <span class="kt">Stream</span> <span class="kt">MyStream</span> <span class="kr">where</span>
  <span class="kr">type</span> <span class="kt">Token</span>  <span class="kt">MyStream</span> <span class="o">=</span> <span class="kt">WithPos</span> <span class="kt">MyToken</span>
  <span class="kr">type</span> <span class="kt">Tokens</span> <span class="kt">MyStream</span> <span class="o">=</span> <span class="p">[</span><span class="kt">WithPos</span> <span class="kt">MyToken</span><span class="p">]</span>
  <span class="c1">-- …</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">Stream</code> 的文档可以在 <code class="language-plaintext highlighter-rouge">Text.Megaparsec.Stream</code> 模块中找到。现在我们直接把剩下的方法定义完：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">-- …</span>
  <span class="n">tokenToChunk</span> <span class="kt">Proxy</span> <span class="n">x</span> <span class="o">=</span> <span class="p">[</span><span class="n">x</span><span class="p">]</span>
  <span class="n">tokensToChunk</span> <span class="kt">Proxy</span> <span class="n">xs</span> <span class="o">=</span> <span class="n">xs</span>
  <span class="n">chunkToTokens</span> <span class="kt">Proxy</span> <span class="o">=</span> <span class="n">id</span>
  <span class="n">chunkLength</span> <span class="kt">Proxy</span> <span class="o">=</span> <span class="n">length</span>
  <span class="n">chunkEmpty</span> <span class="kt">Proxy</span> <span class="o">=</span> <span class="n">null</span>
  <span class="n">take1_</span> <span class="p">(</span><span class="kt">MyStream</span> <span class="kr">_</span> <span class="kt">[]</span><span class="p">)</span> <span class="o">=</span> <span class="kt">Nothing</span>
  <span class="n">take1_</span> <span class="p">(</span><span class="kt">MyStream</span> <span class="n">str</span> <span class="p">(</span><span class="n">t</span><span class="o">:</span><span class="n">ts</span><span class="p">))</span> <span class="o">=</span> <span class="kt">Just</span>
    <span class="p">(</span> <span class="n">t</span>
    <span class="p">,</span> <span class="kt">MyStream</span> <span class="p">(</span><span class="n">drop</span> <span class="p">(</span><span class="n">tokensLength</span> <span class="n">pxy</span> <span class="p">(</span><span class="n">t</span><span class="o">:|</span><span class="kt">[]</span><span class="p">))</span> <span class="n">str</span><span class="p">)</span> <span class="n">ts</span>
    <span class="p">)</span>
  <span class="n">takeN_</span> <span class="n">n</span> <span class="p">(</span><span class="kt">MyStream</span> <span class="n">str</span> <span class="n">s</span><span class="p">)</span>
    <span class="o">|</span> <span class="n">n</span> <span class="o">&lt;=</span> <span class="mi">0</span>    <span class="o">=</span> <span class="kt">Just</span> <span class="p">(</span><span class="kt">[]</span><span class="p">,</span> <span class="kt">MyStream</span> <span class="n">str</span> <span class="n">s</span><span class="p">)</span>
    <span class="o">|</span> <span class="n">null</span> <span class="n">s</span>    <span class="o">=</span> <span class="kt">Nothing</span>
    <span class="o">|</span> <span class="n">otherwise</span> <span class="o">=</span>
        <span class="kr">let</span> <span class="p">(</span><span class="n">x</span><span class="p">,</span> <span class="n">s'</span><span class="p">)</span> <span class="o">=</span> <span class="n">splitAt</span> <span class="n">n</span> <span class="n">s</span>
        <span class="kr">in</span> <span class="kr">case</span> <span class="kt">NE</span><span class="o">.</span><span class="n">nonEmpty</span> <span class="n">x</span> <span class="kr">of</span>
          <span class="kt">Nothing</span> <span class="o">-&gt;</span> <span class="kt">Just</span> <span class="p">(</span><span class="n">x</span><span class="p">,</span> <span class="kt">MyStream</span> <span class="n">str</span> <span class="n">s'</span><span class="p">)</span>
          <span class="kt">Just</span> <span class="n">nex</span> <span class="o">-&gt;</span> <span class="kt">Just</span> <span class="p">(</span><span class="n">x</span><span class="p">,</span> <span class="kt">MyStream</span> <span class="p">(</span><span class="n">drop</span> <span class="p">(</span><span class="n">tokensLength</span> <span class="n">pxy</span> <span class="n">nex</span><span class="p">)</span> <span class="n">str</span><span class="p">)</span> <span class="n">s'</span><span class="p">)</span>
  <span class="n">takeWhile_</span> <span class="n">f</span> <span class="p">(</span><span class="kt">MyStream</span> <span class="n">str</span> <span class="n">s</span><span class="p">)</span> <span class="o">=</span>
    <span class="kr">let</span> <span class="p">(</span><span class="n">x</span><span class="p">,</span> <span class="n">s'</span><span class="p">)</span> <span class="o">=</span> <span class="kt">DL</span><span class="o">.</span><span class="n">span</span> <span class="n">f</span> <span class="n">s</span>
    <span class="kr">in</span> <span class="kr">case</span> <span class="kt">NE</span><span class="o">.</span><span class="n">nonEmpty</span> <span class="n">x</span> <span class="kr">of</span>
      <span class="kt">Nothing</span> <span class="o">-&gt;</span> <span class="p">(</span><span class="n">x</span><span class="p">,</span> <span class="kt">MyStream</span> <span class="n">str</span> <span class="n">s'</span><span class="p">)</span>
      <span class="kt">Just</span> <span class="n">nex</span> <span class="o">-&gt;</span> <span class="p">(</span><span class="n">x</span><span class="p">,</span> <span class="kt">MyStream</span> <span class="p">(</span><span class="n">drop</span> <span class="p">(</span><span class="n">tokensLength</span> <span class="n">pxy</span> <span class="n">nex</span><span class="p">)</span> <span class="n">str</span><span class="p">)</span> <span class="n">s'</span><span class="p">)</span>
  <span class="n">showTokens</span> <span class="kt">Proxy</span> <span class="o">=</span> <span class="kt">DL</span><span class="o">.</span><span class="n">intercalate</span> <span class="s">" "</span>
    <span class="o">.</span> <span class="kt">NE</span><span class="o">.</span><span class="n">toList</span>
    <span class="o">.</span> <span class="n">fmap</span> <span class="p">(</span><span class="n">showMyToken</span> <span class="o">.</span> <span class="n">tokenVal</span><span class="p">)</span>
  <span class="n">tokensLength</span> <span class="kt">Proxy</span> <span class="n">xs</span> <span class="o">=</span> <span class="n">sum</span> <span class="p">(</span><span class="n">tokenLength</span> <span class="o">&lt;$&gt;</span> <span class="n">xs</span><span class="p">)</span>
  <span class="n">reachOffset</span> <span class="n">o</span> <span class="kt">PosState</span> <span class="p">{</span><span class="o">..</span><span class="p">}</span> <span class="o">=</span>
    <span class="p">(</span> <span class="n">prefix</span> <span class="o">++</span> <span class="n">restOfLine</span>
    <span class="p">,</span> <span class="kt">PosState</span>
        <span class="p">{</span> <span class="n">pstateInput</span> <span class="o">=</span> <span class="kt">MyStream</span>
            <span class="p">{</span> <span class="n">myStreamInput</span> <span class="o">=</span> <span class="n">postStr</span>
            <span class="p">,</span> <span class="n">unMyStream</span> <span class="o">=</span> <span class="n">post</span>
            <span class="p">}</span>
        <span class="p">,</span> <span class="n">pstateOffset</span> <span class="o">=</span> <span class="n">max</span> <span class="n">pstateOffset</span> <span class="n">o</span>
        <span class="p">,</span> <span class="n">pstateSourcePos</span> <span class="o">=</span> <span class="n">newSourcePos</span>
        <span class="p">,</span> <span class="n">pstateTabWidth</span> <span class="o">=</span> <span class="n">pstateTabWidth</span>
        <span class="p">,</span> <span class="n">pstateLinePrefix</span> <span class="o">=</span> <span class="n">prefix</span>
        <span class="p">}</span>
    <span class="p">)</span>
    <span class="kr">where</span>
      <span class="n">prefix</span> <span class="o">=</span>
        <span class="kr">if</span> <span class="n">sameLine</span>
          <span class="kr">then</span> <span class="n">pstateLinePrefix</span> <span class="o">++</span> <span class="n">preStr</span>
          <span class="kr">else</span> <span class="n">preStr</span>
      <span class="n">sameLine</span> <span class="o">=</span> <span class="n">sourceLine</span> <span class="n">newSourcePos</span> <span class="o">==</span> <span class="n">sourceLine</span> <span class="n">pstateSourcePos</span>
      <span class="n">newSourcePos</span> <span class="o">=</span>
        <span class="kr">case</span> <span class="n">post</span> <span class="kr">of</span>
          <span class="kt">[]</span> <span class="o">-&gt;</span> <span class="n">pstateSourcePos</span>
          <span class="p">(</span><span class="n">x</span><span class="o">:</span><span class="kr">_</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="n">startPos</span> <span class="n">x</span>
      <span class="p">(</span><span class="n">pre</span><span class="p">,</span> <span class="n">post</span><span class="p">)</span> <span class="o">=</span> <span class="n">splitAt</span> <span class="p">(</span><span class="n">o</span> <span class="o">-</span> <span class="n">pstateOffset</span><span class="p">)</span> <span class="p">(</span><span class="n">unMyStream</span> <span class="n">pstateInput</span><span class="p">)</span>
      <span class="p">(</span><span class="n">preStr</span><span class="p">,</span> <span class="n">postStr</span><span class="p">)</span> <span class="o">=</span> <span class="n">splitAt</span> <span class="n">tokensConsumed</span> <span class="p">(</span><span class="n">myStreamInput</span> <span class="n">pstateInput</span><span class="p">)</span>
      <span class="n">tokensConsumed</span> <span class="o">=</span>
        <span class="kr">case</span> <span class="kt">NE</span><span class="o">.</span><span class="n">nonEmpty</span> <span class="n">pre</span> <span class="kr">of</span>
          <span class="kt">Nothing</span> <span class="o">-&gt;</span> <span class="mi">0</span>
          <span class="kt">Just</span> <span class="n">nePre</span> <span class="o">-&gt;</span> <span class="n">tokensLength</span> <span class="n">pxy</span> <span class="n">nePre</span>
      <span class="n">restOfLine</span> <span class="o">=</span> <span class="n">takeWhile</span> <span class="p">(</span><span class="o">/=</span> <span class="sc">'</span><span class="se">\n</span><span class="sc">'</span><span class="p">)</span> <span class="n">postStr</span>

<span class="n">pxy</span> <span class="o">::</span> <span class="kt">Proxy</span> <span class="kt">MyStream</span>
<span class="n">pxy</span> <span class="o">=</span> <span class="kt">Proxy</span>

<span class="n">showMyToken</span> <span class="o">::</span> <span class="kt">MyToken</span> <span class="o">-&gt;</span> <span class="kt">String</span>
<span class="n">showMyToken</span> <span class="o">=</span> <span class="nf">\</span><span class="kr">case</span>
  <span class="p">(</span><span class="kt">Int</span> <span class="n">n</span><span class="p">)</span>    <span class="o">-&gt;</span> <span class="n">show</span> <span class="n">n</span>
  <span class="kt">Plus</span>       <span class="o">-&gt;</span> <span class="s">"+"</span>
  <span class="kt">Mul</span>        <span class="o">-&gt;</span> <span class="s">"*"</span>
  <span class="kt">Div</span>        <span class="o">-&gt;</span> <span class="s">"/"</span>
  <span class="kt">OpenParen</span>  <span class="o">-&gt;</span> <span class="s">"("</span>
  <span class="kt">CloseParen</span> <span class="o">-&gt;</span> <span class="s">")"</span>
</code></pre></div></div>

<p>更多关于 <code class="language-plaintext highlighter-rouge">Stream</code> 类型类的背景资料（以及为什么它长这样）可以在<a href="https://markkarpov.com/post/megaparsec-more-speed-more-power.html">这篇博客</a>中找到。</p>

<p>现在我们可以为自定义的流定义 <code class="language-plaintext highlighter-rouge">Parser</code> 了：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kr">type</span> <span class="kt">Parser</span> <span class="o">=</span> <span class="kt">Parsec</span> <span class="kt">Void</span> <span class="kt">MyStream</span>
</code></pre></div></div>

<p>下一步是基于 <code class="language-plaintext highlighter-rouge">token</code> 和 <code class="language-plaintext highlighter-rouge">tokens</code> 两个原语，定义基本的语法分析器了。对于原生支持的流我们有 <code class="language-plaintext highlighter-rouge">Text.Megaparsec.Byte</code> 和 <code class="language-plaintext highlighter-rouge">Text.Megaparsec.Char</code> 模块，但要使用自定义的单词，我们需要自定义工具函数。</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">liftMyToken</span> <span class="o">::</span> <span class="kt">MyToken</span> <span class="o">-&gt;</span> <span class="kt">WithPos</span> <span class="kt">MyToken</span>
<span class="n">liftMyToken</span> <span class="n">myToken</span> <span class="o">=</span> <span class="kt">WithPos</span> <span class="n">pos</span> <span class="n">pos</span> <span class="mi">0</span> <span class="n">myToken</span>
  <span class="kr">where</span>
    <span class="n">pos</span> <span class="o">=</span> <span class="n">initialPos</span> <span class="s">""</span>

<span class="n">pToken</span> <span class="o">::</span> <span class="kt">MyToken</span> <span class="o">-&gt;</span> <span class="kt">Parser</span> <span class="kt">MyToken</span>
<span class="n">pToken</span> <span class="n">c</span> <span class="o">=</span> <span class="n">token</span> <span class="n">test</span> <span class="p">(</span><span class="kt">Set</span><span class="o">.</span><span class="n">singleton</span> <span class="o">.</span> <span class="kt">Tokens</span> <span class="o">.</span> <span class="n">nes</span> <span class="o">.</span> <span class="n">liftMyToken</span> <span class="o">$</span> <span class="n">c</span><span class="p">)</span>
  <span class="kr">where</span>
    <span class="n">test</span> <span class="p">(</span><span class="kt">WithPos</span> <span class="kr">_</span> <span class="kr">_</span> <span class="kr">_</span> <span class="n">x</span><span class="p">)</span> <span class="o">=</span>
      <span class="kr">if</span> <span class="n">x</span> <span class="o">==</span> <span class="n">c</span>
        <span class="kr">then</span> <span class="kt">Just</span> <span class="n">x</span>
        <span class="kr">else</span> <span class="kt">Nothing</span>
    <span class="n">nes</span> <span class="n">x</span> <span class="o">=</span> <span class="n">x</span> <span class="o">:|</span> <span class="kt">[]</span>

<span class="n">pInt</span> <span class="o">::</span> <span class="kt">Parser</span> <span class="kt">Int</span>
<span class="n">pInt</span> <span class="o">=</span> <span class="n">token</span> <span class="n">test</span> <span class="kt">Set</span><span class="o">.</span><span class="n">empty</span> <span class="o">&lt;?&gt;</span> <span class="s">"integer"</span>
  <span class="kr">where</span>
    <span class="n">test</span> <span class="p">(</span><span class="kt">WithPos</span> <span class="kr">_</span> <span class="kr">_</span> <span class="kr">_</span> <span class="p">(</span><span class="kt">Int</span> <span class="n">n</span><span class="p">))</span> <span class="o">=</span> <span class="kt">Just</span> <span class="n">n</span>
    <span class="n">test</span> <span class="kr">_</span> <span class="o">=</span> <span class="kt">Nothing</span>
</code></pre></div></div>

<p>最后让我们写一个语法分析器测试一下加法表达式：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">pSum</span> <span class="o">=</span> <span class="kr">do</span>
  <span class="n">a</span> <span class="o">&lt;-</span> <span class="n">pInt</span>
  <span class="kr">_</span> <span class="o">&lt;-</span> <span class="n">pToken</span> <span class="kt">Plus</span>
  <span class="n">b</span> <span class="o">&lt;-</span> <span class="n">pInt</span>
  <span class="n">return</span> <span class="p">(</span><span class="n">a</span><span class="p">,</span> <span class="n">b</span><span class="p">)</span>
</code></pre></div></div>

<p>这里是一个样例输入：</p>

<div class="language-haskell highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">exampleStream</span> <span class="o">::</span> <span class="kt">MyStream</span>
<span class="n">exampleStream</span> <span class="o">=</span> <span class="kt">MyStream</span>
  <span class="s">"5 + 6"</span>
  <span class="p">[</span> <span class="n">at</span> <span class="mi">1</span> <span class="mi">1</span> <span class="p">(</span><span class="kt">Int</span> <span class="mi">5</span><span class="p">)</span>
  <span class="p">,</span> <span class="n">at</span> <span class="mi">1</span> <span class="mi">3</span> <span class="kt">Div</span>         <span class="c1">-- (1)</span>
  <span class="p">,</span> <span class="n">at</span> <span class="mi">1</span> <span class="mi">5</span> <span class="p">(</span><span class="kt">Int</span> <span class="mi">6</span><span class="p">)</span>
  <span class="p">]</span>
  <span class="kr">where</span>
    <span class="n">at</span>  <span class="n">l</span> <span class="n">c</span> <span class="o">=</span> <span class="kt">WithPos</span> <span class="p">(</span><span class="n">at'</span> <span class="n">l</span> <span class="n">c</span><span class="p">)</span> <span class="p">(</span><span class="n">at'</span> <span class="n">l</span> <span class="p">(</span><span class="n">c</span> <span class="o">+</span> <span class="mi">1</span><span class="p">))</span> <span class="mi">2</span>
    <span class="n">at'</span> <span class="n">l</span> <span class="n">c</span> <span class="o">=</span> <span class="kt">SourcePos</span> <span class="s">""</span> <span class="p">(</span><span class="n">mkPos</span> <span class="n">l</span><span class="p">)</span> <span class="p">(</span><span class="n">mkPos</span> <span class="n">c</span><span class="p">)</span>
</code></pre></div></div>

<p>让我们试一下：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest (pSum &lt;* eof) exampleStream
(5,6)
</code></pre></div></div>

<p>如果我们把 (1) 处的 <code class="language-plaintext highlighter-rouge">Plus</code> 改成 <code class="language-plaintext highlighter-rouge">Div</code>，我们也能得到正确的错误信息：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>λ&gt; parseTest (pSum &lt;* eof) exampleStream
1:3:
  |
1 | 5 + 6
  |   ^^
unexpected /
expecting +
</code></pre></div></div>

<p>换言之，我们拥有一个能够处理自定义流的功能完备的语法分析器了。</p>
<div class="footnotes" role="doc-endnotes">
  <ol>
    <li id="fn:modern-uri">
      <p>实际上有个 <a href="https://hackage.haskell.org/package/modern-uri">modern-uri</a> 包，其 Megaparsec 语法分析器支持 RFC 3986 定义的 URI 格式，但它远比我们这里介绍的要复杂。 <a href="#fnref:modern-uri" class="reversefootnote" role="doc-backlink">&#8617;</a></p>
    </li>
  </ol>
</div>]]></content><author><name>Mark Karpov</name></author><category term="译文" /><summary type="html"><![CDATA[原文标题：Megaparsec tutorial from IH book 原文链接：https://markkarpov.com/tutorial/megaparsec.html]]></summary></entry><entry><title type="html">日系偶像综述</title><link href="https://blog.yzsun.me/aidoru/" rel="alternate" type="text/html" title="日系偶像综述" /><published>2019-02-24T00:00:00+00:00</published><updated>2019-02-24T00:00:00+00:00</updated><id>https://blog.yzsun.me/aidoru</id><content type="html" xml:base="https://blog.yzsun.me/aidoru/"><![CDATA[<p>刚刚过去的 2018 年，被一些媒体称为「中国偶像元年」，这一年里爱奇艺的《偶像练习生》和腾讯的《创造101》将偶像和粉丝经济的概念一下子推向了主流文化。很遗憾没有去萧山现场看《创造101》的录制，不过我去钱江世纪公园看了首届天猫亚洲偶像嘉年华（AIF2018），明显感觉在两大偶像综艺繁荣的背后，中国国内所谓的养成系偶像还不成气候。一般来说持续数日或者多舞台同时演出才称得上是音乐节，单日单舞台的 AIF 也就是地下偶像拼盘的水平吧，演出阵容和规模远不如日本的 TIF。不过在 AIF 上见证了 AKB48 Team SH 的首次公开亮相，我还是非常兴奋的，毕竟也算是跻身上海队的古参饭了。</p>

<p>对「偶像」这个词的理解，一千个读者眼中就有一千个哈姆雷特。在日本虽然也有汉语词「偶像」，但只有崇拜对象之意；我们现在讨论的偶像则为「IDOL」的音译词「アイドル」。虽然「IDOL」仍然是偶像的意思，但经过音译的转换已经退化为了表音符号；因此在当代日语的语境中，「アイドル」并不会自然而然地跟崇拜对象之意关联起来，而是被赋予了新的含义：与粉丝分享成长过程、以其存在本身为魅力的人物（参见<a href="https://ja.wikipedia.org/wiki/アイドル">维基百科</a>）。请允许我将这个语义下的偶像称为日系偶像，本文便是个人对日系偶像领域的国内外研究现状综述，其中也结合了我在东京一年的见学体验以及赴北上广参加偶像活动的经历。</p>

<!--more-->

<ul id="markdown-toc">
  <li><a href="#格子裙经济学" id="markdown-toc-格子裙经济学">格子裙经济学</a></li>
  <li><a href="#主流偶像地下偶像" id="markdown-toc-主流偶像地下偶像">主流偶像・地下偶像</a>    <ul>
      <li><a href="#中国偶像" id="markdown-toc-中国偶像">中国偶像</a></li>
    </ul>
  </li>
  <li><a href="#公演" id="markdown-toc-公演">公演</a></li>
  <li><a href="#call--mix" id="markdown-toc-call--mix">CALL / MIX</a></li>
  <li><a href="#握手会" id="markdown-toc-握手会">握手会</a></li>
  <li><a href="#综艺" id="markdown-toc-综艺">综艺</a></li>
  <li><a href="#结语" id="markdown-toc-结语">结语</a></li>
</ul>

<h2 id="格子裙经济学">格子裙经济学</h2>

<p>日系偶像的魅力，简而言之我认为是参与感。首先偶像基本上都是素人出身，是未经加工的原石，在粉丝的陪伴下最后能否打磨成闪闪发光的钻石，本身就是一场大型养成游戏。在此基础上，秋元康还独辟蹊径打造了一整套偶像商法，事实证明这非常成功，乃至现今日本所有的偶像团体几乎都有秋元康的影子。田中秀臣教授在《AKB48 的格子裙经济学：粉丝效应中的新生与创意》中阐述了帮助 AKB48 走向成功的粉丝经济模式，综合其理论和我自己的体验，日系偶像有几个值得注意的特点：</p>

<ul>
  <li>
    <p>剧场公演：AKB48 在秋叶原的唐吉诃德百货店八楼拥有专属剧场，Team A/K/B/4/8 五个队轮流公演，保证每周都有多场公演，这从演唱会时代粉丝被动调整日程安排，进化到了剧场公演时代粉丝主动自由挑选时间场次。同时剧场公演的票价还十分低廉，比如上海 SNH48 的普通座票只要 80 人民币。在高频率、低票价的剧场公演下，日本粉丝还发展出了自己独特的应援方式，也就是后面重点叙述的 CALL / MIX 等等，这极大提升了公演中粉丝的参与感。</p>
  </li>
  <li>
    <p>握手会：这大概是日系偶像最具代表性的制度了，真正兑现了粉丝和偶像可以面对面的承诺。所谓握手会，就是通过购买唱片获取握手券，凭券即可赴会场跟偶像近距离握手聊天，其变种还有签名会、合影会等等。正因如此，粉丝们会大量购买同一张唱片来拿里面的券，近十年来 AKB48 GROUP 和坂道系列只要发了单曲就一定是当周的公信榜榜首。根据国际唱片业协会对 2017 年的数据统计，日本是全球第二大音乐市场，不过其实体唱片比例 72% 远超第一大市场美国的 15%，这说到底少不了握手会制度的功劳，虽然反过来日本偶像也被称为日音毒瘤。</p>
  </li>
  <li>
    <p>总选举：AKB48 另一大代表性制度是通过粉丝投票来决定偶像们在歌曲中的站位，每年都会举办一次，这或许也是 PRODUCE 101 模式的起源。AKB48 总选举的投票券都是真金实银买来的，虽然各种官方会员也会送投票权，但数额的大头还是来自投票期间发行的那张单曲。自 2010 年以来，公信榜单曲年榜冠军就雷打不动一直是 AKB48 的投票单。而总选举的开票现场也会由富士电视台直播，算得上是非常隆重了，腾讯视频这几年也买下了国内的网络直播权。除此之外，AKB48 其实还有名为[重温时间最佳曲目100]{Request Hour Setlist Best 100}的歌曲票选活动，也是通过购买单曲来取得投票权；另外还有一年一度的猜拳大会，完全凭运气来决定单曲出道的人选。</p>
  </li>
  <li>
    <p>地元化：虽然东京都市圈聚集了日本近 30% 的人口，但让其他地方的居民也能够在自己家门口见到小偶像则是进一步发展的必由之路。AKB48 小有名气之时，秋元康便开始在名古屋等地开设分团，如今更是印尼、泰国、菲律宾、越南、中国大陆和台湾遍地开花。最能体现地元化理念的其实还是 AKB48 Team 8：这个队定员 47 人，分别来自日本的 47 个都道府县，她们的口号也从本部的「[能见面的偶像]{会いに行けるアイドル}」转变为了「[去见你的偶像]{会いに行くアイドル}」，在日本全国各地巡回演出。顺带一提，Team 8 的构想大概是来源于 NHK 晨间剧《海女》中影射 AKB48 的 GMT47。</p>
  </li>
  <li>
    <p>毕业制度：用毕业一词代指退团，据说最早来源于上世纪秋元康策划的小猫俱乐部。在日系偶像的养成模式下，成员们是不会一辈子待在团里的，总要迎来毕业、迈向人生的下一个阶段，或是成为歌手、或是成为演员、或是成为模特、或是宣布引退……在多期招募和毕业制度下，AKB48 这样的大型偶像团体历经十余年的发展，比起特定的人的集合更像是一套制度的实体化，正所谓铁打的秋元康流水的小偶像。</p>
  </li>
</ul>

<h2 id="主流偶像地下偶像">主流偶像・地下偶像</h2>

<p>日本有主流偶像和地下偶像之分，前者经常能在大众媒体上见到，而后者鲜有人知、大多以小型剧场公演为中心进行活动。虽然这没有严格的界限，但常常会以[主流出道]{メジャーデビュー}作为分界点，即在日本唱片协会的 <a href="https://www.riaj.or.jp/about/member.html">18</a> 家会员公司发售过唱片。</p>

<p><img src="/images/idol-00.jpg" alt="" /></p>

<p>以我粗浅的认识，如今广为人知的主流偶像可以说有这么几大势力：</p>

<ul>
  <li>
    <p>Hello! Project：早安少女组、ANGERME、Juice=Juice、Country Girls、玉兰花工厂、山茶花工厂、BEYOOOOONDS。由淳君担任制作人，是 21 世纪日本女子偶像团体的开端，旗下组合经历了数次重组，早安少女组 1998 - 2007 连续十年登上过红白歌会。</p>
  </li>
  <li>
    <p>AMUSE：Perfume、BABYMETAL 等等。Perfume 原为广岛地元偶像，上京后成为流行电音组合，2008 年开始每年都会登上红白歌会；BABYMETAL 原为樱花学院的重音部，现已成为世界知名的重金属乐团，多次在欧美巡演。虽然她们都是偶像出身，但现在的身份更像是主流歌手。</p>
  </li>
  <li>
    <p>AKB48 GROUP：日本国内六团 [AKB]{<strong>Ak</strong>iha<strong>b</strong>ara}48（东京・秋叶原）、[SKE]{<strong>S</strong>a<strong>k</strong>a<strong>e</strong>}48（名古屋・荣）、[NMB]{<strong>N</strong>a<strong>mb</strong>a}48（大阪・难波）、[HKT]{<strong>H</strong>a<strong>k</strong>a<strong>t</strong>a}48（福冈・博多）、[NGT]{<strong>N</strong>ii<strong>g</strong>a<strong>t</strong>a}48（新潟）、[STU]{<strong>S</strong>e<strong>t</strong>o<strong>u</strong>chi}48（濑户内），海外六团 [JKT]{<strong>J</strong>a<strong>k</strong>ar<strong>t</strong>a}48（雅加达）、[BNK]{<strong>B</strong>a<strong>n</strong>g<strong>k</strong>ok}48（曼谷）、[MNL]{<strong>M</strong>a<strong>n</strong>i<strong>l</strong>a}48（马尼拉）、[SGO]{<strong>S</strong>ai<strong>go</strong>n}48（西贡）、AKB48 Team [SH]{<strong>S</strong>hang<strong>h</strong>ai}（上海）、AKB48 Team [TP]{<strong>T</strong>ai<strong>p</strong>ei}（台北）。均由秋元康担任制作人，AKB48 从地下偶像一步步成长为国民偶像，为日本偶像团体开创了制度的蓝本，开启了「偶像战国时代」。在 2014 年，曾经出现过 AKB48、SKE48、NMB48、HKT48 四个团同时登上红白歌会的奇观。</p>
  </li>
  <li>
    <p>STARDUST PLANET：桃色幸运草Z、私立惠比寿中学、虎鱼组、彩虹章鱼烧等等。桃草大概是日本近十年来唯一能够撼动秋元康的偶像团体，于 2012 - 2014 连续三年登上过红白歌会的舞台。星尘近年来在名古屋、大阪、福冈、仙台都设了团，也在探索地元化拓展之路。</p>
  </li>
  <li>
    <p>DEARSTAGE：电波组.inc、彩虹征服者等等。电波组诞生于秋叶原的女仆咖啡店 Dear Stage，慢慢发展为以御宅族为成员的偶像团体，2015 年曾两次出演 Music Station。虹控最初由 pixiv 创建，成员由插画师、编舞师、声优、Cosplayer 等组成，2017 年底宣布并入 DEARSTAGE。</p>
  </li>
  <li>
    <p>坂道系列：乃木坂46、欅坂46、日向坂46、吉本坂46。亦由秋元康担任制作人，前三个团由索尼音乐主导运营，吉本坂则由吉本兴业运营。乃团最初以 AKB48 官方对手的名义成立，抛弃了剧场公演而改走演唱会路线，从 2015 年开始成为红白歌会的常客；欅坂则是摇滚风格的姐妹团体，在 2016 年创造了出道当年就登上红白歌会的历史；日向坂原称平假名欅坂（けやき坂46），今年刚刚宣告独立；吉本坂与上述三个团的画风完全不同，是吉本兴业旗下艺人组成的团体，性别不限，年龄不限。</p>
  </li>
  <li>
    <p>WACK：BiS、BiSH 等等。由渡边淳之介担任制作人，松隈健太担任音乐制作，两团均为朋克系偶像。BiS 曾于 2014 年一度解散，后于 2016 年再度结成；BiSH 则于 2015 年结成，2017 年底登上过 Music Station，我觉得是目前最有潜力的偶像之一。</p>
  </li>
</ul>

<p>因为基本上只有上述主流偶像才有能力上电视（这里以 Music Station 为标准），或者办武道馆以上的大型演唱会，所以其他主流出道的偶像在大众心目中很可能仍然是地下偶像。</p>

<p>另外不得不提的一大类是声优偶像，这是偶像文化和二次元文化的交汇地带。光谱的一极是没有专属虚拟形象、只是招募了声优作为成员的偶像团体，譬如爱贝克思旗下的 i☆Ris、代代木动画学院旗下的 =LOVE 和 ≠ME；另一极则是以二次元作品中的虚拟形象为主体活动的偶像团体，譬如 THE IDOLM@STER 系列、Love Live! 系列和 BanG Dream! 系列；也有介于两者之间的，譬如秋元康担任制作人的 22/7。「家虎」等偶像 CALL / MIX 在中国的扩散便归功于部分 Aqours 粉丝把地下偶像的玩法引入了 Aqours 的演唱会，进而影响到了中国二次元圈。</p>

<h3 id="中国偶像">中国偶像</h3>

<p>国内日系偶像现在是 SNH48 GROUP 一家独大，其董事长是有日本留学背景的久游网创始人王子杰。SNH48 于 2013 年在上海浅水湾进行首次公演，现在已经开出了 [SNH]{<strong>S</strong>ha<strong>n</strong>g<strong>h</strong>ai}48（上海）、[BEJ]{<strong>Be</strong>i<strong>j</strong>ing}48（北京）、[GNZ]{<strong>G</strong>ua<strong>n</strong>g<strong>z</strong>hou}48（广州）、[SHY]{<strong>Sh</strong>en<strong>y</strong>ang}48（沈阳・刚刚解散）、[CKG]{<strong>C</strong>hung<strong>k</strong>in<strong>g</strong>}48（重庆・刚刚解散）五个分团，分别在嘉兴路、悠唐、中泰、豫珑城、国瑞设有星梦剧院，这些地名常作为这些团的代称。因为合同纠纷 AKS 在 2016 年 6 月 9 日宣布 SNH48 不再是 AKB48 GROUP 的一部分，并在 2018 年成立了新的官方分团 AKB48 Team SH，丝芭则表示将继续独立走原创道路。</p>

<p>SNH48 GROUP 也独立举办一年一度的总决选和金曲大赏，而总决选两连冠的鞠婧祎现在已经被收编为丝芭旗下独立艺人，这跟 AKS 让成员外签到大手事务所的模式相当不同。这一差异的主因是丝芭会让成员入团前签专属艺人合约，8 年内即使退团也不得参与其他演艺活动，否则需要缴纳巨额违约金，这从<a href="http://wenshu.court.gov.cn/content/content?DocID=38eefb50-5601-4464-96c3-a9e000936c82">赵嘉敏</a>、<a href="http://wenshu.court.gov.cn/content/content?DocID=4d8148fe-30b3-4958-aa07-a72000aecec1">李豆豆</a>和<a href="http://wenshu.court.gov.cn/content/content?DocID=ccbb10a6-2061-4860-8600-a8f00134f155">陈怡馨</a>的民事判决书就可见一斑。目前 SNH48 成立还未满 8 年，因此任何成员退团都几乎等同于引退，不像 AKB48 GROUP 的成员毕业只是人生新的开始。一个很直观的感受就是，很多成员无声无息地就走了，丝芭从来不发表毕业公告或是安排正式的毕业公演。</p>

<p>国内被普遍认可为日系偶像的团体还有上海的 Lunar、Idol School、ATF（这三家都参加过 @JAM in 上海），广州的 1931，香港的 Ariel Project 等等。其中 Lunar 的出道时间甚至早于 SNH48，脱胎于上海一家女仆咖啡厅，不过在 2017 年发生了<a href="https://mp.weixin.qq.com/s/mSKz4RZ89iPynU02oCehLA">丑闻</a>和资本变动，运营重心迁移到了重庆新团 Lunar 雾队，但不到一年又杳无音信了。Idol School 由早安少女组毕业成员钱琳担任制作人，也是走剧场公演路线，不过近年陷入<a href="https://mp.weixin.qq.com/s/EZuRDkno0Klx1Sn8-ju8yQ">欠薪风波</a>前途未卜。1931 由欢聚时代（YY）投资组建，在广州拥有专属剧场，遗憾的是在 2017 年底宣布停止运营。ATF 由心动网络投资组建，未设剧场而走坂道系列路线，亦于 2017 年底发布公告并停止活动。上述团体的不少成员参加了去年腾讯制作的《创造101》，但都没能进入最终出道名单。相较之下还是香港的 Star Creative Production 运营比较稳健，旗下的 Ariel Project 除了保持每月定期公演之外，至今已连续三年获邀出演 @JAM EXPO，并在日本成功发行了单曲。</p>

<h2 id="公演">公演</h2>

<p>一般来说，偶像公演有这么三种形式：[单独公演]{ワンマン}、[拼盘]{対バン}、[音乐节]{フェス}。因为大多数地下偶像都没有实力租巨蛋、武道馆这样的万人场，所以大多数情况下都在数百到千人左右的[小型剧场]{ライブハウス}举办，票价十分便宜，偶像与粉丝也不会有什么距离感，终演后还会有物贩和特典会，于是这些活动都会以[公演]{ライブ}而不是[演唱会]{コンサート}来代称。</p>

<p>除了 AKB48 GROUP 这类有专属剧场的，其他偶像团体大多都需要自己搜集情报来跑活动。一般都是去自己推的偶像的官网确认公演的日程安排，如果只是周末有闲暇想去打打尻跳跳高，也可以看看定期举办的偶像相关企划，比如「<a href="https://akibalive.jp">AKIBAカルチャーズ劇場</a>」「<a href="http://www.tokyoidolgekijo.com">東京アイドル劇場</a>」「<a href="http://www.at-jam.jp">@JAM</a>」「<a href="http://girlsbomb.com">Girl’s Bomb!!</a>」「<a href="http://idolkoushien.com">アイドル甲子園</a>」「<a href="http://marquee-mag.com/newevent.html">MARQUEE祭</a>」「<a href="http://s.mxtv.jp/mip_mif/">MX IDOL PROJECT</a>」「<a href="http://www.radionikkei.jp/idol_generation/">アイドルジェネレーション</a>」「<a href="https://zamurai.tokyo">アイドル侍</a>」「<a href="http://www.idol-pass.com">楽遊 IDOL PASS</a>」「<a href="https://tiget.net/users/139">IDOL CONTENT EXPO</a>」「<a href="https://ticket.rakuten.co.jp/features/tip/index.html">TOKYO IDOL PROJECT</a>」「<a href="http://idorisefes.jp">IDORISE!! FESTIVAL</a>」等等等等。</p>

<p>如果还没有特别心仪的偶像或是想要发掘地下偶像中的原石，大型偶像音乐节则是最佳去处。日本最大的偶像音乐节是富士电视台发起的「<a href="http://www.idolfes.com">TOKYO IDOL FESTIVAL</a>」，其会场遍布整个台场，最近三年每年出演的偶像都超过了两百组。因为仍然有很多没得到出演权的地下偶像希望报名，TIF 还举办了「TIF への扉」系列企划来进行甄选，富士电视台亦推出了指原莉乃的冠番「この指と〜まれ！」进行联动。其次要数「<a href="http://www.at-jam.jp">@JAM EXPO</a>」和「<a href="http://summerfes.idolyokocho.com">アイドル横丁夏まつり!!</a>」，这两场都在离东京不远的横滨举办，分别是在室内的[横滨体育馆]{横浜アリーナ}和露天的[横滨红砖仓库]{横浜赤レンガ倉庫}。</p>

<p>另外非常值得一去的是各所大学的学园祭，很多大学的偶像文化研究会都会邀请偶像来参加，而且原则上都是免费的。以 2018 年东京的学园祭为例，<a href="https://gogatsusai.jp">五月祭</a>（东京大学本乡校区）邀请了 なんキニ！、FES☆TIVE、煌めき☆あんフォレント、lyrical school 等，<a href="https://www.komabasai.net">駒場祭</a>（东京大学驹场校区）邀请了 NEO JAPONISM、メイビーME、天晴れ！原宿、フィロソフィーのダンス 等，<a href="http://www.hosei.ac.jp/campuslife/club/daigakusai.html">自主法政祭</a>邀请了 STU48、アキシブproject 等，最强的是「早稲田アイドルフェスティバル!!! in <a href="https://wasedasai.net">早稲田祭</a>2018」邀请了包括 =LOVE 和 AKB48 在内的 29 组偶像。</p>

<p><img src="/images/idol-01.jpg" alt="" /></p>

<p>如果想酣畅淋漓地享受偶像公演的乐趣，下面介绍的 CALL / MIX 则是解乏的良方。</p>

<h2 id="call--mix">CALL / MIX</h2>

<h3 class="no_toc" id="call">CALL</h3>

<p>所谓 CALL，就是附和歌曲的节奏或歌词进行声援。最简单而常见的 CALL 就是挥舞荧光棒、跟随歌曲的节奏喊 Hey / Hai / Oi，地下偶像粉丝则不怎么用荧光棒，更喜欢拍手或做肢体动作。虽然打 CALL 的时机可以用音乐理论知识分析，但我个人感觉还是去公演现场多体验几场学起来更快一些，到时候自然而然就对节拍产生条件反射了。</p>

<h4 class="no_toc" id="人名-call">人名 CALL</h4>

<p>人名 CALL 非常好理解，就是在成员唱歌的时候喊她的名字。AKB48 剧场公演喊「超絶可愛い〇〇〇」比较常见，地下偶像公演喊「オーレーの〇〇〇」比较常见。</p>

<h4 class="no_toc" id="ppph">PPPH</h4>

<p>PPPH 是「パンパパン、ヒュー」的缩写，先在身体左侧拍一次手，再在右侧连拍两次手，最后跳起来喊一声「ヒュー」。虽然 PPPH 这个名字很常见，但相同的节奏下现在更流行的是 Oh-ing。</p>

<h4 class="no_toc" id="oh-ing">Oh-ing</h4>

<p>一开始大家是先上举荧光棒喊「オー」，再向前挥荧光棒喊「ハイ」；但地下偶像粉丝不怎么用荧光棒，就改成了先喊「オー」再拍手两次，这被称为「オーイング」（Oh-ing）。</p>

<h4 class="no_toc" id="fufu-fuwafuwa">Fufu-Fuwafuwa</h4>

<p>在副歌的时候会打「オーフッフー 👏👏 フワフワ」，后面经常还会跟着「ハイセーノ、ハーイハイ、ハイハイハイハイ」，例如 まねきケチャ『<a href="https://www.bilibili.com/video/av32045128?t=96">冗談じゃないね</a>』。</p>

<h4 class="no_toc" id="家虎-">家虎 🏠🐯</h4>

<blockquote>
  <p>イエッタイガー！</p>
</blockquote>

<p>「家」是「イエ」的谐音，「虎」是「タイガー」的意译，「家虎」现在已经成为地下偶像 CALL 的代名词。通常在副歌快要开始之前喊，大家经常会先重复「イエッ！」来预警，经典实战是 ベイビーレイズ JAPAN『<a href="https://www.bilibili.com/video/av3923091?t=225">夜明け Brand New Days</a>』；有时后面还会接上「ファイボワイパー！」，比如 まねきケチャ『<a href="https://www.bilibili.com/video/av32019029?t=165">きみわずらい</a>』。</p>

<h4 class="no_toc" id="高低-call--松隆子-call">高低 CALL &amp; 松隆子 CALL</h4>

<blockquote>
  <p>高まるよ！高まるよ！高まる低まるビスマルク！<br />
シジマール！アルシンド！カズダンス！ニーハイ！オーハイ！<br />
缶チューハイ！ウーロンハイ！ナチュラルハイ！アイ・キャン・フライ！</p>
</blockquote>

<blockquote>
  <p>パン、パン、パン、パン、ポケモンパン！<br />
フレッシュブレッド、伊藤パン！<br />
松たか子！松たか子！<br />
ヤマザキ春のパンまつり！</p>
</blockquote>

<p>这两个 CALL 没有上面几种那么普遍，也都是地下偶像 CALL。前者实战案例比如 天晴れ！原宿『<a href="https://www.bilibili.com/video/av9063541?t=100">アッパレルヤ!!</a>』，后者比如 26時のマスカレイド『<a href="https://www.bilibili.com/video/av39760291?t=490">ハートサングラス</a>』。如今这些 CALL 亦被国内聚聚引入了 SNH48 GROUP 的原创公演，因此在星梦剧院也能时常听到。</p>

<h4 class="no_toc" id="世界-call">世界 CALL</h4>

<blockquote>
  <p>[世界]{せかい}の、[一番]{いちばん}、[可愛]{かわい}い、〇〇〇！<br />
〇〇〇、最可爱、超绝可爱、〇〇〇！<br />
L・O・V・E、Lovely、〇〇〇！</p>
</blockquote>

<p>这是 SNH48 粉丝原创的 CALL，糅合了日语、汉语和英语，虽然从语法上来说「世界の」应该是「世界で」才对。这个 CALL 在 SNH48 GROUP 十分常见，实战案例比如剧场公演曲『<a href="https://www.bilibili.com/video/av15602616">恋爱捉迷藏</a>』，最早这是 Team NII 唐安琪的 SOLO 曲。</p>

<h3 class="no_toc" id="mix">MIX</h3>

<p>所谓 MIX，就是在前奏和间奏发动的活跃气氛（但没有实际意义）的呼喊。通行的 MIX 分为英语、日语、阿伊努语三部分，阿伊努语是日本北海道原住民的语言，之所以出现这门语言是因为偶像 MIX 的布道师 <a href="https://www.youtube.com/watch?v=YhNg7JPrK7c">園長</a> 是北海道出身。如果前奏时间短就只打英语部分，等间奏再打日语和阿伊努语部分；如果前奏足够长就加上日语二连 MIX，或者再加上阿伊努语三连 MIX，亦或者打到日语「繊維」时再发动一次英语的 2.5 连 MIX。MIX 发展至今已有不少微妙的变化，下面以首次大规模投入使用的 AKB48 版本为基础进行介绍，不过 AKB48 Team 8 和 SNH48 原创曲的 MIX 更接近地下偶像。</p>

<h4 class="no_toc" id="英语-mix">英语 MIX</h4>

<blockquote>
  <p>あーよっしゃいくぞー！<br />
タイガー！ファイヤー！サイバー！ファイバー！ダイバー！バイバー！ジャージャー！</p>
</blockquote>

<p>地下偶像 MIX 通常将发动部分简化为「あー 👏👏 ジャージャー」或「👏👏👏👏👏 しゃーいくぞー」，最近还有虎火发动版本：</p>

<blockquote>
  <p>タイガーファイヤー！<br />
サイバー！ファイバー！ダイバー！バイバー！ジャージャー！ファイボー！ワイパー！</p>
</blockquote>

<h4 class="no_toc" id="日语-mix">日语 MIX</h4>

<blockquote>
  <p>あーもういっちょいくぞー！<br />
[虎]{とら}！[火]{ひ}！[人造]{じんぞう}！[繊維]{せんい}！[海女]{あま}！[振動]{しんどう}！[化繊]{かせん}[飛]{とび}[除去]{じょきょ}！</p>
</blockquote>

<p>与英语部分类似，地下偶像 MIX 会简化发动部分，并且只喊「化繊」不喊「飛除去」，最近还有虎火发动版本：</p>

<blockquote>
  <p>虎×12 虎火！<br />
人造！繊維！海女！振動！化繊！飛！除去！</p>
</blockquote>

<h4 class="no_toc" id="阿伊努语-mix">阿伊努语 MIX</h4>

<blockquote>
  <p>チャペ！アペ！カラ！キナ！ララ！トゥスケ！ミョーホントゥスケ！</p>
</blockquote>

<p>因为很多歌曲的前奏和间奏不够长，所以阿伊努语部分相对罕见一些，而阿伊努语的另一个长版本就更罕见了：</p>

<blockquote>
  <p>チャペ！アペ！カラ！キナ！ララ！トゥスケ！ウィスゥペ！ケスィ！スィスゥパ！</p>
</blockquote>

<h4 class="no_toc" id="特殊-mix">特殊 MIX</h4>

<p>上面介绍的三段 MIX 几乎在所有偶像歌曲中都通用，还有很多特殊的 MIX 会在特定的歌曲中出现，有名的比如 Cheeky Parade『<a href="https://www.bilibili.com/video/av7158148">BUNBUN NINE9’</a>』中的「チキパ MIX」、虹のコンキスタドール『<a href="https://www.bilibili.com/video/av6216391?t=133">トライアングル・ドリーマー</a>』中的「三角関数 MIX」、まねきケチャ『<a href="https://www.bilibili.com/video/av32045128?t=141">冗談じゃないね</a>』中的「林修 MIX」、SNH48『<a href="https://www.bilibili.com/video/av15672385/?p=2">春夏秋冬</a>』中的「桃花庵 MIX」等等。这类特殊 MIX 中有一些脱颖而出，在近年得到了大规模应用，比如下面两个：由通行的三连 MIX 衍生出来的「可变三连 MIX」和另起炉灶的「混沌 MIX」。</p>

<h4 class="no_toc" id="可变三连-mix">可变三连 MIX</h4>

<blockquote>
  <p>人造ファイヤファイボワイパー！<br />
タイガー！タイガー！タタタタタイガー！<br />
チャペアペカラキナ！チャペアペカラキナ！<br />
ミョーホントゥスケ！👏 ワイパー！<br />
ファイヤー！ファイヤー！虎虎カラキナ！<br />
チャペアペファーマー！海女海女ジャスパー！<br />
虎タイガー！虎タイガー！<br />
人造繊維イエッタイガー！</p>
</blockquote>

<p>发动时间跟后面介绍的「ガチ恋口上」一模一样，属于比较进阶的 MIX 形式，实战案例比如 真っ白なキャンバス『<a href="https://www.bilibili.com/video/av29393742?t=134">SHOUT</a>』、なんキニ！『<a href="https://www.bilibili.com/video/av40742210?t=207">僕を未来へ運ぶ列車</a>』。</p>

<h4 class="no_toc" id="混沌-mix">混沌 MIX</h4>

<blockquote>
  <p>ワー！ワー！ワールドカオス！<br />
[諸行]{しょぎょう}！[木暮]{こぐれ}！[時雨]{しぐれ}！[神楽]{かぐら}！[金剛山]{こんごうさん}！[翔襲叉]{しょうしゅうしゃ}！<br />
[黒雲]{こくうん}！[無常]{むじょう}！[世界混沌]{せかいこんとん}！</p>
</blockquote>

<p>大喊数声「ワー」发动，因为经常是合着歌词一起喊，所以比起 MIX 其实更像 CALL，实战案例比如 26時のマスカレイド『<a href="https://www.bilibili.com/video/av39760291?t=500">ハートサングラス</a>』『<a href="https://www.bilibili.com/video/av39760291?t=769">チャプチャパ</a>』。</p>

<h3 class="no_toc" id="口上">口上</h3>

<blockquote>
  <p>言いたいことがあるんだよ！<br />
やっぱり〇〇〇はかわいいよ！<br />
好き好き大好きやっぱ好き！<br />
やっと見つけたお姫様！<br />
俺が生まれてきた理由！<br />
それはお前に出会うため！<br />
俺と一緒に人生歩もう！<br />
世界で一番愛してる！<br />
ア！イ！シ！テ！ル！</p>
</blockquote>

<p>最常见的口上是上面这个「ガチ恋口上」，翻译过来是真爱口述的意思，前奏、间奏、尾奏都有可能发动，其与 MIX 的不同在于口上的内容是有实际意义的。口上走出地下为大众所知的歌曲大概是 AKB48 Team 8 队歌『<a href="https://www.bilibili.com/video/av3514790">47の素敵な街へ</a>』了，BEJ48 粉丝还在剧场公演曲『<a href="https://www.bilibili.com/video/av5941977">恋爱中的美人鱼</a>』中喊出了中文版，堪称 MIX 本土化的典范：</p>

<blockquote>
  <p>有一些 心里话 想要说给你！<br />
〇〇〇 就是你 最可爱的你！<br />
喜欢你 喜欢你 就是喜欢你！<br />
翻过山 越过海 你就是唯一！<br />
有了你 生命里 全都是奇迹！<br />
失去你 不再有 燃烧的意义！<br />
让我们 再继续 绽放吧生命！<br />
全世界所有人里我最喜欢你！<br />
我！最！喜！欢！你！</p>
</blockquote>

<h3 class="no_toc" id="肢体动作">肢体动作</h3>

<p>地下偶像公演中，因为大家普遍不带荧光棒，所以肢体动作异常地多。虽然每首歌都有各自的套路，但仍有一个放之四海而皆准的做法，就是模仿台上偶像的动作，这被称为「振りコピ」。另外还有几个常见的动作，比如上升气流（ケチャ）和开花掌（咲きクラップ），前者是向前伸出双臂慢慢上举，后者是在面前击掌然后慢慢张开，两者一般都出现在节奏舒缓、伴奏音量下降以突出人声的部分，比如「落ちサビ」。前者之所以在日本叫做 Kecak，是因为这个动作特别像巴厘岛的传统舞蹈 <a href="https://www.youtube.com/watch?v=eLc2cS8JZx4">Kecak</a>。「推しジャンプ」也非常常见，就是你推的成员在唱歌的话就不停地跳，但注意有的地方是禁止这种行为的。还有一些行为是大多数公演都禁止的，建议见到了也不要模仿，比如托举（リフト）、跳水（ダイブ）、狂舞（モッシュ）等。</p>

<h4 class="no_toc" id="御宅艺">御宅艺</h4>

<p>御宅艺（ヲタ芸）是在动画歌曲或偶像歌曲演唱途中，粉丝在台下跳的一系列独特的舞蹈，拿着大闪打的又叫荧光棒舞，空手打的又叫地下艺。因为很多演唱会或剧场公演都没有空间来打御宅艺，我本人也不会（笑），这里就不详细介绍了。有兴趣的话可以参考 B 站上夜瞑的攻略手册<a href="https://www.bilibili.com/video/av2708165">新手入门篇</a>、<a href="https://www.bilibili.com/video/av2835230">第二篇</a>、<a href="https://www.bilibili.com/video/av2975940">完结篇</a>、<a href="https://www.bilibili.com/video/av4842571">技巧理论篇</a>，無用男的<a href="https://www.bilibili.com/video/av20845434">地下艺教学</a>。</p>

<h3 class="no_toc" id="厄介">厄介</h3>

<p>偶像圈有一个常用语叫做「[厄介]{やっかい}」，这个词跟「[迷惑]{めいわく}」基本上是一个意思，在日语中都是指给别人添麻烦的行为，比如在公演中瞎搞。当然，不同场合的评价尺度不尽相同：在大众歌手的演唱会中，CALL 就已经有点厄介了；而在动画歌曲或坂道系列的演唱会中，CALL 不算厄介，MIX 才算；而在 AKB48 剧场公演中，MIX 也很正常，但以家虎为代表的地下偶像玩法还是比较厄介的；在会出警的地下偶像公演中，CALL / MIX 喊成什么样都没事，只要肢体动作别太过分就行；而一些地下偶像的野外公演，那跟摇滚现场就没区别了。</p>

<p>下面介绍几种厄介行为，供大家批判：</p>

<ul>
  <li><a href="https://www.bilibili.com/video/av11395298">在動漫演唱會中當厄介 入門五招</a></li>
  <li><a href="https://www.bilibili.com/video/av39762269">【普通话对白】四十七条丢人的街【请勿模仿】</a></li>
  <li><a href="https://www.bilibili.com/video/av28649557">在TIF2018上的夜明けBrand New Days，神回啊</a></li>
</ul>

<h3 class="no_toc" id="参考资料">参考资料</h3>

<ul>
  <li><a href="https://www.bilibili.com/video/av37629245">打call很難嗎?從沒有概念到會一點點的基礎打call講座</a></li>
  <li><a href="https://www.bilibili.com/video/av38193288">★流行MIX大集合★ 簡單介紹2018年還會在各大偶像現場常聽到的call和MIX 卍最強鬼叫攻略卍</a></li>
  <li><a href="https://www.youtube.com/watch?v=BSmiGblnlOk">アイドルのコール・ＭＩＸを集めてみたぉ</a></li>
  <li><a href="https://www.youtube.com/watch?v=CPJx79ISf_o">最近よく耳にする可変MIX BEST3</a></li>
  <li><a href="https://ameblo.jp/aoirokucho/entry-12274207791.html">アイドルライブ MIX・コール・口上・ヲタ芸・縛り まとめ</a></li>
  <li><a href="https://shikaku-kenkyujyo.com/maneki-kecak/mix/">まねきケチャのMIX・コール解説</a></li>
  <li><a href="http://www.popidolblog.com">地下アイドルコールまとめ</a></li>
</ul>

<hr />

<h2 id="握手会">握手会</h2>

<p>握手会制度是日系偶像的代表性标志，如果要判断一个团体是不是日系偶像，当今最行之有效的方法应该就是看她们握不握手了。</p>

<p>AKB48 GROUP 和坂道系列的制度相似，握手会分为「全国握手会」和「個別握手会／大握手会」，分别简称全握和个握。全握就像列车的自由席，只要购买对应唱片的初回限定盘即可参加，通常开场前还会有一场 MINI LIVE，但排队时间极长、握手时间极短；个握就像列车的指定席，需要事先在网上指定成员和时间段，每档都有限额所以要进行多轮抽选。</p>

<p>具体以 AKB48 为例，其单曲 CD 可以分为三类：</p>

<ul>
  <li>初回限定盘（Type 〇，CD+DVD，¥1646）内封全握券；</li>
  <li>通常盘（Type 〇，CD+DVD，¥1646）内封生写真；</li>
  <li>剧场盘（CD，¥1028）在 <a href="https://akb48.chara-ani.com">キャラアニ</a> 抽选购入，外附生写真和个握券。</li>
</ul>

<p>AKB48 的个握花样比较多，除了普通的握手还有「[签名会]{サイン会}」「[合影会]{２ショット写真会}」「[摄影会]{１ショット動画会}」等等，可以自由挑选喜欢的项目进行抽选。一些大握手会现场会同时举办「[特别舞台庆典]{スペシャルステージ祭り}」，通常是时长一刻钟的 MINI LIVE，和握手项目一样需要预约抽选。另外「[加推]{推し増し}」制度允许当天所有券都能在规定的时间段（每部 90 分钟的正中间 30 分钟）任选当天官方规定的成员（基本上是没卖完的成员），但只能握手不能参加特别的活动。根据以往经验，东京都市圈的场地一般都在神奈川县的[横滨国际平和会议场]{パシフィコ横浜}或千叶县的[幕张展览馆]{幕張メッセ}，从东京都心出发都差不多一个小时到达。</p>

<p><img src="/images/idol-02.jpg" alt="" /></p>

<p>中国国内的话，因为参加握手会的人数没有日本那么夸张，所以 SNH48 GROUP 没有分自由席和指定席两套制度，只要买了唱片就能凭券参加握手会，不过也时常有握手会规定部分人气成员需要网上预约时间段获取二维码。另外需要注意的是，有时丝芭会用「全握」一词表示每张券可以跟出席握手会的全体成员握手一次，相应地会把普通的握手叫做「单握」，这跟 AKB48 的全握和个握是不同的术语体系。丝芭官方商城每买 10 张（投票单要 20 张）唱片将随机赠送一张签名券或合影券，两券交替赠送；另有官方应援护照，凡参加官方活动即可敲章，比如北广集齐 100 个章可以跟全队 16 名成员合影等等。</p>

<p>地下偶像的话，当然办不起专场握手会，因此通常是在公演之后跟物贩一起举行特典会。不同的偶像团体规则不同，但大体上都是终演后运营出来摆摊卖特典券，最常见的特典是[拍立得]{チェキ}合影加签名。如果参加的是拼盘，有时候工作人员会在检票时问你是来看哪个团的，然后会给你发这个团的物贩优先购入券。</p>

<h2 id="综艺">综艺</h2>

<p>前面说了这么多，然而也不是每个人都有机会到现场参加活动，对于屏幕饭来说综艺便成了深入了解成员们的唯一途径。秋元康担任制作人的偶像团体基本上都能拿到不错的资源，在电视上播放的冠番也很多，比如在 NHK BS Premium 播出就有 <a href="https://www4.nhk.or.jp/akb48show/">AKB48 SHOW!</a> 和 <a href="https://www4.nhk.or.jp/P4329/">乃木坂46的学旅！</a>。</p>

<p>在日本，只有 NHK 有资格进行全国放送，各地的民间放送局需要组建电视联播网才能覆盖全国，因此民放形成了五大电视网外加一批独立放送局的局面。譬如在东京，能够收到的地上放送共有八个台：① NHK 综合；② NHK 教育；［③ 未使用；］④ 日本电视台；⑤ 朝日电视台；⑥ TBS 电视台；⑦ 东京电视台；⑧ 富士电视台；⑨ 东京都会电视台（TOKYO MX，独立局）。除了依赖东京晴空塔的地上放送之外，还有放送卫星（BS）和通信卫星（CS）两种卫星放送方式，可以收看到额外的免费和付费电视频道。</p>

<p>以下是目前东京地区地上放送的偶像常规番组（日本标准时间，三十小时制）：</p>

<ul>
  <li><a href="http://www.ntv.co.jp/AKBINGO/">AKBINGO!</a>（日本电视台，周二 24:59，MC: Woman Rush Hour）</li>
  <li>宾果系列（日本电视台，周一 25:29）：<a href="http://www.ntv.co.jp/NOGIBINGO/">NOGIBINGO!</a>（MC: 冈田昇）、<a href="http://www.ntv.co.jp/KEYABINGO/">KEYABINGO!</a>（MC: 三明治人）、<a href="http://www.ntv.co.jp/SETOBINGO/">SETOBINGO!</a>（MC: 枫叶超合金）、<a href="http://www.ntv.co.jp/HKTBINGO/">HKTBINGO!</a>（MC: 指原莉乃、三四郎）、<a href="https://www.ntv.co.jp/SKEBINGO/">SKEBINGO!</a>（MC: 三四郎）</li>
  <li><a href="https://www.tv-aichi.co.jp/nogi-kou/">乃木坂工事中</a>（东京电视台，周日 24:00，MC: 香蕉人）</li>
  <li><a href="https://www.tv-tokyo.co.jp/keyaki/">欅会不会写？</a>（东京电视台，周日 24:35，MC: 土田晃之、泽部佑）</li>
  <li><a href="https://www.tv-tokyo.co.jp/hiraganaoshi/">平假名推</a>（东京电视台，周日 25:05，MC: 奥黛丽）</li>
  <li><a href="https://www.tv-tokyo.co.jp/yoshimotozaka/">吉本坂46爆红前的全记录</a>（东京电视台，周二 26:05，MC: 东野幸治、松村沙友理、古川洋平）</li>
  <li><a href="https://www.nanabunnonijyuuni.com/tv/">22/7 计算中</a>（东京都会电视台，周六 23:00，MC: 三四郎）</li>
  <li><a href="https://www.tv-asahi.co.jp/last-idol/">最后的偶像</a>（朝日电视台，周六 24:10，MC: 小木矢作）</li>
  <li><a href="https://www.tv-asahi.co.jp/momocloch/">桃草 Chan</a>（朝日电视台，周二 26:24）</li>
</ul>

<h2 id="结语">结语</h2>

<p>最后的最后，祝正在大阪巨蛋举行毕业演唱会的西野七濑女士毕业快乐！</p>

<p><img src="/images/idol-03.jpg" alt="" /></p>

<script src="https://code.jquery.com/jquery-3.5.1.min.js"></script>

<script>
  collapsed = $('#call--mix');
  collapsed.click(function() {
    $(this).children().toggleClass('fa-caret-up').toggleClass('fa-caret-down');
    $(this).nextUntil('hr').toggle();
  });
  collapsed.append(' <i class="fa fa-caret-up"></i>');
  collapsed.trigger('click');
</script>]]></content><author><name>孙耀珠</name></author><category term="杂谈" /><summary type="html"><![CDATA[刚刚过去的 2018 年，被一些媒体称为「中国偶像元年」，这一年里爱奇艺的《偶像练习生》和腾讯的《创造101》将偶像和粉丝经济的概念一下子推向了主流文化。很遗憾没有去萧山现场看《创造101》的录制，不过我去钱江世纪公园看了首届天猫亚洲偶像嘉年华（AIF2018），明显感觉在两大偶像综艺繁荣的背后，中国国内所谓的养成系偶像还不成气候。一般来说持续数日或者多舞台同时演出才称得上是音乐节，单日单舞台的 AIF 也就是地下偶像拼盘的水平吧，演出阵容和规模远不如日本的 TIF。不过在 AIF 上见证了 AKB48 Team SH 的首次公开亮相，我还是非常兴奋的，毕竟也算是跻身上海队的古参饭了。 对「偶像」这个词的理解，一千个读者眼中就有一千个哈姆雷特。在日本虽然也有汉语词「偶像」，但只有崇拜对象之意；我们现在讨论的偶像则为「IDOL」的音译词「アイドル」。虽然「IDOL」仍然是偶像的意思，但经过音译的转换已经退化为了表音符号；因此在当代日语的语境中，「アイドル」并不会自然而然地跟崇拜对象之意关联起来，而是被赋予了新的含义：与粉丝分享成长过程、以其存在本身为魅力的人物（参见维基百科）。请允许我将这个语义下的偶像称为日系偶像，本文便是个人对日系偶像领域的国内外研究现状综述，其中也结合了我在东京一年的见学体验以及赴北上广参加偶像活动的经历。]]></summary></entry><entry><title type="html">幽灵・熔毁・预兆</title><link href="https://blog.yzsun.me/spectre-meltdown-foreshadow/" rel="alternate" type="text/html" title="幽灵・熔毁・预兆" /><published>2019-01-31T00:00:00+00:00</published><updated>2019-01-31T00:00:00+00:00</updated><id>https://blog.yzsun.me/spectre-meltdown-foreshadow</id><content type="html" xml:base="https://blog.yzsun.me/spectre-meltdown-foreshadow/"><![CDATA[<p><img src="/images/spectre-meltdown-foreshadow-00.png" alt="" /></p>

<p>去年肆虐了一年的幽灵系列漏洞似乎已经风平浪静了，但实际上它们对 CPU 微架构和系统软件领域依然有着长久而深远的影响。幽灵系列漏洞针对的并不是某个具体的硬件缺陷，而是将矛头对准了分支预测和乱序执行这两个现代 CPU 普遍采用的优化策略，并通过缓存旁路攻击完成对机密数据的任意读取，通用性极强，也极难做到全面的防御。本文将从幽灵系列漏洞的原理入手，介绍它们对现代计算机系统产生的影响和目前可行的对策。</p>

<!--more-->

<p>幽灵系列漏洞截至目前至少已有十种变体被通用漏洞披露（CVE）数据库收录，亦有未被单独收录的 SpectreRSB（USENIX WOOT 2018）、ret2spec（ACM CCS 2018）等攻击被陆续发现。</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>漏洞编号</th>
      <th>代号</th>
      <th>正式名称</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Variant 1</td>
      <td>CVE-2017-5753</td>
      <td>Spectre-V1</td>
      <td>Bounds Check Bypass</td>
    </tr>
    <tr>
      <td>Variant 2</td>
      <td>CVE-2017-5715</td>
      <td>Spectre-V2</td>
      <td>Branch Target Injection</td>
    </tr>
    <tr>
      <td>Variant 3</td>
      <td>CVE-2017-5754</td>
      <td>Meltdown</td>
      <td>Rogue Data Cache Load</td>
    </tr>
    <tr>
      <td>Variant 3a</td>
      <td>CVE-2018-3640</td>
      <td>Spectre-NG</td>
      <td>Rogue System Register Read</td>
    </tr>
    <tr>
      <td>Variant 4</td>
      <td>CVE-2018-3639</td>
      <td>Spectre-NG</td>
      <td>Speculative Store Bypass</td>
    </tr>
    <tr>
      <td>-</td>
      <td>CVE-2018-3665</td>
      <td>LazyFP</td>
      <td>Lazy FP State Restore</td>
    </tr>
    <tr>
      <td>-</td>
      <td>CVE-2018-3693</td>
      <td>Spectre 1.1</td>
      <td>Bounds Check Bypass Store</td>
    </tr>
    <tr>
      <td>-</td>
      <td>CVE-2018-3615</td>
      <td>Foreshadow</td>
      <td>L1 Terminal Fault - SGX</td>
    </tr>
    <tr>
      <td>-</td>
      <td>CVE-2018-3620</td>
      <td>Foreshadow-NG</td>
      <td>L1 Terminal Fault - OS/SMM</td>
    </tr>
    <tr>
      <td>-</td>
      <td>CVE-2018-3646</td>
      <td>Foreshadow-NG</td>
      <td>L1 Terminal Fault - VMM</td>
    </tr>
  </tbody>
</table>

<h2 id="熔毁meltdown">熔毁（Meltdown）</h2>

<p>熔毁漏洞又称幽灵变体三，是这一系列漏洞中最容易利用、也最为人所知的一个。它由来自 Google Project Zero、德国 Cyberus 技术有限公司和奥地利格拉茨科技大学的三个团队各自独立地发现，论文发表在 USENIX Security 2018 上。要解释清楚熔毁漏洞的原理，需要综合三方面的知识：虚拟内存、乱序执行、基于缓存的旁路攻击。</p>

<p>我们都知道，现代的操作系统都应用了<strong>虚拟内存</strong>（Virtual Memory）技术，也就是说每个进程都拥有自己的虚拟地址空间，操作系统会根据页表将这些虚拟地址映射到物理地址。虚拟地址空间通常划分为用户和内核两部分，应用程序只能访问各自的用户地址空间，而只有在内核态下才能触及内核地址空间。为了进行访问权限控制，页表项中会有一个 User/Supervisor 位用来指定用户态能否访问，起到了隔离用户空间和内核空间的作用。在 Linux 和 macOS 等主流操作系统中，为了方便系统的访存操作，整个物理内存会直接映射到一部分内核空间上。而熔毁漏洞的目标便是攻破上述安全防线，在用户态也能任意访问所有物理内存。</p>

<p>为了达到目的，熔毁漏洞选择了从处理器的微架构着手攻击。现代处理器普遍采用了指令级并行技术来最大程度地发挥计算性能，其中一个特性便是<strong>乱序执行</strong>（Out-of-Order Execution）。在支持乱序执行的处理器上，所有指令在译码后将发往保留站（Reservation Station），一旦操作数就绪指令即可执行，不管先来后到。不过为了保证程序的正确性，指令执行的结果将以取指顺序写回程序员可见的寄存器，这被称为顺序提交（In-Order Commit）。而处理器的错误检测和异常处理都在提交阶段进行，如果发生异常则清空流水线并恢复原来的状态。</p>

<p><strong>旁路攻击</strong>又称侧信道攻击（Side-Channel Attack），指绕开对加密算法的理论分析，而利用其硬件实现泄露的信息来进行攻击，譬如用时、功耗、电磁辐射等。在熔毁漏洞的例子中，缓存充当了攻击的旁路，基于缓存的 Flush+Reload 攻击击溃了乱序执行的最后一道防线。简单来讲，Flush+Reload 攻击首先利用 <code class="language-plaintext highlighter-rouge">clflush</code> 等指令预先清空缓存，再等待受害程序进行访存操作，然后通过数据访问的用时来判断某段数据在此期间是否被受害程序访问过。因为访问过的数据会载入缓存，所以下一次访问的速度会是第一次的两倍以上。如果没有权限调用 <code class="language-plaintext highlighter-rouge">clflush</code> 等指令，也可以手动访问大量无关数据来达到清空缓存的目的，这种变体被称为 Evict+Reload 攻击。</p>

<p>现在我们已经集齐了三片拼图，是时候把它们组合起来了。首先我们构造这样一段代码：</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">char</span> <span class="n">data</span> <span class="o">=</span> <span class="o">*</span><span class="p">(</span><span class="kt">char</span><span class="o">*</span><span class="p">)</span><span class="mh">0xffffffff81a000e0</span><span class="p">;</span>
<span class="kt">char</span> <span class="n">tmp</span> <span class="o">=</span> <span class="n">array</span><span class="p">[</span><span class="n">data</span> <span class="o">*</span> <span class="mi">4096</span><span class="p">];</span>
</code></pre></div></div>

<p>我们可以看到第一行访问了一个内核空间的地址，理论上会因无权访问而触发段错误，从而中止程序的运行。然而我们之前提到错误检查是在提交阶段才进行的，于是第二行的代码有很大概率会在读到 <code class="language-plaintext highlighter-rouge">data</code> 之后到触发异常之前的时间窗口内提前执行。虽然这种乱序执行最终不会对寄存器有任何可见的影响，但容易被忽略的一点是，<code class="language-plaintext highlighter-rouge">array</code> 的部分数据被载入了缓存。虽然我们无法读取缓存中的数据，但我们可以通过 Flush+Reload 攻击来判断是 <code class="language-plaintext highlighter-rouge">array</code> 的哪部分被载入了缓存，从而得知 <code class="language-plaintext highlighter-rouge">data</code> 的值是多少。比如我们可以根据下图的用时曲线推断出 <code class="language-plaintext highlighter-rouge">data = 84</code>：</p>

<p><img src="/images/spectre-meltdown-foreshadow-01.png" alt="" /></p>

<p>像这样在极短的时间窗口内留下副作用的指令，在论文中被称为<strong>暂态执行</strong>（Transient Execution），这也是幽灵系列漏洞的核心技术。因为不少主流操作系统都在内核空间中直接映射了物理内存，所以通过暂态执行和缓存旁路攻击能够提取物理内存中的所有数据，危害性极强。</p>

<h3 id="影响范围">影响范围</h3>

<table>
  <thead>
    <tr>
      <th>厂商</th>
      <th>产品</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Intel</td>
      <td>几乎所有在售的 CPU</td>
    </tr>
    <tr>
      <td>AMD</td>
      <td>未受影响</td>
    </tr>
    <tr>
      <td>ARM</td>
      <td>仅 Cortex-A75 受到影响</td>
    </tr>
    <tr>
      <td>IBM</td>
      <td>z/Architecture 和 Power 架构均受影响</td>
    </tr>
    <tr>
      <td>Apple</td>
      <td>所有在售的 Mac 和 iOS 设备</td>
    </tr>
  </tbody>
</table>

<h3 id="对策">对策</h3>

<ul>
  <li>
    <p>硬件：重新设计 CPU 以确保在发射读取指令之前进行权限检查，英特尔已于 Coffee Lake Refresh 及后续微架构中修补熔毁漏洞，但之前的 CPU 就只能软件修补了；</p>
  </li>
  <li>
    <p>软件：各大操作系统都推出了内核页表隔离补丁来抵御熔毁漏洞：</p>

    <ul>
      <li>Linux 4.15 已部署 Kernel Page-Table Isolation；</li>
      <li>Windows 10 build 17035 已部署 KVA Shadow；</li>
      <li>macOS 10.13.2 / iOS 11.2 已部署 Double Map；</li>
      <li>……</li>
    </ul>
  </li>
</ul>

<h2 id="内核页表隔离">内核页表隔离</h2>

<p>说起 Linux 这次针对熔毁漏洞的内核页表隔离补丁，其背后还有一段不短的历史，最早要从 Linux 的内核地址空间布局开始说起。</p>

<h3 id="kaslr">KASLR</h3>

<p>最早 Linux 的内核映像在地址空间中的地址是固定的，这使黑客能够硬编码地址对 Linux 进行攻击。为了使这类攻击不容易奏效，Linux 3.14 引入了<strong>内核地址空间布局随机化</strong>（Kernel Address Space Layout Randomization），也就是说在每次系统启动时可以随机生成一个内核映像地址的偏移量，不过直到 Linux 4.12 开始 KASLR 才被默认开启。</p>

<h3 id="kaiser">KAISER</h3>

<p>虽然 KASLR 增加了攻击的难度，但不能杜绝黑客访问到内核映像。2017 年，格拉茨科技大学的研究人员提出了<strong>高效移除旁路的内核地址隔离</strong>（Kernel Address Isolation to have Side-channels Efficiently Removed）补丁来进一步加固，恰好这个补丁对后来的熔毁漏洞也十分有效。KAISER 提议内核态和用户态使用两张不同的页表，内核态的页表还跟原来一样，而用户态的页表中不再暴露内核地址空间，除了少量 x86 架构必需的部分。不过其缺点也很明显，切换页表和清空转译后备缓冲器（Translation Lookaside Buffer）带来了不少额外的性能开销。</p>

<h3 id="kpti">KPTI</h3>

<p>在得知熔毁漏洞之后，Linux 社区开始着手从软件层面进行修补。开发团队在 KAISER 的基础上加入了一些优化，譬如支持进程上下文标识符（Process-Context Identifier）以避免清空页表缓存从而降低性能影响，并将其改名为<strong>内核页表隔离</strong>（Kernel Page-Table Isolation）最终并入了 Linux 4.15。</p>

<h2 id="幽灵spectre">幽灵（Spectre）</h2>

<p>幽灵漏洞的影响范围比熔毁漏洞更加广泛，影响当下几乎所有计算机系统；不过幽灵漏洞相较而言更难利用，因为它需要被攻击的软件中包含特定形式的可利用代码。</p>

<p>幽灵漏洞的核心也是暂态执行，暂态执行除了前面叙述的乱序执行之外还有其他的触发方式，而幽灵的论文中便提到了两种，这两种均与分支预测有关。因为分支指令可能涉及内存读取，需要上百个时钟周期才能完成，因此现代处理器都设计了分支预测器来预先推测执行。一个解耦的分支预测器通常包含两个部分：</p>

<ul>
  <li>分支方向预测器：预测分支的条件是真是假，通常利用模式历史表（Pattern History Table）等进行预测；</li>
  <li>目标地址预测器：预测间接跳转的目标地址，通常利用分支目标缓冲器（Branch Target Buffer）等进行预测。</li>
</ul>

<p>幽灵漏洞的步骤与熔毁漏洞类似，也是通过缓存旁路攻击来获取暂态执行泄漏的信息。在攻击之前，通常还会训练分支预测器，使其运行目标代码时会进行特定的预测执行；同时可以把条件判断所需的数据挤出缓存，以提高预测执行发生的概率。</p>

<h3 id="变体一边界检查绕过">变体一：边界检查绕过</h3>

<p>我们设想系统调用或库中有这样一段代码：</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="p">(</span><span class="n">x</span> <span class="o">&lt;</span> <span class="n">len</span><span class="p">(</span><span class="n">array1</span><span class="p">))</span>
    <span class="n">y</span> <span class="o">=</span> <span class="n">array2</span><span class="p">[</span><span class="n">array1</span><span class="p">[</span><span class="n">x</span><span class="p">]</span> <span class="o">*</span> <span class="mi">4096</span><span class="p">];</span>
</code></pre></div></div>

<p>其中 <code class="language-plaintext highlighter-rouge">x</code> 是一个外部传入的变量，所以条件语句进行了数组越界的检查。我们可以训练分支预测器让它暂态执行第二行的代码，则 <code class="language-plaintext highlighter-rouge">array1[x]</code> 可以访问任意数据，再对 <code class="language-plaintext highlighter-rouge">array2</code> 进行缓存旁路攻击即可。</p>

<p>Spectre-V1 影响几乎所有 CPU，且不仅可以在系统级编程语言中构造，在带即时编译优化的 JavaScript 引擎中亦可复现。因此 Mozilla 宣布从 Firefox 57 开始 <code class="language-plaintext highlighter-rouge">performance.now()</code> 的精度将降到 20µs，<code class="language-plaintext highlighter-rouge">SharedArrayBuffer</code> 将默认禁用。而英特尔等厂商未推出硬件解决方案，建议开发者从软件层面解决，譬如 ICC 新增 <code class="language-plaintext highlighter-rouge">-mconditional-branch=pattern-fix</code> 选项来自动插入 <code class="language-plaintext highlighter-rouge">LFENCE</code> 指令避免预测执行。</p>

<p><img src="/images/spectre-meltdown-foreshadow-02.png" alt="" /></p>

<h3 id="变体二分支目标注入">变体二：分支目标注入</h3>

<p>第二个变体则是针对间接跳转目标地址的预测，我们可以训练分支预测器对方法调用等进行错误的目标地址预测，使其暂态执行我们挑选的可利用代码，辅以缓存旁路攻击获取机密数据。</p>

<p>Spectre-V2 同样也影响几乎所有 CPU。英特尔发布了微码更新，引入了三种 Indirect Branch Control Mechanisms，可供对间接跳转预测进行限制。而谷歌工程师提出了 Retpoline，将间接跳转指令替换为返回指令，并将预测执行拖入死循环以缓解漏洞，ICC / GCC / Clang 等各大编译器均已提供支持。譬如 x86 (Intel Syntax) 中的 <code class="language-plaintext highlighter-rouge">jmp rax</code> 指令会被 Retpoline 替换为：</p>

<div class="language-nasm highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="err">1</span><span class="p">:</span>    <span class="nf">call</span> <span class="nv">set_up_target</span>
  <span class="nl">capture_spec:</span>
<span class="err">2</span><span class="p">:</span>    <span class="nf">pause</span>
<span class="err">3</span><span class="p">:</span>    <span class="nf">jmp</span> <span class="nv">capture_spec</span>
  <span class="nl">set_up_target:</span>
<span class="err">4</span><span class="p">:</span>    <span class="nf">mov</span> <span class="p">[</span><span class="nb">rsp</span><span class="p">],</span> <span class="nb">rax</span>
<span class="err">5</span><span class="p">:</span>    <span class="nf">ret</span>
</code></pre></div></div>

<p>虽然处理器对于间接跳转目标地址的预测相对复杂，容易被投毒；但对于返回指令目标地址的预测是确定的，主要依赖一个后进先出的返回栈缓冲器（Return Stack Buffer）。在上面的例子中，指令 1 会将指令 2 的地址压入 RSB 中，并直接跳转到指令 4，指令 4 会将原来间接跳转的目标地址写入调用栈中返回地址的位置，于是下一行的返回指令 5 便完成了间接跳转的工作。另一方面，如果处理器进行了预测执行，在指令 5 处它会读取 RSB 并跳转到指令 2，接下来预测执行便陷入了死循环，直到处理器意识到预测并不正确。这样一来，Retpoline 便杜绝了目标地址预测被投毒的可能性。</p>

<h2 id="预兆foreshadow">预兆（Foreshadow）</h2>

<p>在全世界计算机安全风雨飘摇的一年里，大家都在寻找更加安全的可信计算环境，而其中经常被提到的便是 SGX。SGX 全称软件保护扩展（Software Guard Extensions），是英特尔处理器的一组扩展指令集。SGX 能够在内存上创建飞地（Enclave），这块空间受到处理器的严格保护，OS / Hypervisor / BIOS 等系统软件亦无法访问，相当于一个硬件级别的沙盒。</p>

<p>可惜的是，SGX 也被熔毁漏洞的变体攻破了，这个变体被称为预兆漏洞。其流程与熔毁漏洞相似，但 SGX 的安全机制使攻击流程多了两步：</p>

<ul>
  <li>即使利用乱序执行漏洞，SGX 飞地数据也无法从内存读取，必须预先加载到 L1 缓存才能绕过限制，这也是该漏洞被英特尔官方命名为 L1 Terminal Fault 的原因；</li>
  <li>对指向 SGX 飞地的指针解引用会返回中止页（Abort Page）使得结果为 -1，而不像之前因访问内核空间而缺页（Page Fault）。为了绕开这个限制，需要调用 <code class="language-plaintext highlighter-rouge">mprotect</code> 函数将页表项的 Present 位设为无效，从而提前在传统页表检查时便抛出缺页。</li>
</ul>

<p>预兆漏洞影响英特尔所有支持 SGX 的 CPU，即 Skylake 及其后续微架构，Atom 系列除外。英特尔已经发布了微码更新，后续 CPU 也将进行硬件修复。</p>

<h2 id="系统性分类">系统性分类</h2>

<p>随着幽灵系列漏洞如雨后春笋般不断涌现，格拉茨科技大学的研究人员又撰写了论文对暂态执行攻击进行了系统性分类和梳理分析。首先论文以暂态执行的成因将攻击分为幽灵和熔毁两大类：前者是预测执行的误判，后者是乱序执行对异常的延时处理。</p>

<p>对于幽灵类的攻击，论文以预测执行所依赖的处理器元件进行分类：</p>

<ul>
  <li>Spectre-PHT：利用的是进行分支方向预测的模式历史表，包括 Spectre-V1、Spectre 1.1、NetSpectre；</li>
  <li>Spectre-BTB：利用的是进行目标地址预测的分支目标缓冲器，包括 Spectre-V2；</li>
  <li>Spectre-RSB：利用的是进行返回地址预测的返回栈缓冲器，包括 SpectreRSB、ret2spec；</li>
  <li>Spectre-STL：利用的是进行内存依赖预测的 store-to-load 转发，包括 Spectre-V4。</li>
</ul>

<p>对于熔毁类的攻击，论文首先以异常类型分类，如果利用的异常是缺页则再基于页表项的属性位进行二级分类：</p>

<ul>
  <li>Meltdown-GP：使用 <code class="language-plaintext highlighter-rouge">RDMSR</code> 等指令非法读取系统寄存器会触发一般保护错误（General Protection Fault，#GP），利用这个异常进行暂态执行的攻击是 Spectre-V3a；</li>
  <li>Meltdown-NM：FPU 的 SIMD 寄存器很大，然而不是所有进程都会用到它们，所以基于性能考虑，英特尔处理器没有在每次上下文切换的时候都保存和恢复这些寄存器。当新的进程第一次访问这些寄存器时，会触发设备不可用错误（Device Not Available，#NM），此时才会将它们保存进上一个进程的上下文中。利用这个异常进行暂态执行的攻击是 LazyFP；</li>
  <li>Meltdown-BR：现代处理器通常支持 <code class="language-plaintext highlighter-rouge">BOUND</code> 指令来进行数组越界的检查，更新的还有英特尔的扩展指令集 MPX，它们都会在数组越界时触发越界错误（BOUND Range Exceeded，#BR），论文在英特尔和 AMD 的处理器上成功实施了基于该异常的攻击；</li>
  <li>Meltdown-PF：基于缺页（Page Fault，#PF）的暂态执行攻击；
    <ul>
      <li>Meltdown-P：如前文所述，Foreshadow 和 Foreshadow-NG 为了绕开 SGX 的限制，通过将页表项的 Present 位设为无效引发缺页；</li>
      <li>Meltdown-RW：Spectre 1.2 指出可以在暂态执行期间无视 Read/Write 位对只读数据进行写入；</li>
      <li>Meltdown-US：元祖 Meltdown，利用了 User/Supervisor 位引发的缺页；</li>
      <li>Meltdown-PK：英特尔的 Skylake-SP 服务器处理器支持了 Memory Protection Keys for Userspace，可在用户空间更改页的权限，但论文提出该权限控制可通过暂态执行绕过。</li>
    </ul>
  </li>
</ul>

<p>论文中还分析了一些可能存在但实际上未能成功的攻击种类，在这里就不一一赘述了。</p>

<p><img src="/images/spectre-meltdown-foreshadow-03.png" alt="" /></p>

<h2 id="参考论文">参考论文</h2>

<ol>
  <li>Paul Kocher, et al. <a href="https://spectreattack.com/spectre.pdf">Spectre Attacks: Exploiting Speculative Execution</a>. 40th IEEE Symposium on Security and Privacy. San Francisco, USA. May 20-22, 2019.</li>
  <li>Moritz Lipp, et al. <a href="https://meltdownattack.com/meltdown.pdf">Meltdown: Reading Kernel Memory from User Space</a>. 27th USENIX Security Symposium. Baltimore, USA. August 15-17, 2018.</li>
  <li>Van Bulck, et al. <a href="https://foreshadowattack.eu/foreshadow.pdf">Foreshadow: Extracting the Keys to the Intel SGX Kingdom with Transient Out-of-Order Execution</a>. 27th USENIX Security Symposium. Baltimore, USA. August 15-17, 2018.</li>
  <li>Claudio Canella, et al. <a href="https://arxiv.org/pdf/1811.05441">A Systematic Evaluation of Transient Execution Attacks and Defenses</a>. arXiv:1811.05441.</li>
  <li>Daniel Gruss, et al. <a href="https://gruss.cc/files/kaiser.pdf">KASLR is Dead: Long Live KASLR</a>. 9th International Symposium on Engineering Secure Software and Systems. Bonn, Germany. July 4-5, 2017.</li>
  <li>Daniel Gruss, et al. <a href="https://www.usenix.org/system/files/login/articles/login_winter18_03_gruss.pdf">Kernel Isolation: From an Academic Idea to an Effective Patch for Every Computer</a>. USENIX ;login: Winter 2018.</li>
  <li>Oliverio J. Santana, et al. <a href="http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.2.4047&amp;rep=rep1&amp;type=pdf">A Comprehensive Analysis of Indirect Branch Prediction</a>. 4th International Symposium on High Performance Computing. Kansai Science City, Japan. May 15-17, 2002.</li>
</ol>]]></content><author><name>孙耀珠</name></author><category term="文献综述" /><summary type="html"><![CDATA[去年肆虐了一年的幽灵系列漏洞似乎已经风平浪静了，但实际上它们对 CPU 微架构和系统软件领域依然有着长久而深远的影响。幽灵系列漏洞针对的并不是某个具体的硬件缺陷，而是将矛头对准了分支预测和乱序执行这两个现代 CPU 普遍采用的优化策略，并通过缓存旁路攻击完成对机密数据的任意读取，通用性极强，也极难做到全面的防御。本文将从幽灵系列漏洞的原理入手，介绍它们对现代计算机系统产生的影响和目前可行的对策。]]></summary></entry></feed>