乐者为王

Do one thing, and do it well.

checked/unchecked应该翻译成什么?

翻译有关Java异常的文章时,总是犹豫是否该把checked/unchecked也翻译过来。原因是,不是很清楚该如何优雅传神地翻译这两个单词。

《Java核心技术》将它们翻译成“已检查/未检查”。《Java编程思想》和《Effictive Java中文版》则翻译成“被检查的/不检查的”。至于技术文章的翻译更是花样百出,有“检测/非检测”、“可检测/非检测”、“可查/不可查”、“受查/非受查”、“检查型/非检查型”、“检查/非检查”等。

到底该翻译成什么呢?在回答这个问题前,让我们先确定什么是checked/unchecked异常?

exception-hierarchy

上图是Java中的异常层次结构图。Java语言规范将派生自RuntimeException类和Error类的所有异常称为“unchecked异常”,其它的异常称为“checked异常”。

The unchecked exception classes are the run-time exception classes and the error classes.

The checked exception classes are all exception classes other than the unchecked exception classes. That is, the checked exception classes are Throwable and all its subclasses other than RuntimeException and its subclasses and Error and its subclasses.

并且,在编译时编译器会检查程序是否为所有的“checked异常”提供处理器。

This compile-time checking for the presence of exception handlers is designed to reduce the number of exceptions which are not properly handled.

从上述的描述可以得出,“checked异常”和“unchecked异常”是两种异常类型,且“checked异常”隐含有必须要检查的思想。

紧紧围绕这些描述,细细地思考和比较,个人认为:1. 《Java核心技术》的翻译存在问题,“已检查”和“未检查”说明的是异常的检查状态,没有表达出异常的分类这个概念。2. 《Java编程思想》和《Effictive Java中文版》的翻译则正确地表达了异常的分类,但“被检查”翻译的有点无厘头,如果能改成“要检查”则会更好,缺陷是连接“异常”这个词组后是短语,而非名词,读来费劲,也不上口;如果去掉“的”的话,后者会有歧义,听起来像是命令。3. “检测/非检测”和“检查/非检查”是同个意思。4. “可检测”这个翻译看上去似乎表示异常是可以检查的,和Java语言规范要求的该类异常必须要检查不符。5. “可查/不可查”也是如此。6. “受查/非受查”的翻译则有些莫名其妙的感觉。7. “检查型/非检查型”翻译的很好,既表达了异常的分类,也表达了一种异常是要检查的,另一种异常是不要检查的意义,只是前者还缺少点强制的意味。

分析到这里,结果已经是不言而明。“要检查的/不检查的”和“检查型/非检查型”是两种更好的翻译,都能把Java语言规范对checked/unchecked异常的描述尽量地表述出来。而后者在实际使用中更为简洁适宜。

接下来的事情就是把以前译文中未翻译的checked/unchecked修改成“检查型/非检查型”。在以后的翻译中也继续使用这个翻译结果,除非能找到更好的表述方式。

费曼技巧:最好的学习方法

英文原文:https://www.farnamstreetblog.com/2012/04/learn-anything-faster-with-the-feynman-technique/

费曼技巧有4个简单的步骤,我将在下面解释它们:

  • 选择一个概念
  • 把它教给某个小孩
  • 识别薄弱环节,回到原始材料
  • 回顾和简化(可选)

如果你不学习就会固步自封。那么,学习新主题并识别现有知识的薄弱环节的最好方式是什么?

两种类型的知识

两种类型的知识,我们大多数人关注错误的那种。第一类知识注重知道某事物的名称。第二类注重知道某事物。它们不是一回事。著名的诺贝尔物理学奖获得者理查德·费曼(Richard Feynman)明白知道某事物和知道某事物的名称之间的差异,这是他成功的最重要的原因之一。事实上,他创造了一个学习公式,确保他比其他人更明白某些东西。

这被称为费曼技巧,它将帮助你更快更明白地学到东西。最重要的是,它极其容易实现。

一个人如果说他知道他在想些什么,却表达不出来,通常是他其实并不知道自己在想些什么。——莫提默·艾德勒

费曼技巧

费曼技巧有4个步骤。

步骤1:把它教给某个小孩

拿出一张白纸,在顶部写下你想要学习的主题。写出你对这个主题的了解,好像你正在把它教给某个小孩。不是你聪明的成年朋友,而是一个8岁的小孩,他刚好有足够的词汇和注意力来涵盖基本的概念和关系。

很多人倾向于使用复杂的词汇和行话来掩盖他们不明白的东西。问题是我们仅仅愚弄自己,因为我们不知道我们不明白。另外,使用行话会掩盖周围的人对我们的误解。

当你自始至终都用孩子可以理解的简单的语言写出某个想法时(提示:只用最常见的单词),你迫使自己在更深的层次上去理解这个概念,并简化想法之间的关系和连接。如果你努力,你会清楚地知道自己在哪里还有薄弱环节。这种压力很好——它预示着学习的机会。

步骤2:回顾

在第一步中,你不可避免地会遇到你的知识的薄弱环节,你忘记了某些重要的东西,或者不能解释它,或者只是很难把重要的概念联系起来。

这是宝贵的反馈,因为你已经发现你的知识的边缘。胜任力是知道你能力的极限,你刚刚已经识别出一个!

这是学习开始的地方。现在你知道在哪里会遇到困难,回到原始材料并重新学习,直到你可以用基本的术语去解释它们。

识别你的理解的边界也限制了你可能犯的错误,并增加了在应用知识时成功的机会。

步骤3:整理和简化

现在你有一套手工制作的笔记。检查它们以确保你没有错误地从原始材料中借用任何行话。将它们组织成一个丰满的简单的故事。

把它们大声地朗读出来,如果解释不直白或者听起来很混乱,这表明你在该领域的理解仍需要做些工作。

步骤4(可选):传播

如果你真的想要确保自己的理解没有任何偏差,那就把它告诉别人(理想状态是这个人对该主题知之甚少,或者就找个8岁的小孩)。对你的知识的最终考验是你将其传达给另一个人的能力。

这不仅是学习的一个妙诀,它也是一种不同的思维方式的窗口,允许你将想法分解,然后从头开始重建。(Elon Musk称它为从第一个原则思考)。这会导致对想法和概念的更深入的理解。重要的是,以这种方式解决问题,你可以在别人不知道他们自己在说什么的情况下理解这个问题。

费曼的方法直观地认为智力是一个成长的过程,这与卡罗尔·德韦克(Carol Dweck)的工作非常吻合,卡罗尔·德韦克漂亮地描述了固定型和成长型思维之间的区别

100%代码覆盖率的悲剧

英文原文:http://labs.ig.com/code-coverage-100-percent-tragedy

有趣的是,我对测试的观点正在发生变化。十五年来,我一直在宣扬TDD(测试驱动开发,或者被称为测试先行方法),或至少让开发者写些单元测试。不过,最近我发现自己更经常地说,“你为什么要写测试?”而不是“你应该写测试”。

怎么回事?

在办公室四处走走时,开发者要求我帮助他进行单元测试。看来他在使用Mockito测试以下代码时遇到了麻烦:

initialise-method

我想他是非常惊讶于我的回应:“你不需要测试。”

“但我不得不测啊!”他说。“否则如何知道这段代码是正常的?”

“这段代码很明显。没有条件,没有循环,没有转换,没有任何东西。它们只是一些普通的旧式胶水代码。”

“但没有测试,任何人都可以来修改和破坏这段代码呀!”

“看,如果那个虚构的邪恶/无知的开发者来了,破坏了这些简单的代码,如果相关的单元测试中断,你认为他会做什么?他只会删除它。”

“但是如果非要写测试怎么办?”

“在这种情况下,我将这样写测试:”

initialise-test

“但是你没有使用Mockito啊!”

“那又怎么样呢?Mockito没有帮助你。恰恰相反:它会妨碍你,并且它也不会使测试变得更易读或更简单。”

“但是我们决定使用Mockito进行所有测试!”

我:“……”

后来我碰到他,他自豪地说,他已经设法用Mockito写了测试。我明白让测试代码正常运行的心理满足感,但尽管如此,这种解决方案让我难过。

另一个例子

我加入的某个开发团队,他们对新应用程序的高代码覆盖率以及对BDD(行为驱动设计)的新发现感到兴奋。查看代码,可以发现如下的Cucumber测试:

cucumber-test

如果你以前使用过Cucumber,你就不会震惊于它所需的支持代码的数量:

cucumber-support

cucumber-support2

和所有要测试的代码:

cucumber-code

是的,一个简单的地图查找。我和这个开发者有足够的信任去直言不讳地说,“这是在浪费时间。”

“但我的老板希望我能为所有的类写测试,”他回答。

“代价是什么?”

“费用?”

“无论如何,这些测试与BDD无关。”

“我知道,但是我们决定使用Cucumber进行所有测试”

我:“……”

我明白按照自己意愿改造工具的心理满足感,但尽管如此,这种解决方案让我难过。

悲剧在哪里?

悲剧是两位聪明的开发者(我需要带他们去团队面试)浪费时间写那种测试,测试是毫无意义的,但需要后来的IG开发者维护。

悲剧是不使用正确的工具,因为没有特别好的理由,我们决定坚持不懈地使用错误的工具。

悲剧是一旦某个“良好实践”成为主流,我们似乎就忘记它是怎么来的,它的好处是什么,最主要的是,使用它的代价是什么。

如果我们只是机械地应用它而没有太多的思考,这通常意味着我们最终得到最平庸的结果,失去大部分的好处,但支付所有(甚至更多)的成本。根据我的经验,编写好的单元测试并非易事。

那么100%的代码覆盖率值得追求吗?

是的,每个人都应该实现它……在一个项目中。我认为你必须用极端的手段去了解限制是什么。

我们已经有了一个极端的大量经验:0个单元测试的项目,所以我们知道在这上面工作的痛苦。我们通常缺乏的是在另一个极端的经验:强制100%代码覆盖率和一切都是TDD的项目。单元测试(尤其是测试先行方法)是一个非常好的实践,但我们应该学习哪些测试是有用的,哪些是适得其反的。

要记住没有什么是免费的,没有什么是银弹。使用工具前请停下来想一想。

关于作者

Daniel Lebrero在IG的大数据团队担任技术架构师。拥有超过15年的Java经验和4年的Clojure经验,他现在是函数式编程的大力倡导者。可以在TwitterLinkedIn或者他的个人博客找到他。

学习新编程语言的非传统方式

英文原文:https://hackernoon.com/unconventional-way-of-learning-a-new-programming-language-e4d1f600342c

现在已经有500多种编程语言。因此,开始学习新的编程语言对你来说是很正常的。你可能知道C++和Java,但是你的工作需要Python;或者你精通Python,但是需要用Java编写代码;或者也许你想要学习这种很酷的语言只是为了扩展你的编程技能。

如果你想学习新的编程语言,你会选择哪种方式?

  • 从若干在线教程中学习
  • 或者从若干在线课程(MOOC)中学习

有些人甚至可能认为,学习新语言的最佳方式应该是这样的:

  • 学习这门新的编程语言的语法
  • 再用这门语言构建一些个人项目

有道理!这样可以确保你能够应用学习语言的语法而获得的知识。

我开发过20多个迷你项目,同时学习不同的语言。相信我,当你为个人项目编写代码的时候,不管这些项目是周末项目还是紧急快速补丁,你编写代码都只是为了完成任务。你只会关注——“我的代码是否工作?”你几乎不关心代码的质量。

任何傻瓜都能写出计算机可以理解的代码。好的程序员能写出人类可以理解的代码。——Martin Fowler

那么,你是如何学习你正在尝试学习的新的编程语言的良好实践呢?

向该语言的开源项目贡献代码

惊讶吗?有些人可能在想——“等等,开源是很难的。只有当我们是该语言的专家时,我们才能为开源项目贡献代码,对吗?”答案是不。

让我给你们讲个故事。

去年,我收到Booking.com全职工作的邀请,而且我知道我将使用Perl(这是该公司后端使用的主要语言)工作。2016年6月,当我完成大学学位后,我开始学习Perl,以便为自己在大学毕业后的首份工作做准备。因为我会在7月的第二周入职,所以我差不多有1个月的时间。

我开始阅读Perl的语法,并开始理解这门语言的一些常见模式。现在,我真的想使用Perl构建一些东西,以便我可以应用我的这门语言的知识和实践这门语言的各种概念。当我在寻找使用Perl构建某些东西的想法时,我在GitHub偶遇DuckDuckGo的开源组织。我注意到这个组织的某些开放项目是用Perl写的。我浏览这些项目的Issues发现有很多“新手”问题。我立即开始去解决它们,并提交了几个pull request。到今天为止,我已经是该组织的几个开放项目的主要贡献者之一,也是DuckDuckGo的20个开源社区领袖之一。

故事的寓意——通过向用Perl编写的开源项目贡献代码我学会了Perl。

为什么这种方法奏效呢?

就在我学会Perl的语法之后,我开始向开源项目贡献代码。当这样做的时候,我总是习惯看看现有的模块。我经常留意在Perl中使用的模式。此后,我开始在自己的代码中吸收这些良好的实践,它帮助我学习如何使用Perl编写好的代码。

这并不是偶然。让我给你们讲个另外类似的故事。

最近,当我在Booking.com工作的时候,我挑选了一些任务,包括给用Go语言编写的服务之一添加新功能。以下是我和队友的对话:

我:我真的喜欢这项任务。我想做它。你怎么看?

他:是的,它的确很有意思。但是,它需要Go的知识。你知道Go吗?

我:不知道。

他:你想学习Go吗?

我:是的!

他:😊 那就去吧!

我去了,那也是我学习另外一门编程语言——Go的起点!

我开始阅读Go的语法,并在它的官方网站上发现了一个非常棒的初学者语言教程。它足以让我熟悉该语言的所有基本概念。

再次地,我开始寻找含有“新手”或“易于修复”问题的Go开源项目。我发现了一个Google的项目,它基本上是GitHub的REST API的Go包装器。

在我开始学习Go的2天后,我有了这个项目的第一个PR。下图是我过去1年的贡献图表

contribution-graph

开源是如何帮助的?

现在你可能会疑惑给开源贡献代码如何帮助你学习一门语言的良好实践。它有多个方面。让我们来逐个讨论。

代码质量

大多数良好的开源项目都有严格的编码指导原则,你必须遵守它们才能使你的代码被合并。参与开源将帮助你适应这些指导原则,从而编写优质的代码,即使你只是在学习这门语言。

不仅如此,你还有机会查看其余的代码,学习别人是如何写代码和/或写文档的。

代码审查

给开源贡献代码的最好部分是代码审查。当你推送代码时,你将获得与该项目相关的专家的反馈,因此可以让你有机会提升对语言的理解。

这就像获得了关于如何编写好代码的一次免费的个人指导。

赞赏

下图是我在Go语言上的第一个PR的首个评论

go-appreciation-comment

作为软件开发者,我们的工作真的需要得到赞赏。而开源社区能够确保这些。在我的整个开源贡献经历中,我从来没有收到过甚至一条侮辱或者挫伤的评论。每个人都善于鼓舞和乐于助人。

下图是DuckDuckGo社区中另个人的评论:

duckduckgo-appreciation-comment

所以,下次你想学习一门新语言,只管去学!找个开源项目贡献代码,在学习这门语言和它微妙之处的道路上奋勇前进吧;)

务必让我知道这种非传统方式是否对你有效。另外,如果你认为这种方式对某人有用,请推荐(❤)这篇文章。

如果有任何其它有效的方法也请告诉我。可以在Twitter上关注我@sahildua2305

GitHub对软件职业生涯的影响

英文原文:https://medium.com/@sitapati/the-impact-github-is-having-on-your-software-career-right-now-6ce536ec0b50

在未来的12-24个月里——换句话说,即2018到2019年间——程序员的聘用方式将彻底改变。

2004-2014年间,我任职于Red Hat,世界上最大的开源软件工程公司。2004年7月,在我工作的第一天,我的上司Marty Messer对我说:“你在这里所做的一切工作都是开源的。在将来,你不再需要简历,人们可以直接Google你。”

在那时,它是在Red Hat工作的一个独特之处:我们有机会在开源社区创立自己的个人品牌和声誉。我们通过邮件列表、缺陷追踪器以及提交源代码到Mercurial、Subversion和CVS版本库 来和其他软件工程师进行交流。所有这些都是公开的,并且可以被Google索引。

快进到2017,我们生活的这个世界已经被开源软件所吞噬。

有两个因素可以让你真切地感受到开源时代的到来:

  1. 微软——曾经是闭源私有软件的典型代表和反对开源的圣战士——已经全心全意地拥抱开源软件,成立.NET基金会(Red Hat是其中的一员)和加入Linux基金会。现在.NET已经作为开源项目进行开发。
  2. GitHub已经成为一个奇特的社交网络,它把问题追踪和分布式代码控制捆绑在一起。

对于来自主要是闭源背景的软件开发者来说,刚刚发生了什么还不是很清楚。对他们来说,开源等于“在业余时间免费工作”。

然而,对于我们这些在过去10年里建成一个10亿美元开源软件公司的人来说,为开源工作没有什么免费或业余时间。并且,为开源工作的好处和结果是显而易见的:你的声誉是你的,而且在公司间是可携带的。GitHub是一个社交网络,在那里,你的社会资本,通过你的提交和对你正在工作的任何技术的全球交流的贡献创造的,是你的——不会绑定到你正在临时工作的公司。

聪明人会利用这个优势——他们会向他们日常工作中使用的语言和框架贡献补丁、问题和评论——TypeScript、.NET、Redux。

他们同样会提倡并创造性地安排他们的工作尽可能地以公开的方式完成——即使那只是他们对私有版本库的贡献图。

GitHub是一个很好的均衡器。你可能不能从印度找到一份澳大利亚的工作,但没有什么阻止你在印度利用GitHub与澳大利亚人进行合作。

在过去的十年里,从Red Hat获取一份工作的方式是显而易见的。你只要开始与Red Hat的工程师一起协作开发他们的一些开源项目,然后作出有价值的贡献并且得到他们的认可,你就可以申请一份工作。或者他们会找你。

现在,同样的途径对每个人都开放,不过仅限于技术职位。随着世界被开源所吞噬,同样的求职方式在各个地方开始流行起来。

最近的访谈中,Linux和Git的发明者Linus Torvalds(在GitHub上有4.9万关注者)这样说道:

你提交大量的小补丁,直到项目的维护者信任你,到那时你会成为信任网络的一部分,而不仅仅是个发送补丁的家伙。

你的声誉是你在信任网络中的定位。当你换公司时,它们会减弱并且有所丢失。如果你生活在一个小镇,并且已经在那里很长一段时间,那么小镇里所有的人都了解你。如果你去了其他国家,那么你最终到了一个没人了解你的地方——更糟糕的是,没人知道有谁了解你。

你已经丢失了你的第一度和第二度,甚至可能是第三度连接(译者:不明白什么是“度”的可以搜索六度分隔理论)。除非你已经通过在会议上演讲或者其它一些重要的事情建立品牌,否则你通过与其他人合作以及给企业内部版本库提交代码建立的信任将会不复存在。

但是,如果这些工作一直都在GitHub上完成,它就不会消失。它是可见的。它连接到了一个可见的信任网络。

首先发生的事情之一是弱势群体将开始利用这个优势。学生、新毕业生、移民,他们将利用这个优势搬到澳大利亚。

并且这也将改变整个软件开发的生态环境。以前的特权开发者会突然发现他们的网络被破坏了。开源的原则之一是精英政治——最好的想法胜出,最多的提交胜出,通过测试最多的胜出,最好的实现胜出,等等。

它并不完美(没有什么是完美的)。并且它不会让成为一个好同事的努力废除或打折。在Red Hat,我们解雇过一些摇滚明星工程师,他们只是不能很好地与其他人一起工作——这样的事情不会出现在GitHub,因为大部分开发者都在与其他贡献者互动。

正如有些人用稻草人谬误描述它一样,GitHub不仅仅是代码版本库和原始提交数字的列表。它是一个社交网络。这么说吧:

它不是你的代码在GitHub上的计数——它是其他人在GitHub上谈及你的代码的计数。

那是你的可携带声誉。在未来的12-24个月里,由于一些开发者开发这种声誉而其他开发者不,它将成为一个鲜明的区分因素。就像有电子邮箱和没电子邮箱(现在每个人都有电子邮箱)、有蜂窝电话和没蜂窝电话(现在每个人都有蜂窝电话)。最终绝大多数将会以开源的方式工作,它将再次是区别于其它因素的一个公平竞争的领域。

但现在,开发者的职业生涯空间正在被GitHub破坏。

如何修改Android的hosts文件

由于「你懂的」的原因,某些时候我们需要修改Android的hosts文件。Android的hosts文件路径是/system/etc/hosts,在修改该文件前首先需要Android手机获取root权限。至于如何root你的手机,这里就不加详述,可以自行在网络上查找,很多也很简单。

本文将要阐述的是如何在命令行下通过adb程序访问root过的手机,把hosts拖到电脑上修改,然后再复制回手机来实现修改hosts的方法。

下面就开始具体的步骤:

1
2
C:\tools>adb pull /system/etc/hosts hosts.mod
[100%] /system/etc/hosts

上面的命令是把手机上的hosts文件拖到电脑上,[100%]表明文件已经传输完成,可以修改hosts文件了。在修改完成后就使用以下命令上传到手机:

1
2
C:\tools>adb push hosts.mod /system/etc/hosts
adb: error: failed to copy 'hosts.mod' to '/system/etc/hosts': Read-only file system

从回显的消息可以看到,文件系统是只读的,所以不能直接上传。

试试以下的命令看能不能成功?!

1
2
3
C:\tools>adb root  # 帮助文档说该命令可以让adbd守护进程获得root权限
C:\tools>adb push hosts.mod /system/etc/hosts
adb: error: failed to copy 'hosts.mod' to '/system/etc/hosts': Read-only file system

还是不行,看来要重新挂载/system目录才可以。

1
2
C:\tools>adb remount
remount failed: Operation not permitted

没有权限?这是必须祭起shell大法的节奏啊!

1
2
3
4
5
C:\tools>adb shell
* daemon not running. starting it now on port 5037 *
* daemon started successfully *
shell@maguro:/ $ ls -al /system/etc/hosts
-rw-r--r-- root     root           25 2013-08-14 07:00 hosts

从上面最后一行可以看出hosts这个文件只有它的拥有者能写入,对于其他人来说都是只读的。要想让其他人也能做修改,必须使用以下命令进行提权,再改变hosts文件的属性才行。

1
shell@maguro:/ $ su

如果是第一次执行这个命令,手机会亮起,SuperSU应用会提示你是否同意权限的分配。这里当然是要同意的!接着你就可以看到终端下的提示符从$变成了#,@前的字符也由shell变成了root。然后我们就可以修改hosts文件的权限属性了。

1
2
3
4
root@maguro:/ # chmod +666 /system/etc/hosts
Bad mode
root@maguro:/ # chmod 666 /system/etc/hosts
Unable to chmod /system/etc/hosts: Read-only file system

又是Read-only file system!输入以下命令看看/system目录的文件系统详情呢。

1
2
root@maguro:/ # mount | grep system
/dev/block/platform/omap/omap_hsmmc.0/by-name/system /system ext4 ro,seclabel,relatime...

看到ext4后面的ro了吗?它是read only的缩写,即只读的意思。这说明/system目录是只读的。接下来我们要把它改成可以读写。

1
root@maguro:/ # mount -o rw,remount /system

上面的-o用于指定加载文件系统时的选项。这些选项包括:

1
2
3
remount 重新加载设备。通常用于改变设备的设置状态。
ro 以只读模式加载。
rw 以可读写模式加载。

再次查看,可以看到原来ro的位置已经变成rw了。

1
2
root@maguro:/ # mount | grep system
/dev/block/platform/omap/omap_hsmmc.0/by-name/system /system ext4 rw,seclabel,relatime...

不过到这里我们还不能向手机拷贝hosts文件,因为hosts文件的权限属性还没被改过,如果强行上传的话,会得到如下的错误消息:

1
2
C:\tools>adb push hosts.mod /system/etc/hosts
adb: error: failed to copy 'hosts.mod' to '/system/etc/hosts': Permission denied

运行以下命令:

1
root@maguro:/ # chmod 666 /system/etc/hosts

然后查看hosts文件的属性。

1
2
root@maguro:/ # ls -al /system/etc/hosts
-rw-rw-rw- root     root           25 2013-08-14 07:00 hosts

可以看到所有人都可以读写hosts文件了。

既然一切都准备就绪,那就再来试试上传修改后的hosts文件吧。

1
2
C:\tools>adb push hosts.mod /system/etc/hosts
adb: error: failed to copy 'hosts.mod' to '/system/etc/hosts': Read-only file system

怎么回事,为什么还是拷贝失败呢?不是已经把文件系统改为可读写了吗?

并且在查找原因的过程中还发现一个奇怪的事情。在root模式下/system目录是可读写的,但在shell模式下/system却是只读的。

1
2
3
4
5
root@maguro:/ # mount | grep system
/dev/block/platform/omap/omap_hsmmc.0/by-name/system /system ext4 rw,seclabel,relatime...
root@maguro:/ # exit
shell@maguro:/ # mount | grep system
/dev/block/platform/omap/omap_hsmmc.0/by-name/system /system ext4 ro,seclabel,relatime...

而且在手机上的Terminal Emulator中把/system目录mount成可读写之后,在adb shell的root模式下查看/system的状态仍然显示为只读。

在网上找啊找啊找啊,都快要绝望了,终于找到可能之问题所在。就是这个帖子:mount in shell as user or root with different output。有个回答提到mount namespace这样东西。然后才知道:

A mount namespace is the set of filesystem mounts that are visible to a process.

每个进程的挂载点对其它进程是不可见的。Terminal Emulator中mount后的挂载点属于该进程,而adb shell中shell模式和root模式的挂载点分别属于各自的进程。这就是前面root模式下修改/system目录为可读写后在shell模式下仍显示为只读的原因。

知道问题的原因了,那如何解决呢?

在SuperSu应用的设置中有个mount namespace separation的选项,如下图所示:

mount-namespace-separation

把勾选取消,然后mount的挂载点就是全局性的了,不再为mount它们的进程所独有。不过要记住的是,只有在重启手机后该修改才有效。

下面是取消mount namespace separation后在上传的结果:

1
2
C:\tools>adb push hosts.mod /system/etc/hosts
[100%] /system/etc/hosts

可以看到[100%]的回显,说明文件已经上传完成。

查看hosts文件的属性:

1
2
root@maguro:/ # ls -al /system/etc/hosts
-rw-rw-rw- root     root       137679 2017-02-16 00:20 hosts

文件的大小已经由25变成137679,说明文件已经替换完成。

下面就是恢复手机到原先的状态:

1
2
3
4
root@maguro:/ # chmod 644 /system/etc/hosts
root@maguro:/ # ls -al /system/etc/hosts
-rw-r--r-- root     root       137679 2017-02-16 00:20 hosts
root@maguro:/ # mount -o ro,remount /system

至此,修改hosts文件的工作就算大功告成。

ANTLR 4权威参考读书笔记(33)

ANTLR的语法分析器可以生成错误信息,也可以从大量不同情况的错误中恢复。我们还可以自定义错误信息以及将它们重定向到不同的错误监听器。所有这些功能都被封装在指定ANTLR错误处理策略的对象中。接下来我们就将详细研究该策略,以学习更多关于自定义语法分析器如何响应错误的知识。

更改ANTLR的错误处理策略

默认的错误处理机制工作得很好,但在有些非典型的情况下我们可能要更改它。首先,我们可能由于运行时开销而想要禁用一些内联错误处理。其次,我们可能希望在出现第一个语法错误时就退出语法分析器。例如,当为类似bash这样的shell解析命令行时,没必要试图从错误中恢复。无论如何我们不能冒险执行该命令,所以语法分析器可以在一遇到麻烦时就推出。

要了解错误处理策略,请查看接口ANTLRErrorStrategy及其具体的实现类DefaultErrorStrategy。这个类持有与默认错误处理行为相关联的一切。ANTLR语法分析器指示该对象报告错误并恢复。例如,下面是在每个ANTLR生成的规则函数里面的捕获块:

1
2
_errHandler.reportError(this, re);
_errHandler.recover(this, re);

_errHandler是一个持有DefaultErrorStrategy实例的引用的变量。方法reportError()和recover()表示错误报告和同步和返回功能。reportError()根据抛出的异常类型将错误报告委托给3个方法的其中之一。

回到第一种非典型的情况,让我们来降低语法分析器上的错误处理在运行时的负担。看看下面这段ANTLR为语法Simple中member+子规则生成的代码:

1
2
3
4
5
6
7
8
_errHandler.sync(this);
_la = _input.LA(1);
do {
    setState(22); member();
    setState(26);
    _errHandler.sync(this);
    _la = _input.LA(1);
} while ( _la==6 );

对于可以安全地假设输入语法是正确的应用程序,比如网络协议,我们最好避免检测并从错误中恢复的开销。我们可以通过继承DefaultErrorStrategy并用空方法覆写sync()来做到这点。Java编译器可能会内联并消除_errHandler.sync(this)调用。我们将在下个例子中阐述如何通知语法分析器使用不同的错误策略。

另一种非典型的情况是在出现第一个语法错误时就退出语法分析器。为使它工作,我们必须覆写3个关键的恢复方法,如下面的代码所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import org.antlr.v4.runtime.*;

public class BailErrorStrategy extends DefaultErrorStrategy {
    /** Instead of recovering from exception e, rethrow it wrapped
     *  in a generic RuntimeException so it is not caught by the
     *  rule function catches. Exception e is the "cause" of the
     *  RuntimeException.
     */
    @Override
    public void recover(Parser recognizer, RecognitionException e) {
        throw new RuntimeException(e);
    }

    /** Make sure we don't attempt to recover inline; if the parser
     *  successfully recovers, it won't throw an exception.
     */
    @Override
    public Token recoverInline(Parser recognizer) throws RecognitionException {
        throw new RuntimeException(new InputMismatchException(recognizer));
    }

    /** Make sure we don't attempt to recover from problems in subrules. */
    @Override
    public void sync(Parser recognizer) { }
}

对于测试平台,我们可以重复使用我们典型的样板代码。除了创建并启动语法分析器,我们需要创建一个新的BailErrorStrategy实例,并告诉语法分析器使用它替换默认的策略。

1
parser.setErrorHandler(new BailErrorStrategy());

当我们处理这个问题的时候,我们也应该在出现第一个词汇错误时退出。要做到这点,我们必须重写Lexer中的recover()方法。

1
2
3
4
5
6
7
public static class BailSimpleLexer extends SimpleLexer {
    public BailSimpleLexer(CharStream input) { super(input); }

    public void recover(LexerNoViableAltException e) {
        throw new RuntimeException(e);    // Bail out
    }
}

让我们先尝试一个词法错误,通过在输入开头插入一个#字符,词法分析器会抛出异常并从控制流中返回到主程序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$ antlr Simple.g
$ compile Simple TestBail
$ run TestBail
# class T { int i; }
EOF
line 1:1 token recognition error at: '#'
Exception in thread "main"
java.lang.RuntimeException: LexerNoViableAltException('#')
at TestBail$BailSimpleLexer.recover(TestBail.java:9)
at org.antlr.v4.runtime.Lexer.nextToken(Lexer.java:165)
at org.antlr.v4.runtime.BufferedTokenStream.fetch(BufferedT...Stream.java:139)
at org.antlr.v4.runtime.BufferedTokenStream.sync(BufferedT...Stream.java:133)
at org.antlr.v4.runtime.CommonTokenStream.setup(CommonTokenStream.java:129)
at org.antlr.v4.runtime.CommonTokenStream.LT(CommonTokenStream.java:111)
at org.antlr.v4.runtime.Parser.enterRule(Parser.java:424)
at SimpleParser.prog(SimpleParser.java:68)
at TestBail.main(TestBail.java:23)
...

语法分析器也会从第一个语法错误中退出(在这里是缺少类名)。

1
2
3
4
5
6
$ run TestBail
class { }
EOF
Exception in thread "main" java.lang.RuntimeException:
org.antlr.v4.runtime.InputMismatchException
...

我们将通过下面更改语法分析器报告错误的方式来演示ANTLRErrorStrategy接口的灵活性。要改变标准的消息“noviable alternative at input X,”,我们可以覆盖reportNoViableAlternative()并将消息更改为其它不同的内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
import org.antlr.v4.runtime.*;

public class MyErrorStrategy extends DefaultErrorStrategy {
    @Override
    public void reportNoViableAlternative(Parser parser, NoViableAltException e)
        throws RecognitionException {
        // ANTLR generates Parser subclasses from grammars and
        // Parser extends Recognizer. Parameter parser is a
        // pointer to the parser that detected the error
        String msg = "can't choose between alternatives";    // nonstandard msg
        parser.notifyErrorListeners(e.getOffendingToken(), msg, e);
    }
}

到这里,我们已经涵盖了ANTLR内所有重要的错误报告和恢复设施。因为ANTLRErrorListener和ANTLRErrorStategy接口,我们在错误消息发生的地方具有很大的灵活性:这些消息是什么,以及语法分析器如何从错误中恢复。

共享代码的风险

英文原文:https://www.innoq.com/en/blog/the-perils-of-shared-code/

通往地狱的道路往往是由良好的意愿铺就。在各种软件项目中,我看到人们走在这样的道路上,他们在微服务之间借助库共享代码。在几乎每个组织支持微服务架构的项目中,各个团队和开发者都期望以某些核心库为基础构建他们的微服务。显然,即使可能带来的问题已经被知道很长时间了,很多人仍然不知道它们。在这篇博文中,我想研究为什么使用这样的库可能起初听起来有吸引力,为什么可能会出现问题,以及如何能够减轻这些问题。

共享代码的目的

通过库来共享代码有两个主要目的:共享领域逻辑和共享基础设施层中的抽象。

  1. 共享的领域模型:领域模型的特定部分在两个或多个有界上下文之间是共同的,因此,作为三番五次实现它的替换,你消除了重复的需要和引入该领域逻辑的不一致实现的可能性。通常,人们想要像那样共享的领域模型的部分是核心领域或一个或多个通用子领域。在领域驱动设计的行话中,这也被称为共享内核。通常,你可以在这里找到像会话和身份验证逻辑这样的概念,但不限于此。一套相关的方法是规范数据模型。

  2. 基础设施层抽象:你想避免一次又一次地实现基础设施层的有用抽象,因此你把它们放进一个库里。通常,这些库在数据库访问、消息传递和序列化等方面提供一套统一的方法。

两者的动机是相同的——避免重复,也就是说,遵循DRY原则(Don’t repeat yourself!)。一旦实现这些逻辑有几个好处:

你不需要花费宝贵的时间致力于那些已经被解决的问题。

有一套统一的方式做消息传递、数据库访问等。这意味着,当开发者需要去阅读和修改其他开发者最初创建的微服务中的代码时,他们很容易找到他们的方式。

关于彼此行为略有不同的业务逻辑或基础设施关注点,你不想有不同的实现。取而代之的是,有一套做正确事情的规范实现。

共享代码的问题

在理论上听起来很棒的东西不会没有自己的问题,而且这些问题可能比你试图用你的库解决的问题更令人痛苦。Stefan Tilkov已经详细解释了为什么你应该避免规范的数据模型。除此之外,让我指出一些其它的问题。

分布式单体

通常,似乎存在一个隐含的假设,将东西放入库意味着你永远不必担心使用错误或过时的实现构成的服务,因为他们只需要更新其对库的依赖关系到最新版本。

每当你依靠通过将所有的微服务更新到同样的新版本库,来对所有微服务的某些行为作出一致的改变时,你就会在它们之间引入强耦合。你失去了微服务的一个主要优点,即它们彼此独立地演进和部署的能力。

我见过这样的案例,所有的服务必须同时部署,以便服务仍能正常工作。如果你达到这种状态,不可否认,你实际上构建了一个分布式的单体。

一个流行的示例是使用代码生成,例如,基于服务API的Swagger描述,以便为你的服务提供一个客户端库。比你想象的更多,开发者可能会滥用此种方式进行重大变更,因为依赖服务“只”需要使用新版本的客户端库。这不是你如何演进一个分布式系统

依赖地狱

库,尤其是那些旨在为基础设施关注点提供通用解决方案的库,往往有个额外的问题:它们会附上它们依赖的一整套额外的库。你的库的传递依赖树越大,它导致俗称为依赖地狱的噩梦的可能性就越高。因为你的微服务可能需要自己的额外的依赖,它们同样具有传递依赖性,直到它们中的某些库间接地拉进一些库的冲突版本,这只是个时间问题,只在不同版本之间选择是不可能的,因为它们是二进制不兼容的。

当然,你的解决方案也许只是提供微服务可能需要的所有库作为你的核心库的依赖。那仍然意味着你的微服务不能独立地演进,例如通过升级到它们依赖的唯一的特定库的更高版本——它们都与核心库的发布周期步调一致。除此之外,为什么你要强制每个服务接受一整堆的依赖,当它们实际上可能只需要依赖中的一些时?

自顶而下的库设计

通常情况下,我见过的库被一个或多个架构师强加于开发者,采用自顶而下的方法进行库设计。

通常,在这种情况下发生的是,由库暴露的API太受限制和不灵活,或者使用了错误的抽象级别,因为它们是由不够熟悉广泛的不同的真实世界用例的人设计的。这样的库经常导致不得不使用它的开发者遭受挫折,以及导致人们试图绕过库的限制。

单语言解决一切

强制使用库的最明显的缺陷之一是,这使得它更难以切换到不同的编程语言(或者平台,比如JVM或.NET),再次失去了微服务架构的一个优势,即选择最适合给定问题的技术的能力。如果你后来意识到,你终究需要这种语言或者平台的多样性,你必须创造各种奇怪的支持。例如,Netflix提出的Prana,一个同时运行非JVM服务的附加件,为他们提供到Netflix技术栈的一套HTTP API。

我们能不能做得更好?

由于所有的问题都是通过库共享代码引入的,最极端的解决方案是根本没有这样的库。如果你这样做,你将不得不做一些复制和粘贴或者为新的微服务提供一个模板项目,以便从前面所述的步调一致中释放你的服务。基础设施代码以及领域模型的共享内核中都可以这么做。事实上,Eric Evans在他的关于领域驱动设计的经典蓝皮书中提到,“通常各个团队只是在各自的内核备份上改动,每隔一定时间才会与其他团队集成”[1]。共享内核不一定要是库。

如果你不喜欢复制和粘贴的想法,那也很好。毕竟,如上所述,通过库共享代码有一定的优势。在这种情况下,这里有一些重要的事情需要考虑:

最少依赖的小型库

尝试将大的共享库分成一组非常小的、高度集中的库,每个库解决一个特定的问题。试着让这些库成为零依赖库,只依靠语言的标准库。是的,仅仅针对语言的标准库来编程并不总是令人愉快的,但是对于你公司的所有团队的巨大好处(甚至超出你的公司,如果你的馆是开源的)显然大于这个微小的不便。

当然,零依赖并不总是可能的,特别是对于基础设施关注点。对于这些,通过你的每个小型库最小化所需的依赖。另外,有时可以独立于库的核心,提供与别的库的绑定或集成作为单独的工件。

留下选择余地

不要指望服务将在特定时间点更新到共享库的最新版本的事实。换句话说,不要强制团队进行库更新,而是让他们可以按照自己的节奏自由更新。这可能需要你以向后和向前兼容的方式修改库,但它会解耦你的服务,不仅给你微服务架构的运营成本,而且还有一些优势。

如果可能,不仅要避免强制库更新,还要使库本身的使用可选。

自底而上的库设计

最后,如果你想拥有共享库,我见过的获得成功的项目是使用自底而上的方法。让你的团队实现他们的微服务,而不是让象牙塔架构师设计在现实世界中几乎不可用的库,而当在多个服务的生产中已经证明它们自己的一些常见模式出现时,将它们提取到库中。

[1] Evans, Eric: Domain-Driven Design: Tackling Complexity in the Heart of Software, p. 355

ANTLR 4权威参考读书笔记(32)

前面我们介绍过ANTLR的自动错误恢复机制,现在让我们看看手动机制,有些时候它能够提供更好的错误恢复。

错误选项

某些语法错误非常常见,所以值得特别处理。例如,程序员经常在带有嵌套参数的函数调用结尾处忘记写大括号。特别是处理这些情况,我们所要做的就是添加选项来匹配错误但常见的语法。下面的语法识别单个参数或者可能在参数中使用嵌套括号的函数调用。规则fcall有两个所谓的错误选项。

1
2
3
4
5
6
7
8
9
stat: fcall ';' ;
fcall
    : ID '(' expr ')'
    | ID '(' expr ')' ')' { notifyErrorListeners("Too many parentheses"); }
    | ID '(' expr         { notifyErrorListeners("Missing closing ')'"); }
    ;
expr: '(' expr ')'
    | INT
    ;

虽然这些错误选项可能使ANTLR生成的语法分析器在选项之间选择时更困难,但它们不以任何方式混淆语法分析器。就像任何其它选项,如果它们与当前的输入一致,语法分析器就会匹配它们。现在,让我们从一个有效的函数调用开始,尝试一些匹配错误选项的输入序列。

1
2
3
4
5
6
7
8
9
10
11
12
13
$ antlr Call.g
$ compile Call
$ grun Call stat
f(34);
EOF
$ grun Call stat
f((34);
EOF
line 1:6 Missing closing ')'
$ grun Call stat
f((34)));
EOF
line 1:8 Too many parentheses

ANTLR 4权威参考读书笔记(31)

  • lookahead 前导符

现在我们已经对ANTLR语法分析器生成的消息种类以及如何调整和重定向它们有很好的了解,让我们来探讨错误恢复。

自动错误恢复策略

错误恢复是允许语法分析器找到语法错误后还能继续的东西。ANTLR的错误恢复机制是:如果可能的话,语法分析器在不匹配的记号错误上执行单次记号插入和单次记号删除。如果不能,语法分析器会吞掉记号直到它找到可以合理遵循当前规则的记号然后返回它,继续下去,就好像什么事都没发生。在本节中,我们将探讨ANTLR如何在各种情况下从错误中恢复。让我们从ANTLR使用的基本恢复策略开始。

通过扫描后续记号恢复

当真正面对残缺的输入时,当前规则不能继续,因此语法分析器通过吞掉记号来恢复,直到它认为它已经重新同步然后返回到调用规则。我们可以称之为“同步和返回策略”。有些人称它是“恐慌模式”,但它工作的非常好。语法分析器知道它不能用当前规则匹配当前输入。它只能抛出记号直到前导符与语法分析器退出规则后应该匹配的内容一致。例如,如果在赋值语句中有个语法错误,在语法分析器看到分号或其它语句终结符之前抛出记号是非常有意义的。激烈但有效。正如我们将看到的,ANTLR试图在规则中恢复,然后在撤回到这个基本策略。

每个ANTLR生成的规则方法都被包裹在try-catch中,通过报告错误并在返回之前尝试恢复来响应语法错误。

1
2
3
4
5
6
try {
    ...
} catch (RecognitionException re) {
    _errHandler.reportError(this, re);
    _errHandler.recover(this, re);
}

在这里,recover()会消费记号直到它在重新同步集合中找到记号。重新同步集合是所有调用栈上的规则的规则引用跟随集合的并集。规则引用的跟随集合是可以立即匹配引用而无需离开当前规则的跟随引用的记号集合。例如,选项assign ';',规则引用assign的跟随集合是{';'}。如果选项仅仅是assign,它的跟随集合就为空。

让我们通过示例来看看重新同步集合中包含什么。考虑以下语法,并设想在每个规则调用中,语法分析器跟踪每个规则调用的跟随集合。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Filename: F.g

grammar F;

group
    : '[' expr ']'      // Tokens following ref to expr: {']'}
    | '(' expr ')'      // Tokens following ref to expr: {')'}
    ;
expr: atom '^' INT ;    // Tokens following ref to atom: {'^'}
atom: ID
    | INT
    ;

INT : [0-9]+ ;
ID  : [a-zA-Z]+ ;
WS  : [ \t\r\n]+ -> skip ;

对于输入[1^2],考虑下图中左边的语法分析树:

syntax-parse-tree

当在规则atom中匹配记号1时,调用栈是[group, expr, atom](group调用expr,expr调用atom)。通过查看调用栈,我们可以准确地知道记号集合可以跟随语法分析器调用的任何规则,以便把我们带到当前位置。在当前规则中跟随集合只考虑记号,以至在运行时,我们可以只组合与当前调用栈有关系的集合。换句话说,我们不能同时从group的所有选项到达规则expr。

结合从语法F中的注释里提取的跟随集合,我们得到一个重新同步集合{'^', ']'}。为什么这是我们想要的,让我们观察当语法分析器遇到错误的输入[]时会发生什么。我们得到上图中显示在右边的语法分析树。在规则atom中,语法分析器发现当前记号]不符合atom的任何选项。要想重新同步,语法分析器需要消费记号直到它在重新同步集合找到记号。在这种情况下,当前记号]作为重新同步集合的成员开始,所以语法分析器不会消费任何记号以便在atom中重新同步。

在规则atom中完成恢复过程之后,语法分析器回到规则expr但是立刻发现它没有^记号。重复同样的过程,语法分析器消费记号直到它在重新同步集合找到记号。expr的重新同步集合是在group的第一个选项中的expr引用的跟随集合:{ ']' }。再次,语法分析器不消费任何东西并退出expr,回到规则group的第一个选项。现在,跟随着expr的引用,语法分析器明确知道它在寻找什么。它成功地匹配规则group中的']'。语法分析器现在已经正确地同步。

在恢复期间,ANTLR语法分析器避免发出级联错误消息。也就是说,语法分析器为每个语法错误发出单独的错误消息,直到它们从该错误成功地恢复。通过使用一个简单的布尔变量,设置语法错误,语法分析器避免发出更多的错误,直到语法分析器成功匹配记号和重置变量。

在许多情况下,ANTLR可以比单纯消费更智能地恢复,直到重新同步集合并从当前规则返回。尝试“修复”输入并在相同的规则内继续是值得的。在接下来的几个部分,我们将看看语法分析器如何从不匹配的记号以及子规则里的错误中恢复。

从不匹配记号中恢复

在语法分析过程中最常见的操作之一是“匹配记号”。对于语法中的每个记号引用T,语法分析器调用match(T)。如果当前的记号不是T,match()通知错误监听器并尝试重新同步。要重新同步,它有3个选择。它可以删除一个记号,它可以想象出一个,或者可以放弃并抛出异常以便从事基本的同步和返回机制。

如果这样做有意义的话,删除当前记号是最容易去重新同步的方法。让我们再次讨论来自在语法Simple中的简单类定义语言的规则classDef。

1
2
3
4
classDef
    : 'class' ID '{' member+ '}'    // a class has one or more members
      { System.out.println("class " + $ID.text); }
    ;

给定输入class 9 T {int i; },语法分析器将删除9并继续在规则中进行来匹配类体。下图说明了语法分析器消费class后的输入状态:

input-state-1

LA(1)和LA(2)记号前导符号的第一个记号(当前记号)和第二个记号。match(ID)期望LA(1)是一个ID,但它不是。实际上,下一个记号LA(2)才是一个ID。为了恢复分析,我们只需要删除当前记号,消费我们期望的ID并退出match()。

如果语法分析器无法通过删除记号重新同步,则会尝试插入一个记号作为代替。假设我们忘记了ID,以致classDef看到输入class { int i; }。匹配class后,输入状态如下所示:

input-state-2

语法分析器调用match(ID)后发现是{。在这种情况下,语法分析器知道{是下一步需要的记号,因为它在classDef中跟随ID引用。要重新同步,match()可以假装看到标识符并返回,从而允许下一个match('{')调用成功。

如果我们忽略嵌入动作(如引用类名标识符的print语句),那么这很有用。print语句通过$ID.text引用缺失的记号,如果记号为空,将导致异常。而不是简单地假装记号存在,由错误处理程序想象出一个。这个想象出的记号有语法分析器期望的记号类型以及从当前输入记号LA(1)中获得的行和字符的位置信息。这个想象出的记号也用于阻止在监听器和访问者中引用缺失记号的异常。

看看发生了什么的最简单的方法是查看语法分析树,它显示语法分析器如何识别所有记号。在出现错误的情况下, 语法分析树用红色突出显示在重新同步期间语法分析器删除或想象出的记号。对于输入class { int i; }和语法Simple,我们得到以下的语法分析树:

error-parse-tree

语法分析器也会执行嵌入print动作而不抛出异常,因为错误恢复会为$ID想象出一个有效的Token对象。

1
2
3
4
5
6
$ grun Simple prog -gui
class { int i; }
EOF
line 1:6 missing ID at '{'
var i
class <missing ID>

当然,带有文本<missing ID>的标识符对于任何我们试图完成的目标都不是很有用,但至少错误恢复不会导致一堆空指针异常。现在我们知道ANTLR如何对简单的记号引用进行规则内恢复,下面让我们来探讨如何从以前和子规则识别期间的错误中恢复。

从子规则的错误中恢复

为避免刚碰到错误就从子规则循环中抽身,对周围规则强制同步和返回恢复,ANTLR v4会在开始和循环继续测试处自动插入同步检查。机制看起来是这样:

子规则开始 在任何子规则的开头,语法分析器尝试单个记号删除。但是,与记号匹配不同,语法分析器不会尝试单个记号插入。ANTLR很难想象出一个记号,因为它将不得不猜测几个选项中的哪个最终会成功。

循环子规则继续测试 如果子规则是循环结构,(...)*或者(...)+,语法分析器在错误出现时积极尝试恢复以停留在循环中。在成功匹配循环的某个选项后,语法分析器消费直到它找到符合以下这些集合之一的记号:

  1. 循环的另个迭代
  2. 循环后面的
  3. 当前的重新同步集合

让我们先来看下子规则前的单个符号删除。考虑规则classDef中的member+循环结构。如果我们不小心输入一个额外的{member+子规则在跳进member前将会删除这个额外的记号,就像下面语法分析树显示的那样:

member-parse-tree

以下的会话可以证实恢复是妥当的,因为它能正确地识别变量i:

1
2
3
4
5
6
$ grun Simple prog
class T { { int i; }
EOF
line 1:9 extraneous input '{' expecting 'int'
var i
class T

现在让我们尝试一些真正混乱的输入,看看member+循环能否恢复并继续寻找member。

1
2
3
4
5
6
7
8
9
10
11
12
$ grun Simple prog
class T { {
  int x;
  y;;;
  int z;
}
EOF
line 1:9 extraneous input '{' expecting 'int'
var x
line 3:2 extraneous input 'y' expecting {'int', '}'}
var z
class T

语法分析器重新同步并停留在循环中是因为它确定了变量z。语法分析器消费y;;;直到看到另一个成员的开始(就如前面所说的集合3),然后循环回到member。如果输入不包含int z,语法分析器会一直消费下去直到看到}(前面的集合2)并退出循环。语法分析树突出显示已被删除的记号并说明语法分析器仍然把int z;解释为有效的成员。

parse-tree-10

如果提供的规则member有语法错误并且没有},在语法分析器找到}之前我们不希望它进行扫描。语法分析器重新同步可以为查找}抛出整个下面的类定义。相反,语法分析器如果看到集合3中的记号会停止消费,就如同下面的会话那样:

1
2
3
4
5
6
7
8
9
10
11
$ grun Simple prog
class T {
  int x;
  ;
class U { int y; }
EOF
var x
line 3:2 extraneous input ';' expecting {'int', '}'}
class T
var y
class U

如我们所见,语法分析器在看到关键字class时会停止从语法分析树重新同步。

parse-tree-11

除了记号和子规则的识别之外,语法分析器也可能无法匹配语义谓词。

捕捉失败的语义谓词

语义谓词就像断言,它指定在运行时必须为真的条件以便让语法分析器通过它们。如果谓词评估为假,则语法分析器将抛出FailedPredicateException异常,被当前规则的catch所捕获。语法分析器报告错误,并进行通用的同步和返回恢复。

让我们来看一个使用语义谓词限制矢量中的整数数量的例子,规则ints匹配max整数。

1
2
3
4
5
vec4: '[' ints[4] ']' ;
ints[int max]
locals [int i=1]
    : INT ( ',' { $i++; } { $i<=$max }? INT )*
    ;

如果像下面的会话那样,给定一个太多整数的矢量,我们会看到错误消息并得到抛出额外逗号和整数的错误恢复:

1
2
3
4
5
6
$ antlr Vec.g
$ compile Vec
$ grun Vec vec4
[1,2,3,4,5,6]
EOF
line 1:9 rule ints failed predicate: { $i<=$max }?

语法分析树显示语法分析器在第5个整数处检测到错误。

parse-tree-12

错误消息{ $i <= $max }可能对作为文法设计者的我们有帮助,但对我们的用户肯定没有帮助。我们可以通过使用语义谓词上的失败选项把消息变得更加可读。例如,下面是带有计算可读字符串动作的ints规则:

1
2
3
4
ints[int max]
locals [int i=1]
    : INT ( ',' { $i++; } { $i<=$max }?<fail={"exceeded max "+$max}> INT )*
    ;

对于相同的输入,现在我们得到一个更好的消息。

1
2
3
4
5
6
$ antlr VecMsg.g
$ compile VecMsg
$ grun VecMsg vec4
[1,2,3,4,5,6]
EOF
line 1:9 rule ints exceeded max 4

fail选项使用在双引号中的字符串字面量或者计算结果是字符串的动作。如果你想在谓词失败时执行一个函数,这个动作是很方便的。只需使用一个调用{...}?<fail={failedMaxTest()}>那样的函数的动作。

谨慎使用语义谓词来测试输入有效性。在向量示例中,谓词强制执行语法规则,所以它可以抛出异常并尝试恢复。另一方面,如果我们有一个语法上有效但语义无效的结构,那么使用语义谓词并不是一个好主意。

想象一下,在某种语言中,我们可以给变量赋除0外的任何值。这意味着赋值语句x = 0;在语法上是有效的,但在语义上是无效的。当然,我们必须向用户发出一个错误,但是我们不应该触发错误恢复。x = 0;在语法上是完全合法的。从某种意义上说,语法分析器会从错误中自动执行“恢复”。这是个简单的语法问题:

1
2
3
4
assign
    : ID '=' v=INT {$v.int>0}? ';'
      { System.out.println("assign "+$ID.text+" to "); }
    ;

如果规则assign中的谓词抛出异常,则同步和返回行为会在谓词之后抛出;。这可能会进行的很顺利,但我们冒着不完美重新同步的风险。更好的解决办法是手动发出一个出错,并让语法分析器继续匹配正确的语法。所以,我们应该用一个有条件的简单动作而不是谓词。

1
{if ($v.int==0) notifyListeners("values must be > 0");}

现在我们已经看过所有可能引发错误恢复的情况。现在需要指出这个机制存在潜在的缺陷。鉴于语法分析器有时在单次恢复尝试期间不消费任何记号,整体恢复可能会陷入无限循环。如果我们能够不消费记号而恢复并回到语法分析器中的相同位置,我们可以再次恢复而不消费记号。在下一节中,我们将会显示ANTLR如何避免这个陷阱。

错误恢复的失效保护

ANTLR语法分析器具有内置的失效保护,以保证错误恢复终止。如果我们到达相同的语法分析器位置并具有相同的输入位置,语法分析器在尝试恢复之前会强制消费一个记号。现在让我们来看一个失效保护的例子。如果我们在字段定义中添加一个额外的int记号,语法分析器检测到错误并尝试恢复。就像我们将在下个测试中看到的那样,语法分析器将调用recover()并在正确重新同步前尝试重新分析多次。

parser-resynchronization

右边的语法分析树显示从classDef到member有3次调用。

1
2
3
4
5
6
7
8
$ grun Simple prog
class T {
  int int x;
}
EOF
line 2:6 no viable alternative at input 'intint'
var x
class T

第一个引用不匹配任何东西,但第二个引用匹配没有直接联系的int记号。匹配member的第三次尝试匹配正确的int x;序列。

我们来看一下事件的确切顺序。当语法分析器检测到第一个错误时它在规则member中。

1
2
3
4
5
6
member
    : 'int' ID ';'                          // field definition
      { System.out.println("var "+$ID.text); }
    | 'int' f=ID '(' ID ')' '{' stat '}'    // method definition
      { System.out.println("method: "+$f.text); }
    ;

输入int int不适合member的任何选项,所以语法分析器参与同步并返回错误恢复策略。它发出第一个错误信息然后消费记号,直到它在调用栈[prog, classDef, member]的重新同步集合中看到记号为止。

由于语法中的classDef+member+循环,计算重新同步集合有点复杂。在调用member之后,语法分析器可以循环回去并找到另一个成员,或退出循环并找到关闭类定义的'}'。在调用classDef之后,语法分析器可以循环回去查看另一个类的开始或简单地退出prog。因此对于调用栈[prog, classDef, member],重新同步集合是{'int', '}', 'class'}

在这点上,语法分析器恢复不消费记号,因为当前输入记号int在重新同步集合中。它只是返回到调用者:classDef中的member+循环。然后循环尝试匹配另一个成员。不幸的是,因为它没有消费任何记号,当语法分析器返回到member时它检测到另一个错误(虽然它凭借errorRecovery标志消除了虚假错误消息)。

在第2次错误的恢复过程中,语法分析器触发失效保护,因为它已经到达相同的语法分析器位置和输入位置。失效保护在尝试重新同步之前强制记号消费。由于int是在重新同步集合中,它不会消费第2个记号。幸运的是,这正是我们想要的,因为语法分析器现在是正确地同步的。接下来的3个记号表示一个有效的成员定义:int x;。语法分析器再次从member返回到classDef中的循环。第3次,我们回到member,但现在语法分析将会成功。