作者丨Tom Hacohen
編譯丨千山
我寫軟件已經(jīng)有20多年了,隨著時間的推移,我越來越確信強靜態(tài)類型不僅是一個好主意,而且?guī)缀蹩偸钦_的選擇。
非類型化語言(或語言變體)肯定有用途,例如,當使用REPL時,或者在已經(jīng)無可救藥的無類型環(huán)境(例如shell)中使用一次性腳本時,它們會更好。然而,在幾乎所有其他情況下,強類型都是首選。
不使用類型是有好處的,比如更快的開發(fā)速度,但與所有的好處相比,它們就顯得微不足道了。對此,我要說:
編寫沒有類型的軟件可以讓你全速前進——全速沖向懸崖。
關(guān)于強靜態(tài)類型的問題很簡單:你是愿意多做一點工作,在編譯時檢查不變量(或非編譯語言的類型檢查時間),還是愿意少做一點工作,在運行時強制執(zhí)行它們,或者更糟糕的是,即使在運行時也不強制執(zhí)行(JavaScript,我在看著你…)。
在運行時出錯是一個糟糕的想法。首先,這意味著在開發(fā)過程中你不會總是抓住它們。其次,當你抓住他們的時候,它會以面向客戶的方式發(fā)生。是的,測試有幫助,但是考慮到無限的可能性,為每一個可能的錯誤類型函數(shù)參數(shù)編寫測試是不可能的。即使可以,擁有類型也比測試錯誤類型容易得多。
1、類型導(dǎo)致更少的錯誤
類型還為代碼提供注釋,使人類和機器都受益。擁有類型是一種更嚴格地定義不同代碼段之間協(xié)定的方法。
請考慮以下四個示例。它們都做完全相同的事情,只是契約定義級別不同。
// 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}!`;
}
第一個甚至沒有定義參數(shù)的數(shù)量,因此如果不閱讀文檔,很難知道它的作用。我相信大多數(shù)人都會同意第一個是令人討厭的,不會寫這樣的代碼。雖然它的思想與類型非常相似,但它是關(guān)于定義調(diào)用者和被調(diào)用者之間的契約。
至于第二個和第三個,由于類型的原因,第三個將需要更少的文檔。代碼更簡單,但不可否認,優(yōu)點相當有限。好吧,直到你真正更改這個函數(shù)前......
在第二個和第三個函數(shù)中,作者假設(shè)年齡是一個數(shù)字。因此,更改代碼絕對沒問題,如下所示:
// 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輸入(因此始終是字符串)收集的用戶輸入。這將導(dǎo)致:
> birthdayGreeting2("John", "20")
"John will turn 201 next year!"
雖然類型化版本將無法正確編譯,因為此函數(shù)將年齡除外,否則年齡是數(shù)字,而不是字符串。
在調(diào)用方和被調(diào)用方之間建立協(xié)定對于代碼庫非常重要,這樣調(diào)用方就可以知道被調(diào)用方何時更改。這對于開源庫尤其重要,因為調(diào)用方和被調(diào)用方不是由同一組人編寫的。沒有這個合同,就不可能知道事情在發(fā)生時是如何變化的。
2、類型帶來更好的開發(fā)體驗
IDE和其他開發(fā)工具也可以使用類型來極大地改善開發(fā)體驗。如果你的任何期望是錯誤的,你將在編寫代碼時得到通知。這大大降低了認知負荷。你不再需要記住上下文中所有變量和函數(shù)的類型。編譯器將與你同在,并在出現(xiàn)問題時告訴你。
這也帶來了一個非常好的額外好處:更容易重構(gòu)。你可以相信編譯器會讓你知道你所做的更改(例如上面示例中的更改)是否會破壞代碼中其他地方所做的假設(shè)。
類型還可以使新工程師更容易加入代碼庫或庫:
- 他們可以遵循類型定義來了解事物的使用位置。
- 修改東西要容易得多,因為更改會觸發(fā)編譯錯誤。
讓我們考慮對上述代碼進行以下更改:
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作為參數(shù)。其中一些問題可以通過詳盡的文檔來解決,但是:(1)如果使用類型可以實現(xiàn)更多的功能,為什么還要費心呢?(2)文檔過時,這里的代碼是document。
這與你不編寫代碼的方式非常相似:
// a is a person
function birthdayGreeting2(a) {
b = a.name;
c = a.age;
return `$ will turn ${c + 1} next year!`;
}
你可能希望使用有用的變量名。類型也是一樣的,它只是steriods上的變量名。
3、我們對類型系統(tǒng)中的所有內(nèi)容進行編碼
在Svix,我們喜歡類型。事實上,我們嘗試在類型系統(tǒng)中對盡可能多的信息進行編碼,以便在編譯時捕獲所有可以在編譯時捕獲的錯誤;同時也要壓縮開發(fā)者體驗改進的額外里程。
例如,Redis是一個基于字符串的協(xié)議,沒有固有的類型。我們使用Redis進行緩存(以及其他功能)。問題是,我們所有的優(yōu)秀的類型優(yōu)勢將在Redis層丟失,并且可能發(fā)生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做了兩件事。首先,我們要求鍵是某種類型的(不是泛型字符串),要創(chuàng)建這種類型,需要調(diào)用一個特定的函數(shù)。我們做的第二件事,是將鍵與值強制配對。
所以上面的例子看起來像這樣:
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));
這已經(jīng)好多了,并且不可能出現(xiàn)前面提到的任何錯誤。雖然我們可以做得更好!
請考慮以下函數(shù):
pub fn do_something(id: String) {
let person: Person = cache.get(PersonCacheKey::new(id));
// ...
}
它有幾個問題。首先是不太清楚id應(yīng)該用來做什么。是一個人嗎?一個寵物嗎?很容易意外地用錯誤的名稱調(diào)用它,就像下面的例子一樣
let pet = ...;
do_something(pet.id); // <-- should be pet.owner!
第二,我們正在失去可發(fā)現(xiàn)性。很難知道寵物與人有關(guān)系。
因此,在Svix,我們?yōu)槊總€類型都有一個特殊的類型,以確保沒有錯誤。調(diào)整后的代碼如下所示:
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,
}
這確實比我們之前的例子要好得多。
4、那么為什么不是每個人都喜歡類型呢?
反對類型一方論證的主要依據(jù)是:
- 開發(fā)速度
- 學(xué)習(xí)曲線和類型復(fù)雜性
- 所需的工作量和樣板
首先,我認為即使上述所有情況都是真的,上面提到的優(yōu)勢也值得麻煩。
首先是開發(fā)速度。沒有類型的原型設(shè)計肯定要快得多。你可以注釋掉代碼片段,并且不會讓編譯器向你抱怨。你可以為某些字段設(shè)置錯誤的值,直到你準備好找出正確的字段等。
雖然就像我上面說的:“編寫沒有類型的軟件可以讓你全速前進。全速向懸崖走去。”問題在于,這只是激進且不必要的技術(shù)債務(wù)。當你需要調(diào)試代碼無法正常工作的原因時(無論在本地、測試套件或生產(chǎn)環(huán)境中),你都需要多次支付這筆費用。
至于學(xué)習(xí)曲線:是的,學(xué)習(xí)更多的東西需要時間。不過我得說,大多數(shù)人不需要成為類型專家。他們可以使用非常簡單的類型表達式過日子,并詢問他們是否曾經(jīng)遇到瓶頸。然而,如果你讓事情保持簡單,你可能很少會碰到一個。
此外,人們已經(jīng)被要求學(xué)習(xí)如何編碼,學(xué)習(xí)框架(React,Axum等),以及許多其他東西。我認為學(xué)習(xí)負擔并不像人們想象的那么重。
最后,但并非最不重要的是,關(guān)于學(xué)習(xí)曲線:我堅信,不必了解類型而減少學(xué)習(xí)曲線的好處遠遠小于在特定代碼庫上使用類型腳本的好處。特別是因為學(xué)習(xí)類型是一次性的成本。
最后一點是關(guān)于在代碼庫中使用類型所需的工作量和樣板。我堅信,比起不寫類型所需要的工作量,這種工作量實際上要少得多。
不使用類型需要大量的文檔和測試,才能達到基本的健康水平。文檔可能會過時,測試也會過時;無論哪種方式,它們都比添加正確的類型需要更多的努力。閱讀帶有類型的代碼也更容易,因為你可以內(nèi)聯(lián)獲取類型,而不是在函數(shù)文檔中獲取類型,在函數(shù)文檔中,它的格式不一致,并且增加了很多干擾。
是的,在不支持推理的語言中,類型可能是一種痛苦,例如Java可能很乏味:
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);
因此,擁有合適的工具肯定會有所幫助。說到工具,為了獲得類型的好處,你可能需要使用支持語言感知的現(xiàn)代代碼完成的代碼編輯器(或 IDE)。
5、結(jié)語
我可以在許多話題上看到雙方的爭論,比如vs.,制表符vs.空格,甚至更具爭議性的主題。盡管在這種情況下,與收益相比,成本是如此之低,以至于我不明白為什么有人會選擇不使用類型。我不知道自己忽略了什么,我只知道,強類型是我愿意死在上面的一座山。
參考鏈接:https://www.svix.com/blog/strong-typing-hill-to-die-on/