Stream: ideas

Topic: Eureka moment for ROC function syntax


view this post on Zulip Brian Teague (Feb 23 2024 at 21:53):

It took me a week to put this together, but I think it could have big potential.

====== Problem Summary
--Roc has three different ways to handle multiple arguments

--Lambda with args
lambda : Num a, Num a, Num a -> Num a
lambda = \a, b, c -> a + b + c

--Tuple - Unnamed variables, accessed using index
tupleAccess: (Num a, Num a, Num a)* -> Num a
tupleAccess = \args -> args.0 + args.1 + args.2

tupleDestructured: (Num a, Num a, Num a)* -> Num a
tupleDestructured = \(a, b, c) -> a + b + c

--Record - Named variables, optional variables, accessed using property name
recordAccess : { a : Num a, b : Num a, c : Num a }* -> Num a
recordAccess = \obj -> obj.a + obj.b + obj.c

recordDestructured: { a: Num a, b: Num a, c: Num a }* -> Num a
recordDestructured = \{a, b, c} -> a + b + c

--These three examples are logically equivalent and return the same result,
--    but have different internal implementations.
-- Can we have a syntax that is both simple and meets these requirements:
--   optional arguments and accessing the values with simple destructuring
--   and using existing lambda syntax?

====== Eureka !!

--Below is the result of combining the functionality into a simplified syntax
--   that meets the functionality of value access and optional parameters.

--Lamda would just be a destructured record using the variable names, no syntax changes here
--When the function is called demonstrates how it would be used as a record
lambdaAsRecord : Num a, Num a, Num a -> Num a
lambdaAsRecord = \a, b, c -> a + b + c

--Best practice would be matching variable names to function arguments
variableNamesMatch =
    a = 1
    b = 2
    c = 3
    lambdaAsRecord a b c

--Non-matching variable names or literal values would require mapping
--    variable names when calling the function
differentVariableNames =
    x = 1
    y = 2
    lambdaAsRecord a:x b:y c:3

--Optional arguments still supported and defaulted when "destructured" / variables are defined
recordWithOptional : Num a, Num a, ? Num a -> Num a
recordWithOptional = \a, b, c ? 0 -> a + b + c

--Examples where variable names match, or need to be mapped
callRecordWithOptional =
    a = 1
    b = 2
    recordWithOptional a b

callRecordWithOptional =
    recordWithOptional a:1 b:2 c:3

callRecordWithOptional =
    recordWithOptional a:1 b:2

--No impacts to definition, only how it is called
isEven : Int -> Bool
isEven = \num -> num % 2 == 0

--Have to match or map variable name when calling single argument lambda as record
callIsEven = isEven num:4
callIsEven2 =
    num = 4
    isEven num

-- Variable names have to be explicit for nested records, but the object
nestedRecord : { a : Num a, b : Num a, c : Num a } -> Num
nestedRecord = \object -> object.a + object.b + object.c

--Don’t have to specify record property names until destructured or function is called.
Point a : { x: Frac a, y: Frac a, z ? Frac a }

distanceBetweenPoints : Point, Point -> Frac a
distanceBetweenPoints = \p1, p2 ->
    { x: x1, y: y1, z: z1 ? 0.0 } = point1
    { x: x2, y: y2, z: z2 ? 0.0 } = point2
    power = 2.0
    Num.sqrt x:( --Need to match Num.sqrt variable name
        Num.pow base:(x2 - x1) power + --Need to match Num.pow variable names
        Num.pow base:(y2 - y1) power +
        Num.pow base:(z2 - z1) power
    )

--List.range example
createRange =
    List.range start:At 0 end:Before 10

createEvens =
   List.range start:After 1 end:At 10 step:2

===== Conclusion

--If all function calls use record based access, function calls would be more expressive
--The compiler would expect variable names to match at compile time.
--Function call arguments that don't match the variable name definitions would throw a compile error.
--Order of variable names when a function is called would no longer matter
--Based on how both the function is defined and called, the compiler can probably optimize the execution

view this post on Zulip Kevin Gillette (Feb 24 2024 at 05:09):

For my own part, I'll contemplate this over a few days. One minor thought: your last few examples may need to be parenthesized like:

List.range start:(At 0) end:(Before 10)

view this post on Zulip Anton (Feb 24 2024 at 10:29):

I think no parenthesis are required in this case because the arguments do not contain any function calls.

view this post on Zulip Anton (Feb 24 2024 at 10:31):

I do like it when an IDE shows me the argument names but I am concerned with verbosity and how different it is compared to other popular programming languages.

view this post on Zulip Norbert Hajagos (Feb 24 2024 at 13:33):

I get the desire for a unifying concept, but the current impl is clear for the majority I presume, and this proposal would make roc more verbose (i think unnecesarily). This would push Roc towards a "big idea" language, where you could point out how cool it is that every argument is a record. We would also loose piping|>. If you want you api arguments to be written out at the call site, you use a record, which is 2 more characters that the proposed syntax. This is good for List.range, where it would not be clear what the bouds are just by looking at the program. It is not good for all the functions though. Like I wouldn't want to write this:

myList = [1, 2, 3]
List.get list:myList index:3

It would also insentivise people to use shorter variable names, to make it less painful to call functions. I can easily imagine List.get becoming:
List.get = \l, i ->
This is fine for this small of a function, but the tendency is worrying.

Also, it is usually advantageous to not call your variable the way they are called inside of a function you use. Let's you focus on the business logic. Like if I want to count how may Hamsters I have in my cages (have so much you need an app for it), I prefere this code:

hamstersInCages = [5, 6, 3]
List.sum hamstersInCages

With named args, I would write this

hamstersInCages = [5, 6, 3]
List.sum list:hamstersInCages

except as a human being, I gravitate towards the lowest resistance, so I would write

list = [5, 6, 3]
List.sum list

We have lost the focus from the domain. This can be debated if it is a good thing or not. In higher lvl application code, I think it is bad. The next example if pretty wild, but (when things go out of hands, which they tend to do) the prev example could become this to explain why we need the list:

# Represents the number of hamsters inside of my cages
list = [5, 6, 3]
List.sum list

Idk if this is also part of the idea, or you glanced over it, but this snippet is invalid Roc:

distanceBetweenPoints = \p1, p2 ->
    { x: x1, y: y1, z: z1 ? 0.0 } = point1
    { x: x2, y: y2, z: z2 ? 0.0 } = point2

The tutorial states why this is not allowed:

Destructuring is the only way to implement a record with optional fields [...]

This means it's never possible to end up with an optional value that exists outside a record field. Optionality is a concept that exists only in record fields, and it's intended for the use case of config records like this. The ergonomics of destructuring mean this wouldn't be a good fit for data modeling, consider using a Result type instead.

If I want to be cheaky, I could write this, which isn't something I want to be able to do:

distanceBetweenPoints = \p1, p2 ->
    # What's the value of myEvilVariable?
    myEvilVariable = p1.z
    { x: x1, y: y1, z: z1 ? 0.0 } = point1
    { x: x2, y: y2, z: z2 ? 0.0 } = point2

This became long. I really don't mean to bash on your idea, since you have to same goal as me: Make Roc Great! Three ways to handle multiple args seems too much indeed. In practice though, tuple arguments are really rare I think. The std doesn't have that many and I find myself almost always refactoring them to be a record, making Roc basically have 2 types of args simultaneously : positional and named.

view this post on Zulip Brendan Hansknecht (Feb 24 2024 at 16:02):

While there is merit to this idea, I don't think the problem statement is accurate.

Roc has a single way to handle function arguments. There are simply multiple types that can be passed into a function.
That said, there is a caveat here.

Roc does allow for both building and destructuring an argument directly when calling the function, but that is not a whole new way to handle arguments.

As more concrete example:

-- This is how a tuple is meant to be used.
-- Not some special argument syntax, just a logical grouping.
Point a : (Num a, Num a, Num a)
tupleDestructured : Point a -> Num a
tupleDestructured = \(x, y, z) -> x + y + z

-- This is how a record is generally used.
Person : { name: Str, age: U8 }
recordAccess: Person -> U8
recordAccess = \person -> person.age

That said, there are two specific holes in Roc's calling syntax:

  1. Optional Record fields: I honestly think this are the biggest hole by a long shot. They can't be used anywhere else. They are only valid in record destructuring in a function call. They can be written in any type alias, yet doing so will almost certainly be wrong and lead to bugs. E.G. #beginners > Compiler says optional record fields are missing. I think it would be great to either make these way more general or remove them from the language. They just don't match anything else in the langauge. On top of that, there is no way to get an optional argument without using optional record fields. Definitely an inconsistency in the calling convention. (aside: they really should be called default valued record fields).
  2. Named arguments: They just don't exist. As such you have to use records to gain the feature. That said, records come with an abi overhead that really shouldn't be needed for this feature to exist.

All this said, I think an opt-in syntax like this could potentially be useful. Then we can stop band-aiding over missing features with records.

Key words here are opt in though. I don't think we should take away standard positional arguments with any arbitrary name. @Norbert Hajagos comment goes over this quite well.

view this post on Zulip Norbert Hajagos (Feb 24 2024 at 16:43):

Yes, "default valued record fields" would be a better name. I am going to make a new topic for that (to me seems small change, but maybe others would have longer comments on it).

I haven't felt the hole with the current system. Tho when I create a tail recursive function, it would be great not having to include a record, just so that I can have the accumulator be initialized to a default value. Because of that, I often create a myRecFunctionHelp with an accumulator parameter and pass the default value from myRecFunction.

About records as named arguments having overhead: I know it is easy to say "the compiler will optimize that", but... couldn't it do that? (I don't quite understand what the overhead is in the first place tho). If the parameters are destructured inside the function definition, we know only the individual fields will be used and there is no need for a record. I guess the hard part is at the call site. But those calls that have an anonymous record constructed only to be immediately passed into the function could be transformed. I suspet those will be the most common use cases for functions like List.range. So functions like this:

List.range l {start: At 0, end: Before 10}

But not these

bounds = getBounds ...
List.range l bounds

view this post on Zulip Brian Teague (Feb 25 2024 at 04:45):

You're right, I missed the fact that tuples and records are not multiple arguments, but are a single type pass as an argument to functions. While reviewing these replies, I realized I need to shift focus on how defaults work. I'll post a new thread on that subject.


Last updated: Jun 16 2026 at 16:19 UTC