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

Better record construction syntax for optional values #617

Open
isaksky opened this Issue Nov 9, 2017 · 12 comments

Comments

Projects
None yet
7 participants
@isaksky

isaksky commented Nov 9, 2017

The current record constructors are problematic, because if you add a field to it, everywhere that uses it is broken. Adding a field to a record is therefore often not backwards compatible, which is a big downside, and leads to some authors avoiding records altogether if they may be exposed externally.

This is a shame, because records and the record construction syntax are otherwise fantastic parts of F#.

Solution: Better record construction syntax

To solve this problem, I propose we add a new record construction syntax that is more clear, more tooling friendly, more concise, and less brittle. The current record constructors are problematic, because if you add a field

Summary:

  1. Introduce %RecordName { ... } syntax for constructing records (stolen from elixir)
  2. Allow omitting fields with obvious "zero" values (such as None for option)
  • null for reference types does not count
  1. In addition to 2, or instead of 2: Allow omitting fields for fields that have a default defined in the record definition

Examples:

type Person = { fullName: string; email: string option; }

// A symbol like `%` (stolen from elixir) to say you want to start creating the record
// We leave out the optional field "email" here, and it still compiles. It will be set to None.
let person1 = %Person { fullName = "Don Syme";  }

// This does NOT compile, because fullName is not an option, and we don't want to encourage null values
let person3 = %Person { }

// Some other cases to consider
type InventoryRow = { name: string; weight: float; tags: string list; quantity: int }
let row1 = %InventoryRow { name =  "Bungie" }
// This would be equivalent to:
let row2 = { name = "Bungie"; weight = 0.0f; tags = []; quantity = 0 }

For the alternative version:

type InventoryRow = 
  { name: string = "<unnamed>"; 
    weight: float; 
    tags: string list = ["unlabeled"]; 
    quantity: int = 1 }

let row1 = %InventoryRow { weight = 1.0f }  // allowed

// no weight specified, a field that has no default specified.
// Allowed only if we go with AND for part 3 of the suggestion
let row2 = %InventoryRow {  }  

Pros and Cons

  • Adding an optional field to a record will no longer lead to tons of compilation errors all over your codebase (everywhere you used record construction syntax)
  • When typing your code, you type your intent to make a new X record at the very beginning, which leads to better / easier to implement editor support
  • Faster type checking
  • Better error messages
  • Increased clarity

The disadvantages of making this adjustment to F# are ...

  • There would be more than 1 way to construct records, adding confusion.

Extra information

For point 2 of the summary, some implicit defaults to consider for types:

  • Option : None
  • Collections: Empty collections
  • Numbers : 0
  • Built in non-numbers that have a .MinValue property. for example TimeSpan, DateTime.

Somewhat related: Kotlin data classes.

Estimated cost (XS, S, M, L, XL, XXL): Unknown.

Related suggestions: (put links to related suggestions here)

Affidavit (please submit!)

Please tick this by placing a cross in the box:

  • This is not a question (e.g. like one you might ask on stackoverflow) and I have searched stackoverflow for discussions of this issue
  • I have searched both open and closed suggestions on this site and believe this is not a duplicate
  • This is not something which has obviously "already been decided" in previous versions of F#. If you're questioning a fundamental design decision that has obviously already been taken (e.g. "Make F# untyped") then please don't submit it.

Please tick all that apply:

  • This is not a breaking change to the F# language design
  • I or my company would be willing to help implement and/or test this
@cartermp

This comment has been minimized.

Show comment
Hide comment
@cartermp

cartermp Nov 9, 2017

Member

Overall I think this is a good idea. Two questions:

  1. You listed better error messages as a benefit. What did you have in mind w.r.t improvements?
  2. Off the top of your head, what would you count as having an obvious zero value? Not looking for a detailed breakdown, just curious what your thoughts are here.
Member

cartermp commented Nov 9, 2017

Overall I think this is a good idea. Two questions:

  1. You listed better error messages as a benefit. What did you have in mind w.r.t improvements?
  2. Off the top of your head, what would you count as having an obvious zero value? Not looking for a detailed breakdown, just curious what your thoughts are here.
@Rickasaurus

This comment has been minimized.

Show comment
Hide comment
@Rickasaurus

Rickasaurus Nov 9, 2017

Did you know you can specify the type of the record currently in other ways like:
let person1 : Person = { fullName = "Don Syme"; email = None }
or
let person2 = { fullName = "Don Syme"; email = None } : Person

Maybe another way to go about the options as optional thing would be with a keyword like

let person3 = optionsBeGone { fullName = "Don Syme" }

As it would mirror the way struct tuples and such look.

Rickasaurus commented Nov 9, 2017

Did you know you can specify the type of the record currently in other ways like:
let person1 : Person = { fullName = "Don Syme"; email = None }
or
let person2 = { fullName = "Don Syme"; email = None } : Person

Maybe another way to go about the options as optional thing would be with a keyword like

let person3 = optionsBeGone { fullName = "Don Syme" }

As it would mirror the way struct tuples and such look.

@isaksky

This comment has been minimized.

Show comment
Hide comment
@isaksky

isaksky Nov 9, 2017

@cartermp

  1. In some cases now there is ambiguity from guessing the type the user is trying to construct based only on the field names. For example, imagine if you were trying to construct the A type here. :

image
The error message assumes you meant to construct B.

  1. Just off the top of my head, I would consider:
  • Option : None
  • Collections: Empty collections
  • Numbers : 0
  • Built in non-numbers that have a .MinValue property. for example TimeSpan, DateTime.

isaksky commented Nov 9, 2017

@cartermp

  1. In some cases now there is ambiguity from guessing the type the user is trying to construct based only on the field names. For example, imagine if you were trying to construct the A type here. :

image
The error message assumes you meant to construct B.

  1. Just off the top of my head, I would consider:
  • Option : None
  • Collections: Empty collections
  • Numbers : 0
  • Built in non-numbers that have a .MinValue property. for example TimeSpan, DateTime.
@isaksky

This comment has been minimized.

Show comment
Hide comment
@isaksky

isaksky Nov 9, 2017

@Rickasaurus I've seen that, and I think for the former, the problem is it may be far away from the actual {...} syntax at the end of the expression, which means editor support cannot be as good.

For example, imagine you wanted to make a feature that automatically filled added fullName: <cursor here now > } after you typed %Person {. One could not do that when typing { with the let a : Person = .. syntax, because you do not know if the expression being typed will end up in a return position.

For the latter variation, the problem is it comes after, which is also problematic for tooling support.

isaksky commented Nov 9, 2017

@Rickasaurus I've seen that, and I think for the former, the problem is it may be far away from the actual {...} syntax at the end of the expression, which means editor support cannot be as good.

For example, imagine you wanted to make a feature that automatically filled added fullName: <cursor here now > } after you typed %Person {. One could not do that when typing { with the let a : Person = .. syntax, because you do not know if the expression being typed will end up in a return position.

For the latter variation, the problem is it comes after, which is also problematic for tooling support.

@cartermp

This comment has been minimized.

Show comment
Hide comment
@cartermp

cartermp Nov 9, 2017

Member

@isaksky Makes sense! And w.r.t your tooling point, this does indeed make it easier. Eventually, we'll spec out what IntelliSense for F# needs to look like, and it's likely that we'll take a similar approach that Roslyn did where there are multiple providers. This would be a case for another IntelliSense provider and fits snugly with what I think to be a good approach to IntelliSense.

Member

cartermp commented Nov 9, 2017

@isaksky Makes sense! And w.r.t your tooling point, this does indeed make it easier. Eventually, we'll spec out what IntelliSense for F# needs to look like, and it's likely that we'll take a similar approach that Roslyn did where there are multiple providers. This would be a case for another IntelliSense provider and fits snugly with what I think to be a good approach to IntelliSense.

@theprash

This comment has been minimized.

Show comment
Hide comment
@theprash

theprash Nov 9, 2017

Regarding inference of the correct record type, I often use the fully qualified field name for the first field as a type hint:

let person1 = { Person.fullName = "Don Syme"; email = None }

Regarding default values, this seems to go against the philosophy of F# in other places, where it avoids implicit default values like those in C#, which are more useful with mutation. None as a default for option is fine, but why should the default of int be zero, and why should the default of int list be []? I think I'd prefer those defaults to be made explicit in code:

let makePerson fullName = { Person.fullName = fullName; email = None; productivityFactor = 1.0 }

let person1 = { makePerson "Don Syme" with productivityFactor = 10.0 }

Using a function, we can specify the mandatory values and this also gives you the type inference.

theprash commented Nov 9, 2017

Regarding inference of the correct record type, I often use the fully qualified field name for the first field as a type hint:

let person1 = { Person.fullName = "Don Syme"; email = None }

Regarding default values, this seems to go against the philosophy of F# in other places, where it avoids implicit default values like those in C#, which are more useful with mutation. None as a default for option is fine, but why should the default of int be zero, and why should the default of int list be []? I think I'd prefer those defaults to be made explicit in code:

let makePerson fullName = { Person.fullName = fullName; email = None; productivityFactor = 1.0 }

let person1 = { makePerson "Don Syme" with productivityFactor = 10.0 }

Using a function, we can specify the mandatory values and this also gives you the type inference.

@isaksky

This comment has been minimized.

Show comment
Hide comment
@isaksky

isaksky Nov 9, 2017

@theprash A slight variation that is slightly more explicit would be:

type InventoryRow = 
  { name: string = "<unnamed>"; 
    weight: float; 
    tags: string list = ["unlabeled"]; 
    quantity: int = 1 }

let row1 = %InventoryRow { weight = 1.0f }  // allowed

// no weight specified, a field that has no default specified. Allowed? [0]
let row2 = %InventoryRow {  }  

So allow supplying default values in the record definition that will be used when nothing is specified for the field during initial construction.

This way everything would (or could, depending on [0]) be explicit, but we'd still have the benefits discussed earlier.

I think most people wouldn't be too shocked if [] was the default value for a list, and 0 for a number, but I don't have any evidence to point to.

isaksky commented Nov 9, 2017

@theprash A slight variation that is slightly more explicit would be:

type InventoryRow = 
  { name: string = "<unnamed>"; 
    weight: float; 
    tags: string list = ["unlabeled"]; 
    quantity: int = 1 }

let row1 = %InventoryRow { weight = 1.0f }  // allowed

// no weight specified, a field that has no default specified. Allowed? [0]
let row2 = %InventoryRow {  }  

So allow supplying default values in the record definition that will be used when nothing is specified for the field during initial construction.

This way everything would (or could, depending on [0]) be explicit, but we'd still have the benefits discussed earlier.

I think most people wouldn't be too shocked if [] was the default value for a list, and 0 for a number, but I don't have any evidence to point to.

@isaksky

This comment has been minimized.

Show comment
Hide comment
@isaksky

isaksky Nov 9, 2017

One of the goals of this is to prevent needing to make record constructors private because of how brittle they are. As things stand now, you cannot expose them in libraries or non-trivial programs, because if you need to add a field, you just broke a ton of code. Even if none of that code cares about your field at all.

Yes, you can make a factory type function that you expose instead, but then it is no longer first class, and people have to know about it.

isaksky commented Nov 9, 2017

One of the goals of this is to prevent needing to make record constructors private because of how brittle they are. As things stand now, you cannot expose them in libraries or non-trivial programs, because if you need to add a field, you just broke a ton of code. Even if none of that code cares about your field at all.

Yes, you can make a factory type function that you expose instead, but then it is no longer first class, and people have to know about it.

@isaacabraham

This comment has been minimized.

Show comment
Hide comment
@isaacabraham

isaacabraham Nov 9, 2017

The "setting defaults" worries me. You add a new field to a record and won't know that you've forgotten to initialise it - it'll just be set to an empty list or 0.0 or whatever.

isaacabraham commented Nov 9, 2017

The "setting defaults" worries me. You add a new field to a record and won't know that you've forgotten to initialise it - it'll just be set to an empty list or 0.0 or whatever.

@enricosada

This comment has been minimized.

Show comment
Hide comment
@enricosada

enricosada Nov 10, 2017

I dont like the initial proposal % to default optional/part of fields.
I have some issues with that:

  • seems like default initialization of field of c#. While is ok in c# lang, i dont think is a feature i like to have in F#.
  • is hard to define default in compiler side. you need to restrict it to some types, like options/int? is the zero/empty? what about types without that, like DateTime? why i need to care about few cases? for classes? what about user types? dev need to know what is the default, so rules to learn (in .NET , default of classes object is null. That as example string, the default is null, not the empty string).
  • personally, i like that when i add a new field, the whole program doesnt compile, so i see exactly where i used that. and the new value for that field maybe is not the same, depends on where is used.
  • if i want to create a default for other consumers, is often domain specific or for some use case so i add a value (State.initial, Array.empty, etc) or function (mkUser name)
  • creating a real default value for a type, is also faster, because can be reused/optimized/inlined. but that can be done intenrally by compiler (but more work to implement)

That said, the second variation you proposed is better.

type InventoryRow = 
  { name: string = "<unnamed>"; 
    weight: float; 
    tags: string list = ["unlabeled"]; 
    quantity: int = 1 }

but that mean the values are harder to bind. what is going to be allowed?

  name: string = "<unnamed>".Trim(); 
  name: string = f "<unnamed>"; 
  name: string = String.empty; 
  name: string = myInitialValue; 

enricosada commented Nov 10, 2017

I dont like the initial proposal % to default optional/part of fields.
I have some issues with that:

  • seems like default initialization of field of c#. While is ok in c# lang, i dont think is a feature i like to have in F#.
  • is hard to define default in compiler side. you need to restrict it to some types, like options/int? is the zero/empty? what about types without that, like DateTime? why i need to care about few cases? for classes? what about user types? dev need to know what is the default, so rules to learn (in .NET , default of classes object is null. That as example string, the default is null, not the empty string).
  • personally, i like that when i add a new field, the whole program doesnt compile, so i see exactly where i used that. and the new value for that field maybe is not the same, depends on where is used.
  • if i want to create a default for other consumers, is often domain specific or for some use case so i add a value (State.initial, Array.empty, etc) or function (mkUser name)
  • creating a real default value for a type, is also faster, because can be reused/optimized/inlined. but that can be done intenrally by compiler (but more work to implement)

That said, the second variation you proposed is better.

type InventoryRow = 
  { name: string = "<unnamed>"; 
    weight: float; 
    tags: string list = ["unlabeled"]; 
    quantity: int = 1 }

but that mean the values are harder to bind. what is going to be allowed?

  name: string = "<unnamed>".Trim(); 
  name: string = f "<unnamed>"; 
  name: string = String.empty; 
  name: string = myInitialValue; 
@isaksky

This comment has been minimized.

Show comment
Hide comment
@isaksky

isaksky Nov 10, 2017

@isaacabraham @enricosada

F# already has this for classes:

and InventoryRow()=
  member val name : string = "<unnamed>" with get, set
  member val weight : float = 0. with get, set
  member val tags: string list = ["unlabeled"] with get, set

Do you think this is a problematic feature of F#? If not, what is so different about this case (especially the second variation)?

Keep in mind - if you want to be explicit, and have code that breaks when you add a new field, you already have that option, and will continue to have it even if this alternate syntax is added.

@enricosada Also, we definitely would not want null to ever be a default - that would go against the goals of F#. If we go with the version that adds an implicit default value for some types, it would only be for ones that have a sensible default value, like None for option.

To make it easier on people just starting to read this, I'll update the main post with the other variation too.

isaksky commented Nov 10, 2017

@isaacabraham @enricosada

F# already has this for classes:

and InventoryRow()=
  member val name : string = "<unnamed>" with get, set
  member val weight : float = 0. with get, set
  member val tags: string list = ["unlabeled"] with get, set

Do you think this is a problematic feature of F#? If not, what is so different about this case (especially the second variation)?

Keep in mind - if you want to be explicit, and have code that breaks when you add a new field, you already have that option, and will continue to have it even if this alternate syntax is added.

@enricosada Also, we definitely would not want null to ever be a default - that would go against the goals of F#. If we go with the version that adds an implicit default value for some types, it would only be for ones that have a sensible default value, like None for option.

To make it easier on people just starting to read this, I'll update the main post with the other variation too.

@dsyme

This comment has been minimized.

Show comment
Hide comment
@dsyme

dsyme Nov 16, 2017

Collaborator

I think this suggestion could only be part of a more systematic look at how F# treats default values and optionality throughout a number of constructs, not just record types

Collaborator

dsyme commented Nov 16, 2017

I think this suggestion could only be part of a more systematic look at how F# treats default values and optionality throughout a number of constructs, not just record types

@dsyme dsyme changed the title from Better record construction syntax to Better record construction syntax for optional values Nov 16, 2017

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment