About object holding api design, Arc<T> etc

#1

My native language is not English.I will describe my problem as much as possible.Please forgive the strange grammar.


Rust has an ownership system. This is very different from traditional languages. Some times I’m confused about this.

In actual business service, some objects usually need to be holded on multiple places.But this seems to violate the rust ownership principle.

For example, I have write a game engine.There are two struct: Texture, Sprite

In other language, it may like this:

class Texture {
    data: List<u8>,
}

class Sprite {
    texture: Texture, // this is an object reference
    rect: Rect,
}

Texture texture = Texture.load("some/path"); // a texture may be holded by more than one time
Sprite sprite1 = new Sprite(texture, new Rect(0, 0, 100, 100));
Sprite sprite2 = new Sprite(texture, new Rect(100, 100, 100, 100));  // it's OK!

But in rust, it must be:

struct Texture {
    data: Vec[u8],
}

struct Sprite {
    texture: Texture, // can not use like this, this is a moved object
    rect: Rect,
}

let texture = Texture::load("some/path");
let sprite1 = Sprite::new(texture, Rect::new(0, 0, 100, 100));
let sprite2 = Sprite::new(texture, Rect::new(100, 100, 100, 100)); // This is an compiler error, because use moved texture.

There are also some solutions.

  • Solutions 1:
struct Sprite {
    texture: &Texture, // Error! Expected Lifetime parameter, --explain E0106 
    rect: Rect,
}

I know the reason for the error here. But I do not want to use <'a> to solve this.
Actually, object texture’s really lives longer than sprites.
But this will introduce more problems about the life cycle.

  • Solutions 2:

I more prefer this solution.

struct Sprite {
    texture: Arc<Texture>, // Reference counter seems more reasonable
    rect: Rect,
}

I think use reference counter is more reasonable semantically.
But this means I must export the Arc in API:

impl Sprite {
    fn new(texture: Arc<Texture>, rect: Rect) -> Sprite {
        Sprite { .. }
    }
}

The result of this is there are a lot of Arc in APIs. And use Arc is forced.
I don’t know if this is appropriate. And if may couse performance problems.


In addition, two solutions let texture can not be mut. This is also a problem.

I just use Texture and Sprite for the example above.
In actual business service, there are a lot of similar situations.

I don’t know what the best practice in rust about this situation.
Can guys anyone give me some advice?
Thank you!

#2

Arc for shared references in structs is indeed better than a temporary borrow &. The borrow would limit usage of the sprites to the scope the texture lives in, which may be too limiting.

Exposing Arc in the API is fine. You can create a type alias for it if you don’t like typing Arc<>:

type ATexture = Arc<Texture>;

If you really don’t want Arc showing up, you can do:

#[derive(Clone)]
struct ClonableTexture(Arc<Texture>);

but if you expect to work with functions that take &Texture that will be less convenient (or you’ll end up reinventing your own Arc).

3 Likes
#3

As an addendum: you can also make ref-counting part of the expected semantics and do something like

struct Texture(Arc<TextureInner>);

As for Arc not letting you mutate things: that’s just something Rust forces you to deal with. If you want shared updates, you’ll need locking of some kind (which can impact the rest of your API). Or maybe it would be better to switch to a functional design that ditches mutation entirely. Maybe Arc::make_mut is enough. The only person that can decide this is you.

There is no “best” practice.

1 Like
#4

Also, you can use a bit more of a wrapper to allow mutability.

type Texture = Arc<Mutex<Data>>;

This allows you to mutate it like so:

let my_texture = get_texture(); //This is of type Texture
//Pass it around, threads and other stuff etc.
{
    let my_texture = &mut *(my_texture.lock().unwrap());
    //this `my_texture` is now the only one who can be held
    //and it allows you to read and mutate it
} //Once it goes out of scope you can acquire it again

Or, what I find to be more idiomatic, (although it has some more overhead) is a RwLock:

type Texture = Arc<RwLock<Data>>;

where the above example can become this:

let my_texture = get_texture(); //Arc<RwLock<Data>>
//Pass it around, threads and other stuff etc.
{
    let my_texture = &mut *(my_texture.write().unwrap());
} //Once it goes out of scope you can acquire it again
{
    let my_texture = &*(my_texture.read().unwrap());
}

This allows multiple threads to read, but only one to write; still proving to be safe.