KCL part 3: look and feel
This post isn't about a single feature or implementation detail, but about the overall experience of using KCL. PL people sometimes denigrate syntax and ergonomics as superficial, but it is the interface between the user and the language, and UI matters! The first impression of a language can dictate whether a potential user will bother to learn the language. Code being easier to read and write will make users more productive using the language (some people argue that code being easier to read and write is purely subjective or doesn't matter, they are wrong). A language which is pleasant to use will get better word-of-mouth marketing and better user retention.
For KCL specifically, the language is a point of differentiation for the product, and the audience are not experienced programmers. The ergonomics of the language are even more important than for a general purpose language; users have lower tolerance for complexity.
I identified a bunch of paper cuts which I thought made KCL noisy or awkward, and worked to remove them (with a lot of help from others, especially Adam who took on the monstrous task of converting the whole standard library to using keyword arguments). The changes look minor, but together they made KCL programs much clearer, and programming more ergonomic. Not all of these are purely syntactic, some required changes to the semantics of the language as well, but in pretty small ways.
However, despite the changes being small, they often needed a lot of work to make the change. The root cause for this is that the AST is (or at least was) pretty concrete and used throughout the interpreter. How concrete to make an AST is always a trade-off. Making it more abstract makes it easier to analyse and change over time, but means you lose some detail and make it harder to reconstruct source text. Lowering the AST to a more abstract form (e.g., the HIR in rustc) before any non-syntactic analysis is a good approach, but has some performance implications, increases complexity, and still has trade-offs (albeit easier ones). It is typical to start implementing an interpreter or compiler by using the AST for all analyses, then as the project gets more mature, use more representations of the program which makes each analysis easier. For an interpreter (especially one with relatively simple analysis and performance constraints), there is more of a trade-off in using these extra representations (which give rise to things like JIT VMs where hotspot code is optimised using extra representations, but most code isn't). KCL only uses an AST, which means that changing the AST data structures affects a lot of code.
The changes
% syntax
% is used in KCL as a 'variable' pointing at the current target of a pipeline (the foo in foo |> bar() when calling bar). Since it is extremely common for an argument of a function to be the pipeline target, nearly every function call looked like |> bar(..., %). This causes a lot of noise for little benefit. By changing the rules around the first function argument (treated a bit like the receiver of a method call in OO languages), the % could simply be dropped in nearly all cases, e.g., |> bar(...). There are still a few cases where the % is required (nested function calls, basically). I had ideas for eliding these too, but they either required a little too much implicit magic, or they restricted expressivity too much.
Named arguments
Functions in KCL took a single argument, and used a struct to support multiple, named arguments. This meant that nearly most function call had both parentheses and braces: foo({ ... }). We added support for multiple, named arguments (often called keyword arguments), which meant that function calls could drop the { }.
Function declarations
The old function declaration syntax in KCL looked like fn add = (x, delta) => { ... }. This was somewhat consistent with variable declaration and familiar to Javascript/Typescript programmers. However, the intended audience of KCL are mostly non-programmers or Python programmers. I simplified the syntax to fn add(@x, delta) { ... }, making function declarations less noisy. The @ indicates an unnamed argument: only one is allowed, and that argument is distinguished in several ways (e.g., able to be used as the input in a pipeline). I also added support for types on arguments. These are checked dynamically; the primary use cases are better documentation and better IDE support.
Attributes
This is a new feature, rather than polishing an existing one, but it is a more disciplined way to do a several things done previously in an ad hoc manner. Attributes use a @ syntax and may be named and/or have a parameter list, e.g., @settings(defaultLengthUnit = ft), @(lengthUnit = ft), or @no_std. The syntax is familiar from other languages (though doesn't follow any exactly), and also follows the named argument syntax of function calls.
Numbers
KCL does not have categories of numbers (u8, f64, etc.), it just has numbers. Internally they were represented as either floats or integers and this leaked into user code. E.g., the literal 1 is an integer but 1.0 is a float, and they couldn't be used interchangeably. This led to lots of int() scattered around KCL programs, as well as some confusing errors and behaviour.
I fixed this by making all numbers floats. I considered decimal and other representations too, but since nearly all numbers in KCL are measurement (it's very unlikely anyone would need to represent currency in KCL, for example), potentially of very different magnitudes, using floats seems fine. This required some fine-tuning around rounding, but the result is good.
I also added units to numbers (discussed previously). This added some syntactic complexity, but I think that is justified by the benefits. I put in a bunch of work to make the system simpler for programmers (also described in that blog post).
Object initialisation
KCL has objects which are similar to structs or records in other languages. These used to have initialisation syntax similar to Javascript or Rust, { (field: expr,)* }. I changed this to { (field = expr,)* }. This is a pretty minor change, but it means that : is fully reserved for type annotations. It also means initialising an object field is the same as initialising a local variable or using a named argument in a function call. This all makes the language more consistent and hopefully intuitive. As mentioned above we changed from passing objects to named arguments in functional calls, the change in object initialisation made this migration easier for existing users.
As an aside, I'd love to have made this change to Rust too, but it was considered too big a change just before the 1.0 release. That is unfortunate since it made the type ascription syntax clunky. Other than the cost of the change, a difference between the two situations is that for Rust, being syntactically similar to other languages was important, but for KCL there wasn't as much reason for that because users were likely to have less programming experience and where they did, it was most likely to be with Python (which has both syntaxes: = for dataclasses and named arguments, : for dictionaries).
Magic strings
There were a lot of magic strings used in KCL, for example "X" for axes, "XY" for planes, "CW" for clockwise, "START" and "END" for identifying the start and end of an extrusion, etc. The requirement for quote marks made KCL look noisy (these strings are very commonly used), and using strings rather than typed values made type checking weaker (even though KCL is mostly dynamically typed, this still has negative effects, e.g., on error messages and IDE help). This is not helped by KCL accepting either single or double quotes for string literals, so a program can have either punctuation but with the same semantics.
I added support for standard library constants and migrated to using these for all magic strings.
And some things I couldn't fix
There are a few things I couldn't fix, including:
- Some remaining uses of
%- sometimes you do really want to name the pipeline target and the simple, implicit scheme doesn't work. I think there are solutions here, but it's more of a trade-off because the solutions are not better in every respect and there are diminishing returns, so the cost-benefit is not great. - Tag syntax - tags are used to refer to geometry and have some interesting semantics around declaration and scoping (see the before/after example, below). They also introduce some mutability into the otherwise immutable world of KCL, for example a tag which refers to a line, will refer to a plane after extrusion. I wanted to come up with a design which was simpler and eliminated the mutability. I had an early draft of a design, but it was clearly a huge change which would have taken too long to implement.
- Point syntax - points in KCL are 2- or 3-element arrays. I would have liked to make them a built-in type because they are so fundamental to modelling. We could then differentiate between points and vectors in the type system as well as unrelated arrays. However, this was another large, invasive change where the benefit didn't justify the cost.
Before and after
Here is, I think, an illustrative example. There are a few changes other than those described in this post, some I was involved in and some not. I've tidied up the formatting to make the before and after more similarly formatted for an easier comparison.
fn circl = (x, tag) => {
return startSketchOn(p, tag)
|> startProfileAt([x + radius, triangleHeight / 2], %)
|> arc(
{
angle_start: 0,
angle_end: 360,
radius: radius
},
%,
'arc-' + tag
)
|> close(%)
}
const plumbus1 = circl(-200, 'c')
|> extrude(plumbusLen, %)
|> fillet({
radius: 5,
tags: [
'arc-c',
getOppositeEdge('arc-c', %)
]
},
%
)
fn circl(x, face) {
return startSketchOn(p, face = face)
|> startProfile(at = [x + radius, triangleHeight / 2])
|> arc(
angleStart = 0,
angleEnd = 360,
radius = radius,
tag = $arc_tag,
)
|> close()
}
plumbus1 = circl(x = -200, face = c)
|> extrude(length = plumbusLen)
|> fillet(
radius = 5,
tags = [
c1.tags.arc_tag,
getOppositeEdge(c1.tags.arc_tag)
],
)
Hopefully you'll agree that, while the examples are pretty similar and there are no really significant differences, the 'after' version is a bit less noisy, easier to read, and more consistent.