Skip to content

Instantly share code, notes, and snippets.

@laughinghan
Created January 26, 2023 03:32
Show Gist options
  • Save laughinghan/5e374dd574c58ca21a55f52346f1e6c8 to your computer and use it in GitHub Desktop.
Save laughinghan/5e374dd574c58ca21a55f52346f1e6c8 to your computer and use it in GitHub Desktop.

Extensible functions in Mechanical

Extensible functions are like Java interface, Rust trait, or Haskell typeclass methods, except the interface name is optional, you can define a standalone method signature (basically an anonymous one-method interface), or if you want you can define an interface which is a set of method signatures.

To implement an extensible function on a tag (or a pattern of multiple tagged variants), you have to either be the module that defined the extensible function, or the module that defined one of the tags that your implementation is restricted to. This is so you can't have like, two independent dependencies providing conflicting implementations of the method for the same tag. (Should it be relaxed to, you have to be the package that owns the extensible function or a tag, instead of the specific module?) This is called coherence by Rust and Haskell.

Like Rust trait/Haskell typeclass methods, and also like function overloading in C++ and Java, dispatch is static and can be based on multiple parameters. Left-to-right parameter order matters: parameters with tag restrictions cannot come after parameters without:

// okay:
foo(x, y, z)
foo(x: #a | #b, y, z)
foo(x: #a | string, y, z: .bar()) // .bar() means has a method .bar()

// error:
foo(x, y: #a | #b, z)
foo(x: .bar(), y: #a | #b, z)

// this restriction is also for coherence. It prevents conflicts like:
impl foo(x, y: #a)
// vs
impl foo(x: #b, y)
// which implementation wins? What if #b is a custom type in an independent package?

Note that a parameter that requires a method(s) (what Rust calls trait bounds and Haskell calls typeclass constraints) still lacks a tag restriction, and cannot come before a parameter with a tag restriction.

Coherence restrictions are slightly looser than Rust/Haskell: overlapping implementations are allowed, as long as one is a strict subset of the other (more specific wins when dispatch is being compiled, of course). Note that this restriction on overlapping means that negative method restrictions or negative record field restrictions may be necessary to avoid undesirable overlap:

foo(x: .bar())
foo(x: .qux() - .bar()) // x must have method .zod() and *not* have method .bar()

zod(x: {name: string})
zod(x: {value: number, -name: string}) // x must be a record with field 'value' of type number,
                                       // and *no* field 'name' of type string

This might be annoying but is necessary to avoid conflicting implementations. Hopefully there'll be IDE extensions that insert these automatically.

Note that implementing .foo() in terms of .bar() like that, which provides a .foo() implementation for any value that also has a .bar() implementation, can only be done by the module that defines the extensible function .foo().

Like Rust, the way to do operator overloading is to import certain special methods from stdlib and implement.

Auto-convertible values

Another special method that can be imported and implemented is auto_convert(). If you implement it on a custom type, the compiler will infer what supertype your custom type is convertible to, and your custom type will be treated as a subtype of that supertype, usable everywhere it is. This lets you create eg a custom number type, usable anywhere a number is, but which implements an extensible function you didn't define, possibly overiding the original implementation of that extensible function for the number type. Design notes:

  • even if you weren't overiding another implementation when you wrote your custom implementation, that supertype might gain such an implementation later which would conflict, which is why this is only allowed on your own custom type
  • it might sound crazy to let you create a custom number type that responds to method calls differently from actual numbers and then pass that to unwitting functions written to work with numbers, but really this is no different from monkey-patching/method-swizzling, only statically dispatched; is it so bad?

Note that you can have a generic wrapper type that's auto-convertible to the wrapped type, and a .foo() implementation on your custom wrapper type based on calling .bar() on the wrapped type, which is almost like providing a .foo() implementation for any value that also has a .bar() implementation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment