如何用语文知识改善代码可读性

5,279 阅读18分钟

我们经常能看到许多技术文章从工程角度介绍各种编码实践。不过在计算机科学之外,编程语言和自然语言之间同样有着千丝万缕的联系。下面我们会从高中水平的语文和英语出发,分析它们与代码可读性之间的关系。如果你看腻了各种花哨的技术新概念,或许回归基础的本文能给你一些启发🤔

编程语言与高考作文

大师所编写的代码与其说是给计算机看倒不如说是给人看的。真正的大师级程序员所编写的代码是十分清晰易懂的,而且他们注意建立有关文档。

——《代码大全》

不妨思考一下,我们对某段代码【十分清晰易读】的评价,比起对某篇文章【写得通俗易懂】的评价,是否具有相近的评价标准呢?进一步说,编程语言的代码和自然语言的文章之间,是否存在着某些技术之外的共通性呢?这里我们拿出和代码一样死板的高考作文作为对比,不难发现一些有趣的相似之处:

  • 代码很难正确预测需求的变更,而高考作文也很难从题目提炼出主题立意。
  • 代码编写前要做好架构设计,而高考作文落笔前也要好好构思。
  • 代码要做好模块化、组件化,而高考作文也要求段落划分恰当、衔接紧凑。
  • 代码的 Warning 越少越好,而高考作文的语病也是越少越好。
  • 代码要求排版、缩进格式正确,而高考作文也要求字迹工整。
  • 代码要求尽量少复制粘贴,而高考作文更是严禁抄袭。
  • 代码写得好的人很少,而高考作文写得好的人可能更少。
  • ……

是不是有着不少相近之处呢?不过,高考作文的记叙、议论、抒情等文体已经是人类思维的高级抽象,尤其是抒情文这类涉及感情的文体,其内容与理念是很难和讲求逻辑的程序代码做类比的。并且,编写作文所用的汉语也更不是主流编程语言所用的英语,这也就意味着从中文作文的角度着手分析可能过于宏大且不够贴切。因此,下面我们会改从英语的角度来探讨代码与语言之间的关系。

词性与关键字

中英文里都有词性的概念,词汇可以分类为名词、动词、形容词、连词、代词等不同词性。而在计算机语言中,内置的【词汇】就是 for / if / else 这些关键字了。那么这些关键字的词性,和计算机语言的性质之间有什么关系呢?

实际上,不同用途的计算机语言,其关键字中对词性的选择会有很大的不同。请注意,编程语言其实只是计算机语言的子集。比如,经典的前端三件套 HTML + CSS + JavaScript 中:

  • HTML 是标记语言
  • CSS 是样式语言
  • JavaScript 是编程语言

它们都归属于计算机语言,但它们各自所用的关键字,在词性上有什么区别呢?

  • 提到 HTML,我们首先会想到 <head> / <body> / <img> / <table> 这些标签。这些标签的名称都是名词
  • 提到 CSS,它的典型代码就是形如 .xxx { background: black; } 这样的规则。这里,规则的名称基本都是形如 background / position / width / color名词,而规则的值则常见各种形容词
  • 提到 JavaScript,除了 function / var / class 这三个名词以外,它的控制流逻辑几乎都是由 if / else / for / while 这些虚词控制的。并且,还有大量 return / break / new 这样的动词

我们可以发现,标记语言和样式语言中,关键字几乎完全由实词组成,完全不需要虚词的起承转合。而实词能够表达什么呢?它能够表达一个东西是什么。所以,HTML 和 CSS 中,你需要告诉机器的是你想要的是什么,而不关心怎么去实现。比如,你告诉 HTML 解析器这里有个 <img> 图片,而无需操心图片的格式、如何载入等细节;你告诉 CSS 引擎去把标题颜色渲染为红色,但无需关心布局的如何计算、GPU 如何渲染等实现方式。因此,在计算机科学中我们把 HTML 和 CSS 归为声明式的语言,而这类语言的一大特色就是其关键字几乎全部是实词

声明式的语言一般而言比较简单易懂(类比一下,你觉得最难维护的代码是 HTML 和 CSS 吗?),而三件套中剩下的 JavaScript 显然不是这样。为了搞懂它所用关键字词性和它作为编程语言之间的关系,我们有必要更详细地对它的常见关键字做一个分类:

名词
function var class

动词
import export extends return break continue
delete switch new try catch throw yield

介词
for in else

连词
if while

代词
this

联想一下编程语言日常的使用场景:告诉浏览器要先请求某个接口、拿到数据后如果格式怎样怎样就做什么什么事情、如果点击确定那么发一份新数据看后台回复了什么……这些内容所编写的代码都是在描述问题怎么做而非问题是什么。所以,编程语言需要大量的虚词,来用分支、循环等方式表达等各个语句间的逻辑关系。这种代码的【文体】就是所谓的过程式编程了。

除了出现许多表达控制流的虚词以外,编程语言的一大特色在于它具备大量的动词作为关键字。如果说 function / var / class 能够让我们定义基本数据概念的话,大量的动词关键字则提供了对这些概念的操作能力。比如,我们会用 import / export 来操作 模块 这个概念模型;用 try / catch / throw 来处理 异常 这个概念模型;用 new / extends 来处理 这个概念模型……所以,过程式的编程语言中需要大量的动词,来表达对数据的操作

对动词的使用并不仅仅体现在关键字中,在实际的编码实践中也会大量运用。比如,Python 2 中的 print 语句在 Python 3 中变成了 print 函数,不就说明日常编写的函数和语言关键字之间是可以互相转化的吗?所以,在我们编写对数据的处理代码时,相应的代码也应当能够用命名为动词的函数来封装。当然,真实世界中的函数定制型一般非常强,比起编程语言中的精粹关键字来要具体的多,因此函数名多半不能简单地用一个动词来表达,这时候用一个形如 getElementById动宾短语结构来命名函数,就能够达到很好的效果了。

在现代的编程语言中,除了变量、类对应的名词;函数、方法对应的动词、控制流对应的介词、连词以外,还有一类非常特殊的存在:this 对应的代词。代词在编程语言中起到了什么作用呢?自然语言中,代词可以在语境中自然地指代先前提到的概念,而 this 则用来指向某个上下文中的引用,在概念上是不是非常接近呢?

不过遗憾的是,从类比自然语言的角度来看,JavaScript 中的 this 初始设计是十分失败的。在早期的前端开发中,this 经常不能够在代码的【语境】中指向你所认为自然的地方,而是有各种奇怪的规则来指向不同的上下文。对这类语言机制上的缺陷,社区也做了不少改进,来让使用了 this 的代码更易写易读。这其实也可以理解为自然语言的可读性对编程语言设计的影响吧。

句型与表达式

自然语言中,我们可以将词汇整理为句子,而句子则具有不同的结构,如陈述句、祈使句、疑问句、感叹句等。

类比到编程语言中,在一门语言的新手课程里,一般会提到 Statement 语句和 Expression 表达式的概念。比如,if (color === 'RYB') fxck(); 整体就是一个语句,而其中的 color === 'RYB' 则是一个表达式。

编程语言的语句、表达式比起自然语言的句子,它们之间有什么关系呢?祈使句、疑问句、感叹句都夹杂了一定的感情,和我们的主题不太相关。让我们从自然语言中最简单的陈述句语序来做些探索吧。我们选出其中两种最具有代表性的结构,即主谓宾结构和主系表结构:

S + V + O 主谓宾结构

孩子去上学。

这就是一个非常容易理解的主谓宾结构了。这个结构也非常容易对应到编程语言里的代码:

child.go(school);

主语对应一种数据模型,谓语对应函数方法,宾语对应函数的参数。没有问题,非常清晰易读吧?

S + V + P 主系表结构

学校是黑色的。

主语 + 系动词 + 表语的结构同样非常易读。但这里存在着一个非常大的陷阱:自然语言不区分语句和表达式,而上面这句话既可以理解为语句,也可以理解为表达式:

  • school = 'black'; 是一种语句类型的代码实现,语句没有返回值。
  • school === 'black' 是一种表达式类型的代码实现,表达式会返回 truefalse

这样就出现了非常大的歧义了:这句话在翻译为代码的时候,到底指的是 把 school 赋值为 'black',还是 判断 school 是否为 'black' 呢?在控制流里,这样的歧义就会造成问题:

if (school = 'black') fxck()
if (school === 'black') fxck()

在表达 如果学校是黑色的,那么 xxx 时,就会在代码里造成混淆。上面的代码里,前一种不管学校黑不黑都会 xxx,而后一种才是合理的实现。

从这个例子中我们可以发现,在将可读的自然语言转换为代码逻辑时,自然语言的简单陈述句可以对应到编程语言的语句上,而表达逻辑的复合句中,从句则更接近表达式的概念。编程的一大挑战就是去理清自然语言中模糊不清的逻辑,这需要对编程语言的学习和不断的训练才能更好地做到。

时态与同异步

词汇可以组成句子,而英语中的句子是存在时态的概念的。巧合的是,数据的状态也是程序运行时非常重要的概念。在这里,我们也能建立很好的类比关系。

同步和异步,在真实世界的程序中非常常见。比如用户在页面上点击确定按钮向后台提交数据的时候,网络请求和响应就需要时间来传输,请求的结果就是异步展示的。那么,同步与异步能够类比到自然语言中的什么时态呢?

同步代码不存在时态的问题,你大可以用一般现在时来命名变量和函数,整个执行流程会十分清晰。但牵扯到异步时,你就会发现在你访问某个变量的时候,它可能还没有值。这时候怎么处理呢?

Promise 对象是处理异步逻辑的一大利器。一个 Promise 具有 pending / resolved / rejected 三种状态,我们可以用 resolvereject 来在状态间迁移。这里我们表达操作的命名仍然是动词,但这时我们可以注意到,不同的状态是用现在进行时现在完成时来命名的。更一般地,我们可以抽象出这样的规则:

  • 表达【进行中】的状态变量用现在进行时命名。
  • 表达【已完成】的状态变量用现在完成时命名。

这样一来,我们就能够把自然语言中对时态的思维模型,平滑地迁移到代码里表达异步的状态中,从而让代码更加易读了。

文法与编译器

上面的诸多内容其实都仅仅是 Grammar 语法层面的内容,但【语文】的外延是【语言学】这一学科,其研究领域远不仅仅是高中语法知识这么简单。可以非常肯定地说,作为文科的语言学,对编程语言的设计和实现都有着非常重要的影响。这么说有什么根据呢?让我们从语言学中的一个分支【句法学】说起吧。

句法即 Syntax,编译器的常见报错 SyntaxError 指的就是句法错误。句法学的研究领域中,涉及到了对句子的结构分析。早期的研究者们提出过两种分析法,即【双切分法】和【方括弧法】。比如下面的句子:

The teacher abuses a child.

按照双切分法,我们先把整句话一分为二,然后把谓语分开,最后分解名词短语,就可以一步步地得到这样的结果:

第一次切分
The teacher / abuses a child.

第二次切分
The // teacher / abuses // a child.

第三次切分
The // teacher / abuses // a /// child.

这样我们就能够拆解出句子的主谓宾结构了。

而方括弧法的解释方式则是这样的:

[3 [1 The teacher] [2 abuses [1 a child]]]

我们先为名词短语 The teachera child 添加方括弧,然后组成更高层的动宾结构,最后合成为句子。

那么这两种方法和编程语言有什么关系呢?从上例中我们可以看到,双切分法的处理方式是自顶向下,而方括弧法的处理方式是自底向上。如果了解过编译原理的同学,看到这里应该会立刻想起语法分析器中的 LL 算法和 LR 算法吧?LL 算法递归向下地处理代码语句,而 LR 算法则是自底向上地归约词法元素。所以,编译器前端在将代码字符串转换为语法树的时候,语法分析算法的运行方式和语言学中的方法论是共通的

除了结构分析外,句法学对编程语言的一大贡献在于它提出了如何定义一门语言的方式。在句法学的课本中,会提及 Chomsky 在 1957 年提出的《句法结构》一书,这本书中提出了生成文法的概念,能够抽象地用数学符号定义任意一门语言。比如一条表明【名词短语(Noun Phrase)包含形容词(Adjective)和名词(Noun)】的句法规则,形如:

NP → A, N

这样,语法树中 NP: damn school 的节点就能被拆分为 A: damnN: school 的子节点了。推广到计算机语言,这个文法同样适用。比如这条规则:【一个 HTML 标签(Tag)要包含开始标签(TagOpen)、值(Value)和结束标签(TagClose)】:

Tag -> TagOpen, Value, TagClose

通过这样的句法规则,我们就能把 HTML 树中形如 <p>123</p> 的字符串拆分为 TagOpen: <p>Value: 123TagClose: </p> 的三个子节点了。在现代的 LLVM 编译器前端中,我们只需要提供这样的句法规则,就能够定义出自己的一门新计算机语言了,是不是完全相通呢?所以,Chomsky 文法在《编译原理》中也有详细的介绍,这也是一个计算机科学中横跨文理的概念了。

值得一提的是,实现一个语法分析器的轮子是件很有趣的事情。笔者在大学时的编译原理大作业中,实现的就是一个 JavaScript 版的 LALR 语法分析器。这个过程能让你深刻地认识到弱类型语言到底有多坑…欢迎有兴趣的同学尝试😀

语义与作用域

在语言学的范畴中,还有一个和编码密切相关的地方出现在【语义学】中。不太准确地说,这个学科研究的是【词汇到底有什么涵义】的问题。比如,一个 望远镜 在什么时候会让人觉得恶心?这其实可以对应到编程语言中另一个非常重要的概念,即作用域

语义学中认为,名词指称事物、动词指称行为、形容词指称属性、副词指称方式,故而词语具有指称的功能。词语的指称分为有指和无指两种,比如 school 能够指代明确的对象,是为有指;而 if 指称的是抽象的条件和假设关系,是为无指。有指可以进一步分为衡指和变指两种。比如,长城 就是一个所指对象不变的衡指,而 he 就是所指对象因场合而变的变指。在不同的语境下,词汇的实际指称会发生改变。

衡指和变指的概念,是不是和编程语言中的全局变量很接近呢?比如,document 就是一个总是指向 DOM 的全局变量,而 this 的指向就会因代码所在的上下文而变。在语言中明确而无歧义的指代关系,恰好能够和编程语言中严谨的作用域相类比。

自然的指代关系,能够让我们在写文章时流畅许多。而代码中的局部变量,只要处在受限的作用域(如函数作用域)中,就可以有精炼易读的命名。如果没有作用域机制,我们就必须用笨拙而沉重的命名约定来防止指代不清和重名(例如早先前端的 BEM 命名法)。

所以,我们不妨把作用域机制也当做现代编程语言为了【更接近自然语言】所作出的努力。在为变量起名的时候,我们更可以从语义学的角度来考虑这个变量名的指称是什么,具备怎样的涵义。

总结

从自然语言来类比编程语言,我们确实能发现很多从技术角度被忽略的地方:

  • 代码中词汇的词性和编程语言机制密切相关。
  • 代码语句和自然语言句型间有着细微的歧义。
  • 程序的异步状态处理可以类比时态。
  • 源代码的解析方式其实来源于语言学中的句法学。
  • 基于作用域机制的变量命名可以参考语言学中的语义学。
  • ……

可以看到,编程并不是理科生的枯燥工作,它和人文学科之间的关系同样紧密。

不过,有的同学可能会有疑问:写了这么多,到底该怎样能写出更好的代码呢?这不是一篇文章能够解决的问题。宽泛地说,这仍然需要多做、多思考、多向更好的代码学习。如果本文能够激发你对于编程和人文间某些交叉点的兴趣,那就足够啦。

在写作本文的过程中,笔者还有了一个额外的发现,那就是如果人工智能会取代人类,那么编程恐怕也是最后几个被取代的岗位。从上面的论述中我们可以发现,好的代码需要清晰的模块拆分和流畅的表达,而这两点实际上都和人文科学有着莫大的关系。这个角度上说,编程并不是重复性的工作,而是智慧的沉淀。

由于作者只是计算机科学和语言学的【用户】而非【研究者】,因此本文的内容其实非常粗浅,对于错漏,希望这两个领域中更加专业的同学斧正。

最后,这个专栏后续还会不定期更新一些将编程与真实世界现象相结合的杂文。有兴趣的同学,欢迎关注作者的掘金或 Github 哦😀