The one thing I don't get about rust

So I recently had to compromise the ergonomics of my library (GitHub - audulus/rui: Experimental Rust UI library) because Rust doesn't allow arbitrary code to be executed on assignment.

That is, an expression of the form x=y always denotes a bitwise copy and no allocation happens. The Copy trait is "Types whose values can be duplicated simply by copying bits."

Rust devotees seem to love this, so you don't need to tell me how much you do. I chalk it up to survivorship bias.

Anyway, suppose you wanted to make a NxM (dynamic) matrix type (like Eigen, say) and you want it to have good ergonomics, so x = y not x = y.clone(). Can't do it.

"but it's just a call to clone. No big deal!"

Ok, fine, well what about this case:

f(move || g(a, b));
f(move || g(a, b));

If a and b aren't Copy I've gotta clone them:

let x = a.clone();
let y = b.clone();
f(move || g(a,b));
f(move || g(x,y));

In my UI library case there were more lines of cloning and things got a bit ugly. It took away from the declarative style.

Annoying. What I ended up doing was making those types Copy, which caused other ergonomics issues, but I decided the tradeoff was worth it.

I was just talking with a programmer I respect greatly, who used rust for a project but decided because of this behavior it felt "too low level" and went back to C++ (for new projects!). He wants to program in a very value-oriented FP style. I've also spoken with a rather prominent rust programmer who called it an "ergonomics paper cut."

Now, I understand the point of standard library types not copying implicitly. They should be designed with performance in mind. But there are cases where you'd like to have ergonomics over performance. So I think it would be great if the user had the freedom to implement Copy as they want.

People of course say they like that this enforces a style on people so other people's code obeys their preferred rules. If you believe that, you may be a bit of an authoritarian. If an organization wants to dictate "Copy must be bitwise", that's fine. But for a language to say it feels more like a religious edict.

Anyway I'd love to see rust replace c++, so we all can have an easier time programming (I've done c++ professionally for two decades, sadly). Rust's memory safety, thread safety, powerful type system, and great package management should be convincing enough. Taking away this barrier would be another step IMO.

cheers!

1 Like

Can you talk more about what those tradeoffs and different ergonomic issues were?

1 Like

Sure. These are state handles which refer to some UI state. Originally they were using Rc<RefCell<T>> internally which users preferred because they could do state.get() and state.set(whatever). Now I just have them as u64's (internally), relying on passing around a context object to look up the values: cx[state] = whatever. I like that this got rid of the interior mutability, but now the Context (cx) needs to be passed all over the place. On balance I like this better than having all the clones.

As an example, this defines a text editor the new way:

pub fn text_editor(text: impl Binding<String>) -> impl View {
    focus(move |has_focus| {
        state(TextEditorState::new, move |state, cx| {
            let cursor = cx[state].cursor;
            canvas(move |cx, rect, vger| {
                vger.translate([0.0, rect.height()]);
                let font_size = 18;
                let break_width = Some(rect.width());

                vger.text(text.get(cx), font_size, TEXT_COLOR, break_width);

                if has_focus {
                    let rects = vger.glyph_positions(text.get(cx), font_size, break_width);
                    let lines = vger.line_metrics(text.get(cx), font_size, break_width);
                    let glyph_rect_paint = vger.color_paint(vger::Color::MAGENTA);
                    let p = if cursor == rects.len() {
                        if let Some(r) = rects.last() {
                            [r.origin.x + r.size.width, r.origin.y].into()
                        } else {
                            [0.0, -20.0].into()
                        }
                    } else {
                        rects[cursor].origin
                    };
                    vger.fill_rect(LocalRect::new(p, [2.0, 20.0].into()), 0.0, glyph_rect_paint);

                    cx[state].glyph_rects = rects;
                    cx[state].lines = lines;
                }
            })
            .key(move |cx, k| {
                if has_focus {
                    let t = text.with(cx, |t| t.clone());
                    let new_t = cx[state].key(&k, t);
                    text.with_mut(cx, |t| *t = new_t);
                }
            })
        })
    })
}

and this was the old way with the cloning:

    fn body(&self) -> impl View {
        let text = self.text.clone();
        focus(move |has_focus| {
            let text = text.clone();
            state(TextEditorState::new(), move |state| {
                let text = text.clone();
                let text2 = text.clone();
                let cursor = state.with(|s| s.cursor);
                let state2 = state.clone();
                canvas(move |rect, vger| {
                    vger.translate([0.0, rect.height()]);
                    let font_size = 18;
                    let break_width = Some(rect.width());

                    let rects = vger.glyph_positions(&text.get(), font_size, break_width);
                    let lines = vger.line_metrics(&text.get(), font_size, break_width);

                    vger.text(&text.get(), font_size, TEXT_COLOR, break_width);

                    if has_focus {
                        let glyph_rect_paint = vger.color_paint(vger::Color::MAGENTA);
                        let r = rects[cursor];
                        vger.fill_rect(
                            LocalRect::new(r.origin, [2.0, 20.0].into()),
                            0.0,
                            glyph_rect_paint,
                        );
                    }

                    state2.get().glyph_info.borrow_mut().glyph_rects = rects;
                    state2.get().glyph_info.borrow_mut().lines = lines;
                })
                .key(move |k| {
                    if has_focus {
                        state.with_mut(|s| s.key(&k, &text2))
                    }
                })
            })
        })
    }

I don't know if that was the most annoying example, but it was the one that came to mind.

You're comparing to C++, but C++ has a lot more non-ergonomic ugliness to deal with the copy vs move distinction than just having to write .clone().

It has rvalue references, it has std::move, it has std::forward, it has complicated reference collapsing rules because of the two types of references, complicated template deduction rules because of that, functions taking references often are overloaded with the two types of references, etc etc.

17 Likes

AFAICT rust could offer the user more powerful copying without needing any of that complexity. After all, the compiler knows when to generate a bitwise copy, so it could generate some other code.

In your dynamically sized matrix example, if x = y meant "clone", how would you distinguish that from a mere move? Sometimes you don't want to clone, you want to move.

7 Likes

Sure, if you don't use y later, then it's a move. Otherwise it invokes the copy code. This is no different from the current copying behavior, AFAIK, it's just that copying wouldn't merely be bitwise copy if that's what the user defined.

The current behavior of x = y is always same regardless of whether you use the value later - it's always a bitwise copy.

It would be quite counter-intuitive if the semantics of x = y changed depending on some code further down the function.

24 Likes

Copy is often more about the type not implementing Drop rather than how it's cloned. I try to avoid using Copy types that are much more than a few pointers in size to not mask performance issues.

For your case theirs a few possible alternatives. Try to use shared refs instead of owned, since those are copy. This of course may not quite work, since the owner would need to be rooted deep enough.

Potentially some kind of object pool library, like generational_arena may help get you closer to what you want.

It maybe possible to make a procedure macro to auto insert clones or add some kind of clone "operator".

At the language level, maybe some kind of clone annotation/operator. For example in move closures, where I annotate the variable within the closure but it gets cloned before the closure (Maybe the proc macro could do this). Alternative maybe a specially wrapper type that auto clones, and is syntactically like Copy but isn't copy (since its clones may Drop). I really doubt that would get accepted since Rust isn't big on hiding arbitrarily complicated code, Drop is the only exception I can think of, but that adds massive ergonomics.

5 Likes

My Context object is similar to that, and I've looked at the generational arena code in detail.

You'd have to have pathological (i.e. broken) assignment code for that to happen.

But anyway, maybe it just always invokes the copy code you've defined. That would be fine too!

I'll tease this out into two parts, actually.

First is whether a move is always bitwise. This is now essentially a fait accompli since there's lots of unsafe code that would be broken if it changed, since it's what lets Vec use realloc, for example. That absolutely does limit the kinds of designs that are directly expressible, but it can't practically change anyway, and I think that's the one where people have largely accepted it as fine.

But notably you're talking about copies, not just moves. And tweaking that comes up regularly.

Just a couple weeks ago there was this IRLO thread, for example `autoclone` variable marker - language design - Rust Internals. Or there's this comment in Zulip https://rust-lang.zulipchat.com/#narrow/stream/213817-t-lang/topic/I'd.20love.20to.20write.20.22slow.20Rust.22.20sometimes/near/276550447.

Or there have been conversations about explicit closure capture syntax so that you could do something like

f(move[clone a, clone b] || g(a,b));

Although that one you can probably already find a bunch of macros available to help with. Or use this pattern to help emphasize what you're doing, though of course it's still not more ergonomic:

f({
    let a = a.clone();
    let b = b.clone();
    move || g(a, b)
});

(The macros usually emit something like that, taking advantage of shadowing to not need the different names like you had in your example.)

I'll add that if you want to actually bring about change related to this, rather than hear from "survivor bias", then you probably want https://internals.rust-lang.org/ or Zulip instead of URLO.

23 Likes

I like the autoclone idea, at least as a trait. Seems that would not conflict with anything in std. Explicit closure syntax strikes me as less noisy than without, but still noisy.

So would there be any problem with the suggested trait from the other thread?

pub trait AutoClone: Clone {}

That is, if y is AutoClone then x = y is equivalent to x = y.clone(). I can't think of a good reason not to do that.

Though marking variables as autoclone (let autoclone foo) seems like more noise.

I do generally think of Copy as being more about "I promise I won't add Drop", which makes this a bit moot, but I do roughly agree that it's a bit annoying sometimes.

I will say that the idea of just silently calling clone does now seem... icky? Particularly with lambda capture. That is probably the learned helplessness from rust beating me down though! But seriously it mostly feels like the cost of doing business with Rust, asking the same lines as not having implicit conversions, no String literals, and so on, all in the "you have to say what you want to happen" bucket of it's language design.

I'd be happy to try a middle-ground if there's some sort of way to have an ergonomic use-site way to declare implicit clonablity though!

2 Likes

This is sort of philosophical, but I like the idea of a language that can be different things to different people depending on their preferences. Leave the standard library as is (don't want to rock the boat after all), but give people tools for further abstraction if they want.

That's called "another language".

22 Likes

This is the reason modern C++ is so messy. It has adopted a bazillion different meanings of the same basic notation, to suit specific use cases. For example list initialization in C++ has 14 different meanings depending on context. There are very few people in the world that really know all this, and even they get confused and annoyed.

21 Likes

I never thought of that as the main reason C++ is a mess, though I'll grant you that feature creep is a thing. More that it inherits the bones of C and adds a lot of stuff. Rust has better bones.

1 Like

This gets into philosophy, so there are no hard answers. Technically it certainly could be done.

As you probably know, one of the things that many people consider a "Rust got it right by learning from C++" is that the default is to move, with extra syntax needed for cloning. One could argue that Copy is already the middle-ground here for avoiding .clone(), and even there people want to Lint for undesirable, implicit copies · Issue #45683 · rust-lang/rust · GitHub.

So is it ok for Rc to be AutoClone? What about Arc? I know there are people who want to be really careful about avoiding unnecessary reference count updates, and probably prefer the current state. But it'd also be unfortunate for people who don't worry about Rc reference count updates to have to newtype it in order to get the AutoClone behaviour. Should a SmartString — data structures in Rust // Lib.rs be AutoClone? If so, why not String too?

One way to resolve that would be to have a lint for AutoClone happening, like the lint for undesirable Copys. But at that point do we need the trait? What if we just said "meh, we'll always just clone" but make it a deny-by-default lint so you could opt-in to "I don't care" by silencing the lint at some scope -- maybe even your whole crate if you wanted. But then it's auto-Cloneing everything, which seems rather unrusty, more like a dialect to me.

Maybe we keep the trait, then? But then does it make sense for generic code to bound on the trait? You can't have a trait bound on must_use, so maybe this would be better as an attribute instead of a trait? But maybe you want to forward it in your generic newtypes, or something like Option, so it does need to be a trait again? But then when should we implement for core types, then? Is [Rc<String>; 1000] still AutoClone? That doesn't seem great, but [Arc<String>; 0] certainly could be AutoClone...

So what should we do here? Should we even do anything here? I don't know.

(But I do know that the decision won't happen on URLO.)

9 Likes

I for one love the explicit nature of clone.

Bitwise moves and explicit clones are a great help when reasoning about code vs. a bunch of silent copy constuctors firing off everywhere.

18 Likes