编辑:这篇文章的先前版本在标题中没有“静态”以保持简短(但正文中有)。将其添加到标题中以进行澄清。
更新:现在有关于有效使用类型系统的第 2 部分。
我编写软件已有 20 多年了,随着时间一天天过去,我越来越确信强静态类型不仅是一个好主意,而且几乎总是正确的选择。
无类型语言(或语言变体)肯定有用途,例如,在使用 REPL 时它们会更好,或者在已经完全无类型的环境(例如 shell)中用于一次性脚本。然而,在几乎所有其他情况下,强类型是强烈首选。
不使用类型有一些优点,例如更快的开发速度,但与所有优点相比,它们就显得苍白无力了。对此我想说的是:
编写没有类型的软件可以让您全速前进。全速冲向悬崖。
关于强静态类型的问题很简单:您愿意多做一点工作并在编译时(或非编译语言的类型检查时间)检查不变量,还是少做一点工作并在运行时强制执行它们,或者更糟糕的是,即使在运行时也没有强制执行(JavaScript,我正在看着你...... 1 + "2" == 12
)。
在运行时出现错误是一个糟糕的主意。首先,这意味着您在开发过程中并不总能捕获它们。其次,当你抓住他们时,将以面向客户的方式进行。是的,测试有帮助,但是考虑到无限的可能性,为每个可能输入错误的函数参数编写测试是不可能的。即使可以,拥有类型也比测试错误类型容易得多。
类型还为代码提供注释,这对人类和机器都有好处。拥有类型是一种更严格地定义不同代码片段之间契约的方法。
考虑以下四个例子。他们都做完全相同的事情,只是合同定义的级别不同。
// 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!"
虽然键入的版本将正确地无法编译,因为该函数将年龄除外为数字,而不是字符串。
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 查找)使用 Person 的所有位置非常容易。你可以看到它是在 main 中启动的,你可以看到它是由 birthdayGreeting3 使用的。但是,为了知道它在 birthdayGreeting2 中使用,您需要阅读整个代码库。
另一方面,在查看 birthdayGreeting2 时,很难知道它需要 Person 作为参数。其中一些问题可以通过详尽的文档来解决,但是:(1)如果可以通过类型实现更多目标,为什么还要麻烦呢?(2)文档过时了,这里的代码就是文档。
这与您不会编写如下代码的方式非常相似:
// a is a person
function birthdayGreeting2(a) {
b = a.name;
c = a.age;
return `${b} will turn ${c + 1} next year!`;
}
您可能希望使用有用的变量名称。类型是一样的,只是变量名称更极端。
在 Svix,我们喜欢类型。事实上,我们尝试在类型系统中编码尽可能多的信息,以便所有可以在编译时捕获的错误都将在编译时捕获;并进一步提高开发者体验。
例如,Redis 是一种基于字符串的协议,没有固有的类型。我们使用 Redis 进行缓存(除其他外)。问题是我们所有良好的类型优势都将在 Redis 层丢失,并且可能会发生错误。
考虑下面的代码:
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}"));
代码片段中有几个错误:
为了避免此类问题,我们在 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); // <-- should be pet.owner!
第二是我们正在失去可发现性。要知道宠物与人之间的关系有点困难.
因此,在 Svix,我们为每个 id 设置了一个特殊类型,以确保没有错误。调整后的代码如下所示:
pub struct PersonId(String);
pub struct PetId(String);
pub struct Person {
pub id: PersonId,
pub name: String,
pub age: u16,
}
pub struct Pet {
pub id: PetId,
pub owner: PersonId,
}
这确实比我们之前的例子要好得多。
还有一个问题。如果我们接受来自 API 的 id,我们怎么知道它们是有效的?例如,Svix 中的所有宠物 ID 都以 pet_ 为前缀,然后后面跟着一个 Ksuid,如下所示:pet_25SVqQSCVpGZh5SmuV0A7X0E3rw.
我们希望能够告诉我们的客户,他们在 API 中传递了错误的 ID,例如,当需要宠物时,他们传递了个人 ID。一个简单的解决方案是验证它(呃......),但很容易忘记在使用它的任何地方验证它.
因此,我们强制要求,在未事先验证的情况下,绝不能创建 PetId。这样,我们就知道创建 PetId 的所有代码路径首先要确保它有效。这意味着,当我们因为在数据库中找不到宠物而将 404 “未找到”返回给客户时,我们可以确定它实际上是数据库中未找到的有效 ID。如果它不是有效的 ID,那么当它传递给 API 处理程序时,我们已经返回了 422 或 400。
反对类型的主要论点是:
首先,我认为即使上述所有内容都是正确的,上面提到的优点也是值得的。虽然我也不同意以上所有内容。
首先是开发速度。没有类型的原型设计肯定要快得多。您可以注释掉代码的各个部分,并且不会有编译器向您抱怨。您可以为某些字段设置错误的值,直到您准备好找出正确的值,等等。
尽管就像我上面所说的:“编写没有类型的软件可以让你全速前进。全速冲向悬崖。”问题在于,这只是激进且不必要的技术债务。当您需要调试代码不起作用的原因(无论是在本地、在测试套件中还是在生产中)时,您将多次支付费用。
至于学习曲线:是的,学习更多的东西需要时间。虽然我想说大多数人不需要成为类型专家。他们只能用非常简单的类型表达式来度过难关,并询问他们是否曾经碰壁。然而,如果你保持简单,你可能很少会碰到一个。
此外,人们已经被要求学习如何编码、学习框架(React、Axum 等)以及许多其他东西。我认为学习负担并不像人们想象的那么严重。
最后但并非最不重要的一点是,关于学习曲线:我坚信,由于不必了解类型而缩短学习曲线的好处远小于使用类型脚本加入特定代码库的好处。特别是因为学习类型是一次性成本。
最后一点是关于在代码库中使用类型所需的工作量和样板文件。我坚信,实际上比不写类型所需的努力要少。
不使用类型需要大量的文档和测试才能达到基本的理智水平。文档可能会过时,测试也是如此;无论哪种方式,他们都需要付出更多的努力,而不仅仅是添加正确的类型。读取带有类型的代码也更容易,因为您可以内联获取类型,而不是在函数文档中获取类型,后者的格式不一致,并且增加了很多噪音。
是的,在不支持推理的语言中,类型可能会很痛苦,例如 Java 可能很乏味:
编辑:现在Java似乎有类型推断。感谢 pron 对 HN 的更正
Person person1 = newPerson();
Person person2 = newPerson();
Person child = makeChild(person1, person2);
而其他具有推理功能的语言(如 Rust)则要好得多:
let person1 = new_person();
let person2 = new_person();
let child = make_child(person1, person2);
因此,拥有正确的工具肯定会有所帮助。
说到工具,为了获得类型的好处,您可能需要使用支持语言感知的现代代码完成的代码编辑器(或 IDE)。
我很想知道我错过了什么,但在那之前:强类型是我愿意死在上面的一座山。
更新:现在有关于有效使用类型系统的第 2 部分。