Proposal: Changing `Copyable` to refine `Movable`

Hi folks,

I checked a proposal for making a small but important stdlib change into the repo today, it should be out in the next nightly build. Ahead of that, I’m sharing it below. We discussed it internally and believe it is the right thing to do, but I’d love additional feedback.

-Chris


Proposal: Copyable refines Movable

This is a short rationale doc for why Copyable should refine the Movabletrait in the Mojo standard library.

Historical perspective

Today we have:

trait Movable:
    fn __moveinit__(out self, deinit existing: Self, /): ...
        ...

trait Copyable:
    fn __copyinit__(out self, existing: Self, /): ...
        ...

trait ImplicitlyCopyable(Copyable):
    pass

Proposal: Change Copyable to refine Movable

The proposal is to make Copyable refine/imply Movable:

trait Copyable(Movable):

   ...

This change would mean that you could not define a type that conforms to Copyable without conforming to Movable. The standard library change is trivial, but this is a significant change that needs to be carefully considered.

Rationale / Benefit

First benefit: reduction of verbosity defining structs: One can now just write:

struct SimplePerson(Copyable):
    var name: String
    var age: Int

instead of having to remember to write:

struct SimplePerson(Copyable, Movable):
    var name: String
    var age: Int

Second benefit: performance: It is easy to forget to add a Movableconformance - and doing so for copyable types will silently generate much worse performance. A key part of Mojo is that it does transparent “Copy to Move” optimizations based on dataflow analysis. These optimizations are silently disabled for types that are not Movable.

This is a footgun that I ran into, and is the original motivation for this proposal - I forgot to implement a Movable conformance and only noticed a ton of extra non-optimized copies by looking at the MLIR. This is not great UX!

Third benefit: reduction of verbosity for generic algorithms: Generic algorithms need Copyable are simplified to always have Movable without having to require Copyable & Movable. This is a minor win, but does fall out.

Fourth benefit: elimination of a false choice for generic algorithms: Today, it is possible to define an algorithm that requires Copyable without Movable. However, this is always a bad idea: such an approach could be appealing because it allows the algorithm to work with a wider range of types, but such a decision has two problems: 1) it prevents manual and compiler copy->move optimizations, and generic algorithms should work with a wide range of types where those are important. 2) it breaks composability with other algorithms that are written to require Movable.

Observation: ~All copyable types can implement moveinit

The only reason not to do this is if there were types that were Copyable but not Movable. All non-linear types (those with an implicit destructor) can validly (but probably not optimally) implement __moveinit__ like this:

fn __moveinit__(out self, var existing: Self, /):
    # Move by performing a copy and deleting the original value.
    self = Self(existing)

I’m not aware of any types that would want to be copyable without movable. In practice, most types also have more-efficient move constructors than copy constructors.

What about linear types?

The above implementation of __moveinit__ (which is implemented in terms of a copy) requires an implicit destructor, but most normal types would implement move in a fancier way.

The only problem with this proposal are for types that are:

1. linear, so they have no implicit destructor

2. want to be copyable but cannot implement a move constructor

I’m not aware of any concrete examples, but it is theoretically possible that some type wants to behave this way. I don’t think that supporting such things is valuable - such a type can implement a different operation, e.g. .copy() or implement __copyinit__ without conforming to Copyable.

12 Likes

SGTM

@owenhilyard I summon thee, our purveyor of cursed edge-case knowledge :laughing: Any worries here?

3 Likes

Nice! Sonetimes the sum of two traits is more than each trait considered on its own.

One thing that could be done to allow for „theoretical“ copyable structs that are not movable is to rename the current Copyable to something else like RawCopyable and use it for the new Copyable:

trait Movable:
    fn __moveinit__(out self, deinit existing: Self, /): ...
        ...

trait RawCopyable:
    fn __copyinit__(out self, existing: Self, /): ...
        ...

comptime Copyable = RawCopyable & Movable
"""Use `Copyable` for compiler optimized copying
instead of `RawCopyable` without `Movable`"""

trait ImplicitlyCopyable(Copyable):
    pass

This would allow for Copyable structs that always implement Movable and for RawCopyable structs that can omit to be Movable:

struct SimplePerson(Copyable):
    var name: String
    var age: Int

struct MyNonMovable(RawCopyable):
    ...

But if non-movable copyable structs are only „theoretical“ things that will never be needed in reality I would prefer the original proposal.

2 Likes

Not directly related to this proposal, but I think it’s a good idea to solve this in the general case. Meaning, one should be able to easily inspect and understand what exactly the compiler did with one’s code, to avoid footguns similar to this in the future.

Overall, the proposal sounds great! I like that it’s making things simpler for Mojo devs, and there appear to be very few downsides.

When trying to come up with a reasonable case for a struct that should be Copyable but not Movable, it’s hard to come up with something sane. But an insane idea I had was some weird FFI case. Say you’re doing FFI into a C library (or something similar), and you hand out a pointer to a struct allocated by mojo. In that case, I think moving the struct under that pointer while the C code has that pointer would be a big no-no, but copying the struct should be fine. I think you’d definitely want a guarantee that the mojo compiler couldn’t move the struct under that pointer implicitly, and omitting Movable seems like the right way to do it.

Given that this case (if it is indeed a valid case) is probably off in super edge-case land, I think supporting it on the easy path (ie, separate Copyable and Movable) is not necessary. Moving it to some not-easiest path would be fine, like the proposed RawCopyable, or maybe OnlyCopyable or CopyableNotMovable. That way, the vast majority of not edge-case mojo code can benefit from the proposal.

1 Like

I don’t think “normal” mojo users should have to look at IR, but I agree with you it can be a useful tool for advanced devs!

If you’re interested, the “compile” modular can be used to inspect various ir’s, and there is an -emit-llvm flag as well. The “compiler explorer” web site has a nice interface set up to do this conveniently.

-Chris

1 Like

I’m not aware of any types that would want to be copyable without movable. In practice, most types also have more-efficient move constructors than copy constructors.

I think we may run into issues with constructs that have what Rust would call “internal mutability” that aren’t movable. Atomics and synchronization primitives come to mind, since I see no reason for Atomic to not be something you can copy just by doing a load, especially if someone wants to make an atomic type parameterized on the consistency level so you don’t have to specify it for every operation.

I agree with @cuchaz that this is likely going to cause problems for FFI. A lot of C libraries have a bit of an internal pointer soup which makes moving data hazardous but copy functions are often provided that do the proper internal book-keeping. I’m not sure we want to force people to implement move as a copy and then a free when the free may be the more expensive operation. This is made even worse by types which intentionally hide their ABIs and have a byte array of the correct size then some alignment parameters on the struct, something which I’ve also seen used to hide driver or firmware details when context must be passed around.

I don’t think we can have an OnlyCopyable, or similar since that just puts us back to where we are now with worse names. Similarly, I think that having Copyable and Movable be bundles moves us back towards a CollectionElement situation.

Is it possible to implement negative bounds in Mojo? That way we could have:

struct ImmovableThing(NotMovable):
    ...

# Somewhere in the depths of the stdlib, with either compiler magic or static reflection

trait NotMovable:
    pass

# this may be a bunch of compiler magic for 
extension[T: AnyType] Movable for T where not T: NotMovable and all(map(T.members, lambda member: conforms_to[member, Movable]())):
    """"
    Implements `Movable` for all types which are not marked as non-movable and who's members are all movable. 
    """"
    ...

I know that for now it would need to be heavily special cased, but I think that the initial idea around Copyable, Movable by default (using @value) was a good ergonomics decision and that seeing NotCopyable or NotMovable would be something that gets developers to pay more attention, since it’s no long a matter of a developer forgetting to add them but that someone had to explicitly go declare that they weren’t. This pattern also extends nicely to other things we probably want as “default traits” like Send and Sync (or whatever more granular version of those we come up with).

I understand that it’s probably more work than implementing the fix this way, but I think that we’re going to run into a similar problem with Send and Sync as far as developers forgetting them and we almost definitely want those to be opt-out.

I think there may be a couple of points of confusion in this thread - Mojo is significantly different than Rust, so we have to be careful to pull lessons from it.

Notably, Mojo allows defining custom move constructors, which addresses the “internal pointers” problem. Any type that can be copied would create that new copy with the internal pointers set to point to the new instance’s fields… so should its moveinit. Rust doesn’t support this sort of thing, because you can’t implement a custom moveinit.

I may not be fully understanding your FFI concern, but I also don’t think it is a problem. Mojo doesn’t introduce implicit copies or moves unless you ask for it (though it will try to optimize copies INTO moves if you ask for a copy). If you’re asking for a copy or move of your FFI’d value into another value, then of course the two different values have different addresses. This proposal doesn’t affect that.

As the proposal says, it’s easy to prove that any copyable type can implement move semantics in mojo: if you have a copyinit, you can just define your moveinit in terms of copyinit. This isn’t theoretical, it is fact :slight_smile:

-Chris

1 Like

So it would be easy to define a DefaultsToCopying trait based on the proposal by just providing a default __moveinit__ method?

trait DefaultsToCopying(Copyable):
     __moveinit__(out self, var existing: Self, /):
        # Move by performing a copy and deleting the original value
        self = Self(existing)


struct MyNonMovable(DefaultsToCopying):
    # No __moveinit__ implementation needed
    ...

Such „copyable but non movable“ structs can then be passes to any Copyable argument in generic functions too.

Yep, that works. I don’t think it is a great idea to encourage though, because most types have a more efficient move operation than copy operation - this would be encouraging the more expensive operation.

1 Like