Forcing Arc on developers

I'm developing an interface in which it makes sense that objects are stored in Arcs. All the functions take in Arc<Blah> types. But it is conceivable - at least in theory - that someone, somewhere, would want to .. not use Arcs, so the new() methods don't actually return Arcs. But now that I'm actually using the API myself I'm getting somewhat frustrated by having to sprinkle Arc::new() everywhere, and I'm thinking about making all new()s return Arcs.

However, I tend to think like this: Have I ever seen this done before? No? Well is there perhaps a good reason I have never seen this done before?

Do you feel it is anti-social to return Arc-wrapped objects from new() methods? Would you prefer to Arcify yourself, even though it becomes quite verbose?

It's possible to unwrap an Arcd object, as long as the count is 1, if I recall correctly -- so it wouldn't be a huge burden if one were so inclined.

1 Like

Definitely do NOT wrap everything in an Arc. At least provide methods that return a plain old object without forcing allocation or unwrapping on downstream users, and provide a convenience constructor for streamlining Arc-wrapping, like this:

struct Foo {
   …
}

impl Foo {
    pub fn new() -> Self {
        …
    }

    pub fn new_arc() -> Arc<Self> {
        Arc::new(Self::new())
    }
}

That's not type safe anymore. Now you have to have the implicit information that "this Arc only ever has reference count 1", and then you will probably use arc.try_unwrap().unwrap() or arc.try_unwrap().expect("other Arcs exist"), which might panic at runtime. That's quite the burden on someone trying to use your API correctly.


By the way, why an Arc if there's a single reference to it? You should probably be using a Box in a case like this.

2 Likes

What does your crate do? If it's something that may be interesting in no_std scenarios your users probably wouldn't want alloc forced on them if the rest of the crate can do without.

Generally, the best design choice is to let users deal with your struct and store it how they wish, and leave you to work with source-agnostic references. Unless you're designing a crate which works directly with the Arc API, it's probably best if you permit users to store the types exposed as they fit.

2 Likes

Might your library be useful in a single-threaded context? If so, some users might prefer Rc to Arc. Can you methods be generic over AsRef<YourType> + Clone instead of requiring Arc?

1 Like

If you want to, for your own convenience you can create a trait like this, and just use it to bring it in scope, which allows you to call into_arc on any type to automatically wrap it in an Arc:

( playground )

use std::sync::Arc;

trait IntoArc<T> {
    fn into_arc(self) -> Arc<T>;
}

impl<T> IntoArc<T> for T {
    fn into_arc(self) -> Arc<T> {
        Arc::new(self)
    }
}

fn main() {
    let url = String::from("Hello World").into_arc();
    dbg!(url);
}
1 Like

Sometimes objects really need to be shared, e.g. widgets in a GUI library. In that case it's fine to force Arc on users of a library.

Rust exposes mutability, ownership, and thread-safety in interfaces, and Arc is just another combination of these options.

However, I tend to think like this: Have I ever seen this done before? No?

Some libraries use a pattern like this:

#[derive(Clone)]
struct Blah(Arc<BlahInner>);

(e.g. gkt-rs uses equivalent of this, but the refcounting is on the C side)

But it's fine to expose Arc if you can, so that library users have access to all of its features.

10 Likes

A point of clarification: that is true for traditional object-oriented approaches to GUI toolkits. But it is not true in Druid, widgets are singly owned in the containment hierarchy, and interactions with widgets happen in other ways that don't involve ownership: sending and receiving commands, mutating app data (which is not stored in the widget) and receiving updates when app data changes. All such things are a tradeoff, and this architecture can be pretty unintuitive when people see it, but it questions your assertion that such things "need to be shared."

6 Likes

I think that it's unfriendly to force multi-threaded costs (memory fences etc) on the coder when perhaps they're happy to run that part of their app single-threaded. A lot of old GUI event loops were single threaded. Is that not done any more? Rust makes it easy and safe.

1 Like

I don't buy this argument. I've been using Rust for about 4 years now and am yet to encounter a real world scenario where my decision to use Arc over Rc had a noticeable performance impact.

On the other hand, I've run into loads of situations where you can't use something because it's not Send when it could have been, just because someone decided to use an Rc on non-atomic operations.

That said, this is just a sample size of one.

2 Likes

Yeah, I assume that's why Skype for Business hangs for 30 seconds every time the network is a little bit slow. Some things really should be multithreaded.

That aside, though, I think the point is that if you're implementing an abstraction and you use Arc to make that abstraction work, that's fine. Whether that abstraction is "easy way to make multithreaded GUIs" or something totally different. As a library developer, you have to decide what kind of API you're going to expose, and that means making some decisions about the implementation. You can't defer all the decisions to the user, or they might as well not use a library in the first place.

4 Likes

However, several libraries provide thread-safe and single-threaded alternatives of the same abstraction, exactly for the purpose of only paying for what one needs. Yesterday I recommended the once_cell::Lazy type to someone (for the umpteenth time), only to notice that it's actually not one type but two: once_cell::sync::Lazy and once_cell::unsync::Lazy.

So this really absolutely is a decision that a library author can defer to the user.

Sure, you absolutely can! But that doesn't mean you ought to. If you expect most users to want Arc, and giving the user flexibility comes at the cost of making that "normal" use of the API awkward and finicky, it's perfectly fine to say "eh, I'm not interested in supporting that."

Another possibility might be to allow both, but make the "normal" use easier. For example, make Blah::new() return Arc<Blah> for the intended use of the library, and have a Blah::new_unwrapped (or similar) constructor for the people who want or need greater control.

2 Likes

The example I cited specifically does not fall into that category. Unless someone finds typing the two extra letters of unsync (instead of sync) in the import awkward.

I was suggesting the very same approach in my first post.

1 Like

That's confusing two things. You can happily run single-threaded without ever blocking on the network. You don't need to go multi-threaded just because of that.

4 Likes

True, that was a bad example. But the point still stands: there are situations where it's just not practical to avoid multithreading. There's no value in adding flexibility that (1) nobody will want to use and (2) you still have to test and maintain even so.

I'm not talking specifically about GUIs, because @blonk didn't mention GUIs. A GUI library might be one example where this is reasonable. It might also not be; it depends on the library. That's my whole point.

The original question was: is it anti-social to return Arc<Self> from new? My answer is no. It sounds to me like OP is asking about a scenario where using the unwrapped version is really implausible. It's fine to say "That use case is implausible and I don't care to support it in my library." Not anti-social. To say more I think we'd need to know more specifics than OP actually gave.

3 Likes

Okay, it's kornel who brought up GUIs, and I was replying to that. Anyway, if the crate is designed to be used exclusively multi-threaded, then fine. For people who care about maximum single-threaded performance, it's a clear signal to stay away.

Also, there seems to be some common misconception that to scale you have to go multi-threaded and pay all those synchronization costs at a fine-grained level. Actually multi-threaded only lets you scale to the capacity of the machine. Beyond that you need sharding or load balancing or something. So to me it's a completely valid choice to stay single-threaded for speed and handle the scaling at a higher level, or use multiple threads but have them work mostly independently. So I think crate authors cannot assume that everyone will want to run multi-threaded. If they can make the core processing independent of all that, so much the better. (IMHO)

1 Like

What I would do in this case is that I would have my new() function return a plain object, but I would also have something like into_arc() or new_arc().
But I'm still a bit of a beginner, so take my opinion with a grain of salt.