API设计中的内部可变性滥用?

14

我的C++背景让我对内部可变性感到不舒服。

从借用检查器的角度来看,处理每个结构体上的许多引用,这些引用的内部状态可能很快或很久以后会被改变,是不可能的;这显然就是内部可变性可以发挥作用的地方。

此外,在《Rust编程语言》的第15.5章“RefCell和内部可变性模式”中,关于Messenger特质及其在MockMessenger结构上的实现的示例使我想到,即使很明显某种可变性迟早会成为必需,也通常会系统地优先选择&self而不是&mut self

当发送消息时,Messenger的实现如何不改变其内部状态呢?异常情况只是打印消息,这与&self一致,但一般情况可能包括写入某种内部流,这可能涉及缓冲、更新错误标志等等,所有这些都肯定需要&mut self,例如impl Write for File

依赖内部可变性来解决这个问题对我来说听起来像是在C++中使用const_cast或滥用mutable成员,只是因为我们在应用程序的其他地方没有保持一致性(这是C++学习者常犯的错误)。

因此,回到下面的示例代码,我应该:

  • change_e()change_i()使用&mut self(即使不是必须的,编译器也不会抱怨),以保持与存储的整数值被更改的事实一致?
  • 继续使用&self,因为内部可变性允许它,即使我实际上改变了存储的整数值?

这个决定不仅局限于结构体本身,而且将对使用这个结构体的应用程序表达能力产生很大影响。第二种解决方案肯定会有很大帮助,因为只涉及共享引用,但它是否符合Rust的期望呢?

我在Rust API Guidelines中找不到这个问题的答案。是否有其他类似于C++ Core Guidelines的Rust文档?

/*
    $ rustc int_mut.rs && ./int_mut
     initial:   1   2   3   4   5   6   7   8   9
    change_a:  11   2   3   4   5   6   7   8   9
    change_b:  11  22   3   4   5   6   7   8   9
    change_c:  11  22  33   4   5   6   7   8   9
    change_d:  11  22  33  44   5   6   7   8   9
    change_e:  11  22  33  44  55   6   7   8   9
    change_f:  11  22  33  44  55  66   7   8   9
    change_g:  11  22  33  44  55  66  77   8   9
    change_h:  11  22  33  44  55  66  77  88   9
    change_i:  11  22  33  44  55  66  77  88  99
*/

struct Thing {
    a: i32,
    b: std::boxed::Box<i32>,
    c: std::rc::Rc<i32>,
    d: std::sync::Arc<i32>,
    e: std::sync::Mutex<i32>,
    f: std::sync::RwLock<i32>,
    g: std::cell::UnsafeCell<i32>,
    h: std::cell::Cell<i32>,
    i: std::cell::RefCell<i32>,
}

impl Thing {
    fn new() -> Self {
        Self {
            a: 1,
            b: std::boxed::Box::new(2),
            c: std::rc::Rc::new(3),
            d: std::sync::Arc::new(4),
            e: std::sync::Mutex::new(5),
            f: std::sync::RwLock::new(6),
            g: std::cell::UnsafeCell::new(7),
            h: std::cell::Cell::new(8),
            i: std::cell::RefCell::new(9),
        }
    }

    fn show(&self) -> String // & is enough (read-only)
    {
        format!(
            "{:3} {:3} {:3} {:3} {:3} {:3} {:3} {:3} {:3}",
            self.a,
            self.b,
            self.c,
            self.d,
            self.e.lock().unwrap(),
            self.f.read().unwrap(),
            unsafe { *self.g.get() },
            self.h.get(),
            self.i.borrow(),
        )
    }

    fn change_a(&mut self) // &mut is mandatory
    {
        let target = &mut self.a;
        *target += 10;
    }

    fn change_b(&mut self) // &mut is mandatory
    {
        let target = self.b.as_mut();
        *target += 20;
    }

    fn change_c(&mut self) // &mut is mandatory
    {
        let target = std::rc::Rc::get_mut(&mut self.c).unwrap();
        *target += 30;
    }

    fn change_d(&mut self) // &mut is mandatory
    {
        let target = std::sync::Arc::get_mut(&mut self.d).unwrap();
        *target += 40;
    }

    fn change_e(&self) // !!! no &mut here !!!
    {
        // With C++, a std::mutex protecting a separate integer (e)
        // would have been used as two data members of the structure.
        // As our intent is to alter the integer (e), and because
        // std::mutex::lock() is _NOT_ const (but it's an internal
        // that could have been hidden behind the mutable keyword),
        // this member function would _NOT_ be const in C++.
        // But here, &self (equivalent of a const member function)
        // is accepted although we actually change the internal
        // state of the structure (the protected integer).
        let mut target = self.e.lock().unwrap();
        *target += 50;
    }

    fn change_f(&self) // !!! no &mut here !!!
    {
        // actually alters the integer (as with e)
        let mut target = self.f.write().unwrap();
        *target += 60;
    }

    fn change_g(&self) // !!! no &mut here !!!
    {
        // actually alters the integer (as with e, f)
        let target = self.g.get();
        unsafe { *target += 70 };
    }

    fn change_h(&self) // !!! no &mut here !!!
    {
        // actually alters the integer (as with e, f, g)
        self.h.set(self.h.get() + 80);
    }

    fn change_i(&self) // !!! no &mut here !!!
    {
        // actually alters the integer (as with e, f, g, h)
        let mut target = self.i.borrow_mut();
        *target += 90;
    }
}

fn main() {
    let mut t = Thing::new();
    println!(" initial: {}", t.show());
    t.change_a();
    println!("change_a: {}", t.show());
    t.change_b();
    println!("change_b: {}", t.show());
    t.change_c();
    println!("change_c: {}", t.show());
    t.change_d();
    println!("change_d: {}", t.show());
    t.change_e();
    println!("change_e: {}", t.show());
    t.change_f();
    println!("change_f: {}", t.show());
    t.change_g();
    println!("change_g: {}", t.show());
    t.change_h();
    println!("change_h: {}", t.show());
    t.change_i();
    println!("change_i: {}", t.show());
}

内部可变性只有在无法使用其他方法时才应该使用,例如互斥锁的使用是因为没有它就无法工作。在应用程序代码中使用这种方法非常罕见,而且很明显,人们应该尽量避免使用它。 - Stargateur
@Stargateur 那我是否应该认为书中的Messenger trait示例是误导性的?设计这样的trait意味着强制实现依赖内部可变性。 - prog-fh
没有 "messenger trait" 这个特征是“不应该”需要改变状态的,但是用户想要改变状态,所以对于用户的解决方案是使用内部可变性,就像示例中保留之前消息的跟踪一样。 - Stargateur
请注意,虽然 Write 特质确实使用了 &mut self,但是 File 本身实际上并没有。您可以通过使用 &'_ File 的实现 来向 &File 写入和读取数据。(这不涉及内部可变性;这只是底层操作系统 API 的工作方式。) - trent
1个回答

18
依赖内部可变性来解决这个问题,听起来就像在C++中使用`const_cast`或滥用`mutable`成员,仅仅因为应用程序的其他地方我们没有保持一致的`const`性(这是C++学习者常犯的错误)。
在C++环境下这种想法完全可以理解。但它不准确的原因是因为C++和Rust有不同的可变性概念。
某种意义上,Rust的`mut`关键字实际上有两个含义。在模式匹配中它表示“可变的”,而在引用类型中它则表示“独占”。`&self`和`&mut self`之间的区别并不在于是否能够改变`self`,而在于它是否能被别名。在这个示例中,我们不必过于认真对待 Messenger;它旨在说明语言特性,而非系统设计。但是我们可以想象为什么会使用 &selfMessenger 应该由共享的结构体实现,因此不同的代码片段可以持有对同一个对象的引用,并使用它来发送警报而无需相互协调。如果 send 接受 &mut self,那么它对于此目的将没有用处,因为同时只能存在一个 &mut self 引用。这将无法向共享的 Messenger 发送消息(除非添加一个内部可变性的外部层,例如 Mutex)。

另一方面,每个 C++ 引用和指针都可以被别名化。因此在 Rust 中,所有的可变性都是内部可变性!Rust 没有与 C++ 的 mutable 等价物,因为 Rust 没有 const 成员(这里的口号是“可变性是绑定的属性,而不是类型的属性”)。Rust 有一个 const_cast 相当于,但仅适用于原始指针,因为将共享的 & 引用转换为独占的 &mut 引用是不安全的。相反,C++ 没有像 Cell 或 RefCell 这样的东西,因为每个值都已经隐式地位于 UnsafeCell 背后。

那么,回到下面我示例代码,我应该吗[...]

真正取决于 Thing 的预期语义。它是共享的本质,就像通道端点或文件吗?调用 change_e 方法以共享(别名)引用的方式是否有意义?如果是这样,那么使用内部可变性公开&self方法。Thing 是否主要是数据容器?它有时候是共享的,有时候是独占的?然后 Thing 应该可能不使用内部可变性,并让库的用户决定如何处理共享变异,如果有必要的话。

另请参阅


¹实际上,C++有一个类似于 Rust 引用的指针特性。但是这只是一种近似。 restrict 是 C++ 中的非标准扩展,但它是 C99 的一部分。Rust 的共享(&)引用类似于 const *restrict 指针,而独占(&mut)引用类似于非 const*restrict 指针。请参见C++ 中的 restrict 关键字是什么意思?

你上一次有意识地使用过C++中的restrict指针(或__restrict等)是什么时候?别想了,答案是“从未”。restrict比普通指针启用更激进的优化,但很难正确使用,因为你必须非常小心地处理别名,并且编译器没有提供任何帮助。它基本上是一个巨大的陷阱,几乎没有人使用它。为了使广泛使用restrict与在C++中使用const的方式相似,你需要能够注明哪些指针允许与其他指针别名,在何时别名,制定一些关于指针何时有效的规则,并拥有编译器检查每个函数是否遵循这些规则的编译器传递。就像某种...检查器。


1
感谢您详细的回答。即使我知道非mut引用的共享/独占等价性,我也没有意识到在API设计方面,我们应该优先关注共享/独占方面,而非/mutability只是这种选择的结果。您的澄清将极大地有益于我的设计选择,因为我一直在考虑完全相反的方向(非/mutability → 共享/独占访问)! - prog-fh
作为奖励(我甚至没有要求;^),您确认了我预期的引用“restrict”方面,而我并不确定它是否在Rust中是隐含的(实际上,我在我的C ++模拟代码中广泛使用__restrict__)。再次感谢您所有宝贵的解释。 - prog-fh
1
叹气 我知道如果我在那里加上关于永远不使用 restrict 的那一段,我最终会发现自己正在和唯一一个经常使用它的人交谈!:-P - trent
我无法更好地提出问题,你的答案非常棒! - Marti Nito
1
可变性与别名角度的规范解释在此文章中:https://docs.rs/dtolnay/0.0.9/dtolnay/macro._02__reference_types.html - matklad
谢谢,@matklad,我之前不知道那篇文章。把它链接在这里是有意义的,我同意。 - trent

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接