为什么要学习...静态类型语言?
大多数人的第一门编程语言是动态类型、解释型语言——JavaScript、Python 或 Ruby。这些都是学习编程的优秀语言,并且能够高效地运用这三种语言。但还有一类广泛使用的语言:静态类型、编译型语言——C、Go、Java、C# 等等。本文将尝试解释这两类语言的区别,分析它们的优缺点,并思考对于只熟悉动态类型语言的程序员来说,哪种静态类型语言更适合学习。
这是给谁的?
本文的目标读者是那些熟悉动态类型语言编程,并且有兴趣学习静态类型语言,并想知道其价值的人。示例分别使用 JavaScript、TypeScript、Python 和 Go 编写,但无需了解这些语言。本文基于我作为一名自学成才的开发者的经验,我最初使用 Ruby 和 JavaScript 进行开发,后来扩展到 Go、TypeScript、Kotlin 和 Rust 等语言。
什么是静态类型编译语言?
这里有两对对立的词需要注意:动态类型与静态类型,以及编译型与解释型。让我们按顺序来看一下。
动态类型与静态类型
如果有人问你:
一根香蕉加五颗等于多少?
你会感到困惑——他们到底是什么意思?看起来他们搞错了。也许他们不知道“add”是什么意思,或者“banana”是什么意思。也许他们理解的“add”和我们理解的不一样。但肯定哪里出了问题,因为我们不明白他们的问题。1
编程语言有一种方法来告诉我们用该语言编写的表达式有意义或无意义。它们通过使用编程语言中每个值所具有的类型来实现这一点。在动态类型语言中,只有当我们以错误的方式使用某种类型的值时——当我们说出一些“无意义”的东西时——我们才会真正意识到类型。
例如,在 Python 中我们可以这样写:
5 + "banana"
尝试将其保存到名为 的文件中typecheck.py
,然后使用 执行python typecheck.py
。你应该会在终端中看到以下错误:
Traceback (most recent call last):
File "typecheck.py", line 1, in <module>
5 + "banana"
TypeError: unsupported operand type(s) for +: 'int' and 'str'
这是一个类型错误——你可以从错误信息中看出这一点TypeError
。错误告诉你不能将+
类型int
和str
相加。这很合理;就像你不知道如何将 5 和一根香蕉相加一样,Python 也不知道。
类型错误由类型检查器抛出,它会检查表达式中的所有类型是否以正确的方式使用。类型检查器会在 Python 程序运行时启动,检查被+
合并的两个变量是否属于正确的类型。2
类型检查可以在以下两个时间点进行:程序运行时(通常称为“运行时”)或运行前的某个时间。动态类型语言在运行时检查其类型——我们上面运行的 Python 程序就是如此;类型错误在程序运行时才显现出来。静态类型语言在运行之前检查其类型。
类型注解
为了让类型检查器能够准确地检查静态类型语言中的类型,你通常需要通过类型注解显式声明变量的类型。类型注解是添加到变量中的一些额外信息,用于说明变量的类型。在英语中,我们可以想象在名词和动词后面添加类型注解,作为括号中的额外信息。因此,我们的简单句子是:
一根香蕉加五颗等于多少?
成为
将五(数字)加(加法是对数字进行的操作)到一根香蕉(水果)上,结果是多少?
这可能很好地证明自然语言不适合类型注释。
有了这些英文类型注解,我们无需知道“five”是什么,“banana”是什么,“addition”是什么,就能知道这句话没有意义。我们甚至不需要知道“number”是什么。我们只需要知道,中间的动词需要两个名词属于“number”类型,这句话才有效。我们只需查看单词就能自动执行此类检查,而无需了解它们的含义——我们无法对“fruit”进行“adding”。静态类型语言中的类型检查器也以相同的方式工作。3
让我们看一下 TypeScript 中的类型注释,它是 JavaScript 的静态类型变体:
var theNumberFive: number = 5
这声明了变量theNumberFive
具有类型,并为其number
分配值。5
JavaScript 中的等效代码如下:
var theNumberFive = 5
完全相同,只是没有类型注释。
我们还可以在函数签名中添加类型声明。JavaScript 中的函数add
如下:
function add(n1, n2) {
return n1 + n2
}
在 TypeScript 中看起来像这样:
function add(n1: number, n2: number): number {
return n1 + n2
}
我们说该函数add
接受两个参数,n1
即 anumber
和n2
a number
,并返回一个也是 a 的值number
。
这些注释将被 TypeScript 类型检查器使用,该检查器在编译TypeScript 时运行。
编译/解释
在 JavaScript 等解释型语言中,程序的每一行都由解释器按顺序读取和执行,一行接一行,4解释器逐行构建您编写的程序的运行进程。
编译是将你用一种语言编写的程序转换为另一种语言的过程。对于 TypeScript 来说,目标语言是 JavaScript。在编译期间(即“编译时”),类型检查器会分析 TypeScript 程序中是否存在任何错误。
编译器通常用于将高级编程语言(例如 JavaScript)转换为低级语言,例如汇编语言或机器码。对于 TypeScript,编译器会输出另一种高级语言——JavaScript。5
对于某种编程语言来说,编译型与解释型的区别几乎从来都不是一成不变的——解释器有时会在代码执行之前执行编译步骤,而编译器的输出也必须由解释器来运行。此外,编译型或解释型并非语言本身的属性。编译器是为通常解释型的语言编写的,而解释器则是为通常编译型的语言编写的。
对于静态类型的编译型语言,类型检查器在编译阶段运行。类型检查对于编译器非常有用,因为它可以优化软件的性能——如果一个变量总是 a,number
它可以优化所使用的内存位置。
优势
类型检查可以发现错误
让我们把这些放在一起,用 JavaScript 和 TypeScript 编写我们的示例自然语言“表达式”,我们很快就会看到静态类型语言的优点之一
var five = 5
var banana = "banana"
function add(n1, n2) {
return n1 + n2
}
add(five, banana)
这将给我们结果
'5banana'
哦,JavaScript……我很乐意和你一起做任何+
事。7这类错误很容易让人一笑置之,但我见过一些团队因为将数字存储为字符串而花了好几天时间修复 JavaScript 错误。这是一个很容易犯的错误。而且,这种错误永远不会发生在你身上——除非它真的发生在你身上。
但如果我们尝试在 TypeScript 中复制相同的错误
var five: number = 5
var banana: string = "banana"
function add(n1: number, n2: number): number {
return n1 + n2
}
add(five, banana)
当我们使用 TypeScript 编译器8编译它时
add.ts:8:11 - error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.
8 add(five, banana)
~~~~~~
Found 1 error.
TypeScript 编译器发现了我们的错误,甚至在我们出错的地方划出了下划线——我们不能将 a放在应该放string
a 的位置。number
从程序员的角度来看,这是静态类型的最大优势;类型检查器可以确保我们不会做任何愚蠢的事情,比如把 astring
当作 a number
。突然之间,我们对编写的程序将如何运行有了新的确定性——甚至无需运行它。
编辑器集成
但类型检查的乐趣远不止于编译——远非如此。由于类型检查器甚至可以在编译程序之前运行,因此它可以与文本编辑器集成,在您输入时提供有关程序的信息。由于类型注释声明了变量的类型,编辑器现在可以告诉您一些有用的信息,例如可以使用哪些方法。9
编译后的代码运行速度更快
编译不仅仅是将一种语言翻译成另一种语言;编译器还会检查你的程序,并尝试找出使其运行速度更快或更高效的方法。例如,递归函数调用会被转换成简单的循环。
缺点
这一切听起来都很好 - 但是使用静态类型编译语言有什么缺点呢?
编译需要时间
程序的编译可能需要很长时间。如今有了快速的计算机和优秀的编译器,编译时间会缩短,但在我经历过的最坏情况下,编译时间也需要两三分钟。如果你的工作流程依赖于快速、紧密的反馈循环,那么随着程序规模的扩大,你可能会发现编译器会越来越烦人。
类型需要更多语法
如果你习惯了动态类型语言,那么静态类型语言的冗长可能会让你非常反感。必须声明每个变量和函数参数的类型,这会让人非常厌烦。现代语言会尽可能地通过推断变量类型来减轻这种负担,但像 Java、C#、C++ 和 C 这样的老牌静态类型语言看起来会非常冗长。
世界没有打字
静态类型语言的冗长性在程序边界——也就是程序与“外部世界”交互的地方——体现得非常明显。需要一些额外的步骤来处理进入系统的数据。这在解析 JSON 时尤为明显——为了充分利用系统中的类型,你必须将通用JSON
类型转换为你自己的类型,这可能相当费力。动态语言会让这个过程变得容易得多(尽管如上所述,也更容易出现类型错误)。
没有基于 REPL 的开发
大多数编译型语言不支持“读取-求值-打印-循环” 10,也不支持像 Clojure 这样的语言那样的交互式开发。如果你以这种方式工作,你会错过它——即使你不这样做,它对你来说也没有任何区别。
我应该从哪里开始?
那么什么是好的静态类型编译语言呢?
如果我有丰富的 JavaScript 经验,那么尝试 TypeScript 可能会很有道理,但我发现编译为 JavaScript 的语言会引入一层开销和工具,让你无法专注于语言本身。
我建议远离 Java,因为该语言中有很多不必要的繁琐和复杂之处,其中一些是 C 语言的遗留问题。例如,比较
User user = new User()
在 Java 中,我总是觉得我user
至少写了两次这个词,而在 Go 中
user := NewUser()
如果您确实想了解基于 JVM 构建的静态类型语言,Kotlin 是一个不错的选择。
我认为最好的选择是Go 编程语言。它拥有简单的类型系统(无需担心泛型),语法简洁易学,工具和文档也是同类最佳,而且越来越受欢迎。不妨看看优秀的Go By Example或Learn Go With Tests。
你怎么认为?
你有过从动态类型语言过渡到静态类型语言的经历吗?或者反过来?最难的部分是什么?你有什么建议?你认为哪种语言最适合静态类型的入门?
-
我们可以说这个句子在语法上是正确的,但在语义上是无意义的 。↩
-
试想一下,如果一种语言中没有类型会发生什么。你只会看到内存中漂浮的比特位。你怎么知道“数字”从哪里开始?或者在哪里结束?或者内存中的哪些位是程序?这就是为什么所有编程语言都是类型化的——没有类型,编程就不可能实现 。↩
-
虽然类型检查器通常知道它所检查值的类型,但它会知道这
1
是一个数字。这就是类型推断的工作原理,它可以帮助静态类型语言简化很多代码。例如,在 Go 中,我们只需输入x := 1
,类型检查器就能推断出 的类型x
是一个数字 。↩ -
这其中有一些微妙之处——语言解释器通常会动态编译部分代码,而编译型语言可以包含一些代码段,这些代码段的类型只有在编译后运行程序时(在“运行时”)才能确定 。↩
-
这有时被称为转译。↩
-
出于显而易见的原因,这被称为“即时”编译器 。↩
-
如果你还没有看过 Gary Bernhardt 的JavaScriptWAT视频,现在正是时候 。↩
-
如果你有兴趣亲自体验一下,你需要在电脑上安装 NodeJS 环境。然后,你需要通过运行 来从 NPM 安装 TypeScript 编译器
npm install -g typescript
。要编译 TypeScript 文件(例如名为 的文件),add.ts
请运行。如果没有编译错误,tsc add.ts
编译后的 JavaScript 输出将位于名为 的文件中。↩add.js
-
动态类型语言中也提供这种帮助,但程度不同 。↩
-
当然,这其中有一些细微差别。例如,在 Java 虚拟机 (JVM) 上运行的语言可以通过将 REPL 生成的编译后的 Java 字节码直接发送到正在运行的 JVM 实例来支持 REPL。↩