- 原文:Rust Programming Language Tutorial – How to Build a To-Do List App
- 做者/譯者:Claudio Restifo/@hylerrix
- 發佈時間/翻譯時間:20210104/20210129
- 備註:本文遵循 freeCodeCamp 翻譯規範,同時本文會收錄在《Deno 鑽研之術》的 Rust 篇中。
Rust 語言從 2015 年發佈的首個開源版本開始,便得到了社區大量的關注。從 StackOverflow 上的開發者調查來看,Rust 也是 2016 年每一年都最受開發者喜歡的編程語言。html
Rust 由 Mozilla 設計,被定義爲一個系統級編程語言(就像 C 和 C++)。Rust 沒有垃圾處理器,所以性能極爲優良。且其中的一些設計也常讓 Rust 看起來很高級。git
Rust 的學習曲線被廣泛認爲是較爲艱難的。我並非 Rust 語言的深刻了解者,但在這篇教程中,我將嘗試提供一些概念的實用方法,來幫助你更深刻的理解。程序員
咱們將在這篇實戰教程中構建什麼?github
我決定經過遵循 JavaScript 應用的悠久傳統,來將一個 to-do app 當作咱們的第一個 Rust 項目。咱們將重點使用命令行,因此有關命令行的知識必須有所瞭解。同時,你還須要瞭解一些有關編程概念的基礎知識。數據庫
這個程序將基於終端運行。咱們將存儲一些元素的集合,並在其中分別存儲一個表示其活動狀態的布爾值。express
對於來自 JavaScript 背景的開發者來講,這裏有幾個咱們開始深刻前的建議:npm
;
來將其當作一條 return)。譯者注:AFI,Automatic semicolon insertion,自動分號插入。JavaScript 能夠不用寫分號,但某些語句也必須使用分號來保證正確地被執行。編程
事不宜遲,讓咱們開始吧!json
開始的第一步:下載 Rust 到你的電腦上。想要下載,能夠在 Rust 官方文檔中的入門篇中根據指導來安裝。服務器
譯者注:經過
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
安裝。
在上面的文檔中,你還會找到有關如何將 Rust 與你熟悉的編輯器集成以得到更好開發體驗的相關說明。
除了 Rust 編譯器自己外,Rust 還附帶了一個工具——Cargo。Cargo 是 Rust 的包管理工具,就像 JavaScript 開發者會用到的 npm 和 yarn 同樣。
要開始一個新項目,請先在終端下進入到你想要創造項目的位置,而後只需運行 cargo new <project-name>
便可開始。就我而言,我決定將個人項目命名爲「todo-cli」,因此有了以下命令:
$ cargo new todo-cli
複製代碼
如今切入到新建立的項目目錄並打印出其文件列表。你應該會在其中看到這兩個文件:
$ tree .
.
├── Cargo.toml
└── src
└── main.rs
複製代碼
在本教程的剩餘篇章中,咱們將會主要關注在 src/main.rs
文件上,因此直接打開這個文件吧。
就像其它衆多的編程語言同樣,Rust 有一個 main 函數來看成一切的入口。fn
來聲明一個函數,同時 println!
中的 !
符號是一個宏(macro)。你極可能會立馬看出來,這是 Rust 語言下的一個「Hello World
」程序。
想要編譯並運行這個程序,能夠直接直接 cargo run
。
$ cargo run
Hello world!
複製代碼
咱們的目標是讓咱們的 CLI 工具接收兩個參數:第一個參數表明要執行的操做類型,第二個參數表明要操做的對象。
咱們將從讀取並打印用戶輸入的參數開始入手。
使用以下內容替換掉 main 函數裏的內容:
let action = std::env::args().nth(1).expect("Please specify an action");
let item = std::env::args().nth(2).expect("Please specify an item");
println!("{:?}, {:?}", action, item);
複製代碼
來一塊兒消化下代碼裏的重要信息:
let
[文檔] 給變量綁定一個值std::env::args()
[文檔] 是從標準庫的 env 模塊中引入的函數,該函數返回啓動程序時傳遞給其的參數。因爲它是一個迭代器,咱們可使用 nth()
函數來訪問存儲在每一個位置的值。位置 0 引向程序自己,這也是爲何咱們從第一個元素而非第零個元素開始讀取的緣由。expect()
[文檔] 是一個 Option
枚舉定義的方法,該方法將返回一個須要給定的值,若是給定的值不存在,則程序當即會被中止,並打印出指定的錯誤信息。因爲程序能夠不帶參數直接運行,所以 Rust 經過給咱們提供 Option 類型來要求咱們檢查是否確實提供了該值。
做爲開發者,咱們有責任確保在每種條件下都採起適當的措施。
目前咱們的程序中,若是未提供參數,程序會被當即退出。
讓咱們經過以下命令運行程序的同時傳遞兩個參數,記得參數要附加在 --
以後。
$ cargo run -- hello world!
Finished dev [unoptimized + debuginfo] target(s) in 0.01s
Running target/debug/todo_cli hello 'world'\!''
"hello", "world!"
複製代碼
讓咱們考慮一下咱們想在這個程序中實現的目標:可以讀取用戶在命令行輸入的參數,更新咱們的 todo 清單,而後存儲到某個地方來提供記錄。
爲了達到這個目標,咱們將實現自定義類型,來在其中知足咱們的業務。
咱們將使用 Rust 中的 struct(結構體),它使開發者能設計有着更優良結構的代碼,從而避免了必須在主函數中編寫全部的代碼。
因爲咱們將在項目中會用到不少 HashMap,所以咱們能夠考慮將其歸入自定義結構體中。
在文件頂部添加以下行:
use std::collections::HashMap
複製代碼
這將讓咱們能直接地使用 HashMap
,而無需每次使用時都鍵入完整的包路徑。
在 main 函數的下方,讓咱們添加如下代碼:
struct Todo {
// 使用 Rust 內置的 HashMap 來保存 key - val 鍵值對。
map: HashMap<String, bool>,
}
複製代碼
這將定義出咱們須要的 Todo 類型:一個有且僅有 map 字段的結構體。
這個字段是 HashMap 類型。你能夠將其考慮爲一種 JavaScript 對象,在 Rust 中要求咱們聲明鍵和值的類型。
HashMap<String, bool>
表示咱們具備一個字符串組成的鍵,其值是一個布爾值:在應用中來表明當前元素的活動狀態。方法就像常規的函數同樣——都是由 fn
關鍵字來聲明,都接受參數且均可以有返回值。
可是,它們與常規函數不一樣之處在於它們是在 struct 上下文中定義的,而且它們的第一個參數始終是 self
。
咱們將定義一個 impl(實現)代碼塊在上文新增的結構體下方。
impl Todo {
fn insert(&mut self, key: String) {
// 在咱們的 map 中新增一個新的元素。
// 咱們默認將其狀態值設置爲 true
self.map.insert(key, true);
}
}
複製代碼
該函數內容十分簡單明瞭:它經過使用 HashMap 內置的 insert 方法將傳入的 key 插入到 map 中。
其中兩個很重要的知識是:
mut [doc] 設置一個可變變量
mut
關鍵字來給相關變量加入可變性。因爲咱們的函數須要經過修改 map 來添加新的值,所以咱們須要將其設置爲可變值。& [doc] 標識一個引用。
有了前面關於借用(borrow)和引用(reference)的知識鋪墊,如今是個很好的時機來簡要地討論 Rust 裏的全部權(ownership)。
全部權是 Rust 中最獨特的功能,它使 Rust 程序員無需手動分配內存(例如在 C/C++ 中)就能夠編寫程序,同時仍能夠在無需垃圾收集器(如 JavaScript 或 Python)的狀況下運行,Rust 會不斷查看程序的內存以釋放未使用的資源。
全部權系統有以下三個規則:
Rust 會在編譯時檢查這些規則,這意味着是否以及什麼時候要在內存中釋放值須要被開發者明確指出。
思考一下以下示例:
fn main() {
// String 的全部者是 x
let x = String::from("Hello");
// 咱們將值移動到此函數中
// 如今 doSomething 是 x 的全部者
// 一旦超出 doSomething 的範圍
// Rust 將釋放與 x 關聯的內存。
doSomething(x);
// 因爲咱們嘗試使用值 x,所以編譯器將引起錯誤
// 由於咱們已將其移至 doSomething 內
// 咱們此時沒法使用它,由於此時已經沒有全部權
// 而且該值可能已經被刪除了
println!("{}", x);
}
複製代碼
在學習 Rust 時,這個概念被普遍地認爲是最難掌握的,由於它對許多程序員來講都是新概念。
你能夠從 Rust 的官方文檔中閱讀有關全部權的更深刻的說明。
咱們不會深刻研究全部權制度的前因後果。如今,請記住我上面提到的規則。嘗試在每一個步驟中考慮是否須要「擁有」這些值後刪除它們,或者是否須要繼續引用它以即可以保留它。
例如,在上面的 insert 方法中,咱們不想擁有 map
,由於咱們仍然須要它來將其數據存儲在某個地方。只有這樣,咱們才能最終釋放被分配的內存。
因爲這是一個演示程序,所以咱們將採用最簡單的長期存儲解決方案:將 map 寫入文件到磁盤。
讓咱們在 impl
塊中建立一個新的方法。
impl Todo {
// [其他代碼]
fn save(self) -> Result<(), std::io::Error> {
let mut content = String::new();
for (k, v) in self.map {
let record = format!("{}\t{}\n", k, v);
content.push_str(&record)
}
std::fs::write("db.txt", content)
}
}
複製代碼
->
表示函數返回的類型。咱們在這裏返回的是一個 Result
類型。content
容寫入名爲 db.txt
的文件中。值得注意的是,save
擁有自 self 的全部權。此時,若是咱們在調用 save 以後意外嘗試更新 map,編譯器將會阻止咱們(由於 self 的內存將被釋放)。
這是一個完美的例子,展現瞭如何使用 Rust 的內存管理來建立更爲嚴格的代碼,這些代碼將沒法編譯(以防止開發過程當中的人爲錯誤)。
如今咱們有了這兩種方法,就能夠開始使用了。如今咱們將繼續在以前編寫的 main 函數內編寫功能:若是提供的操做是 add,咱們將該元素插入並存儲到文件中以供將來使用。
將以下代碼添加到以前編寫的兩個參數綁定的下方:
fn main() {
// ...[參數綁定代碼]
let mut todo = Todo {
map: HashMap::new(),
};
if action == "add" {
todo.insert(item);
match todo.save() {
Ok(_) => println!("todo saved"),
Err(why) => println!("An error occurred: {}", why),
}
}
}
複製代碼
讓咱們看看咱們都作了什麼:
let mut todo = Todo
讓咱們實例化一個結構體,綁定它到一個可變變量上。.
符號來調用 TODO insert
方法。讓咱們測試運行吧。打開終端並輸入:
$ cargo run -- add "code rust"
todo saved
複製代碼
讓咱們來檢查元素是否真的保存了:
$ cat db.txt
code rust true
複製代碼
你能夠在這個 gist 中找到完整的代碼片斷。
如今咱們的程序有個根本性的缺陷:每次「add」添加時,咱們都會重寫整個 map 而不是對其進行更新。這是由於咱們在程序運行的每一次都創造一個全新的空 map 對象,如今一塊兒來修復它。
咱們將爲 Todo 結構實現一個新的功能。調用後,它將讀取文件的內容,並將已存儲的值返回給咱們的 Todo。請注意,這不是一個方法,由於它沒有將 self 做爲第一個參數。
咱們將其稱爲 new
,這只是一個 Rust 約定(請參閱以前使用的 HashMap::new()
)。
讓咱們在 impl 塊中添加如下代碼:
impl Todo {
fn new() -> Result<Todo, std::io::Error> {
let mut f = std::fs::OpenOptions::new()
.write(true)
.create(true)
.read(true)
.open("db.txt")?;
let mut content = String::new();
f.read_to_string(&mut content)?;
let map: HashMap<String, bool> = content
.lines()
.map(|line| line.splitn(2, '\t').collect::<Vec<&str>>())
.map(|v| (v[0], v[1]))
.map(|(k, v)| (String::from(k), bool::from_str(v).unwrap()))
.collect();
Ok(Todo { map })
}
// ...剩餘的方法
}
複製代碼
若是看到上面的代碼感到頭疼的話,請不用擔憂。咱們這裏使用了一種更具函數式的編程風格,主要是用來展現 Rust 支持許多其餘語言的範例,例如迭代器,閉包和 lambda 函數。
讓咱們看看上面代碼都具體發生了什麼:
咱們定義了一個 new
函數,其會返回一個 Result 類型,要麼是 Todo
結構體要麼是 io:Error
。
咱們經過定義各類 OpenOptions 來配置如何打開「db.txt」。最顯著的是 create(true)
標誌,這表明若是該文件不存在則建立這個文件。
f.read_to_string(&mut content)?
讀取文件中的全部字節,並將它們附加到 content
字符串中。
std:io::Read
在文件的頂部以及其餘 use 語句來使用 read_to_string
方法。咱們須要將文件中的 String 類型轉換爲 HashMap。爲此咱們將 map 變量與此行綁定:let map: HashMap<String, bool>
。
lines [文檔] 在字符串的每一行上建立一個 Iterator 迭代器,來在文件的每一個條目中進行迭代。由於咱們已在每一個條目的末尾使用了 /n
格式化。
map [文檔] 接受一個閉包,並在迭代器的每一個元素上調用它。
line.splitn(2, '\t')
[文檔] 將咱們的每一行經過 tab 製表符切割。
collect::<Vec<&str>>()
[文檔] 是標準庫中最強大的方法之一:它將迭代器轉換爲相關的集合。
::Vec<&str>
附加到方法中來將咱們的 Split 字符串轉換爲借來的字符串切片的 Venctor,這回告訴編譯器在操做結束時須要哪一個集合。而後爲了方便起見,咱們使用 .map(|v| (v[0], v[1]))
將其轉換爲元祖類型。
而後使用 .map(|(k, v)| (String::from(k), bool::from_str(v).unwrap()))
將元祖的兩個元素轉換爲 String 和 boolean。
use std::str::FromStr;
在文件頂部以及其它 use 語句,以便可以使用 from_str 方法。咱們最終將它們收集到咱們的 HashMap 中。此次咱們不須要聲明類型,由於 Rust 從綁定聲明中推斷出了它。
最後,若是咱們從未遇到任何錯誤,則使用 Ok(Todo { map })
將結果返回給調用方。
phew!
你作的很棒!圖片來源於 rustacean.net/。
儘管一般認爲 map 更爲好用,但以上內容也能夠經過基本的 for
循環來使用。你能夠選擇本身喜歡的方式。
fn new() -> Result<Todo, std::io::Error> {
// 打開 db 文件
let mut f = std::fs::OpenOptions::new()
.write(true)
.create(true)
.read(true)
.open("db.txt")?;
// 讀取其內容到一個新的字符串中
let mut content = String::new();
f.read_to_string(&mut content)?;
// 分配一個新的空的 HashMap
let mut map = HashMap::new();
// 遍歷文件中的每一行
for entries in content.lines() {
// 分割和綁定值
let mut values = entries.split('\t');
let key = values.next().expect("No Key");
let val = values.next().expect("No Value");
// 將其插入到 HashMap 中
map.insert(String::from(key), bool::from_str(val).unwrap());
}
// 返回 Ok
Ok(Todo { map })
}
複製代碼
上述代碼和以前的函數式代碼是功能性等價的關係。
在 main 中,只須要用如下代碼塊來初始化 todo 變量:
let mut todo = Todo::new().expect("Initialisation of db failed");
複製代碼
如今若是咱們回到終端並執行若干個以下「add」命令,咱們應該能夠看到咱們的數據庫被正確的更新了。
$ cargo run -- add "make coffee"
todo saved
$ cargo run -- add "make pancakes"
todo saved
$ cat db.txt
make coffee true
make pancakes true
複製代碼
你能夠在這個 gist 中找到目前階段下全部的完整代碼。
正如全部的 todo app 同樣,咱們但願不只可以添加項目,並且可以對齊進行狀態切換並將其標記爲已完成。
咱們須要在 Todo 結構體中新增一個 complete 方法。在其中,咱們獲取到 key 的引用值,並更新其值。在 key 不存在的狀況下,返回 None
。
impl Todo {
// [其他的 TODO 方法]
fn complete(&mut self, key: &String) -> Option<()> {
match self.map.get_mut(key) {
Some(v) => Some(*v = false),
None => None,
}
}
}
複製代碼
讓咱們看看上面代碼發生了什麼:
Option
。Match
表達式的結果,該結果將爲空 Some()
或 None
。*
[文檔] 運算符來取消引用該值,並將其設置爲 false。咱們能夠像以前使用 insert 同樣使用 「complete」 方法。
在 main
函數中,咱們使用 else if
語句來檢查命令行傳遞的動做是不是「complete」。
// 在 main 函數中
if action == "add" {
// add 操做的代碼
} else if action == "complete" {
match todo.complete(&item) {
None => println!("'{}' is not present in the list", item),
Some(_) => match todo.save() {
Ok(_) => println!("todo saved"),
Err(why) => println!("An error occurred: {}", why),
},
}
}
複製代碼
是時候來分析咱們在上述代碼中作的事了:
若是咱們檢測到返回了 Some 值,則調用 todo.save 將更改永久存儲到咱們的文件中。
咱們匹配由 todo.complete(&item)
方法返回的 Option。
若是返回結果爲 None
,咱們將向用戶打印警告,來提供良好的交互性體驗。
&item
將 item 做爲引用傳遞給「todo.complete」方法,以便 main 函數仍然擁有該值。這意味着咱們能夠再接下來的 println!
宏中繼續使用到這個變量。若是咱們檢測到返回了 Some
值,則調用 todo.save
將這次更改永久存儲到咱們的文件中。
和以前同樣,你能夠在這個 gist 中找到目前階段下的全部相關代碼。
如今是時候在終端來完整運行咱們開發的這個程序了。讓咱們經過先刪除掉以前的 db.txt 來從零開始這個程序:
$ rm db.txt
複製代碼
而後在 todos中進行新增和修改操做:
$ cargo run -- add "make coffee"
$ cargo run -- add "code rust"
$ cargo run -- complete "make coffee"
$ cat db.txt
make coffee false
code rust true
複製代碼
這意味着在這些命令執行完成後,咱們將會獲得一個完成的元素(「make coffee」),和一個還沒有完成的元素(「code rust」)。
假設咱們此時再從新新增一個喝咖啡的元素「make coffee」:
$ cargo run -- add "make coffee"
$ cat db.txt
make coffee true
code rust true
複製代碼
該程序即便很小,但也能正常運行了。此外,咱們能夠稍微改變一些邏輯。對於來自 JavaScript 世界的我,決定將值存儲爲 JSON 文件而不是純文本文件。
咱們將藉此機會瞭解如何安裝和使用來自 Rust 開源社區的名爲 creates.io 的軟件包。
要將新的軟件包安裝到咱們的項目中,請打開 cargo.toml
文件。在底部,你應該會看到一個 [dependencies]
字段:只須要將如下內容添加到文件中:
[dependencies]
serde_json = "1.0.60"
複製代碼
這就夠了。下次咱們運行程序的時候,cargo 將會編譯咱們的程序並下載和導入這個新的包到咱們的項目之中。
咱們要使用 Serde 的第一個地方是在讀取 db 文件時。如今,咱們要讀取一個 JSON 文件而非「.txt」文件。
在 impl
代碼塊中,咱們更像一下 new
方法:
// 在 Todo impl 代碼塊中
fn new() -> Result<Todo, std::io::Error> {
// 打開 db.json
let f = std::fs::OpenOptions::new()
.write(true)
.create(true)
.read(true)
.open("db.json")?;
// 序列化 json 爲 HashMap
match serde_json::from_reader(f) {
Ok(map) => Ok(Todo { map }),
Err(e) if e.is_eof() => Ok(Todo {
map: HashMap::new(),
}),
Err(e) => panic!("An error occurred: {}", e),
}
}
複製代碼
值得注意的改動是:
文件選項再也不須要 mut f
來綁定,由於咱們不須要像之前同樣手動將內容分配到 String 中。Serde 會來處理相關邏輯。
咱們將文件拓展名更新爲了 db.json
。
serde_json::from_reader
[文檔] 將爲咱們反序列化文件。它會干擾 map 的返回類型,並會嘗試將 JSON 轉換爲兼容的 HashMap。若是一切順利,咱們將像之前同樣返回 Todo 結構。
Err(e) if e.is_eof()
是一個匹配守衛,可以讓咱們優化 Match 語句的行爲。
對於其它全部錯誤,程序會當即被中斷退出。
咱們要使用 Serde 的另外一個地方是將 map 另存爲 JSON。爲此,將 impl 塊中的 save
方法更新爲:
// 在 Todo impl 代碼塊中
fn save(self) -> Result<(), Box<dyn std::error::Error>> {
// 打開 db.json
let f = std::fs::OpenOptions::new()
.write(true)
.create(true)
.open("db.json")?;
// 經過 Serde 寫入文件
serde_json::to_writer_pretty(f, &self.map)?;
Ok(())
}
複製代碼
和之前同樣,讓咱們看看這裏所作的更改:
Box<dyn std::error::Error>
。此次咱們返回一個包含 Rust 通用錯誤實現的 Box。
咱們固然已經將文件名更新爲 db.json
以匹配文件名。
最後,咱們讓 Serde 承擔繁重的工做:將 HashMap 編寫爲 JSON 文件。
請記得從文件頂部刪除 use std::io::Read;
和 use std::str::FromStr;
,由於咱們再也不須要它們了。
這就搞定了。
如今你能夠運行你的程序並檢查輸出是否保存到文件中。若是一切都很順利,你會看到你的 todos 都保持爲 JSON 了。
你能夠在這個 gist 中閱讀當前階段下完整的代碼。
這是一段漫長的旅程,很榮幸你能閱讀到這裏。
我但願你能在這個教程中學到一些東西,併產生了更多的好奇心。別忘了咱們在這裏介紹的是一門很是「底層」的語言。
這是 Rust 吸引個人重要緣由——Rust 使我可以編既快速又具備內存效率的代碼,而沒必要畏懼承擔過多的編碼責任:我知道編譯器會幫我優化更多,在運行前可能會出現錯誤的狀況下提早中斷運行。
在結束前,我想向你分享一些其餘技巧和資源,以幫助你在 Rust 的旅途中繼續前行:
cargo check
[文檔] 將嘗試在不運行的狀況下編譯代碼:這在你只想在不實際運行時檢查代碼正確性的狀況下,會變得頗有用。想要了解有關 Rust 的更多內容,我認爲這些資源真的很棒:
你能夠在 Github 中找到本文的相關源碼。
文中的插圖來自於 rustacean.net/。
感謝閱讀,祝你編碼愉快!