不能作为不可变借用,因为在实现 ECS 时它也作为可变借用

时间:2021-05-20 11:52:41

标签: rust

我正在尝试编写一个简单的 ECS:

struct Ecs {
    component_sets: HashMap<TypeId, Box<dyn Any>>,
}

impl Ecs {
    pub fn read_all<Component>(&self) -> &SparseSet<Component> {
        self.component_sets
            .get(&TypeId::of::<Component>())
            .unwrap()
            .downcast_ref::<SparseSet<Component>>()
            .unwrap()
    }

    pub fn write_all<Component>(&mut self) -> &mut SparseSet<Component> {
        self.component_sets
            .get_mut(&TypeId::of::<Component>())
            .unwrap()
            .downcast_mut::<SparseSet<Component>>()
            .unwrap()
    }
}

我试图获得对某个组件的可变访问,而另一个是不可变的。此测试代码触发错误:


fn testing() {
    let all_pos = { ecs.write_all::<Pos>() };
    let all_vel = { ecs.read_all::<Vel>() };

    for (p, v) in all_pos.iter_mut().zip(all_vel.iter()) {
        p.x += v.x;
        p.y += v.y;
    }
}

和错误

error[E0502]: cannot borrow `ecs` as immutable because it is also borrowed as mutable
   --> src\ecs.rs:191:25
    |
190 |         let all_pos = { ecs.write_all::<Pos>() };
    |                         --- mutable borrow occurs here
191 |         let all_vel = { ecs.read_all::<Vel>() };
    |                         ^^^ immutable borrow occurs here

我对借用检查器规则的理解告诉我,可以可变或不可变地获取对不同组件集的引用(即 &mut SparseSet<Pos>&SparseSet<Vel>)完全没问题,因为它们是两种不同的类型。不过,为了获得这些引用,我需要通过拥有集合的主 ECS 结构,这是编译器抱怨的地方(即,我首先在调用 &mut Ecs 时使用 ecs.write_all,然后使用 {{ &Ecs 上的 1}})。

我的第一直觉是将语句括在一个作用域中,认为它可以在我获得对内部组件集的引用后删除 ecs.read_all,以免同时具有可变和不可变的 &mut Ecs引用同时活着。这可能非常愚蠢,但我不完全理解如何,所以我不介意在那里进行更多解释。

我怀疑需要一个额外的间接级别(类似于 EcsRefCellborrow),但我不确定我到底应该包装什么以及我应该如何去做

更新

解决方案 1:通过将 SparseSet 包装在 RefCell 中,使 borrow_mut 的方法签名采用 write_all(尽管返回 &self)(如下面 Kevin Reid 的回答所示)。

解决方案2:与上面类似(方法签名采用RefMut<'_, SparseSet<Component>>)但使用了这段不安全的代码:

&self

使用解决方案 1 有什么好处,RefCell 提供的隐含运行时借用检查在这种情况下是否会成为障碍,或者它实际上是否有用? 在这种情况下,使用 unsafe 是否可以容忍?有好处吗? (例如表现)

1 个答案:

答案 0 :(得分:2)

<块引用>

可变或不可变地获取对不同组件集的引用完全没问题

这是真的:我们可以安全地拥有多个可变引用,或者可变引用和不可变引用,只要没有可变引用指向与任何其他引用相同的数据。

然而,并非所有获取这些引用的方法都会被编译器的借用检查器接受。这并不意味着它们不健全;只是我们还没有让编译器相信它们是安全的。特别是,编译器理解同时引用的唯一方式是结构的字段,因为编译器可以使用纯本地分析(只查看单个函数的代码):

struct Ecs {
    pub pos: SparseSet<Pos>,
    pub vel: SparseSet<Vel>,
}

for (p, v) in ecs.pos.iter_mut().zip(ecs.vel.iter()) {
    p.x += v.x;
    p.y += v.y;
}

这会编译,因为编译器可以看到引用指向不同的内存子集。如果您用方法 ecs.pos 替换 ecs.pos(),它不会编译 - 更不用说 HashMap。一旦涉及到某个函数,就会隐藏有关字段借用的信息。你的功能

pub fn write_all<Component>(&mut self) -> &mut SparseSet<Component>

具有省略的生命周期(编译器为您选择的生命周期,因为每个 & 都必须有一个生命周期)

pub fn write_all<'a, Component>(&'a mut self) -> &'a mut SparseSet<Component>

这是编译器将使用的关于借用内容的唯一信息。因此,对 'aSparseSet 可变引用是借用 Ecsall(如 &'a mut self),您不能有任何其他访问它。

Borrow Splitting 上的文档页面中讨论了如何安排以主要静态检查的方式拥有多个可变引用的方法。但是,所有这些都基于具有一些静态已知的属性,而您不知道。没有办法表达“只要 Component 类型不等于另一个调用的类型就可以”。因此,要做到这一点,您确实需要 RefCell,我们用于运行时借用检查的通用助手。

鉴于您已经拥有的,最简单的做法是将 SparseSet<Component> 替换为 RefCell<SparseSet<Component>>

//                         no mut;    changed return type
pub fn write_all<Component>(&self) -> RefMut<'_, SparseSet<Component>> {
    self.component_sets
        .get(&TypeId::of::<Component>())
        .unwrap()
        .downcast::<RefCell<SparseSet<Component>>>()      // changed type
        .unwrap()
        .borrow_mut()                                     // added this line
}

请注意更改后的返回类型,因为借用 RefCell 必须 返回显式句柄以跟踪借用的持续时间。但是,由于取消引用强制,RefRefMut 的行为大多类似于 &&mut。 (您在地图中插入项目的代码(您没有在问题中显示)也需要一个 RefCell::new。)

另一种选择是将内部可变性——可能通过 RefCell,但不一定——放在 SparseSet 类型中,或者创建一个包装类型来做到这一点。这可能会也可能不会帮助代码更清晰。