计算机语言学习导论[C/C++]
作者:@幻の上帝
1 前置条件
语文其实挺重要,这个没问题,但容易被忽视。当然,如果不是经常要折腾文档,要求不高;但起码要能说清楚话。
数学重要,主要是广度,作为快速学习相关领域知识的基础。深度上面可深可浅,若只是学习语言,初中水平的基础足够。(不过要用标准库的complex什么的当然不止了。)而大部分人缺的要补的数学并不是学校里的内容,更偏向于类似小学奥数之类。专业点的说法是离散数学(其实也只是最开始的一些基础)。当然,数学基础好的有些东西(比如逻辑运算)理解起来会简单一点。
英语重要是对于长远发展,主要在于两点:一是非英文资料比较落后匮乏,二是质量上普遍是英文的高。翻译过来的质量参差不齐,名著还好说,稍微冷门点的东西就没底了。而且,很多用词不统一,对初学来说是个小麻烦。水平要求其实也不高,不用CET4,看熟了就行。至于学习语言本身,分得清52个字母就行。(不过标准库的ctype和locale等的使用需要有这些常识。)
2 语言和实现
一般意义上的语言包括文化等方面,这里不提。这里说的最一般的语言是指形式语言,是数学分支研究的内容,如果不是这方面专业或者要实现一个语言,可以先略过。但是至少应该清楚要学的是什么。作为编程语言,研究方法和一般的语言是不一样的,因为有很多特定于具体语言的内容,无法通用。但是,对于编写所有运行于计算机上的程序所需要的语言(主要的是编程语言)来说,有些公共的内容的常识性理解是必要的。这里首要的一个概念是语言的实现,简称实现。
所谓实现,按中文来说至少有两个含义。第一个含义是指过程(implementating),另一个是这种过程的结果(implementation)。通常单独的“实现”作为名词指后者。实现的过程粗略地概括就是让一个语言表达的意义在某个特定设备上体现出来的手段。广义来说,这里的“设备”可以指人脑,那么实现过程就是学习和理解的过程;实现的结果与之对应,即掌握——当然,一般只讨论对于计算机来说的狭义含义。对计算机来说,编程语言的典型实现分为编译型和解释型实现,实现的结果就是一个特定的环境(可能包括操作系统等),尤其是编译器/解释器等关键的程序;而实现作为过程就是指使环境从无到有。
特别应该注意的是,语言不是实现,尽管很多时候“语言”可以包含语言实现的含义——这仅是作为统称,且是在不会混淆的前提下。
3 计算机语言的一些简单分类
按用途来说,计算机使用的语言可以分为通用的(general-purposed)和特定于领域的(domain-specific)。
编程语言按抽象的能力通常可以笼统地分为低级语言和高级语言。低级语言就是所谓的1GL(1st-generation language)即机器语言,以及2GL即汇编(2nd-generation language)。从作为3GL(3rd-generation language)的算法语言开始,就是通常所说的高级语言。4GL和5GL并不是常见的通用程序语言,更侧重于特定用途或问题的描述,而不是问题的解——由于目前技术的限制,4GL和5GL编程并不能用于直接通用地解决问题,因此3GL仍然大行其道,所谓的“编程”通常也就是指编写3GL程序。
C和C++是通用编程语言,且是两种不同的3GL。不过因为无论是语言还是实现都有相似之处。
通用语言对于DSL(domain-specific language)而言,一般适合解决问题的范围更广,但对于特定领域的问题,往往不如DSL。例如,对关系数据库的操作一般使用的结构化查询语言(SQL)就是一种DSL。
4 编程语言的实现方式
编程语言的实现方式可以分为编译型实现和解释型实现。两者的一个显著的直观区别就是,前者的实现包含编译器(compiler),而后者的实现包含解释器(interpreter)。编译器和解释的输入是源代码(source code),一般是被人类可读的文本。
编译器负责把源代码文本转换为目标代码(object code),在机器上直接加载运行。解释器则直接按字面上的顺序逐步加载源代码(一般就是文本)并立即执行,它在运行时直接根据这些代码执行相应操作以体现程序的行为。
两种实现方式各有优缺点。编译型实现适合深度优化,而解释型实现对语言特性的限制比较少。因此一个语言选择是编译型实现还是解释型实现,除了需要考虑环境外,还要看语言特性支持。
尽管C/C++本身并不禁止解释型实现,但语言特性注定了编译型实现更有实用价值,实现技术也比较成熟。
一种效果介于编译型和解释型之间的流行的语言实现方式是进程虚拟机(process virtual machine),或应用程序虚拟机(application virtual machine),它在宿主环境(一般是一个操作系统上)作为一个进程运行,模拟物理机器,但它运行的代码不是物理机器的处理器(CPU/GPU/coprocessor等)原生支持的机器指令,一般是特定的字节码(bytecode)。进程虚拟机提供高级语言执行环境和宿主环境的隔离,因此运行的目标代码即是跨平台的。注意它和解释器的不同:加载的不是源代码文本。最早的著名的进程虚拟机是用于实现Java语言的JVM(Java virtual machine)。虚拟机运行的字节码经过JIT(just-in-time)编译后,性能可以接近编译型实现。
注意虚拟机作为程序,和语言类似,也具有对应的实现。虚拟机或者虚拟机实现都不保证和语言一一对应。例如Java语言可以在JVM的实现如Java HotSpot VM上实现,也可以在Dalvik VM(Android所使用的就是这个,它不符合JVM Specification,因此不是JVM)上实现;而JVM可以也可以实现Scala等其它语言。微软提交标准化的公共语言运行时CLR则更明显,可以在上面实现C#、C++/CLI、F#等各种语言。
实现字节码表达的行为的程序称为字节码解释器(bytecode interpret)。有些字节码解释器虽然不强调环境隔离,某种意义上也可以算虚拟机,如Emacs Lisp VM。字节码并不只用于通用语言实现,还可能隐藏于特定应用的底层,如TrueType bytecode interpreter。
另外一种比较特殊的实现方式(其实可以算是使用方式)是借助宿主语言(host language)。语言实现对宿主语言提供接口,然后宿主语言中可以使用这些接口以字符串的方式对语言片段进行操作。C/C++作为宿主语言,一个简单的例子是stdlib的system()函数,它的参数字符串作为脚本语言交给命令行环境(如*NIX shell或Windows的命令行解释器)执行。其它可以以C/C++作为宿主的有SQL、Lua等。
可以看出,无论是编译型实现和解释型实现都涉及到把一种代码(源代码)转换为另一种代码(目标代码)的过程。这种过程统称为翻译。实现翻译的程序称为翻译器(translator)。上述实现方式对应的代码转换程序是翻译器的特例:
若一个翻译器把高级语言翻译为低级语言,这个翻译器是编译器;
若一个翻译器把高级语言翻译并使之紧接被执行,这个翻译器是解释器;
若一个翻译器把低级语言翻译为高级语言,这个翻译器是反编译器(decompiler);
若一个翻译器把汇编语言翻译为机器语言,这个翻译器是汇编器(assembler);
若一个翻译器把机器语言翻译为汇编语言,这个翻译器是反汇编器(disassembler)。
此外也有实现逆向过程的与不直接用于计算机上实现语言(如把一种高级语言转换成直接供人类阅读的文本)的翻译器。
对于用户而言,平时所使用的编译器,实际上是包含一组翻译程序在内的程序集。这些程序的输入是文本的源代码,输出的是机器语言的目标代码,因此能被整体抽象为一个编译器。接收用户最初输入的程序(如GCC的gcc和g++,VC++的cl)往往被误称为编译器,这是不正确的。它的主要功能是调用编译及其它相关过程需要的程序,确切的说法是编译器驱动器(compiler driver)。
翻译器一次接受的输入被称为翻译单元(translation unit)。翻译单元往往也会在语言规范中被明确定义,并且可能在一个程序中允许出现多个翻译单元,如:
ISO C11(N1570)
5.1.1.1 Program structure
1 A C program need not all be translated at the same time. The text of the program is kept in units called source files, (or preprocessing files) in this International Standard. A source file together with all the headers and source files included via the preprocessing directive #include is known as a preprocessing translation unit. After preprocessing, a preprocessing translation unit is called a translation unit. Previously translated translation units may be preserved individually or in libraries. The separate translation units of a program communicate by (for example) calls to functions whose identifiers have external linkage, manipulation of objects whose identifiers have external linkage, or manipulation of data files. Translation units may be separately translated and then later linked to produce an executable program.
ISO C++11
2.1/1 1 The text of the program is kept in units called source files in this International Standard. A source file together with all the headers (17.6.1.2) and source files included (16.2) via the preprocessing directive #include, less any source lines skipped by any of the conditional inclusion (16.1) preprocessing directives, is called a translation unit. [ Note: A C++ program need not all be translated at the same time. —end note ]
5 语言实现的典型组成和程序的典型实现过程
对于C/C++支持多个翻译单元的语言,在编译得到多组目标代码后还需要组合成为完整的程序,称为链接(linking)。编译型实现的链接过程一般由独立于编译器外的链接器(linker)实现。(对于具体实现,有时可能还需要考虑装载器(loader)的行为。)
编译型实现一般是包含编译器驱动器、被编译器驱动器调用的翻译器、链接器在内的多个可执行程序和若干库的组合。
解释型实现则相对容易做得比较紧凑,比如单一的可执行的解释器(当然环境往往需要其它程序或操作系统提供支持)。
借助宿主实现时,宿主程序可以只存在被实现的语言的代码,通过一个可调用的对应的外部的实现(基本上都是解释器)执行;但若语言能够实现得足够轻便那么也可以直接让宿主程序具备实现语言的功能(比如直接链接到某些库上)。
此外,程序运行时的某些公共行为(例如动态地获取类型信息)只能在运行时确定,由运行时环境(runtime environment)提供支持。其中的程序除了可能存在的虚拟机外,一般实现为供用户程序调用的库,称为运行时库(runtime library)。
若编译得到一个简单程序,只需要向编译器驱动器传递信息指定要编译和连接的代码以及需要调用的选项。但是,若有更复杂的需求(例如单独指定每个翻译单元调用的翻译器),直接调用编译器驱动器就力有不逮了,通常需要调用编译器中的各个翻译和其它辅助程序以完成程序的生成
6 使用语言实现以外的工具
可以理解,程序的翻译单元一多,手动调用的翻译程序命令行的重复工作也越多,低效易错。解决方法是使用构建系统(build system)完成自动化构建(build automation)。(确切地说,这里只是按需自动化(on-demand automation),此外还可以定时构建等等。)
原理是很简单的:生成程序的流程无非是那么几种,那么把重复的工作交给工具自动完成——还可以生成程序以外的东西,例如文档。
由于需求的复杂,命令行往往是不够的,需要脚本DSL来辅助。最流行的手法之一是使用makefile,通过make工具调用。这在大型(尤其是*NIX背景的)项目中可能起到非常重要的作用。
make可能仍然不够自动化,还有automake等工具。make可能过于复杂而不直观,有CMake等替代……
对一些不大的程序,用make这样的命令行构建工具可能不够简便,加上编写程序时自然还有使用编辑器和其它工具的需要,一个提供图形用户界面(GUI)的集成开发环境(Integrated Development Environment, IDE)可能是更好的选择,如Visual Studio、Code::Blocks、Qt Creator、Dev-C++、CodeLite、Eclipse、NetBeans、KDevelop,等等。IDE提供的GUI允许用户不用记忆具体工具的命令行用法,但对于熟练用户可能没什么优势,而且对系统开销一般较大,所以他们宁可使用编辑器+命令行来编写程序。
这里的要点是,IDE不属于实现,虽然和计算机上的语言实现同属软件范畴。当然,IDE更不可能和语言等同。事实上,绝大多数IDE可以供用户选择切换多种语言和语言实现。
其它如版本控制、设计工具等和编码的直接联系较少,学习时可以酌情使用。
扩展阅读A 可复用的语言实现的一般架构
语言实现有许多相似之处。通常需要考虑到多个目标平台(target);而相同的语言,文法和语义规则检查,逻辑上是一致的。这样,实现可以分为对应语言的前端(front end)和对应目标平台的后端(back end)。这样的架构还允许不同的前端共用后端,减少跨平台语言实现的工作量。GCC的后端(注意GCC同时是总的实现(GNU Compiler Collection)和C编译器(GNU C Comiler)——包含C的前端和公用的后端的名称)、LLVM、JVM、CLR等都支持多个前端。
若实现中前端和后端一一对应,它们之间的交互可以被隐藏,只用于实现内部。否则,这样的交互一般通过中间代码(也可以说是语言,和被实现的语言并不对应,如对于C++原生实现有GCC的GENERIC和GIMPLE、LLVM IR等,对于使用VM的Java实现有JVM bytecode、Dalvik bytecode等)来进行,前端的输出成为后端的输入。
实用的实现需要考虑优化。若存在中间代码,部分优化操作可保持和被实现的语言无关,集中对中间代码进行,这部分实现称为中端(middle end)或(中间代码)优化器(optimizer),实际上的架构是前端-中端-后端。包括GCC和LLVM在内的许多传统编译型实现都使用这种架构。
如果你是新手,那么建议前面的部分先过一遍,到后面C/C++语言/实现上的具体实例时,再回顾这些概念。但是其中有些东西是新手迟早必须知道的,否则新手永远是新手。加粗部分无论如何至少得眼熟,而红字必须记牢。当然如果有可能,尽量先使用搜索引擎看看自己能理解到哪个程度,以后需要学的就会少一些。
注意本文标题是《计算机语言学习导论》,讨论学习需要注意的事项为主,并不会过多讨论如何使用语言,不代替教材。(相关C/C++学习,请参照《C primer plus》& 《C++ Primer》 & 《Essential C++》)
辅助材料I 体例说明
粗体字表示需要读者注意的要点。红字引申07th Expansion《うみねこのなく顷に》中的用法,表示不言自明、约定俗成或存在权威依据的公认的事实。
(话说我一直都这样用,不知道有谁注意了没有。)
7 编程语言和实现的规范
无论是语言的规则还是语言实现的行为,都需要有一定的稳定性——剧烈的变动很容易使用户无所适从。而当语言对应多个实现时,这点的重要性得更显著:以现在的技术,无法假定所有代码都有在不同实现环境上的足够的自适应性(能表现预期行为),无法总是消除在不同实现之间迁移代码的需求。
所以,对于如何修订和实现语言,共识是有必要的。同许多其它技术领域一样,这里的共识在书面上的最终正式形式就是技术标准(technical standard),简作标准 (standard)。这里的标准文档的主要内容都是需求(requirements)集,也称为标准规格(standard specification),简称规格或规范(specification)。(非标准规格的技术标准还包括标准测试方法、标准定义、标准单位等。)
建立和实施标准的过程称为标准化(standardization)。各个领域的标准化一般由少数几个标准化组织(standardsorganization)进行。在语言设计和实现领域的影响比较大的标准化组织机构主要有两个,一个是国际标准化组织(ISO)和国际电工委员会(IEC)成立联合技术委员会(JTC)下属的专管编程语言的子委员会(subcommittee)ISO/IEC JTC1/SC22,另一个是欧洲计算机制造联合会(ECMA)发展的面向信息与通信技术和消费电器的全球标准化组织Ecma International下属的技术委员会Ecma TC49(前TC39)。
举例说明:
C语言和C++语言的标准化分别由JTC1/SC22下的两个工作组WG14和WG21实行,出版的标准ISO/IEC 9899和ISO/IEC 14882,分别通称ISO C和ISO C++。
一种重要语言ECMAScript(有在浏览器流行的方言JavaScript、Jscript等)标准化为ECMA-262,ECMA-262 5.1被采纳为ISO/IEC 16262:2011。
也有其它非标准化组织出产规范,虽然实际同属于标准规格,但不称为标准。例如Java语言规范The Java(TM) Language Specification,最终由专门机构JCP执行委员会(Java Community Process ExecutiveCommittee)决定。(Sun曾经提交到Ecma进行标准化,但随后撤回;在ISO的标准化方案也不了了之。)
这些规范的共性是决定了一种标准语言。具体的每一个实现可能并未完全包含标准语言的内容,还可能有扩展(extension),事实上相当于约定了一种和标准语言有一定差异的语言,借用Lisp实现的习惯,称为方言(dialect)。若一个实现和标准文档中的某些需求定义的一致,这个实现在这些需求上遵照(conforming)标准,否则就是非标准的(non-conforming)。
此外,也有语言实现环境的规范,如The Java(TM) VirtualMachine Specification和ECMA-335(Common Language Infrastructure(CLI))。这些环境自身又可以有不同的实现。
除了以上的正式规范,一些权威的(通常是发明者的)专著会被作为事实(de facto)标准,在标准化前起到一定的类似作用。如Ken Thompson和Dennis Ritchie的著作The C Programing Language中的语言称为K&R C,Bjarne Stroustrup的Annotated C++ ReferenceManual中的语言称为ARM C++。事实标准往往是正式标准的基础,但显然不会解决在标准化中才发现的问题,所以若有正式标准,应该选择后者作为通用规范。
语言规范的内容包含语言应当怎么被实现,但一般不会包含过于复杂的细节——除非只打算有一种或少数几种实现。即便是后者,也并不大量限定怎么实现,而以说明实现什么为主。因为若都限定死了,遵照标准的实现没法自由发挥了,那就使语言规范的意义大打折扣。至于说明怎么实现的,以及没被限定的“实现什么”,这就是各个实现的文档需要讲清楚的了。这些文档也可以事实上正式地确定对应的实现具体支持的方言。尤其是,一个实现可能在不同程度上支持多种标准语言或方言(如GCC的C前端支持ANSI C89、ISO C99、ISO C11这些标准语言和GNU C89、GNU C99等方言)时,清晰的文档是正确使用这些实现专有特性的重要支持。最后,应该意识到,规范是人为的规定,因此,可能存在错误,只是经过精心设计维护的正式规范的错误在应用领域往往体现得很不明显直接,其中的条文又往往比较抽象,一般使用者相对难以发现和确认罢了。一旦确认是一个bug,可以同负责维护规范的组织责任者反映,具体操作各有不同,这里不再详细展开。
文法、语法和语义
文法(grammar)是描述语言的正确形式的规则,它包含两重含义——语法(syntax)和语义(semantics)。(这里按计算机科学领域的习惯翻译,自然语言范畴的grammar和syntax都可以翻译成“语法”。)前者是指语言的字面上的形式,后者则是语言隐藏在字面下的含义。作为一般的抽象,在计算机科学(其实算是数学)中,字符串(character string)是给定非空字母表中的字符(character)(在这里或可以称为符号(symbol))组成的有限序列,形式语言(formal language)是字符串(character string)的有限集。形式文法(formal grammar)是描述特定的这种有限集的方法,对应形式语法(formal syntax)和形式语义(formal semantics)。
这里有一点非常重要:语法不是语义,它们之间有足够清晰的界限——尽管精确的定义需要涉及具体的形式化所以这里从略,很容易理解这个界限来自于是否是“字面上”的规则。不过实际应用中,不少非专业——语言实现意义上的——用户可能容易忘记这个浅显的常识而混淆两者,比如把语言教科书当作“语法书”——这显然是错误的——任何一本以教授读者如何使用语言的书都不应该只讲语法。这里需要强调这一点是因为区分这两点对全面、正确理解语言基础前提之一。只有知道语言规则中的哪些才是语法,才有资格算是学会语言,否则只是撞大运会“用”而已;而一旦实现有bug(对于专业——程序员意义上的——用户来讲,这其实很有机会遇到),往往无法发挥判断到底哪里出问题了。基于字符串的计算机语言是可以精确描述的形式语言,它的实现依赖于文法的确定。但是,由于实用的通用语言的设计往往会受到用户习惯的影响,特性从形式化的观点来看带有一些随意性,因此并不是那么容易给出形式文法。考虑到实现和用户的学习难度,只形式化语法而不形式化语义还是比较可行的(若不形式化语法,则语言的实现会极为困难,必须手动完成太多的工作;若只给形式语义,则非专业——研究者意义上的——用户基本无法使用这门语言),因此一般编程语言规范中的语法包含相对较少的形式规则,而语义使用较多的自然语言描述(也有一些语言规范如Scheme R6RS附带了形式语义)。也就是说,至少阅读语言规范时是不那么容易混淆语法和语义的;而若先入为主地混淆了,读起来可能就更不好懂了。
语言的规则可以依此分为两类,语法规则和语义规则。
程序的正确性
什么是正确的程序?这是一个复杂的问题,因为“正确”有多种含义。一个大而化之的说法可以是满足需求的程序。作为软件项目实现的常识,一般意义上的需求是模糊的,因此凭这个显然无法得到精确的定义。事实上,确认包括程序在内的软件系统的是否符合预期(尤其是在正式投入生产环境使用前的可重复的、可控的)的过程——验证(verification),是一种普遍的刚性需求。所以,必须约定几个更清晰但外延更小的正确性。下文讨论由高级语言提供的规则保证的一些程序正确性。这里的正确性不止是一种,具体语言规范可能会提供不同的定义,并约定实现对此的行为(实现或程序的外在表现)等等。(更多的相关话题是软件测试理论讨论的内容,这里不作展开)。
首先,语言的语法规则确定了语法上的正确。实现应当理所当然地拒绝非法(invalid)——不合乎语法的代码。
其次,对于合乎语法的代码,可能违反了语义规则,例如类型正确性(type correctness)。但是,这里实现可能表现的行为就比较复杂了。例如,ISO C/C++明确规定了未定义行为(undefined behavior),允许实现接受违反语义规则的错误(或不可移植)的代码,标准保证(对实现)没有任何进一步的要求。于是检查出这种错误的责任就落在用户身上。若不知道这回事,可能导致程序隐藏了大量在运行时才能发现的bug,这是极端危险的。作为使用C/C++这样的语言的程序员,必须清楚一个实现没有义务检查出代码中所有违反语言规则的错误。未定义行为的意义是减少对实现的限制,允许能在更多不同的硬件环境的支持下提供机制迥然的实现,且可能使用更激进的优化手段。相比较而言,一开始就设计运行在虚拟机上的语言由于有虚拟机这个实现比较一致的环境的支持,往往限制得比较死而使“未定义”的情况较少出现,更能确保正确性(当然也有优化上的不灵活的代价,但虚拟机的不同实现可以弥补一部分)。例如,The Java(TM) Language Specification根本没有定义undefined的含义(仅用于字面上的“没有定义”,如未定义的变量),而C#的语言规范ECMA-334仅把未定义行为限制在unsafe代码等相对极少的上下文中才可能出现。
此外,仍然是考虑到实现的自由,语言规范可能会允许实现把一些不违反语言规则的代码关联到不同的行为,但不认为用户对其的使用是对语言规则的违反。ISO C/C++即规定了未指定行为(unspecified behavior)和实现定义的行为(implementation-defined behavior)。用户应当了解,若不希望使用自己的程序的预期行为依赖于某个具体实现,应当设法使代码表达的逻辑不依赖于特定的未指定行为或实现定义的行为
。
除了存在未定义行为的情况,实现遇到可能违反语言规则时自身的行为应该是大体相同的。对于编译型实现,最常见的就是给用户一些文本信息,告诉哪里出问题了。(说个不太常见的,比如ICE——Internal Compiler Error——编译器给个sorry就这么崩了……)这里的文本信息被ISO C/C++称为诊断消息(diagnostic message)。对于具体实现以及Java、C#等的语言规范,可以更明确地分为编译时的(compile-time)错误(error)和警告(warning)。“错误”这个概念也有非常多的含义,这里是指代码会被拒绝,停止编译(为了发现更多错误,往往不会立即停止)后不会生成预期的程序;而警告则是指可以得到程序。关于诊断消息,后面专门讨论C/C++时会进一步涉及。
扩展阅读B 几种重要的正确性验证方法
形式化验证(formal verification)是一种对程序实现的使用的算法严格的验证方法,它的基础来自现代数学的根本组成部分之一——证明论。对于具体软件项目使用专用的形式化验证会使用较复杂的理论工具,因此很不流行,主要的例子是用于一些操作系统内核。但其中的一部分——类型正确性,在支持内建类型系统的现代编程语言中却是重要的特性。内建的类型正确性通过语言规则指定的类型语义约束,由实现提供检查,使类型使用的错误能被暴露出来——例如,拒绝接受代码或抛出运行时异常。
静态代码分析(static code analysis)是常用于编译型语言的一种验证手段。它可以按比语言指定的规则更严格的策略对输入的代码进行检查来保证代码的质量。在这个意义上,静态代码分析工具类似于语言的实现。
动态程序分析(dynamic program analysis)是一种比静态代码分析更强大的验证方法。它的优势在于,可以收集程序在运行时的信息来验证正确性。要知道一些错误条件是编译时无法轻易模拟和满足的(比如静态类型语言会丢失的类型信息、实时的内存使用等),因此往往能发现更隐蔽的错误。当然,实现动态程序分析的工具可能更依赖于具体实现。