作者丨Tom Hacohen
编译丨千山
我写软件已经有20多年了,随着时间的推移,我越来越确信强静态类型不仅是一个好主意,而且几乎总是正确的选择。
非类型化语言(或语言变体)肯定有用途,例如,当使用REPL时,或者在已经无可救药的无类型环境(例如shell)中使用一次性脚本时,它们会更好。然而,在几乎所有其他情况下,强类型都是首选。
不使用类型是有好处的,比如更快的开发速度,但与所有的好处相比,它们就显得微不足道了。对此,我要说:
编写没有类型的软件可以让你全速前进——全速冲向悬崖。
关于强静态类型的问题很简单:你是愿意多做一点工作,在编译时检查不变量(或非编译语言的类型检查时间),还是愿意少做一点工作,在运行时强制执行它们,或者更糟糕的是,即使在运行时也不强制执行(JavaScript,我在看着你…)。
在运行时出错是一个糟糕的想法。首先,这意味着在开发过程中你不会总是抓住它们。其次,当你抓住他们的时候,它会以面向客户的方式发生。是的,测试有帮助,但是考虑到无限的可能性,为每一个可能的错误类型函数参数编写测试是不可能的。即使可以,拥有类型也比测试错误类型容易得多。
1、类型导致更少的错误
类型还为代码提供注释,使人类和机器都受益。拥有类型是一种更严格地定义不同代码段之间协定的方法。
请考虑以下四个示例。它们都做完全相同的事情,只是契约定义级别不同。
// Params: Name (a string) and age (a number).
function birthdayGreeting1(...params) {
return `${params[0]} is ${params[1]}!`;
}
// Params: Name (a string) and age (a number).
function birthdayGreeting2(name, age) {
return `${name} is ${age}!`;
}
function birthdayGreeting3(name: string, age: number): string {
return `${name} is ${age}!`;
}
第一个甚至没有定义参数的数量,因此如果不阅读文档,很难知道它的作用。我相信大多数人都会同意第一个是令人讨厌的,不会写这样的代码。虽然它的思想与类型非常相似,但它是关于定义调用者和被调用者之间的契约。
至于第二个和第三个,由于类型的原因,第三个将需要更少的文档。代码更简单,但不可否认,优点相当有限。好吧,直到你真正更改这个函数前......
在第二个和第三个函数中,作者假设年龄是一个数字。因此,更改代码绝对没问题,如下所示:
// Params: Name (a string) and age (a number).
function birthdayGreeting2(name, age) {
return `${name} will turn ${age + 1} next year!`;
}
function birthdayGreeting3(name: string, age: number): string {
return `${name} will turn ${age + 1} next year!`;
}
问题是使用此代码的某些位置接受从HTML输入(因此始终是字符串)收集的用户输入。这将导致:
> birthdayGreeting2("John", "20")
"John will turn 201 next year!"
虽然类型化版本将无法正确编译,因为此函数将年龄除外,否则年龄是数字,而不是字符串。
在调用方和被调用方之间建立协定对于代码库非常重要,这样调用方就可以知道被调用方何时更改。这对于开源库尤其重要,因为调用方和被调用方不是由同一组人编写的。没有这个合同,就不可能知道事情在发生时是如何变化的。
2、类型带来更好的开发体验
IDE和其他开发工具也可以使用类型来极大地改善开发体验。如果你的任何期望是错误的,你将在编写代码时得到通知。这大大降低了认知负荷。你不再需要记住上下文中所有变量和函数的类型。编译器将与你同在,并在出现问题时告诉你。
这也带来了一个非常好的额外好处:更容易重构。你可以相信编译器会让你知道你所做的更改(例如上面示例中的更改)是否会破坏代码中其他地方所做的假设。
类型还可以使新工程师更容易加入代码库或库:
- 他们可以遵循类型定义来了解事物的使用位置。
- 修改东西要容易得多,因为更改会触发编译错误。
让我们考虑对上述代码进行以下更改:
class Person {
name: string;
age: number;
}
function birthdayGreeting2(person) {
return `${person.name} will turn ${person.age + 1} next year!`;
}
function birthdayGreeting3(person: Person): string {
return `${person.name} will turn ${person.age + 1} next year!`;
}
function main() {
const person: Person = { name: "Hello", age: 12 };
birthdayGreeting2(person);
birthdayGreeting3(person);
}
很容易查看(或使用IDE查找)所有使用过的位置。你可以看到它被启动,你可以看到它被使用。然而,为了知道它的用途,你需要阅读整个代码库。
这样做的另一方面是,在看的时候,很难知道它期望a作为参数。其中一些问题可以通过详尽的文档来解决,但是:(1)如果使用类型可以实现更多的功能,为什么还要费心呢?(2)文档过时,这里的代码是document。
这与你不编写代码的方式非常相似:
// a is a person
function birthdayGreeting2(a) {
b = a.name;
c = a.age;
return `${b} will turn ${c + 1} next year!`;
}
你可能希望使用有用的变量名。类型也是一样的,它只是steriods上的变量名。
3、我们对类型系统中的所有内容进行编码
在Svix,我们喜欢类型。事实上,我们尝试在类型系统中对尽可能多的信息进行编码,以便在编译时捕获所有可以在编译时捕获的错误;同时也要压缩开发者体验改进的额外里程。
例如,Redis是一个基于字符串的协议,没有固有的类型。我们使用Redis进行缓存(以及其他功能)。问题是,我们所有的优秀的类型优势将在Redis层丢失,并且可能发生bug。
考虑下面这段代码:
pub struct Person {
pub id: String,
pub name: String,
pub age: u16,
}
pub struct Pet {
pub id: String,
pub owner: String,
}
let id = "p123";
let person = Person::new("John", 20);
cache.set(format!("person-{id}"), person);
// ...
let pet: Pet = cache.get(format!("preson-{id}"));
代码片段中有几个bug:
- 第二个键名称有个拼写错误。
- 我们正在尝试将一个人装入宠物类型。
为了避免这样的问题,我们在Svix做了两件事。首先,我们要求键是某种类型的(不是泛型字符串),要创建这种类型,需要调用一个特定的函数。我们做的第二件事,是将键与值强制配对。
所以上面的例子看起来像这样:
pub struct PersonCacheKey(String);
impl PersonCacheKey {
fn new(id: &str) -> Self { ... }
}
pub struct Person {
pub id: String,
pub name: String,
pub age: u16,
}
pub struct PetCacheKey;
pub struct Pet {
pub id: String,
pub owner: String,
}
let id = "p123";
let person = Person::new(id, "John", 20);
cache.set(PersonCacheKey::new(id), person);
// ...
// Compilation will fail on the next line
let pet: Pet = cache.get(PersonCacheKey::new(id));
这已经好多了,并且不可能出现前面提到的任何错误。虽然我们可以做得更好!
请考虑以下函数:
pub fn do_something(id: String) {
let person: Person = cache.get(PersonCacheKey::new(id));
// ...
}
它有几个问题。首先是不太清楚id应该用来做什么。是一个人吗?一个宠物吗?很容易意外地用错误的名称调用它,就像下面的例子一样
let pet = ...;
do_something(pet.id); //