[Pitch] Syntactic tail calls (STC)

This one, specifically, doesn't work, because it violates what a tail call is. If the return statement is a separate statement, it cannot run after the tail (Leading to an "unreachable statement" warning). If it's part of the same statement, it cannot alter the return value (by specifying 1).

1 Like

Are you sure? I think it would be possible to make this happen, example pseudo asm:

// let's suppose return value lives in $R0

bar:
    // doing something, and doesn't change $R0 (or preserves / restores it)
    RET

foo:
    // doing something
    MOV $R0, 1  // return value is 1
    JMP bar     // instead of the usual CALL for non-tail calls

Why calling bar() if it doesn't affect the return value? Could be many reasons, e.g. bar() has a side effect.


Or to put it differently, this:

func bar() {...}
func foo() -> Int {
    ...
    tail bar()
    return 1
}

could be treated as if it was written as:

func bar() {...}
func bar_autogenerated_wrapper() -> Int {
    bar()
    return 1
}
func foo() -> Int {
    ...
    return tail bar_autogenerated_wrapper()
}
1 Like

This seems very ABI-specific (return value is in $R0 and can be preserved). At the very least, I don't think WebAssembly's return_call would support it. Taken from spec discussion:

the typing rules for return_call require that the result of the function exactly matches the expected return type in the calling context.

Having to deal with a situation where bar returns Void, but that return might be overridden would be difficult for the compiler as well. It would have to either detect that usage, or write any Void returning method (at least any stdcall one) to preserve $R0.

New idea for tail calls, one that does not require new syntax: Create a new built-in generic type TailCalled<T>. This type can only be used as the return type of a function. Additionally, it carries special semantics similar to Never.

Usage would be like

func callee(param1: Int) -> TailCalled<Int> {
  return param1 + 123 // Automatically "wrapped"
}

func caller(docall: Bool) -> Int {
  guard !docall else {
    callee(param1: 42) // No need for "return". This function counts as non-returning, in this context
  }
  return 7
}

The remaining semantics would be the same as the OP: defer occurs before the call. do-catch in the caller is ignored. throwing must be the same for both.

A key here is that a function returning TailCalled<T> cannot be called except as a tail call. If calling it as a normal function is desired, it's possible to wrap it using a stub that does nothing but call it:

func callee_stub(param1: Int) -> Int {
  callee(param1: param1) // No need for "return"
}

I was thinking defer wouldn't be allowed to interact with these calls, since it can't function normally (tail-calling gives up the ability to run anything after the logical return statement) and special-casing it like this seems likely to cause confusion and bugs.

defer is too important a tool, though, and is also the only way to manage lifecycle of structs. Example of an actual use I make of it:

		func add(waiter: UnsafeMutablePointer<Waiter>, withContinuation: UnsafeContinuation<T, Error>) {
			do {
				lock.lock()
				defer { lock.unlock() }
				assert(waiter.pointee.continuation == nil)
				waiter.pointee.continuation = withContinuation

Not being able to have guaranteed release of resource due to the presence of a tail call would be pretty bad.

Besides, this is consistent with other explicit tail call implementations. The WebAssembly spec overview specifically states:

  • Tail calls behave like a combination of return followed by a respective call

A tail call should support anything a trampoline (i.e. instead of making a call, returning a closure, and having the caller's caller make the call) would support, with equivalent semantics.

This semantic difference relative to return callee() is exactly why explicit syntax is needed, and TCO/TRE are not sufficient.

If there is potential for confusion, it should be alleviated by choosing syntax such that it is clear that the return (and hence, any defer) would happen first.

P.S. Even if you denied defer, you'd still get different semantics due to ARC. Unless you also forbid having local reference variables in the caller:

class HasDeinit {
  deinit {
    print "deinit"
  }
}
func normal_callee() -> Void {
  print "normal"
}
func tail_callee() -> TailCalled<Void> {
  print "tail"
}
func caller(tail: Bool) -> Void {
  let shallDeinit = HasDeinit()
  print("caller")
  if tail {
    tail_callee()
  }
  return normal_callee()
}

caller(tail: false)
caller(tail: true)

Will print

caller
normal
deinit
caller
deinit
tail
1 Like

I think there's a difference, since being tail-callable doesn't generally affect the type system semantics of a call. Never at its core doesn't have special semantics that don't emanate from the type's core properties; it's a type with no values, so of course a function that returns Never, or any other uninhabited type, cannot return. The special treatment for dataflow analysis etc. arise from that fundamental property.

2 Likes

So is Void, but Void is different from Never. The semantic of "You can use it instead of break or return inside a guard" doesn't exactly stem from the type's core properties, either.

At any rate, a function that would return TailCalled<T> cannot be used in an expression, so if anything, the type semantics work:

func callee() -> TailCalled<Int> {
  return 123
}
let x = callee()+13 // Error: Cannot add TailCalled<Int> and Int
// And also
let y = callee() // Error: Local variable cannot have type TailCalled<Int>
// Same as
var z : Never // Error: Local variable cannot have type Never

func caller() -> Int {
  return callee() // Error: Cannot convert TailCalled<Int> to Int. Did you mean "callee()" (no "return")?
}

Void has one value, (). To me, I would expect that a tail-callable function still ought to be callable in non-tail position too, which is important for many use cases.

4 Likes

Like I've said, it's achievable with a stub. If anything, turning the tail version and non-tail version into separate functions, gives the compiler a much better capability to make the tail version as efficient as possible, e.g. removing some of the function entrance headers, or attempting to pass as many of its parameters in registers as possible.