描写老奶奶外貌的句子一段话 描写奶奶外貌的句子段落100字
你现在阅读的是一篇关于描写老奶奶外貌的句子一段话的文章,里面有丰富多彩的内容,还有给你准备描写奶奶外貌的句子段落100字和描写老奶奶外貌的句子一段话的精彩内容哦。
描写老奶奶外貌的句子一段话 描写奶奶外貌的句子段落100字
火影的人物风格
说起火影的设定,本应该是一种近代的风格,毕竟涉及日本武士和忍者的时代,但是看动漫的话,又感觉里面有很多现代化的东西(比如对讲机)。虽然我们知道火影的背景设定是一个架空世界,所以将这两者结合起来没有什么问题。
但是近代和现代的风格显然是不一样的,尤其是在穿着和忍者相关的服装之时,又该用什么的方式去设计人物呢,所以我们看到的火影忍者其实是一个混搭风格的人物画风,让人捉摸不透到底应该是什么类型的风格。
或许有人觉得这种混搭的风格没有什么问题,但是却忽视了一个问题,那就是这种风格可能会影响人们对于颜值的判断。比如小樱和井野明明设定上是属于颜值相当的水准,但是明显在第一部里面她们都比不过红、红豆、纲手等等。而到了第二部,井野的造型一变,立马就符合她美女的身份设定了,而小樱依旧还是那个样子。
这种情况一直到《博人传》才有所转变,小樱终于符合了她女主的颜值,同时也是靠着几位老牌美女颜值衰退过多所形成的一种对比反差。如果单纯以为是造型问题,可能还没有抓到重点,其实很主要的原因就在于动画风格了。
很明显小樱原本的造型不适合偏向于近代的火影风格,而到了《博人传》之后,由于一开始抬高了科技的地位,使得整体风格向现代靠拢,于是小樱的造型随着风格的变化而改变,她也终于回归了自己的美女本质。
火影采用写实风格
火影原本的风格不仅影响了女主小樱的颜值判断,也影响了对两个男主的颜值判断。尤其是到了后期,鸣人颜值提升,佐助颜值下降,甚至给人一种错觉好像二人的颜值其实没差多少,这就误会大了。
在二人尚在忍校之时,佐助是属于校草级别的,鸣人虽然不至于像丁次那样垫底,可也只是普普通通的类型。
这正是岸本想要给表达的地方,就像《千与千寻》中相貌平平的千寻一样,鸣人如果不考虑“外挂”的话,就是一普通长相的人,他最后之所以能够达到如此境界,很大程度上还是取决于他自身的努力。
所以这个风格上的影响真的是让鸣人被误会大了。我们知道,像佐助这种类型的在学校即使就是坐在桌子上不说话,也是一个“安静的美男子”,而鸣人必须要做出很大的成绩,比如在篮球场上击败了全校最强的队伍,又或是外校来“踢馆”打败了校队,却输给了鸣人。只有发生这样的事情,才能够让人觉得鸣人很帅气。
其实仙鸣的样子一点儿也不好看,之所以那么帅气,全拜他的实力所赐,这也是为什么他在击败佩恩回村后才被井野等人开始觉得鸣人很帅气的原因了。
如果按照写实的风格来便会发现,鸣人远没有他老爸水门颜值高,但是和宇智波一家的带土、佐助比起来,又不在一个层次。而女角色这边,纲手的颜值是真的“耐打”,无论怎么换风格都是一样高。
句子成分分析器(字节工程师自研基于)
前言
众所周知,程序员最讨厌的四件事:写注释、写文档、别人不写注释、别人不写文档。因此,想办法降低文档的编写和维护成本是很有必要的。当前写技术文档的模式如图:
痛点总结有如下三方面:
针对上述问题,我们的解决思路:
- 本地的编辑、浏览工作收敛至 IDE,提供沉浸式体验;
- 在文档、代码间建立强关联,减少拷贝,提升联动性,同时提升文档的触达率;
- 代码与文档同属一个 Git 仓库,借助版本管理,避免因业务迭代导致的文档版本与代码不匹配;
- 制作可将文档导出到线上的工具,可利用浏览器做到随时访问;
方案总览
与原始模式相比,新方案可以做到完全脱离浏览器 / 文档编辑器,线上页面的同步完全交给定时触发的自动化部署。
图中橙色部分是方案的重点,按照分工,划分为线下、线上两部分,职责如下:
- 线下:IDEA Plugin
- 实现自定义语言的解析、分析;
- 提供文档内容的预览器、编辑器;
- 提供一系列实用功能,关联代码与文档;
- 线上:Gradle / Dokka Plugin
- 桥接、复用 IDE Plugin 的语义分析、预览内容生成能力;
- 扩展 Dokka Renderer,实现 HTML 与飞书文档的导出能力;
方案建设使用了不少有意思的技术,放到后面详细介绍。
线下效果
IDEA Plugin 提供一个侧边栏和强大的编辑器。下面分别从编辑、浏览两个角度介绍。
编辑体验
假设存在源码如下:
public class ClassA { public static final String TAG = "tag"; ClassB b; /** * method document here. * * @param params input string */ public static void invoke(@NotNull String params) { System.out.println("invoke method!"); System.out.println("this is method body: " + params); } public ClassA() { System.out.println("create new instance!"); } private static final class ChildClass { /** * This is a method from inner class. */ void innerInvoke() { System.out.println("invoke method from child!"); } }}
文档中添加该类的引用就是这个效果:
不同于复制、粘贴代码,新方案有如下优势:
- 关联性更强,预览会随代码片段的变更时时改变;
- 易于重构,被引用的类名、方法名、字段名发生重命名时,文档内容会自动随之变化,防止引用失效;
- 更加直观,编辑、浏览时能更快速地找到代码出处;
- 输入更流畅,有完善的补全能力;
浏览体验
相对于普通 Markdown,新方案用起来更加友善:
- 沉浸式使用,界面内嵌在 IDE 内,无需跳转到其他应用;
- 被提及的源码旁均有行标,点击一键查阅文档;
- 文档“浏览器”支持与 IDE 一致的代码高亮、引用跳转;
线上效果
代码中文档会定期自动部署到远端。以一篇真实业务文档举例,HTML 部署到轻服务后长这样:
对应飞书的产物长这样:
这些线上页面主要面向非当前团队的读者,内容由 CI 定时同步,暂不提供跳转到 IDE 的能力。
技术实现
项目的架构如图所示:
考虑到用户体验部分主要在 IDEA(Android Studio)内呈现,我们的技术栈选择基于 IntelliJ 打造。按模块可分为三部分:
- 基建层
- IDEA Plugin
- Gradle / Dokka Plugin
通用逻辑(语言实现相关)封装在基建层,仅依赖 IntelliJ Core。相对于 IntelliJ Platform,IntelliJ Core 仅保留语言相关的能力,精简了 codeInsight、UI 组件等代码,被广泛用于 IntelliJ 各大产品中(包括图中的 Kotlin、Dokka 等)。
下面将针对这三个主要模块展开介绍。
基建
纵观整个方案,基建层是所有功能的基石,其最核心的能力是建立代码与文档关联。这里我们设计实现了一套标记语言 CodeRef,满足以下几个需求:
- 语法简洁,结构上与源码一一对应;
- 指向精准,即必须满足一对一的关系;
- 支持仅保留声明(去掉 body),提升信噪比;
- 有扩展性,方便后续迭代新功能;
CodeRef 语言并不复杂,采用类似 Kotlin/Java 的风格,用关键字、字符串、括号构成语句和代码块,代码块中每个节点都有与之对应的源码节点。下图是一个简单的示例,对应关系用着色文字标识:
注意:即使不改动文档内容,图中“源码”部分一旦发生变化,对应的渲染效果也会实时发生改变,产生“动态绑定”的效果。那么如何实现“动态绑定”呢?大致拆解成以下三步:
- 设计语法,编写语言实现;
- 结合现有能力(IntelliJ Core、Kotlin Plugin)获取双边语法树,从而建立文档节点到源码节点的单向对应关系;
- 结合现有能力(Markdown Parser)生成用于渲染的文档文本;
语言基础实现
基于 IntelliJ Platform,实现一个自定义语言起码要做以下几件事:
- 编写 BNF 定义,描述语法;
- 借助 Grammar Kit 生成 Parser、PsiElement 接口、flex 定义等;
- 基于生成的 flex 文件和 JFlex 生成 Lexer;
- 编写 Mixin 类用 PsiTreeUtil 等工具实现 PSI 中声明的自定义方法;
BNF 是后面一切的基础,每个定义、值的选择都至关重要。一小段示例:
{ /* ...一些必要的 Context */ tokens = [ /* ...一些 Token,转换为代码中的 IElementType */ AT='@' CLASS='class' ] /* ...一些规则 */ extends("class_ref_block|direct_ref|empty_ref") = ref extends("package_location|class_location") = ref_location extends("class_ref|method_ref|field_ref") = direct_ref}ref_location ::= package_location | class_locationpackage_location ::= AT package_def { pin=2 // 只有 '@' 和 package_def 一起出现时,才把整个 element 视为 package_location}class_location ::= AT class_def { pin=2 // 只有 '@' 和 class_def 一起出现时,才把整个 element 视为 class_location}direct_ref ::= class_ref | method_ref | field_ref | empty_ref { methods = [ // 一些自定义的 method,需要在下面指定的 mixin class 中给出实现 getNameStringLiteral getReferencedElement getOptionalArgs ] mixin="com.bytedance.lang.codeRef.psi.impl.CodeRefDirectRefMixin"}class_ref ::= CLASS L_PAREN string_literal [COMMA ref_args_element*] R_PAREN { methods = [ property_value="" ] pin=1 // 即遇到第一个元素 class 后,就将当前 element 匹配为 class_ref}
上面的小片段中定义了 @class("")、@package("")、class("", ...) 语法。实战中比较关键的是 pin 和 recoverWhile,前者影响一段“未完成”的代码的类型,后者控制一段规则何时结束。具体参考 Grammar-Kit。
编写完成后,我们就可以使用 Grammar-Kit 生成 Parser 和 Lexer 了,前者负责最基础的语法高亮,后者负责输出 PSI 树。将二者注册在自定义的 ParserDefinition,再结合自定义的 LanguageFileType,相应类型文件就会被 IDE 解析成由 PsiElement 构成的树。示意如图:
值得一提的是,后续 Formatter、CompletionContributor 等组件的实现受上述过程影响极大,实现不好必然面临返工。而偏偏这里面又有不少“坑”需要一一淌过,这部分限于篇幅没办法写得太细,有兴趣看看语言特性“相对简单”的 Fortran 的 BNF 定义感受一下。
语法树单向对应
考虑到 IDE 内置了对 Java、Kotlin 语言的支持,有了上一步的成果,我们就得到了两颗语法树,是时候把两棵树的节点关联起来了:
这里我们借用 PsiReferenceContributor(官方文档) 注册 CrElement(即 CodeRef 语言 PsiElement 的基类)向源码 PsiElement 的引用,依据便是每行双引号内的内容(字符串)。如何找到每个字符串对应的元素呢?遵循以下三步:
- 除根节点外,每个节点需要向上递归找到每一级 parent 直至根节点;
- 根节点是给定 full-qualified-name 的 package 或 class,由上一步的结果可确定元素在该 package 或 class 中的位置;
- 通过 JavaPsiFacade 和一系列查找方法确定源码中对应的 PsiElement;注意:Kotlin Plugin 提供一套针对 Java 的 “Light” PsiElement 实现,因此这里我们考虑 Java 即可。
生成文档文本
有了语法树对应关系,就可以生成用于预览的文本了。这部分比较常规,时刻注意读写环境,按照以下步骤实现即可:
- 为每个 CodeRef 语法树根节点指向的源码文件创建副本;
- 遍历该 CodeRef 树中每个 Ref 或 Location,创建或定位副本中对应位置,将源码文件中的元素(修饰后)复制到副本中;
- 导出副本字符串;考虑到 IDE 中 PSI 和文件是实时映射的,为不影响原文件内容,必须在副本环境中进行语法树的增删改。
这部分虽然难度不大,繁琐程度却是最高的。一方面,由于要深入到细节,使得前文提到的 Kotlin Light PSI 不再适用,因此必须针对 Java 和 Kotlin 分别编写实现。另一方面,如何保证复制后的代码格式仍是正确的也是个大问题,尤其是涉及元素之间穿插注释的情况。最后,文本内容生成的工作在不停的断点、调试的循环中玄学般地完成了。
至此,基建层的任务——将 CodeRef 还原成代码段——便全部完成了。
IDEA Plugin
有了前面的基础,IDEA Plugin 主要负责把方案的本地使用体验做到可用、易用。具体来说,插件的功能分为两类:
- 面向 CodeRef,丰富语言功能;
- 面向 Markdown,提升编辑、阅读体验;
接下来分别从以上角度介绍。
语言优化
对于一门“新语言”,从体验层面来看,PSI 的完成只是第一步,自动补全、关键字高亮、格式化等功能对可用性的影响也是决定性的。尤其是在 CodeRef 的语法下,指望用户能不依赖提示手动输入正确的包名、类名、方法名,无疑过于硬核了。下面挑几个有意思的展开说说。
代码补全
在 IDEA 中,大部分(不太复杂的)代码补全使用 Pattern 模式注册。所谓 Pattern 相当于一个 Filter,在当前光标位置满足该 Pattern 时就会触发对应的 CompletionContributor。
我们可以使用 PlatformPatterns 的若干内置方法描述一个 Pattern。比如一段 CodeRef 代码:method("helloWorld"),其 PSI 树长这样子:
- CrMethodRef // text: method("helloWorld") - CrStringLiteral // text: "helloWorld" - LeafPsiElement // text: helloWorld
Pattern 因此为:
val pattern = PlatformPatterns.psiElement() .withParent(CrStringLiteral::class.java) .withSuperParent(2, CrMethodRef::class.java)
对应每个 Pattern,我们需要实现一个 CompletionProvider 给出补全信息,比如一个固定返回关键字补全的 Provider:
val keywords = setOf("package", "class", "lang")class KeywordCompletionProvider : CompletionProvider<CompletionParameters>() { override fun addCompletions( parameters: CompletionParameters, context: ProcessingContext, result: CompletionResultSet ) { keywords.forEach { keyword -> if (result.prefixMatcher.prefixMatches(keyword)) { // 添加一个 LookupElementBuilder,可以指定简单的样式 result.addElement(LookupElementBuilder.create(keyword).bold()) } } }}
掌握上述技能,诸如 class、package、method 等关键字,乃至方法名和字段名的补全就都很容易实现了。
比较 trick 的是包名和带有包名的类名的补全,它们形如 a.b.c.DEF。不同的是,每次输入 '.' 都会触发一次补全,而且要求在字符串开头直接输入“DE”也能正确联想并补全。限于篇幅不展开介绍了,详见 com.intellij.codeInsight.completion.JavaClassNameCompletionContributor 的实现。
格式化
格式化这件事上,IDEA 并没有直接使用 PSI 或者 ASTNode,而是基于二者建立了一套“Block”体系。所有缩进、间距的调整都是以 Block 为最小粒度进行的(一些复杂语言拆的太细,这样设计可以很好地降低实现复杂度,妙啊)。
这里的概念也不多,列举如下:
- ASTBlock:我们用现有的 ASTNode 树构建 Block,因此继承此基类;
- Indent:控制每行的缩进;
- Spacing:控制每个 Block 之间的间距策略(最小、最大空格,是否强制换行 / 不换行等);
- Wrap:单行长度过长时的折行策略;
- Alignment:自己在 Parent Block 中的对齐方向;
实际敲代码时,大部分时间花在 getSpacing 方法上,写出来效果类似这样:
override fun getSpacing(child1: Block?, child2: Block): Spacing{ /*...*/ return when { // between ',' and ref node1?.elementType == CodeRefElementTypes.COMMA && psi2 is CrRef -> Spacing.createSpacing(/*minSpaces*/0, /*maxSpaces*/0, /*minLineFeeds*/1, /*keepLineBreaks*/true, /*keepBlankLines*/1) // between '[', literal, ']' node1?.elementType == CodeRefElementTypes.L_BRACKET && psi2 is CrStringLiteral || psi1 is CrStringLiteral && node2?.elementType == CodeRefElementTypes.R_BRACKET -> Spacing.createSpacing(/*minSpaces*/0, /*maxSpaces*/0, /*minLineFeeds*/0, /*keepLineBreaks*/false, /*keepBlankLines*/0) }}
格式化属于说起来很简单,实现起来很头痛的东西。实操过程中,被迫把前面写好的 BNF 做了一波不小的调整,才达到理想效果。好在我们的语言比较简陋简洁,没踩到什么大坑,如果面向更复杂的语言,工作量将是指数级提升(参考 com.intellij.psi.formatter.java 包下的代码量)。
MarkdownX
上面罗列这么多内容,说白了只是对 Markdown 中代码块的增强方案,接下来 CodeRef 和 Markdown 终于要合体了。
实际上官方一直有对 Markdown 的支持(IDEA 内置,AS 可选安装),包含一整套语言实现和编辑器、预览器。这里重点说说其预览的生成流程,如图:
分为以下几步(逻辑在 org.jetbrains:markdown 依赖中,未开源):
- 利用 MarkdownParser 将文本解析成若干 ASTNode;
- 利用 HtmlGenerator 内置的 visitor 访问每个 ASTNode 生成 HTML 文本;
- 将生成的 HTML Document 设置给内置浏览器(如果有),最终呈现在屏幕上;
交代个背景:在本项目启动之初,IDEA 正处于 JavaFX-WebView 到 JCEF 的过渡期(直接导致了 AndroidStudio 4.0 左右的版本没有可用的内置 WebView 实现)。
上述方案总结有以下问题:
- 兼容性较差,部分 IDE 版本无法看到预览;
- 每次 MD 的变更都会触发全量 generateHtml,如果文档内容复杂度较高,将有性能瓶颈;
- 将 HTML 文本 set 给浏览器时没有 diff 逻辑,会触发页面 reload,同样可能导致性能问题(后来针对带有 JCEF 的 IDE 增加了 diff 能力,但并不是所有 IDE 都内置 JCEF);
综合考虑下,我们决定不直接使用原生插件,而是基于其创建新的语言“MarkdownX”,最大程度复用原本的能力,追加对 CodeRef 的支持,同时基于 Swing 自制一套类似 RecyclerView 的机制改善预览性能。
优化后的方案流程类似这样:
自制的方案有很多优势:
- 内存占用更低(浏览器 vs. JComponent)
- 性能更佳(局部刷新、控件复用等)
- 体验更佳(浏览器内置对<code>标签的支持过于基础,无法实现代码高亮、引用跳转等功能,原生控件不存在这些限制)
- 兼容性更佳(不解释)
CodeRef 支持
MarkdownX 只是表现为“新语言”,实现上依然复用 MarkdownParser 和 HtmlGenerator,主要区别只有文件扩展名和对 code-fence 的处理。
所谓 code-fence,即 Markdown 中使用 「```」 符号包裹的代码块。不同于原生实现,我们需要在生成预览时替换代码块的内容,并使内容随代码变化而变化。
实操上,我们需要实现一个 org.intellij.markdown.html.GeneratingProvider,简写如下:
class MarkDownXCodeFenceGeneratingProvider : GeneratingProvider { override fun processNode(visitor: HtmlGenerator.HtmlGeneratingVisitor, text: String, node: ASTNode) { visitor.consumeHtml("<pre>") var state = 0 // 用于后面遍历 children 的时候暂存状态 /* ...一些变量定义 */ for(child in childrenToConsider) { if (state == 1 && child.type in listOf(MarkdownTokenTypes.CODE_FENCE_CONTENT, MarkdownTokenTypes.EOL)) { /* ...拼接每行内容 */ } if (state == 0 && child.type == MarkdownTokenTypes.FENCE_LANG) { /* ...记录当前 code-fence 的语言 */ applicablePlugin = firstApplicablePlugin(language) // 找到可以处理当前语言的“插件” } if (state == 0 && child.type == MarkdownTokenTypes.EOL) { /* ...进入代码段,设置状态 */ state = 1 } } if (state == 1) { visitor.consumeTagOpen(node, "code", *attributes.toTypedArray()) if (language != null && applicablePlugin != null) { /* ...命中自定义处理逻辑(即 CodeRef)*/ visitor.consumeHtml(content) // 即由自定义逻辑生成的 Html } else { visitor.consumeHtml(codeFenceContent) // 默认内容 } } /* ...一些收尾 */ }}
可以看到,在遍历 node 的 children 后,就可以确定当前代码段的语言。如果语言为 CodeRef,就会走到前文提到的“预览文本生成”逻辑中,最后通过 visitor(相当于一个 HTML Builder)将自定义的内容拼接到 Html 中。
预览性能优化
考虑到 JList 并没有“item 回收”能力,在 List 实现上我们选择直接使用 Box。处理流程如下图:
机制分为两大步:
- Data 层将 HTML 的 body 拆分成若干部分,diff 后将变更通知给 View 层;
- View 层将变更的数据设置到 List 对应位置上,并尽可能复用已有的 ViewHolder。过程可能涉及 ViewHolder 的创建和删除;
目前我们针对文本、图片和代码创建了三种 ViewHolder:
- 文本:使用 JTextPane 配合 HTML + CSS 完成文字样式的还原;
- 图片:自定义 JComponent 进行缩放、绘制,保证图片居中且完整展示;
- 代码:以 IDE 提供的 Editor 作为基础,进行必要的设置与逻辑精简;
这里对 Editor 的处理花费了大量精力:
- 使用原代码文件作为 context 创建 PsiCodeFragment 作为内容填充 Editor,以保证代码中对原文件 import 过的类、方法、字段可被正常 resolve(这点很重要,如果用 Mock 的 Document 作为内容,绝大部分代码高亮和跳转都是不生效的);
- 设置合适的 HighlightingFilter,确保“没有报红”(将原文件作为 context 的代价是,当前代码片段的类极有可能被认为是类重复,并且代码结构也不一定合法,因此需要禁用“报红”级别的代码分析);
- 禁用 Intention,设置只读(提升性能,降低干扰);
- 禁用 Inspection 和 ExternalAnnotator;(两者是性能消耗的大户,后者包括 Android Lint 相关逻辑)
经过上述优化,实测大部分情况下预览都可以流畅展示 & 刷新了。但如果同时打开多个文档,或者“操作速度惊人”,还是会时不时出现长时间卡顿。分析一波发现,性能消耗主要出在 HTML 生成上。
由于 Markdown 语法限制(节点深度低),常规的 MD 转 HTML 性能开销有限。但回顾上文,我们对 codeRef 的处理会伴随大量 PSI resolve,复杂度暴涨,频繁的全量 generate 就不那么合适了。一个很自然的想法是为每段 codeRef 添加缓存,内容不变时直接使用缓存的内容。这样在修改文字段落时可以完全避开其他文件的语法解析,修改 codeRef 段落时也仅会刷新当前代码块的内容。
那么问题来了:若用户修改的不是文档文件,而是被引用的代码,则在缓存的作用下,预览并不会立刻改变。那么更进一步,如果向所引用的所有文件注册监听,在变更时刷新缓存,问题可否得解呢?事实上,这样做问题确实解决了,但引入了新的问题:如何释放文件监听?
此处插入背景:对 code-fence 内容的干预是基于 Visitor 模式回调完成的,因此作为 generator 本身是不知道本次处理的代码块与前一次、后一次回调是否由同一个变更引起。举个例子:一个文档中有 A、B、C 三个 codeRef 块,则在一次 HTML 生成过程中,generator 会收到三次回调,且没有任何手段可以得知这三次回调的关联性。
目前,我们只能在一次 HTML 生成前后通知 generator,在 generator 内部维护一个队列 + 计数器,不那么优雅地解决泄漏问题。
至此,插件的整体性能表现终于落到可接受范围内。
Gradle / Dokka Plugin
为了让受众更广、内容随时可读,把文档做到可导出、可自动化部署是非常必要的。方案上,我们选用同为 IntelliJ 出品的 Dokka 作为基础框架,利用其完善的数据流变换能力,高效地适配多输出格式的场景。
Dokka 流程扩展
Dokka 作为同时兼容 Kotlin 和 Java 的文档框架,“数据流水线”的思想和极强的可扩展性是其特点。代码转换到文档页面的流程如下:
每个节点都有至少一个 Extension Point,扩展起来非常灵活。
图中几个主要角色列举如下:
- Env:包含基于 Kotlin Compiler 和 IntelliJ-Core 扩展的代码分析器(用于输出 Document Models)、开发者自定义的插件等组件;
- Document Models:对 module、package、class、function、fields 等元素的抽象,呈树形组织,本质是一些 data class;
- Page Models:由 PageCreator 以 Document Models 为输入,创建的一系列对象,是对“页面”的封装,描述“页面”的结构;
- Renderer:用于将 Page Models 渲染成某种格式的产物(Dokka 内置的有 HTML、Markdown 等);
从上述内容可以看出,Dokka 原本的作用只是将代码转换为文档页面,并不原生支持转换文档文件(也确实没必要)。但在我们的场景下,MarkdownX 的渲染是依赖源码信息的,也就正好能用到 Dokka 的这部分能力。
通过重写 PageCreator,我们将含有 MarkdownX 文档的工程变成类似这样的节点树:
MdxPageNode 对应 MarkdownX 文档内容,包含若干类型的 children 分别代表不同类型的内容片段;
在创建 MdxPageNode 时,我们用类似前文 IDEA-Plugin 的做法,重写一个 org.jetbrains.dokka.base.parsers.Parser 并修改对 code-fence 的处理,改为调用到「基建」部分中生成 CodeRef 预览文本的代码,最终得到所需的文档文本。
飞书适配
得到页面内容后,结合 Dokka 自带的 HtmlRenderer,输出一份可用于部署的 HTML 产物就轻而易举了。但现状是,我们更希望能把文档收敛在飞书上,这就需要再编写一份针对飞书的自定义 Renderer。
考虑到自己处理页面的树形结构过于复杂,实际上我们基于内置的 DefaultRenderer 基类进行扩展:
abstract class DefaultRenderer<T>( protected val context: DokkaContext) : Renderer { abstract fun T.buildHeader(level: Int, node: ContentHeader, content: T.() -> Unit) abstract fun T.buildLink(address: String, content: T.() -> Unit) abstract fun T.buildList( node: ContentList, pageContext: ContentPage, sourceSetRestriction: Set<DisplaySourceSet>= null ) abstract fun T.buildNewLine() abstract fun T.buildResource(node: ContentEmbeddedResource, pageContext: ContentPage) abstract fun T.buildTable( node: ContentTable, pageContext: ContentPage, sourceSetRestriction: Set<DisplaySourceSet>= null ) abstract fun T.buildText(textNode: ContentText) abstract fun T.buildNavigation(page: PageNode) abstract fun buildPage(page: ContentPage, content: (T, ContentPage) -> Unit): String abstract fun buildError(node: ContentNode)}
上面只列出一部分了回调方法。
可以看到,该类的接口方式比较新颖:用 Visitor 的方式遍历页面节点树,再提供一系列 Builder/DSL 风格的待实现方法给开发者。对于这些 abstract function,内置的 HtmlRenderer 采用 kotlinx.html(一个 DSL 风格的 HTML 构建器)实现,这意味着我们也要实现一套 DSL 风格的飞书文档构建器。
飞书开放平台文档查看链接:https://open.feishu.cn/document/home/index。
DSL 的部分就不详述了,这里主要说说飞书的文档结构。众所周知,Markdown 在设计之初就是面向 Web 的,因此与 HTML 天生具有互转的能力。然而飞书文档的数据结构相对更像 Pdf、Docx 这类文件,拥有有限层级,相对扁平。举个例子,同样的文档内容,MdxPageNode 中结构长这样:
而飞书的结构长这样:
可见差异是巨大的。这部分差异的抹平全靠自定义的 FeishuRenderer,具体做法只能 case by case 介绍,限于篇幅就不展开了,大体思路就是对不兼容的节点进行展开或合并,穿插必要的子树遍历。
下面提两个特殊点的处理:图片和链接。
文档链接
写 Markdown 文档时,往往需要插入链接,指向其他的 Markdown 文档(一般使用相对路径)。这时,我们需要想办法把相对路径映射成飞书链接,而且需要在 Render 步骤之后进行,因为映射的时候需要知道对应文档的飞书链接是什么。
第一反应肯定就是对文档做拓扑排序了,按照依赖关系一个个上传文档。但这样需要文档间没有循环依赖,显然这是不能保证的(两篇文档相互引用还蛮常见的)。幸好,飞书文档提供了修改文档的接口,因此我们可以提前创建一批空文档,获取到空文档的链接后,再做相对路径的替换。换句话说,处理文档上传流程为:创建空文档-> 替换相对路径为对应文档链接 -> 修改文档内容。
图片
图片在 Markdown 中可以和文本并列,属于 Paragraph 的一种。而飞书文档结构中,图片属于 Gallery,只能独占一行,无法和文字同行。两种格式从实现上无法完全兼容。当前初步实现方案是在 Paragraph 的 Group 入口向下 DFS,找到所有图片,单提出来放在文本前面。效果嘛,只能忍忍了。
顺便一提,图片也需要上传并替换的逻辑,这部分与文档链接相似,不赘述了。
结语
以上就是文档套件的全部内容:我们基于 IntelliJ 技术栈,通过设计新语言、编写 IDE 插件、Gradle / Dokka 插件,形成一套完整的文档辅助解决方案,有效建立了文档与代码的关联性,大幅提升编写、阅读体验。
未来,我们会为框架引入更多实用性改进,包括:
- 添加图形化的代码元素选择器,降低语言学习、使用成本;
- 优化预览渲染效果,对齐 WebView;
- 探索针对部分框架(Dagger、Retrofit 等)的文档自动生成能力;
目前框架尚处内测阶段,正逐步扩大范围推广。待方案成熟、功能稳定后,我们会将方案整体开源,以服务更多用户,同时吸取来自社区的 Idea,敬请期待!
加入我们
我们是字节跳动直播营收客户端团队,专注礼物、PK、直播权益等业务,并深入探索渲染、架构、跨端、效率等技术方向。目前北京、深圳都有大量人才需要,欢迎投递简历至 zhangtianye.bugfree@bytedance.com 加入我们!
描写友情的唯美句子(友谊的唯美句子)
有一种心心相印叫闺蜜情深,有一种体贴入微叫关怀备至,有一种亲密无间叫无话不说。不管你还相不相信我们的友情,请你记住,只要你回头,我还会在那里等你。
1 闺蜜最暖心的话
1、闺蜜就是坐在一起即使我说的前言不搭后语,你也懂,即使什么也不说,也不会感到尴尬。对你最好的那个人,往往是最好欺负的人。天下间的人,往往总是欺负对他最好的那个人。
2、我们只是想走在一起傻笑,想走在一起说一些怕瞬间就会忘记的笑话。我们说好、绝不放开相互牵的手。你知道,就算大雨让整个城市颠倒,我会给你温暖怀抱。你的肩膀,不厚实,但总能给我带来温暖和力量。
4、你经历那么多的人,聚聚散散,分分合合,以后还会有。但是,你要记得最后留下的永远是我。在最窝囊和无助的时候,能够懂你,耐心地和你说话,并且用真实的情感安慰你的人。
5、不管你走在哪里,我都会站在你看得见我的地方。如果有难过,有委屈,只要你回头,我始终站在你身后,站在你看得见我的地方。
2 闺蜜感动到哭的留言
1、谢谢这些年陪在我身边,不多说了,要不你又说我矫情了。我不好,但只有一个,珍惜也好,不珍惜也罢,如果哪天你把我弄丢了,我不会再让你找到我。友情也好,爱情也罢,我若离去,后会无期!陪我笑过的人我可能以经忘了,陪我哭过的人永远都不会忘记。
2、我有一个闺蜜推心置腹无话不说偶尔也心存芥蒂虽不是一开始就这么好但在共同经历过的日子里彼此坚定的心。
3、很久以前,我未想过有这样一个你,陪我一起哭一起笑。也曾感动过、吵过、闹过、疯狂过。但却从不后悔,有这样一个你陪伴我度过。是你让我相信在最孤单的时候觉得我不是自己一个人。是你和我形影不离连去厕所也要一起。
4、我们总是手牵手一起上学放学,互相说着经历的笑话或糗事,一边说一边手舞足蹈。嗨完以后发现怎么周边的人都看着自己,两个人再互相对视。继续笑,笑累了就当没事发生,继续前进。
5、无论多久没见,见面之前的澎湃心情立刻被见面以后的平静淹没,好像我们明明昨天就一起出来逛了街吃了饭,手牵手。分别的时间还是会到,我们回到各自的新生活。却总觉得,只要一个电话一条短信,就还是能在那个老地方。看见你站在那里等我,我们都不曾离开。
3 给闺蜜的暖心简信
1、只因把你当朋友,才会太在乎。只有死党才可以毫无顾忌的说话,毫无保留的付出,更加不对你说那么多的表面话,因为没意思。所以,不要因此而认为我不够好。
2、别以为不经常见面,感情就淡了。吵不散,骂不走的才叫闺蜜。对我们来说,甚至是比情人更重要的。即使你只是我生命拼图的一小块,但是没有你,我的生命便不会完整。
3、亲爱的闺蜜,你永远也看不到我最寂寞时候的样貌,正因只有你不在我身边的时候,我才最寂寞。
4、世界上如果有一百个幸运我愿意把九十九个留给闺蜜,最后个留给自己,那就是在人海茫茫中我们的相遇。
句子成分分析器(Swift)
作者 | 赵志、曾庆隆、顾梦奇、王强、赵发
出品 | CSDN(ID:CSDNnews)
2023 年 3 月 25 日,苹果发布了 Swift 5.0 版本,宣布了 ABI 稳定,并且Swift runtime 和标准库已经植入系统中,而且苹果新出文档都用 Swift,Sample Code 也是 Swift,可以看出 Swift 是苹果扶持与研发的重点方向。
目前国内外各大公司都在相继试水,只要关注 Swift 在国内 iOS 生态圈现状,你就会发现,Swift 在国内 App 应用的比重逐渐升高。对于新 App 来说,可以直接用纯 Swift 进行开发,而对于老 App 来说,绝大部分以前都是用 OC 开发的,因此 Swift/OC 混编是一个必然面临的问题。
CSDN 付费下载自视觉中国
Swift 和 OC 混编开发
关于 Swift 和 OC 间如何混编,业内也已经有很多相关文章详细讲解,简单来说 OC/Swift 调用 Swift,最终通过 Swift Module 进行,而 Swift 调用 OC 时,则是通过 Clang Module,当然也可以通过 Clang Module 进行 OC 对 OC 的调用。58同城于 2023 年正式上线首个 Swift/OC(Objective-C,以下简称 OC)项目,与此同时,也在全公司范围内开展了一个多部门协作项目——混天项目,主要目标:
一是提供混编的基础设施建设,如提供通过的 Module 化方案;
二是扩展各工具链的混编能力,如对无用类检测工具 WBBlades(https://github.com/wuba/WBBlades)进行 Swift 能力的扩展;
三是对已有的基础库进行 Module 化和 Swift 适配;
四是将混编开发在各 App 和各业务线中推广和落地。
我们在 Module 化实践中发现,实际数据与苹果官方 Module 编译时间数据不一致,于是我们通过 Clang 源码和数据相结合的方式对 Clang Module进行了深入研究,找到了耗时的原因。由于 Swift/OC 混编下需要 Module 化的支持,同时借鉴业内 HeaderMap 方案让 OC 调用 OC 时避开 Module 化调用,将编译时间优化了约 35%,较好地解决了在 Module 化下的编译时间问题。
Clang Module 初探
Clang Module 在 2023 LLVM Developers Meeting 上第一次被提出,主要用来解决 C 语言预处理的各种问题。Modules 试图通过隔离特定库的接口并且编译一次生成高效的序列化文件来避免 C 预处理器重复解析 Header 的问题。在探究 Clang Module 之前,我们先了解一下预处理的前世今生。
一个源代码文件到经过编译输出为目标文件主要分为下面几个阶段:
源文件在经过 Clang 前端包含:词法分析(Lexical analysis) 、语法分析(Syntactic analysis) 、语义分析(Semantic analysis)。最后输出与平台无关的 IR(LLVM IR generator)进而交给后端进行优化生成汇编输出目标文件。
词法分析(Lexical analysis)作为前端的第一个步骤负责处理源代码的文本输入,具体步骤就是将语言结构拆分为一组单词和记号(token),跳过注释,空格等无意义的字符,并将一些保留关键字转义为定义好的类型。词法分析过程中遇到源代码 “#“ 的字符,且该字符在源代码行的起始位置,则认为它是一个预处理指令,会调用预处理器(Preprocessor)处理后续。在开发中引入外部文件的 include/import 指令,定义宏 define 等指令均是在预处理阶段交由预处理器进行处理。Clang Module 机制的引入带来的改变着重于解决常规预处理阶段的问题,那么跟随我们一起来重点探究一下其中的区别和实现原理吧!
2.1 普通 import 的机制
Clang Module 机制引入之前,在日常开发中,如果需要在源代码中引入外部的一些定义或者声明,常见的做法就是使用 #import 指令来使用外部的 API。那么这些使用的方式在预处理阶段是怎么处理的呢?
针对编译器遇到 #import<PodName/header.h> 或者 #import ”header.h” 这种导入方式时候,# 开头在词法分析阶段会触发预处理(Preprocessor)。而对于 Clang 的预处理器 import 与 include 指令都属于它的关键词。预处理器在处理 import Directive 时候主要工作为通过导入的 header 名称去查找文件的磁盘所在路径,然后进入该文件创建新的词法分析器对导入的头文件进行词法分析。
如下所示:编译器在遇到 #import 或者 #include 指令时,触发预处理机制查询头文件的路径,进入头文件对头文件的内容进行解析的流程。
以单个文件编译过程为维度举例:在针对一个文件编译输出目标文件的过程中,可能会引入多个外界的头文件,而被引入多个外界头文件也有可能存在引入外界头文件。这样的情况就导致虽然只是在编译单个文件,但是预处理器会对引入的头文件进行层层展开。这也是很多人称 #import 与 include 是一种特殊“复制”效果的原因。
那么在这种预处理器的机制在工程中编译中会存在什么问题呢?苹果官方在 2023 的 WWDC 视频上同样给了我们解答:Header Fragility (健壮性)和 Inherently Non-Scalable (不可扩展性)。
来看下面一段代码,在 PodBTestObj 类的文件中定义一个 ClassName 字符串的宏,然后在导入 PoBClass1.h 头文件,在 PoBClass1.h 的头文件中同样定义一个结构体名为 ClassName,这里与我们在 PodBTestObj 类中定义的宏同名。预处理的特殊的“复制”机制,在预处理阶段会发生下图所见的结果:
这样的问题相信在日常开发中并不罕见,而为了解决这种重名的问题,我们常规的手法只能通过增加前缀或者提前约定规则等方式来解决。
视频中同时指出这种机制在应对大型工程编译过程中的所带来的消耗问题。假设有 N 个源文件的工程,那么每个源文件引用 M 个头文件,由于预处理的这种机制,我们在针对处理每个源文件的编译过程中会对引入的 M 个头文件进行展开,经历一遍遍的词法分析-语法分析-语义分析的过程。那么你能想象一下针对系统头文件的引入在预处理阶段将会是一个多么庞大的开销!
那么针对 C 语言预处理器存在的问题,苹果有哪些方案可以优化这些存在的问题呢?
2.2 PCH (Precompiled Headers)
PCH(Precompile Prefix Header File)文件,也就是预编译头文件,其文件里的内容能被项目中的其他所有源文件访问。日常开发中,通常放一些通用的宏和头文件,方便编写代码,提高效率。
关于 PCH 的概述,苹果是这样定义的:
which uses a serialized representation of Clang’s internal data structures, encoded with the LLVM bitstream format.
(使用 Clang 内部数据结构序列化表示,采用的 LLVM 字节流表示)。
它的设计理念当项目中几乎每个源文件中都包含一组通用的头文件时,将该组头文件写入 PCH 文件中。在编译项目中的流程中,每个源文件的处理都会首先去加载 PCH 文件的内容,所以一旦 PCH 编译完成,后续源文件在处理引入的外部文件时候会复用 PCH 编译后的内容,从而加快编译速度。PCH 文件中存放我们所需要的外部头文件的信息(包括不局限于声明、定义等)。它以特殊二进制形式进行存储,而在每个源代码编译处理外部头文件信息时候,不需要每次进行头文件的展开和“复制”重复操作。而只需要“懒加载”预编译好的 PCH 内容即可。
存储内容方面它存放着序列化的 AST 文件。AST 文件本身包含 Clang 的抽象语法树和支持数据结构的序列化表示,它们使用与 LLVM’s bitcode file format. 相同的压缩位流进行存储。关于 AST File 文件的存储结构你可以在官方文档有详细的了解。
它作为苹果一种优化方案被提出,但是实际的工程中源代码的引用关系是很复杂的,所以找出一组几乎所有源文件都包含的头文件基本不可能,同时对于代码更新维护更是一个挑战。其次在被包含头文件改动下,因为 PCH 会被所有源文件引入,会带来代码“污染”的问题。同时一旦 PCH 文件发生改动,会导致大面积的源代码重编造成编译时间的浪费。
2.3 Modules
上述我们简单回顾了一些 C 语言预处理的机制以及为解决编译消耗引入 PCH 的方案,但是在一定程度上 PCH 方案也存在很大的缺陷。因此在 2023 LLVM Developer’s Meeting 首次提出了 Modules 的概念。
那么 Module 到底是什么呢?
Module 简单来说可以认为它是对一个组件的抽象描述,包含组件的接口和实现。Module 机制推出主要用来解决上述所阐述的预处理问题,想要探究 Clang Module 的实现,首先需要去开启 Module。那么针对 iOS 工程怎么开启 Module 呢只需要打开编译选项中:
对!你没看错,仅仅需要在 Xcode 的编译选项中修改配置即可。
而在代码的使用上几乎可以不用修改代码,开启 Module 之后,通过引用头文件的方式可以继续沿用 #import <PodName/Header.h> 方式。当然对于开发者也可以采用新的方式 @import ModuleName.SubModuleName,以及 @import ModuleName这几种方式。更为详细的信息和使用方法可以在苹果的官方文档中查看。
2.4 苹果对 Module 的解读
上文提到过基于 C 语言预处理器提供的 #include 机制提供的访问外界库 API 的方式存在的伸缩性和健壮性的问题。Modules 提供了更为健壮,更高效的语义模型来替换之前 textual preprocessor 改进对库的 API 访问方式。
苹果官方文档中针对 Module 的解读有以下几个优势:
扩展性:每个 Module 只会编译一次,将 Module 导入 Translantion unit 的时间是恒定的。对于库 API 的访问只会解析一次,将 #include 的机制下的由 M x N 编译问题简化为 M + N。
健壮性:每个 Module 作为一个独立的实体,具备一个一致的预处理环境。不需要去添加下划线,或者前缀等方式解决命名的问题。每个库不会影响另外一个库的编译方式。
我们翻阅了苹果 WWDC 2023 的 Advances in Objective-C 视频,视频中针对编译时间性能方面进行了 PCH 和 Module 编译速度的数据分析。苹果给出的结论是小项目中 Module 比 PCH 能提升 40% 的编译时间,并且随着工程规模的不断增大,如增大到 Xcode 级别,Module 的编译速度也会比 PCH 稍快。PCH 也是为了加速编译而存在的,由此也可以间接得出结论,Module的编译速度要比没有 PCH 的情况下,是更快的,如在 Mail 下,应该提升 40% 以上。
对 Clang Module 机制建立一定的认知上,我们着手进行了 Clang Module 在 58同城 App 上的 Module 化改造。
58同城初步实践
3.1 Module 化工程配置
组件 Module 化
在多 pod 的项目中,通过以下几种方式可以将各 pod 进行 Module 化:
Podfile 中添加 use_modular_headers! 对所有的 pod 进行 Module 化;
Podfile 中通过 modular_headers 对每个 pod 单独进行 Module 化,如对 PodC 进行 Module 化,pod 'PodC', :path => '../PodC',:modular_headers => true;
在 pod 所对应的 .podspecs 中的 xcconfig 中 sg 配置 DEFINES_MODULE,如 s.xcconfig = {'DEFINES_MODULE' => 'YES'}。
此外,为了能让其它组件能通过 module 方式引用 Module 化的组件,还需要设置它们之前的依赖关系。
在58同城中,维护了一个全局的依赖配置文件 dependency.json,这个文件通过自动化工具进行维护,各组件 pod 的 .podspecs 从 dependency.json 中动态读取自己依赖的其它组件,并生成相应的 dependency 关系。
3.2 Swift/OC 混编桥接文件
通常在 Swift/OC 混编工程中会自动或手动在当前pod添加加一个桥接文件,如 PodC-Bridging-Header.h,配置当前 pod 中 Swift 需要引用的 OC 文件,形式如下所示。
这样可以达到编译的目的,但是由于依赖的组件都是在桥接文件中统一配置,对于每个 Swift 文件依赖了哪些 pod 组件,实际上并不清楚,而且 Swift 中每次修改新增一个 OC 文件的引用,都需要在桥接文件中进行修改,并且如果是减少对某个 OC 文件的引用,也不好确定是否要在桥接文件中进行删除,因为还需要判断其它 Swift 文件中是否有引用。
Swift 文件中可以通过 module 的方式去引用 OC 文件,因此,如果所依赖 OC 文件的 pod 都 Module 化后,可以通过 import module 的方式进行引用,每个 Swift 文件各自维护对外部 pod 的依赖,从而将 XXX-Bridging-Header.h 文件删除,也减少了对桥接文件的维护成本。
3.3 同城的 Module 化编译数据
万事具备,只差编译!
结合苹果官方给出了性能数据,我们预测 Module 化后的编译速度是要比非 Module 情况更快,那不妨就编译试试,接下来在 58同城中分别在 module 和非 module 场景下进行编译。
通过编译数据,我们看到的结果发生了逆转,Module 化之后的时间竟然比非 Module 情况下长约 8%,这跟刚才我们看到的苹果官方数据不符,有点乱了。需要说明的是这份数据是 58同城全业务线在 M1 机器上运行出来的,并且把资源复制的环节从配置中删除了,即不包含资源复制时间,是纯代码编译时间,并且在非 M1 机器上也运行了进行对比,除了时间长些,结论基本也是 module 化之后时间长 10% 左右。
在面对实际测试结果 Module 化之后的编译耗时更长的情况下,我们从更深层次上进行对 Clang Module 原理进行了探究。
Clang Module 原理深究
Clang Module 机制的引入主要是为了解决预处理器的各种问题,那么工程在开启 Module 之后,工程上会有哪些变化呢?同时在编译过程中编译器工作流程与之前又有哪些不同呢?
4.1 ModuleMap 与 Umbrella
以基于 cocoapods 作为组件化管理工具为例,开启 Module 之后工程上带来最直观的改变是pod组件下 Support Files 目录新增几个文件:podxxx.moduleMap , podxxx-umbrella.h。
Clang 官方文档指出如果要支持 Module,必须提供一个 ModuleMap 文件用来描述从头文件到模块逻辑结构的映射关系。ModuleMap 文件的书写使用 Module Map Language。通过示例可以发现它定义了 Module 的名字,umbrella header 包含了其目录下的所有头文件。module * 该通配符的作用是为每个头文件创建一个 subModule。
简单来说,我们可以认为 ModuleMap 文件为编译器提供了构建 Clang Module 的一张地图。它描述了我们要构建的 Module 的名称以及 Module 结构中要暴露供外界访问的 API。为编译器构建 Module 提供必要条件。
除了上述开启 Module 的组件会新增 ModuleMap 与 Umbrella 文件之外。在使用开启 Module 的组件时候也有一些改变,使用 Module 组件的 target 中 BuildSetting 中 Other C Flag 中会增加 -fmodule-map-file 的参数。
苹果官方文章中对该参数的解释为:
Load the given module map file if a header from its directory or one of its subdirectories is loaded.
(当我们加载一个头文件属于 ModuleMap 的目录或者子目录则去加载 ModuleMap File)。
4.2 Module 的构建
了解完 ModuleMap 与 Umbrella 文件和新增的参数之后,我们决定深入去跟踪一下这些文件与参数的在编译期间的使用。
上文提到过在词法分析阶段以“#”开头的预处理指令,我们对针对 HeaderName 文件进行真实路径查找,并对要导入的文件进行同样的词法,语法,语义等操作。在开启 Module 化之后,头文件查找流程与之前有什么区别呢?在不修改代码的基础上编译器又是怎么识别为语义化模型导入(Module import)呢?
如下图所示:在初始化预处理之前,会针对 buildsetting 中设置的 Header Search path,Framework Search Path 等编译参数解析赋值给 SearchDirs。
在 Clang 的源码中 Header Search 类负责具体头文件的查找工作,Header Search 类中持有的 SearchDirs 存放着当前编译文件所需要的头文件搜索路径。其中对于一个头文件的搜索分三种情况:hmap, Header Search Path 以及 Frameworks search path。而 SearchDirs 的赋值发生在编译实体(CompilerInstance)初始化预处理器时,而这些参数的来源则是在 Xcode 工程 Buildsetting 中的相关编译参数。
编译器在查询头文件具体磁盘路径的过程中,会通过 Header.h 或者 PodName/Header.h 与 SearchDirs 集合中的路径拼接判断该路径下是否存在我们要查找的头文件。当前循环的 SearchDirs 对应的元素中根据类型:(Header Search Path,Frameworks,HeaderMap)进行相应的查询流程。
上文提到过针对开启 Module 的组件不需要额外的修改头文件导入的代码,编译器自动识别我们的头文件导入是否属于 Module,而判断 Header 导入是否属于 Module import 就发生在查找头文件路径中。上述代码我们会注意到针对 Framework 与常规的目录查找中,会透传一个参数 SuggestedModule。
我们进一步向下跟踪 SuggestModule 的赋值过程,在查找到头文件的磁盘路径之后,编译器会进行该文件目录或者父级目录路径作为 Key 去 UmbrellaDirs 查找该头文件的是否有对应的 Module 存在。如果能查询到则赋值 SuggestModule(ModuleMap::KnownHader(Module *,NormalHeader) )。下图为查询并赋值 SuggestModule 的流程。
相信你看到上面的源码,你又会出现新的疑惑。UmbrellaDirs 是什么?前面提到过使用开启 Module 组件的 Target 中会新增 -fmodule-map-file 的参数,编译器在解析编译参数时加载 MoudleMapFile,读取使用 Module Map Language 书写的 ModuleMap 文件,解析文件的内容。
编译器在编译工程源代码时候通过 -fmodule-map-file 参数读取我们要使用的 Module,并把 ModuleMap 文件所在的路径作为 key,我们要使用的 Module 作为 Value,赋值给 UmbrellaDirs。预处理器在解析外界引入的头文件时候,会判断头文件路径下或者头文件路径父级目录是否存在 ModuleMap 文件,如果存在则 SuggestModule 有值。头文件查找的流程至此结束。
SuggestModule 的值是编译器决定使用 Module Import 还是“文本导入” 的关键因素。预处理器处理头文件导入,会去查找头文件在磁盘上的绝对路径,如果 SuggestModule 有值,编译器会调用 ModuleLoader 加载需要的 Module,而不开启 Module 的组件头文件,编译器则会进入该文件进行新的词法分析等流程。
至此,相信读到这里大家对 ModuleMap、Umbrella 文件以及 -fmodule-map-path 有了一定的认知。而且我们也跟踪了为什么编译器可以做到不修改代码的“智能”的帮助代码在 # import 和 Module Import 之间切换。
与非 module 不同,我们来继续追踪一下 LoadModule 的后续发生了什么?ModuleLoader 进行指定的 Module 的加载,而这里的 LoadModule 正是 Module 机制的差异之处。
Module 的编译与加载是在第一次遇到 ModuleImport 类型的 importAction 时候进行缓存查找和加载,Module 的编译依赖 moduleMap 文件的存在,也是编译器编译 Module 的读取文件的入口,编译器在查找过程中命中不了缓存,则会在开启新的 compilerInstance,并具备新的预处理上下文,处理该 Module 下的头文件。产生抽象语法树然后以二进制形式持久化保存到后缀为 .pcm 的文件中(有关 pcm 文件后文有详细讲解),遇到需要 Module 导入的地方反序列化 PCM 文件中的 AST 内容,将需要的 Type 定义,声明等节点加载到当前的翻译单元中。
Module 持有对 Module 构建中每个头文件的引用,如果其中任何一个头文件发生变化,或者 Module 依赖的任何 Module 发生变化,则该 Module 会自动重新编译,该过程不需要开发人员干预。
4.3 Clang Module 复用机制
Clang Module 机制的引入,不仅仅从之前的“文本复制”到语义化模型导入的转变。它的设计理念同时也着重在复用机制,做到一次编译写入缓存 PCM 文件在此后其他的编译实体中复用缓存。关于 Module 都是编译和缓存探究的验证,我们可以在 build log 中通过 -fmodules-cache-path 来查看获取到 Module 缓存路径(eg:/Users/xxx/Library/Developer/Xcode/DerivedData/ModuleCache.noindex/ )。当前如果你想自定义缓存路径可以通过添加 -fmodules-cache-path 指定缓存路径。
我们知道针对组件化工程,我们每个 pod 库都可能存在复杂的依赖关系,以某工程示例:
在多组件工程中,我们会发现不同的组件之间会存在相同的依赖情况。针对复杂的 Module 依赖的场景,通过 Clang源码发现,在编译 Module-lifeCirclePod(上述示例)时候,而 lifeCirclePod 依赖于 Module-UIKitPod。在编译 Module-lifeCirclePod 遇到需要 Module-UIKitPod 导入时,那么此时则会挂起该编译实体的线程,开辟新的线程进行 Module-UIKitPod 的编译。
当 Module-UIKitPod 编译完成时候才会恢复 lifeCirclePod 的任务。而开启 Module 之后每个组件都会作为一个 Module 编译并缓存,而当 MainPagePod 后续编译过程中遇到 Module-UIKitPodModule 的导入时,复用机制就可以触发。编译器可以通过读取 pcm 文件,反序列化 AST 文件直接使用。编译器不用每次重复的去解析外界头文件内容。
上述基本对 Module 的本质及其复用机制有一定的了解,是不是无脑开启 Moudle 就可以了呢?
其实不然!
我们在实践中发现(以基于 cocoapods 管理为例)在 fmodules-cache-path 的路径下存在很多份的 pcm 缓存文件,针对同一个工程就会发现存在多个下面的现象:
可以发现在工程的一次编译下,会出现多个目录出现同一个 module 的缓存情况(eg:lifeCirclePod-1EBT2E5N8K8FN.pcm)。之前讲过 Module 机制是一次编译后续复用的吗?实际情况好像与我们的理论冲突!这就要求我们去深入探究 Module 复用的机制。
追寻 Clang 的源码发现编译器进行预处理器 Preprocessor 的创建时,会根据自身工程的参数来设定 Module 缓存的路径。
我们将影响 Module 缓存的产生的 hash 目录的主要受编译参数分为下面几大类:
在实际的工程中,常常不同 pod 间的 build settting 不同,导致在编译过程中会生成不同的 hash 目录,从而缓存查找时候会出现查找不到 pcm 缓存而重复生成 Module 缓存的现象。这也解释了我们上面发现不同的缓存 hash 目录下会出现相同名字的 pcm 缓存。了解 Module 缓存的因素可以有助于在复杂的工程场景中,提高 Module 的复用率减少 Module Complier 的时间。
Tips:除了上述的缓存 hash 目录外,我们会发现在目录下存在以 ModuleName-hashxxxxxx.pcm 的命名,那么缓存文件的命名方式我们发现是 ModuleName+hash 值的方式,hash 值的生成来自 ModuleMap 文件的路径,所以保持工程路径的一致性也是 Module 复用的关键因素。
4.3 PCM
上文提到了一个很重要的文件 PCM,那么 PCM 文件作为 Module 的缓存存放,它的内容又是怎么样的呢?
提到 PCM 文件,我们第一时间很容易联想到 PCH。PCH 文件的应用大家应该都很熟悉,根据苹果在介绍 PCH 的官方文档中结构如下:
PCH 中存放着不同的模块,每个模块都包含 Clang 内部数据的序列化表示。采用 LLVM’s bitstream format 的方式存储。其中 Metadata 块主要用于验证 AST 文件的使用;SourceManager 块它是前端 SourceManager 类的序列化,它主要用来维护 SourceLocation 到源文件或者宏实例化的实际行/列的映射关系;Types: 包含 TranslationUnit 引用的所有类型的序列化数据,在 Clang 类型节点中,每个节点都有对应的类型;Declarations: 包含 TranslationUnit 引用的所有声明的序列化表示;Identifier Table: 它包含一个 hash Table,该表记录了 ASTfile 中每个标识符到标识符信息的序列化表示;Method Pool: 它与 Identifier Table 类似,也是 Hash Table,提供了 OC 中方法选择器和具体类方法和实例方方法的映射。Module 实现机制与 PCH 相同,也是序列化的 AST 文件,我们可以通过 llvm-bcanalyzer 把 pcm 文件的内容 dump 出来。
Module 的编译是在独立的线程,独立的编译实体过程,与我们输出目标文件对应的前端 action 不同,它所对应的FrontAction为GenerateModuleAction。Module 的机制思想主要是提供一种语义化的模块导入方式。所以 PCM 的缓存内容同样会经过词法,语法,语义分析的过程,PCM 文件中的 AST 模块的序列化保存是在发现在语义分析之后。
它利用了 Clang AST 基类中的 ASTConsumer 类,该类提供了若干可以 override 的方法,用来接收 AST 解析过程中的回调,当编译单元TranslationUnit的AST完整解析后,我们可以通过调用 HandleTranslationUnit 在获取到完整抽象语法树上的所有节点。PCM 文件的写入由 ASTWriter 类提供 API,这些具体的流程我们可以在 ASTWriter 类中具体跟踪。在该过程中主要分为 ControlBlock 信息的写入,该步骤包含 Metadata, InputFiles,Header search path 等信息的记录。这些 PCM 的具体内容 dump 出来如下图:
其中 Types,Declarations 等信息的写入流程发生在 ASTBlock 阶段。由于在处理处理 ModuleMap 文件的编译流程中会对 umbrella.h 中所暴露的头文件进行预处理,词法,语法,语义分析等流程。我们在使用 WriteAST 写入时,会将当前编译实体的 Sema 类(该类是 build AST 和语义分析的实现类)传递过来。Sema 持有当前的 ASTContext,ASTContext 则可以用于访问当前抽象语法树上的所有 Nodes(例如 types,decls)等信息。
如果所示:ASTWriter 将已经解析无误的 Module 信息,包括 AST 等内容写入 Module 的缓存文件 PCM 中。
我们在源码跟踪过程中可以发现会将AST节点信息等写入PCM中的ASTBlock中,我们可以通过打印获取到节点的类型和节点的名称:
通过上面源码等流程相信你掌握了以下:
ModuleMap 文件用来描述从头文件到模块逻辑结构的映射关系,Umbrella 或者Umbrella Header 描述了子Module的概念;
Module 的构建是“独立”进行的,Module 间存在依赖时,优先编译完成被依赖的Module;
Clang 提供了 Module 的新用法(@import ModuleName),但是针对就项目无需改造,Clang 在预处理时期提供了 Module 与非 Module 的转换;
Module 提供了复用的机制,它将暴露外界的 API 以 ASTFile 格式存储,在代码未发生变化时,直接读取缓存。而在代码变动时,Xcode 会在合适的时机对 Module 进行更新,开发者无需额外干预。
同城编译时间数据分析
鉴于在58同城工程上实施的编译数据时间的加长的背景,我们在深入探究 Module 构建,复用等机制后,我们针对整个编译流程做了详细的编译阶段的插桩。
5.1 分析工具
Clang 9.0 合并了一个非常有用的功能 -ftime-trace,该功能允许以友好的格式生成时间跟踪分析数据,clang中预先插入了一些点标记,如每个文件的编译时间ExecuteCompiler、前端编译时间Frontend、module加载时间Module Load、后端处理时间Backend等。接下来通过-ftime-trace查看各编译阶段的打点时间。操作比较简单,只需要在Other C Flags中添加-ftime-trace即可。
编译完成后clang会在编译目录下,为每个源文件自动生成一个json文件,文件名和源码文件相同。
每个json文件中大概会有ExecuteCompiler、Frontend、Source、Module Load、Backend等打点数据,也有Total ExecuteCompiler、Total Frontend、Total Source、Total Module Load、Total Backend这样的数据,后者是前者的一个汇总,这是clang自带的,也可以在clang中去扩展。通过chrome://tracing/可以很方便查看单个json文件的耗时分布,如下。
-ftime-trace设置后主要时间段说明:
Total ExecuteCompiler:文件编译总时间;
Total Frontend:前端编译时间,如在clang中编译时间;
Total Source:头文件处理时间,如处理import;
Total Module Load:Module的加载时间,如在Source的处理过程中,判断当前import的是一个module,则会执行此操作,如import系统库;
Total Module Compile:Module的编译时间,如第一次加载自定义的源码Module,会对Module进行编译,生成AST缓存起来;
Total Backend:编译器后端处理时间。
这些时间段都是Clang中已有的打点,从前面的chrome://tracing/图也能看出来是有一些包含关系的,如:
ExecuteCompiler 包含Frontend和Backend;
Frontend包含Source;
Source中包含Module Load(前提是如当前.m中import了A/XX.h,而A没有module化,但XX.h中import了B/YY.h,B是Module化的,如果A是module化的,Module Load不包含在Source中);
Module Load包含Module Compile。
5.2 时间段分析
先选取单个文件进行分析,将其拖到chrome://tracing/中,可看到如下数据。
从图上可看出,Total Frontend占总编译时间在都在70%以上,module编译中Total Frontend时间比非module明显要长,而Total Source占Total Frontend时间的70%左右,而Total Module Load是Total Source中最耗时的操作。结果中Total Module Load阶段,module明显是要比非module耗时更长。
上面是从单个文件进行分析,并不能代表整体项目的编译情况,因此,我们做了一个自动化工具,将所有.json文件中的对应时间进行统计汇总,得出整体各个时间段的汇总数据,如下。说明一下,我们统计的Total ExecuteCompiler指每个文件的编译时间总和,相当于在单核下编译时间,而前面显示的实际整体的编译时间少很多,是因为我们实际是在多核下编译。
从整体分析图上可看出,Total Frontend时间均占总编译时间Total ExecuteCompiler的80%以上,而Total Frontend中时间Total Source的总时间占80%以上,而在Total Source中Total Module Load时间占70%左右。总时间Total ExecuteCompiler和前端Total Frontend依然是module下更长,而在Total Frontend中Total Module Load的时长在module下明显比非module下长很多,跟上面单文件分析的结论基本一致。这里需要注意的是,Total ExecuteCompiler时间比前面统计的总时间长很多,是因为项目是在多核下编译,而Total ExecuteCompiler统计的是所有文件编译时间总和,而前面统计的时间是多文件并行编译下的时间,其它各段时间同理。
在Total Module Load中会执行Module的编译,但从上图我们可以看到其实Total Module Compile时间很短,都不超过50S,因此还需要进一步分析Total Module Load的耗时操作。为此我们根据clang中的处理流程,在clang中Module Load处理代码中扩展两个打点:
Module ReadAST:验证Module缓存并反序列化Module cache PCM文件的时长;
Module WaitForLock:一个线程在ModuleCompiler期间,其他线程需要挂起等待的时长。
并在头文件查找扩展打点:
Lookup HeaderFile :预处理阶段查找导入头文件的磁盘路径时间。
将Clang源码修改后编译生成自定义的Clang,替换XCode中的Clang分别在module和非module下再次进行编译,得出如下数据:
从图中可以看出,Module Load阶段中Module ReadAST时间占比近70%,此次编译module比非module下时间长约3%,而Module ReadAST段module比非module下时间长约2%,整个Module Load阶段module下比非module下长约4%。
因此,我们可以得出,相比非module,module化编译更为耗时,而主要耗时在验证Module缓存并反序列化操作。那么问题来了,有什么办法可以在module开启的情况下进行编译时间优化呢?
编译时间的优化
从上面的数据分析我们知道,如果底层组件进行 Module 化,并且上层组件通过module方式进行引用的话,会更耗时。但是为了支持 Swift/OC 混编,如 Swift 调用 OC,需要对组件进行 Module 化。因此,我们需要在 Module 化的基础上优化编译时间,如果上层组件不通过 Module 方式调用其它 Module 化的组件,而采用非 Module 化方式进行引用,理论上是能避免上述module化操作的耗时。
6.1 优化方案
为了进一步优化混编下的编译时间,我们参考苹果 WWDC 2023 的 header search path 中 headermap 查找方案,主要思路是通过 hmap 的方式来替换header search path 下的文件搜索,来减少编译耗时,为描述方便,我们称为hmap方案,目前业内美团对 hmap 有应用,并且有 50% 的优化效果。58同城也对 headermap 方案进行了研究并进行了落地,理想的实现方案就是做一个 cocoapods 插件,在插件中做了以下几件事:
HooksManager注册cocoapods的post_install钩子;
通过header_mappings_by_file_accessor遍历所有头文件和header_dir,由header_dir/header.h和header.h为key,以头文件搜索路径为value,组装成一个Hash<key,value>,生成所有组件pod头文件的json文件,再通过hmap工具将json文件转成hmap文件。
再修改各pod中.xcconfig文件的HEADER_SEARCH_PATHS值,仅指向生成的hmap文件,删除原来添加的搜索目录;
修改各pod的USE_HEADERMAP值,关闭对默认的hmap文件的访问。
58对应的插件名为cocoapods-wbhmap,插件完成后,在Podfile中通过plugin 'cocoapods-wbhmap'接入。
6.2 优化数据
以下是58同城分别在非 Module、Module 化和优化后的 hmap 三种场景下编译时间数据,这里的 hmap 是在各组件 Module 化的基础上使用的。
首先说明一下,这里的整体编译时间数据上跟前面不一致,是因为重新编译了,每次编译时间略有不同,但不影响我们分析。从整体时间来看 Module 下的编译时间比非 Module 下略长,而 hmap 比非 Module 下优化了 32% 左右,比 Module 下优化了 33% 左右,可以看出 hmap 的优化效果是很显著的。
接下来分析一下编译各阶段的时间,是不跟我们预想的一致,我们预想的是 Total Lookup HeaderFile 和 hmap 在 Module Load 阶段加载的 Module基本是系统库,应当时间上差不多,而由于hmap节省了在众多目录下文件搜索的时间,应当在Total Lookup HeaderFile有较大差别。
从分段数据来看,三种编译方式的 Total ExecuteCompiler 跟上述整体时间比例接近,但是 Total Lookup HeaderFile 时间都较小,自然没多大差别,而 Total Module Load 差别较大,非 Module 和 Module 下比 hmap 大 61% 左右,跟我们预想的不一致。观察数据可以看到,Module Load 中大部分时间是在 Module ReadAST 阶段,因而我们继续研究 Module ReadAST 中的处理操作。
6.3 hmap 优化了什么?
针对 ReadAST 阶段再次细分打点计时,发现在 ReadAST 阶段去读取缓存时候,会对缓存 PCM 文件的 ControlBlock 块信息进行解析,该内容包含了当前 Module 缓存引用外界其他 ASTFile 的记录。而加载外界 ASTFile 的 PCM 缓存时候,会针对该 ModuleName 进行验证确保我们不会加载一个 non-Module 的 ASTFile 作为一个 Module。它通过查询是否存在 ModuleMap 文件来描述 Module 对应当前要查询的 ModuleName。
我们将重点聚焦在这个阶段,因为我们 hmap 方案最直接的优化之处在减少了 Header Search Path 的参数路径,将预处理期间的头文件查找转换为 key-value 查找,从而减少了在 Header Search Path 众多 pod 的目录中(如private、public)的搜索时间,源码中 SearchDirs 即为这些目录,Header Search Path 中目录越多,SearchDirs 中元素更多,要遍历的目录就更多,无用的搜索时间就越长,通过单个文件进行调试发现这里消耗的时间约有 70%,而系统库的查找在这里耗时较长,因为按照编译器搜索的顺序,系统库目录的是排在 Header Search Path 后的,经过一顿徒劳的搜索之后才到系统库目录搜索,效率较低。
我们猜想前面非 Module 和 hmap 在 Module Load 时间差较大的原因应当就在此,因此在 ReadAST 阶段的 HeaderSearch::lookupModule 方法内打个点 Lookup Module,即 Module ReadAST 包含 Lookup Module,重新编译进行数据统计如下:
这里只统计非 Module 和 hmap,整体编译时间如下:
从数据可以看出,再次编译 hmap 下的编译时间比非 Module 方式同样是优化了 35% 左右。再看分段数据,如下:
从占比分析,非 Module 方式下 Total Lookup Module 时间占 Total Module ReadAST 时间的 77%,并占 Total Module Load 时间的 72%,而在 hmap 方式中,Total Lookup Module 时间占 Total Module ReadAST 时间的 35%,并占 Total Module Load 时间的 27%,远小于非 Module 方式下的占比。
从数值分析,非 Module 方式下 Total Lookup Module 时间为 1422 秒,而 hmap 方式下时间仅为 182 秒,相差 7 倍多。
上面数据也进一步验证了我们对于 hmap 编译时间优化原因的猜想。到这里我们就从数据和原理上对 hmap 方案的编译优化做了一个完整的分析。
总结
由于 Swift/OC 混编项目的需要,58同城对组件进行了 Module 化,并且尝试让所有组件通过 Module 方式进行头文件引用。但我们发现编译时间却比非 Module 情况下更长,这也与苹果官方在 WWDC2023 中的 Module 性能分析结果不符。
然后在寻求编译时间的优化方案时,发现在 WWDC2023 中有提到 hmap 机制,并借鉴业内的一些宝贵经验,采用了 hmap 方案对编译时间进行优化。Module 方案虽无法降低编译耗时,但对比之前混编的桥接方式,可增强项目向 Swift 迁移过程中混编组件的可维护性。通过 hmap 方案对编译时间进行优化,同城最终编译时间比 Module 化之前优化了约 35%,对于其它 App 的 Module 化也是有较好的借鉴意义。
作者简介
赵志:58同城-用户价值增长部
曾庆隆:58同城-用户价值增长部
顾梦奇:58同城-房产事业群
王强:58同城-招聘客户端
赵发:58同城-汽车事业群
参考文献
LLVM源码:https://github.com/llvm/llvm-project
Clang/LLVM官方文档:https://clang.llvm.org/docs/
苹果WWDC 2023 Advances in Objective-C Module相关视频:https://developer.apple.com/videos/play/wwdc2023/404/
苹果WWDC 2023 Header Search Path相关视频:https://developer.apple.com/videos/play/wwdc2023/415/
LLVM开发者大会Doug Gregor的视频和PPT:https://llvm.org/devmtg/2023-11/
ftime-trace耗时报告配置:https://blog.csdn.net/wwchao2023/article/details/109147192
美团编译速度优化公众号文章:https://mp.weixin.qq.com/s?__biz=MjM5NjQ5MTI5OA==&mid=2651760497&idx=1&sn=2042896ac13cbc9b010625c7c24897e8&chksm=bd127e3c8a65f72aab2f2e0993654593bfbe4c44db36709f909ae40ce69cb0c2e02598c0ebc0&cur_album_id=1751291735726456834&scene=189#rd
Hmap工具:https://github.com/milend/hmap
llvm-bcanalyzer:https://llvm.org/docs/CommandGuide/llvm-bcanalyzer.html
bitstream format:https://llvm.org/docs/BitCodeFormat.html
PCH结构:https://clang.llvm.org/docs/PCHInternals.html#pchinternals-modules
Modules:https://clang.llvm.org/docs/Modules.html
句子成分分析器(ACL)
ACL 2023 Long Papers
基于自关注编码器的成分句法分析
Constituency Parsing with a Self-Attentive Encoder
伯克利大学
University of California,Berkeley
本文是伯克利大学发表于ACL的论文,概述了Nikita Kitaev和Dan Klein的工作。全文主要贡献点在于使用自关注架构代替长短时记忆网络编码器,可以改善目前为止最好的成分句法分析器。作者的解析器在Penn Treebank上训练后获得了目前为止最好的效果,其中不使用外部数据时获得93.55的F1,使用其他预先训练的单词表示时获得95.13的F1。作者的解析器也优于SPMRL数据集中8种先前公布的最佳精度数据。
引言
RNNs在很大程度上取代了固定窗口大小的前馈网络,部分原因是它们捕获全局上下文的能力。然而,RNNs并不是唯一能够概括全局上下文的体系结构。文章中介绍了一种解析器,它结合使用这种自关注架构构建的编码器和自定义用于解析的解码器。如下图所示。
模型
树形记分和图表译码器
语法解析器对每个树分发了一个实值分数s(T)
其中s(I,j,l) 是一个关于成分的实值记分。
它位于句子中篱笆位置i和j之间,并具有标签l。为了处理一元链,标签集包括训练集中的每个一元链的折叠条目。该模型通过二值化和引入虚拟标签来处理n元树在二值化过程中创建的节点,具有以下性质:与虚拟标签相关联的分数总是零,确保对于n元树的所有可能的二进制化继续保持。
上下文感知词表示
编码器模型被分割为两个部分:基于文字的部分分配了上下文感知向量yt给每一个句子中的位置t。以及组合向量yt以产生跨度分数s(i;j;l)的图表部分。
编码器将一个字序列作为输入嵌入[w1; w2; :::; wT],第一个和第一个最后嵌入是特殊的开始和停止令牌。所有单词嵌入都是与模型的其他部分共同学习的。向量[z1; z2; : : : ; zT ]被转换成一堆8个相同的层,每层由两个堆叠的子层组成:多头关注机制和位置前馈子层。给定输入x的每个子层的输出是LayerNorm(x+SubLayer(x)),即,每个子层之后是残余连接和层规范化步骤。最后,所有子层输出,包括最终输出yt,大小都是dmodel。
8层中的每一层中的第一个子层是多头自我关注机制,这是信息可以在句子中的位置之间传播的唯一手段。自我关注机制的输入是一个T x dmodel矩阵X,其中每个行向量xt代表句子中的单词t。如下图中的每一个都有自己的可训练参数:这允许单词从句子中每个聚焦子层的最多8个远程位置收集信息。
位置智能前馈子层
由于在整个模型中使用了残余连接,输入和输出尺寸是相同的,但作者可以通过调整应用非线性的中间向量的大小来改变参数的数量。
来自前一节中描述的基于字的编码器部分的输出yt被组合以形成跨度分数s(i;j;),如下所示
其中LayerNorm代表层规范化,relu代表整流线性单位非线性。
合句子中相关位置的摘要向量。一个单词右侧的跨度端点可能需要与右端点不同信息的左端点。
内容与位置关注
在整个编码器中,信息传递的主要机制是自我关注,其中单词可以使用内容特征和位置信息相互联系。内容和位置信息交织在整个网络中。
将内容和位置信息混合在一个向量中,可能会导致一种关注力凌驾于另一种关注力之上,并损害网络在这两种关注力之间找到最佳平衡的能力。为此,作者提出了一个分解的模型版本,它显式地分离内容和位置信息。
如下图所示,作者的模型中的前馈子层同样被分成两个独立的部分,分别处理位置和内容信息。
或者,因子分解可以看作在整个模型中对参数矩阵实施块稀疏约束。
作者保持与之前相同的向量大小,这意味着因子分解严格地减少可训练参数的数量。为了简单起见,作者将每个向量分成包含位置和内容信息的相等的一半,将模型参数的数量大致减少一半。该分解方案能够达到开发集F1下的93.15分,比未分解模型提高了近0.5分。
这些结果表明,分解不同类型的信息会导致更好的解析器,但原则上存在一个混淆:也许通过使所有矩阵块稀疏,作者偶然发现了更好的超参数配置。例如,这些增益可能仅由于可训练参数的数量不同而导致。
模型分析
为了检查在作者的架构中基于内容和基于位置的关注的相对利用,作者在测试时通过选择性地将内容或位置组件对任何关注机制的贡献归零来干扰经过训练的模型。这可以在不同的层上独立完成。作者的模型学习使用两种关注类型的组合,其中基于位置的关注是最重要的。作者还看到,基于内容的关注在网络中较晚的层更有用,这与作者模型的初始层的行为类似于扩展的卷积网络,而上层在两种关注类型之间具有更大的平衡相一致。
作者还可以通过将窗口应用到注意机制来检查作者的模型对长距离上下文信息的使用。如下表所示,严格开窗的结果很差:即使是大小为40的窗口,与原始模型相比,也会导致解析精度的损失。
下表中还示出了在测试时间内轻松开窗的结果。
接下来,作者检查解析器对远程依赖项的使用是否对通过重新训练作者的模型以适应窗口化而实现任务至关重要。为了评估全局计算的作用,作者同时考虑严格窗口和松弛窗口。原则上,作者可以在训练时用全局计算的显式规定来代替松弛的窗口,但是为了分析的目的,作者选择最小化与原始架构的偏差。
下表所示的结果证明,对于使用作者的模型实现最大的解析精度,远程依赖仍然是必不可少的。严格和松弛窗口之间的旁侧比较表明,使用松弛方案中始终可用的指定位置来汇集全局信息的能力一致地转化为精度增益,但不足以补偿小窗口大小。这表明,原则上不仅必须能够得到来自远程令牌的信息信号,而且还有助于在不存在中间瓶颈的情况下直接访问该信息。
词汇模型
为了在不引入任何对外部系统的依赖的情况下恢复性能,作者探索将词汇特征直接结合到作者的模型中。本节中描述的不同方法的结果如下表所示。
考虑到自关注编码器在句子级上的有效性,将其看作子词结构同样具有美学吸引力。然而,它在经验上要慢得多,没有比字符级LSTM更好的并行化(因为单词往往很短),并且初始结果不如LSTM。一种解释是,在词汇模型中,人们只希望计算每个单词的单个向量,而自我关注的体系结构更适合于在序列中的多个位置生成上下文感知摘要。
作者的方法能够捕获子词信息和上下文线索:嵌入是由网络产生的,该网络以字符作为输入,然后使用LSTM捕获上下文信息,当为句子中的每个词生成向量表示时。
这些预先训练的单词表示是1024维的,而到目前为止作者所有的因式分解模型都有512维的内容表示;作者发现解决这种不匹配的最有效的方法是使用学习到的权重m将ELMo向量投影到所需的维度阿特里克斯随着语境化词语表达的增加,作者假设不再需要完整的8层自我关注。这在实践中证明是正确的:作者用四层编码器获得了95.21F1的最佳开发集结果。
实验分析
下表总结了前面部分中给出的解析器变体的开发集得分。性能最好的解析器在ELMo单词表示上使用了因子化的自关注编码器。
作者在测试集上评估模型的结果如下表所示。
作者通过九种语言上进行训练来测试作者的模型在语言之间通用的能力。除了学习率,作者在SPMRL任务中对一些较小的数据集进行了调整。结果如下表所示。
在9种语言的8种语言中,只在单系统条件下进行评估,作者的测试集结果超过了作者已知的任何系统中以前发布的最好的分数。
结论
在本文中,作者证明了编码器的选择对解析器的性能有很大的影响。特别地,作者使用一种基于因数自关注的新型编码器来演示解析结果。作者看到的益处不仅来自于合并更多的信息(例如子词特征或外部训练的词表示),还来自于结构化体系结构以将各种不同的信息彼此分离。作者的研究结果提示,进一步研究不同的编码方法可以导致对解析和其他自然语言处理任务的进一步改进。
以上内容是关于描写老奶奶外貌的句子一段话和描写奶奶外貌的句子段落100字的内容,小编幸苦为你编辑整理,喜欢的请点赞收藏把。