Tru is a programming language that I’ve been designing since June. And while I would like to develop it to one day become a language that rivals the likes of C++ and Rust in the systems programming space, I am first and foremost focusing hard on the language’s syntax.
The vast majority of new programming languages opt for the familiar rather than innovating for better syntax, and this has been a source of frustration for me when using these languages.
One such frustration has been function syntax, which is one of the first wheels I’ve reinvented in Tru as I aim to answer the question: What is the closest we can get to perfection?
Function Calls
To start, let’s have a look at some different syntax for function calls:
myFunc(5, "fizz", 4.2)
myFunc(foo = 5, bar = "fizz", baz = 4.2)
myFunc 5 "fizz" 4.2
myFunc ~foo:5 ~bar:"fizz" ~baz:4.2
myFunc 5, "fizz", 4.2
myFunc foo: 5, bar: "fizz", baz: 4.2
"All args must be named, and we call from an object."
myObj foo: 5 bar: 'fizz' baz: 4.2.
The first will be the most familiar, as it is the syntax used by C, C++, Rust, Python, Java, JavaScript, and the countless other languages with C-inspired syntax. And it’s no coincidence, as there are clear benefits to this syntax:
- It mirrors function calls taught in maths classes,
f(x, y, z). - It is clear that the arguments belong to the function when you start the parens directly after the identifier.
- There is clear distinction between
fooandfoo()- function pointer/value and function call respectively. - There is clear separation between the function identifier and identifiers passed as arguments, which can help reduce confusion without syntax highlighting.
- Commas are a natural way to separate a list of arguments and prevent the need for additional parentheses.
- For languages that support named arguments, the comma-separation allows them to fit naturally.
- When expanding the function args multi-line, you can track when they end by following the enclosing parentheses.
And, in my opinion, this is the best syntax out of the examples above. The rest lack as-explicit function call syntax, they have no clear separation between the function identifier and its arguments, and their named arguments generally just make things harder to read even though named arguments should make code easier to understand.
So let’s focus on the C-like syntax and see how it can be improved.
Improving
First of all, I’m not a fan of how the different args are immediately surrounded by different symbols based solely on their position. To make things more uniform, although it does take (not much) more horizontal space, I’d add additional whitespace:
# before
myFunc(foo = 5, bar = "fizz", baz = 4.2)
# after
myFunc( foo = 5 , bar = "fizz" , baz = 4.2 )
As far as I’m aware, this is already a practice used by many anyway since most languages don’t have strict formatting rules. But personally, I would enforce good conventions as they make code formatting uniform across all codebases. For example, notice how Rust enforces snake_case for variable and function names, but PascalCase for types and traits; not following this convention will emit warnings (that can be disabled, but I digress).
Next, we should look at how this syntax looks for function calls spanning multiple lines.
myFunc(
foo = 5,
bar = "fizz",
baz = 4.2,
)
I dislike this for two reasons:
- The closing parenthesis takes up a whole line of its own. While it is possible to make it inline with the final arg instead, doing so is unconventional in C-like syntax - it’s more of a Haskell-y thing in my experience.
- Trailing commas are a bandage over an issue that doesn’t really need to exist here.
This is where I make one of the most convention-breaking syntax decisions in Tru, which is to go all-in on significant whitespace like so:
myFunc!
foo = 5
bar = "fizz"
baz = 4.2
And for single-line function calls:
myFunc! foo = 5 ; bar = "fizz" ; baz = 4.2
This removes the parentheses, instead relying on significant whitespace to separate the args for multi-line calls, while maintaining explicit function call syntax and removing trailing commas. Then, we instead use the ; operator to inline code that would otherwise be multi-line - a concept that isn’t all too foreign to other languages. I also just generally prefer using ; here over , anyway since I feel it creates a more visually solid border between args.
I also like how it actually makes use of the fact that Tru has significant whitespace. Even in language with insignificant whitespace, like C, it is conventional to indent the args here anyway, so why not enforce this convention by actually making the whitespace significant?
Overall, I feel this ends up looking quite a bit nicer:
Window(title = "MyWindow",
size = Vec2(x = 800, y = 600),
fps = 30,
color = Turquoise,
)
Window! title = "MyWindow"
size = Vec! x = 800 ; y = 600
fps = 30
color = Turquoise
It’s also a fair bit easier to write as you don’t have to keep track of commas and closing parentheses.
Now, you might be one to argue that : would look nicer here instead of =, like Rust does with its struct initialisation args:
Window! title: "MyWindow"
size: Vec! x: 800 ; y: 600
fps: 30
color: Turquoise
And while I sympathise, I believe it’s better to keep consistent syntax throughout the language’s features. Tru already uses = for assignment, particularly because it can nicely form +=, -=, *=, etc., and also because : is reserved for starting blocks (like function bodies, as we’ll see in a second) - and if : wasn’t used for that, it’d probably be used for type annotations instead, as seen in Rust (foo: T).
So with that established, how should we go about function definition syntax?
Functions Defintions
Let’s have a look at how functions are defined in Rust, a language that is C-like but uses trailing types:
fn my_func(foo: u32, bar: String, baz: f64) -> bool {
/* ... */
}
However, Tru doesn’t have a function defintion statement like this. Such syntax is quite frankly redundant when you can already achieve the same thing using anonymous functions - or as I prefer to call them function literals:
let my_func = |foo: u32, bar: String, baz: f64| -> bool {
/* ... */
};
Let’s take a look at how this would be translated to Tru:
def myFunc #= fn! foo # U32 ; bar # String ; baz # F64 => Bool :
-- ... --
First off, notice that # is used for type annotation rather than :. As hinted at above, this is because : is already used to start a block, as can be seen at the end of the first line here. I could use a different symbol instead of :, like ->, but I find this to look the most fitting.
You will also notice that an explicit fn keyword is used rather than surrounding symbols to signify the start of a function literal. Again, no parentheses are used, similar to how Python, for whatever reason, doesn’t either with its lamda x, y, z. The ! follows the keyword purely so as to separate it from the args in some way, as it’s otherwise an eyesore without syntax highlighting.
Generally-speaking, I’d prefer if the language didn’t rely on syntax highlighting to remain easy to read and skim. Yes, the vast majority of developers do and definitely should use it, but these styles vary wildly between users, and sometimes you genuinely just end up reading code without it. You might be surprised to hear that Linus Torvalds of Linux and Git fame doesn’t use syntax highlighting .
And, again, this syntax is specifically designed to expand well across multiple lines:
let my_func = |
foo: u32,
bar: String,
baz: f64,
| -> bool {
/* ... */
};
def myFunc #= fn!
foo # U32
bar # String
baz # F64
=> Bool
:
-- ... --
No trailing commas are needed, and there’s no funky | -> T { or }; towards the end. Interestingly enough, it also ends up taking the same number of lines to write, even though the Rust code somehow feels more compact.
There is good reason to put the : on its own line as it both avoids needing an additional level of indentation for the function body and also allows you to remove the function’s return type by simply deleting that line.
To also explain some of the other design decisions which are less relevant to this article:
defis used instead ofletquite arbitrarily. I just think “define x that is…” reads better than “let x be…”.camelCaseis preferred oversnake_caseas it is more compact and thus is less likely to encourage shortening names for the sake of saving space. Yes, the space saved is minor in practice anyway, but a) space saved is space saved, and b)snake_casehas always even just felt longer to me for some reason - I mean, it literally snakes out.- All types use
PascalCasefor consistency. This includes the “primitive” types likeU32andBool. #=is the equivalent of:=, meaning “infer this type”. I haven’t seen a ligature for it yet, but it’s obviously very easy to design one since you can just connect the horizontal lines.fnis also an arbitrary choice againstfunorfuncor evenproc. It’s just short and sweet. However, it may change depending on whether I go withtypeortpfor type literals. There is the additional benefit that using short names like these allowfuncandtypeto be used as normal identifiers. For example, Rust ends up usingkindas an alternative to the reservedtypekeyword.=>is used over->merely because the other symbols like#,!, and:are also tall. At the time of writing,->isn’t used for anything else.:could be replaced with->, but I don’t like how the arrow ends up pointing into space when followed by a line break.--is used for comments like in Lua and Haskell. I just like how it imitates an aside in English punctuation - although I myself only use one dash lol. Note that a closing--does indeed end a comment, so inline comments are possible.
As a bonus, here is how you’d write the type signature of a function in Tru:
def myFunc # Fn! U32 ; String ; F64 => Bool =
fn! foo ; bar ; baz :
-- ... --
Furthermore: Positional Args
A decision I’ve been deliberating is wheter to allow when-I-feel-like-it positional args or make it so that args are explicitly declared as either named or positional.
For instance, Python makes it up to the caller:
add(1, 2)
add(x = 1, y = 2)
Whereas Swift has it strictly determined by the function definition:
func say(_ message: String, to name: String) {
print("\(message), \(name)!")
}
say("Hello", to: "Kojo")
// compile errors:
// say(message: "Hello", to: "Kojo")
// say("Hello", "Kojo")
Granted, Python does also have a way to disallow positional args too, just not by default.
Then there is Smalltalk which only lets you have named and no positional args.
I think positional args are useful though, as there are many cases where a name adds nothing useful:
add(2, 3)
print("Hello, world!")
player.setHealthTo(100)
Username("kojobailey")
add(a = 2, b = 3)
print(message = "Hello, world!")
player.setHealth(to = 100)
Username(new = "kojobailey")
I do think that Swift’s enforcement of named and positional args at a function’s definition is the way to go. I care deeply about code being consistent and unambiguous across codebases, and named function arguments are so helpful in understanding unfamiliar code that they really should be required when a useful name can be provided.
So how to go about this syntax? Well, my proposal is quite different from Swift and other languages as it doesn’t separate an internal and external name.
def add #= fn! @x # U32 ; @y # U32 => U32 : x + y
But I am not sold on this syntax yet, and it will depend on how other features in the language develop.
Plus, by restricting named vs positional args like this, there is a question of how that affects a function’s type signature. Should the arg names and named/position status be part of the type as well? Or should re-assignable functions allow changing of these?
In Conclusion…
There are still questions to answer and problems to solve regarding Tru’s function syntax and design, but so far, I think this is on the right path to more consistent and readable and less noisy code.
To finish off, here are some examples of Tru’s function syntax in action:
A lot of this is highly subject to change.
def main #= fn! :
printLine! "Hello, world!"
drawText! "Score: @score"
position = Vec2!
x = 400
y = 150
fontSize = 20
color = LightGray
map! Array[U8]<1 , 2 , 3 , 4>
mapping = fn! x : x * x
-- Function calls parse arguments greedily to the end of an expression.
-- Think Haskell's `$` operator.
f! x - y -- f! (x - y)
(f! x) - y
-- Binary operators must have spaces between them.
5-3 -- error
5 -3 -- error: 5 (-3)
5 - 3 -- 2
5 - -3 -- 8
-- Use parentheses where appropriate.
5 + sqrt! 4
(sqrt! 4) + 5
-- Call infix operators as normal functions.
+! 5 ; 3
(5 + (3 / 2)) * (24 - 7)
*! (+! 5 ; (/! 3 ; 2)) ; (-! 24 ; 7)
*!
+!
5
/!
3
2
-!
24
7
def calcQuadraticRoots #= fn!
a # Real
b # Real
c # Real
=> Real ; Real
:
def determinantSqrt #= root2! (pow2! b) - (4 * a * c)
return
(-b + determinantSqrt) / (2 * a)
(-b - determinantSqrt) / (2 * a)
-- 2x^2 + 2x - 12 = 0
calcQuadraticRoots! a = 2 ; b = 2 ; c = -12
-- 2 ; -3
