tomoyanonymous / mimium-rs

minimal musical medium- an infrastructural language for sound and music.
Mozilla Public License 2.0
8 stars 1 forks source link

Proposal: Record Syntax #99

Open tomoyanonymous opened 1 week ago

tomoyanonymous commented 1 week ago

Though old v0.4.0 had a experimental record (struct) type but I'm going to implement a new syntax for record type.

The new syntax design allows the code to more directly reflect the programmer's intent by grouping data by name, in addition to allowing default values to reduce the amount of code written.

Also, combining the parameter packs discussed in #58 with this default-valued record type will allow the concept of "UGen as a function" to be expressed directly while maintaining type safety, and without the confusion over the number of arguments that is common in function w/ default argument.

Basic Syntax

The syntax is based on Elm, I'm not going to make it similar to Rust here.

We use an anonymous record type positively.

let myadsr_param = { 
   attack = 100.0,
   decay = 200.0,
   sustain:float = 0.6, // type annotation is optional as like let expression 
   release = 2000.0, //trailing comma should be allowed
 }
let singlerecord = {
  value = 100, //a record with single field requires explicit trailing comma to distinct from block and assignment syntaxes.
}

I'm going to implement this later but in the type alias/newtype declaration, default value can be set. In the type declaration, default value is needed to be a literal.

type alias ADSR= { 
   attack:float = 100.0,
   decay:float = 200.0,
   sustain:float = 0.6,
   release:float = 2000.0,
 }

Each element can be accessed through dot operator, or captured at let pattern syntax.

let myattack = myadsr_param.attack

let {attack ..} = myadsr_param // note that the variable name depends on the record type declaration

With the partial application using underscore syntactic sugar, a little tricky code like this can be work.

let myattack = myadsr_param |> _.attack  // type can be correctly inferred

"Update" syntax inspired from elm is also available though we uses arrow instead of pipe as a separator(see following discussion).

let newadsr = { myadsr <- attack = 4000.0, decay = 2000.0 } // create new struct value while overwritng only attack field

This can be implemented as a syntax sugar like this.

let newadsr = { 
   attack = 4000.0,
   decay = myadsr_param.decay,
   sustain = myadsr_param.sustain,
   release = myadsr_param.release,
 }

Note that this expansion can only be done after the type inference. It will be implemented in mirgen.

Default Argument with function declaration

fn adsr(attack = 100, decay = 200, sustain:float = 0.7, release = 1000.0)->float{
 // implementation...
}
//This works of course.
adsr(200,400,0.5,200)

//THESE ARE DISALLOWED. See the following parameter pack explanation.
adsr()
adsr(attack =200)
adsr(200)

Auto Parameter pack with Record Type

In the function application for the function w/ more than 2 arguments, either of 2 cases are allowed.

  1. same number of arguments as the declaration
  2. single record argument that matches the type unification rule.
adsr(myadsr_param) // this is allowed!
//This evaluates everything with default arguments. 
adsr({..})
// Maybe the case above can be do like this with special syntax sugar
adsr(..)

This is the part I'm wondering now, kind of "anonymous update syntax". Let's think about the case of the function that has both of arguments with default-value and without.

fn myugen(freq, phase = 0.0, amp = 1.0){
// impl... user must give "freq" parameter
}
myugen({ freq = 200.0 .. }) //this should be allowed
myugen({ phase = 0.05 .. }) //this should be an error because freq is not given.

This {key = val ..} syntax used in right-hand value, same as in the let pattern capture, will emit something like ImcompleteRecord ast node, which has a ImcompleteRecord type. I guess that ImcompleteRecord expression can be used only as an argument, same as underscore for the partial application.

In the type system level, Record type has only the information whether the each field has a default value or not, not the value itself.

In the type inference, Record and ImcompleteRecord can be unified as like this.

//pseudo-code
Record{ 
 [(key:"freq",    type:float,default_v: false),
  (key:"phase", type:float,default_v: true),
  (key:"amp"   ,type:float,default_v: true), ]
}

ImcompleteRecord{
 [(key:"freq", type:float ),]
}
===unify===>
Record{ 
 [(key:"freq",    type:float,default_v: true),
  (key:"phase", type:float,default_v: true),
  (key:"amp"   ,type:float,default_v: true), ]
}

Any ImcompleteRecord type left in the end of type inference, it should be a compile error.

Then, if the single ImcompleteRecord argument was given for the multi-arguments function(case 2), the pre-mirgen syntax sugar remover expands the ast to like this using update syntax.

let default_v = {freq = 0.0, phase = 0.0, amp=1.0 } //generate based on the type information of "myugen". The value for freq can be anything because it will be overwritten anyway.
myugen( { default_v <- freq = 200.0 } )

I don't know how to implement the logic to generate default value but it will be a kind of restricted version of typeclass(interface) like Rust's Default trait.

tomoyanonymous commented 1 week ago

Random thoughts:

For this code, can the compiler infer correct type?

{ freq = 200.0 .. } |> myugen(_)
tomoyanonymous commented 1 week ago

Elm's update syntax may conflict with an existing syntactic rule.

{ default_v | freq = 200.0 } can be parsed as "block(bitor(var(default_v) ,assign(var(freq),float(200.0))))" though it apparently violates typing rule.

Other candidates: use arrow?

{ default_v <- freq = 200.0 } or { freq = 200.0 -> default_v }

tomoyanonymous commented 1 week ago

The other concern: should we allow function as a member of the record?

It makes difficult to implement a garbage collection(need to implement destructor functionality).