Syntax Proposal: Replace `thin` with `{}`

Syntax Proposal: Replace thin with {}

Currently, a non-capturing function pointer type is written as def(Int) thin -> Int. We propose replacing the thin keyword with an empty capture list {} (e.g. def(Int) {} -> Int).

  • f: def(): Generic function type (allows both closures and function pointers).
  • f: def() {}: Explicitly disallows captures (strictly a function pointer).

This change:

  1. Matches the syntax of a closure definition that captures nothing (analogous to a function pointer, as non-capturing closures implicitly coerce into them).
  2. Removes the need for a separate thin keyword.
def main():
    def add_1(n: Int) {} -> Int:
        return n + 1
    print(take_fn_ptr(add_1))

# current
def take_fn_ptr(f: def(Int) thin -> Int) -> Int:
    return f(42)

# proposed
def take_fn_ptr(f: def(Int) {} -> Int) -> Int:
    return f(42)


def complex_example():
    # current
    def return_fn_ptr(f: def(Int) thin -> Int) {} -> def(Int) thin -> Int:
            return f

    # proposed
    def return_fn_ptr(f: def(Int) {} -> Int) {} -> def(Int) {} -> Int:
        return f

Open Questions:

  • Is there a practical use case for explicitly requiring a closure and rejecting a thin function pointer (e.g. using a syntax like f: def() {_} or f: def() {...})?
  • Currently, it is bit confusing that def(Int) thin -> Int acts as a direct function type, while def(Int) -> Int acts as a meta function type requiring explicit parameterization (or Some[]). Instead of this split, could def(Int) -> Int auto-parameterize on the closure type?
# current: thin function type
def take_fn(f: def(Int) thin -> Int) -> Int:
    return f(42)

# current: generic - explicit parameterization required
def take_fn[F: def(Int) -> Int](f: F) -> Int:
    return f(42)

# current: Some[] alternative
def take_fn(f: Some[def(Int) -> Int]) -> Int:
    return f(42)

# proposed: explicitly strict function pointer
def take_fn(f: def(Int) {} -> Int) -> Int:
    return f(42)

# proposed: explicit parameterizes on the closure type
def take_fn(f: def(Int) {_} -> Int) -> Int:
    return f(42)

# proposed: auto-parameterizes on the closure type
def take_fn(f: def(Int) -> Int) -> Int:
    return f(42)

I don’t believe so - or I would be very surprised if someone had a real motivating example where they want a closure but want to reject a thin function pointer.

I don’t believe so - or I would be very surprised if someone had a real motivating example where they want a closure but want to reject a thin function pointer.

I have use-cases for this functionality.

Owen, can you elaborate?

Can you spell out the benefit, why this would be desirable? Do you see a practical application where this would add a benefit for the users?

(Personally I find it a bit alarming that a trait def(Int)->Int and a type def (Int) thin ->Int are so similar in spelling)

Yes, I completely agree! They do look very similar, which is exactly why it’s confusing that one acts as a trait and the other acts as a type. It would be more consistent if they both acted as types.

One way to look at it is:
Using C++ notation to avoid confusion with current Mojo syntax, a closure can be thought of as a parameterized struct containing capture data and a function pointer:

template<typename CDATA>
struct MyIntToIntClosure {
    CDATA capture_data;
    int (*fn_ptr)(CDATA, int);

    // Assuming we had a dunder call in C++
    int __call__(int x) {
        return this->fn_ptr(this->capture_data, x);
    }
};

In Mojo, since typenames act like parameters, MyIntToIntClosure can be specialized on the CDATA type (the user capture data).

For a non-capturing function (what we currently call thin), the capture data is just NoneType. Because NoneType has zero size, it is optimized away, leaving a thin function pointer:

# Explicitly specialized with NoneType
def take_fn(f: MyIntToIntClosure[NoneType]) -> Int:
    return f(42)

# Currently: represented as a special 'thin' type
def take_fn(f: def(Int) thin -> Int) -> Int:
    return f(42)

# Proposed: empty capture list {} acts as sugar for NoneType
def take_fn(f: def(Int) {} -> Int) -> Int:
    return f(42)

When we do have closure captures, the type is parameterized on the specific capture data. Currently, Mojo uses a trait to accept generic closures:

# Explicitly parameterized on an unknown closure capture type
def take_fn(f: MyIntToIntClosure[_]) -> Int:
    return f(42)

# Currently: requires falling back to a trait
def take_fn[F: def(Int) -> Int](f: F) -> Int:
    return f(42)

# Proposed: explicitly indicates a capture type parameter
def take_fn(f: def(Int) {_} -> Int) -> Int:
    return f(42)

Of course there are optimizations like when the CDATA type contains all register passable types there is an optimization to pass it in registers, but these optimizations come later and don’t change the MyIntToIntClosure original type:

struct CDATA(TrivalRegisterPassable):
    ref a: Int
    ref b: Int

Now because Mojo has auto-parameterization for types, if we treat the base function signature as a parameterized type rather than a trait, we get:

# Auto-parameterized on the closure capture type
def take_fn(f: MyIntToIntClosure) -> Int:
    return f(42)

# Currently: requires boilerplate trait syntax
def take_fn[F: def(Int) -> Int](f: F) -> Int:
    return f(42)

# Proposed: `def(Int) -> Int` acts as an auto-parameterized type
def take_fn(f: def(Int) -> Int) -> Int:
    return f(42)

The practical benefits for the user are:

  1. Consistency: def(Int) -> Int and def(Int) {} -> Int are both types, making the syntax more uniform.
  2. Ergonomics: Users can just write (f: def(Int) -> Int) without explicitly using trait parameterization syntax [F: def(Int) -> Int](f: F), exactly like other generic types in Mojo.
  3. Unified Mental Model: Closures can be reasoned about as normal parameterized structs rather than requiring a separate trait system.

Footnote: This {} syntax also nicely opens the door to restricting side-effects in closures. For example, there might be a use case for explicitly requiring an immutable (imm) closure that doesn’t have side effects, ensuring it can be used idempotently multiple times:

def take_fn(f: def(Int) {imm} -> Int) -> Int:
    var one = f(42)
    var two = f(42)
    assert one == two

While I agree the current syntax is confusing, I’m not sure this proposal actually adds much clarity and arguably just moves the confusion elsewhere.

I think an important distinction is that def(Int) -> Int needs to be expressible as a trait. Otherwise, how would things like trait composition work?

def foo[F: def(Int) -> Int & OtherTrait](f: F):
    ...

Similarly, you need some way to generically store a closure without knowing its concrete type:

struct Foo[F: def(Int) -> Int]:
    var closure: Self.F

So while I agree the current spellings of def(Int) -> Int and def(Int) thin -> Int are confusingly similar, I think the better direction is to make the closure trait look more obviously like a trait.

For example, something along the lines of:

def foo(f: Some[FnOnce[(Int) -> Int]]):
    ...

(or whatever the eventual spelling is).

I think we need the inverse of Some[]—perhaps something like Type[] or Trait[]—to extract the meta-type or trait of a given type. Alternatively, if we prefer to restrict this specifically to functions and closures, we could adopt a Python-like Callable[def(Int) -> Int] to retrieve the trait of a function type.

This approach preserves the consistency and ergonomics of treating both def(Int) -> Int and def(Int) thin -> Int as types, while providing a clear mechanism to access the trait.

Using this syntax, trait composition could look like one of the following:

def foo[F: Type[def(Int) -> Int] & OtherTrait](f: F):
    ...

def foo[F: Trait[def(Int) -> Int] & OtherTrait](f: F):
    ...

def foo[F: Callable[def(Int) -> Int] & OtherTrait](f: F):
    ...

I agree that the type/trait syntax not being consistent with signatures is … unfortunate. In types,thin also appears to be in the place of effects, which is not what we are trying to denote.

I am in favour of deprecating thin in types and just writing def(Int) {} -> Int for the type of thin Int to Int functions. Whether we also should support def(Int) {_} -> Int(or def(Int) {...} -> Int) as the trait of capturing Int to Int function types is something we can punt until we have use cases (@owenhilyard , we are all ears).

Finally, while I am not a fan of def(Int) -> Int being the syntax of the trait, rather than thin function type, it does read well when it comes to writing that you need some type that conforms to the trait. E.g., def f(g: Some[def(Int) -> Int], i: Int) -> Int and def f(g: FnOnce[def(Int) -> Int]) -> Int. Presumably that would later on extend to def f(g: Dyn[def(int) -> Int]) -> Intwhere argumentg uses indirection so that values from all the types which are callable can be passed.

(@nate , shouldFnOnce maybe be called CallOnceinstead?)

@Steffi do you have arguments for or against the proposal?

The use-case is currently internal-only, I’ll ping people on the slack thread.

The issue is that thin means a function type, without thin, it means a trait.

IMO, we should make them looks more different instead of making them more similar to avoid confusion.

Alternatively, both could be traits, where a thin function trait can accept any zero-sized callable struct. For example:

struct AddN[N: Int]:
    # Zero-sized with no runtime state
    def __init__(out self):
        pass

    def __call__(self, x: Int) -> Int:
        return x + Self.N

# Currently (as a type):
def take_fn_ptr(f: def(Int) thin -> Int) -> Int:
    return f(42)

# Proposed (as a trait):
def take_fn_ptr(f: Some[def(Int) {} -> Int]) -> Int:
    return f(42)

def main():
    var f = AddN[10]()
    print(take_fn_ptr(f)) # Fails currently

You do need a bare function pointer-like type for FFI staff though?

For FFI, we need to specify the abi explicitly, for example:

def take_ffi_ptr(ffi_ptr: def(UInt32) {} abi("C") -> UInt32):
    ...

So maybe it could behave like a type when it has the abi effect?

Or perhaps it could still use Some[]:

def take_ffi_ptr(ffi_ptr: Some[def(UInt32) {} abi("C") -> UInt32]):
    ...

But since the {} (and abi) already reduces it down to a single possible type, Some[] would introduce a degenerate parameter that could just be optimized out?