I also find the name confusing. In another programming language, deinit could as well mean to invoke the destructor, but in mojo it is exactly the opposite.
+1 to decorator
If we would stick to implicitly injecting code to disable destructor, as it is done currently, decorator is IMO much more reasonable as it emphasizes modifying behavior of decorated object while separated argument convention for this looks very magical.
I agree, and apologies for not explicitly saying it: deconstruct should only be usable from a method of that class. For now, outside users should not be able to deconstruct anything theyâd like.
An interesting potential I want to mention: I like the ComptimeArcPointer case that @owenhilyard mentioned. The case from my talk whereLiveEntityHandle can be destroyed by LiveEntityList is similar to that. I think @clattnerâs solution of having LiveEntityList call a private LiveEntityHandle::_destroy(self) method is reasonable, especially if long-term we find a way to make it so only LiveEntityList can call it, perhaps via friend or a simpler Java-style package access control. I think getting this aspect right, combined with good memory safety design, could make it so Mojoâs linear types enable memory-safe back-references, which would be a massive win for us and also unlock a lot of OO patterns. If anyone wants me to expound on that, let me know and I can start a different thread about it.
Maybe Iâm misunderstanding, but is that true of deinit? Iâm not seeing anything in the proposal that says that we can put fields back / reassemble an object when inside a deiniting method. I would assume it can, but not sure.
If it helps, the imperative deconstructstatement composes cleanly with the reassembling mechanic:
struct Spaceship:
var engine: Engine
var fuel: Int
...
fn __del__(var self):
deconstruct self # we now own the fields
self.engine^.explode()
self.engine = Engine("Warp Engine")
# At this point, `self` is whole again
... # presumably the user would deconstruct self again or throw self on a free-list or something
Itâs unusual, and I canât think of any cases off the top of my head for it. But itâs a good sign from the universe that this composes with / is decoupled from the putting-fields-back mechanism.
Actually, I take that back, I might know some cases. I vaguely recall times in the Google Chat android app where a Fragment/Viewâs âdestructorâ extracts some fields, replaces them with âempty-ishâ values, and then threw this âready for reusingâ self onto a free-list (ask me how I know this, thatâs a fun story). I wouldnât be surprised if some virtual-dom-heavy web frameworks did this occasionally as well. The fact that deconstruct doesnât interfere with reassembling could be a good thing.
Composability, huzzah!
That would be great, that would save me some time (to implement more features!) and give us something concrete to compare to the deinit proposal. No rush, I know your job takes a lot of your time as well. Thanks Nick!
We can already move fields away without this keyword, we just need to put them back so that
xis in a properly initialized state at the end of the function. We can explain this to users this way:Maybe Iâm misunderstanding, but is that true of
deinit? Iâm not seeing anything in the proposal that says that we can put fields back / reassemble an object when inside adeiniting method. I would assume it can, but not sure.
What Iâm trying to say is that in a normal function, you can move a field away on a mut self if you put something back before the function ends. Thus, the ability to take ownership of fields isnât granted by deinit or deconstruct. Now, consider the following code:
@explicit_destroy
@fieldwise_init
struct WarpCore(Movable, Copyable):
var id: UInt
@fieldwise_init
struct Engine(Movable):
var id: UInt
var warp_cores: List[WarpCore]
fn __del__(deinit self):
try:
print("Engine {} destroyed".format(self.id))
print("{} remaining warp cores".format(len(self.warp_cores)))
except:
pass
fn print_id(self):
print("Engine ID:", self.id)
@fieldwise_init
struct Spaceship(Movable):
var engine: Engine
var fuel: Int
var used_warp_core_containment: List[WarpCore]
fn engine_upgrade(mut self, var engine: Engine):
var old_engine = self.engine^
# disassemble old engine by removing warp cores
for _ in range(len(old_engine.warp_cores)):
self.used_warp_core_containment.append(old_engine.warp_cores.pop())
# old_engine is now inert
_ = List[WarpCore].__moveinit__(old_engine.warp_cores^)
# old_engine.print_id()
self.engine = engine^
When compiled, the compiler complains that field 'old_engine.warp_cores' destroyed out of the middle of a value, preventing the overall value from being destroyed. deinit/deconstruct gives us the ability to make that error go away, which means theyâre telling the compiler to not call old_engine.__del__(), and not trying to call a function with a partially uninitialized value stops the error. If we donât want __del__ to be special, it should conform to the same rules as other functions in that regard, and should give the same use of uninitialized value 'old_engine.warp_cores' that uncommenting old_engine.print_id() does.
I think that for a deconstruct statement, the following works well as implementation pseudocode:
fn deconstruct(var x):
@parameter
for field in x.fields: # owning iterator of magic static reflection values
@parameter
if field.is_init():
field^.__del__() # destroy the field
__disable_del x
This also means that, depending on how far we push static reflection and macros in the language, we might eventually be able to lift deconstruct out of the compiler.
If we want to go the âdeconstruct x gives you ownership of the fields of xâ route with a deconstruct statement, where deconstruct x is done before you start moving away fields, I think we need to determine semantics for how ownership transfer without a move work first, since we canât move the fields of x or else you canât have a linear type with an atomic variable in it.
I think that Iâm moving away from liking the declarative version as I think more about how this will be implemented.
I donât see why it is âbetterâ for the position to matter. You can already move the fields away. There isnât anything gained by such approach IMO.
I agree that the declarative design cuts off stuff like this, but I consider that to be a feature, not a bug. It provides a simple funnel point that is a dual to initializers.
Yes, I can see this concern, this is also pushed by wanting to make this terse and feel âargument convention likeâ even though it really isnât (as many folks have pointed out).
New proposed approach
Summarizing the feedback Iâm hearing:
- there are some people that like the expressive capability of a statement - I remain very unmotivated by this. I would rather have a simple feature that is intentionally limited that is easy to explain, and I would like to tie it into âthis method is a destructorâ in a declarative way.
- No one likes confusing this into an argument convention, but there is support for a decorator.
- I want this to be simple and explainable, and low boilerplate.
How about this as a new direction:
-
We change this to be a decorator with a long name, e.g.
@destructorwhich has the same semantics as currently implemented. These methods are required to take avar self. -
You can therefore implement ânamed destructorsâ as well as implicit destructors using this decorator:
struct YourType:
@destructor
fn explode(var self, how_fast: Int): ...
@destructor
fn __del__(var self): ...
-
I donât like the boilerplate of adding this to every
__del__- so letâs just make that implicit. There is precedent for this in Python, where__new__is implicitly a static method without having to declare it as such. -
We extend the del handling to support transferring the entire object away even if marked as
@destructor. All together this would allow things like this to âjust workâ:
struct YourType:
@destructor
fn explode(var self, how_fast: Int): ...
fn __del__(var self):
self^.explode(42)
We would document this as a way to define ânamed destructorsâ and explain the behavior. This solves the argument confusion problem, and keeps it concise and limited - a dual to initializers.
Thoughts?
-Chris
This would have been a foot gun anyway. Is there a reason to recursively call the destructor. Destructors are already special, because they are automatically inserted.
I also favor the go approach with simple and orthogonal features. Even though I donât which feature go had chosen if it did not have garbage collector. However I remain convinced this resembles the spirit of go.
I am more satisfied with the name destructor. Naming is hard, if you negate the name, it becomes clearer but is this also more concise? Nevertheless destructor is a proper noun, thus there is less room for interpretation and there are only 2 outcomes.
-Dominic
I like this design a lot better than deinit! That said, I think the deconstruct statement might have some significant advantages, so I will write it up as a formal proposal so that we can compare it against @destructor.
While we wait for that, here is my feedback your new design.
Firstly, âdestructorâ isnât the right term, is it? Is __moveinit__ a destructor? Maybe @deconstruct would be a more accurate name. The feature weâre talking about is the phenomenon of âtaking apartâ an object piece-by-piece, emptying out its memory. __moveinit__ doesnât really destroy anything, it just dismantles an object, salvaging the pieces to build another object.
In fact, I recall that Modular is considering replacing __moveinit__ and __copyinit__ with overloads of __init__. In that case, __moveinit__ is actually a constructor! It would certainly be strange to annotate a constructor with the word @destructor. ![]()
I disagree. How often does a programmer write a custom destructor implementation? This will be very rare outside of the standard library, so I donât see the benefit to leaving out the decorator for __del__. Iâd much rather have a design that is uniform.
I donât think we should be creating the unnecessary burden of requiring every Mojo tutorial to explain how __del__ is magical/different in this respect.
Agreed. Iâm surprised to hear that this doesnât already work.
Againâhow would we explain __moveinit__?
Yes
C.L and here is an simple idea to solve moveinit:
@destructor applies to every var arg of type Self of an method.
struct MyType:
fn __moveinit__(init self, var other: Self):
self.ptr = other^.scavage_pointer()
@destructor
fn scavage_pointer(var self) -> UnsafePointer[Int]):
return self.ptr
struct SpaceShip:
@destructor
fn scavage_booster(var self, out booster: Booster):
# This method safely scavage the booster
# it is an complicated thing, so this method handle that.
self.hull^.jettison() # jettison hull
booster = self.booster^ # extract booster
self.field1^.__del__() # move this one here
How do we explain __moveinit__
(Here is an attempt Nick)
What is __moveinit__ ?
It is just an constructor.
This constructors takes the arg other as var (owned value).
Why?
Instead of doing an copy of the data like __copyinit__,
having the ownership makes it possible to move the ownership of the data.
What is ownership ?
An owner is responsible for calling the destructor on the data,
this is why an change of ownership involve an custom destructor.
The argument other can go trough an scavaging destructor instead of the default __del__.
An custom destructor method can be created with @destructor decorator.
For example, the default destructor of an type (__del__) would free an pointer.
And an custom scavaging destructor would return that pointer but not free it.
This is how we moved the ownership of the data, instead of moving the data.
After the move, the previous owner did not free the data,
and the new owner have still an default destructor that will free the data.
This move tie in with the origin system:
Any existing pointer to the previous owner are not valid to dereference anymore,
since the new owner might have destructed it already.
I believe we have retread all this ground many times over at this point. I am not convinced at all, but feel free to write up your thoughts, it could be a useful way to frame the argument.
Yes, moveinit can be a named destructor, even though it is also a constructor. When used with the @destructor decorator, move init destroys the value it runs on, and constructs a new value. It is both a destructor of the old value and a constructor of the new value.
That said, moveinit doesnât /have/ to destruct the value, being âvarâ is required, but @destructor is optional. When you donât use the @destructor decorator, Mojo will run the implicit destructor for you.
As we discussed upthread, while many people donât think about it this way, moveinit acts as a method on an existing value - the design is consistent.
-Chris
While I disagree with your conclusions, there is something I like about your proposals, Chris. They have a certain ergonomic simplicity. They work for as high as 90-99% of cases without complications. You can explain how they should use it, and they can just use it and continue on with their lives. You have really high standards for this aspect, which is good because we donât want users to swim in complexity that they shouldnât have to care about. @destructor is really nice on these dimensions.
Iâll explain what I value about deconstruct:
deconstructis decoupled from unrelated features.deinitraises questions about how it overlaps withvar, how it interferes with the reassembling mechanic, and about how it affects the callsite (we know it doesnât, but the question is raised).@destructorraises some of those questions, plus one about othervararguments.deconstructis composable. The user can deconstruct an object, move a field away, reinitialize the field, and toss the new object onto a free-list. Itâs unusual, but hey, we donât judge.deconstructbuilds on what the user already knows. They know about moving, and thatâs all this is.- âŚwhich also makes it easy to explain. âIt takes ownership of
self, and gives you ownership of its fields.â deconstructgives the user has more freedom. Not as an absolute, but when the user can use your building blocks in new creative ways, thatâs a nice thing. Especially because weâre a systems programming language.- Itâs precise and flexible. We could use it to destruct non-
selfarguments in theory.@destructorappears to cut that option off. Maybe we want that, maybe we donât, but that@destructorforces our hand is a hint from the universe that we should hesitate. - It reveals a truth to the user, that a âdestructorâ is just a composition of more fundamental concepts:
- A function thatâs automatically called when something goes out of scope (
__del__) - The choice to use
deconstructonself. - (This is also the truth that completely solves the
__moveinit__question)
- A function thatâs automatically called when something goes out of scope (
Thatâs simplicity.
Itâs a âfundamental simplicityâ as opposed to @destructorâs âergonomic simplicityâ, in my terms.
If we had to choose between the two, I personally would choose the former every time. Fundamentally solid big rocks first, after all.
That said, maybe we donât have to choose. Iâd love to make deconstruct more ergonomic. Some promising angles:
- Error messages will help, and be easy to code.
- 90%+ (guessing) of destructors are auto-generated, which is nice.
- An annotation for the common case perhaps.
That last item raises an interesting question: I wonder if we could have deconstruct, and then also offer @destructor as syntactic sugar to automatically deconstruct self at the right time. That way, most users can just use @destructor, and they can drop down to deconstruct when they want more freedom and power. It wouldnât cut off any options, it would give users more power, and it would be ergonomically simple for the 90-99% of cases that donât care about the power.
@clattner I think I might be able to replace the deconstruct statement with a solution that is entirely library-defined, so that destruction doesnât require any new syntax.
The vague idea is for all structs to have a private method named self^.contents_ptr() that surrenders ownership of the object in exchange for an exclusive pointer to the contents of the object. Once you have that, you can move the data out field-by-field. This pointer type would be defined on top of a variant of lit.ref that does fieldwise destruction of the remaining fields when it is dropped. In other words, we would offer the equivalent of deinit through an MLIR type, rather than making it an argument convention.
Example:
struct Foo:
var x: String
var y: String
fn __moveinit__(out self, var other):
var c = other^.contents_ptr()
self.x = c[].x^
self.y = c[].y^
Notes:
- If we wanted to avoid the
[]boilerplate, we could either replace the pointer with a second-classref, or we could add âauto-derefâ support for pointers, like in Rust. This would allow us to writeself.x = c.x^. - This design would need to tie into Mojoâs origin systemâwe need to prevent the use of
otherwhile the pointercis live. (We can probably use âindirect originsâ to model this constraint.) - The compiler needs to be able to keep track of the initialization state of the pointeeâs fields. I address this concern at the end of my post.
contents_ptrwould be defined onAnyType, and abuse of this method can be prevented by making it private. (While we wait for Mojo to add privacy, we can prefix it with__unsafe_.)
I need to spend more time on this design, but I think it could be the simplest design yet. It means that the concept of destruction would be a library feature built on top of lit.ref, rather than being baked into the language. All else equal, a âsmaller Mojoâ is better!
Furthermore, this design would allow the Mojo community to invent new destruction approaches, all built on top of a modified lit.ref. This is better than forcing Mojo users into one specific design pattern for value destruction, with its associated limitations.
Prerequisites
Note: If we define other^.contents() to return a second class -> ref, rather than a first-class pointer, the following feature is not technically required.
Required feature (?):
This design requires the ability to support transferring (^) fields out of a pointerâs target. (Both for Pointer, and this new âcontents pointerâ.)
This is a feature that Mojo needs anyway IMO, and I believe I know a means of achieving this via tweaking the definition of Pointer. In short: the internal lit.ref should be stored in a field named __item__, and the p[] syntax should desugar to something like __get_address_as_lvalue(p.__item__). This lets the compiler associate the pointee with a specific object, and therefore track the initialization state of the pointeeâs fields. The compiler would need to forbid the mutation of __item__ (i.e. the address) within the window where one or more of the fields are uninitialized.
I just had a chance to read Mojoâs new vision doc and roadmap, and I must say, I agree wholeheartedly with the perspective on language design. This is the perspective Iâve been holding while trying to find a âcleanâ design for destructors.
Vision doc:
- Emphasize composability and simplicity: Every Mojo feature must work reliably in all situations and combine seamlessly with other features (compose orthogonally). Weâre not satisfied with features that work 80% of the time but fail in edge cases.
- Defer syntactic sugar: Language sugar is often tempting, but we prioritize core âbig rocksâ first. Only once the fundamentals are solid do we revisit syntactic enhancements.
Roadmap:
we must resist the urge to chase short-term wins at the expense of long-term clarity, consistency, and quality.
Attribute macros: Replacing ad-hoc constructs like
@register_passable("trivial"),@nonmaterializable,@value, etc., using traits and other existing language features (shrinking the language).
This last quote is especially relevant. I donât feel comfortable with the idea of adding a new decorator to manipulate the behaviour of functions that take var self. Itâs too magical IMO. Value destruction isnât conceptually complicated, so we should be able to model it using a small, clean, composable primitive.
This is why Iâm thinking we should introduce a variant of lit.ref (MLIR type) that does exactly what we want: treat its target as a raw bundle of fields that must be transferred away. We can then build ergonomic abstractions on top of this, using language features that already exist in Mojo! This allows us to avoid introducing a new syntax specifically for destructors.
This approach is consistent with the vision doc (âsyntactic sugar is tempting, but we prioritize the core language firstâ) and the roadmap (âreplacing ad-hoc constructsâ; âshrinking the languageâ).
I believe we have an opportunity to find a design thatâs small, simple, and beautiful.
deconstructbuilds on what the user already knows. They know about moving, and thatâs all this is.- âŚwhich also makes it easy to explain. âIt takes ownership of
self, and gives you ownership of its fields.â
To me, this implies that deconstruct moves the fields. Iâm strongly against that aspect if thatâs what you intended because it would force using escape hatches to destroy anything with an atomic in it.
Itâs precise and flexible. We could use it to destruct non-
selfarguments in theory.@destructorappears to cut that option off. Maybe we want that, maybe we donât, but that@destructorforces our hand is a hint from the universe that we should hesitate.
I agree, I can envision a syntax for non-self destruction with the decorator, but I think that it might be more confusing than deconstruct. ex:
@destructor("other")
fn deconstruct_both_args(var self, var other: Self):
...
To the uninformed reader, this seems like it deconstructs only other, but it implicitly deconstructs self. if it doesnât, then you have to write @deconstruct("self", "other"). To me, this feels like no matter what itâs special casing some behavior for destructors.
What I could see as a middle ground approach, if we make use of a terminal deconstruct x where it destroys the remaining fields in x, is to make @destructor a shorthand for "stick deconstruct self at the last line before every return statement in the function and before the function terminates. That keeps the ânormal data typeâ path nice and short, and then if you want any other behavior then an LSP action to âexpandâ the decorator can add all of the deconstruct self instances and then you make your additions from there. It also keeps the implementation of the decorator simple.
This means that the default destructor would be:
@destructor
fn __del__(var self):
pass
Which expands to:
fn __del__(var self):
deconstruct self
Which, once we have some sort of static reflection capabilities in the language, can expand to (made-up syntax):
fn __del__(var self):
for field in reflect.fields(self):
if field.is_init(): # only there for more complex cases
reflect.get_owned(self, field)^.__del__()
__disable_del self #or unsafe replacement
Which removes any âcompiler magicâ from the process and provides an easy pseudocode explanation for what deconstruct does. This whole chain also means that, once users can write decorators, they can write a different decorator if they want different behavior instead of having deconstruct be a special macro that changes the compilerâs rules.
I think this lines up with what youâre asking for in your last paragraph, but it does involve slightly changing what deconstruct does.
edit: After talking with Nick more, the transfer sigil should work for this, Iâve run into a lot of things around immovable types that made me thing it wouldnât.
I like the change in direction towards a decorator instead of âmuddying the waterâ with no-op argument convention changes.
I like the ideas of trying to make deinitialization be able to be done field-wise (and possibly reinitialized before the end of the scope), I never quite understood why deinit is limited to Self arguments.
If we can add more traits like AnyType that are automatically inherited with reflection and different destruction mechanics (or create decorators that insert the code), that is also much better IMO than hard-coding behavior in the compiler.
Just a quick example (with invented syntax) to give an idea of why being able to âdeconstructâ other arguments might be valuable:
fn some_process(
replace_val: List[Int], data: Dict[String, List[Int]], out removed: List[Int]
) raises:
for read key, destruct mut value in data.items():
if key == "some specific key":
removed = value^
# value is deinitialized, has to be reinitialized before exiting the scope
value = replace_val
return
raise Error("key not found")
My only question with using a decorator is this: how does it fit into a future where the constructors are consolidated into just an __init__?
The behaviour shown in your example should work for ordinary refs that point to mutable data. Itâs not a feature that would/should be associated with destructors.
@clattner It seems Mojo doesnât support moving out of local refs yet. I assume this is planned at some point? For example:
words: List[String] = ["one", "two", "three"]
for ref word in words:
foo(word^)
word = "empty"
To make this safe, we would need to reason that word and words alias, and therefore the latter object should not be readable during the window where the former object is deinitialized.
I think the idea is for the move constructor to be declared as follows:
fn Thing:
var x: Int
@destructor
fn __init__(out self, *, move: Self):
self.x = move.x^
Here, the @destructor decorator is acting on the second argument, because itâs the first ânon-out argumentâ.
I find this very unintuitive, but I believe thatâs what the proposal is.
No, I donât expect ownership transferring refâs to be a thing, at least in the foreseeable future. It is possible we can add them some point in the distant future, but Iâd want roughly-everything-else to be nailed down first, so it wouldnât be for quite some time.
The way we can support that is with a destroying iterator, e.g.:
for var elt in words^.consume_elements():
Such an iterator would yield (by consuming move) the elements that are produced, and would destroy any unconsumed elements (eg due to a break out of the loop). The result is that the entire collection is consumed by such an iterator.
-Chris
In the text you quoted I didnât mean ownership transferring refs just FYI. I meant being able to temporarily move out of a local ref, as long as you put the value back again. That doesnât seem to work in todayâs mojo, despite it working for args.
Oh yes, sorry for the confusion - I could see supporting that, so long as you move something back into it.