modularml / mojo

The Mojo Programming Language
https://docs.modular.com/mojo/manual/
Other
23.3k stars 2.59k forks source link

[Docs] The "transfer operator" `^` is incorrectly described as an operator #2326

Open nmsmith opened 7 months ago

nmsmith commented 7 months ago

Where is the problem?

https://docs.modular.com/mojo/manual/values/ownership#transfer-arguments-owned-and-

What can we do better?

Throughout the Mojo docs, the postfix ^ sigil is described as an operator. But I believe that most programmers who understand the purpose of this sigil would be reluctant to describe it as such. For example, when this sigil is placed next to a variable being passed as an argument, e.g. foo(x^), it doesn't trigger an operation, i.e. the execution of code. Instead, it just indicates that foo will deinitialize the variable. (In other words, foo is doing the work, not the ^ sigil.)

^ only implies an operation when it is used in statements of the form <expr> = <expr>^. In this case, it usually triggers a move operation, i.e. the invocation of __moveinit__.

^ also has a secondary use as an "operation suppressor", stopping a Copyable variable from being copied when the variable is used as an owned argument.

I have seen several Mojo users confused by the ^ sigil. IMO this is caused by a combination of things:

I suggest referring to ^ as the "transfer marker" or "transfer sigil". At the very least, this will prevent people from conflating it with moving, which is an operation. There isn't a strong correlation between moving and transferring: ^ won't necessarily trigger a move (and will never trigger a move for a non-movable type), and moving doesn't necessarily require the use of ^. (Because the compiler often optimizes copies into moves.)

Update: There's been some discussion around renaming owned to consumed. If that happens, it would make sense to call ^ the "consumption marker" or "consumption sigil". This allows us to avoid the term "transfer", and will prevent people making false connections to the concept of "moving".

Also, the ^ operator is pronounced "carrot", and carrots are consumable. 🤯

melodyogonna commented 7 months ago

It doesn't just clarify that the lifetime of an object is going to end, it does a transfer operation. That is, it transfers the ownership of the object. The object's lifetime did not end, it just is no longer usable in the current scope.

nmsmith commented 7 months ago

it does a transfer operation

There's no such thing as a transfer operation. No code needs to be executed to transfer ownership. It's a concept that only exists for the purpose of type checking, and to decide whether a destructor needs to be called at some point.

it transfers the ownership of the object

Mojo doesn't really have a notion of "object" right now. It just has "variables" and "values".

melodyogonna commented 7 months ago

When does moveinit get executed?

nmsmith commented 7 months ago

Moveinit gets executed when the right-hand side of an assignment statement is a variable or field name followed by the ^ sigil. For example y = x^. (However, such a move can often be eliminated during an optimization pass, so there's no guarantee that it will happen.)

Moveinit doesn’t get executed when ^ is used next to an argument, e.g. y = f(x^).

soraros commented 7 months ago

I think the problem is that you insist on using a very specific definition for 'operation', and 'ownership transformation' just happens not to be an "operation" according to that definition. As counter examples, the unary + for Int also "does nothing", and the identity function is still a function.

^ only implies an operation when it is used in statements of the form <expr> = <expr>^. In this case, it signals a move operation. (i.e. the invocation of __moveinit__.)

This is wrong (see example). The documentation made it very clear that "ownership transfer" and "move" are different things, and __moveinit__ being called in b = a^ is merely a implementation detail. I think we should be able to optimise the move away. I actually think we should be able to optimise the move away in simply cases like var b = a^, and tying __moveinit__ to ownership transfer will prevent us from doing that.

fn main():
    var a = S()
    var b = a^  # __copyinit__
    print(b.value)

struct S:
    var value: Int
    fn __init__(inout self):
        self.value = 0
    fn __copyinit__(inout self, other: Self):
        self.value = other.value
        print("__copyinit__")

In summary, I don't find the semantics of the transfer operator ^ itself confusing at all, and I don't think calling ^ the transfer sigil helps clarifying anything. That said, I do think we could do a better job in explaining when how the implementation works under the hood.

nmsmith commented 7 months ago

the unary + for Int also "does nothing"

Unary ^ is not the same kind of phenomenon as unary +. The latter is syntactic sugar for a call to the __pos__ method. If __pos__ has not been defined, then (in Mojo) + cannot be used. So conceptually, + executes a piece of code. Yes, somebody could define + as the identity function, and then "not much" code would need to be executed (you'd still need to copy the value), and potentially the compiler could optimize it away. But that's not the same thing as ^, because in the context of f(x^), the sigil always does nothing, at least in today's Mojo. It does not invoke a dunder method, and nothing is being optimized away.

This is wrong [...] moveinit being called in b = a^ is merely a implementation detail.

I'd say my statement is mostly correct (not merely "wrong"), but yes, you're right, b = a^ appears to invoke __copyinit__ as a "backup" if a type neglects to define __moveinit__. (This is an uncommon scenario, because by virtue of @value, almost all data types that implement copyinit will implement both.) If you define both methods, it always calls __moveinit__, and if you define neither method, the statement is rejected by the compiler.

Because the statement is rejected, the fact that it invokes one of these methods is not an implementation detail. The presence of these methods is essential to the meaning of the statement. This is in contrast to expressions such as f(x^). This expression is valid even if x is non-copyable and non-movable.

Regardless, this is all nitpicking. Broadly, you seem to agree with my contention: unary ^ doesn't correspond to any particular code being executed. In some uses, it might invoke certain dunder methods, and in other uses, it will never invoke a dunder method, nor will it execute any other code—not even an identity function.

Given this, it doesn't really make sense to call ^ an operator, in my opinion.

soraros commented 7 months ago