Skip to content

allow red functions to be passed as arguments and return values - #608

Open
seibert wants to merge 1 commit into
spylang:mainfrom
seibert:functype
Open

seibert wants to merge 1 commit into
spylang:mainfrom
seibert:functype

Conversation

@seibert

@seibert seibert commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

This is a redo of #607 based on out-of-band discussion with @antocuni. In this version, we don't need a new type for C callbacks, we use the existing W_FuncType, and make the minimum changes required to allow red functions to be passed around as arguments. The end goal is the same: to enable external modules to accept SPy functions as C callbacks. Calling a function passed as an argument is still not allowed in this PR.

This PR adds a builtin @functype decorator to make it easier to define a new function type, so you can write stuff like:

@functype
def CB(x: i32) -> i32:
    pass

@blue
def make_adder(n: i32) -> CB:
    def adder(x: i32) -> i32:
        return x + n
    return adder

(This PR was created with AI assistance.)

@JeffersGlass

Copy link
Copy Markdown
Contributor

@functype essentially allows up to not have to write a body for the function, yeah? I've always just used type(CB) in place or assigned to a variable:

def CB(x: i32) -> i32:
    return 0

@blue
def make_adder(n: i32) -> type(CB):
    def adder(x: i32) -> i32:
        return x + n
    return adder

@seibert

seibert commented Jun 26, 2026

Copy link
Copy Markdown
Contributor Author

Talking with @antocuni, it is likely that @functype works on accident right now (the pass in the body of CB is only working because it is a blue function and the signature isn't being checked), so we might want a different approach.

@antocuni antocuni left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's mostly good, but with a lot of nitpicks here and there that overall degrade code quality and should be fixed.

The biggest thing to solve is the change to SPyVM.make_fqn_const which is plainly wrong, see the inline comment.

Comment thread spy/backend/c/cbackend.py
if d != fqn and d not in seen:
seen.add(d)
deps.append(d)
return deps

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this deserves its own unit test.
See e.g.:

def test_struct_deps(self, vm):
src = """
from unsafe import gc_ptr
@struct
class Color:
name: str
@struct
class Point:
x: i32
y: i32
@struct
class Rect:
a: Point
b: Point
color: gc_ptr[Color]
"""
backend = self.compile_until_CBackend(vm, src)
def deps(fqn_str: str) -> list[str]:
return [str(d) for d in backend.get_type_deps(FQN(fqn_str))]
# A struct containing only primitive/pointer fields has no by-value
# type deps. Pointers do NOT count as dependencies, since we already
# emit a forward declaration for the pointee type.
assert deps("test::Color") == []
assert deps("test::Point") == []
assert deps("test::Rect") == ["test::Point"]

c_params = ", ".join(str(self.ctx.w2c(p.w_T)) for p in w_ft.params)
else:
c_params = "void"
self.tbh_fwdecl.wl(f"typedef {c_ret} (*{c_name})({c_params});")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this works and it's good enough for this PR.
But note that it produces an incredibly ugly C type name.

This happens because function types get an FQN which depends on their "structural" shape. For example for a functype def(i32, i32) -> f64 the fqn is:

FQN('builtins::def[i32, i32, f64]')

And the C typedef is:

typedef double (*spy_builtins$def__builtins$i32_builtins$i32_builtins$f64)(int32_t, int32_t);

It's unclear how to improve it though (apart making builtins shorter, which maybe it's a good idea). The nice property of this is that if you compile two spy programs separately (e.g. a main executable and a lib) their function types are automatically compatible at the C level.

Random ideas for how to improve it:

  1. give a unique numering and call them FuncType1, FuncType2, etc. They are more opaque, but complex function types will have unreadable names anyway. The problem is that separate compilation becomes harder because you need to agree on numbering.
  2. do it at the spy level. Declare e.g. that @functype defines a NEW type with its own fqn:
@functype
def BinOp(x: int, y: int) -> int:
    pass

Here, we would define a fresh functype whose fqn is mod::BinOp: the advantage is that the redshift output and the C output become very clear. The new type would have conversion functions to convert to and from def(int, int) -> int.

I kind of like option 2, but my only doubt is that most PLs do NOT treat function types as new types. The only exception seems to be go, from what I understand.

# red def(i32) -> i32: a callback that takes one i32 and returns i32
_w_cb_type = W_FuncType.new([FuncParam(B.w_i32, "simple")], B.w_i32)
CB = Annotated[W_Func, _w_cb_type]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this seems the wrong place where to put this test code, as it's not about out-of-tree at all.
I'd put it in vm/modules/_testing_helpers.py

""")
assert mod.foo() == "hello from mymod"

def test_c_callback(self):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and these tests should go to test_functype.py

Comment on lines +18 to +21
assert w_CB.w_restype is B.w_i32
assert [p.w_T for p in w_CB.params] == [B.w_i32, B.w_i32]
assert w_CB.color == "red"
assert w_CB.kind == "plain"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if you want to simplify this test, you can probably just assert w_CB.name == 'def (i32, i32) -> i32'.

Comment on lines +57 to +72
def test_functype_at_call_site(self):
mod = self.compile("""
@functype
def CB(x: i32, y: i32) -> i32:
pass

def apply(cb: CB, x: i32, y: i32) -> i32:
return x + y

def my_add(a: i32, b: i32) -> i32:
return a + b

def run() -> i32:
return apply(my_add, 3, 4)
""")
assert mod.run() == 7

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and this seems to be the exact same test as above?

Comment on lines +74 to +117
def test_signature_mismatch_wrong_ret(self):
src = """
@functype
def CB(x: i32) -> bool:
pass

def my_fn(x: i32) -> i32:
return x

def bad() -> CB:
return my_fn
"""
errors = expect_errors("mismatched types")
self.compile_raises(src, "bad", errors)

def test_signature_mismatch_wrong_argcount(self):
src = """
@functype
def CB(x: i32, y: i32) -> i32:
pass

def my_fn(x: i32) -> i32:
return x

def bad() -> CB:
return my_fn
"""
errors = expect_errors("mismatched types")
self.compile_raises(src, "bad", errors)

def test_signature_mismatch_wrong_arg_type(self):
src = """
@functype
def CB(x: f64) -> i32:
pass

def my_fn(x: i32) -> i32:
return x

def bad() -> CB:
return my_fn
"""
errors = expect_errors("mismatched types")
self.compile_raises(src, "bad", errors)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these have nothing to do with func types.
Typechecking function calls is already tested elsewhere.

Kill.

Comment on lines +119 to +135
def test_blue_func_rejected(self):
# A @blue function's functype has a different FQN than a red functype,
# so passing it where a @functype type is expected fails with a type mismatch.
src = """
@functype
def CB(x: i32) -> i32:
pass

@blue
def my_fn(x: i32) -> i32:
return x

def get_cb() -> CB:
return my_fn
"""
errors = expect_errors("mismatched types")
self.compile_raises(src, "get_cb", errors)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is an interesting case, we can keep it.
But expect_errors should be MORE precise and check that we get a reasonable explanation/error annotation.

I have noticed multiple times that claude tends to be very bad at writing these tests.

Comment on lines +137 to +154
def test_blue_factory(self):
# A @blue function can generate a red callback that captures compile-time
# constants. After redshifting, captures are inlined so each becomes a
# standalone C symbol.
self.compile("""
@functype
def CB(x: i32) -> i32:
pass

@blue
def make_adder(n: i32) -> CB:
def adder(x: i32) -> i32:
return x + n
return adder

add5: CB = make_adder(5)
add10: CB = make_adder(10)
""")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kill, we already have an end-to-end test.

Comment thread spy/vm/vm.py
Comment on lines +518 to +519
if fqn in self.globals_w:
return fqn

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is VERY wrong and it's a code smell. Bad claude 😡.
Early in this function we do a reverse lookup:

        fqn = self.reverse_lookup_global(w_val)
        if fqn is not None:
            return fqn

so, if w_val is ALREADY in the globals, we returned the FQN.
If we reach this stage, it means that we need to add w_val to the globals, and the assert is a sanity check to ensure that for some reason the FQN doesn't already exist.
It turns out that the FQN builtins::functype is already taken by W_FuncType (see
function.py):

W_FuncType._w = W_Type.declare(FQN("builtins::functype"))

So the failing assert here was catching a real bug. This means that we should probably rename the @functype decorator.

@antocuni

antocuni commented Jun 29, 2026

Copy link
Copy Markdown
Member

@functype essentially allows up to not have to write a body for the function, yeah? I've always just used type(CB) in place or assigned to a variable:

@JeffersGlass type(CB) works but I think it would be good to have a way to give explicit names to function types, that's why I suggested the @functype decorator.


Talking with @antocuni, it is likely that @functype works on accident right now (the pass in the body of CB is only working because it is a blue function and the signature isn't being checked), so we might want a different approach.

Yes, I think that now it works by chance, but we could make it working with ...:

@functype
def CB(x: int, y: int) -> float: ...

Speaking of which, I'm not 100% sure about the name @functype. What about:

@def_functype
def CB(x: i32, y: i32) -> i32: ...

Other possibilities:

  • @define_functype
  • @functype_alias
  • @make_functype
  • @get_functype
  • @new_functype

@JeffersGlass

JeffersGlass commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

My personal favorites among the names you listed @antocuni are def_functype and make_functype, but that's only based on vibes 😄

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

Successfully merging this pull request may close these issues.

3 participants