一起长锈:4 默认不可变的变量绑定与引用(从Java与C++转Rust之旅)

2024-06-10 19:45:26 浏览数 (2)

讲动人的故事,写懂人的代码

  • 故事梗概:
  • 在她所维护的老旧Java系统即将被淘汰的危机边缘,这位在编程中总想快速完事的女程序员,希望能转岗到公司内部使用Rust语言的新项目组,因此开始自学Rust;
  • 然而,在掌握了Rust编程知识之后,为了通过Rust项目组的技术面试,使得转岗成功而不至被裁员,她必须领会编程如何"快速"才能有真正的意义。

上次我们聊到,我们那三个程序员小伙伴一起钻研了Rust的prelude和宏。他们还在继续深入研究代码。

赵可菲:“那个给 guess 赋值的语句,没写明类型,看来Rust是自动帮我们判断类型的吧。”

代码语言:javascript复制
    let mut guess = String::new();

贾克强:“没错,Rust是会自动帮我们判断类型,这样代码看起来就更简洁了。但是要注意那句话其实是在做变量绑定(binding),不是赋值哦。”

4.1 默认不可变的变量绑定

贾克强:“在Rust中,我们let关键字把一个值和一个变量名绑定在一起。”

“这个过程可能会涉及到类型推断和类型标注,但类型标注是可选的,所以不用太担心。”

默认情况下,变量绑定的值是不能改变的,也就是说,一旦你把一个值绑定到一个变量,那这个变量的值就不能改变了。”

“但是,如果你在声明变量的时候用了mut关键字,那这个变量就可以改变了,很灵活。”

席双嘉:“在C 里是没有这个变量绑定的概念。”

赵可菲:“Java里也没有变量绑定。那在Rust里就没有变量赋值吗?”

贾克强:“在 Rust 语言中,赋值语句的概念确实还是存在的,但它和变量绑定不一样,两者在用法和含义上有不少区别。”

赋值语句在 Rust 中用来修改已经绑定的变量的值。如果变量是可变的,也就是用 mut 声明的,那就可以对其进行重新赋值。”

“比如在我们的代码中的这两行。上面一行就是变量绑定,非常简洁。然后下面一行的.read_line(&mut guess),这个方法调用,就是对 guess 变量进行赋值的。”

代码语言:javascript复制
let mut guess = String::new();

io::stdin()
    .read_line(&mut guess)
    .expect("Failed to read line");

read_line 方法尝试从标准输入(stdin)读取一行数据,并会把读取的内容追加到 guess 字符串的末尾。”

”在这里,&mut guess 是对 guess 的可变引用,这让 read_line 方法可以修改 guess 的内容。”

“注意,&意味着guess这个参数是个引用。引用默认也是不可变的。”

赵可菲:“嘿,为啥Rust要设计变量绑定这样的机制呢?有啥好处不?”

贾克强:“Rust的变量绑定跟Java或C 的赋值不一样,其实主要是体现了更深的语言设计原则和变量行为的差别。”

变量绑定除了默认不可变这点,还包括变量值的所有权和范围的特性。

“Rust的所有权模型就是,你给一个值取个名字的时候,也在定义这个值的有效范围,以及对它的内存管理的责任。”

这个概念跟Rust的所有权、借用和生命周期系统紧紧相连,跟Java和C 的内存管理模型可是大不一样呢。”

4.1.1 默认可变的Java 和 C 中的赋值

贾克强:“在 Java 和 C 这种语言里,‘赋值’这个词就像是直接把操作摆上桌面。”

“首先,就是默认都可以改。跟 Rust 不一样,在 Java 和 C 里,变量默认都是可以随便改的。你给一个变量赋个值,然后就可以随便改。”

“然后就是内存管理。Java 是靠垃圾收集来管理内存的。C 就需要你自己去管理内存,比如用 newdelete。赋值这个概念在这里,不像 Rust 那样涉及到所有权和范围。”

“最后,就是更简单的语义。在这些语言里,赋值并不涉及所有权或生命周期的复杂性。通常就是把值复制到已经分配好的内存空间。”

4.2 默认不可变的引用

赵可菲:“你能给我浅浅地解释一下Rust的引用吗?”

贾克强:“当然可以。Rust中的引用,就是一种借用数据的方式,分为不可变引用(&T)和可变引用(&mut T)。“

”引用得遵守Rust的所有权和借用规则哦。

借用规则让Rust的编译器能确保引用的安全使用,防止数据竞争和悬挂指针等问题。”

“还有,引用不涉及任何运行时开销,比如计数或额外的内存分配,有助于高性能。”

“但是,引用也会让灵活性变得有点受限。因为借用规则,引用的使用比指针更受限。”

“比方说,在同一作用域内,你不能拥有一个值的多个可变引用。”

“如果你需要安全的修改和访问数据,那引用就是首选。”

“而且,如果你想避免数据拷贝,那也可以用引用。对于大型数据结构,使用引用可以避免昂贵的拷贝操作。”

席双嘉:“你能给我举个在Rust里变量引用默认不可变的例子吗?”

贾克强:“没问题,让我们一起看看下面的代码。”

代码语言:javascript复制
fn modify_value(x: &i32) {
    *x  = 1;  // 尝试修改通过不可变引用传递的值
}

fn main() {
    let mut value = 10;
    modify_value(&value);  // 将不可变引用传递给函数
    println!("Value: {}", value);
}

“在 main 函数中,我们定义了一个变量 value 并绑定为 10。“

”尽管这个变量被声明为可变的(mut),但在将其传递给 modify_value 函数时,我们只传递了一个默认不可变的引用(&value),而不是可变引用(&mut value)。“

“在 modify_value 函数中,x: &i32是函数的参数。“

”其中 &i32 表示 x 是一个指向 i32 类型整数的不可变引用。“

”在 Rust 中,不可变引用意味着你不能通过这个引用修改它所指向的数据。“

”参数 x 只能被读取,不能被修改。“

*x = 1;这行代码尝试解引用 x ,并将其值增加 1。解引用操作符 * 被用于访问引用所指向的值。”

”我们试图修改 x解引用后所指向的值。这里的 x 是一个不可变引用,因此尝试修改它的值(*x = 1)将导致编译错误。”

贾克强运行了cargo check,错误信息显示在屏幕上:

代码语言:javascript复制
error[E0594]: cannot assign to `*x`, which is behind a `&` reference
 --> src/main.rs:2:5
  |
2 |     *x  = 1; // 尝试修改通过不可变引用传递的值
  |     ^^^^^^^ `x` is a `&` reference, so the data it refers to cannot be written
  |
help: consider changing this to be a mutable reference
  |
1 | fn modify_value(x: &mut i32) {
  |                        

贾克强:“编译器还贴心地提示,把x: &i32,改成x: &mut i32试试。咱们改改看。咦,又报错了。”

代码语言:javascript复制
error[E0308]: mismatched types
 --> src/main.rs:7:18
  |
7 |     modify_value(&value); // 将不可变引用传递给函数
  |     ------------ ^^^^^^ types differ in mutability
  |     |
  |     arguments to this function are incorrect
  |
  = note: expected mutable reference `&mut i32`
                     found reference `&{integer}`
note: function defined here
 --> src/main.rs:1:4
  |
1 | fn modify_value(x: &mut i32) {
  |    ^^^^^^^^^^^^ -----------

“在modify_value(&value);里面的value前加上mut试试看。不报错了。运行cargo run试试看。”

屏幕显示了运行结果Value: 11

4.2.1 Java的引用

赵可菲:“Java中的引用并不像Rust那样是借用数据的方式,而是一种可以指向任何对象的变量或表达式类型。”

“Java引用其实就是存储对象内存地址的东西,像一个通向对象的链接,不过我们程序员是看不见这个过程的,不能直接操作地址。”

“这是Java设计的一个核心原则,就是要让我们这些普通的程序员少操心一些内存操作的复杂事,这样可以增加程序的安全性和可移植性。这也是我喜欢Java的主要原因哦。”

“所以,Java并没有提供我们通常理解的指针操作,而是让我们通过引用来访问和操作对象。”

“这样做的好处是,Java的引用更安全。因为它隐藏了对内存地址的操作,增加了程序的安全性,防止我们编写可能导致内存泄漏或越界访问的代码。”

“还有一个好处是,Java的引用简化了内存管理。Java会使用垃圾收集机制来自动管理内存,我们程序员就不需要也不能手动释放对象,这就简化了内存管理。”

“但是,Java的引用也会带来一些性能开销。自动内存管理,也就是垃圾收集,可能会导致性能不可预测,特别是在内存密集型的应用程序中。”

“还有一点就是,Java的引用可能会让我们感到控制度降低。相比那些提供底层内存操作的语言,比如C/C ,Java程序员对内存的控制度确实较低。”

4.2.2 C 的引用

席双嘉:“在C 中,引用既不像Rust那样是借用数据的方式,也不像Java那样是存储对象内存地址的东西,而是某个变量的别名。”

C 的引用一旦定义后,就不能改变指向,而始终指向被引用的初始变量。”

“C 的引用,使用&标识符进行定义,但与取地址的&符号不同。“

”例如,int& ref = x;定义了一个对变量x的引用。“

”而int* ptr = &x;是取变量x的地址。“ “使用C 的引用,就像使用原始变量一样,不需要特殊符号。“

“这么说有点抽象,咱们可以看一段C 代码。”

代码语言:javascript复制
#include <iostream>

int main()
{
  int x = 10;
  int y = 20;

  // 指针的使用
  int* ptr = &x;
  std::cout << "ptr points to x: " << *ptr << std::endl;  // 10
  ptr = &y;  // 改变ptr指向
  std::cout << "ptr now points to y: " << *ptr << std::endl;  // 20

  // 引用的使用
  int& ref = x;
  std::cout << "ref is an alias for x: " << ref << std::endl;  // 10
  ref = y;  // 并不改变ref的指向,只是将y的值赋给x
  std::cout << "ref is still an alias for x, but x's value is now y's: " << ref
            << std::endl;  // 20
  std::cout << "x's value: " << x << std::endl;  // 20

  return 0;
}

“在这个例子中,ptr作为指针,最初指向x,随后被改为指向y。”

“相反,ref作为x的引用,尽管执行ref = y;看似将ref指向了y,实际上是将y的值赋给了x。”

4.3 Rust与C 的解引用的异同

席双嘉:“Rust的解引用,与C 的解引用很像哦。”

贾克强:“是很像。要想了解更多,让我们问问艾极思。”

艾极思很快给出了下面的答复。

“C 和 Rust 中的解引用操作都非常相似,因为它们共享同样的基本目的——通过指针或引用访问或修改其指向的内存中的数据。”

“不过,尽管这两种语言在解引用操作的基础功能上相似,它们在安全性、用法以及语言的总体设计哲学上存在一些差异。”

4.3.1 相似之处

“操作符:C 和 Rust 都使用星号 (*) 作为解引用操作符。”

“在两种语言中,*都用于访问或修改指针(C )或引用(Rust)指向的内存的值。

“基本用法:在两种语言中,解引用都是一种间接访问和操作变量的方式。如果你有一个指向数据的指针或引用,你可以使用解引用来读取或改变这些数据。”

4.3.2 差异之处

4.3.2.1 安全性与错误处理

C :C 的解引用操作允许较高的灵活性,但这也带来了潜在的风险。”

“程序员可以解引用任何指针,包括空指针、悬挂指针或野指针,这可能导致运行时错误,如段错误。”

“C 编译器通常不检查这些错误,它们往往在运行时才显现出来。”

Rust:Rust 在编译时强制实行所有权和借用规则,确保不会解引用空指针或悬挂引用。”

“如果代码尝试进行不安全的解引用,它将无法编译通过,这样极大地提高了程序的安全性。”

4.3.2.2 引用的默认行为

C :在 C 中,默认情况下指针可以修改它们指向的数据,除非它指向的是常量(例如使用 const 关键字声明的指针)。

Rust:在 Rust 中,默认情况下,引用所指向的数据是不可变的。如果你想修改通过引用访问的数据,你必须显式地使用可变引用(&mut)。

4.3.2.3 内存和生命周期管理

C :程序员需要手动管理内存(尽管现代 C 提供了智能指针等工具来帮助管理内存)。指针的生命周期管理完全是程序员的责任。”

Rust:Rust 通过其所有权系统自动管理内存,不允许裸指针的随意使用,除非在不安全代码块中。”

“这确保了更高级别的安全性,防止了内存泄露和访问已释放内存的问题。”

“总结来说,尽管 C 和 Rust 的解引用操作在表面上很相似,它们在语言的设计、内存安全保障、以及它们如何被用于实际编程中有着明显的差异。”

“这些差异反映了两种语言对性能、安全性、以及易用性不同的优先级和方法。”

4.4 小结

这一次咱们主要讨论了Rust编程语言中变量绑定、引用以及解引用的概念,结构和用法。主要讨论了以下几个要点:

  • Rust中,变量绑定是默认不可变的,即一旦一个值被绑定到一个变量,这个变量的值就不能再改变。但是,如果在声明变量的时候使用了mut关键字,那么这个变量就可以改变。

Rust

Java

C

变量绑定

使用let将值绑定到变量名。默认情况下,变量绑定是不可变的,但可以通过mut关键字使其变为可变的。变量绑定涉及所有权和范围

不存在

不存在

赋值

存在,用于修改已绑定变量的值。如果使用mut声明,可以重新赋值

默认情况下,变量赋值是可变的。赋值不涉及所有权或范围的概念。值被复制到预分配的内存中

默认情况下,变量赋值是可变的。需要使用new和delete进行显式内存管理。赋值不涉及所有权或范围的概念。值被复制到预分配的内存中

内存管理

涉及所有权,借用和生命周期的明确概念。内存管理与变量绑定有关

由垃圾收集管理。内存管理与赋值无关

需要使用new和delete进行手动内存管理。内存管理与赋值无关

语义

由于所有权,借用和生命周期的概念,更为复杂

更简单,只涉及将值复制到内存中

更简单,只涉及将值复制到内存中

  • Rust的引用是一种借用数据的方式,分为不可变引用(&T)和可变引用(&mut T)。引用需要遵守Rust的所有权和借用规则,这些规则使得Rust的编译器能确保引用的安全使用,防止数据竞争和悬挂指针等问题。

特性

Rust

Java

C

可变性

支持不可变和可变引用 (&T 和 &mut T)。

不支持可变和不可变引用的区分,所有对象引用默认是可变的。

支持不可变 (const T*) 和可变 (T*) 引用。

所需库支持

标准库中包含丰富的函数和宏来支持安全的引用操作。

标准库中不包含专门支持引用操作的特殊库,引用被视为对象的默认行为。

标准库支持广泛,包括对智能指针等现代C 引用机制的支持。

安全性

编译时检查引用安全,防止数据竞争和悬垂引用。

运行时通过垃圾回收和异常处理提供引用安全,但不涉及编译时检查。

提供一定的安全性保护,但需要程序员显式管理内存和指针。

运行时性能

性能优良,引用操作几乎无开销。

引用操作通常不直接影响性能,因为Java虚拟机进行优化。

性能优良,指针操作直接且快速,但风险较高。

内存管理方式

借助所有权系统自动管理内存,无需手动释放内存。

由垃圾收集器自动管理内存,无需手动释放。

需要程序员显式管理内存,可以使用智能指针简化管理。

学习难度

学习曲线较陡峭,需要理解所有权和借用规则。

相对简单,因为不需要管理内存和复杂的指针操作。

学习难度较高,需要理解指针、引用以及内存管理的复杂性。

多线程编程安全性

强类型系统和所有权规则使得多线程编程更安全。

线程安全主要依赖于同步机制和并发库。

需要使用特定的并发库和同步机制,以避免竞态条件和其他问题。

  • Rust中的解引用操作是通过指针或引用访问或修改其指向的内存中的数据。如果代码尝试进行不安全的解引用,它将无法编译通过,这样极大地提高了程序的安全性。

方面

Rust

C

操作符

两者都使用星号(*)作为解引用操作符。

两者都使用星号(*)作为解引用操作符。

用途

两者都允许通过解引用间接访问和操作变量。

两者都允许通过解引用间接访问和操作变量。

安全性和错误处理

Rust在编译时执行所有权和借用规则,确保不会解引用空指针或悬空引用。不安全的解引用会阻止代码编译,提高程序安全性。

C 允许解引用任何指针,包括空指针,悬空指针,或野指针,可能导致运行时错误,如段错误。C 编译器通常不检查这些错误,它们通常只在运行时出现。

引用的默认行为

默认情况下,引用指向的数据是不可变的。要修改通过引用访问的数据,必须显式使用可变引用(&mut)。

默认情况下,指针可以修改它们指向的数据,除非它们指向一个常量(例如,声明为const关键字的指针)。

内存和生命周期管理

Rust通过其所有权系统自动管理内存,并且不允许在不安全代码块之外任意使用原始指针。这确保了更高级别的安全性,防止了内存泄漏和访问已释放内存。

在C 中,程序员需要手动管理内存(尽管现代C 提供了像智能指针这样的工具来帮助内存管理)。指针的生命周期管理完全是程序员的责任。

  • Java和C 的引用和赋值语义与Rust有所不同。与Rust的变量绑定和引用相比,Java和C 更注重简洁和直观,但可能牺牲了一些安全性。Java和C 的赋值默认可以改变,而Rust的变量绑定默认不可变。
  • 最后,文章通过一些代码示例讲解了Rust,Java和C 中引用和解引用的不同用法和行为,以及Rust与C 的解引用操作的异同。

如果你对Rust是如何用Result类型处理错误的有兴趣,或者想看看它和Java和C 处理错误有什么不一样,那就跟着我一起看下去吧!

【未完待续】


如果喜欢我的文章,期待你的点赞、在看和转发。

如果不喜欢,在评论区留个言告诉我哪里不喜欢呗~

0 人点赞