Rust程式語言融合了多種程式設計法(programming paradigm),以指令式程式設計(imperative programming)為主,支援函數式程式設計(functional programming),不必明確定義出函數名稱的「閉包」和for迴圈所使用的「迭代器」和即是衍生自函數式程式設計的特性。
在開始介紹閉包之前,我們可以先看一下這個程式碼:
fn hello_something(something: &str) -> String {
let mut s = String::from("Hello ");
s.push_str(something);
s.push_str("!");
s
}
fn main() {
let h = hello_something;
println!("{}", h("world"));
}
以上程式,第11行將hello_something
函數指派給變數h
儲存,接著在第13行直接使用h
作為函數來呼叫。這個是合法的語法,可以成功編譯,程式執行結果如下:
我們可以將任意的函數或是方法使用變數或是參數儲存,將其作為「函數指標」(function pointer)來使用。當然,變數和參數一定會需要有個型別才有辦法分配記憶體空間,那麼函數或方法是屬於什麼型別呢?以上面這個例子來說,h
變數的型別為fn(&str) -> String
,函數或是方法的型別語法就和定義它們的語法一樣,且該型別都會實作Fn
、FnMut
和FnOnce
特性。
此外,函數也可以被定義在某個程式區塊內來使用。舉例來說:
fn main() {
fn hello_something(something: &str) -> String {
let mut s = String::from("Hello ");
s.push_str(something);
s.push_str("!");
s
}
let h: fn(&str) -> String = hello_something;
println!("{}", h("world"));
}
但是,被定義在程式區塊內的函數無法直接使用其所在的scope的資源。舉例來說:
fn main() {
let hello = "Hello ";
fn hello_something(something: &str) -> String {
let mut s = String::from(hello);
s.push_str(something);
s.push_str("!");
s
}
let h: fn(&str) -> String = hello_something;
println!("{}", h("world"));
}
以上程式會編譯失敗,因為我們不能夠在main
函數裡的hello_something
函數中,使用main
函數裡的hello
變數。
接著來談談閉包吧!閉包和函數非常相似,但它不像函數一樣有著具體的型別(concrete type),不必定義出函數名稱,也不必明確定義出參數的型別。閉包甚至還可以在主體內使用其所在的scope的資源。
舉例來說:
let y = 10;
let plus_one = |x| x + y + 1;
println!("{}", plus_one(2)); // 2 + 10 + 1 = 13
以上程式,我們實作出一個能夠計算x + y + 1
的閉包,並指派給plus_one
變數儲存,我們雖然無法具體的定義plus_one
變數的型別是什麼,但可以確定的是它的型別必定有實作Fn
、FnMut
或FnOnce
特性。也就是說,我們依然可以透過使用泛型來傳遞閉包。
整理一下,閉包與函數不同的地方有以下幾點:
||
語法來定義,且參數和回傳值型別可以被推論,不需要明確定義出來。2. 當閉包的主體只有一行敘述時,且閉包沒有明確定義回傳值型別,則程式區塊可以省略大括號
{}
。3. 閉包主體的程式敘述可以使用其所在的scope的資源。
4. 閉包沒有具體的型別。
這裡比較需要注意到的是第3點,在閉包主體內使用其所在的scope的資源,實際上會去「借用」該資源。因此以下這個程式會因為num
變數已經借給plus_num
變數所指的閉包使用,而無法再使用可變參考。如以下程式會編譯錯誤:
let mut num = 5;
let plus_num = |x: i32| x + num;
let y = &mut num;
如果該資源會直接被閉包回傳,其行為和「將變數指派給另一個變數」一樣,會先嘗試「複製」,如果不行就進行「移動」。如以下程式會編譯錯誤:
let mut v = vec![2];
let c = || v;
println!("{v:?}");
閉包可以與move
關鍵字搭配使用,可以強制將其主體內所使用到的其所在的scope的資源先嘗試「複製」,如果不行就進行「移動」,而非原先的借用。因此,以下程式可以編譯成功:
let mut num = 5;
let plus_num = move |x: i32| x + num;
let y = &mut num;
而以下程式會編譯失敗:
let mut s = String::from("Hello!");
let c = move || &s[..1];
println!("{s}");
因為String
結構體並沒有實作Copy
特性,所以變數s
會被移動給閉包來使用。
接著來談談Fn
、FnMut
和FnOnce
特性。
Fn
特性
當閉包並沒有使用到其所在的scope的資源,或是只使用到其所在的scope的不可變資源時,且這個閉包可以被呼叫多次,就會實作這個特性。舉例來說:
fn call_with_one<F>(func: F) -> usize
where
F: Fn(usize) -> usize,
{
func(1)
}
fn main() {
let double = |x| x * 2;
println!("{}", call_with_one(double));
}
double
變數所儲存的閉包並沒有使用到其所在的scope的資源,因此它會實作Fn
特性。這裡要注意到以上程式第2行,我們在指定泛型要實作的Fn
特性時,也要去指定其閉包參數和回傳值的型別,是比較特別的用法,而FnMut
、FnOnce
特性的泛型使用方式也是跟Fn
特性一樣。
FnMut
特性
當閉包有使用到其所在的scope的可變資源時,且這個閉包可以被呼叫多次,就會實作這個特性。舉例來說:
fn do_twice<F>(mut func: F)
where
F: FnMut(),
{
func();
func();
}
fn main() {
let mut x: usize = 1;
let y: usize = 2;
{
let add_two_to_x = || x += y;
do_twice(add_two_to_x);
}
println!("{x}");
}
Fn
特性是FnMut
特性的子特性,多了資源不可變的限制。
FnOnce
特性
只能被呼叫一次的閉包只會實作這個特性。舉例來說:
fn say_hello<F>(func: F)
where
F: FnOnce() -> String,
{
println!("Hello {}.", func());
}
fn main() {
let word = String::from("world");
let get_word = || word;
say_hello(get_word);
}
get_word
變數所儲存的閉包,直接將word
變數儲存的String
結構實體回傳出去,擁有權會發生變化,因此這個閉包在被呼叫過一次之後就不能再被呼叫第二次了。
整理一下,FnOnce
特性是所有閉包都會實作的特性,當閉包只能夠被呼叫一次,就只會實作FnOnce
特性;FnMut
特性繼承(inheritance)自FnOnce
特性,當閉包有使用到其所在的scope的可變資源時,就會實作這個特性;Fn
特性繼承自FnMut
特性,當閉包並沒有使用到任何其所在的scope的資源,或是只使用到其所在的scope的不可變資源時就會實作這個特性。
也就是說,如果我們讓泛型型別明確定義為有實作Fn
特性的閉包時,其也允許有實作FnMut
和FnOnce
的閉包;如果我們讓泛型型別明確定義為有實作FnMut
特性的閉包時,其也允許有實作FnOnce
的閉包;如果我們讓泛型型別明確定義為有實作FnOnce
特性的閉包時,其只允許有實作FnOnce
的閉包。利用這個觀念,我們可以對要傳遞的閉包進行一些特別的篩選。
我們也可以使用Fn
、FnMut
或FnOnce
特性的參考型別來傳遞閉包。如下:
fn call_with_one(some_closure: &dyn Fn(i32) -> i32) -> i32 {
some_closure(1)
}
fn main() {
let answer = call_with_one(&|x| x + 2);
println!("{answer}");
}
以上的dyn
關鍵字,是在撰寫特性的參考型別時要加入的關鍵字,之後的章節會詳細介紹這部份。
一般的函數和方法都會去實作Fn
、FnMut
和FnOnce
特性,所以也可以像閉包這樣被變數或參數儲存以及傳遞。如下:
fn call_with_one(some_closure: &dyn Fn(i32) -> i32) -> i32 {
some_closure(1)
}
fn add_one(i: i32) -> i32 {
i + 1
}
fn main(){
let f = add_one;
let answer = call_with_one(&f);
println!("{answer}");
}
需要傳入閉包作為參數的函數,其閉包的參數或回傳值的生命周期,可以基於函數本身,而非函數的呼叫者。如下:
fn call_with_ref<'a, F>(mut some_closure: F, value: &'a mut i32)
where
F: for<'b> FnMut(&'b mut i32),
{
let mut step = 0;
some_closure(&mut step);
*value += step;
}
fn main() {
let some_closure = |x: &mut i32| *x += 2;
let mut value = 4;
call_with_ref(some_closure, &mut value);
println!("{value}");
}
注意以上程式第3行,使用了for
關鍵字來定義泛型型別的生命周期參數,而非使用其所在函數的泛型參數。如此一來就可以套用函數內scope的生命周期,而非呼叫這個函數的呼叫者所在的scope的生命周期。
迭代器
運用迭代器可以依照順序對陣列或集合進行一些處理,由迭代器負責決定走訪元素的順序以及該次迭代回傳結果的時機。Rust程式語言的標準函式庫所提供的最基本的迭代器會依照元素在資料結構中的儲存順序,走訪每個元素,並在每次迭代結束時回傳該次走訪到的元素。
舉例來說:
fn main() {
let v1 = [1, 2, 3, 4, 5];
let v1_iter = v1.iter();
for val in v1_iter {
println!("Got: {val}");
}
}
以上程式執行結果為:
Got: 2
Got: 3
Got: 4
Got: 5
善用迭代器可以省下很多撰寫重複程式碼的時間,Rust程式語言的標準函式庫提供了多種常用的迭代器相關功能。例如若要篩選出以上程式,Vec
結構體所儲存的偶數元素,可以利用迭代器提供的filter
方法,並搭配閉包使用,產生新的迭代器,使其只會回傳符合某些條件的元素。程式改寫如下:
fn main() {
let v1 = [1, 2, 3, 4, 5];
let v1_iter = v1.iter();
for val in v1_iter.filter(|x| *x % 2 == 0) {
println!("Got: {val}");
}
}
以上程式執行結果為:
Got: 4
也可以使用map
方法,並搭配閉包使用,產生新的在迭代器,使舊的迭代器在迭代的同時能夠透過閉包對元素進行一些改變,在交由新的迭代器回傳。例如將以上程式改成,取得Vec
結構體內所儲存的偶數元素,並將它們通通乘2。程式改寫如下:
fn main() {
let v1 = [1, 2, 3, 4, 5];
let v1_iter = v1.iter();
for val in v1_iter.filter(|x| *x % 2 == 0).map(|x| x * 2) {
println!("Got: {val}");
}
}
以上程式執行結果為:
Got: 8
如果不想要一次走訪所有元素,就不需要使用for迴圈,可以使用迭代器提供的next
方法,來進行一次迭代,並回傳Option
列舉實體,如果該次迭代有可回傳的元素,就會用Some
變體將元素包裹起來回傳;如果沒有了,則會回傳None
變體。舉例來說:
fn main() {
let v1 = [1, 2, 3];
let mut v1_iter = v1.iter();
println!("Got: {:?}", v1_iter.next());
println!("Got: {:?}", v1_iter.next());
println!("Got: {:?}", v1_iter.next());
println!("Got: {:?}", v1_iter.next());
}
注意此處的v1_iter
變數必須使用mut
關鍵字,因為在使用迭代器的next
方法時,其當下所指的元素位置會有變化。
以上程式執行結果為:
Got: Some(2)
Got: Some(3)
Got: None
迭代器除了能夠讓我們自行處理每次迭代的邏輯外,也有提供一些方便的方法能直接在方法內完成走訪,來達成某個目的。例如sum
這個方法,可以快速地將陣列或是集合內的元素做加總。舉例來說:
fn main() {
let v1 = [1, 2, 3];
let v1_iter = v1.iter();
let total: i32 = v1_iter.sum();
println!("{total}");
}
利用迭代器提供的sum
方法,來快速完成Vec
結構實體中所有元素的加總。以上程式執行結果為:
我們先前使用過的collect
方法,也是跟sum
方法一樣,會直接在方法內完成陣列或是集合的走訪,直接回傳新的集合。
我們也可以自行實作出迭代器,只需要讓結構體去實作Iterator
特性,完成Iterator
特性的next
函數即可。舉例來說:
struct Color {
r: u8,
g: u8,
b: u8,
}
struct ColorIterator<'a> {
color: &'a Color,
index: usize,
}
impl<'a> Iterator for ColorIterator<'a> {
type Item = u8;
fn next(&mut self) -> Option<u8> {
let result = match self.index {
0 => self.color.r,
1 => self.color.g,
2 => self.color.b,
_ => return None,
};
self.index += 1;
Some(result)
}
}
impl Color {
fn new(r: u8, g: u8, b: u8) -> Color {
Color { r, g, b }
}
fn iter(&self) -> ColorIterator {
ColorIterator { color: self, index: 0 }
}
}
fn main() {
let color = Color::new(0, 128, 255);
for v in color.iter() {
println!("{v}");
}
}
以上程式,實作了一個ColorIterator
結構體作為Color
結構體的迭代器,能夠依照r
、g
、b
的順序來走訪Color
結構體的欄位。這邊要注意到的是程式第13行的部份,實作Iterator
特性的時候,必須使用type
關鍵字來指定Item
的型別,這個Item
為Iterator
特性的關聯型別(Associated Type),代表每次迭代時要被包裹在Option
列舉的Ok
變體回傳的元素型別。關聯型別的詳細用法會在之後的章節介紹。
另外還有IntoIterator
特性,可以用來替結構體實作其在遇到for迴圈的時候要如何取得迭代器來使用。通常IntoIterator
特性會實作在結構體的不可變參考型別和可變參考型別上,而不會實作在結構體本身。像是陣列的參考型別有實作IntoIterator
特性,因此假設要用for迴圈走訪一個陣列,可以使用for e in &array
這樣的寫法。而Vec
結構體本身也有實作IntoIterator
特性,因此假設要用for迴圈走訪一個Vec
結構使體,可以使用for e in vec
這樣的寫法,但是這會發生「變數指派給另一個變數」的行為,而導致vec
變數被「移動」了,故通常還是會使用參考型別作為進入迭代器的方式。
將以上程式的Color
結構體的不可變參考,加上IntoIterator
特性後,程式如下:
struct Color {
r: u8,
g: u8,
b: u8,
}
struct ColorIterator<'a> {
color: &'a Color,
index: usize,
}
impl<'a> Iterator for ColorIterator<'a> {
type Item = u8;
fn next(&mut self) -> Option<u8> {
let result = match self.index {
0 => self.color.r,
1 => self.color.g,
2 => self.color.b,
_ => return None,
};
self.index += 1;
Some(result)
}
}
impl Color {
fn new(r: u8, g: u8, b: u8) -> Color {
Color { r, g, b }
}
fn iter(&self) -> ColorIterator {
ColorIterator { color: self, index: 0 }
}
}
impl<'a> IntoIterator for &'a Color {
type Item = u8;
type IntoIter = ColorIterator<'a>;
fn into_iter(self) -> Self::IntoIter {
self.iter()
}
}
fn main() {
let color = Color::new(0, 128, 255);
for v in &color {
println!("{v}");
}
}
改寫grep程式
我們來用閉包和迭代器來改寫上一章節完成的grep程式吧!
use std::env;
use std::process;
use minigrep::*;
fn main() {
let args: Vec<String> = env::args().collect();
let config = Config::new(&args).unwrap_or_else(|err| {
eprintln!("Problem parsing arguments: {err}");
process::exit(1);
});
if let Err(e) = minigrep::run(config) {
eprintln!("Application error: {e}");
process::exit(1);
}
}
use std::env;
use std::error::Error;
use std::fs::File;
use std::io::prelude::*;
pub struct Config {
pub query: String,
pub filepath: String,
pub case_sensitive: bool,
}
impl Config {
pub fn new(args: &[String]) -> Result<Config, &str> {
if args.len() < 3 {
return Err("not enough arguments");
}
let query = args[1].clone();
let filepath = args[2].clone();
let case_sensitive = env::var("CASE_INSENSITIVE").is_err();
Ok(Config { query, filepath, case_sensitive })
}
}
fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
let mut results = Vec::new();
for line in contents.lines() {
if line.contains(query) {
results.push(line);
}
}
results
}
fn search_case_insensitive<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
let query = query.to_lowercase();
let mut results = Vec::new();
for line in contents.lines() {
if line.to_lowercase().contains(&query) {
results.push(line);
}
}
results
}
pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = open_read_file(&config.filepath);
for line in if config.case_sensitive {
search(&config.query, &contents)
} else {
search_case_insensitive(&config.query, &contents)
} {
println!("{line}");
}
Ok(())
}
fn open_read_file(filepath: &str) -> String {
let mut f = File::open(filepath).expect("file not found");
let mut contents = String::new();
f.read_to_string(&mut contents).expect("something went wrong reading the file");
contents
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn one_result() {
let query = "duct";
let contents = "Rust:\nsafe, fast, productive.\nPick three.";
assert_eq!(vec!["safe, fast, productive."], search(query, contents));
}
#[test]
fn case_insensitive() {
let query = "rUsT";
let contents = "Rust:\nsafe, fast, productive.\nPick three.\nTrust me.";
assert_eq!(vec!["Rust:", "Trust me."], search_case_insensitive(query, contents));
}
}
在我們原先的grep程式,lib.rs
檔案的第18行和第19行使用了字串的clone
方法來複製字串。原則上,如非必要,應儘量地避免使用clone
方法複製相同的資料。複製記憶體中的資料不但會需要花費時間進行,還會在記憶體中耗用不同的空間去儲存相同的東西,賠了夫人又折兵。我們之所以在這邊使用clone
方法來複製字串,是因為我們無法直接將某資料結構欄位的資料之擁有權交給其它變數儲存。
為了要解決這個問題,我們必須改良讀取透過命令列傳進來的參數的方式。原先在main.rs
檔案的第7行,我們取得命令列參數的迭代器後,直接使用collect
方法將其走訪完,並產生Vec
結構實體。如今的我們,已經學會了迭代器的用法,要想辦法善用迭代器的特性來讀取資料。
首先,我們需要修改Config
結構體的new
關聯函數,使它可以透過參數傳入命令列參數的迭代器,接著就可以利用該迭代器的next
方法來逐一讀取參數了!next
方法只能用在可變的資料上,因此在定義new
關聯函數的參數時,必須加上mut
關鍵字。
修改後的程式如下:
use std::env;
use std::process;
use minigrep::*;
fn main() {
let config = Config::new(env::args()).unwrap_or_else(|err| {
eprintln!("Problem parsing arguments: {err}");
process::exit(1);
});
if let Err(e) = minigrep::run(config) {
eprintln!("Application error: {e}");
process::exit(1);
}
}
use std::env;
use std::env::Args;
use std::error::Error;
use std::fs::File;
use std::io::prelude::*;
pub struct Config {
pub query: String,
pub filepath: String,
pub case_sensitive: bool,
}
impl Config {
pub fn new(mut args: Args) -> Result<Config, &'static str> {
args.next();
let query = match args.next() {
Some(arg) => arg,
None => return Err("Didn't get a query string"),
};
let filepath = match args.next() {
Some(arg) => arg,
None => return Err("Didn't get a file path"),
};
let case_sensitive = env::var("CASE_INSENSITIVE").is_err();
Ok(Config { query, filepath, case_sensitive })
}
}
fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
let mut results = Vec::new();
for line in contents.lines() {
if line.contains(query) {
results.push(line);
}
}
results
}
fn search_case_insensitive<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
let query = query.to_lowercase();
let mut results = Vec::new();
for line in contents.lines() {
if line.to_lowercase().contains(&query) {
results.push(line);
}
}
results
}
pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = open_read_file(&config.filepath);
for line in if config.case_sensitive {
search(&config.query, &contents)
} else {
search_case_insensitive(&config.query, &contents)
} {
println!("{line}");
}
Ok(())
}
fn open_read_file(filepath: &str) -> String {
let mut f = File::open(filepath).expect("file not found");
let mut contents = String::new();
f.read_to_string(&mut contents).expect("something went wrong reading the file");
contents
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn one_result() {
let query = "duct";
let contents = "Rust:\nsafe, fast, productive.\nPick three.";
assert_eq!(vec!["safe, fast, productive."], search(query, contents));
}
#[test]
fn case_insensitive() {
let query = "rUsT";
let contents = "Rust:\nsafe, fast, productive.\nPick three.\nTrust me.";
assert_eq!(vec!["Rust:", "Trust me."], search_case_insensitive(query, contents));
}
}
lib.rs
檔案的部份有個地方要注意的是,原先我們並沒有對Config
結構體的new
關聯函數的回傳值,也就是Result
列舉的泛型參數值&str
設定生命周期,那是因為當時函數的泛型生命周期參數顯然是可以被編譯器推論的(只有一個參考型別的參數)。而此時因為new
關聯函數沒有參考型別的參數了,&str
的生命周期就無法被自動推論,但我們知道這個&str
就是我們寫在程式碼內的字串定數,因此它的生命周期就是'static
。
修改過的grep程式,已經解決了在記憶體無謂地複製相同資料的問題。但它還是有個我們可以明顯改進的地方,那就是for迴圈和if
關鍵字的連用,可以用迭代器的filter
方法和閉包來處理。Vec
結構體的部份也可以直接用迭代器的collect
方法來產生。將所有被找出來的該行印出的部份也可以使用迭代器的for_each
方法來完成。
於是我們可以再將lib.rs
修改如下:
use std::env;
use std::env::Args;
use std::error::Error;
use std::fs::File;
use std::io::prelude::*;
pub struct Config {
pub query: String,
pub filepath: String,
pub case_sensitive: bool,
}
impl Config {
pub fn new(mut args: Args) -> Result<Config, &'static str> {
args.next();
let query = match args.next() {
Some(arg) => arg,
None => return Err("Didn't get a query string"),
};
let filepath = match args.next() {
Some(arg) => arg,
None => return Err("Didn't get a file path"),
};
let case_sensitive = env::var("CASE_INSENSITIVE").is_err();
Ok(Config { query, filepath, case_sensitive })
}
}
fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
contents.lines().filter(|line| line.contains(query)).collect()
}
fn search_case_insensitive<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {
let query = query.to_lowercase();
contents.lines().filter(|line| line.to_lowercase().contains(&query)).collect()
}
pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = open_read_file(&config.filepath);
let results = if config.case_sensitive {
search(&config.query, &contents)
} else {
search_case_insensitive(&config.query, &contents)
};
results.iter().for_each(|line| {
println!("{line}");
});
Ok(())
}
fn open_read_file(filepath: &str) -> String {
let mut f = File::open(filepath).expect("file not found");
let mut contents = String::new();
f.read_to_string(&mut contents).expect("something went wrong reading the file");
contents
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn one_result() {
let query = "duct";
let contents = "Rust:\nsafe, fast, productive.\nPick three.";
assert_eq!(vec!["safe, fast, productive."], search(query, contents));
}
#[test]
fn case_insensitive() {
let query = "rUsT";
let contents = "Rust:\nsafe, fast, productive.\nPick three.\nTrust me.";
assert_eq!(vec!["Rust:", "Trust me."], search_case_insensitive(query, contents));
}
}
在大多數的情況下,迭代器都擁有比迴圈更好的效能以及可讀性,應多加利用。
結論
受到函數式程式設計的啟發,閉包和迭代器已是Rust程式語言的一大特色,可以增加程式碼的利用率,不會佔用程式執行階段的運算資源,還可以增進程式的效能。下一個章節,我們會深入學習Cargo和crates.io的用法。
下一章:Cargo和Crates.io。