Skip to content
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

Introduce the ?. operator into F# #14

Open
baronfel opened this issue Oct 20, 2016 · 170 comments
Open

Introduce the ?. operator into F# #14

baronfel opened this issue Oct 20, 2016 · 170 comments

Comments

@baronfel
Copy link
Contributor

baronfel commented Oct 20, 2016

Submitted by John Azariah on 11/23/2015 12:00:00 AM
16 votes on UserVoice prior to migration

Since we allow the . operator to reference fields and properties of objects in F#, we're faced with the same problem of null checking that plagued C# until C# 5.

The C# 6 'elvis' operator propagates nulls in a succinct way, and I think that working with objects in F# will be similarly simplified if we introduce it here as well!

Original UserVoice Submission
Archived Uservoice Comments

@dsyme dsyme removed the open label Oct 29, 2016
@smoothdeveloper
Copy link
Contributor

The null propagation operator (why give it a fancy name?) is one of those things which makes me wonder about choices made in C#.

I see it as "help you to do the wrong thing", I find it useful for event handlers (but R# allowed already to introduce the null check) but in most other cases I'm seeing it introduced in code, I'd actually prefer a more explicit handling of null value than operator which is actually hard to see (since it is always in middle of two other names).

@ReedCopsey
Copy link

And, in F#, the event handler issue is not an issue already...

@dsyme
Copy link
Collaborator

dsyme commented Mar 7, 2017

In F# any version of this operator would presumably work with both the nullable and option type as well, so

let x = Some "s"
x?.Length

and perhaps over a more general range of x.HasValue, x.Value types?

@cloudRoutine
Copy link

Would there be a constraint that types could satisfy through their implementation to buy into that functionality?

Or would it a specific method to implemnent like how .Item enable array indexing?

It'd be nice it were a more general purpose operator instead of one locked to a type like how (::) is

@Richiban
Copy link

Since

let x = Some "s"
x?.Length

can be rewritten as:

let x = Some "s"

x |> Option.map (fun x -> x.Length)

wouldn't it be great to have an operator that basically performed a map?

I'm going to borrow the "Spread-dot operator" from Groovy (http://mrhaki.blogspot.co.uk/2009/08/groovy-goodness-spread-dot-operator.html) which accomplishes the same thing:

let x = Some "s"

x*.Length

Now, this can also be used with sequences, lists etc

type Person = { name : string }

let people = [{ name = "Alex" }; { name = "Sam" }]

let names = people*.name

I don't know how possible this is since the map method exists in a module, not on the object itself.

@jzabroski
Copy link

:/ I feel like this feature request is an example of how C# is starting to lap F#.

@smoothdeveloper
Copy link
Contributor

@jzabroski do you mean F# miss this badly or that you rather not see this?

I don't think it makes C# a safer and higher level language to have that feature as opposed to no null allowed on most types by default, or making it hard by default to have type members that are unitialized (which F# does since day 1 AFAIK).

In that respect C# can't be fixed, at most patched, leave alone idioms of C# codebases.

Speaking myself as an F# + C# user standpoint.

I'm not craving for the feature, but if it comes, it is great if it would be enabled on arbitrary types, has some room to not just be the one single thing that C# does.

Why I'm not craving: (if isNull then a else b) is idiomatic and very explicit (and allows b to be something else than propagating nulls).

It also would have potential to replace usages of custom CE to deal with option monadically.

I'm surprised @vasily-kirichenko downvoted the suggestion due to this:

https://twitter.com/kot_2010/status/1156096657609646080

@smoothdeveloper
Copy link
Contributor

@jzabroski in case you are not familiar with F# 1:

type A() = class end
let a : A = null // error FS0043: The type 'A' does not have 'null' as a proper value

This may give you some context as to why we aren't feeling we lack the feature so much, you'd need to try F# 1 and understand some of the choices there.

Nice that C# now has some embedded analyser to track nulls and slow down a bit their ever propagation (it must be very fast at the assembly level, but very expensive to have a BCL and large codebases to maintain where null soundness is > /dev/null and only soft enforced due to backward compatibility with C# 1).

The concept of one language lapping another (especially on same VM where you can mix & match) has not been a concern to F# users AFAIK.

A place you may contribute to the discussion if you are using F# is fsharp/fslang-design#339 which I'm looking forward to and may interest you.

@smoothdeveloper
Copy link
Contributor

smoothdeveloper commented Apr 22, 2021

Some OO people like the https://en.wikipedia.org/wiki/Law_of_Demeter (the D in SOLID IIRC), and the null propagation operator is an invitation to infringe this law and never challenge the API choices / think too much before litering the code with .?, this feature has low priority to me.

if it comes, fine, I'll be importing a bit of that C# experience when I hit a NRE on code breaking the law of demeter, fixing code with a single char and not thinking much more before shipping the fix, adding to the billion dollar mistake tally.

thanks @jzabroski for helping me solidify better my stance on this feature and why C# programmer likes it 🙂.

@Happypig375
Copy link
Contributor

@smoothdeveloper Many times I'm using a library or an entire framework (BCL/Xamarin) where I can't change design decisions directly. I can only write match ... with null -> () | x -> match ... with null -> () | x -> ... where ?. would have been more appropriate.

@jzabroski
Copy link

jzabroski commented Apr 22, 2021

@jzabroski do you mean F# miss this badly

Yes.

Nice that C# now has some embedded analyser to track nulls and slow down a bit their ever propagation

I thought that about compiler performance for awhile, but there are a couple of compelling user stories:

  1. F# code calling C# code (same solution) - this is my most common pain point, and why I am getting rid of a large F# code base (was previously over 11k SLOC, now under 7k as I've been rewriting it)
  2. F# code calling arbitrary nuget package code - for example, who needs to know what Entity Framework Core is written in, the only thing that needs knowing is an entity may be null, so an entity path expression may fail when accessing an entitie's null properties.
  3. Unifying behavior of option and nullable types

Some OO people like the https://en.wikipedia.org/wiki/Law_of_Demeter (the D in SOLID IIRC), and the elvis operator is an invitation to infringe this law

Actually, if you read Karl Lieberherr's research papers and his book about OO programming with the Law of Demeter, he advocates using wild card patterns as substitute for needing to fully express a chain of objects. I guess this is a cultural example of how a solution was created in search of a problem, and then people only remember the problem that was raised. At the time Karl proposed the idea, code bases were tangled spaghetti code with very little architecture, and so Law of Demeter was created as a way to describe one way code bases were a tangled mess. Karl then came up with the solution, which was his pattern matching approach to function calls. Functional languages should make such code easier.

Most of what people describe the D in SOLID as is copy-paste programming in the name of avoiding direct path commonalities in unrelated business requirements, since path collisions tend to result in feature composition bugs where pairwise features intersect.

@smoothdeveloper
Copy link
Contributor

@jzabroski, thanks, the additional context is very helpful, and expanding on law of demeter also.

You should upvote the feature and provide more context than "C# > F#" next time, which I feel is counter productive if your comment gets ignored or worse, it starts a flame war (which I hope you can see, is not my intent).

Are you getting rid of F# codebase just for the lack of this feature, or you also have other issues with the language?

@Happypig375

where ?. would have been more appropriate.

I have not downvoted the suggestions, and even in my earlier comment I'm not opposing it, just giving my feeling about tension.

I'd be asking we do our best so, if it reaches F#, it does so in a more powerful manner than C#, by allowing to leverage that idiom for more than just null references.

@jzabroski
Copy link

jzabroski commented Apr 22, 2021

Are you getting rid of F# codebase just for the lack of this feature, or you also have other issues with the language?

I have a killer issue where if I reference a C# assembly that transitively references a Resources assembly, F# compiler goes OOM. I reported it but there was no fix after much discussion. With Visual Studio 2022 going 64 bit finally, it's all too little too late. I suspect VSCode uses 64 bit processes and most F# developers just use that and don't run into this pain point as daily as I had to encounter it. Plus, VSCode uses a client/server paradigm where if the server segfaults, it just spins back up a new server in the background.

I guess the other reason F# is going out the door is the original system was not well documented and had no clear business owner, and the system had a kitchen sink of F# features, like active pattern matching and partial application. At the same time, C# has made massive improvements under Mads direction. The other annoying thing with using F# in a team with a lot of C# projects in the same solution is that renaming an F# binding doesnt automatically update the Errors List in Visual Studio, which on a solution with very large projects, really lengthens the REPL cycle and getting feedback as to whether a refactoring was a good idea with 3 days left before a sprint closes. In addition, CodeLens doesnt peer through from C# to F#, so when assessing whether something is not in use, I have to use CodeLens plus Year 2000 style Control+Shift+F Find in ALl Files... Practical stuff like this is really against F# at the moment, sadly.

@smoothdeveloper
Copy link
Contributor

I have a killer issue where if I reference a C# assembly that transitively references a Resources assembly, F# compiler goes OOM.

just for reference: dotnet/fsharp#9175

Thanks again @jzabroski and let's not kill the good discussion about the feature (sorry for inviting digression), please contribute to issues, suggestions, PRs, etc. whenever you feel like 🙂.

@snuup
Copy link

snuup commented Jun 1, 2021

Lazy argument Evaluation of ?. in C# (useful in Logging)

I want to highlight that the ?. in C# has lazy argument evaluation which has an important use case. For logging we constantly face the issue that log statements might be fed with arguments that are expensive to compute:

void Log(string a) { Console.WriteLine(a); }
string expensive() { return "expensive computation"; }
Logger logger = null;
logger?.Log(expensive());  // expensive is not evaluated when logger is null !

In earlier days, the idea in C# was to introduce lazy argument evaluation via a lambda function. With the ?. operator this can now be done

  • simple
  • very succinct and with a minimum of visual noise (log statements should not clutter the code)

because ?. does not invoke the member but also does not evaluate the arguments of the member function .

This is a great relief for C# programmers and there is no equivalent for F# programmers to avoid expensive argument evaluation when logging is disabled. F# programmers still need to use lambdas or lazy statements, which are noisy and also generate additional code in the assembly, increasing image footprint, slowing down applications.

This would be my only use case for such an operator in F# but it would be very very helpful. And I think the lazy argument evaluation of an operator could have other important use cases, since lazy evaluation in F# is nowhere present as of today.

@dsyme
Copy link
Collaborator

dsyme commented Jun 16, 2022

Closing as covered by #577

@dsyme dsyme closed this as not planned Won't fix, can't repro, duplicate, stale Jun 16, 2022
@dsyme
Copy link
Collaborator

dsyme commented Jul 10, 2024

Reopening as mentioned in dotnet/fsharp#15181

@dsyme dsyme reopened this Jul 10, 2024
@T-Gro
Copy link

T-Gro commented Jul 10, 2024

This is now being reopened with having RFC FS-1060 (Nullable reference types) in mind.
Based on the discussion above, the suggestion would be to support 4 different container types:

  • option<T>
  • voption<T>
  • System.Nullable<T>
  • T | null (nullable reference types. Erased at compile time, nonexistent at runtime)

Making this ?. feature not just "null propagation operator", but rather a "missing value propagation operator" (definitely accepting a good name covering it)

Out of the 4 above, option+voption can carry unrestricted T.
System.Nullable can only carry value types, and T | null can only carry types supporting null. Which apart from value types also excludes selected F# types, like F# options and tuples.
This means that for a long identifier like nullableDtoType?.TupleProperty the type of the outer container (in this case, T | null) cannot be maintained.

Which leads to suggesting the following ruleset for picking an appropriate container for the result of ?.:

option,voption keeps the container
Nullable<T>, T|null maps to option<T> at first conversion point, no matter the type of the ?. 's RHS (right-hand side of the operator). If the RHS is itself an option, it does not flatten it - it will become an option<option<T>>.

In a long chain of ?., like A?.B?.C?.D, the overall container type is only determined from C (the last LHS, left-hand side).
All the intermediate ?. do not matter for overall type of the expression, but of course they do matter for codegen doing branching and value unwrapping.

The downside of this ruleset are the intermediate allocations for Some x.
But as soon as one would want to heuristically solve it to keep allocations at a minimum (e.g. maintaining T|null if the RHS supports null, or selectively balancing between T|null and Nullable<T> where possible), the ruleset would get more complicated and less predictable (and likely less refactoring friendly). And out of the options presented, option is the most F#-native container type for missing values, with established functions to work with it.

Open question: If .D, i.e. the last RHS in a chain A?.B?.C?.D, is itself a property with missing-value-container type, should it be flattened? What if the containers are not compatible, which one should win ( LHS / RHS / always option / always voption / .. )?

@vzarytovskii
Copy link

vzarytovskii commented Jul 10, 2024

Nullable, T|null maps to option
The downside of this ruleset are the intermediate allocations for Some x.

We probably can map to value option instead, since they're not intended to live long in this case.

Also, probably worth mentioning that in such chained value propagations we won't be ourselves flatten nested (v)options in sake of uniformity and not having special cases.

i.e.

instance?.SomethingWhichReturnsIntOption() // will be 'int option option'

Overall this definitely needs a detailed design. My concern for options is that people will be overusing it instead of proper matching. Which is not inherently bad, but subjectively will be harder to read and debug through.

@Lanayx
Copy link

Lanayx commented Jul 10, 2024

Open question: If .D, i.e. the last RHS in a chain A?.B?.C?.D, is itself a property with missing-value-container type, should it be flattened? What if the containers are not compatible, which one should win ( LHS / RHS / always option / always voption / .. )?

I would expect that

  1. No, it shouldn't be flattened, but there should be a way to easily make it flattened (like adding one more ?. in the end)
  2. Left-most container should be used (I hope this is what LHS stands for), e.g. A in A?.B?.C?.D

@dsyme
Copy link
Collaborator

dsyme commented Jul 17, 2024

@ken-okabe

Ok, understood. Thanks for your suggestion.

Thank you. I'll hide the comments up above to allow people to meaningfully read the core discussion of the proposed design. No disrespect is intended - you're welcome to post the material it on another issue in this repo, or a blog elsewhere, and link it here.

I want to emphasise that I appreciate your writing and you present ideas well. But I just need to keep this thread on-topic so we can make progress here.

@ken-okabe
Copy link

@dsyme I appreciate your sincere intentions. Thank you.

@ken-okabe
Copy link

ken-okabe commented Jul 17, 2024

I understand and respect your concern for the readers of this thread. However, given that you have read my text in its entirety, I would like to know in detail the reasoning behind your response, preferably in line with the context I provided:

#14 (comment)
#14 (comment)
#14 (comment)

Are they "abnormal"? Yes, in F#-only domain logic that does no interop I would say yes.

@dsyme
Copy link
Collaborator

dsyme commented Jul 18, 2024

@ken-okabe

I would like to know in detail the reasoning

My replies above have covered this in enough detail, e.g.

Given the "softness" of NRT checking - it is very easily subverted and only gives warnings - teams should consider carefully about normalizing the use of nullable reference types.

Also see the design principles of the accepted RFC https://github.com/fsharp/fslang-design/blob/main/RFCs/FS-1060-nullable-reference-types.md#principles

Please also note that I've requested that this thread get back to discussing the very specific design alternatives, and we pause it until @T-Gro gets back from vacation and gets a chance to digest the discussion. I don't want to discuss abstract principles further at this point.

@ken-okabe
Copy link

Sounds great. Let's discuss the abstract principles further after @T-Gro returns from vacation. I'm also glad to see that the principle is still in the RFC (Request for Comments) stage.

@T-Gro
Copy link

T-Gro commented Jul 19, 2024

@dsyme:
The first point is about opinionated preferred type, and therefore elimination of the disliked ones.
This can be just a subjective design, but I also see it as a way to "escape" the unsound world of nullable reference types - it just takes a #nowarn to destroy safety of code build around this feature.

The other two points are more about practical reasoning, I will comment below each citation separately.

Resulting type

@T-Gro So am I correct the alternative would be this

let foo = Foo()
foo?.ReturnsOption?.Thing         // Option<_>
foo?.ReturnsVOption?.Thing        // ValueOption<_>
foo?.ReturnsNullable?.Thing       // Nullable<_> or _ | null depending on whether thing is struct or reference. 
                                  // Compilation failure if it is fully generic without any knowledge either way based on known 
                                  // type information at point of inference, or can't support null, or is Nullable 
foo?.ReturnsSystemNullable?.Thing // Nullable<_> or _ | null depending on whether thing is struct or reference. 
                                  // Compilation failure if it is fully generic without any knowledge either way based on known 
                                  // type information at point of inference, or can't support null, or is Nullable

I think this design would also be pretty much consistent with the design principles to be honest. The perpetual strangeness of the rules around null/Nullable and other things in F# would continue to steer people away from its use.

Yes.
Emphasis give on types which can't support null. This would make the feature useless if a chain of identifiers involves particularly an anonymous record or a tuple. Defaulting to option enables this.
The design is consistent, but requires a higher level of F# expertize to know and understand. It does go against the theme of "simple F#" if changing a field to an idiomatic type leads to a compilation failure like this.

Second reason against this alternative would be the desire to eliminate both System.Nullable as well as NRT as soon as possible - and prefer working with options. Building a complicated ruleset to maintain NRTs for as long as possible seems to go against a central design of the NRT feature.

This ruleset does have one small advantage over the convert to option for unpreferred types - it leads to the smaller amount of allocations/struct copies.

Flattening

As an aside, I think I would prefer no "flattening" in any of these designs but would like to see a full pros/cons of this.

A small pro is that C# implicitely does flattening, because it's missing value containers (NRT and System.Nullable) do not allow nesting at all.

The example at the bottom of #14 (comment) is the best reasoning for doing flattening I came up with.
A big pro for doing flattening is the example of doing A?.B?.C?.D on either a single line, or multi-line. If we want them to have the same result, we should do flattening.
A nested long identifier like this in a DTO was a motivating example for reviving this discussion, with each level being a possible missing value.
Last, but not least - not doing the flattening means occasionally having to deal with nested containers of missing values. If processing different levels of missing information is important, a nested pattern matching still exists as an idiomatic way to process it. For all other use cases (= where it does not matter which level was missing information), not doing flattening would just complicate working with the resulting value.

??

Also, I know people have said we shouldn't have a ?? b but I'd like us to look at in including this in the design too. What speaks against it, as a general way for specifying default values?

The main reasoning is the "one way to do things" pseudo-principle, which of course has exceptions as of now already.
The ?? would likely be equivalent to a type-directed Option/ValueOption/ *.defaultWith call.
A specific function for nullable reference types does not exist yet, but could be added to make the set of functions regular.
Utilizing a named function keeps existing preference for explicit names, good support for functional pipelines, and is consistent with expressions of default values for existing containers of missing values. It's also more apparent to see what values it closes over.

Adding ?? would add more to F#'s syntax where an alternative exists.

@vzarytovskii
Copy link

Adding ?? would add more to F#'s syntax where an alternative exists.

I'll only comment on a small portion for now. I don't yet have strong opinion about the propagation and resulting type. I do, however, regarding the ?? operator - I agree that we shouldn't have a universal one for any "null representation", and should encourage using respective defaultWith functions, as it's already a common case in existing F# code.

@T-Gro
Copy link

T-Gro commented Jul 19, 2024

@vzarytovskii , @dsyme

@T-Gro So am I correct the alternative would be this

Tomáš is on vacation currently, but yes, it's what we discussed as well, which looks logical, the conversion to (v)option was liked by some people as it's "more consistent and fits better in F#". I personally don't have a very strong preference, but as you saw, people don't like it for different reasons - some theoretical performance implications and it not following certain laws and rules.

A rule of result is always option<T> is simpler to explain and communicate, and also simpler to build correctly especially in generic or type-inferred contexts.

Given that F# has added opt-in options to support ValueOption for advanced users who seek to minimize allocations, it would be consistent with existing F# features to enable it here:

  1. If a type is fully-inferred to be ValueOption, ?. results in ValueOption.
  2. In all other cases, Option is returned.

This does complicate the question of flattening and nesting of mixed containers a little (as in, which one to prefer of a long ident uses more different containers), but one could likely take the container type which was last (right-most).

Code which opted-in for ValueOption will continue to have it, all other code can have a simple-to-explain ruleset.

@ken-okabe
Copy link

The introduction of this operator is inherently and algebraically inseparable from the discussion of how the NRTs should be. Initially, I carelessly tried to focus on this in this thread, but thanks to the advice I received, I have now separated the discussion:

My comment on [RFC FS-1060 - Nullable Reference Types] discussion #339

@Tarmil
Copy link

Tarmil commented Jul 19, 2024

Emphasis give on types which can't support null. This would make the feature useless if a chain of identifiers involves particularly an anonymous record or a tuple.

Does the nullable reference types RFC not allow {| x: int|} | null or (int * string) | null?

@ken-okabe
Copy link

ken-okabe commented Jul 19, 2024

@T-Gro

This can be just a subjective design, but I also see it as a way to "escape" the unsound world of nullable reference types

I prefer not to reduce this discussion to individual use cases, but to demonstrate a concrete counterexample that your subjectivity is not universally shared, I would like to illustrate my own subjectivity. I use Nullable reference types a lot when interfacing with Fable/JavaScript.

Currently, F# does not support NRT, so I have implemented as below:

type NullableT<'a> =
    | Null
    | T of 'a
    member this.Value
            = match this with
            | Null -> failwith "Value is null"
            | T a -> a

let NullableT a =
    match box a with
    | :? NullableT<'a> as nullable -> nullable
    | _ -> T a

then, convert JS Promise to FRP+Nullable type based on this.
JS Promise objects do not nest like Option, so if all of these are implicitly "escaped", it would break type consistency and cause potential substantial errors.

This is also true with C#. Some programmers would like to use NRT directly in F#, and in that case, implicit "escape" would break type consistency, potentially leading to significant issues.

@T-Gro
Copy link

T-Gro commented Jul 19, 2024

Emphasis give on types which can't support null. This would make the feature useless if a chain of identifiers involves particularly an anonymous record or a tuple.

Does the nullable reference types RFC not allow {| x: int|} | null or (int * string) | null?

It does not.
Aim of the NRT feature is not to sneak in nulls in places where F# was null-safe before.

Also, since it is just a warning (= can be ignored), it would be way to easy to create null versions of anon. records or tuples and propagate them into code that would not expect them.

That is why the set union of types supporting "(value types) + (types supporting nulls)" does not cover all possible types, whereas option does.

@Lanayx
Copy link

Lanayx commented Jul 20, 2024

I agree that we shouldn't have a universal one for any "null representation", and should encourage using respective defaultWith functions, as it's already a common case in existing F# code.

Adding ?? would add more to F#'s syntax where an alternative exists.

?? doesn't bring universal null representation, but rather operator that works with different types, just as ?. or + or |> or many others. We already have similar entities today - defaultArg and defaulValueArg functions, which are very confusing, since they are identical to Option.defaultValue and ValueOption.defaultValue. So again, I'm voting for deprecating those functions and using ?? operator instead that will replace those 2 functions and work for Nullables and NRT as well.

Aim of the NRT feature is not to sneak in nulls in places where F# was null-safe before.

I know the context was a bit different, but it already does. Imagine totally null-safe code

let getLength myStr =
    if isNull myStr then 0 else myStr.Length

After I enable NRT this will give warning (since NRT doesn't work with if in F#). I'd have to rewrite thousands of lines of code from ifs to pattern matches to fix all the warnings (because warnings should not be ignored if a project is in a good shape), so I'd like to instead go further and introduce !. operator which would allow me to ignore NRT warning locally.

let getLength myStr =
    if isNull myStr then 0 else myStr!.Length

@vzarytovskii
Copy link

vzarytovskii commented Jul 20, 2024

so I'd like to instead go further and introduce !. operator which would allow me to ignore NRT locally.

This is probably something we won't do.

So again, I'm voting for deprecating those functions and using ?? operator instead that will cover all .defaultValue functions.

I would also personally also push against it. If users desire, they can write their own operator for having a universal defaultwith for multiple types.

@ken-okabe
Copy link

ken-okabe commented Jul 22, 2024

Aim of the NRT feature is not to sneak in nulls in places where F# was null-safe before.

That is why the set union of types supporting "(value types) + (types supporting nulls)" does not cover all possible types, whereas option does.

I'm sorry, but once again, as I pointed out in the latter part here, these statements are confusing cause and effect, or aim and reason, which constitutes a logical fallacy.

(Historically, both in the context of programming languages as a whole and F# specifically,) null-safety has not been guaranteed in the Nullable type operation. due to the absence of dedicated null-safe operators, while option types have been available.

The new operator proposed here is the counterpart of NullableTypes (set) to ensure null-safety, and I honestly don't understand the argument that the "Aim of the NRT feature is not to" ensure null-safety when specifying its characteristics.

@brianrourkeboll
Copy link

In the latest update to the C# type unions proposal it looks like there's a chance an Option<'T> type could be added to the BCL at some point in the future. I don't mean to stir up additional controversy prematurely, but it may be yet another optional-ish type to consider here.

(If they actually did end up adding it to the BCL, it could probably also be worth waiting to see whether they gave any special treatment to it in C#, like C# already does for Nullable<'T>.)

@ken-okabe
Copy link

Common Unions in the csharplang/proposals/TypeUnions.md also suggests Result type other than Option (which makes sense).

Variety/selection of types of C# or .net framework as the foundation is real also reasonable, and the approach of unifying all types into the preferred option types in F# (or containing the other unpreferred types in the system boundary) seems quite unrealistic, especially from a medium- to long-term perspective, I think.

@T-Gro
Copy link

T-Gro commented Jul 26, 2024

it could probably also be worth waiting to see ..

I think it's good to keep that in mind by making the ?. design flexible enough to accommodate a future BCL type with the same missing-value-container semantics (not necessarily flexible in user-extensibility kind of way, but from a feature design and compiler perspective).

I don't think we should actually wait for it before adding ?. to FSharp.

@T-Gro
Copy link

T-Gro commented Jul 26, 2024

so I'd like to instead go further and introduce !. operator which would allow me to ignore NRT warning locally.

I did encounter the same situation when applying nullness to the FSharp compiler itself, this will definitely be a code-migration concern.
Worth mentioning this was not so much about pure isNull, but rather BCL functions having null check inside: String.IsNullOrEmpty, String.IsNullOrWhiteSpace, File.Exists etc.).

I used two solutions, which I used and (subjectively!) consider more appropriate than a language-wide addition:

  • An active pattern covering the method call and unwrapping from T | null to T if the check succeeds
  • A locally added !! operator being a shorthand for the library call Unchecked.nonNull. The locality means that it is an explicit opt-in (e.g. define a duplicate of it only in selected files, or via explicit module opening). Making it language-wide would normalize the unsafety this can bring in just one or two symbols (= easy to overlook compared to a function from Unchecked module).

@charlesroddie
Copy link

charlesroddie commented Jul 29, 2024

I am against adding this operator for the following reasons:

  1. FSharp is already good at handling option types, via match, map, bind, maybe CEs, etc..
  2. C# needs the operators because it is not expression-based, while F# doesn't have this problem.
  3. C# operators are beneficial only in very specific situations where the brevity is an advantage. Namely in code where the optionality of something is for some reason unimportant. E.g. a property almost always exists and the code involving the optionality is unimportant to the reader, and can then be hidden in a single character ?.
  4. C# operators have the following disadvantages:
    a) The brevity is a disadvantage in the typical case, obscuring logic where the optionality is an important part of code logic.
    b) They are hard to remember. I find them hard to remember in C#. Compare to Option where a beginner who doesn't know the Option module well can just type Option and press . and find what the functions are. Github copilot also finds it hard to write C# NRT code and finds F# option code easier.
    c) They are less general compared to bind/map/match. For example, accessing a property of x via ?. is just one of many things you might want to do with an x. I love properties but giving them special treatment is a mistake.
  5. Adding C# operators to F# would bring the additional disadvantage of adding another way of doing what we can already do with match, map, bind, and maybe.

People are not understanding the concision benefit of F# here. It is nice that F# is a concise language but we already have concision here in that there is already a commensurability of conceptual complexity to written complexity (via match etc.). Attempting to reduce written complexity to below conceptual complexity just causes confusion.

@T-Gro
Copy link

T-Gro commented Jul 30, 2024

  1. FSharp is already good at handling option types, via match, map, bind, maybe CEs, etc..

I think we should emphasize that this lang suggestion has been revived in the context of Nullable Reference Types support.
For projects who want to do strict null checking but have null-heavy project dependencies (e.g. not enabling the nullable switch, coming from an older C# version etc.), accessing nested properties will require a lot of boilerplate. Either a function call or a pattern match for every dot-access invoked for a possibly-null reference.

Without strict null checking, code accessing A.B.C.D can ignore the null possibilities, e.g. because it inherently knows the properties are never null. After the change, it would report warnings which either have to be ignored (globally, file-level, kept in) or rewritten with boilerplate.

Without this context, e.g. just considering options/voptions, I would be on the side with you, @charlesroddie .

@charlesroddie
Copy link

@T-Gro Sorry for the slow reply. What you are describing is a stopgap feature to help people with using non-NRT-tagged libraries and a lot of nested properties. If that is the case then if implemented, can it be restricted with a feature switch so that only this subset of users can turn it on? And would you propose having an removal date when non-NRT-tagged libraries are sufficiently rare? From what I see this date would be in the past as almost everything is NRT-tagged these days.

@T-Gro
Copy link

T-Gro commented Sep 9, 2024

... almost everything is NRT-tagged these days.

As of now, any project targeting just net472 or netstandard (and NOT multi targeting netcore TFMs as well) will not be in a position to have good nullable annotations, because they are not maintained in the corresponding BCL either.
So this would first need to be positioned after sunseting desktop framework as well as netstandard(s).

I think we will have to admit that non-annotated code will be around for many more years.

@smoothdeveloper
Copy link
Contributor

@T-Gro:

because they are not maintained in the corresponding BCL either.

this is kind of a shame and orthogonal (to keep things "focused" assuming it is the best thing in the world, at all times, at all cost), but I think it is worth for F# team to bring to BCL team the complaint that desktop framework codebases also deserve null safety, they can consider adding the annotations to a desktop framework update, for sake of rewarding customers that are windows only, and made .net the success it is today and still support code that isn't running on new runtime.

@charlesroddie, I'm personally not opposed to the operator (or equivalent) being brought to F#, in C# it is concise, sure it is used (by me and others) in sloppy code, for codefixes that is only one or few bytes and not most readable when speed reading such code, but this is reality of codebases, and the operator is endorsed by the C# users.

It is not because a facility in the language exist that we encourage abusing it.

The truth is, null reference type is the closest to "zero cost abstraction" F# can support, and having an approach for null/default propagation that works with those, as well as arbitrary types (option & voption for example), could be helpful, even if it doesn't suit the taste of all codebases nor the defaults aesthetics of F#.

We could put the operator definition in an "unsafe" module, for which it is relatively easy to add an analyzer, to achieve what you need.

This is just to mitigate my earlier feedback, I'm using the operator in C#, shame on me 🙂, I know in F#, if expression, better code design, etc. makes it less useful, but I'm not radicalized against.

Question is, is there a need for the compiler to be updated for supporting this in an external library, while yielding zero cost abstraction and generality over option/voption/arbitrary types?

@charlesroddie, if the design guideline are edited to discourage abusing the operator, while describing how to use it or make it work with arbitrary types, would you feel it doesn't compromise F# beyond repair?

@T-Gro
Copy link

T-Gro commented Sep 9, 2024

Question is, is there a need for the compiler to be updated for supporting this in an external library, while yielding zero cost abstraction and generality over option/voption/arbitrary types?

This operator does need a parser change to make the syntax smooth, e.g. not having to use a lambda for the member access. And in chained scenarios, not having to put in parens.

let achievableViaOperator = data ?. _.Member
let afterParserChange = data?.Member

Similarily with ??, doing it without a compiler change would mean passing the default as a lazy invoked lambda function.
A language change is needed to detect this as a lazy-evaluated operator.

@charlesroddie
Copy link

Going out of our way to help legacy projects in a way that hurts modern F# is a really bad approach. Legacy F# will continue to work for legacy projects. If legacy projects want minimal change, they can turn NRTs off.

... almost everything is NRT-tagged these days.

As of now, any project targeting just net472 or netstandard (and NOT multi targeting netcore TFMs as well) will not be in a position to have good nullable annotations

Every library that I have seen that is actively maintained has moved to multitargeting or alternatively to dotnet6+ from netstandard. Failure to do so would annoy all users (w.r.t. NRTs but also trimmability) so there is a lot of demand for libraries to do this.

I think it is worth for F# team to bring to BCL team the complaint that desktop framework codebases also deserve null safety ... for sake of rewarding customers that are windows only

F# is not cobol.net so F# should have no more interest than other dotnet languages in pushing for support of legacy frameworks.

There could be a miswording here. dotnet6+ targets desktop platforms fine. You can also make a windows-only application on dotnet6+ if you want. The F# community doesn't need to reward this as it is not our goal to make people buy more windows licenses.

Microsoft general approach here with dotnet is good: focus on the best experience for modern dotnet. Now that dotnet8 supports every dotnet target, previous platforms (mono, netfx) don't need to be given new features since users of those platforms can just update. Keeping netfx and mono up to date with new features would hurt the future of dotnet as it takes energy away from development. Both legacy frameworks are just inferior to dotnet6+.

If the design guideline are edited to discourage abusing the operator, while describing how to use it or make it work with arbitrary types, would you feel it doesn't compromise F# beyond repair?

I would trust myself personally not to use it but would want a way to enforce that no one ever adds it to the codebase in our team. This would need to be automated. Also these guidelines would then become more confusing to beginners ("don't use it unless x" and they would need to understand what "x" is which is some extremely niche use case).

@smoothdeveloper
Copy link
Contributor

smoothdeveloper commented Sep 9, 2024

Going out of our way to help legacy projects in a way that hurts modern F# is a really bad approach. Legacy F# will continue to work for legacy projects. If legacy projects want minimal change, they can turn NRTs off.

Sure, I can make similar statement about going out our way to use F# instead of C#, it is really "bad approach" (according to some), yet, we all have, to some extent, try to make progress, in ways that are at our grasp, and improved situation for F#, despite "really bad" feedback.

My statement is just more about Microsoft honouring those customers that basically funded .net from day one, and will continue to do so, whether or not desktop framework support is disincentivized. We don't have to look at it 100% from our limited perception about ROI measured on FED denomination, we can have holistic understanding and demean this as "really bad approach", based on $$$ROI metric only, IMO, is a shortcoming too.

F# is not cobol.net so F# should have no more interest than other dotnet languages in pushing for support of legacy frameworks.

Anything for good of dotnet codebases, holistically, is not bad to just express, but I'll not push further here. cobol.net analogy is "really bad approach" 🙂.

Microsoft approach is overall good, maybe not double plus good good. No NRT annotation on framework is bad decision (as it doesn't incentivize anything, just bad code for discriminated against codebases).

I'm singling out mono (I assume codebases that are mono specific are in less amount than .net desktop framework), but NRT on assemblies, it would just mostly work, could be nudged independently than what I'm doing.

I was just saying to MSFT F# team (not you), "please state once to BCL team, if/when engaging about implementation of this suggestion, concern that no annotations for .net framework harms dotnet codebases".

I would trust myself personally not to use it but would want a way to enforce that no one ever adds it to the codebase in our team. This would need to be automated.

If under a specific module, possibly another assembly, I think it would be minor for people interested in using the analyzer toolkit and needing it done.

Also these guidelines would then become more confusing to beginners

advanced topics can be marked in the guidelines with a bit styling, I rather have them comprehensive and touching on ethos/aspects, even if it is not for every beginner to grasp them 100%.

Thanks for all your feedback and hearing me.

Maybe you can reconsider being neutral rather than 👎 on the suggestion, and I'll tackle the other radicalized "no nullness operator in F#" as they come back in troops to trample my words 🙂.

I think the suggestion is legit, and lower priority than if we were dealing with C#, being neutral or 👍 for it, feels better to F# of today, to me.

@vzarytovskii
Copy link

That's a great discussion. I think, at some point in the next release window, we shall spend time to write an rfc for that feature (covering custom operators, types which are covered, alternatives, etc), it will be much easier to argue about and discuss specific code. We should also spend some time experimenting with existing OSS projects, trying to enable nullness there and see how many new diagnostics do we have.

@T-Gro
Copy link

T-Gro commented Sep 12, 2024

I would trust myself personally not to use it but would want a way to enforce that no one ever adds it to the codebase in our team. This would need to be automated. Also these guidelines would then become more confusing to beginners ("don't use it unless x" and they would need to understand what "x" is which is some extremely niche use case).

This proposition fits very well into the direction of custom analyzers which we are planning for one of upcoming releases as well.
The very first use case I have in mind is the BannedApis.txt analyzer, allowing to trigger a warning whenever a banned API is used - configurable per project via a plaintext file. Operators should be listable as well, with the options to selectively "nowarn xxx" them in the "unless x" scenarios.

@ken-okabe
Copy link

Discussion in Fable has been initiated.
Rolling out nullness annotations

Since JS/TS nowadays actively leverages 'T | null for nullable types, it might be beneficial to share their perspective as well here.

In fact, from Fable perspective, .NET 9 Nullable instead of Option natually fit JS/TS type systems.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests