Rust在函数式编程范式中的不足 - mmapped


本文详细介绍了如果您以函数式编程的思维方式来处理 Rust 会有多令人沮丧!

对象、值和引用
在深入研究 Rust 之前,了解对象、值和引用之间的区别是有帮助的。
在本文的上下文中,值是具有不同标识的实体,例如数字和字符串。对象是计算机内存中值的表示。引用是对象的地址,我们可以使用它来访问对象或其部分。

系统编程语言,例如 C++ 和 Rust,迫使程序员处理对象和引用之间的区别。这种区别使我们能够编写出非常快的代码,但它也付出了高昂的代价:它是一个永无止境的错误来源。如果程序的某些其他部分引用该对象,则修改该对象的内容几乎总是一个错误。有多种方法可以解决此问题:

  • 忽略问题并相信程序员。大多数传统的系统编程语言,例如 C++,都走这条路。
  • 使所有对象不可变。此选项是 haskell 和clojure中纯函数式编程技术的基础。
  • 采用防止修改引用对象的类型系统。ats和 Rust等语言踏上了这段旅程。
  • 完全禁止引用。val语言探索了这种编程风格。

对象和引用之间的区别也是偶然复杂性和选择爆炸的来源。具有不可变对象和自动内存管理的语言允许我们忽略这种区别并将一切都视为一个值(至少在纯代码中)。统一的存储模型解放了程序员的脑力资源,使她能够编写出更具表现力和优雅的代码。然而,我们获得了便利,却失去了效率:纯函数式程序通常需要更多内存,可能变得反应迟钝,并且更难优化(您的里程可能会有所不同)。

当抽象受到伤害时
手动内存管理和所有权感知类型系统会干扰我们将代码分解成更小部分的能力。

常用表达式消除
将通用表达式提取到变量中可能会带来意想不到的挑战。让我们从以下代码片段开始。

f(compute_x());
g(compute_x());

compute_x()出现了两次!我们的第一直觉是为表达式指定一个名称并使用它两次:

let x = compute_x();
f(x);
g(x);

然而,我们的第一个原始版本只有在 x 的类型实现了trait特征时才会编译Copy。我们必须改为编写以下表达式:

let x = compute_x();
f(x.clone());
g(x);

如果我们关心额外的内存分配,我们可以从积极的角度看待额外的冗长,因为复制内存变得明确。但在实践中可能会很烦人,尤其是当您在两个月后添加h(x)时。

let x = compute_x();
f(x.clone());
g(x);

// fifty lines of code...

h(x);
// ← won’t compile, you need scroll up and update g(x).

单态性的限制
在Rust中,let x = y;并不总是意味着x与y是同一事物。当这种自然属性被打破时,一个例子是y是一个重载函数。

例如,让我们为一个重载函数定义一个简短的名字。

// Do we have to type "MyType::from" every time?
// How about introducing an alias?
let x = MyType::from(b
"bytes");
let y = MyType::from(
"string");

// Nope, Rust won't let us.
let f = MyType::from;
let x = f(b
"bytes");
let y = f(
"string");
//      - ^^^^^^^^ expected slice `[u8]`, found `str`
//      |
//      arguments to this function are incorrect


这个片段不能编译,因为编译器会将f绑定到MyType::from的一个特定实例,而不是绑定到一个多态的函数。我们必须明确地使f具有多态性。

// Compiles fine, but is longer than the original.
fn f<T: Into<MyType>>(t: T) -> MyType { t.into() }

let x = f(b
"bytes");
let y = f(
"string");

Haskell程序员可能会发现这个问题很熟悉:它看起来与可怕的单态性限制很相似,不幸的是,Rustc没有NoMonomorphismRestriction这个pragma。

函数抽象
将代码分解为函数可能比您预期的要难,因为编译器无法推断跨函数边界的别名。假设我们有以下代码。

impl State {
  fn tick(&mut self) {
    self.state = match self.state {
      Ping(s) => { self.x += 1; Pong(s) }
      Pong(s) => { self.x += 1; Ping(s) }
    }
  }
}

该self.x += 1语句出现多次。为什么不将其提取到方法中……

impl State {
  fn tick(&mut self) {
    self.state = match self.state {
      Ping(s) => { self.inc(); Pong(s) } // ← compile error
      Pong(s) => { self.inc(); Ping(s) }
// ← compile error
    }
  }

  fn inc(&mut self) {
    self.x += 1;
  }
}

Rust会责备我们,因为该方法试图完全重新借用self,而周围的上下文仍然持有对self.state的可变引用。

Rust 2021版实现了disjoint capture,以解决闭包的一个类似问题。在Rust 2021之前,看起来像x.f.m(|| x.y)的代码可能无法编译,但手动内联m和闭包可以解决这个错误。例如,设想我们有一个结构,它拥有一个Map和一个Map项的默认值。

struct S { map: HashMap<i64, String>, def: String }

impl S {
  fn ensure_has_entry(&mut self, key: i64) {
    // Doesn't compile with Rust 2018:
    self.map.entry(key).or_insert_with(|| self.def.clone());
// |         ------            -------------- ^^ ---- second borrow occurs...
// |         |                 |              |
// |         |                 |              immutable borrow occurs here
// |         |                 mutable borrow later used by call
// |         mutable borrow occurs here
  }
}

然而,如果我们内联or_insert_with的定义和lambda函数,编译器最终可以看到借用规则是成立的。

struct S { map: HashMap<i64, String>, def: String }

impl S {
  fn ensure_has_entry(&mut self, key: i64) {
    use std::collections::hash_map::Entry::*;
    // This version is more verbose, but it works with Rust 2018.
    match self.map.entry(key) {
      Occupied(mut e) => e.get_mut(),
      Vacant(mut e) => e.insert(self.def.clone()),
    };
  }
}

当有人问你:"Rust闭包可以做什么技巧,而命名的函数不能?"你会知道答案:它们只能捕获它们使用的字段。

Newtype抽象
新型成语( new type idiom):C++ 领域的人们称这个习语为strong typedef;在 Rust 中,允许程序员为现有类型赋予新的标识。这个习语的名字来源于 Haskell 的newtype关键字。

这个习惯用法的一个常见用途是绕过孤儿规则并为别名类型定义特征实现。例如,以下代码定义了一种以十六进制显示字节向量的新类型。

struct Hex(Vec<u8>);

impl std::fmt::Display for Hex {
  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
    self.0.iter().try_for_each(|b| write!(f, "{:02x}", b))
  }
}

println!(
"{}", Hex((0..32).collect()));
// => 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f

新的类型习惯是有效的:Hex类型在机器内存中的表示与Vec<u8>的表示相同。然而,尽管有相同的表示,编译器并不把我们的新类型当作Vec<u8>的强别名。例如,我们不能安全地将Vec<Hex>转换为Vec<Vec<u8>,然后再返回,而不重新分配外部向量。另外,如果不复制字节,我们就不能安全地将&Vec<u8>强制转换为&Hex。

fn complex_function(bytes: &Vec<u8>) {
  // … a lot of code …

  println!(
"{}", &Hex(bytes));        // That does not work.
  println!(
"{}", Hex(bytes.clone())); // That works but is slow.

 
// … a lot of code …
}


总的来说,newtype习语是一个泄漏的抽象,因为它是一个惯例,而不是一个一流的语言特性。如果你想知道Haskell是如何解决这个问题的,我建议你看一下Simon Peyton Jones在haskell中的安全、零成本胁迫的演讲。

视图views 和包bundles
每次程序员描述一个结构字段或将一个参数传递给一个函数时,她必须决定该字段/参数应该是一个对象还是一个引用。或者也许最好的选择是在运行时决定?这是很多决策!不幸的是,有时没有最佳选择。在这种情况下,我们会咬紧牙关,用略有不同的字段类型定义同一类型的多个版本。

Rust中的大多数函数都是通过引用来获取参数,并将结果作为一个独立的对象返回。有时候,如果拷贝参数很便宜,或者函数可以有效地重复使用其输入来产生结果,我们就会用值来传递参数。有些函数返回对其参数之一的引用。这种模式非常普遍,所以定义新的术语可能会有帮助。

我把带有生命周期参数的输入类型称为views 视图,因为它们是检查数据的最佳选择。我把常规的输出类型称为bundles 捆绑式包,因为它们是自成一体的。

下面的片段来自于(日落)lucet WebAssembly运行时间。

/// A WebAssembly global along with its export specification.
/// The lifetime parameter exists to support zero-copy deserialization
/// for the `&str` fields at the leaves of the structure.
/// For a variant with owned types at the leaves, see `OwnedGlobalSpec`.
pub struct GlobalSpec<'a> {
    global: Global<'a>,
    export_names: Vec<&'a str>,
}



/// A variant of `GlobalSpec` with owned strings throughout.
/// This type is useful when directly building up a value to be serialized.
pub struct OwnedGlobalSpec {
    global: OwnedGlobal,
    export_names: Vec<String>,
}

作者复制了GlobalSpec数据结构以支持两个用例:

  • GlobalSpec<'a>是代码作者从字节缓冲区解析的视图对象。该视图的各个字段指向缓冲区的相关区域。这种表示对于需要检查类型值GlobalSpec而不修改它们的函数很有帮助。
  • OwnedGlobalSpec是一个包:它不包含对其他数据结构的引用。GlobalSpec这种表示对于构造类型值并将它们四处传递或将它们放入容器中的函数很有帮助。

在具有自动内存管理的语言中,我们可以在一个类型声明中结合GlobalSpec<'a>的效率和OwnedGlobalSpec的通用性。

..更多点击标题

无畏并发是个谎言
rust 团队创造了fearless concurrency一词来表明 Rust 可以帮助您避免与并行和并发编程相关的常见陷阱。尽管有这些说法,但每次我在 Rust 程序中引入并发时,我的皮质醇水平都会上升。

死锁
Safe Rust 可防止称为数据竞争的特定类型的并发错误。并发 Rust 程序有很多其他方式导致行为不正确。
我亲身体验过的一类并发错误是死锁。对这类错误的典型解释涉及两个锁和两个试图以相反顺序获取锁的进程。但是,如果您使用的锁不是可重入的(而 Rust 的锁不是),那么拥有一个锁就足以导致死锁。

例如,下面的代码是错误的,因为它试图获取同一个锁两次。
如果do_something和helper_function很大并且在源文件中相隔很远,或者如果我们调用helper_function罕见的执行路径,则可能很难发现错误。

impl Service {
  pub fn do_something(&self) {
    let guard = self.lock.read();
    // …
    self.helper_function();
// BUG: will panic or deadlock
   
// …
  }

  fn helper_function(&self) {
    let guard = self.lock.read();
   
// …
  }
}

档提到如果当前线程已经持有锁,RwLock::read函数可能会崩溃。我得到的只是一个挂起的程序。

一些语言试图在他们的并发工具包中提供这个问题的解决方案。Clang 编译器具有线程安全注释,支持一种可以检测竞争条件和死锁的静态分析形式。但是,避免死锁的最好方法是不加锁。从根本上解决该问题的两种技术是软件事务内存(在haskell、clojure和scala中实现)和参与者模型(erlang是第一个完全接受它的语言)。


文件系统是共享资源
Rust 为我们提供了处理共享内存的强大工具。然而,一旦我们的程序需要与外界交互(例如,使用网络接口或文件系统),我们就只能靠自己了。在这方面,Rust 与大多数现代语言相似。但是,它会给您一种虚假的安全感。
请记住,路径是原始指针,即使在 Rust 中也是如此。如果您没有正确同步文件访问,大多数文件操作本质上都是不安全的,并且可能导致数据竞争(从广义上讲)。例如,截至 2023 年 2 月,我仍然在rustup中遇到一个存在六年之久的并发错误

隐式异步运行时
我最喜欢 Rust 的价值在于它对局部推理的关注。查看函数的类型签名通常可以让您深入了解函数的功能。由于可变性和生命周期注释,状态突变是明确的。由于无处不在的Result类型,错误处理是明确和直观的。如果使用得当,这些功能通常会导致神秘的编译——它会起作用。然而,Rust 中的异步编程是不同的。

Rust 支持async/.await定义和组合异步函数的语法,但运行时支持有限。几个库(称为async runtimes)定义了与操作系统交互的异步函数。tokio包是最受欢迎的库。

运行时的一个常见问题是它们依赖于隐式传递参数。例如,tokio 运行时允许您spawn在程序的任何位置执行并发任务。为了使这个功能起作用,程序员必须提前构造一个运行时对象。

fn innocently_looking_function() {
  tokio::spawn(some_async_func());
  // ^
 
// |
 
// This code will panic if we remove this line. Spukhafte Fernwirkung!
}
//                                     |
 
//                                     |
fn main() {
//                           v
  let _rt = tokio::runtime::Runtime::new().unwrap();
  innocently_looking_function();
}

这些隐式参数将编译时错误转化为运行时错误。本来应该是编译错误的东西变成了调试冒险:

  • 如果运行时是一个显式参数,除非程序员构造一个运行时并将其作为参数传递,否则代码将无法编译。当运行时是隐式的时,你的代码可能编译得很好,但如果你忘记用一个神奇的宏来注释你的 main 函数,就会在运行时崩溃。
  • 混合选择不同运行时的库很复杂。如果涉及同一运行时的多个主要版本,问题会更加混乱。我编写异步 Rust 代码的经验与异步工作组收集的现状故事产生共鸣。

有些人可能会争辩说,将无处不在的参数线程化到整个调用堆栈是不符合人体工程学的。 显式传递所有参数是唯一可以很好扩展的方法。

结论
Rust 是一门纪律严明的语言,它做出了许多重要的决定,例如对安全的不妥协关注、特征系统设计。缺乏隐式转换,以及错误处理的整体方法。它使我们能够相对快速地开发健壮且内存安全的程序,而不会影响执行速度。

然而,我经常发现自己被意外的复杂性所淹没,尤其是当我不太关心性能并希望让某些东西快速工作时(例如,在测试代码中)。Rust 会使将程序分解成更小的部分以及将其组合成更小的部分变得复杂。Rust 只是部分地消除了并发问题。哦,好吧,没有一种语言可以完美解决所有问题。