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

[Discuss] Resyntaxing argument conventions and References #3623

Open
lattner opened this issue Oct 7, 2024 · 173 comments
Open

[Discuss] Resyntaxing argument conventions and References #3623

lattner opened this issue Oct 7, 2024 · 173 comments

Comments

@lattner
Copy link
Collaborator

lattner commented Oct 7, 2024

The design of the Mojo references subsystem is starting to come together. To finalize the major points, it helps to come back and re-evaluate several early decisions in Mojo to make the design more self consistent. This is a proposal to gain consensus and alignment on naming topics, etc without diving deep into the overall design (which is settling out).

Instead of embedding this inline, I added this to a github gist here:
https://gist.github.com/lattner/da647146ea573902782525f3446829ff

I'd love feedback and discussion!

@carolinefrasca carolinefrasca pinned this issue Oct 7, 2024
@helehex
Copy link
Contributor

helehex commented Oct 8, 2024

I'm ok with any of the proposed alternatives as long as they are self-consistent, so i don't have much to say with that.
The only thing that came to mind was using the term 'exclusivity sigil' for what is currently called the transfer operator.

@martinvuyk
Copy link
Contributor

martinvuyk commented Oct 8, 2024

Hi, thanks for sharing the process :) . Here are my thoughts as a layman that loves this intuitive and pretty language:

I'll start with the bike-shedding part as you called it 😆

  • immref: I'll close my eyes and pretend it doesn't exist. I can't think of an alternative though 🤷‍♂️
  • mutref: nice 👍
  • fn __init__(out self): clears some confusion and I like folding both named returned values and constructors in one simple keyword and concept, IMO the less unnecessary complexity the better 👍
  • owned: It is not a clear keyword, AFAIK you could kill a copy of the value if the compiler sees you using it after passing it to something that was supposed to destroy it. I can also own a copy of a website's html and deleting it in my machine does nothing to it...
  • ref [lifetime]: this is one where I am still absolutely confused.

I'd also like to take this opportunity to ask for the Python del keyword to be introduced instead of having to do _ = something^ which is cumbersome and unclear to people new to the language... oh yeah, I'll use the argument: "not really pythonic"


Now as for another of my crazy ideas as someone who has no formal training in CS and reading the dragonbook is all the knowledge I have about compilers:

Origin I think does not fully communicate the example given:

var s : String
# Not alive here.
s = "hello"
# alive here
use(s)
# destroyed here.
unrelated_stuff()
# ...
s = "goodbye"
# alive here
use(s)
# destroyed here

A word that is often used in monitoring is Liveness, and this looks to me like monitoring the liveness of a variable during a given scope

What I'd like is for the ref keyword to become the center of everything and the others nice shorthand for it's facets. Take everything I'll write here as unstructured ideas/pseudocode

# nothing to do with the former `Reference` now named `Pointer`, but rather `ref`
struct Ref[
    mutable: Bool,
    stable: Bool,
    scope: Scope,
    liveness: AnyLiveness = Liveness[mutable, stable, scope],
]:
    """A `ref`.

    Parameters:
        mutable: Well, mutability.
        stable: Whether the variable is stable in its assignment.
        scope: Where and when the ref gets destroyed. This would signal that this does not get
            consumed inside the current scope. Aditionally being able to set parameters like this
            would allow for manual liveness guarantees for unsafe types (will it ?). 
        liveness: What the liveness for the given scope is.
    """
    ... 

As such we wouldn't need to write a whole book to explain:

shorthand full representation
ref ref [_]
immref ref [mutable=False, stable=True, scope=Scope.Outer]
mutref ref [mutable=True, stable=True, scope=Scope.Outer]
consume ref [mutable=True, stable=False, scope=Scope.Inner]
out ref [mutable=True, stable=False, scope=Scope.Outer]
var/owned ref [mutable=True, stable=False, scope=Scope.Any]
const ref [mutable=False, stable=True, scope=Scope.Inner]

PS: This might not make much sense on the details, but my main point is that we should try to simplify the amount of concepts and keywords as much as possible.

@nmsmith
Copy link
Contributor

nmsmith commented Oct 8, 2024

Here's my take on things.

We need to consider the syntax for closure-capturing

The keywords that we're discussing will be used for multiple purposes. Chris has mentioned two purposes:

  • argument conventions
  • patterns

IMO there's another major language feature we've forgotten:

  • capture lists (for closures)

Mojo closures are still very much WIP, but at some point we'll need a syntax for explicitly declaring the set of nonlocal variables that a closure captures. For example:

var counter = 0
fn add(amount: Int) inout counter:  # capture counter "by reference"
    counter += amount

A syntax along these lines is necessary, because we need to enforce aliasing restrictions between arguments and captures. The set of captures affects the interface of a function (the arguments that you can pass to it), and we need a syntax that documents this.

  • Also, captured variables are lowered (compiled) to arguments, so it makes sense that we use the same syntax for declaring arguments and captures.

In short: whatever set of keywords we end up choosing should be suitable for declaring captures as well.

On the term "reference"

Mojo's first-class type for memory-safe referencing has been recently renamed to Pointer, so the term "reference" now has basically the same meaning as in C++: a "reference" is an identifier that denotes an existing variable.

Unfortunately, Python already has "references", and they have absolutely nothing to do with the C++ concept, nor the Mojo concept. In Python, a "reference" is a first-class value that points to an object, i.e. an instance of a class. This definition has been deeply ingrained in the Python community—in every tutorial, every book, and in every Python's programmer's lexicon—for 30+ years now. Accordingly, attempting to reclaim or extend the meaning of the term "reference" shouldn't be taken lightly. I anticipate that it will lead to a lot of confusion, especially for less experienced programmers.

Also, it's not possible to explain Mojo's proposed ref syntax by analogy with Python references, because they are different in fundamental ways. The biggest differences being:

  • Mojo "references" refer to variables, whereas Python references refer to objects. (And it's not possible to unify variables and objects into a single concept.)
  • Assigning to a Mojo "reference" overwrites the pointee, whereas assigning to a Python reference overwrites the reference.
  • Mojo "references" are second-class, whereas Python references are values. And there's no reason for Mojo references to be first-class—we have Pointer for that.

Alternatives to the term "reference"

Given that "reference" isn't an ideal term, it's worth considering whether there are any viable alternatives.

As mentioned earlier, a "reference" is basically a new identifier for an existing variable. A reference is certainly not an address or a pointer, because in Mojo, a register-passable variable will usually be passed to a function by value, in a register. In short: a Mojo "reference" doesn't correspond to any particular memory representation, so we should avoid choosing a term that implies that.

So, we need a term that means "a new identifier for an existing variable". It turns out there is already a well-established term for that: a new name for an old thing is an alias.

This term is very well-established. It's especially common in languages that enforce aliasing restrictions, such as Mojo and Rust. In fact, this is probably the biggest reason to describe arguments as "aliases" of variables—it fits so naturally into the topic of aliasing, which is going to be a big deal in the Mojo community.

Before we can contemplate referring to arguments as "aliases", there's a small issue that needs addressing: Mojo already has a keyword named alias, which is used to declare compile-time variables. Luckily, this keyword is officially a placeholder. If we can find a more suitable keyword for compile-time variables, then we can free up the concept of an "alias" for referring to new names for existing variables, which agrees with both the English definition of "alias" and the established meaning of "alias" in programming languages.

Note: I'm not necessarily saying that we should use the keyword alias as the new syntax for ref. I'm just saying that the concept of an alias feels much more suitable for Mojo's argument conventions than the concept of a reference, because Python has already defined "reference" to mean something else.

My keyword suggestions

As discussed earlier, we need keywords suitable for all of the following:

  • argument conventions
  • capture lists
  • patterns

For the first two use-cases, the main thing we want to communicate is whether the function intends to mutate an argument or capture, or merely read it.

Keywords appearing in function signatures would ideally be very short, because horizontal space is already at a high premium in function signatures. For mutable access to a variable, mut is basically the only keyword that is both short and sensible. For read-only access, the keyword that makes the most sense is read. It would be problematic to make this into a keyword, given how widely used the word is. But we could easily make this into a soft keyword that is only permitted in function signatures.

Here's what these keywords would look like:

var x: Int
var y: Int

fn add(read amount: Int) mut x, read y:
    x += amount
    print(x + y)

And obviously, read would be the implicit default for arguments.

Here's how we would describe this signature:

  • amount is an alias for a readable integer
  • x is a captured variable, that we intend to mutate
  • y is a captured variable, that we intend to read

This naming scheme fits well with out, because everything is a verb:

  • mut declares that we will mutate a variable's value
  • read declares that we will read a variable's value
  • out declares that we will output (i.e. initialize) a variable's value

Ideally owned would be renamed to a verb too, such as consume or destruct.

A keyword for "returning aliases", and for patterns

We haven't yet discussed the equivalent of Mojo's -> ref[] syntax, or the syntax for patterns.

For -> ref[], neither the keywords read or mut are suitable, because the mutability of the returned "reference" (or alias) depends upon the provided lifetime parameter.

For patterns, we also want a mutability-independent keyword, because the mutability of the variable that we're binding can always be determined from context, so there's no reason to declare the mutability explicitly.

Here are my top 3 suggestions for mutability-independent keywords:

  • alias
  • bind
  • use

The terms alias and bind both refer to the same thing: the introduction of a new identifier. "Bind" is the formal term for linking an identifier to an entity.

The term use is meant to convey "accessing a variable without any particular mutability". It is a deliberately less-specific version of read and mut.

Here are the keywords in action.

alias

# Define an alias for an element of a list (that we're subscripting)
alias node = nodes[current_id]

# Define an alias for an element of a list (that we're iterating over)
for alias node in nodes:
    print(node)

# Pattern match on a structure, defining aliases for its fields
match node:
    case Node(alias weight, alias edges):

# Accept a string (alias "arg"), and return a string (alias "foo(..)")
fn foo[origin: Origin](alias[origin] arg: String) -> alias[origin] String:
    return arg

Note that for -> alias syntax, the "alias" that we're talking about is the expression foo(x). That is, the expression foo(x) can be considered a name for an existing variable. (In the example, foo(x) is another name for x.)

Also, in case it's unclear, the alias[origin] syntax would only be used when you need to provide a custom lifetime parameter—it's just a replacement for today's ref[] syntax. If you don't need to specify the lifetime, then you would use the keywords read or mut.

bind

The idea behind this keyword is that instead of describing the thing we're declaring (an alias), we can declare action that we're talking (binding a name to a variable). To be clear: we'd still talk about "declaring aliases". The only difference is the keyword we're using.

# Define an alias for an element of a list (that we're subscripting)
bind node = nodes[current_id]

# Define an alias for an element of a list (that we're iterating over)
for bind node in nodes:
    print(node)

# Pattern match on a structure, defining aliases for its fields
match node:
    case Node(bind weight, bind edges):

# Accept a string (alias "arg"), and return a string (alias "foo(..)")
fn foo[origin: Origin](bind[origin] arg: String) -> bind[origin] String:
    return arg

use

This keyword also seems quite reasonable. In other languages, this keyword is used to declare imports, which also entails binding aliases.

# Define an alias for an element of a list (that we're subscripting)
use node = nodes[current_id]

# Define an alias for an element of a list (that we're iterating over)
for use node in nodes:
    print(node)

# Pattern match on a structure, defining aliases for its fields
match node:
    case Node(use weight, use edges):

# Accept a string (alias "arg"), and return a string (alias "foo(..)")
fn foo[origin: Origin](use[origin] arg: String) -> use[origin] String:
    return arg

Summary

These are major goals that I think we should aim for:

  1. Avoid clashing with the established concept of a "reference" in Python.
  2. Choose terminology that combines well with the concept of "aliasing" and "aliasing guarantees", which are a major topic in Mojo.

In my opinion, the keywords that I've suggested achieve these goals, while being self-explanatory and aesthetically-pleasing. (Although maybe alias is a bit verbose.)

That concludes my feedback. What does everyone else think?

@bgreni
Copy link
Contributor

bgreni commented Oct 8, 2024

I appreciate you sharing this with us. I personally like owned/borrowed, but upon reading this proposal I can appreciate your desire to distance Mojo from Rust. I've personally witnessed people criticize Mojo for not having a "real borrow checker" and avoiding Rust-associated verbiage would be a good step to communicating that "Python with a Rust-like borrow checker" is not a primary goal of the language (although I don't love immref sorry bikeshedding over).

Furthermore I realized my own mis-association with the "transfer operator", where due to the name and being a C++ dev by day, I imagined I was essentially using std::move, which isn't quite right. So while I wasn't actively confused by it, I didn't quite understand it either.

I highly look forward to the improvements to ref in general, again as a C++ developer where I can just slap & wherever I please, references have been a point of frustration for me with Mojo.

One point from me, however, and maybe I've missed something, but given the focus here on distinguishing between immutable and mutable references I'm confused why we previously did away with the let keyword to denote immutable values. I would think we would now be able to use it effectively to prevent accidental mutations of a particular value like below, but maybe I'm misunderstanding how this is intended to be used.

struct Foo:
    var a: Int

fn do_thing(mutref f: Foo):
    f.a = 10

var f1 = Foo(3)
let f2 = Foo(4)

do_thing(f1) # Ok
do_thing(f2) # Compiler yells at you

@nmsmith
Copy link
Contributor

nmsmith commented Oct 8, 2024

@bgreni

Given the focus here on distinguishing between immutable and mutable references I'm confused why we previously did away with the let keyword.

What it all comes down to is that it's crucial to know whether a function can mutate an argument—or memory accessible via an argument—because this affects the ability for the caller to pass in arguments that alias each other. Aliasing guarantees are essential for memory safety, data race freedom, optimization, and generally just reasoning about the correctness of a function.

In contrast, the let keyword doesn't provide any of those benefits. Its theoretical purpose is to prevent "accidental mutations" of a variable, but it's not clear that this is a real problem, i.e. a problem that introduces bugs in real-world programs.

I'm sure if somebody can provide strong evidence that let prevents real-world bugs, that would be sufficient for let to be re-added to Mojo.

But let's not have that discussion here, because that would be derailing this thread. 🙂

@soraros
Copy link
Contributor

soraros commented Oct 8, 2024

  • We could/should accelerate the migration to out ("the argument convention marker which is now called out), as it's semantic change/bug fix, which is orthogonal to the rest of the re-syntax.
  • Make the them soft keywords if possible.
  • I'd really want ref[...] instead of ref [...], so it's more consistent with other parametric things.
  • There are some subtlety w.r.t. immref/borrowed in that it's not really a short hand of ref[?ImmutableLifetime]. immref and mutref are of different "type"s. I think it's done this way because of late ABI lowering.
fn main():
  m = 0
  f(m, m)  # exclusivity violation
  g(m, m)  # pass

fn f[lt: ImmutableLifetime](inout a: Int, ref[lt] b: Int):
  pass

fn g(inout a: Int, b: Int):
  pass

@yetalit
Copy link

yetalit commented Oct 8, 2024

As an average programmer, I believe we should make pointer related operations close to C/C++ and keep anything in common with Python, pythonic. So,
immref feels close to const type & which is good👍
mutref feels close to type & which is good👍
using -> is more pythonic than out keyword, I think we should keep it❌
Thanks.

@mind6
Copy link

mind6 commented Oct 8, 2024

Seems that many Mojo adopters will be Python users with C++ background (like myself). Python's use of "reference" is somewhat different, but the keyword isn't in the language, words like "value" and "reference" are often casual concepts in Python. IMO, it's okay to reuse concepts from widely used C++, when it isn't part of what people think about when they think "Pythonic".

@nmsmith
Copy link
Contributor

nmsmith commented Oct 8, 2024

@mind6 Just to clarify: Mojo's concept of "reference" is not the same as C++. It subsumes C++'s pass-by-value and pass-by-reference.

If a C++ programmer attempts to use their mental model of how C++ references work when learning Mojo (e.g. to reason about efficiency), they are going to end up confused.

@mind6
Copy link

mind6 commented Oct 8, 2024

@mind6 Just to clarify: Mojo's concept of "reference" is not the same as C++. It subsumes C++'s pass-by-value and pass-by-reference.

If a C++ programmer relies upon their mental model of how C++ references work when learning Mojo, they are going to end up confused.

Do you mean passing ownership is like passing by value?
I liked the word "alias" for being the most precise. But I wasn't sure if there were benefits to learning a new mental habit, since Rust also uses "reference".

@nmsmith
Copy link
Contributor

nmsmith commented Oct 8, 2024

Do you mean passing ownership is like passing by value?

No, I mean passing as borrowed/inout (or immref/mutref in Chris's proposed syntax) is equivalent to passing by value, for types that are annotated as @register_passable.

Basically: in Mojo, "passing by reference" or "by ref" does not mean "passing an address". It means something highly abstract: the callee "receives access" to the variable through a new identifier/alias, and that access can either be facilitated by passing a copy of the variable's value in a register, or by passing the variable's address. The passing mechanism is decided by the type definition, not (just) the argument convention.

@dmitry-salin
Copy link

dmitry-salin commented Oct 8, 2024

In short: a Mojo "reference" doesn't correspond to any particular memory representation, so we should avoid choosing a term that implies that.

In fn f(ref[_] a: Type), a is always a memory address, even if Type is declared as @register_passable. ref can be renamed ptr to avoid conflict with Python's established concept of a "reference". ptr has parametric mutability.

In fn f(borrowed a: Type), a can be a value in a register if Type is declared as @register_passable. I think it's better to use imm (not immref), because with that default convention we don't care about the specific representation, only that the variable is immutable. The same logic can be applied to fn f(mut a: Type).

This way we can get 4 keywords of the same length:
ptr - address of a variable with parametric mutability (also is consistent with Pointer struct)
imm - immutable variable
mut - mutable variable
out - output slot for variable

  • mut declares that we will mutate a variable's value

I don't see the point in using verbs. mut means that the variable can be mutated because it is mutable. Whether we actually mutate the variable depends on the logic of the function, e.g. it could just be passed to another function depending on some condition.

Ideally owned would be renamed to a verb too, such as consume or destruct.

Whether owned is actually consumed also depends on the logic of the function and whether it was passed with the ^ sigil or not.

@nmsmith
Copy link
Contributor

nmsmith commented Oct 8, 2024

I think it's better to use imm (not immref), because with that default convention we don't care about the specific representation, only that the variable is immutable.

I roughly agree with this, but I think read (as a soft keyword) is more accurate, since we're not actually declaring that a variable is truly immutable. Instead, we're declaring our intention to read the variable's value.

Basically, a function only needs to declare what it does to the variable. It doesn't need to declare anything about the variable's inherent properties.

Whether owned is actually consumed also depends on the logic of the function and whether it was passed with the ^ sigil or not.

This concern was discussed in this thread. The callee always consumes (deinitializes) some variable. All the ^ sigil does is determine whether the variable appearing in the argument position is consumed, or if its value is copied to a temporary variable, and the temporary variable is consumed. Either way, something is consumed.

@dmitry-salin
Copy link

dmitry-salin commented Oct 8, 2024

All the ^ sigil does is determine whether the variable appearing in the argument position is consumed, or if its value is copied to a temporary variable, and the temporary variable is consumed.

Temporary means that a new variable is created rather than something being consumed at the function boundary. Something like an implicit out slot. A function signature with conventions describes what it is allowed to do. The function body describes what it actually does, and the actions are constrained by the signature contract.

@melodyogonna
Copy link

Have we considered replacing some of these keywords with operators (special characters)?

@gabrieldemarmiesse
Copy link
Contributor

gabrieldemarmiesse commented Oct 8, 2024

Thanks @lattner for the detailed proposal and asking community input!

I'll try to avoid as much bikeshedding as possible. I would like for us to improve the section called Patterns + Local reference bindings.

Coming from Python, I would like to surface a few surprising points. Following this, we can either change the proposal or just improve the examples and the description.

1 - Using String as an example in the code snippets is not great because it's immutable in python.

The problem

Because str is immutable in python, the following would works in both python and Mojo:

  for elt in my_list:
      elt += "foo"
# my_list is unchanged here

The list would be unchanged in both python and mojo but for very different reasons. In python, += is allowed to create another object, which is what happens here. In Mojo, an implicit copy is done when elt is created at each iteration.

Python programmers have general rules in their mind about how the language works, and what triggers a change in older variables and what doesn't.. The only exception to this rule are immutable types (int, str, datetime,...). Since they're an exception and can't be used to show the mutability of a value, let's use something else in the code examples.

I suggest using python's list and Mojo's List in code snippets because both are mutable and are well-known by the community. It will better reflect the behavior of both languages.

The new code snippets

Here are the code snippets reworked and should be intuitive to the average python programmer:

# Copy the elements of the list into elt (required for Python compat), like
# "for (auto elt : list)" in C++.
# list_of_lists has the type `List[List[String]]`
for current_list in list_of_lists:
     current_list.append("foo")

# Bind a mutable reference to elements in the list, like
# `for (auto &elt : mutlist)` in C++.
# This happens when `mutlist` yields mutable references.
for ref current_list in mutable_list_of_lists:
      current_list.append("foo")

# Bind an immutable reference to elements in the list, like
# `for (const auto &elt : immlist)` in C++
# This happens when `mutlist` yields immutable references.
for ref elt in mutlist:
     use(elt.field) # no need to copy elt to access a subfield.
     #elt += foo # This would be an error, can't mutate immutable reference.

As you can see the comment "(required for Python compat)" becomes quite funny because Python doesn't behave like that at all.

2 - Behavior difference in Mojo and Python

Let's focus on the first code snippet:

# Copy the elements of the list into elt (required for Python compat), like
# "for (auto elt : list)" in C++.
# list_of_lists has the type `List[List[String]]`
for current_list in list_of_lists:
     current_list.append("foo")

In Mojo then, if we print list_of_lists, we would end up with something like [[], []], in Python we would end up with [["foo"], ["foo"]] and no implicit copy would occur. This behavior would be extremely suprising for Python users and would lead them to either:

  1. Triggering implicit copies all the time if implicit copies are allowed in most types. In the worst case could be a performance/behavior footgun.
  2. Having additional syntax (elt.copy() or ref elt) in their for loops if implicit copies are disallowed in most types. They would have to make the choice and understand it, even if they just want to read the values. We're pushing complexity into users who never needed it then.

A similar issue is found later on in the document with var a = list[i] triggering a copy, and var (a1, a2) = list[i] triggering copies.
This can be seen in the following python example:

>>> a = [["hello"], ["world"]]
>>> a1, a2 = a
>>> a2.append("!!!")
>>> a
[['hello'], ['world', '!!!']]

In Python a copy is never triggered unless:

  1. The user calls .copy()
  2. The user uses immutable types like int or str and tries to modify them with += or something simiar.

Mojo also makes a point to make the first thing that users reach for, the right thing in most cases. As a python user then, I expect b = a[i] to not trigger a (maybe useless) copy, and if I want to trigger a copy, I can always do b = a[i].copy(). If Mojo could do the same, that would make adoption a lot easier for python users.

Conclusion and TL;DR

Python and Mojo are very different in this document, notably because the simplest syntax for a in some_list, a = b[i], a = b.c and a, b = some_list[i] all trigger implicit copies, which never happens in Python.
We should either:

  1. Make Mojo use references by default when nothing is specified and trigger copies when .copy() is called, like in python. This has the consequence of getting rid of ref in function bodies.
  2. Change the section Patterns + Local reference bindings to highlight this huge difference with Python and explain why implicit copies by default is necessary for the goals of the language.

I don't have enough knowledge to say which one of those solutions is the right one, I'll let the Modular team decide.

@ivellapillil
Copy link

ivellapillil commented Oct 8, 2024

I like nmsmith's point on naming. Basically we name the keywords after what we can do with the variable, rather than declaring what it is.

It seems that we are converging towards a realization that it is about access control of the variables instead of how it is passed around (the actual technique). In that sense, it makes sense to use access control terms such as read and write/modify. Even out can be considered as write-only. The owned keyword is more like permission to destruct and here I think consume is appropriate.

Anything that has modify permission needs exclusive access, that exclusivity is guaranteed by the compiler.

I think for most of the programmers, pivoting towards access control terms would be much more easier to understand than C++/Rust derived terminology.

@soraros
Copy link
Contributor

soraros commented Oct 8, 2024

ref can be renamed ptr to avoid conflict with Python's established concept of a "reference". ptr has parametric mutability.

Now, we are basically LLVM! I like this idea.

@jackos
Copy link
Collaborator

jackos commented Oct 8, 2024

ref [lifetime]: this is one where I am still absolutely confused.

Here's a simple example using the proposed syntax:

from memory import UnsafePointer

struct View[
    T: CollectionElement,
    origin: ImmutableOrigin,
]:
    var data: UnsafePointer[T]
    var len: Int

    # The origin is the `list` passed in on init
    fn __init__(out self, ref [origin] list: List[T, *_]):
        self.data = list.data
        self.len = len(list)

    # Return a single item that originates from the `list` passed in on init
    fn __getitem__(self, idx: Int) -> ref [origin] T:
        return self.data[idx]


fn main():
    strings = List[String]("a", "b")
    view = View(strings)

    # `a` and `b` originate from `strings`
    a = view[0]
    b = view[1]

    print(a, b)

    # No more variables that originate from `strings`, so it will now be destroyed

I'm a fan of Origin for intuitiveness and explainability, the concept has differences to Rust lifetimes and is much more simple (for example no complicated variance rules), so makes sense to make it a different word.

I agree with others immref is the only keyword that isn't great on first glance, but it's the default so you won't ever have to write this and shouldn't see it in any code.

@owenhilyard
Copy link
Contributor

I have a slightly orthoginal point, which is that I think that immref, mutref and ref [_] should be usable to declare what type of reference you want to give a function. Consider the following two functions:

fn do_op_with_list(immref foo: Mutex[List[Int]]):
  var guard: MutexGuard[Int, MutableOrigin] = foo.lock()
  guard[][0] += 1

fn do_op_with_list(mutref foo: Mutex[List[Int]]):
  var inner: List[Int] = foo.get_inner_mut()
  inner[0] += 1

Currently, these are treated as the same typeand cause type errors (if you treat immref as an alias for ref [ImmutableLifetime]), but there is a lot of use in being able to specialize on which of the two is used. The version where the user has a mutable reference to the mutex means that the mutex has no other references to it, so you can avoid all of the atomics safely. This can be extended to thread-safe collections to allow for the single-threaded initialization of a thread-safe collection to be much faster than it otherwise would be. This also enables things like filling a collection in parallel for a "map" step and then doing a single-threaded reduce without locks getting in the way. To enable this, I suggest the following syntax be usable for disambiguation:

var locked_list = Mutex(List(1,2,3,4,5))

do_op_with_list(mutref lockedlist)
do_op_with_list(immref lockedlist)
do_op_with_list(ref [_] lockedlist)

The actual final names of the various reference types don't matter, just that you can disambiguate in this way.
This means that ref is treated as a parametric type, and immref/mutref are disjoint subtypes, enabling function overloads on mutability. I don't know if defaulting to the "most powerful" reference type is ideal, but I foresee it causing frustration without disambiguation syntax in some corner cases. The current alternative (and something that would still work) is to do this:

fn do_op_with_list[is_mutable: Bool, //, lt: AnyLifetime[is_mutable]](ref [lt] foo: Mutex[List[Int]]):
  @parameter
  if is_mutable:
    var inner: List[Int] = foo.get_inner_mut()
    inner[0] += 1
  else:
    var guard: MutexGuard[Int, MutableOrigin] = foo.lock()
    guard[][0] += 1

While this is workable, having a top-level branch like this seems like a scenario ripe to be solved via function overloads. Keep in mind that there may also be multiple arguments where each will need to branch on mutability, which quickly will make this type of function a giant mess if you need to consider many objects, such as in an ECS, where this feature could be used to enable a single-threaded mode to avoid locking.

@ivellapillil
Copy link

ivellapillil commented Oct 8, 2024

ref [lifetime]: this is one where I am still absolutely confused.

Here's a simple example using the proposed syntax:

from memory import UnsafePointer

struct View[
    T: CollectionElement,
    origin: ImmutableOrigin,
]:
    var data: UnsafePointer[T]
    var len: Int

    # The origin is the `list` passed in on init
    fn __init__(out self, ref [origin] list: List[T, *_]):
        self.data = list.data
        self.len = len(list)

    # Return a single item that originates from the `list` passed in on init
    fn __getitem__(self, idx: Int) -> ref [origin] T:
        return self.data[idx]


fn main():
    strings = List[String]("a", "b")
    view = View(strings)

    # `a` and `b` originate from `strings`
    a = view[0]
    b = view[1]

    print(a, b)

    # No more variables that originate from `strings`, so it will now be destroyed

I'm a fan of Origin for intuitiveness and explainability, the concept has differences to Rust lifetimes and is much more simple (for example no complicated variance rules), so makes sense to make it a different word.

I agree with others immref is the only keyword that isn't great on first glance, but it's the default so you won't ever have to write this and shouldn't see it in any code.

I did not give it much of a thought, but: Can we just take plain Origin at struct-level and decide whether we need mutable or not at method level?

from memory import UnsafePointer

struct View[
    T: CollectionElement,
    origin: Origin, # <- Not an immutable/mutable
]:
    var data: UnsafePointer[T]
    var len: Int

    # The origin is the `list` passed in on init
    fn __init__(out self, immref [origin] list: List[T, *_]): # Immutability defined at this level
        self.data = list.data
        self.len = len(list)

    # Return a single item that originates from the `list` passed in on init
    fn __getitem__(self, idx: Int) -> immref [origin] T:
        return self.data[idx]


fn main():
    strings = List[String]("a", "b")
    view = View(strings)

    # `a` and `b` originate from `strings`
    a = view[0]
    b = view[1]

    print(a, b)

Or do we really need to track immutability/mutability at struct level?

With an "access control" concept it would look like:

fn __init__(write self, read [origin] list: List[T, *_]):

@mind6
Copy link

mind6 commented Oct 8, 2024

I would love if Mojo's default syntax kept Python's reference semantics. In the loop example, could this be implemented by returning an iterator of mutable references by default?

Perhaps to be inline with Mojo fn's having immref as default, loops can return an immutable iterator. This way at least mutability errors will show up during compilation.

@helehex
Copy link
Contributor

helehex commented Oct 8, 2024

I kinda like the idea of using read or reads for immutable reference, but immref is ok since it's just shorthand and it's the default so we don't have to write it

@gryznar
Copy link
Contributor

gryznar commented Oct 8, 2024

Python and Mojo are very different in this document, notably because the simplest syntax for a in some_list, a = b[i], a = b.c and a, b = some_list[i] all trigger implicit copies, which never happens in Python. We should either:

  1. Make Mojo use references by default when nothing is specified and trigger copies when .copy() is called, like in python. This has the consequence of getting rid of ref in function bodies.

+1. Reference by default makes much more sense to me, than copy by default.

  • I'd really want ref[...] instead of ref [...], so it's more consistent with other parametric things.

+1. This space between looks strange

I'd also like to take this opportunity to ask for the Python del keyword to be introduced instead of having to do _ = something^ which is cumbersome and unclear to people new to the language... oh yeah, I'll use the argument: "not really pythonic"

+1. This pattern to discard moved value is strange

Regarding to owned and ^, Maybe this will be suitable:

  • owned -> own
  • ^ -> mov. IMHO there is no need to postfix operator (allow to perform further actions). So:
var baz String = "baz"
var qux: String = "qux"

fn foo(own String bar): ...
foo(mov baz)  # moves baz into foo, without copying
var quux = mov qux # moves qux, without copying

@nmsmith
Copy link
Contributor

nmsmith commented Oct 8, 2024

^ -> mov

The "transfer operator" doesn't move any data, nor is it an operator. It just signifies that the variable will be passed as owned to some function. (And the owned convention is pass-by-address/pass-in-registers, just like every other convention.) This is why we've been discussing (over the last few months) renaming this sigil to something completely different, such as "consumption sigil".

@nmsmith
Copy link
Contributor

nmsmith commented Oct 8, 2024

@ivellapillil

Can we just take plain Origin at struct-level and decide whether we need mutable or not at method level? Or do we really need to track immutability/mutability at struct level?
With an "access control" concept it would look like:
fn __init__(write self, read [origin] list: List[T, *_]):

What you're suggesting is basically that we model mutability as an effect that the function performs (or a capability that it requests), rather than something baked into an Origin.

I've been thinking about this kind of thing for months and I believe it can be done, but it has major consequences for how memory-safety pointers are modelled in Mojo. For one thing, it means that a struct can no longer tell whether it's storing an "immutable pointer" or a "mutable pointer", so the compiler can't enforce that mutable pointers are globally unaliased. People coming from Rust might think that this is a bad thing, but it's not really a big deal. To reason about memory safety, all you need is local aliasing guarantees: you only need to know that while a particular function is executing, certain arguments/pointers don't alias other arguments/pointers. This can be achieved by slightly tweaking how "borrow checking" works. Rather than borrow-checking the construction of pointers, you can borrow-check function calls.

I'm still researching/experimenting with designs for memory-safe pointers, so I can't tell you if this the right way to go. I think it might be, but I need to demonstrate that.

Anyway, to avoid derailing this thread, that's all I'll say for now. In summary: maybe we don't need both MutOrigin and ImmOrigin. But this is an issue far bigger than keywords—it's deeply intertwined with how memory-safe pointers are modelled.

@nmsmith
Copy link
Contributor

nmsmith commented Oct 9, 2024

@gabrieldemarmiesse Your suggestion to reconsider whether for loops and patterns should copy by-default is valid and worth considering, but from reading your post, it seems that you've smooshed Mojo's "references" (pointers to variables) and Python's "references" (pointers to objects) into one concept. For example, you say:

We should consider making Mojo use references by default when nothing is specified and trigger copies when .copy() is called, like in Python.

Here you've equated Mojo references with Python references, which is one of the major pitfalls that I brought up earlier. These two features behave very differently, and I don't think they can be unified. If patterns bind "Mojo references" by default, I don't think we end up with a behaviour equivalent to Python. In particular, assignment will behave differently. (Unless... you're proposing changing the meaning of assignment in Mojo?)

That said, I agree with others that if we have patterns bind immutable "Mojo references" by default (at least within a fn), then we can avoid the divergence, because assignment would be rejected as an error. Furthermore, this would be consistent with the default behaviour of fn arguments.

I'll reiterate that I strongly believe we should ditch the term "reference" for Mojo references, because irrespective of whether you're equating Mojo references with Python references or C++ references, you're going to end up with the wrong mental model.

@gabrieldemarmiesse
Copy link
Contributor

gabrieldemarmiesse commented Oct 9, 2024

@nmsmith I'm by no means a programming languages expert, and I cannot claim I deeply understand how Mojo's references differ from Python's references. So it's no surprises if I made a few mistakes in my analysis. I believe the conclusion still holds true.

The proposed behaviour of Mojo for iterating and unpacking, as well as the current assignment operation, is surprising for python users since the code written can be exactly the same and have very different outcomes. As such, we need a way to avoir those behaviour footguns for python users. We can either

  • change the behaviour of Mojo
  • trigger errors in the compiler requesting less ambiguity in the code
  • just adress this behaviour change in the docs and in this proposal and acknowledging it's a issue that Python users must be aware of

I do not have enough expertise to say which path is the best or even which one can be achieved (except modifying the docs, this seems easy enough).

@nmsmith
Copy link
Contributor

nmsmith commented Oct 9, 2024

I agree: the inconsistency with Python (wherein every type is reference-semantic, and everything is pass-by-value) is definitely a concern, and we should strive to minimize it. We just have to make sure we preserve Mojo's ownership and referencing model, because that's what gives Mojo its performance and safety guarantees.

@jackos
Copy link
Collaborator

jackos commented Dec 9, 2024

My question to everyone: how do we describe an in argument in plain English?

This is how I'd describe it:

You can use an in argument to avoid copies, for example when initializing a struct:

struct Foo:
    var value: String

    fn __init__(out self, in value: String):
        self.value = value^

var string = String("Mojo")
var foo = Foo(string)

string will not be copied in this scenario, it moves ownership in to the constructor, and out to the Foo object being constructed, which will now be responsible for the lifetime and destruction of the String.

The value will be copied in if the caller reuses it:

var foo = Foo(string)
string += "🔥" # `string` reused here, so `Foo(string)` now passes `in` a copy

print(foo.value) # Mojo
print(string)    # Mojo🔥

The default argument convention read is a reference to a value owned elsewhere, so will always result in a copy when passed in or out, as those conventions must have ownership of the value.

@nmsmith
Copy link
Contributor

nmsmith commented Dec 9, 2024

moves ownership in

If we're going with this description, I think we should just say "takes ownership", because the two phrases are synonymous. And if we're talking about "taking ownership", then the keyword take makes sense.

This is why I don't understand people's opinions that take is misleading. Whatever keyword we use, we will be talking about ownership transfer (see also: @martinvuyk's comment proposing the term "transfer sigil"). "take" is nothing more than a synonym for "transfer in". Therefore, anybody who thinks "take" is misleading must surely think "transfer in" is misleading as well. But I haven't seen anyone express that opinion yet.

@nmsmith
Copy link
Contributor

nmsmith commented Dec 9, 2024

I'm trying to convert myself to an in fanboy but I just can't do it. If you try and read these function signatures aloud, only verbs like take or claim lead to something you can put into words.

The following signature:

    fn __init__(out self, take value: String):

Verbalizes as:

Function "init" outputs self, and takes ownership of value.

The following signature:

    fn __init__(out self, in value: String):

Verbalizes as:

Function "init" outputs self, and inputs (???) value.

or maybe:

Function "init" outputs self, and transfers in the ownership of value.

These verbalizations aren't working very well for me.

I've spent a lot of time teaching Python in classrooms. A good verbalization goes a lot of the way towards building up a good mental model.

@jackos
Copy link
Collaborator

jackos commented Dec 9, 2024

"take" is nothing more than a synonym for "transfer in". Therefore, anybody who thinks "take" is misleading must surely think "transfer in" is misleading as well

The keyword isn't "transfer in" though and doesn't imply that, it's a value being passed "in" by transfer or copy. Like you say take implies "transfer in", not "transfer or copy in".

Function "init" outputs self, and takes ownership of value.

Yes this is what it implies and is an incomplete description, it can also copy the value in:

Function "init" outputs self, and transfers or copies in value.

I like in/out being distinct from the reference conventions as a value with ownership being passed in to the function or out to the caller, feels very natural to explain.

@nmsmith
Copy link
Contributor

nmsmith commented Dec 9, 2024

At the end of the day, you're reading the word "take" as "take away" (lose), whereas I'm reading the word take as in "take possession" (receive).

The second reading is accurate: a function with an owned argument always takes possession of something.

If I'm the only one that appreciates this second reading, then I'd encourage consideration of synonymous keywords, such as receive or gain. I don't think anyone can make the case that these keywords are misleading.

fn __init__(out self, receive value: String):

Hell, we could even use the send/receive metaphor:

fn __init__(send self, receive value: String):

This function sends self to the caller, and receives value from the caller.

We could even use the return keyword. This makes a ton of sense, because a function that has an out self argument is equivalent to a function that invokes return self whenever it returns.

fn __init__(return self, receive value: String):

And so on. Personally, I think return and take work well together:

fn __init__(return self, take value: String):

fn foo(take x: T, return result: NonMovable):

The nice thing about all of these signatures is that they're verbalizable. I still haven't seen anyone suggest how to verbalize a signature that contains in. ("Transfers or copies in" is not something you're going to say aloud for each occurrence of in that you encounter in a signature.)

Perhaps I'm the only one that thinks verbalizability is important. In that case, carry on. That's the only thing I'm fighting for here.

@martinvuyk
Copy link
Contributor

Perhaps I'm the only one that thinks verbalizability is important. In that case, carry on. That's the only thing I'm fighting for here.

I argue for simplicity and intuitiveness so much because I care about the language being easy to teach and having a low barrier to entry. These are complicated topics for newbies and the simpler the better IMO.

fn __init__(send self, receive value: String): ...

This one makes me think of network I/O, receive is a word that makes me slow down and stumble every time I have to spell it out as a non-native speaker. If we go in this direction I'd argue in favor of using send and recv which are often used.

fn __init__(return self, take value: String):

This one (IMO) correctly implies, like @nmsmith pointed out, the true connotation to be read from take which is "it takes ownership of what is given to it (copy or owned)".


Another option that has some nice contrast:

fn __init__(give self, take value: String): ...

this function gives back a Self instance by taking a String value in its constructor

And to concatenate to my previous point about renaming ^:

struct MyType:
    fn __init__(give self, take value: String): ...

fn main():
    a = "123"
    t = MyType(transfer a)

IMO this has an easily readable and explainable meaning

@ivellapillil
Copy link

ivellapillil commented Dec 9, 2024

I also like the give and take. It is easier to explain transfer of ownership this way.

transfer is a bit hard to chain. I prefer ^

@melodyogonna
Copy link

Too much bikeshedding here, I think Chris needs to pick one or the conversation will not end. There can never be a perfect keyword, and since Mojo is still in very active development everyone understands that things can still change. Whatever is picked can still change if it doesn't work.

I had some reservations with "in" - it is already used for other things in Python. But Python and Python programmers are not strangers to overloaded semantics. "in" makes sense in the context of "out", so both could be explained as a shortened version of "take in" and "give out"; Or we'll find an explanation that works. And if we can't find an explanation that works we can always repaint. However, decisions need to be based on real-world experience because there is no end to speculative problems.

It's probably helpful if we can make a mechanical tool for updating argument conventions, as far as tooling goes this should be relatively straightforward for anyone with access to the parsed source code.

@christoph-schlumpf
Copy link

christoph-schlumpf commented Dec 9, 2024

@melodyogonna

"in" makes sense in the context of "out", so both could be explained as a shortened version of "take in" and "give out"

👍

modularbot pushed a commit that referenced this issue Dec 9, 2024
This adds support for spelling "named functions results" using the same
`out`
syntax used by initializers (in addition to `-> T as name`). Functions
may have
at most one named result or return type specified with the usual `->`
syntax.
`out` arguments may occur anywhere in the argument list, but are
typically last
(except for `__init__` methods, where they are typically first).

```mojo
  # This function has type "fn() -> String"
  fn example(out result: String):
    result = "foo"
```

The parser still accepts the old syntax as a synonym for this, but that
will
eventually be deprecated and removed.

This was discussed extensively here:
#3623

MODULAR_ORIG_COMMIT_REV_ID: 23b3a120227a42d9550ba76d8cafb63c3a03edcf
@lattner
Copy link
Collaborator Author

lattner commented Dec 9, 2024

The naming looks fine, but it did give me some concerns with how this will interact with generic code and first-class references, no matter what we call it.

fn foo[T: AnyType](out bar: T):
    ...

For example, if T is mut Bazz, then that means we need to decide on what exactly that means. Is this effectively being handed a const pointer to a pointer to Bazz, and then you need to put a mut pointer to a Bazz into the output pointer? Now, what if it was in bar: T, and T was read Bazz? What am I allowed to do with ownership of an immutable reference?

To clarify, T can't be mut Bazz. Mut is an argument convention, not a type. The function is supposed to work exactly like fn foo[T: AnyType]() -> T: already does. This isn't /quite/ true yet, but will be shortly.

Too much bikeshedding here, I think Chris needs to pick one or the conversation will not end. There can never be a perfect keyword, and since Mojo is still in very active development everyone understands that things can still change. Whatever is picked can still change if it doesn't work.

While this is true to some extent, we are also trying to make the "best possible thing" here, not just "something fast". I'd much rather take time to soak a bit, see how things shake out with the adoption of the existing changes, and then decide what to do. We don't need to decide what to do about owned in the next few days or few weeks, let's give it the time it needs to gel a bit. I think we can make a final decision on this in Jan after the holidays. I really appreciate all the discussion and tradeoff analysis, and new ideas suggested!

FWIW, something that I find helpful is to go and do the global search and replace in the stdlib and "see what it looks like".

-Chris

@ivellapillil
Copy link

ivellapillil commented Dec 9, 2024

To be frank, while reading LinearTypes proposal, the following example sounded better with take

Compared to:

@explicit_destroy("Use consume()")
struct MyLinear:
    fn __init__(out self): pass
    fn consume(in self):
       destroy self

The following nicely rolls.

@explicit_destroy("Use consume()")
struct MyLinear:
    fn __init__(give self): pass
    fn consume(take self):
       destroy self

But it is probably just one data point that sounds good...

Give and take also has no baggage from other languages and can be defined as the way we want for Mojo.

@owenhilyard
Copy link
Contributor

The naming looks fine, but it did give me some concerns with how this will interact with generic code and first-class references, no matter what we call it.

fn foo[T: AnyType](out bar: T):
    ...

For example, if T is mut Bazz, then that means we need to decide on what exactly that means. Is this effectively being handed a const pointer to a pointer to Bazz, and then you need to put a mut pointer to a Bazz into the output pointer? Now, what if it was in bar: T, and T was read Bazz? What am I allowed to do with ownership of an immutable reference?

To clarify, T can't be mut Bazz. Mut is an argument convention, not a type. The function is supposed to work exactly like fn foo[T: AnyType]() -> T: already does. This isn't /quite/ true yet, but will be shortly.

I'm assuming some form of first-class references exist, since my understanding was that there are plans for them. Right now, the type would look something like fn foo[T: AnyType](write bar: Pointer[ImmutableReference, T]). If we have Pointer able to transparently convert itself into a reference, then wouldn't there be ambiguity with fn foo[T: AnyType](write bar: T) when passing the Pointer to a function?

@carlca
Copy link

carlca commented Dec 9, 2024

Hi all, quick update: I landed support for out arguments last night, they should enter the next nightly. out arguments go inside the argument list - a function can have at most one argument, and if it has an out argument, it cannot have a -> specifier (the later is sugar for the former).

There seems to still be some inconsistency in the implementation. After updating nightly to 24.6.0.dev2024120905, the compiler did, indeed, complain about my having out parameters and a -> None clause at the end of my declaration...

  fn __copyinit__(out self, other: Matrix) -> None:
    self.rows = other.rows
    self.cols = other.cols
    self.total_items = other.total_items
    self.debugging = other.debugging
    self.data = DataType.alloc(self.total_items)
    memcpy(self.data.address, other.data.address, self.total_items)

I was able to get the code to build by changing this to...

  fn __copyinit__(out self, other: Matrix):
    self.rows = other.rows
    self.cols = other.cols
    self.total_items = other.total_items
    self.debugging = other.debugging
    self.data = DataType.alloc(self.total_items)
    memcpy(self.data.address, other.data.address, self.total_items)

However, the following code was able to be built without the compiler complaining...

 fn __isub__ (out self: Matrix, other: Matrix) -> None:
   self = self - other

For the sake of consistency, I changed this to...

 fn __isub__ (out self: Matrix, other: Matrix):
   self = self - other

and the code still built, but it does seem that the compiler is behaving differently in this situation without any good reason, that I can see, at least...

@rd4com
Copy link
Contributor

rd4com commented Dec 9, 2024

There are some non idealities with give and take (in my opinion),
it is that it does not convey the idea if it is the function or the caller that gives or take,
but with in and out, it convey the idea that it is from and into the function

@ivellapillil
Copy link

There are some non idealities with give and take (in my opinion), it is that it does not convey the idea if it is the function or the caller that gives or take, but with in and out, it convey the idea that it is from and into the function

read and mut is also from the point of view of the function as in the function reads or mutated. So give and take is more consistent with that 😃Actually I prefer the keywords be named according to what the function does it with them rather than what the caller intends.

@christoph-schlumpf
Copy link

christoph-schlumpf commented Dec 10, 2024

In my opinion, in only makes sense if out is used. But if renaming of out is possible, I like the proposal of @nmsmith to use give and take.

  • give naturally expresses that the function gives ownership of a new value.
  • take can easily be explained as „The function takes ownership of the moved or copied value.“

take/give are good complements for „transfer‘y“ conventions because the convey „ownership“ and are distinct from the read/mut "reference‘y“ ones.

I prefer give to return. In contrast to return which is used in other places to return anything, give returns a value that will be owned by the caller.

I think that verbalizable keywords make it easier to talk about function signatures.

My favorites by example:

fn __init__(give self, take *values: T):
or
fn __init__(out self, in *values: T):

Verbalized: „The function gives out a new List and takes in the values.

@ivellapillil
Copy link

ivellapillil commented Dec 10, 2024

I also have settled my opinion on to prefer give and take. It is unique for Mojo and conveys ownership transfers and is also easy to read in code.

I think it would also result in slightly less bugs as it is easy to verbalize in head while reading code.

For example, when I take I am aware of the responsibility associated with it than when I in. May be just me though 🙂

And borrow (immutable and mutable), take and give have a cohesive meaning together also in the real world.

@nmsmith
Copy link
Contributor

nmsmith commented Dec 11, 2024

Another thing worth thinking about is what you'd call out and owned if they were sugar for first-class types that denoted an obligation to provide a value, and an obligation to remove a value (respectively). I believe Mojo will eventually want these types to be first-class. There are a ton of use cases for these types.

There's a standard name for the first type: Promise[T]. (This is the C++ meaning; the JS meaning is different.) The second concept has no standard name that I know of. Future[T] is obviously not correct, because the value is available right away. For the moment I'll call it a MustTake[T].

Below are some of my early thoughts on how these types would work. I haven't presented a complete design. My goal is just to explain how these types might be useful.

Why promises matter

The main use case for a Promise (a reference to an uninitialized variable) is that it allows you to emplace data into an uninitialized container element. For example, instead of Dict.__setitem__ having the following signature:

fn __setitem__(mut self, owned key: K, owned value: V)

It could have the following signature:

fn __setitem__(mut self, owned key: K) -> Promise[V, __origin_of(self)]:

This provides the caller with an obligation to initialize a value (V) of the dictionary. A promise would be a linear type, meaning you can't just forget about it. The only way to get rid of it is to initialize its target.

The point of having setitem return a promise is that the dictionary value might be large (lets assume 100+ bytes), and we don't want to force the caller to construct the value before the call to setitem, and then force setitem to move the value to its final location in the dictionary. It would be more efficient to just ask the dictionary for the address where the value needs to be stored, and then directly initialize the location.

Note 1: The syntax for adding an element to a dictionary would be unchanged. You'd still write d[k] = v. (I'm assuming that the promise is "auto-dereferencing" itself somehow.)

Note 2: Promises would integrate with Mojo's borrow checker, to ensure that you can't read from the dictionary while the promise is unfulfilled. This is necessary for memory safety.

Why MustTake matters

MustTake allows you to write iterators that destroy the elements of a collection one-by-one, while letting the caller decide what to do with the elements' values—if anything—and without requiring the elements to be moved into a caller-provided memory location. In other words, MustTake offers the inverse of emplacement.

An iterator that destroys elements would define its __next__ method as follows:

fn __next__(mut self) -> MustTake[T, origin]:

As opposed to returning a non-owning reference (which means the element won't be destroyed):

fn __next__(mut self) -> Pointer[T, origin]:

Or not returning a reference at all (which means the element needs to be moved into a caller-provided location):

fn __next__(mut self) -> T:

To illustrate the utility of this feature, imagine we have a list of Nodes (defined below) containing heap-allocated arbitrary precision integers, and we want to partition (move) those integers into an odd list and an even list.

struct Node:
    var value: BigInteger
    <100+ bytes of other data>

nodes = List[Node](...)
even = List[BigInteger]()
odd = List[BigInteger]()
for node: MustTake[Node, __origin_of(nodes)] in nodes^.destroy():
    # Note: I'm assuming that the MustTake "auto-derefs" somehow
    if node.value % 2 == 0:    
        even.append(node.value^)
    else:
        odd.append(node.value^)
    # Any unmoved fields are automatically cleaned up

The nodes are large enough that moving them out of the list one-by-one (by repeatedly popping) would waste a lot of cycles. It's much more efficient to obtain a MustTake reference to each node, and then destroy the node ourselves, by moving out the integer and then letting the compiler clean up the rest of the fields. If those fields are trivially destructible then we never need to load their value from memory. (Whereas if we were using an iterator that moved the node into caller-provided memory, the entire Node would be loaded into cache.)

In summary: returning a MustTake reference gives the caller control over a value's fate, without forcing the value to be moved out of its container. This is extremely useful.

MustTake could also be used when popping items from lists:

struct List[T: CollectionElement]:
    fn pop(mut self) raises -> MustTake[T, __origin_of(self)]:
    
 num = nodes.pop().value^

This avoids the need to actually move the Node when popping it. In fact, as before, we don't even need to load all of the node's data from memory. We only need to load the part containing the integer. More generally, pop doesn't require the type T to conform to Movable. This makes it easier to work with lists of non-movable values.

Using pop, we can also move a node from one list into another list without storing the node in an intermediate variable:

selected_nodes.append(nodes.pop()^)

Remember: the argument(s) of append use the owned convention, which I'm saying should be sugar for MustTake. So the types match up perfectly. append moves the node directly from the buffer of nodes to the buffer of selected_nodes.

Note: Just as with promises, the MustTake type would need to integrate with the borrow checker, to ensure that a container that has handed out one of these references can't be mutated while the reference is alive. This is required for memory safety: mutating the container might deallocate the memory that the MustTake points to.

How this relates to the out and owned keywords

Getting back to the syntax bikeshedding: if Mojo offers these first-class types at some point, the conventions out and owned should have names that align with the concepts of a Promise and a MustTake, respectively. All four of these names would ideally work well together.

In other words: in my opinion, it's too early to settle on keywords for out and owned. We should finish the design of Mojo references first.

Thanks for reading. 🙂

@christoph-schlumpf
Copy link

christoph-schlumpf commented Dec 13, 2024

If Promise is used for emplacement and MustTake is the inverse of emplacement it might be a good idea to use keywords and type names with inverse meaning like out/in or give/take. A function that gives out a value can always be used as an argument in another function that takes in a value.

So the linear types could be named in a similar way:

  • MustGiveOut (instead of Promise)
  • MustTakeIn (instead of MustTake)

or simpler

  • MustGive
  • MustTake

@nmsmith
Copy link
Contributor

nmsmith commented Dec 14, 2024

I've thought of a simpler design for MustGive/MustTake. Instead of making them first-class types, we could make them second-class, like ref. This gives us a whole new set of "reverse argument" conventions.

Recall my earlier definition of pop:

struct List[T: CollectionElement]:
    fn pop(mut self) raises -> MustTake[T, __origin_of(self)]:

Maybe we can instead allow Mojo's owned convention to be used for results:

struct List[T: CollectionElement]:
    fn pop(mut self) raises -> owned[self] T:

The type owned[self] T is a second-class reference, just like ref[self] T, except it is treated as having ownership of the thing it refers to, meaning that when it goes out of scope, the destructor of T is invoked.

Basically, we're allowing owned to be used for outputs, not just inputs.

Exercise for the reader: Test how well your favourite names for the owned keyword work for the above example.

A similar trick works for promises/out/give arguments. I'm going to use the keyword init rather than out or give. The problem with the latter keywords is that they reflect the callee's point of view, whereas promises need to be understood from both the callee's and the caller's point of view. init achieves this: it clarifies that the receiver is obliged to initialize the variable.

In my last post, I presented the following signature for Dict.__setitem__:

struct Dict[K: KeyElement, V: CollectionElement]:
    fn __setitem__(mut self, owned key: K) -> Promise[V, __origin_of(self)]:

Here's the same signature using the second-class init convention:

struct Dict[K: KeyElement, V: CollectionElement]:
    fn __setitem__(mut self, owned key: K) -> init[self] V:

Again, init[self] V is very similar to ref[self] V. The only difference is that the compiler treats the reference's target as being uninitialized at the call site. This compels the caller to initialize it. From the compiler's perspective, this situation is equivalent to having transferred a value out of an ordinary ref (using ^). In both cases, there is a "hole" that needs filling before the reference goes out of scope.

How's that for a table flip? (╯°□°)╯︵ ┻━┻

These new conventions seem easy to implement, easy to teach, and they are very useful when working with large structs. If Mojo adopts these conventions, many of the keywords suggested earlier in this thread—including my own suggestions—would no longer be a good idea. That's not to say we will adopt these conventions. But at the moment I can't see any reason why we wouldn't.

If anyone has thoughts on these "reverse argument" conventions, I'd love to hear them.

@christoph-schlumpf
Copy link

christoph-schlumpf commented Dec 14, 2024

I think this would work well with my favorite keywords if we stick to the notion that they are always interpreted from the viewpoint of the callee (like mut and read)

For in/out:

fn pop(mut self) raises -> T:
would become
fn pop(mut self) raises -> out[self] T:

and

fn __setitem__(mut self, in key: K, in value: V):
would become
fn __setitem__(mut self, in key: K) -> in[self] V:

For take/give:

fn pop(mut self) raises -> T:
would become
fn pop(mut self) raises -> give[self] T:

and

fn __setitem__(mut self, take key: K, take value: V):
would become
fn __setitem__(mut self, take key: K) -> take[self] V:

modularbot pushed a commit to modularml/max that referenced this issue Dec 17, 2024
This adopts the recent changes that allow the use of the `out`
argument convention.  This argument convention more correctly
models the nature of `__init__` which initializes self but never
reads from it.

This was discussed on this public thread:
modularml/mojo#3623

MODULAR_ORIG_COMMIT_REV_ID: d05be1ccb28b254aeccd5807858546aeb1777991
modularbot pushed a commit that referenced this issue Dec 17, 2024
As discussed in [this public
thread](#3623),
the use of `inout self` in initializers is incorrect. Initializers don't
actually read the
argument, they only write it. Per discussion on that thread, we're
moving to 'out' as
the keyword used in function declarations, which allows us to spell the
function
type correctly as well.

This keeps support for the old syntax for migration support, but we
should
eventually remove it.

MODULAR_ORIG_COMMIT_REV_ID: 115768e0c6df069f3cf2e4b3d806db1cc2e6d8a2
modularbot pushed a commit that referenced this issue Dec 17, 2024
This adopts the recent changes that allow the use of the `out`
argument convention.  This argument convention more correctly
models the nature of `__init__` which initializes self but never
reads from it.

This was discussed on this public thread:
#3623

MODULAR_ORIG_COMMIT_REV_ID: d05be1ccb28b254aeccd5807858546aeb1777991
modularbot pushed a commit that referenced this issue Dec 17, 2024
Per extensive discussion over on this public thread:
#3623

We're moving to rename the `inout` argument convention to be called
simply `mut`, and renames `borrowed` to `read` which can still be
generally
elided.  This reduces the need to understand references for the
basic conventions that many people work with, while providing a more
strictly-correct and consistent model. These words are now "soft"
keywords
instead of "hard" keywords as well.

This still maintains support for the `inout` and `borrowed` keywords,
though
they will eventually be removed.

MODULAR_ORIG_COMMIT_REV_ID: e2b41cfb4cb8bb0b2e67ade93d32d7ef8989428e
modularbot pushed a commit to modularml/max that referenced this issue Dec 17, 2024
Per extensive discussion over on this public thread:
modularml/mojo#3623

We're moving to rename the `inout` argument convention to be called
simply `mut`, and renames `borrowed` to `read` which can still be
generally
elided.  This reduces the need to understand references for the
basic conventions that many people work with, while providing a more
strictly-correct and consistent model. These words are now "soft"
keywords
instead of "hard" keywords as well.

This still maintains support for the `inout` and `borrowed` keywords,
though
they will eventually be removed.

MODULAR_ORIG_COMMIT_REV_ID: e2b41cfb4cb8bb0b2e67ade93d32d7ef8989428e
modularbot pushed a commit that referenced this issue Dec 17, 2024
This adds support for spelling "named functions results" using the same
`out`
syntax used by initializers (in addition to `-> T as name`). Functions
may have
at most one named result or return type specified with the usual `->`
syntax.
`out` arguments may occur anywhere in the argument list, but are
typically last
(except for `__init__` methods, where they are typically first).

```mojo
  # This function has type "fn() -> String"
  fn example(out result: String):
    result = "foo"
```

The parser still accepts the old syntax as a synonym for this, but that
will
eventually be deprecated and removed.

This was discussed extensively here:
#3623

MODULAR_ORIG_COMMIT_REV_ID: 23b3a120227a42d9550ba76d8cafb63c3a03edcf
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests