跳到主要内容

34 篇博文 含有标签「Rust」

查看所有标签

模式匹配是Rust语言的一项神奇功能,请阅读文本了解更多。

什么是模式匹配

在许多编程语言中,模式匹配都是一个强大而通用的功能, Rust中也有这个功能。 它是我最喜欢的语言功能之一, 我最早是在OCaml中发现它的,它也是函数式编程的支柱之一。

模式匹配的核心是允许开发人员根据一系列模式检查给定值, 并根据匹配结果执行相应的代码。 这意味着模式匹配更注重数据的形状, 而不是数据本身。

在Rust中的模式匹配语法

在Rust中,模式匹配是使用match关键字完成的。 基本语法如下所示:

match value {
pattern1 => { },
pattern2 => { },
...
_ => { },
}

在花括号之间,您可以找到该值可以具有的不同形状, 在=>之后,您可以找到模式匹配时执行的代码。 代码可以是单个表达式用大括号括起来的代码块

_模式是一个包罗万象的模式, 如果前面的模式都不匹配,则匹配任何内容。

模式匹配是一项强大的功能, 因为Rust会检查模式的详尽性,即它将确保所有模式都得到处理, 并且如果您忘记处理某个模式,它会警告您。

模式匹配案例

  1. 匹配数字
let number = 3;

match number {
1 => println!("One"),
2 => println!("Two"),
3 => println!("Three"),
_ => println!("Something else"),
}

功能

  • 本例检查数字的值,并打印数字对应的单词(如果数字在13之间)
  • 通用模式_,是任何未具体匹配的数字(如3以外的数字)的默认情况。 如果缺少了它,Rust就会提示错误,并告诉您该模式并不详尽。

优势

  • 与多个if-else语句相比,此处使用模式匹配简化了逻辑。 它更加简洁和可读,特别是对于固定范围的值。
  1. 匹配字符串
let day = "thursday";

match day {
"monday" => println!("first day of the week"),
"tuesday" => println!("second day of the week"),
"wednessday" => println!("third day of the week"),
"thursday" => println!("fourth day of the week"),
"friday" => println!("fifth day of the week"),
"saturday" => println!("sixth day of the week"),
"sunday" => println!("seventh day of the week"),
_ => println!("this is not a valid day"),
}

功能

  • 这会将字符串day与代表一周中每一天的七种可能性进行匹配, 并根据匹配执行不同的代码。 最后的包罗万象的模式将捕获不是有效日期的字符串。

优势

  • 模式匹配有利于处理特定的已知字符串。 它比使用一些列if-else语句更清晰、更直接。
  1. 使用可选项
let some_option: Option<i32> = Some(5);

match some_option {
Some(number) => println!("Number is {}", number),
None => println!("No number"),
}

功能

  • 这个例子处理 Option<i32> 类型,该类型可能包含整数(Some) 或不包含任何内容(None)。
  • match语句要么打印数字(如果存在),要么打印一条消息说没有数字。
  • 这种处理值缺失的方法比在其他语言中使用undefinednil更安全, 因为None清楚地表明该值不存在,但代码按预期工作,而不是发生了另一种问题。

优势

  • 模式匹配非常适合Option类型,因为它以安全、简洁的方式优雅地处理 两种情况(SomeNone),同时保持代码的确定性。
  1. 匹配枚举
enum Direction {
Up,
Down,
Left,
Right,
}

let dir = Direction::Up;
match dir {
Direction::Up => println!("Going up!"),
Direction::Down => println!("Going down!"),
Direction::Left => println!("Going left!"),
Direction::Right => println!("Going right!"),
}

功能

  • 枚举定义:

    WebEvent有五个变体,每种变体代表不同类型的事件。 PageLoadPageUnload等变体包含一个StringKeyPress包含一个u32MouseClick包含两个xy坐标结构中的i64值。

  • 模式匹配:

    match语句,每个臂对应不同的WebEvent变体。 对于PageLoadPageUnload,它会打印URL。 对于KeyPress,它会打印按键代码。 对于MouseClick,它会析构结构以获取xy坐标并打印出来。 对于ScreenRefresh(屏幕刷新),它不携带额外数据,只打印一条信息。

优势

  • 对带有值的枚举进行模式匹配,可以简洁地处理封装在每个枚举变量中的不同类型的数据。 与使用嵌套的if-else语句或其他方法相比,通过直接解构每个变体, 代码变得简洁、更易读。这种方法还能确保处理所有可能的情况(枚举的变体), 从而使代码更加健壮和详尽。
  1. 复杂模式
let pair = (0, -2);

match pair {
(0, y) => println!("Y axis: {}", y),
(x, 0) => println!("X axis: {}", x),
_ => println!("Somewhere on the plane"),
}

功能

  • 此示例处理包含两个整数的元组对。
  • 匹配检查两个整数中的任何一个是否为零,并识别该对位于哪个轴上, 或者以其他方式确认它位于平面上的某个位置。

优势

  • 像这样的复杂模式匹配对于解构和处理各种数据类型非常有用。 它比嵌套的if-else语句更高效、更易读,尤其是在处理元组等多组数据结构时。
let array = [1, 2, 3];

match array {
[0, ..] => println!("Array starts with 0"),
[1, 2, 3] => println!("Array contains 1, 2, 3"),
[_, _, _] => println!("Array has three elements"),
}

功能

  • 前缀匹配:[0, ..]使用..模式来匹配以0开头的任何数组。如果数组以0开头(如[0,4,5]), 则会打印Arraystarts with 0
  • 完全匹配:[1, 2, 3]匹配恰好包含1,2,3的数组。如果数组是[1,2,3], 则打印Array contains 1,2,3
  • 长度匹配:[_,_,_]匹配任何具有三个元素的数组,而不管它们的值是多少。 如果数组有三个元素,如[7,8,9]则打印数组有三个元素。由于在进行模式匹配之前, 数组的长度是已知的,因此它或多或少起到了一网打尽的作用。

优势

  • 数组上的模式匹配可以成为验证数组或其包含的元素的形状的强大工具, 而无需诉诸更复杂的遍历数组的方法(如foldmap)

限制和考虑因素

Rust 对数组的模式匹配是有限的,因为我们需要在编译时知道数组的大小。 模式必须考虑数组的长度,这与其他数据类型(如向量)相比会有限制,因为后者的长度可以是动态的。 对于小数组和固定大小的数组, 模式匹配可以为处理基于数组的逻辑提供一种简洁、可读性强的方法, 但对于较长的数组,使用这种方法就会变得更加麻烦。

为什么更喜欢模式匹配而不是if条件

模式匹配是一项强大的功能,因为它不仅仅是检查相等性, 还可以重组数据类型,如元组或枚举,从而直接提取值。

与一系列 if 语句相比,这使得代码更加简洁、可读性更强、更不容易出错, 尤其是在处理复杂的数据结构时。

模式匹配还能确保处理所有可能的情况,无论是特定情况还是默认情况,从而使代码更加健壮。

总之,Rust 中的模式匹配为处理条件逻辑提供了一种清晰、简洁和强大的方法。

在使用 Rust 的各种数据类型(如枚举和选项)时,它的作用尤为突出, 让开发人员可以编写出更可读、更易维护的代码。

无论是处理简单的值还是复杂的数据结构,模式匹配都能大大简化 Rust 代码。

最后的话

Rust 中的模式匹配为处理不同类型的数据和条件提供了一种结构化和优雅的方法。

它能够针对不同的数据类型和结构进行重组和匹配, 因此在很多情况下,它比传统的条件语句更受欢迎。

这不仅使代码更具可读性和可维护性,还能确保以安全、稳健的方式全面处理所有可能的情况。

鱼雪

在Rust中,迭代器是一种尤为重要的数据类型, 被用于遍历集合中的元素。 Rust中的大多数集合类型都可以转换成迭代器, 从而可以对它们进行遍历。 包括:数组(Array)、向量(Vec)、散列表(HashMap)

使用迭代器可以使代码更加简洁、优雅,并且支持 过滤映射折叠 等强大的操作

迭代器的基本概念

在Rust中,迭代器是一种实现了Iterator trait的类型。 该trait定义了在集合中遍历元素的一系列行为。 通过实现Iterator trait,可以将一个类型转换为一个迭代器, 从而实现迭代器等操作。

Iterator Trait

Iterator trait定义了迭代器的核心行为。 它包括next方法和其它几个方法。 next方法为集合中的下一个元素返回一个**Option值**, 直到所有元素都遍历完毕,此时返回None

除了next方法,Iterator trait还定义了许多其他有用的方法, 例如mapfilter等,允许对迭代器元素进行操作和转换

pub trait Iterator {
type Item;

fn next(&mut self) -> Option<Self::Item>;
}

Animal案例

让我们探索Animal结构体的迭代器的实现。 Animal类型实现了Iterator trait, 从而可以对其属性进行迭代。 以下是Animal类型的定义:

#[derive(Debug)]
struct Animal {
name: String,
age: u32,
kind: String,
i: i32,
}

我们为Animal实现Iterator trait,以使用for循环迭代其属性:

impl Iterator for Animal {
type Item = String;

fn next(&mut self) -> Option<Self:Item> {
let next_attribute = match self.i {
0 => Some(self.name.clone()),
1 => Some(self.age.to_string()),
2 => Some(self.kind.clone()),
_ => None,
};
self.i += 1;
next_attribute
}
}

现在我们已经将类型转换为迭代器,
我们可以从迭代器中调用各种方法。
例如,可以使用`for`循环来迭代`Animal`的每个属性:

```rust
fn main() {
let mut animal = Animal {
name: "Tome".to_string(),
age: 15,
kind: "cat".to_string(),
i: 0,
}
}

在上述代码中,我们定义了一个Animal的迭代器, 包括一个名为i的内部状态变量。 代码成功打印了Animal的所有信息。

让我们通过定义Animal向量并迭代打印每个Animal的属性来继续我们的探索:

fn print_all_attrs(animals: Vec<Animal>) {
for mut animal in animals {
println!("Name: {}", animal.next().unwrap());
println!("Age : {}", animal.next().unwrap());
println!("Kind: {}", animal.next().unwrap());
}
}

fn main() {
let animals = vec![Animal {
name: "Tom".to_string(),
age: 15,
kind: "cat".to_string(),
i: 0,
}];
print_all_attrs(animals);
}

在这段代码中,我们使用for循环来迭代所有Animal对象并逐一打印它们的属性。

迭代器的常见用途

map方法

map方法是Iterator trait中的一个关键方法。 它允许我们转换迭代器中的每个元素并返回一个新的迭代器。 例如:

fn main() {
let animals = vec![
Animal {
name: "Tom".to_string(),
age: 14,
kind: "cat".to_string(),
i: 0,
},
Animal {
name: "Jerry".to_string(),
age: 7,
kind: "mouse".to_string(),
i: 1,
},
];

let list: Vec<String> = animals
.into_iter()
.map(|ani| ani.name.clone())
.collect();
println!("{:?}", list);
}

在上面的代码中,我们定义了一个带有两个Animal对象的向量, 并使用map方法从每个Animal中提取name属性, 返回一个新的迭代器。 然后,collect()方法将其转换为向量。

filter方法

假设我们想找到三岁或以上的Animal。 我们可以使用过滤器来实现这一点:

fn main() {
let animals = vec![
Animal {
name: "Tom".to_string(),
age: 33,
kind: "cat".to_string(),
i: 3,
},
];

let filtered_animals: Vec<Animal> = animals
.into_iter()
.filter(|animal| animal.age >= 3)
.collect();
println!("{:?}", filtered_animals);
}

在上面的代码中,我们使用filter方法选择年龄为三岁或以上的Animal, 返回一个新的Animal向量。

enumerate方法

enumerate方法将迭代器中的每个元素与其索引配对, 返回一个新的迭代器。例如:

fn main() {
let animals = vec![
Animal {
name: "Tom".to_string(),
age: 33,
kind: "cat".to_string(),
i: 3,
},
Animal {
name: "Jay".to_string(),
age: 22,
kind: "mouse".to_string(),
i: 4,
},
];

for (i, animal) in animals.iter().enumerate() {
println!("{}: {:?}", i, animal);
}
}

在上面代码中,我们使用enumerate方法将每个Animal与其索引配对, 并使用for循环打印结果。

flat_map方法

flat_map方法不太常见但很有用。 它展平嵌套迭代器到单个迭代器中。例如:

fn main() {
let cat = Animal {
name: "Tom".to_string(),
age: 21,
kind: "cat".to_string(),
i: 3,
};

let mouse = Animal {
name: "Jerry".to_string(),
age: 3,
kind: "mouse".to_string(),
i: 2,
};

let animals = vec![vec![cat], vec![mouse]];

let list: Vec<Animal> = animals
.iter()
.flat_map(|x| x.iter().cloned())
.collect();
println!("{:?}", list);
}

在上面代码中,我么定义了一个2D向量Animals,并使用flat_map方法 将其展平为1D迭代器,将其转换回向量。

zip方法

要同时迭代两个向量,我们可以使用zip方法进行配对:

fn main() {
let names = vec!["Tom", "Jerry", "Bob"];
let ages = vec![3, 4, 5];

for (name, age) in names.iter().zip(ages.iter()) {
println!("{} is {} years old", name, age);
}
}

在上面代码中,我们使用zip方法将姓名和年龄向量中的元素配对, 并在for循环中打印每对。

fold方法

fold方法在Rust中时必不可少的; 它接受一个初始值和一个闭包, 迭代元素, 并将它们合并为一个值。例如:

fn main() {
let cat = Animal {
name: "Tom".to_string(),
age: 13,
kind: "cat".to_string(),
i: 0,
};
let mouse = Animal {
name: "Jerry".to_string(),
age: 7,
kind: "mouse".to_string(),
i: 1,
};

let animals = vec![cat, mouse];

let sum = animals
.iter()
.fold(0, |t, ani| t + ani.age);
println!("{}", sum);
}

在上面代码中,我们定义了一个带有两个Animal对象的向量, 并使用fold方法累加年龄属性, 返回结果总和。

总结

在Rust中,迭代器是遍历集合元素和支持各种操作的重要数据类型。 在本文中,我们探讨了迭代器的基本概念和常用方法, 并使用Animal示例演示了相应的代码。 希望读者能够扎实地理解Rust迭代器,并在实际编程中有效地应用它们。

鱼雪

Rust 的所有权系统是编译器用来处理内存,确保程序内存使用安全性的关键方面。 与一些具有垃圾回收或显式内存分配和释放的语言不同,Rust 采用了一种独特的方法。 它通过一组规则来管理内存,这些规则由编译器检查,确保程序保持内存安全。

以下是 Rust 所有权系统的三个关键规则:

  1. Rust 中的每个值都有一个所有者。
  2. 同一时间只能有一个所有者。
  3. 当所有者超出范围时,该值将被丢弃,内存将被释放。

例如:

fn main() {
// 规则 1:Rust 中的每个值都有一个所有者
let mut s = String::from("Hello");

// 规则 2:同一时间只能有一个所有者
let s1 = s; // s1 现在拥有字符串;s 不再有效

// 规则 3:当所有者超出范围时,该值将被丢弃
// 当 s1 超出范围时,值 "Hello" 将被丢弃
}

这些规则确保 Rust 程序有效地管理内存,防止与内存错误相关的常见陷阱。 理解所有权对于编写健壮且安全的 Rust 代码至关重要。

所有权和函数

Rust 的所有权概念不仅适用于变量,还适用于将值传递给函数。 当将值传递给函数时,它要么移动,要么复制,就像在变量赋值期间一样。 让我们通过一些带有注释的示例来探讨这个问题。

fn main() {
let s = String::from("welcome"); // s 进入作用域
takes_ownership(s); // s 的值移动到函数中,此处不再有效
let x = 8; // x 进入作用域
makes_copy(x); // x 将移动到函数中,但 i32 是 Copy 类型,所以在此之后仍然可以使用 x
} // 此处,首先是 x 超出范围,然后是 s。因为 s 的值已被移动,所以不会发生特殊的事情。

fn takes_ownership(s1: String) {
// s1 进入作用域
println!("{}", s1);
} // 此处,s1 超出范围,其内存被释放。

fn makes_copy(y: i32) {
// y 进入作用域
println!("{}", y);
} // 此处,y 超出范围。不会发生特殊的事情。

如果我们尝试在调用 takes_ownership 后使用 s, Rust 将引发编译时错误,确保我们的代码正确性。

返回值和作用域

函数还可以通过返回值传递所有权。考虑以下带有注释的示例:

fn main() {
let s1 = gives_ownership(); // gives_ownership 将其返回值移动到 s1
let s2 = String::from("hello"); // s2 进入作用域
let s3 = takes_and_gives_back(s2); // s2 被移动到 takes_and_gives_back,该函数还将其返回值移动到 s3
} // 此处,s3 超出范围并被丢弃。s2 被移动,因此什么都不会发生。s1 超出范围并被丢弃。

fn gives_ownership() -> String {
// gives_ownership 将其返回值移动到调用它的函数中
let s4 = String::from("welcome"); // s4 进入作用域
s4 // 返回 s4 并移动到调用它的函数中
}

fn takes_and_gives_back(s5: String) -> String {
// s5 进入作用域
s5 // 返回 s5 并移动到调用它的函数中
}

所有权模式保持一致:将值分配给另一个变量将其移动。 如果具有堆数据的变量超出范围,除非所有权已被转移,否则该值将被清理。

在 Rust 中的借用和引用

在 Rust 中,借用和引用是使多个变量能够与和操作数据而不获取所有权的基本概念,提供对程序中内存使用的更强大的控制。

借用

借用是一种在不声称所有权的情况下临时使用值的行为。 这允许多个变量读取和访问数据而不更改它。 借用通过引用实现,引用是对数据的不可变指针。 让我们通过一个简化的例子来了解这个概念:

fn main() {
let s = String::from("hello");
let len = s.len();
println!("String length: {}", len);
let mut s1 = s;
// s1[0] = 'h'; // 取消注释此行会导致错误,因为 s1 现在拥有数据
println!("String after modifying: {}", s1);
}

在这个例子中,String 值 slen 函数和变量 s1 借用。 但是,只有 len 函数以不可变方式借用该值,而 s1 以可变方式借用该值。 这意味着 s1 可以修改 s 的值,但 len 不能。

引用

在 Rust 中,引用是对数据的不可变指针。它允许您访问和读取数据而不进行修改。 由 & 符号表示的引用必须遵循特定的借用规则:

  1. 引用的作用域不能超过所有者的作用域。
  2. 不能有对同一值的多个可变引用。

以下是演示 Rust 中引用的简化示例:

fn main() {
let s = String::from("hello");
let len = s.len();
println!("String length: {}", len);
let s1 = &s;
let len1 = s1.len();
println!("String length through reference: {}", len1);
}

在这个例子中,len 函数采用对 String 值 s 的不可变引用。 类似地,变量 len1 采用对 s 的不可变引用。 这允许多个变量访问 s 的值而不进行修改。

总之,在 Rust 中,借用和引用使多个变量能够与和操作数据而不声称所有权, 为程序中内存使用提供了更大的灵活性和控制。

参考

Rust 所有权的官方文档

鱼雪

在系统编程不断发展的领域中,Zig和Rust两种语言因其独特的方法和能力而脱颖而出。 两者都提供引人注目的功能,但面向不同的需求和偏好。 本文深入详细比较了Zig和Rust,突出它们的优势、用例和主要差异。

Zig: 注重简单性和控制

简单性和可读性:Zig的简单语法,类似于C,强调可读性和易于维护。 其设计避免了隐藏的控制流和内存分配,使代码透明且易于理解。

  • 性能:Zig被设计为高性能,并提供对底层细节的高度控制。
  • 编译时执行:Zig强调在编译时执行,从而减少运行时开销并优化性能。
  • 控制和底层能力:作为系统编程的理想选择,Zig在需要直接管理系统资源的场景中表现出色。
  • 内存安全:Zig通过编译时检查和显式错误处理来实现内存安全,依赖程序员进行手动内存管理。 与Rust、C、C++一样,Zig不使用垃圾收集器。为了实现像Rust那样的内存安全,Zig提供促进内存安全的机制, 例如:
    1. 严格的编译时检查
    2. 用于处理可能为null值的可选类型
    3. 带有Error类型的显式错误处理
    4. 具有内建分配器的增强型内存分配
  • 互操作性:Zig具有出色的C互操作性,可以直接使用C库而无需包装器。
  • 错误处理:Zig使用错误类型、错误联合和延迟语句进行错误处理。
  • 社区和生态系统:作为相对较新的语言,Zig的社区和生态系统较小,但正在不断发展。
  • 元编程能力:Zig的编译时元编程通过减少样板代码的需求和启用代码优化,提高了代码的灵活性和生产力。
  • 用例:Zig的简单性和直接的C互操作性使其在嵌入C项目或进行低级系统编程, 需要控制和清晰度至关重要的情况下特别有优势。 例如,在构建操作系统、设备驱动程序和嵌入式系统的系统编程中非常理想。 它还在命令行工具中很有用,用于创建高效快速的命令行界面,构建系统脚本或优化现有工具的性能。 Zig以其元编程能力和对简单性的关注而闻名,Bun是一个在Zig中开发的JavaScript运行时环境的著名示例。

Rust: 安全、并发和高性能

  • 无垃圾收集的内存安全:Rust的所有权和借用规则确保内存安全,消除了垃圾收集器的需求。
  • 并发和并行性:内置对安全有效多线程的支持使Rust成为需要并发的应用程序的理想选择。
  • 编译时间:Rust的安全措施可能导致比其他语言更长的编译时间。为了在运行时防止问题,它在编译时检查代码。
  • 互操作性:Rust依赖于外部函数接口(FFI)和类似的倡议,使其能够与C和C++集成。
  • 社区和生态系统:Rust拥有强大的工具和库生态系统。其包管理器Cargo显着简化了依赖管理和与外部库的集成。
  • 错误处理:Rust的Result和Option类型增加了错误处理的表达性,提高了代码的可靠性和可读性。
  • 跨平台兼容性和零成本抽象:Rust提倡跨平台开发。Rust通过将所有代码编译成机器指令,没有解释器或垃圾收集器,实现零成本抽象。这样,Rust确保任何抽象都不会带来额外的运行时成本。
  • 用例:Rust的先进并发特性在Web服务器和数据库系统中得到有效利用,其中安全有效地管理多个线程至关重要。其所有权模型确保线程安全,使其成为高性能并发应用程序的强大选择,例如Servo浏览器引擎或TiKV分布式键值存储。
  • 尽管像任何语言一样,Rust有其优点和缺点,但它仍然是开发人员中的热门选择。在2023年Stack Overflow开发人员调查中,Rust第8年蝉联“最受欢迎和令人敬佩的编程语言”的榜首,超过80%的受访者表示他们明年仍然想要使用它。
  • 在系统编程中,Rust用于构建操作系统、设备驱动程序和嵌入式系统等任务。
  • 后端和前端Web开发人员也使用Rust与流行的框架(如Rocket或Actix)进行后端开发,以及使用WebAssembly或Tauri进行前端开发。
  • Rust还用于网络和网络服务,例如网络协议、代理、负载均衡器、VPN软件等。

Rust vs. Zig: 性能

客观地说,在Rust和Zig之间没有更高性能的语言。Rust在特定应用中可能优于Zig,而Zig在其他应用中可能优于Rust。

让我们仔细检查每种语言和编译器基准的比较中的性能:

此基准项目包含用几种编程语言编写的同时运行的程序。 它们的运行结果然后被测量并以表格形式呈现,供您查看每种编程语言在任务中的性能如何。

Rust Vs Zig

在上图中,我们有使用Rust和Zig编写的mandelbrot和nbody程序。比较表中的测量结果从性能更好到性能较差进行排列。

您会注意到,在某些情况下,Zig的性能优于Rust,在其他情况下,Rust的性能优于Zig。 两者都是高性能的语言,因此在项目中选择任何一种选项都应该能够满足您的需求。

结论:为工作选择合适的工具

  • 用例适应性:如果您的项目需要在内存安全和并发方面提供强大的保证,Rust可能是更好的选择。对于更注重C互操作性和简单性的项目,Zig可能更合适。
  • 社区和支持:Rust更大的生态系统可能是需要广泛的外部库和社区支持的项目的决定性因素。
  • 学习曲线:由于其类似于C的语法,Zig可能在初期更容易掌握,而Rust的学习曲线可能会因其强大的安全功能和并发支持而得到证明,具体取决于项目需求。
  • 性能优化:Zig和Rust都以高度优化的代码、手动内存管理、直接CPU访问和编译时评估而闻名。
  • 低级控制:两者都提供对系统资源的更多控制,使它们成为低级任务和系统编程的理想选择。

最终,选择Zig还是Rust应该基于项目的具体需求、团队对语言的熟悉程度以及您需要的支持和库的种类。 这两种语言在系统编程领域都是强大的工具,可以根据不同情境做出正确选择。 由于Rust存在的时间更长,因此在功能和稳定性方面可能被认为更加成熟。

参考链接

Zig vs Rust: A Comprehensive Comparison for Modern Developers

Zig官网

Rust官网

编程语言和编译器基准测试

鱼雪

CandleRust 的极简 ML 框架,重点关注性能(包括 GPU 支持)和易用性。 今天我们来使用Candle完成一个深度学习的Hello World案例:手写数字识别。 我们使用最简单的线性模型来训练一个自己的手写数字识别模型,作为Candle框架的 最简单入门案例。

环境

  • Rust: 1.75.0-nightly
  • candle-core: 0.3.0
  • candle-nn: 0.3.0
  • candle-datasets: 0.3.0
提示

candle-nn当前版本中依赖了Rust nightly

Cargo.toml内容如下

  1. rand: 随机数
  2. anyhow: 处理异常
  3. clap: 解析命令行参数
[package]
name = "linear_mnist"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
candle-core = { git = "https://github.com/huggingface/candle.git", version = "0.3.0" }
candle-nn = { git = "https://github.com/huggingface/candle.git", version = "0.3.0" }
rand = "0.8.5"
anyhow = "1"
clap = { version = "4.4.4", features = ["derive"] }
candle-datasets = { git = "https://github.com/huggingface/candle.git", version = "0.3.0" }

创建项目并安装Candle相关模块

  1. 使用cargo new创建linear_mnist项目
  2. 进入项目目录
  3. 安装candle三个模块
    • candle-core
    • candle-nn
    • candle-datasets
  4. 安装其他依赖库
    • rand
    • anyhow
    • clap

具体操作如下:

cargo new linear_mnist
cd linear_mnist

cargo add --git https://github.com/huggingface/candle.git candle-core
cargo add --git https://github.com/huggingface/candle.git candle-nn
cargo add --git https://github.com/huggingface/candle.git candle-datasets

代码

导入相关依赖

  1. 导入clap::Parser解析命令行参数
  2. 导入candle_core的相关依赖
    • Device: 数据计算时放置的设备
    • Result: 处理异常
    • Tensor: 张量数据类型
    • D: 是一个enum,包含Minus1Minus2
    • DType: 数据类型enum结构,包含支持的数据类型
  3. 导入candle-nn的相关依赖
    • loss: 损失函数相关操作
    • ops: 函数操作,如log_softmax
    • Linear: 线性模型
    • Module: 由于Linear的依赖
    • Optimizer: 优化器
    • VarBuilder: 构建变量
    • VarMap: 用于存储模型变量
use clap::{ Parser };
use candle_core::{ Device, Result, Tensor, D, DType };
use candle_nn::{ loss, ops, Linear, Module, Optimizer, VarBuilder, VarMap };

定义相关配置

  1. 定义图像维度数量和标签数量的常量
  2. 定义命令行参数解析,并添加指令宏#[derive(Parser)],可以使用clap::Parser解析命令行参数
    • learning_rate: 学习率
    • epochs: 模型训练迭代次数
    • save_model_path: 训练好的模型保存路径
    • load_model_path: 加载预训练模型路径
    • local_mnist: 本地MNIST数据集目录
  3. 定义训练参数结构体TrainingArgs
  4. 定义线性模型结构体LinearModel

具体代码如下:

const IMAGE_DIM: usize = 784;
const LABELS: usize = 10;

#[derive(Parser)]
struct Args {
#[arg(long)]
learning_rate: Option<f64>,

#[arg(long, default_value_t = 10)]
epochs: usize,

#[arg(long)]
save_model_path: Option<String>,

#[arg(long)]
load_model_path: Option<String>,

#[arg(long)]
local_mnist: Option<String>,
}

struct TrainingArgs {
learning_rate: f64,
load_path: Option<String>,
save_path: Option<String>,
epochs: usize,
}

struct LinearModel {
linear: Linear,
}

定义模型

  1. 定义Model trait
  2. LinearModel实现Model trait
    • new: 初始化模型
    • forward: 模型结构,前向传播
  3. linear_z是具体创建Linear模型
    • 创建模型张量变量并调用candle-nn::Linear创建线性模型返回

具体代码如下:

trait Model: Sized {
fn new(vs: VarBuilder) -> Result<Self>;
fn forward(&self, xs: &Tensor) -> Result<Tensor>;
}

impl Model for LinearModel {
fn new(vs: VarBuilder) -> Result<Self> {
let linear: Linear = linear_z(IMAGE_DIM, LABELS, vs)?;
Ok(Self { linear })
}

fn forward(&self, xs: &Tensor) -> Result<Tensor> {
self.linear.forward(xs)
}
}

fn linear_z(in_dim: usize, out_dim: usize, vs: VarBuilder) -> Result<Linear> {
let ws: Tensor = vs.get_with_hints((out_dim, in_dim), "weight", candle_nn::init::ZERO)?;
let bs: Tensor = vs.get_with_hints(out_dim, "bias", candle_nn::init::ZERO)?;
Ok(Linear::new(ws, Some(bs)))
}

定义模型训练函数

  1. 输入参数
    • m: 数据集
    • args: 训练参数TrainingArgs
  2. 获取或设置模型运算的设备Device::Cpu
  3. 从数据集m中获取训练数据和标签,测试数据和标签
  4. 创建varmap用来存储模型参数
  5. 创建vs变量构造,存储模型参数,并将其传入到Model::new
  6. 如果命令行传入load_model_path,则会加载预训练模型
  7. 创建优化器SGD
  8. 根据epochs迭代训练模型
    • 前向传播得到logits
    • 计算概率log_softmax
    • 计算损失函数值
    • 反向传播sgd.backward_step()
    • 输入测试数据得到测试数据准确率test_accuracy
    • 每个epoch花费的时间epoch_duration
  9. 如果命令传入save_model_path,则会保存模型参数
    • 确保存放模型的目录已经建立

具体代码如下:

fn train<M: Model>(
m: candle_datasets::vision::Dataset,
args: &TrainingArgs) -> anyhow::Result<()> {

let dev = Device::Cpu;

let train_labels = m.train_labels;
let train_images = m.train_images.to_device(&dev)?;
let train_labels = train_labels.to_dtype(DType::U32)?.to_device(&dev)?;
let test_images = m.test_images.to_device(&dev)?;
let test_labels = m.test_labels.to_dtype(DType::U32)?.to_device(&dev)?;

let mut varmap = VarMap::new();
let vs = VarBuilder::from_varmap(&varmap, DType::F32, &dev);
let model = M::new(vs.clone())?;

// Load Pre-trained Model Parameters
if let Some(load_path) = &args.load_path {
println!("Loading model from {}", load_path);
let _ = varmap.load(load_path);
}

// Create Optimizer
let mut sgd = candle_nn::SGD::new(varmap.all_vars(), args.learning_rate)?;

// Iterate training model
for epoch in 1..=args.epochs {
let start_time = std::time::Instant::now();
let logits = model.forward(&train_images)?;
let log_sm = ops::log_softmax(&logits, D::Minus1)?;
let loss = loss::nll(&log_sm, &train_labels)?;

sgd.backward_step(&loss)?;

let test_logits = model.forward(&test_images)?;
let sum_ok = test_logits
.argmax(D::Minus1)?
.eq(&test_labels)?
.to_dtype(DType::F32)?
.sum_all()?
.to_scalar::<f32>()?;
let test_accuracy = sum_ok / test_labels.dims1()? as f32;
let end_time = std::time::Instant::now();
let epoch_duration = end_time.duration_since(start_time);
println!("Epoch: {epoch:4} Train Loss: {:8.5} Test Acc: {:5.2}% Epoch duration: {:.2} second.",
loss.to_scalar::<f32>()?, test_accuracy * 100., epoch_duration.as_secs_f64());
}

// Save Model Parameters
if let Some(save_path) = &args.save_path {
println!("Saving trained weight in {save_path}");
varmap.save(save_path)?
}
Ok(())
}

main函数

  1. 解析命令行参数Args
  2. 根据local_mnist命令行参数指定的目录加载MNIST数据集
  3. 设置学习率
  4. 创建模型训练参数TrainingArgs类型变量training_args并填充设置好的参数
  5. 调用模型训练函数train::<LinearModel>(m, &training_args),传入数据集模型训练参数
fn main() ->anyhow::Result<()> {
let args: Args = Args::parse();
let m: candle_datasets::vision::Dataset = if let Some(directory) = args.local_mnist {
candle_datasets::vision::mnist::load_dir(directory)?
} else {
candle_datasets::vision::mnist::load()?
};

println!("Train Images: {:?}", m.train_images.shape());
println!("Train Labels: {:?}", m.train_labels.shape());
println!("Test Images: {:?}", m.test_images.shape());
println!("Test Labels: {:?}", m.test_labels.shape());

let default_learning_rate: f64 = 0.1;

let training_args = TrainingArgs {
epochs: args.epochs,
learning_rate: args.learning_rate.unwrap_or(default_learning_rate),
load_path: args.load_model_path,
save_path: args.save_model_path,
};

train::<LinearModel>(m, &training_args)
}

训练

  1. 如果saved_model不存在,则需要先创建该目录

  2. 目录结构如下

linear_mnist
├── Cargo.lock
├── Cargo.toml
├── dataset
│   ├── t10k-images-idx3-ubyte
│   ├── t10k-labels-idx1-ubyte
│   ├── train-images-idx3-ubyte
│   └── train-labels-idx1-ubyte
├── saved_model
│   └── minst.safetensors
└── src
└── main.rs
  1. 训练并保存模型参数

HuggingFace Candle 训练手写数字识别

  1. 加载预训练模型继续训练

HuggingFace Candle加载预训练模型

  1. 完整代码地址

Candel Linear Model Training MNIST Classification on Github

参考代码

Candle MNIST Training

鱼雪

Rust 是一种由 Mozilla 领导开发的系统编程语言。它设计的目标是提供内存安全、并发性和高性能。Rust 具有以下一些主要特性:

  • 内存安全
  • 所有权系统
  • 并发性
  • 零成本抽象
  • 模式匹配
  • 没有运行时开销
  • 活跃的社区

基本数据类型

  • 很大程度上,Rust是围绕其类型设计的。
  • Rust对高性能代码的支持,源自它能让开发者原则最适合当前场景的数据表示法,在简单性与成本之间进行合理的权衡。
  • Rust的内存和线程安全保障也依赖于Rust类型系统的健全性。
  • Rust的灵活性则源自于其泛型类型和特型。

Rust有以下两个特性

  • 基于已明确写出的类型,Rust的类型推断会帮你推断出剩下的大部分类型
  • 函数可以是泛型的:单个函数就可以处理许多不同类型的值

固定宽度的数值类型

  1. Rust类型系统的根基是一组固定宽度的数值类型,选用这些类型是为了匹配几乎所有现代处理器都已直接在硬件中实现的类型。 固定宽度的数值类型可能会溢出或丢失精度。
  2. char既不是u8,也不是u32(尽管它确实有32位长)
  3. usize类型和isize类型类似于CC++中的size_tptrdiff_t。它们的精度与目标机器上地址空间的大小保持一致, 即在32位架构上是32位长,在64位架构上则是64位长。
  4. Rust要求数组索引是usize值。
  5. 可以使用as运算符将一种整型转换成另一种整型。
  6. Rust几乎不会执行任何隐式的数值转换,随时可以使用as运算符写出显式转换。

布尔类型

  • Rust非常严格
    • ifwhile这样的控制结构要求它们的条件必须是bool表达式
    • 短路逻辑运算符&&||也是如此

字符

  1. Rust的字符类型char会以32位值表示单个Unicode字符
  2. Rust会对单独的字符使用char类型,但对字符串文本流使用UTF-8编码
  3. u8是唯一能通过as运算符转换为char的类型

指针类型

  1. Rust有多种表示内存地址的类型,这是Rust和大多数具有垃圾回收功能的语言之间一个重大差异。
  2. Rust语言旨在帮你将内存分配保持在最低限度。

引用

  1. 将引用视为Rust中的基本指针类型
  2. Rust引用有两种形式
    • &T: 一个不可变的共享引用
    • &mut T: 一个可变的、独占的引用
  3. Rust利用共享引用和可变引用之间的"二选一"机制来强制执行"单个写入者或多个读取者"规则
  4. 你独占读写一个值,或者让任意数量的读取者共享,但二者只能选择其一
  5. 这种由编译期检查强制执行的"二选一"规则是Rust安全保障的核心

数组、向量和切片

  1. Rust用3种类型来表示内存中的值序列

    • 类型[T;N]表示N个值的数组,每个值的类型为TN在编译期已经确定,不能追加新元素或缩小数组
    • 类型Vec<T>可称为T的向量,它是一个动态分配且可增长的T类型的值序列
    • 类型&[T]&mut [T]可称为T的共享切片和T的可变切片
  2. 在数组上看到的那些使用方法都是作为切片而非数组的方法提供的

    • 包括:遍历、搜索、排序、填充、过滤等
    • Rust在搜索各种方法时会隐式地将对数组的引用转换为切片,因此可以直接在数组上调用任何切片的方法
    • sort方法实际上是在切片上定义的
    • len方法也是切片的方法之一
  3. 向量

    • 使用collect时,通常要指定类型,因为它可以构建出不同种类的集合,而不仅仅是向量
    • 与数组一样,可以对向量使用切片的方法
      • reverse方法实际上是在切片上定义的,此调用会隐式地从向量中借用一个&mut [&str]切片并在其上调用reverse
    • Vec是Rust的基本数据类型,它几乎可以用在任何需要动态大小的列表的地方
    • 如果事先知道向量所需的元素数量,就可以调用Vec::with_capacity而不是Vec::new来创建一个向量,它的缓冲区足够大,可以从一开始就容纳所有元素
    • 许多库函数会寻求使用Vec::with_capacity而非Vec::new的机会
  4. 切片

    • 切片数组向量中的一个区域
    • 对切片的引用是一个胖指针
      • 一个双字值
      • 包括指向切片第一个元素的指针切片中元素的数量

字符串类型

  1. 字符串字面量
    • 原始字符串用小写字母r进行标记,原始字符串不识别任何转义序列
    • 可以再原始字符串的开头和结尾添加#做标记
  2. 字符串
    • 带有b前缀的字符串字面量都是字节串,这样的字节串是u8值(字节)的切片而不是Unicode文本

      let method = b"POST";  // method类型是&[u8; 4]
      assert_eq!(method, &[b'P', b'O', b'S', b'T']);
    • 字节串不能包含任意Unicode字符,它们只能使用ASCII\xHH转义序列

  3. 内存中的字符串
    • Rust字符串是Unicode字符序列,但并不以char数组的形式存储在内存中,而是用了UTF-8形式(可变宽度编码)
    • String&str.len()方法会返回其长度
    • &mut str类型确实存在,但它没什么用
    • &mut str上唯一可用的操作是make_ascii_uppercasemake_ascii_lowercase,根据定义,它们会就地修改文本并且只影响单字节字符
  4. String
    • &str非常像&[T],是指向某些数据的胖指针
    • String则类似于Vec<T>
    • String有几种方法
      • .to_string()方法会将&str转换为String,这会复制此字符串
      • .to_owned()方法会做同样的事情,也会以同样的方式使用
      • format!()宏的工作方式与println!()类似,但它会返回一个新的String
      • .concat().json(sep),字符串数组、切片、向量都有这两个方法,它们会从许多字符串中形成一个新的String
  5. 其它类似字符串的类型
    • 对于Unicode文本,坚持使用String&str
    • 当使用文件名,请改用std::path::PathBuf&Path
    • 当处理根本不是UTF-8编码的二进制数据时,请使用Vec<u8>&[u8]
    • 当使用操作系统提供的原生形式环境变量名命令行参数时,请使用OsString&OsStr
    • 当和使用null结尾字符串的C语言库进行互操作时,请使用std::ffi::CString&CStr

所有权与移动

  1. 谈及内存管理,我们希望编程语言具备两个特点

    • 能在我们选定的时机及时释放,这使得我们能控制程序的内存消耗
    • 对象被释放后,绝不希望继续使用使用指向它的指针,这是未定义行为,会导致崩溃和安全漏洞
  2. 几乎所有主流编程语言都只能在两个阵营"二选一"

    • 安全优先:通过垃圾回收机制,在所有指向对象的可达指针都消失后,自动释放对象
    • 控制优先:让开发者自己负责释放内存,程序的内存消耗完全掌握在开发者受众。
  3. Rust通过限制程序使用指针的方式出人意料的打破了这种困局

    • 在运行期,指针仅仅是内存中的地址,和在C与C++中一样
    • 而不一样的是,Rust编译器已然证明你的代码在安全地使用它们
  4. 所有权

    • 每个值都有唯一的拥有者
    • 拥有者及其拥有的哪些值形成了一棵
      • 值的拥有者是值的父节点
      • 值拥有的值是子节点
      • 每棵树的总根都是一个变量
      • 当该变量超出作用域时,整棵树都将随之消失
    • Rust的单一拥有者规则将禁止任何可能让它们排列得比树结构更复杂的可能性
    • Rust程序中的每一个值都是某棵树的成员,树根是某个变量
  5. 为了处理某些场景,Rust从几个方面扩展了这种简单的思想

    • 可以将值从一个拥有者转移给另一个拥有者,
      • 允许你构建、重新排列和拆除树形结构
    • 像整型、浮点数和字符这样的非常简单的类型,不受所有权规则的约束
      • 这些称为Copy类型
    • 标准库提供了引用计数指针类型RcArc,它们允许在某些限制下有多个拥有者
    • 可以对值进行"借用"(borrow),以获得值的引用。
      • 这种引用是非拥有型指针,有着受限的生命周期
  6. 移动

    • 对大多数类型来说,像为变量复制、将其传给函数或从函数返回的操作都不会复制值,而是移动值。
    • 源会把值的所有权转移给目标并变回未初始化状态,改由目标变量来控制值的生命周期
      • 将参数传给函数会将所有权转移给函数的参数
      • 从函数返回一个值会将所有权转给调用者
      • 构建元组会将值转给元组
    • 值移动涉及字符串、向量和其他占用大量内存且复制成本高昂的类型
    • 移动让这些类型的所有权清晰且赋值开销极低
  7. Copy类型:关于移动的例外情况

    • 对于Copy类型的值进行赋值会复制这个值,而不会移动它
    • 赋值的源仍会保持已初始化和可用状态,并且具有与之前相同的值
    • 标准的Copy类型包括:
      • 整数类型、浮点类型、char类型、bool类型,以及其他类型
    • 任何在丢弃值时需要做一些特殊操作的类型都不能是Copy类型
    • 如果结构体的所有字段都是Copy类型,那么可以通过属性#[derive(Copy,Clone)]放置在此定义之上来创建Copy类型
    • Rust的一个原则是:
      • 各种开销对程序员来说应该是显而易见的
      • 基本操作必须保持简单,而潜在的昂贵操作应该是显式的
  8. RcArc:共享所有权

    • RcArc非常相似,唯一的区别是Arc可以安全地在线程之间直接共享
    • 普通Rc会使用更快的非线程安全代码来更新引用计数
    • 克隆一个Rc<T>值并不会复制T,相反,它只会创建另一个指向它的指针并通过递增引用计数
    • 通常的所有权规则适用于Rc指针本身,当丢弃最后一个Rc时,Rust也会丢弃T
      • 资源交给Rc管理,Rc不存在后则一切都不存在了
    • Rust的内存和线程安全保证的基石是:
      • 确保不会有任何值时既共享可变
      • Rust假Rc指针的引用目标通常都可以共享,因此就不能是可变的
    • 弱引用指针std::rc::Weak来避免建立Rc指针循环。
  9. 小结

    • 所有权:每个值有唯一的所有者
    • 移动:涉及特殊操作的赋值会发生所有权转移
    • Copy:简单类型的赋值操作会复制值
    • Rc和Arc:共享值的所有权
    • 移动和引用计数指针是缓解所有权严格性问题的两种途径
    • 第三种途径是借用对值的引用

引用

  • 迄今为止,我们所看到的所有指针类型都是拥有型指针,意味着当拥有者被丢弃时,它的引用目标也会随之消失
    • 如:简单的Box<T>堆指针,String值,Vec值内部的指针
  • Rust还有一种名为引用(reference)非拥有型指针,这种指针对引用目标生命周期毫无影响
  • 引用生命周期决不能超出其引用目标
  • Rust把创建对某个值的引用的操作称为**借用(borrow)**那个值:凡是借用,终须归还
  • 引用本身确实没什么特别之处-说到底,它们只是地址而已
    • 但用以让引用保持安全的规则,对Rust来说是一种创新
  1. 对值的引用

    • 引用能让在不影响其所有权的情况下访问值
      • 共享引用:允许你读取但不能修改其引用目标,可以同时拥有任意数量对特定值的共享引用。
        • 类型写成:&T
        • 共享引用是Copy类型
      • 可变引用:允许你读取和修改值,一旦一个值拥有了可变引用,就无法对该值创建其它任何引用。
        • 类型写成: &mut T
        • 可变引用不是Copy类型
    • 可以将共享引用和可变引用之间的区别视为在编译期强制执行多重读取单一写入
      • 这条规则不仅适用于引用,也适用于所引用值的拥有者
      • 只要存在一个值的共享引用,即使是它的拥有者也不能修改它,该值会被锁定
    • 事实证明:让共享和修改保证完全分离对内存安全至关重要
  2. 由于引用在Rust中随处可见,因此**.运算符**就会按需对其左操作数隐式解引用

    struct Anime { name: &'static str, bechdel_pass: bool};
    let aria = Anime { name: "Aria: The Animation", bechdel_pass: true};
    let anime_ref = &aria;
    assert_eq!(anime_ref.name, "Aria: The Animation");
    • 在Rust中使用&运算符*运算符来创建引用(借用)和追踪引用(解引用),不过.运算符不需要做这种转换,它会隐式借用和解引用
  3. 对引用进行引用

    struct Point {x: i32, y: i32};
    let point = Point {x: 1000, y: 729};
    let r: &Point = &point;
    let rr: &&Point = &r;
    let rrr: &&&Point = &rr;
    • .运算符会追踪尽可能多层次的引用来找到它的目标
  4. 比较引用

    • 就像.运算符一样,Rust的比较运算符也能看穿任意数量的引用
    • 比较运算符操作数(包括引用型操作数)必须是完全相同类型
      • 比如如上代码中rrr类型不匹配
    • 两个引用是否指向同一块内存,可以使用std::ptr::eq,会对两者作为地址进行比较
  5. 引用永不为空

    • 在Rust中,如果需要用一个值来表示对某个可能不存在事物的引用,请使用Option<&T>
    • 在机器级别,Rust会将None表示为空指针,将Some(r)表示为非零地址,因此Option<&T>与C/C++中的可空指针一样高效,但更安全
      • 它的类型要求你在使用之前必须检查它是否为None
  6. 对切片和特型对象的引用

    • Rust还包括两种胖指针,即携带某个值地址的双字值,以及要正确使用该值所需的某些额外信息
      • 对切片的引用就是一个胖指针,携带着此切片的起始地址及其长度
      • 另一种胖指针是特型对象,即对实现了指定型的值的引用
  7. Rust的可变与共享规则

    • 共享访问是只读访问
      • 对于共享借用,这条路径是只读的
    • 可变访问是独占访问
      • 对于可变借用,这条路径是完全不可访问的
      • 通过要求可变访问必须是独占的,Rust避免了一大类日常错误
    • 在编写并发代码时,共享引用和可变引用的互斥性确实证明了其价值

表达式

  • Rust中完全面向表达式的控制流
    • if表达式可用于初始化变量
    • match表达式可以作为参数传给函数或宏
    • 块是一种最通用的表达式,一个块生成一个值
    • if let表达式其实只有一个模式的match表达式的简写形式
    • match表达式的所有分支都必须具有相同的类型
    • while循环for循环的值总是()
    • loop表达式就能生成一个值
      • loop中所有break表达式也必须生成具有相同类型的值,这样该类型就会成为这个loop本身的类型
    • ..运算符会生成一个范围(range),即具有两个字段(start和end)的简单结构体
    • break表达式会退出所在循环
  • 几个重要的自动转换
    • &String类型的值会自动转换为&str类型,无须强制转换
    • &Vec<i32>类型的值会自动转换为&[i32]
    • &Box<Chessboard>类型的值自动转换为&Chessboard
    • 以上这些被称为隐式解引用,因为它们适用于所有实现了内置特型Deref的类型
      • Deref隐式转换的目的是使智能指针类型的行为尽可能像其底层值
      • 多亏了DerefBox<Chessboard>的用法基本上和普通Chessboard的用法一样

crate与模块

  • crate
    • 每个crate都是既完整又内聚的单元
    • 包含单个库或可执行程序的所有源代码
  • crate是关于项目间代码共享的,而模块是关于项目内代码组织的
    • 它们(crate与module)扮演着Rust命名空间的角色,是构成Rust程序或库的函数、类型、常量等的容器
  • 函数标记为pub(crate)
    • 那么就意味着它可以再这个crate的任何地方使用
    • 但不会作为外部接口的一部分公开
  • 预导入(prelude)
    • 它们通过收集几乎所有用户都需要的常用导入,减少了样板代码的编写
    • 把一个模块命名为prelude只是一种约定,旨在告诉用户应该使用*导入它
  • 模块
    • 目录可以作为一个模块,目录名为模块名
      • 目录中包含mod.rs,表示是一个模块,类似Python语言的__init__.py
    • 文件可以作为一个模块,文件名为模块名
    • 自定义文件中的模块
      • 使用mod关键字
    • ::运算符用于访问模块中的各项特型
    • 关键字supercrate在路径中有着特殊的含义
      • super指的是父模块
      • crate指的是当前模块所在的crate
    • as关键字可以给导入的内容重命名
    • 绝对路径
      • ::开头,总会引用外部crate
    • src/lib.rs中代码构成了库的根模块

结构体

  • Rust有三种结构体类型

    • 具名字段型结构体
    • 元组型结构体
    • 单元型结构体
  • 具名字段型结构体

    • Rust中的约定是
      • 所有类型(包括结构体)的名称都将每个单词的第一个字母大写,称为大驼峰式
      • 字段和方法是小写的,单词之间用下划线分隔,称为蛇形格式
    • 结构体默认情况下是私有的
      • 仅在声明它们的模块及其子模块中可见
      • 即使一个结构体声明为pub,它的字段也可以是私有的
      • 其它模块可以使用此结构体及其任何公共的关联函数,但不能按名称访问私有字段或使用结构体表达式创建新值
  • 用impl定义方法

    • impl块只是fn定义的集合,每个定义都会成为块顶部命名的结构体类型上的一个方法
    • impl块中定义的函数称为关联函数,因为它们是与特定类型相关联的
    • Rust会将调用关联函数的结构体值作为第一个参数传给方法
      • 该参数必须具有特殊名称self
      • 由于self的类型显然就是在impl块顶部命名的类型或对该类型的引用
      • self的不同形式
        • self
        • &self
        • &mut self
  • BoxRcArc形式传入self

    • 方法的self参数也可以是Box<Self>类型,Rc<Self>类型或Arc<Self>类型
    • Rust会自动从BoxRcArc等指针类型中借入引用
      • 因此&self&mut self几乎总是方法签名里的正确选择
  • 类型关联函数

    • impl块还可以定义根本不以self参数的函数
    • 这些函数仍然是关联函数,因为它们在impl块中
    • 但它们不是方法,因为它们不接受self参数
    • 为了将它们与方法区分开来,我们称其为类型关联函数
  • 泛型结构体

    • Rust结构体可以是泛型的
      • 在泛型结构体定义中,尖括号(<>)中的类型名称叫作类型参数
      • Self参数定义为我们要为其添加方法的任意类型。
      • 调用关联函数时,使用::<>(比目鱼)表示法显式地提供类型参数
  • 带生命周期参数的泛型结构体

    • 如果结构体类型包括引用,则必须为这些引用的生命周期命名
  • 带常量参数的泛型结构体

    • 常量泛型参数可以是任意整数类型charbool
    • 不允许使用浮点数、枚举和其他类型
    • 如果结构体还接受其他种类的泛型参数
      • 生命周期参数必须排在第一位,然后是类型,接下来是任何const值
  • 内部可变性

    • 我们需要一个不可变值中的一丁点儿可变数据,称为内部可变性
    • Cell<T>是一个包含类型T的单个私有值的结构体
    • Cell唯一的特殊之处在,即使你对Cell本身没有mut访问权限,也可以获取和设置这个私有字段
    • Cell只是改变不变性规则的一种安全方式
      • 一丝不多,一毫不少
      • Cell不允许在共享值上调用mut方法
      • .get()方法会返回Cell中值的副本,因此它仅在T实现了Copy特型时才有效
    • Cell很容易使用
      • 虽然不得不调用.get().set().borrow().borrow_mut()略显尴尬
      • 但这就是我们为违反规则而付出的代价
    • Cell不是线程安全的
    • Cell不用,RefCell支持借用对其T值的引用

枚举与模式

  • Rust模式有点像针对所有数据的正则表达式
  • 枚举可以是泛型的,如Option<T>,Result<T>
  • 只能用一种安全的方式来访问枚举中的数据,即使用模式
  • match表达式中,模式就是出现在=>符号前面的部分
  • 表达式会生成值,模式会消耗值
  • 模式匹配可以和枚举协同工作,甚至可以测试它们包含的数据,这让match成了C的switch语句的强大而灵活的替代品
  • match也可用来匹配其他类型
    • 字面量、变量、通配符等
    • 字面量也可以用作模式,包括布尔值、字符,甚至字符串
    • 不关心匹配的值,那么可以用单个下划线_作为模式,这就是通配符模式
  • 元组型模式匹配元组
  • 结构体型模式使用花括号,就像结构体表达式一样
  • 数组型模式与切片模式
    • 切片模式不仅匹配值,还匹配长度
  • 引用型模式
    • ref模式会借用己匹配值的一部分
    • &模式会匹配引用`
    • 匹配不可复制的值会移动该值

特型与泛型

  • Rust通过两个相关联的特性来支持多态
    • 特型泛型
  • 特型
    • 是Rust体系中的接口抽象基类
    • 是一种语言特性,我们可以说某类型支持或不支持某个特型
    • 大多数情况下,特型代表着一种能力,即一个类型能做什么
    • 特型本身必须在作用域内
      • CloneIterator的各个方法没有导入就能工作,因为默认它们始终在作用域中,是标准库一部分
      • 预导入主要就是一些精心挑选的特型
  • 泛型
    • 是Rust中多态的另一种形式
  • 界限
    • 对类型T可能的类型范围做了限制
  • 泛型和特型的紧密相关
    • 泛型函数会在限界中使用特型来阐明它能针对哪些类型的参数进行调用
  1. 为什么向类型添加特型不需要额外的内存,以及如何在不需要虚方法调用开销的情况下使用特型

  2. 特型对象

    • 在Rust中使用特型编写多态代码有两种方式:
      • 特型对象
      • 泛型
    • 特型类型引用叫作特型对象
    • Rust通常无法在编译期间知道引用目标的类型,因此特性对象要包含一些关于引用目标类型的额外信息
    • 特型对象的内存布局
      • 特型对象是一个胖指针
      • 指向值的指针指向表示该值类型虚表的指针组成
      • 在Rust中,虚表只会在编译期生成一次,并由同一类型的所有对象共享
      • 当调用特型对象的方法时,该语言会自动使用虚表来确定调用哪个实现
      • 在C++中,虚表指针vptr是作为结构体的一部分存储,而Rust是胖指针方案
      • Rust中,结构体本身只包含自己的字段
        • 这样一来,每个结构体就可以实现几十个特型而不必包含几十个vptr
      • Rust在需要时会自动将普通引用转换为特型对象
      • 创建特型对象的唯一方法
        • &mut dyn train_name
        • &mut dyn WriteBox<dyn Write>也是一个胖指针,包含写入器本身的地址和虚表的地址
  3. 特型对象还是泛型代码的选择相当微妙

    • 由于这两个特性都基于特型
    • 当需要一些混合类型值的集合时,特型对象是正确的选择
  4. 特型对象相比,泛型具有三个重要优势

    • 第一个优势是速度,没有dyn则不会有动态派发,没有运行期开销
    • 第二个优势在于并不是每个特性都能支持特型对象
    • 第三个优势是它很容易同时指定具有多个特型和泛型参数限界,特型对象不能这样
  5. 定义与实现特型

    • 定义特型很简单,给它一个名字并列出特型方法的类型签名即可
    • 要实现特型,请使用语法impl TraitName for Type
    • 特型impl代码中定义一切都必须是真正属于此特型的
    • 自定义函数不能放在特型的impl代码中,只能包含在单独的impl块
    • 特型的impl代码中的一切都必须是真正属于此特型
    • 特型中有未实现的函数,也有已实现的函数
  6. 特型中的Self

    • 特型可以用关键字Self作为类型
    • 使用了Self类型的特型特型对象不兼容
    • 特型对象实际上是为最简单的特型类型而设计的
    • 特型的高级特性很有用,但它们不能与特型对象共存
      • 因为一旦有了特型对象,就会失去Rust对你的程序进行类型检查时所必须的类型信息
  7. impl Trait

    • 是一种静态派发形式
    • Rust不允许特型方法使用impl Trait作为返回值
    • 只有最简单的泛型函数中才能把impl Trait参数用作类型,参数的类型之间不能存在关系
  8. 类型限界的另一个优点

    • 当遇到编译期错误时,至少编译期可以告诉你问题出在哪里
    • 限界就这么写在代码和文档中,你可以查看Rust中泛型函数的签名
    • 并准确了解它能接受的参数类型
    • 而使用模版则做不到这些
  9. 以特型为基础

    • 特型泛型在所有这些主题中都扮演着核心角色
鱼雪

CandleRust 的极简 ML 框架,重点关注性能(包括 GPU 支持)和易用性。尝试我们的在线演示: whisperLLaMA2T5yoloSegment Anything

模块

Candle项目包括一些crates,如下:

  • candle-book: candle相关的文档
  • candle-core: 核心功能库,核心操作,设备,Tensor结构定义等。
  • candle-nn: 神经网络,构建真实模型的工具
  • candle-examples: 在实际环境中使用库的示例
  • candle-datasets: 数据集和数据加载
  • candle-transformers: Transformer相关实现工具
  • candle-flash-attn: Flash Attention v2实现
  • candle-kernels: CUDA加速实现
  • candle-pyo3: Rust提供的Python接口
  • candle-wasm-examples: Rust WASM示例

其它有用的库:

  • candle-lora: 提供了符合官方peft实现的LoRA实现

特点

  • 语法简单(看起来像PyTorch)
    • 支持模型训练
    • 支持用于自定义操作运算
  • 后端
    • 优化的CPU后端,具有针对x86的可选MKL支持和针对MacAccelerate支持
    • CUDA后端可以再GPU上高效运行,通过NCCL运行多GPU分配
    • WASM支持,在浏览器中运行模型
  • 包含的模型
    • 语言模型
      • LLaMA v1 and v2
      • FaIcon
      • StarCoder
      • Phi v1.5
      • T5
      • Bert
    • Whisper(多语言支持)
    • Stable Diffusion v1.5, v2.1, XL v1.0
    • Wurstchen v2
    • 计算机视觉
      • DINOv2
      • EfficientNet
      • yolo-v3
      • yolo-v8
      • Segmeng-Anything(SAM)
  • 文件格式
    • 加载模型支持的格式如下:
      • safetensors
      • npz
      • ggml
      • PyTorch files
  • 无服务部署
    • 小型且快速的部署
  • 使用llama.cpp量化类型的量化支持

基本用法介绍

  1. 创建张量

    Tensor::new(&[[1f32, 2.], [3., 4.]], &Device::Cpu)?
    Tensor::zeros((2, 2), DType::F32, &Device::Cpu)?
  2. 张量索引

    tensor.i((.., ..4))?
  3. 张量重塑

    tensor.reshape((2, 2))?
  4. 张量矩阵乘法

    a.matmul(&b)?
  5. 张量数据移动到特定设备

    tensor.to_device(&Device::new_cuda(0)?)?
  6. 更改张量数据类型

    tensor.to_dtype(&Device::F16)?
  7. 张量算术运算

    &a + &b
  8. 保存模型

    candle::safetensors::save(&HashMap::from([("A", A)]), "model.safetensors")?
  9. 加载模型

    candle::safetensors::load("model.safetensors", &device)
鱼雪

Rust Prelude是Rust标准库隐式导入到每个Rust程序中。

它就像呼吸一样无需调用就存在,也可以明确调用。

它包含许多常用类型、函数和宏。

为编写Rust程序提供了大量功能。但不是所有情况下都是最佳选择。

什么时候使用Rust Prelude

Rust Prelude时一款出色的默认设置,可满足大多数基本需求,在以下情况下应使用

  • 需要常见的数据结构例如:Strings, Vectors, HashMaps
  • 需要常用的迭代函数,如:map, filter, any, all
  • 需要常见的转换函数,如:from_strto_string
  • 需要使用常见宏,如:println!, panic!, unreachable!
  • 程序比较短小精悍,需要大量功能而不需要很多导入

什么时候不使用Rust Prelude

  • 名称冲突:Prelude到处了许多常用名称,因此导入具有相同名称的另一个crates可能会导致冲突
  • 更专业的功能:对于更多特定领域的用例,最好使用专门的crates
  • 为了提高性能:一些Prelude是通用的,对于高性能需求,专门的crates可能更好
鱼雪