[Idea] Improving the ergonomics when using Rc

#17

“trivial” here means "involves zero code other than a register/memory copy.

Note that Copy intentionally provides no means of running user code for the copy.

That also ensures that generic code that doesn’t call clone isn’t surprised.

Incrementing a reference count is precisely the sort of thing that requires an explicit operation.

5 Likes

#18

What you are arguing for here is that humans don’t need to understand the code because the compiler will understand it for them. That’s not a strong foundation for ergonomics discussion.

2 Likes

#19

I don’t know why implicit memory copy in Copy is fine, but counter increment is not. To me both are some kind of memory operation and mostly lightweight. I also won’t be surprised if the compiler help me increase reference counter of Rc object implicitly when it required to do so.

When using Rc most user will know there need to call clone, and the compiler knows too. It is not just saving few keystrokes to type .clone(), if there are too many lightweight Rc::clone all over the place. It make spot the true heavy cloning difficult. Therefore, why don’t make Rc implement ImplicitClone?

2 Likes

#20

I think an important point here is that there shouldn’t be too many Rc::clones. After all, most of the times you’ll just pass around a &T deref’d from the Rc<T>.

8 Likes

#21

Rc::clone() causes pointer indirection, which shouldn’t be considered as trivial in performance critical code, due to the possibility of cache miss.

4 Likes

#22

Regarding ergonomics there are other paths that would be more lightweight while remaining explicit:

  • Custom macros:
macro_rules! rc_vec {
  (
    $($expr:expr),*
  ) => (
    vec![ $(::std::rc::Rc::clone(&$expr), )* ]
  );
}

and then use rc_vec![foo, bar, baz]. This way, any reader can know something special is happening since the classic vec macro is not the one being used.

  • A macro-less solution would be to use iterators:
let vec: Vec<Rc<_>> =
  [&foo, &bar, &baz]
    .iter()
    .map(|&x| x.clone())
    .collect();

Both may seem more cumbersome but scale better.

If you are writing you own function, you can then require that the arguments be references to Rcs (and clone them in the first line of the function’s body) so that calling those functions just requires to write &foo instead of foo.clone().

I think that any “easier” or more lightweight way would do more harm than good, since implicitness is the very footgun that rust fights against.

3 Likes

#23
let vec: Vec<Rc<_>> =
  [&foo, &bar, &baz]
    .iter()
    .cloned()
    .collect();
0 Likes

#24

hyeonu already mentioned the pointers indirection, but as an additional note:

SSA compilers (which is basically all of them, including LLVM and Rust’s MIR) are very good at removing redundant copies when no pointers are involved. If there are pointers involved, then you have to explicitly *ptr dereference it to copy it, so it’s not implicit any more.

3 Likes

#25

My bad, it was .map(|&x| x.clone()) instead of .map(|x| x.clone()) (references being Copy prevents auto-deref), which means that .cloned() won’t work either, unless it’s used twice (iterator over &&Rc<_> which just get dereferenced instead). I hope we’ll end up having .into_iter() for arrays too.

0 Likes

#26

I’ve long been inclined to agree with the original author of this thread that there should be an autoclone mechanism, and that Rc should opt into it. The expensive part of reference counting is the initial allocation; incrementing and decrementing the reference count is comparatively cheap in contrast to the cost of, say, memcpying a [usize; 1024], which Rust will let you do implicitly without any warnings at all.

I think the current design of the language encourages the wrong behavior, the false pretense that anything that has costs is “explicit” in Rust leads users to believe that any Copy operation is cheap, whereas cloning an Rc is expensive. The current design misguides users into avoiding heap allocation dogmatically, even when it would be more performant than the memcpys they’re doing.

If we actually believed that everything expensive should be explicit, even optionally, we would have some allow-by-default warning that warned on all but the genuinely cheap copies (anything over a word, or whatever), and probably other things (like operator overloading on types other than primitives). I don’t think this should be a priority to implement (maybe someone who does would like to write a clippy like tool!), but it would at least be intellectually honest, unlike the rigidity of pretending what we currently have made implicit is a well-reasoned set of cheap operations.

I doubt its that hard with the analyses we’re already doing with NLL to only clone when the value is used again without reinitializing it. In any event that could be a design constraint. If you wanted to error when you did it by mistake putting such a wrapper up on crates.io would be trivial.

7 Likes

#27

I think we do want that, and have wanted that for some time:

10 Likes

#28

My suggestion that we don’t want it has to do with our resourcing of the idea, not with its inherent worth. That lint is evidently not a high priority for the project.

Overall, I want to push back - hard - against the idea that what we made implicit/explicit initially was a perfect match for what is cheap/expensive. It was roughly accurate, but in many finer details it was wrong. I would like to adjust the defaults so that doing the right thing doesn’t feel like wearing the hairshirt (which is the case with Rc today), and for the cases where you need extra guard rails against overly-large copies, allocations, function call operators, dynamic dispatch, or whatever else, to provide targeted assistance optionally through newtypes or lint plugins.

4 Likes

#29

I was always under the impression that Copy/Clone was always more about about complexity. Copy of [u8; 24] is as complex as [u8; 1024], semantically speaking, whereas Clone hints to something custom happening.

I always teach Rust as a language where stuff is “pretty visible”, as compared to other languages, but there’s still tons of things going on in the back (for good reason) and I think it’s important to keep that discussion of where visibility is useful going. “When to clone RC” is definitely a subject that needs to be taught and I think there’s still good wins to be made. I’m not sure if moving all of Clone to be optionally implicit is it, though.

5 Likes

#30

I beg to differ, it’s not about “efficiency”. It is, as I mentioned earlier, about triviality. I have to agree with @skade here:

It is also pretty much not the case that “the current design misguides users into avoiding heap allocation dogmatically”. That is an exaggeration at best. I never regarded explicit .clone()s as something to be “avoided dogmatically” (nor does e.g. the book say that!); sometimes you just have to clone, and sometimes it’s better to clone. The important thing is that you know it’s there — and that is exactly what this proposal would forcibly take away from current users.

2 Likes

#31

Right. It’s helpful to know that nothing “magic” is going on with y in the expression x = y or f(y), and they just do exactly what they look like. No copy constructors, no implicit code being run. It’s not just that Rc::clone is more expensive than a simple memory/register copy (though that’s also important), it’s that it runs custom Rust code rather than being entirely a compiler-generated copy. Knowing where code is being run is important.

@scottmcm I agree entirely; that lint would push us more in the direction of explicit clones rather than implicit copies. I certainly wouldn’t want to see us going in the opposite direction.

5 Likes

#32

For Rc, it’s drop method is doing -1 to its reference counter which is opposite of Rc::clone. However, the drop is implicit. Do we required to make the drop explicit? And the proposal here is we add a tool in compiler to allow some type (e.g. Rc) implicitly cloned, not allowing every T: Clone to be implicitly cloned.

5 Likes

#33

Well, there’s a not so small group that would prefer complex drops to be explicit in Rust (it’s not as simple as it sounds though). At least, it’s big enough of a discussion that there’s blog posts on the subject: https://gankro.github.io/blah/linear-rust/

The current model there does come with its problems, like the inability to report errors from drop.

Your proposal would allow any Clone type to opt into being implicitly cloneable, which may lead people just adding it by default on anything that is Clone, but not Copy. It does weaken an existing boundary.

3 Likes

#34

As I mention before, we can make ImplicitClone like Copy a marker trait , which type implementing it is controlled in std. Not allow arbitrary type implement it unless all fields implement ImplicitClone .

0 Likes

#35

Drop is deterministically invoked at the end of the innermost scope (unless the value to be dropped is moved from or std::forgetten), therefore one does know when it’s doing its magic even if it’s implicit.

There is another (perhaps more important) reason: Drop is associated with “teardown”, ie. that is exactly the time we stop caring about a value. When we stop caring about a value, it’s way less important what is happening to it than it was before.

Nevertheless, implicit Drop does have the problem of invoking arbitrary code. It is possible to perform sneaky or even malicious things in a drop impl, nothing actually prevents you from doing so as far as I know.

However, and this is my third point, the benefits of the existence of an automatic cleanup mechanism enormously outweigh this disadvantage, so we can live with it: it’s a net win. The same is not true about implicit cloning. The only advantage of implicit cloning would be slight writing convenience, and that is not nearly enough to justify the loss of control it would result in.

I do understand your proposal, please don’t reiterate it for the third time. I do know that not every clonable type would be implicitly cloned under this proposal. However, I am still arguing that the current implicit copying capabilities of Rust are sufficient and correct, and that no type that is non-Copy should be cloned implicitly.

0 Likes

#36

Unfortunately, both of these can implicitly run code already in a deref coercion context, but that is another reason to not add to the amount of hidden code invocations.

Personally, I think clone usually has a semantic meaning, and so should always stand out.

3 Likes