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

Function definition, dispatch, arguments, and organisation #19

Open
wants to merge 1 commit into
base: main
Choose a base branch
from

Conversation

nrc
Copy link

@nrc nrc commented Nov 6, 2024

This document proposes several changes to how function work in KCL. It supports a lot of the ergonomic changes proposed elsewhere. Although it changes the implementation a lot, I believe the 'feel' of KCL functions will not change too much, other than removing the many flavours of similar function in std (e.g., line, lineTo, angledLine, xLine, etc are all replaced by the single line).

There is a lot in this design doc and it might feel like too much complexity for a small, domain-specific language. However, most of this complexity exists to facilitate very simple and clear usage patterns, and the detail in the doc is to ensure it will all work as desired. Bear in mind that this doc is aimed at implementers, not users, and documentation for users would approach these topics in a very different way. I'm pretty sure that the complexity here fits well with the desired learning curve: beginners will not have to concern themselves with much of this, more advanced users can do more as they learn more. Skip to the end to see an elaborated example of how this all fits together and how it would feel to a user of KCL.

@adamchalmers adamchalmers changed the title Fuction definition, dispatch, arguments, and organisation Function definition, dispatch, arguments, and organisation Nov 6, 2024

Note that if a non-optional argument has optional type, then it must be supplied (though could of course be `None`) and cannot be elided (c.f., optional arguments).

If `self` has array type and the function is called with multiple (unlabelled) values with a single super-type, they will be packed into an array.
Copy link
Collaborator

Choose a reason for hiding this comment

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

Do we need this behaviour? Maybe we could just not allow self to have an array type. Variadics could be tricky to handle, as you call out below

This likely makes this feature interact poorly with array self arguments, and we should probably just not check for matching argument names in that case).

Copy link
Author

Choose a reason for hiding this comment

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

This is useful for points, etc, e.g., pt(0, 0, 0) to create a point at the 3d origin relies on this behaviour. Typing out pt(x = 0, y = 0, z = 0) would be a pain

Copy link
Collaborator

Choose a reason for hiding this comment

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

Yeah, but if points/vectors are a data type, using either array syntax [0, 0, 0] or something different e.g. ( or < delimiters or something, then this isn't necessary.

Copy link
Author

Choose a reason for hiding this comment

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

This is for the ctor function which creates those types


An optional argument may not have an optional type, i.e., `a?: T?` is illegal for all `T`. If `None` is passed for an optional argument, that is equivalent to not specifying the argument, e.g., `foo(a = 0)` and `foo(a = 0, b = None)` are equivalent.

Note that if a non-optional argument has optional type, then it must be supplied (though could of course be `None`) and cannot be elided (c.f., optional arguments).
Copy link
Collaborator

Choose a reason for hiding this comment

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

I don't think there's a good use-case for mandatory arguments of optional type like (a: num?) -- I suggest we just don't allow this. It seems really limited compared to using an optional argument with mandatory type like a?: num and will be a stumbling block for users. We can always relax that restriction later.


E.g., for `fn foo(self: num)`, `42 |> foo()` is equivalent to `foo(42)`. `42 |> foo(0)` is not allowed.

E.g., for `fn foo()`, `42 |> foo()` is equivalent to `foo()`, `42` is ignored (in this example, it should trigger a warning, more generally the lhs may be used elsewhere).
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why should this be allowed? Seems like an error to me. Is it just to avoid breaking the pipeline?

Copy link
Author

Choose a reason for hiding this comment

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

It can be used in a nested function, e.g., a |> debug(start()) would desugar to debug(start(a))


[^self2]: This may remind you of method call syntax in other languages using `.` or `->`. It sort-of is. It also follows `|>` syntax from F#. Note that we use `.` in KCL for field access, including accessing functions if functions are stored in a field (in particular this happens with imported [modules](modules.md)). Note though, that using `.` just locates the function, it does not treat the lhs as the receiver/`self` argument.

[^yikes]: Yikes! This seems a bit subtle and error-prone. However, it does mean that the following examples work: `path |> line(angle = perpendicular(), len = segmentLen())` (`perpendicular` and `segmentLen` are called with `self = path`) and `path |> mirror(line(from = (0, 0), to = end()))` (the non-self version of `line` is used, `path` is passed as `self` to `end`). To get other behaviour, the user can always break the pipeline and use a variable.
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think this will make helper functions pretty annoying to work with. Any helper function you call inside a pipeline will need to take and ignore the pipeline's current self argument. E.g. in your example below:

startSketch(on = XY)                  // : Sketch
  |> startPath(at = pt(0, 0))         // : PathBuilder
  |> line(rel = vec(6, 0))            // : PathBuilder
  |> arc(rel = vec(3, 3))             // : PathBuilder
  |> line(rel = vec(0, 6))            // : PathBuilder
  |> line(to = start())               // : PathBuilder
  |> enclose()                        // : Sketch

the function pt has to take a Sketch as its self, and vec need to take a PathBuilder as its self. If I instead want to replace pt(0,0) with a call like origin() then origin also needs to take and ignore the self arg.

I know it's been a bit confusing but I do like that % solves this problem, by immediately clarifying which subexpressions use the LHS of the |> vs. which subexpressions don't need it.

Instead of automatically passing the LHS as the first arg, could we instead get users to pass self if they want that? E.g.

startSketch(on = XY)                  // : Sketch
  |> startPath(at = pt(0, 0))         // : PathBuilder
  |> line(rel = vec(6, 0))            // : PathBuilder
  |> arc(rel = vec(3, 3))             // : PathBuilder
  |> line(rel = vec(0, 6))            // : PathBuilder
  |> line(to = start(self))               // : PathBuilder
  |> enclose()                        // : Sketch

Copy link
Author

Choose a reason for hiding this comment

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

the function pt has to take a Sketch as its self, and vec need to take a PathBuilder as its self. If I instead want to replace pt(0,0) with a call like origin() then origin also needs to take and ignore the self arg.

I don't think that's right. If the functions don't have a self formal argument, then the pipeline object is ignored.

self would interfere with self from the function, but we could use a different keyword (or use something different for self).

Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm confused. I thought pt had a self formal argument, of type array, which is being used for the 0, 0, 0 arguments which get packed into pt(self). Why does start() get passed the sketch group into its self but not pt()?

Copy link
Author

Choose a reason for hiding this comment

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

The explicit argument to pt overrides the implicit one (%)

fn bar(): num {...} // error: duplicate names
```

Exception: functions may have the same name if they have fully distinct `self` types. A function with no self argument is considered distinct to any functions with `self`. If the `self` types are unrelated, there is no restriction on the other arguments or return type. If the `self` types are subtypes, other arguments must match exactly[^contra] and return types must be covariant. Due to the `self`'array sugar, `T` is not considered distinct from `[T]` or `[T; 1]`.
Copy link
Collaborator

Choose a reason for hiding this comment

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

What's the motivation for this? Seems like this would make our hover and autocomplete support very difficult.

Copy link
Author

Choose a reason for hiding this comment

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

It should work with hover and autocomplete since it can all be done statically. Not sure what your referring to exactly with 'this', but the general concept is to support virtual dispatch, e.g., in the example how line is implemented as a free function and on sketch (and we might want to implement it differently on different kinds of sketch)


### Dispatch

A function name whether imported from a module or defined locally, defines *a set of functions*. Likewise, referring to a function via a module (`moduleName.fnName`) references a set of functions. Passing or storing a function may refer to a specific function (`a = fn(...) { ... }`) or a set (`fn foo(...) { ... }; a = foo`); in the former case, no dispatch rules are applied (the latter case needs some thought and I would not support it initially).
Copy link
Collaborator

Choose a reason for hiding this comment

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

I don't understand how fn foo(...) { ... }; a = foo is a set of functions, the way I see it there's only 1 function here, it just has two names. I'm confused!

Copy link
Author

Choose a reason for hiding this comment

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

Ah sorry, that's not clear, it is a set with size one. In general there might be multiple foos with different selfs so the set might be larger

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.

2 participants