Allow `member val` on records to evaluate expensive read-only properties only once
This proposal is based on this discussion. Since I do not know if converting a discussion into an issue is possible, I have included the remarks and feedback people have shared directed in this OP (just highlighting this to give @T-Gro and @Tarmil credit for many of the remarks below).
I propose we allow the use of member val on records to ensure that expensive read-only properties are evaluated only once.
The existing way of approaching this problem in F# is
- Use
member valfor classes - use
let cachedField =for classes -
{ X : Lazy<...> } - Make the calculated value part of the record and provide a static member (or a module function) to construct it and do the computation
Which has the following drawbacks in my opinions:
-
Using classes would change equality semantics so this is a substantial drawback.
-
The lazy approach changes the API; in some cases this can be acceptable and even desirable, but in the general case this gets in the way of what people truly want to achieve.
-
Then explicitly storing the value in the record with a static constructor solves the problem but introduces others (see below) and introduces boilerplate for what is a common use case.
-
There is also a major issue that only built-in support can really solve: if the copy and update expression is used to modify other fields, the value of the calculated field will be wrong/staled, which is a major gotcha. If copy and update copies by reference (which I suspect it'll do for Lazy
), the issue also applies to the lazy approach if the value has already been evaluated (which would lead to nasty debugging sessions). Moreover someone can modify the value of one of the computed fields directly and set it to an invalid value. By making these fields read-only members, this is not possible and the code/API is self-documenting.
These other approaches are not solutions but workarounds in my opinion. My thinking is that since F# already generates plenty of helpful code, it would be very relevant to optimize this use case that i personally meet extremely often as i design my domain models.
Pros and Cons
The advantages of making this adjustment to F# are that sometimes, read only properties of records may do relatively expensive computations, and if you are doing aggregations accessing such properties multiple times across large number of records, this computation time may start to matter.
The current behaviour is surprising to many users, as F# as a strong reputation for being immutable-first and people like me naively expect read-only properties. I've been shipping code in production for a good while assuming that this was the case, and I've seen a number of questions on the internet about this topic.
Needing to compute a value only once is a very common situation, and a use case that seems reasonably easy to implement at the compiler level while it involves all sorts of boilerplate and complexity if user have to handle it themselves. The compiler already generates this kind of code for classes. This is a feature that people would expect a language promoting immutability to support
The disadvantages of making this adjustment to F# are that assigning a value to a field of a record can be a more expensive operation than the user expects if we implement it in a way such that all member val of records are evaluated eagerly on record instantiation (which looks like the best approach to me). However, i think developers would hardly be surprised since they'd typically know the types they are working with.
Since this feature is opt-in, I can't think of many drawbacks.
Extra information
Estimated cost (XS, S, M, L, XL, XXL): M
Related suggestions: (put links to related suggestions here)
Affidavit (please submit!)
Please tick these items by placing a cross in the box:
- [X] This is not a question (e.g. like one you might ask on StackOverflow) and I have searched StackOverflow for discussions of this issue
- [X] This is a language change and not purely a tooling change (e.g. compiler bug, editor support, warning/error messages, new warning, non-breaking optimisation) belonging to the compiler and tooling repository
- [X] 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
- [X] I have searched both open and closed suggestions on this site and believe this is not a duplicate
Please tick all that apply:
- [X] 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
For Readers
If you would like to see this issue implemented, please click the :+1: emoji on this issue. These counts are used to generally order the suggestions by engagement.
It's worth calling out that there are a few other suggestions that could theoretically cover some or all of the use-cases here, including:
- https://github.com/fsharp/fslang-suggestions/issues/554
- https://github.com/fsharp/fslang-suggestions/issues/164
@brianrourkeboll Thanks for the references!
These proposals are basically about adding record features to classes. While this constitutes another way to approach this problem, in my opinion they are not satisfying for someone being perfectly happy with records and only wanting to have one or more properties computed only once.
The goal of my proposal is to remove the need for cumbersome and error prone boilerplate for this very common need. However with these "class as record" proposals, there are a lot of stuff needing to be wired manually.
These proposals are great, i just do not think they address the need of the current proposal. To me, my proposal is to some extent like proposing to add built-in compiler support for properties with automatic getters and setters to c# because this need is very common but being told that it is possible to define backing fields explicitly instead.
@nkosi23
These proposals are basically about adding record features to classes.
Indeed. I feel like Don has sprinkled around various opinions on adding class features to records/unions versus the other way around over the years, but I haven't had time to dig them up...
It would probably help if you could add some motivating examples to this suggestion, and perhaps some hypothetical syntax.
member val declarations currently rely on either primary constructors or self-identifiers created via as this to bring other instance values (constructor parameters or other members) into scope. member val declarations currently have no way to bring a self-identifier for the instance into scope themselves (unlike member this.X = …, etc.).
Since record declarations don't currently have any explicit constructors, it seems like we'd need some new way to bring a self-identifier into scope.
Compare
type T (a : float) =
member val B = a * 2.0
or
type T (a : float) as this =
member val B = a * 2.0
member val C = this.B * 2.0
against the hypothetical
type T =
{ A : float }
member val B = this.A * 2.0 // Where do we get `this` from here?
A side-thought if this is an existing common concern - expensive read-only properties on records could likely be solved via an static let ConditionalWeakTable and the boilerplate probably even generated with Myriad.
It is of course more of a workaround, but it would provide a lazy cache storage for read only properties.
A particular aspect to keep in mind is how this change would affect sound initialization of records, construction or with - especially in the cases of several member val referencing each other.
To me, this change is too big for existing simple records, and an approach like in #164 feels more powerful. There will for sure be more demands if feature outlined in this suggestion lands - "why not instance let as well", "can you also allow custom get,set", ...
Having another round of thinking, it would be difficult to make such "empowered" records work on older versions of the compiler - signature data and optimization data might not be able to accommodate this without changes. This is not a showstopper per se, but is another reason why the other direction #164 feels better here.
The concept of records and their syntax is simple and behavior predictable.
By including member val, additional storage could be hidden (I could foresee bugs in optimization when an older compiler would try to work with these records) and behavior less predictable by running arbitrary code at construction time.