Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feature Request] [mojo-lang] [proposal] Add Linear / Explicitly Destroyed Types #3848

Open
1 task done
VerdagonModular opened this issue Dec 9, 2024 · 28 comments
Open
1 task done
Labels
enhancement New feature or request mojo-repo Tag all issues with this label

Comments

@VerdagonModular
Copy link
Contributor

VerdagonModular commented Dec 9, 2024

Review Mojo's priorities

What is your request?

See Proposal for Linear / Explicitly Destroyed Types.

TL;DR: We'd upgrade the type system to be able to express "explicitly destroyed types"; a struct (or trait) definition could use an e.g. @explicit_destroy("Use some_method(a, b, c) to destroy") annotation to inform the use when they haven't disposed the object correctly.

What is your motivation for this change?

This solves a bunch of problems, for example:

  • Remind the user when Coroutine objects go out of scope, and that they should instead await them (and take ownership of the coroutine’s result).
  • A (hypothetical) t: Thread object should never go out of scope, the user should only destroy it via t^.join() or t^.abandon().
  • Require the user call e.g. spaceship.land(landing_zone) instead of just letting the ship go out of scope.

For more examples, check out the proposal! Also take a look at this talk at the 2024 LLVM conference where I talked a bit about these concepts.

Any other details?

I previously implemented this feature in Vale, and I've implemented a proof-of-concept to verify this is possible in the Mojo compiler (proposal's steps 1-7), but some open questions still remain:

  • How might this do at scale?
  • What should we call these things, that are temporarily named @explicit_destroy, destroy, ImplicitlyDestructible, etc.
  • Are the benefits (better type system, less errors) worth the costs (different traits for some generic functions).

None of this is final, so I'd love to get everyone's feedback!

@VerdagonModular VerdagonModular added enhancement New feature or request mojo-repo Tag all issues with this label labels Dec 9, 2024
@owenhilyard
Copy link
Contributor

owenhilyard commented Dec 9, 2024

For now, we should make it so only methods can use destroy, and only on Self. This could be hard-coded in the compiler: make sure destroy's argument is a Self.

Could we have some kind of __unsafe_destroy builtin as an escape hatch? For instance, there are reasons to destroy a coroutine without finishing its execution if you want have information you don't need it to run more.

I'd also like to see destroy foo be a shorthand for foo.__del__() for types with a destructor, since I think we'll see a lot of questions if it doesn't since people will equate it with python's del.

I think a Linear auto trait would also be useful so we can specialize on it to produce nicer error messages for things that don't or can't support linear types. As far as I am aware the compiler needs to do this since we can't make a trait that requires the absence of a function.

Arc[T: AnyType] becomes Arc[T: Delable]

One feature I've found useful in Rust's Arc is the ability to unwrap the Arc and take ownership if the refcount is 1. Could we make use of conditional conformance to make arc into a linear type which returns Optional[T] when you destroy it, only returning Some if the refcount is 1? I think the same can apply to a non-atomic Rc.

Coroutine/RaisingCoroutine should remain the same because it doesn’t store it, a method just returns it

What Linear types held across yield points?

UnsafeMaybeUninitialized, UnsafePointer

I'd lean towards keeping those as-is because we have no way of knowing if the inner type is actually properly initialized.

Overall I'm very interested in this feature, but I want to make sure that we both have the tools to make it nice to use and some escape hatches to deal with bad API design or unorthodox requirements.

@helehex
Copy link
Contributor

helehex commented Dec 10, 2024

I would prefer opting-in to the __del__ method. It is then consistent with the other lifecycle methods. The only counter argument you give is that it would break existing code, which incorrectly assumes that my code doesn't already break on a nightly basis. But for what's it's worth, i can come up with some other reasons to not do it like that (although not very good ones, maybe there are some better ones).

  1. makes the error hints slightly more difficult to specify
  2. the team would have to work on giving @value more specialization

@helehex
Copy link
Contributor

helehex commented Dec 10, 2024

in terms of destroy keyword, i see you already gave us a __disable_del which is kind of like a safer version of the current ownership.mark_destroyed op, which we currently use as an escape hatch for destructors. Maybe someone could better explain the benefits of having those extra safety measures on mark_destroyed, but why don't we just keep it how it is, and and give it a shorthand like __unsafe_del. Most user facing code wont be using destroy anyways, right? They should use the higher level methods the library author defines that consume it. I really don't like unnecessary safety, so hopefully there's a good reason (I'm new to linear types btw)

@rd4com
Copy link
Contributor

rd4com commented Dec 10, 2024

Hello, looking forward to use linear types, this is great!
good job 👍

@VerdagonModular
Copy link
Contributor Author

VerdagonModular commented Dec 10, 2024

(@owenhilyard) Could we have some kind of __unsafe_destroy builtin as an escape hatch? For instance, there are reasons to destroy a coroutine without finishing its execution if you want have information you don't need it to run more. ... and some escape hatches to deal with bad API design or unorthodox requirements.

+1 to this line of thinking, since library authors can't perfectly predict the future, and otherwise linear types would cut off their ability to strategically retreat just return from code as a last resort. When it's 2am and prod's on fire, having an escape hatch is nice.

I'd also like to see destroy foo be a shorthand for foo.del() for types with a destructor, since I think we'll see a lot of questions if it doesn't since people will equate it with python's del.

IIRC, del is used for two things in Python:

  • Unbinding a local variable (which might destroy it, if that was the last reference)
  • Deleting an entry in a collection (del c[42]).

destroy is like the first, but pretty different from the second. Using destroy for the first would be nice. I assume we wouldn't use it for the second one too. But would that cause more confusion, defeating the purpose?

One feature I've found useful in Rust's Arc is the ability to unwrap the Arc and take ownership if the refcount is 1. Could we make use of conditional conformance to make arc into a linear type which returns Optional[T] when you destroy it, only returning Some if the refcount is 1? I think the same can apply to a non-atomic Rc.

How we'll do Rc and linear types is an interesting topic that deserves its own entire proposal, but it would be good to address a question "Are linear types incompatible with any future RC?" I think not, because there are a few solutions:

  1. Your solution, where Rc[T] is itself linear and must be checked on destroy.
  2. If a user wants to put a linear type T in an Rc, they would create a (linear) OwningRc[T], which could spawn a bunch of Rc[Option[T]]s (which would contain None if the OwningRc was already destroyed).
  3. Disallow cloning Rc[T] for linear types, and only allow aliasing via weak pointers to them.

I think either #1 or #2 would be sufficient to say Rc isn't incompatible with linear types, though we'd figure out later which we want (or both!).

What Linear types held across yield points?

I think that's fine, because coroutines are themselves linear, so must be continued until their end. Unless there's a problem I'm not seeing?

UnsafeMaybeUninitialized, UnsafePointer ... I'd lean towards keeping those as-is because we have no way of knowing if the inner type is actually properly initialized.

When I was implementing a basic sample LinearList (which I still have to post somewhere), List[T: AnyType] had trouble containing an UnsafePointer[T: ImplicitlyDestructible], the type system rejected it because it thought UnsafePointer[T]'s T required a __del__. Also, I kind of see these like Option[T]: even if it might contain None, we should probably still poke the user to check.

(@helehex) I would prefer opting-in to the del method. It is then consistent with the other lifecycle methods.

That's a pretty good argument. Though, it would be annoying for the user to have to specify __del__ all the time. But maybe that's what @value is for. Would love everyone's thoughts on this!

Also, +1 for having a escape hatch for users to use when the library author didn't design their API correctly. It might even be worth having two keywords: destroy for proper use inside a method, and __force_destroy for outside (credit to Owen for this idea in the discord). Or maybe some alternate syntactical speedbump, like @force destroy or something.

@owenhilyard
Copy link
Contributor

I'd also like to see destroy foo be a shorthand for foo.del() for types with a destructor, since I think we'll see a lot of questions if it doesn't since people will equate it with python's del.

IIRC, del is used for two things in Python:

* Unbinding a local variable (which _might_ destroy it, if that was the last reference)

* Deleting an entry in a collection (`del c[42]`).

destroy is like the first, but pretty different from the second. Using destroy for the first would be nice. I assume we wouldn't use it for the second one too. But would that cause more confusion, defeating the purpose?

Sorry, I meant foo.__del__(), markdown messed up the formatting. I see your point about it being more confusing than helpful, so it may be better to keep them separate.

One feature I've found useful in Rust's Arc is the ability to unwrap the Arc and take ownership if the refcount is 1. Could we make use of conditional conformance to make arc into a linear type which returns Optional[T] when you destroy it, only returning Some if the refcount is 1? I think the same can apply to a non-atomic Rc.

How we'll do Rc and linear types is an interesting topic that deserves its own entire proposal, but it would be good to address a question "Are linear types incompatible with any future RC?" I think not, because there are a few solutions:

1. Your solution, where `Rc[T]` is itself linear and must be checked on destroy.

2. If a user wants to put a linear type T in an Rc, they would create a (linear) `OwningRc[T]`, which could spawn a bunch of `Rc[Option[T]]`s (which would contain None if the `OwningRc` was already destroyed).

3. Disallow cloning `Rc[T]` for linear types, and only allow aliasing via weak pointers to them.

I think either #1 or #2 would be sufficient to say Rc isn't incompatible with linear types, though we'd figure out later which we want (or both!).

I'd lean towards both. I know that there was a prototype for having an optional weak pointer for Arc, and weak pointers are generally useful for some use-cases like string interning. My guess is that many linear types won't have a method to destroy them which takes smart pointer wrappers, so I think some way to get a linear type out of the refcounted smart pointers is important.

What Linear types held across yield points?

I think that's fine, because coroutines are themselves linear, so must be continued until their end. Unless there's a problem I'm not seeing?

I'm considering whether the coroutine frame type (which after a lot of discussing back and forth with @nmsmith I think may be useful to expose to users) needs to be linear.

UnsafeMaybeUninitialized, UnsafePointer ... I'd lean towards keeping those as-is because we have no way of knowing if the inner type is actually properly initialized.

When I was implementing a basic sample LinearList (which I still have to post somewhere), List[T: AnyType] had trouble containing an UnsafePointer[T: ImplicitlyDestructible], the type system rejected it because it thought UnsafePointer[T]'s T required a __del__. Also, I kind of see these like Option[T]: even if it might contain None, we should probably still poke the user to check.

I can live with this if there is a way to say "I know this is empty, free the backing allocation while doing nothing else". I don't want the hot loop of an async executor which is throwing around pointers to coroutines constantly to need to do a non-zero-cost check. For io_uring, linear UnsafePointer would also mean that the most straightforward way to writing the executor (where you stuff the coroutine struct pointer into the user data field) would make every single completion event a linear type.

(@helehex) I would prefer opting-in to the del method. It is then consistent with the other lifecycle methods.

That's a pretty good argument. Though, it would be annoying for the user to have to specify __del__ all the time. But maybe that's what @value is for. Would love everyone's thoughts on this!

I think it would also produce issues for things like FFI if the default was linear type, since that means writing out a dtor for every trivial type.

Also, +1 for having a escape hatch for users to use when the library author didn't design their API correctly. It might even be worth having two keywords: destroy for proper use inside a method, and __force_destroy for outside (credit to Owen for this idea in the discord). Or maybe some alternate syntactical speedbump, like @force destroy or something.

Thank you! I'm generally in favor of having the person who writes main have final say on everything, but I think that having to jump through a hoop in order to say "no, the library author was wrong" is a fine protection since hopefully users only learn about the big override button after they know the language well enough to use it responsibly.

@nmsmith
Copy link
Contributor

nmsmith commented Dec 12, 2024

I'd also like to see destroy foo be a shorthand for foo.__del__() for types with a destructor

I disagree with this.

As I understand it, destroy self is meant to be the statement that you invoke to end a value's lifetime. destroy self should be invoked within __del__, it should not invoke __del__. Said another way: destroy self should perhaps be understood as the canonical "destruction statement", and all other so-called "destructors" (including __del__) should be understood as ordinary methods that take an argument as owned and just-so-happen to invoke destroy on it. Going further, we should probably eliminate the special treatment of __del__ in the compiler r.e. deinitializing fields, and require anyone who explicitly defines __del__ to invoke a destroy statement somewhere in the function body. This would simplify the model significantly.

In fact, there's no reason that __del__ even needs to invoke destroy. __del__ could very well just transfer the value of self somewhere else. In this sense, __del__ is not really a "destructor", it's just a method that is invoked when a variable "goes out of scope", or is otherwise deinitialized. Usually __del__ destroys the value, but it's not required to.

Taking this further: I'd like us to consider making the concept of implicitly going out of scope orthogonal to the concept of destruction. __del__ relates to the former phenomenon, whereas destroy relates to the latter phenomenon.

If we go in this direction, then __del__ should be renamed to something that means "the variable went out of scope" or "the variable is being deinitialized", rather than "the value is being destroyed". There's an established term we can use for this: drop. "I dropped the value" means that I dumped it on the floor and walked away. When you drop something, the compiler comes in to clean it up using the __drop__ method. This term is well established in the Rust community, so it shouldn't be too controversial.

  • I also like the name __discard__. Like "drop", it conveys that we are getting rid of a variable's value, yet it doesn't state what will happen to the value. Maybe the value goes back into a resource pool, instead of being destroyed.

There's a second reason to move away from the name __del__: in Python, __del__ has NO RELATIONSHIP to variables going out of scope. It's invoked when the garbage collector reclaims an object, which happens unpredictably. In this light, the current meaning of __del__ is a potential source of confusion for people coming from Python.

So my first suggestions for Evan's proposal are to consider introducing:

  1. A __drop__ method, to replace __del__. If this method needs to destroy self, then it must include an explicit destroy self statement. This decouples the notion of dropping from the notion of destruction.
  2. A Droppable trait. (This is my proposed name for ImplicitlyDestructible.)

My next suggestion concerns @explicitly_destructible. I agree with @helehex, where he points out that all other lifecycle methods in Mojo are opt-in, so it makes sense for __drop__ to be opt-in as well:

(@helehex) I would prefer opting-in to the del method. It is then consistent with the other lifecycle methods.
(@VerdagonModular) That's a pretty good argument. Though, it would be annoying for the user to have to specify __del__ all the time. But maybe that's what @value is for. Would love everyone's thoughts on this!

I agree that having the existing @value decorator include a default implementation of __drop__ would get us 90% of the way towards making __drop__ opt-in, while still keeping Mojo ergonomic.

Going further, perhaps we should replace the @value decorator with a set of traits that provide default implementations of the lifecycle methods. For example, if you declare struct Foo(Droppable): pass, then Foo immediately gets a default implementation of __drop__. For the @value decorator, we can replace it with a Value trait, which inherits from Copyable, Movable, Droppable, etc, and therefore gives us default implementations of each of these trait methods.

Notably, Mojo doesn't allow trait authors to define default implementations (yet), but in the short term, we can just give the above traits special treatment in the compiler.

IMO this is a promising way to make it ergonomic for Mojo programmers to opt-in to lifecycle methods. Also, it seems more principled than all of these random decorators. 😇

Note: The compiler would only generate default implementations of the above trait methods in the cases where all of the struct's fields conform to the trait as well. Otherwise, the struct author would be required to provide a custom implementation. For example, you'd only get a default implementation of Droppable if all of the fields are Droppable.

In the absence of the @explicitly_destructible decorator, the struct author wouldn't be able to provide those custom error messages that Evan demonstrated. However, we have at least two alternative ways to assist the programmer when they are required to explicitly destroy/transfer ownership of an instance of T:

  1. Have the compiler enumerate all methods of T that take owned self, or more generally, all functions that take T as owned.
  2. Introduce a special @destruction_hint decorator. This fulfils the same role as the error message functionality of @explicitly_destructible.

That's all of the feedback I have for now. Generally speaking, I love the proposal! I can't wait to see how the Mojo community makes use of this feature.

@owenhilyard
Copy link
Contributor

@nmsmith

My next suggestion concerns @explicitly_destructible. I agree with @helehex, where he points out that all other lifecycle methods in Mojo are opt-in, so it makes sense for drop to be opt-in as well.

I think that Mojo probably does need to pick. Do we try to have sane defaults and opt-out of things that aren't commonly needed (meaning that move constructors are automatic), or do we do opt-in and have some more decorators (@copy, @move, @del and an @value) to derive them. However, I would also group async under the "opt in to everything" group, which per our other conversations you aren't in favor of. I think that automatic __del__ is reasonable because almost all structs will use the default impl. Unless you are using unsafe, I can't think of a reason to have a non-default __del__, but there are plenty of reasons to have a non-default __copy__ that doesn't require unsafe, since we're still halfway to .copy() for things which are expensive to copy.

I don't think doing it with traits will be doable until we have reflection, unless it's pure compiler magic.

@helehex
Copy link
Contributor

helehex commented Dec 12, 2024

I have a longer response, but long story short, I'd like a way to specify a custom error (or warning) message when you try to copy a non-copyable anyways, so hopefully we can do something which allows that in the same way non-dels will

@nmsmith
Copy link
Contributor

nmsmith commented Dec 13, 2024

I don't think doing it with traits will be doable until we have reflection, unless it's pure compiler magic.

That's exactly what I'm suggesting: the traits Copyable, Movable, Droppable, etc. are given default implementations via compiler magic, at least until Mojo reaches a point where default implementations can be defined by library authors.

IMO this approach is no less principled than having the @value and @explicitly_destructible decorators. They are also compiler magic.

@VerdagonModular
Copy link
Contributor Author

VerdagonModular commented Dec 18, 2024

(@nmsmith) In fact, there's no reason that __del__ even needs to invoke destroy. __del__ could very well just transfer the value of self somewhere else. In this sense, __del__ is not really a "destructor", it's just a method that is invoked when a value "goes out of scope". Usually it destroys the value, but it's not required to.

Taking this further: I'd like us to consider making the concept of implicitly going out of scope orthogonal to the concept of destruction. __del__ relates to the former phenomenon, whereas destroy relates to the latter phenomenon.

This! You've just clarified something that I think I've been trying to realize for a long time. The classic example I always use for this is a __del__ that really just throws its instance onto a free-list for later reuse. What I never realized though, is that these two concepts are so orthogonal that they allow us to rethink the meaning and mindset of __del__ (or drop in other languages).

(@nmsmith ... In this light, the current meaning of del is a potential source of confusion for people coming from Python. ...

(@owenhilyard) ... Do we try to have sane defaults and opt-out of things that aren't commonly needed (meaning that move constructors are automatic), or do we do opt-in and have some more decorators (@copy, @Move, @del and an @value) to derive them.

Well said. Thinking out loud here, I want to make explicit a certain challenge Mojo faces: that we need to keep complexity down (of course), but if we need complexity, we need to incorporate it in a way that keeps the learning curve gradual as much as possible. It's the only way Python users will adopt a complex language like Mojo.

Example: C#'s class and struct. They introduce class first, because it's flexible, powerful, and does everything the user might need. Then, by the time the user needs the performance benefits of struct, they're probably past the beginner stage, and have the background and foundation (and the "learning bandwidth") that make learning struct much easier. Counter-example: Rust, which throws all the complexity at you immediately, which repels half the people trying to learn it.

This is particularly challenging because high performance (and guarantees in general) works against us here. It's especially egregious with viral mechanisms like async, borrow checking and pure in some languages. I suspect linear types are another instance of this, so we need to be careful here.

Ways to keep our learning curve down (if we can't avoid introducing complexity):

  1. Make it as close as possible to something they already know.
  2. Introduce defaults that are more human.
  3. Good error messages that eagerly hint the user what they should do. Example: if we try to move a struct that doesn't have __moveinit__, we could suggest the user add @value. For a complex feature, a good error message (which always works) could be the difference between "frustratingly complicated nonsense" and an "minor speedbump, but an interesting learning opportunity."
  4. Have an easy+flexible option and a more powerful option. Then, via tutorials, samples, documentation, and community, we should steer the user to the easier+flexible option first. (C#'s struct vs class was this)
  5. Introduce reasonable escape hatches. Rust's unsafe almost did this, but its naming (and community) failed here.
  6. Abstract away complexity if reasonable+possible. Example: Zig's colorblind async. Not sure if they ever got it to work, but the design thinking was great IMO.
  7. Only show an error when it's clear that it's actually helping them. Example: Mojo's mutable aliasing. Counter-example: borrow checkers that are too conservative.

So, from that perspective, here's some more specific options we have w.r.t. linear types:

A. Keep linearity out of the defaults. If a Python user ever encounters an error about __del__, we need to rethink things.
B. If we ever introduce a [reference-counted?] class for Python users to use by default (like C#), we can make struct linear by default. By the time they encounter struct, they'll have enough learning bandwidth to learn linear types. ...I think. They might be overwhelmed by learning borrowing at that time.
C. Make __force_destroy more discoverable (and within reach of the user), possibly renamed. Or, some other way for the user to easily opt-out of it.
D. Legitimize, and make easy, the pattern of just tossing a linear type onto a global list, which must be checked at the end of main. Or something like ObjC's autorelease pool.

As much as the software architect in me is wary of the last two, we must consider them, because we need to prioritize learning curve, truly and highly.

Going further, perhaps we should replace the @value decorator with a set of traits that provide default implementations of the lifecycle methods. For example, if you declare struct Foo(Droppable): pass, then Foo immediately gets a default implementation of __drop__. For the @value decorator, we can replace it with a Value trait, which inherits from Copyable, Movable, Droppable, etc, and therefore gives us default implementations of each of these trait methods.

Notably, Mojo doesn't allow trait authors to define default implementations (yet), but in the short term, we can just give the above traits special treatment in the compiler.

IMO this is a promising way to make it ergonomic for Mojo programmers to opt-in to lifecycle methods. Also, it seems more principled than all of these random decorators. 😇

Just to reduce scope: whether "value"ness comes from @value or extending (Value), the question is the same either way: should a default implementation of __del__ be included in it?

I lean towards yes: types should by default be linear, because otherwise, a Python user might encounter a __del__ error before it's relevant and before they have the learning bandwidth for it (see 2, 4, and A)

There's a second reason to move away from the name __del__: in Python, __del__ has NO RELATIONSHIP to variables going out of scope. It's invoked when the garbage collector reclaims an object, which happens unpredictably. In this light, the current meaning of __del__ is a potential source of confusion for people coming from Python.
So my first suggestions for Evan's proposal are to consider introducing:

  • A drop method, to replace del. If this method needs to destroy self, then it must include an explicit destroy self statement. This decouples the notion of dropping from the notion of destruction.
  • A Droppable trait. (This is my proposed name for ImplicitlyDestructible.)

I like this, because it makes the distinction between dropping and destroying clear. But do most Python users know that it has no relationship? If Python users are coming in with a misconception about Python, it'll make it harder for them to learn Mojo. I don't actually know. ...do most Python users even know about __del__?

@owenhilyard
Copy link
Contributor

4. Have an easy+flexible option and a more powerful option. Then, via tutorials, samples, documentation, and community, we should steer the user to the easier+flexible option first. (C#'s `struct` vs `class` was this)

But, we need to steer library developers towards the more powerful mode, so they can write libraries that can be used in a variety of places.

5. Introduce reasonable escape hatches. Rust's `unsafe` almost did this, but its naming (and community) failed here.

In what way? unsafe means "You have invariants to uphold that are not in the type system, RTFM before using". It's a label you slap on your escape hatches, not the escape hatch itself. The only thing unsafe lets you do by itself is dereference a pointer.

6. Abstract away complexity if reasonable+possible. Example: Zig's colorblind `async`. Not sure if they ever got it to work, but the design thinking was great IMO.

We can continue the argument on whether making async invisible makes things more or less complex elsewhere, but I agree in principle that abstractions which simplify things are good so long as you can always open up the curtain and gradually increase your complexity exposure in order to get more power/expressiveness.

7. Only show an error when it's clear that it's actually helping them. Example: Mojo's mutable aliasing. Counter-example: borrow checkers that are too conservative.

Mutable xor alias brings a lot of benefits, and I don't think it's clear that allowing mutable aliasing is a good idea. We can have warnings for things which are probably a bad idea but you may still want to do, but I would prefer that if the word "unsafe" doesn't appear in my program (in function names, class names, etc) that I get most or all of the guarantees safe Rust has.

Make __force_destroy more discoverable (and within reach of the user), possibly renamed

I think it should be __unsafe_force_destroy, you can cause all manner of UB if you can destroy linear types outside of the intended way. This mechanism should be a last resort, when all other options are exhausted, to work around library design issues, since you are still responsible for upholding every invariant that linear type had.

Legitimize, and make easy, the pattern of just tossing a linear type onto a global list, which must be checked at the end of main.

I don't think we want this. Linear types are often going to be used to represent resources, and those resources should be cleaned up as quickly as possible (within reason). Not reclaiming resources during execution means you can't use it for anything long-running.

As much as the software architect in me is wary of the last two, we must consider them, because we need to prioritize learning curve, truly and highly.

If you prioritize the learning curve above all else, you get Go. Mojo is a systems language, so control of the system needs to come first, and then we can build "I don't care about that" interfaces on top.

Just to reduce scope: whether "value"ness comes from @value or extending (Value), the question is the same either way: should a default implementation of __del__ be included in it?

I think value should be a composite of Copyable + Movable + Droppable, nothing more.

I lean towards yes: types should by default be linear, because otherwise, a Python user might encounter a __del__ error before it's relevant and before they have the learning bandwidth for it (see 2, 4, and A)

There's a second reason to move away from the name __del__: in Python, __del__ has NO RELATIONSHIP to variables going out of scope. It's invoked when the garbage collector reclaims an object, which happens unpredictably. In this light, the current meaning of __del__ is a potential source of confusion for people coming from Python.

It's not always a GC, if there are no cycles the it runs when the refcount hits zero, which often happens at the end of a scope.

@VerdagonModular
Copy link
Contributor Author

If we were just trying to make a better Rust, or a better C++, I'd agree with everything you just said. Especially this part:

As much as the software architect in me is wary of the last two, we must consider them, because we need to prioritize learning curve, truly and highly.
If you prioritize the learning curve above all else, you get Go.

But devil's advocate: if we don't ever prioritize learning curve, we get Rust, and that's not a good thing for us. I've seen veteran programmers (who were already fans of static typing) bounce off Rust's learning wall. Our challenge is even harder: appeal to Python programmers. Whether we succeed depends on how seriously we regard this problem.

If one doesn't believe that this challenge is important, then my entire last post will make zero sense (and sound very ridiculous).

Also note, I didn't say "above all else". We both know that language design is a balancing act between different tradeoffs. And if we're really good, we can even avoid the tradeoffs, and find clever best-of-all-worlds solutions. Here, I hope we can make Mojo have the most powerful type system of any systems programming language in the world, but also make it palatable to newcomers, especially Python users.

Make __force_destroy more discoverable (and within reach of the user), possibly renamed
I think it should be __unsafe_force_destroy, you can cause all manner of UB if you can destroy linear types outside of the intended way. This mechanism should be a last resort, when all other options are exhausted, to work around library design issues, since you are still responsible for upholding every invariant that linear type had.

I appreciate that stance, since it strengthens the power and guarantees and benefits of linear types. However, it also increases linear types' cost, particularly to newcomers' learning curve. Specifically, I suspect that putting unsafe in the name will cause our community to shame newcomers who use it when they're just trying to avoid being checkmated by linear types. And then they get frustrated, and leave. I'm hoping alternate naming could get the best of both worlds. How about something like __temporary_workaround_destroy (or, different wording, but the same spirit) and give a warning if they don't have a TODO comment on it.

Legitimize, and make easy, the pattern of just tossing a linear type onto a global list, which must be checked at the end of main.
I don't think we want this. Linear types are often going to be used to represent resources, and those resources should be cleaned up as quickly as possible (within reason). Not reclaiming resources during execution means you can't use it for anything long-running.

I don't want this; it's against the spirit of linear types 😆 But if we value a good learning curve, newcomers might need this escape hatch to be within reach.

+1 to everything else you mentioned, well said.

@owenhilyard
Copy link
Contributor

If we were just trying to make a better Rust, or a better C++, I'd agree with everything you just said. Especially this part:

As much as the software architect in me is wary of the last two, we must consider them, because we need to prioritize learning curve, truly and highly.
If you prioritize the learning curve above all else, you get Go.

But devil's advocate: if we don't ever prioritize learning curve, we get Rust, and that's not a good thing for us. I've seen veteran programmers (who were already fans of static typing) bounce off Rust's learning wall. Our challenge is even harder: appeal to Python programmers. Whether we succeed depends on how seriously we regard this problem.

If one doesn't believe that this challenge is important, then my entire last post will make zero sense (and sound very ridiculous).

Also note, I didn't say "above all else". We both know that language design is a balancing act between different tradeoffs. And if we're really good, we can even avoid the tradeoffs, and find clever best-of-all-worlds solutions. Here, I hope we can make Mojo have the most powerful type system of any systems programming language in the world, but also make it palatable to newcomers, especially Python users.

I think we do have an inherit vs accidental complexity problem. There is an inherent level of complexity to "any allocation can fail", concurrency, and parallelism. Python choose to ignore all of those issues, but I don't think we can. We can do our best to have language features that let the compiler help users along, but I don't think we can totally eliminate the complexity of many things without making performance compromises.

I think that trying to simplify complex problems can sometimes be dangerous, since you end up making important decisions for the user. Think about how many ML apps crash when they run out of VRAM instead of backing off and asking the user if they would like to quantize the model and try again, or how badly people wanted multithreading in Python when it was decided early on to close that door, such that the entire python ecosystem is now built around having a global mutex. I don't want to allow programs we know are ill formed in the name of UX, instead I would rather have the compiler guide users towards solving the problem. I think that, with the right messaging, users can be brought around to the idea of doing a lot of their debugging before the compiler lets them run their program ("It compiles it works"). This may be a philosophical difference between me and other people, but I generally prefer to have a fight with the borrow checker over a call at 2am.

Make __force_destroy more discoverable (and within reach of the user), possibly renamed
I think it should be __unsafe_force_destroy, you can cause all manner of UB if you can destroy linear types outside of the intended way. This mechanism should be a last resort, when all other options are exhausted, to work around library design issues, since you are still responsible for upholding every invariant that linear type had.

I appreciate that stance, since it strengthens the power and guarantees and benefits of linear types. However, it also increases linear types' cost, particularly to newcomers' learning curve. Specifically, I suspect that putting unsafe in the name will cause our community to shame newcomers who use it when they're just trying to avoid being checkmated by linear types. And then they get frustrated, and leave. I'm hoping alternate naming could get the best of both worlds. How about something like __temporary_workaround_destroy (or, different wording, but the same spirit) and give a warning if they don't have a TODO comment on it.

The reason I want unsafe to be part of the keyword is because I'd like to be able to grep a codebase for "unsafe" and find the places where memory safety problems may have occurred. The ability to do scoped audits and not need to consider every single line of code for debugging like this is very valuable. I think that the Mojo community may need to come up with a testable definition of unsafe, since I am operating on the "able to cause UB" definition, which I know is strict. To me, this is an assertion that you have both upheld the invariants of the linear type and that you have decided, for whatever reason, that you know better than the author of the library. To me, that is a strong assertion to make, and not one that should be made lightly. If we could get something in the compiler that would allow emitting a warning for all instances of "unsafe constructs" (functions marked by some annotation or built into the definition in the compiler for builtins), then I can be fine with __temporary_workaround_destroy with a TODO comment, or we could look at Rust's convention of safety comments.

Legitimize, and make easy, the pattern of just tossing a linear type onto a global list, which must be checked at the end of main.
I don't think we want this. Linear types are often going to be used to represent resources, and those resources should be cleaned up as quickly as possible (within reason). Not reclaiming resources during execution means you can't use it for anything long-running.

I don't want this; it's against the spirit of linear types 😆 But if we value a good learning curve, newcomers might need this escape hatch to be within reach.

We could build the escape hatch with globals and allow global dtors, and then let library authors decide if they want to do it for their library. Things which are not dangerous, just wasteful, to leak can be put there, but things which have actual invariants tied to them can be handled via __temporary_workaround_destroy.

@VerdagonModular
Copy link
Contributor Author

VerdagonModular commented Dec 18, 2024

+1 to pretty much everything you said. And thanks for bringing up how greppable unsafe is, we really should capture that benefit in Mojo. Your idea of having a compiler mode that can find all instances of unsafe constructs sounds promising, especially since it gives us more syntactical design freedom than including the term "unsafe" in every unsafe construct.

Things which are not dangerous, just wasteful, to leak can be put there, but things which have actual invariants tied to them can be handled via __temporary_workaround_destroy.

An extra +1 to this. I hope to write some blog posts on linear type best practices one day, and this should definitely go in there.

I think that, with the right messaging, users can be brought around to the idea of doing a lot of their debugging before the compiler lets them run their program ("It compiles it works"). This may be a philosophical difference between me and other people, but I generally prefer to have a fight with the borrow checker over a call at 2am.

Perhaps. Swift and Typescript are successfully catching up to / overtaking their predecessors... but Rust hasn't had as much success w.r.t. C/C++. I tend to assume it's because Rust hasn't really convinced the mainstream to fight the borrow checker as much as we would have hoped. Perhaps their messaging was indeed a factor. Our target demographic (Python users) is slightly different, but any thoughts on their messaging, or how ours could be different, to make borrow checking (and linear types) more palatable?

@rd4com
Copy link
Contributor

rd4com commented Dec 18, 2024

There probably exists a more meaningful way to convey the idea of what "borrow checking" does,
for example, we refined Lifetime into Origin 👍

One idea is to not call it borrow checking but something else

@nmsmith
Copy link
Contributor

nmsmith commented Dec 18, 2024

I think Value should be a composite of Copyable + Movable + Droppable, nothing more.

+1. This is simple, and consistent with how the @value decorator currently works in Mojo.

To explicitly answer Evan's question: I think conforming to Droppable should be opt-in, either by writing struct Foo(Value) or struct Foo(Droppable). As someone with years of experience teaching Python, I expect this would be easy to teach. All we need to do is ensure that every "introduction to structs" tutorial for Mojo always explains that:

  • We will be learning how to define and work with trivial basic structs first, i.e. structs whose implementation of copyinit, moveinit, and del/drop is boring and obvious. (And we can briefly explain how a "non-basic" struct would differ.)
  • Basic structs are declared using the syntax struct Foo(Value).

Update: I was using the term "trivial" above, but that's not correct, because a struct that inherits from Value may contain fields that have non-trivial destructors, etc. (In C++ a trivial type is something very specific.)

We should choose a name for Value that better integrates into the above narrative, e.g. Basic, Regular, etc. (IMO the term "value" is way too overloaded in PLs, to the point where it carries no meaning anymore.)

As Evan has mentioned, we also need excellent error messages that ensure that if a learner forgets to conform to Value, and this leads to a "missing conformance" error, the first solution we propose is to add the Value conformance.

Do most Python users know that [del has no relationship to going out of scope]? If Python users are coming in with a misconception about Python, it'll make it harder for them to learn Mojo. I don't actually know. ...do most Python users even know about __del__?

I don't think there are many Python users who use __del__, but those who use it need to know how it works in order to use it correctly, and this requires acknowledging that __del__ is not (always) invoked when a reference goes out of scope, nor is it invoked when you write del x. We definitely don't want Mojo to make __del__ even more difficult to teach, by overloading it with new, struct-specific meanings. Choosing a different name has such an incredibly low cost that I think it is worth doing. At minimum, it means when people do a Google search for the struct feature they don't get the Pythonic version of __del__ appearing in their results. (Especially once Mojo adds support for classes.) That would cause mass confusion, because the Python version of __del__ can only be explained by talking about non-deterministic object reclamation.

Concerning escape hatches for linear types, Evan suggested:

Legitimize, and make easy, the pattern of just tossing a linear type onto a global list, which must be checked at the end of main. [...] I suspect that putting unsafe in the name will cause our community to shame newcomers who use it when they're just trying to avoid being checkmated by linear types.

IMO this is jumping too far ahead. It sounds like you're predicting that linear types will be so challenging to work with that we need to have easy-to-reach-for escape hatches with names that don't make people feel ashamed for reaching for them. But... maybe linear types won't actually be that difficult to use. I'd like to see how people use this feature in practice, and what the pain points are. Once we gather that information, that sounds like the best time to talk about what the escape hatches (if any) should be. In the meantime, offering a placeholder function named __unsafe_force_destroy sounds fine. Just like "inout" and "borrowed" were adequate placeholders for a while. 🐨

Said another way: it probably makes more sense to design linear types iteratively rather than trying to predict everybody's needs ahead-of-time, with zero usage data. At the very least, I personally can't plan that far ahead.

To me, [using __unsafe_force_destroy] is an assertion that you have both upheld the invariants of the linear type and that you have decided, for whatever reason, that you know better than the author of the library. To me, that is a strong assertion to make, and not one that should be made lightly.

+1. This is definitely a feature that could be a source of disastrous bugs if used without careful consideration. We don't want to teach this as a "solution" that you should reach for when you're struggling with linear types.

@soraros
Copy link
Contributor

soraros commented Dec 18, 2024

... I think conforming to Droppable should be opt-in ...

Agreed. I believe we need mutually exclusive traits to avoid impossible diamonds. This approach would also be beneficial for Trivial.


I don't think we should delve into discussions about naming or teachability just yet. We could start with reasonably acceptable placeholders (__force_destroy, __del__, etc.) for now. The resyntaxing of ref demonstrated two key lessons: 1) it's largely futile until a more complete model is available for hands-on testing, and 2) semantics are significantly more important anyway.

@nmsmith
Copy link
Contributor

nmsmith commented Dec 19, 2024

Yeah, IMO the resyntaxing thread is a big lesson on "don't try to finalize the syntax of something until you've figured out all of the places it will be used in practice".

You can spend an hour coming up with the perfect name for something on the assumption that it's going to be used in a certain way, and then find out weeks or months later that actually, it's going to be used a different way, and now that name no longer works well.

That's not to say that we shouldn't talk about syntax. I think we should just be careful about how long we bikeshed it for.

@nmsmith
Copy link
Contributor

nmsmith commented Dec 20, 2024

At the risk of being hypocritical—given my last comment—I'll share another thought on names that I just had.

This proposal is currently entitled as one of:

  • linear types
  • explicitly destroyed types

The former name is obviously "academic" and we probably all agree that this is not how we should describe this proposal to others.

The latter name is actually a bit misleading IMO, because this proposal is really about being able to define a data type that doesn't have a __del__ method. That doesn't make the data type "explicitly destroyed". An obvious counter-example is a data type where none of its methods invoke the destroy statement. Such a data type would be indestructible. In short: a data type without __del__ can be explicitly destructible or indestructible.

Given the above, I'd say this proposal is really about introducing support for "undroppable types". Notice I'm using my earlier-proposed "drop" terminology here. It's more accurate than "undeletable types", which is what the "del" terminology would lead us to.

@nmsmith
Copy link
Contributor

nmsmith commented Dec 20, 2024

We should also investigate how trivial destructibility (the ability to get rid of a value without invoking __drop__ on it) fits into this whole story.

Maybe we need both "droppable" and "forgettable" types.

Maybe this will affect naming too. Perhaps "discardable" and "forgettable" is a better combination? The former adjective implies that you actually have to take an action (__discard__) in order to get rid of the value, whereas the latter implies that you can just forget the value ever existed in the first place.

@rd4com
Copy link
Contributor

rd4com commented Dec 20, 2024

One way to think about it is also as an @explicitly_movable type,
because __del__ is just done by ASAP on last use 👍

@owenhilyard
Copy link
Contributor

This proposal is currently entitled as one of:

* linear types

* explicitly destroyed types

The former name is obviously "academic" and we probably all agree that this is not how we should describe this proposal to others.

But, "Linear Types" leads you directly to a bunch of high-quality introductions (Including a lot of stuff by @VerdagonModular). I don't think we need to go full "a monad is a monoid in the category of endofunctors", but for people coming from C, or older versions of C++ (since many students are taught pre-RAII C++), almost all types are "explicitly destroyed". I think that using the academic name may be a good idea here because either you already know what it is, or you go "what's that?" and have to read the introduction in the "Mojo Book", where we can far better explain them than a simple label.

Given the above, I'd say this proposal is really about introducing support for "undroppable types". Notice I'm using my earlier-proposed "drop" terminology here. It's more accurate than "undeletable types", which is what the "del" terminology would lead us to.

While "undroppable" makes sense to those of us from RAII backgrounds, I'm not sure it makes sense to people from GC language backgrounds, which to me means that we've opted into both less precise wording which may cause confusion among those who think they know what it means (ex: "must leak" types) and a major target audience still having no idea what we mean, which to me is the worst of both worlds.

@owenhilyard
Copy link
Contributor

owenhilyard commented Dec 20, 2024

... I think conforming to Droppable should be opt-in ...

Agreed. I believe we need mutually exclusive traits to avoid impossible diamonds. This approach would also be beneficial for Trivial.

Which traits are you suggesting should be mutually exclusive with Trivial? To me, trivial means __copyinit__ is equivalent to memcpy, __moveinit__ is equivalent to memmove, and __del__ is a noop. I think that Trivial should still be Droppable, otherwise we end up with a large headache. Ideally, I'd like TrivialCopy, TrivialMove and TrivialDestroy traits, with Trivial = TrivialCopy + TrivialMove + TrivialDestory. That way something like Arc can be TrivialMove. I can't think of other examples easily, but I'd rather have "fundamental concept" traits and then compose something that most people use for their types, so that library authors only require the behavior they actually need.

"trivial" as a descriptor for things like this is deep enough in the systems programming lexicon that I don't think changing the words but meaning the same thing is a good idea, unless there are some weird warts on how C++ defines it.

@owenhilyard
Copy link
Contributor

+1 to pretty much everything you said. And thanks for bringing up how greppable unsafe is, we really should capture that benefit in Mojo. Your idea of having a compiler mode that can find all instances of unsafe constructs sounds promising, especially since it gives us more syntactical design freedom than including the term "unsafe" in every unsafe construct.

We can also separate this out a bit, so it's less of a blunt hammer than Rust has. There's a difference between "using printf is technically a race condition" unsafe (changing the locale while a function is in printf can cause problems), "you can violate the guarantees of linear types with this" unsafe and "this function takes the reference you gave it, goes 5 bytes past the referree, casts to a function pointer and calls it" unsafe.

I think that, with the right messaging, users can be brought around to the idea of doing a lot of their debugging before the compiler lets them run their program ("It compiles it works"). This may be a philosophical difference between me and other people, but I generally prefer to have a fight with the borrow checker over a call at 2am.

Perhaps. Swift and Typescript are successfully catching up to / overtaking their predecessors... but Rust hasn't had as much success w.r.t. C/C++. I tend to assume it's because Rust hasn't really convinced the mainstream to fight the borrow checker as much as we would have hoped. Perhaps their messaging was indeed a factor. Our target demographic (Python users) is slightly different, but any thoughts on their messaging, or how ours could be different, to make borrow checking (and linear types) more palatable?

I think Rust's main issue is that it has no easy migration path. It threw out OOP and there are many devs, especially those who entered the industry pre-2010, for whom OOP and "Clean Code" is how you stop a enterprise codebase from becoming a mess. Many large C++ codebases are such a tangled web of inheritance that moving to a language without that is nearly impossible. Mojo, in supporting both OOP and ADT, gives the "C with templates" people new things to work with, and gives C++ devs a place to go as far as familiar design patterns. Having to throw out 20+ years of design patterns is I think the hardest part about learning Rust for some people. So, some level of support for inheritance will help, but we also are grabbing a demographic which doesn't use OOP as heavily due to duck typing, so traits should make more sense to them. As far as the borrow checker, Mojo has a lot of the tools that Rust is just getting now, so the smarter borrow checker should help quite a bit. I think some "learning to love the borrow checker" docs, where we give examples of nasty race conditions or bugs that the borrow checker catches (iterator invalidation, pointer/reference instability when appending to a collection, launching a lambda with a reference to the local stack frame on another thread then returning, etc).

@nmsmith
Copy link
Contributor

nmsmith commented Dec 21, 2024

People subscribed to email notifications may have noticed me rambling about trivial types just now. Just ignore that. I've deleted those messages because they don't really have anything to do with Evan's proposal. 🙂

@nmsmith
Copy link
Contributor

nmsmith commented Dec 22, 2024

To follow-up:

On Discord, Owen and I discussed the need to have a trivial equivalent of the Copyable, Movable, and ImplicitlyDestructible traits. We agree that we need all six of these traits. The ImplicitlyDestructible trait is the centerpiece of this proposal, so we should make sure we settle on a name that is consistent with its trivial counterpart.

@owenhilyard
Copy link
Contributor

On Discord, Owen and I discussed the need to have a trivial equivalent of the Copyable, Movable, and ImplicitlyDestructible traits. We agree that we need all six of these traits. The ImplicitlyDestructible trait is the centerpiece of this proposal, so we should make sure we settle on a name that is consistent with its trivial counterpart.

And, TriviallyImplicitlyDestructable is getting close enough to "name soup" that we may want to re-evaluate names.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request mojo-repo Tag all issues with this label
Projects
None yet
Development

No branches or pull requests

6 participants