fsharp / fslang-suggestions

The place to make suggestions, discuss and vote on F# language and core library features
345 stars 21 forks source link

Introduce the ?. operator into F# #14

Open baronfel opened 7 years ago

baronfel commented 7 years ago

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

smoothdeveloper commented 7 years ago

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 commented 7 years ago

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

dsyme commented 7 years ago

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 commented 7 years ago

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 commented 7 years ago

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 commented 3 years ago

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

smoothdeveloper commented 3 years ago

@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 commented 3 years ago

@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 https://github.com/fsharp/fslang-design/discussions/339 which I'm looking forward to and may interest you.

smoothdeveloper commented 3 years ago

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 commented 3 years ago

@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 commented 3 years ago

@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 commented 3 years ago

@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 commented 3 years ago

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 commented 3 years ago

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: https://github.com/dotnet/fsharp/issues/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 commented 3 years ago

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

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 commented 2 years ago

Closing as covered by #577

dsyme commented 2 months ago

Reopening as mentioned in https://github.com/dotnet/fsharp/pull/15181

T-Gro commented 2 months ago

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:

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 commented 2 months ago

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 commented 2 months ago

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

vzarytovskii commented 2 months ago

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) Yes, it should be flattened

We will likely not special-case it, as discussed before. We already do not in all other cases.

Lanayx commented 2 months ago

We will likely not special-case it, as discussed before. We already do not in all other cases.

Yes, sorry, changed the message

vzarytovskii commented 2 months ago

We will likely not special-case it, as discussed before. We already do not in all other cases.

Yes, sorry, changed the message

Yes, easier flattening is something we should consider.

brianrourkeboll commented 2 months ago

It would be interesting to enable this operator for any types that followed a pattern of intrinsic or augmented HasValue, GetValueOrDefault, and op_Implicit members, as in https://github.com/fsharp/fslang-suggestions/issues/14#issuecomment-284818519.

Such a pattern could cover multiple "optional" types ('T option, 'T voption, Nullable<'T>) and pseudo-types ('T | null) and enable target-typing of the result.[^1]

Here's a runnable example that uses SRTPs and custom operators to illustrate the idea.

(Note that the custom operators are meant to stand in for ?., and I'm only using SRTPs to showcase the pattern; I'm not actually proposing that ?. be implemented using SRTPs, or that an Optional<'Option, 'T> type actually be exposed. It is more likely that the compiler would simply search for and use the appropriate members internally, as it already does for other constructs like for … in … do …, expr[…], etc., etc.)

open System

#nowarn "61"

type Optional<'Option, 'T
    when 'Option         : (member HasValue : bool)
    and  'Option         : (member GetValueOrDefault : unit -> 'T)
    and  ('Option or 'T) : (static member op_Implicit : 'T -> 'Option)> = 'Option

/// Map.
let inline (<&>) (x : Optional<'Option1, 'T>) ([<InlineIfLambda>] f : 'T -> 'U) : Optional<'Option2, 'U> =
    if x.HasValue then ((^Option2 or ^U) : (static member op_Implicit : 'U -> 'Option2) (f (x.GetValueOrDefault ())))
    else Unchecked.defaultof<'Option2>

/// Bind.
let inline (>>=) (x : Optional<'Option1, 'T>) ([<InlineIfLambda>] f : 'T -> 'Option2) : Optional<'Option2, 'U> =
    if x.HasValue then f (x.GetValueOrDefault ())
    else Unchecked.defaultof<'Option2>

//
// Example with custom option type.
//

[<CompilationRepresentation(CompilationRepresentationFlags.UseNullAsTrueValue)>]
type Option<'T> =
    | Some of 'T
    | None
    member this.HasValue = match this with Some _ -> true | _ -> false
    member this.GetValueOrDefault () = match this with Some x -> x | None -> Unchecked.defaultof<'T>
    static member op_Implicit x = Some x

and 'T option = Option<'T>

type A = { B : B option }
and  B = { C : C option }
and  C = { D : int }

let a = Some { B = Some { C = Some { D = 3 } } }

let x : int option = a >>= _.B >>= _.C <&> _.D

//
// Example with System.Nullable<'T>.
//

type [<Struct>] E = { F : Nullable<F>}
and  [<Struct>] F = { G : Nullable<G> }
and  [<Struct>] G = { H : int }

let e = Nullable { F = Nullable { G = Nullable { H = 4 } } }

let y : Nullable<int> = e >>= _.F >>= _.G <&> _.H

//
// Example with mixed optional types.
//

type [<Struct>] X = { Y : Nullable<Y> }
and  [<Struct>] Y = { Z : int }

let foo = Some { Y = Nullable { Z = 99 } }

let bar : int option = foo >>= _.Y <&> _.Z
let baz : Nullable<int> = foo >>= _.Y <&> _.Z
let qux : Nullable<Y> = foo >>= _.Y

If Optional<'Option, 'T> were to be defined in FSharp.Core, then it could add and default 'Option : 'T option to enable target-typing while not requiring annotation in the absence of other type information:

type Optional<'Option, 'T
    when 'Option         : (member HasValue : bool)
    and  'Option         : (member GetValueOrDefault : unit -> 'T)
    and  ('Option or 'T) : (static member op_Implicit : 'T -> 'Option)
    and  default 'Option : 'T option> = 'Option

[^1]: I guess for nullable reference types there could be an implicit augmentation along the lines of this:

```fsharp
type 'T when 'T : null with
    member this.HasValue = match this with null -> false | _ -> true
    member this.GetValueOrDefault () : 'T | null = this
    static member op_Implicit (x : 'T) : 'T | null = x
```
T-Gro commented 2 months ago

In C#'s elvis operator, flattening is not a concern - ((string?)?)? would not make any sense, it naturally just keeps a single level of a possibly missing value.

The inclusion of voption/option change that, as well as the possibility of combining multiple container types in a single long identifier.

Pragmatically, since the purpose of the feature is to avoid nested pattern matching for short one-liners, I think there is a case for always flattening. Which would then need either a default container for the result, or a ruleset for deciding the right container.

module rec Test =
    type MyRecord = { Opt : MyRecord option; Vopt : MyRecord voption; MaybeNull : MyRecord | null; JustInt: int}

    let process (x:MyRecord) = x.MaybeNull?.Opt?.Vopt?.JustInt   // degenerate case to illustrate the need for a ruleset
    let moreLikelyCase(x:MyRecord) = x.MaybeNull?.MaybeNull?.MaybeNull?.MaybeNull?.JustInt  
    // Expected for DTOs coming from serializers etc. I think here it is clear to desire flattening, i.e. returning `option<int>`
T-Gro commented 2 months ago

We will likely not special-case it, as discussed before. We already do not in all other cases.

Yes, sorry, changed the message

Yes, easier flattening is something we should consider.

Explicit flattening of the very last value would be semantically equivalent to something like x?.id (id meaning identity). But I am not sure if adding even more changes to the language would help here.

T-Gro commented 2 months ago

...for any types that followed a pattern of intrinsic or augmented...

I would also favor a more universal approach, since I have seen codebases with domain specific option-like types. Nevertheless, I think the design should then think about composability of these types if different container types are used within the same expression separated with .?.

Maybe in practice this is a problem just for Nullable / T | null because of their 'T constraints not allowing arbitrary 'T->'U transformation. In that case, the ruleset could be specialized for them and all unconstrained containers would behave uniformly.

Lanayx commented 2 months ago

I'd also like to mention, that ?. shouldn't come alone, if it is to be implemented, there should be ?? operator as well. This case is very common (using external interop C# object and converting it into non-nullable value to be used later on):

let x: string = A?.B?.C?.D ?? ""

vzarytovskii commented 2 months ago

@Lanayx

I'd also like to mention, that ?. shouldn't come alone, if it is to be implemented, there should be ?? operator as well. This case is very common (using external interop C# object and converting it into non-nullable value to be used later on):

let x: string = A?.B?.C?.D ?? ""

We don't plan introducing it. Option.defaultValue should be used instead. If users want, they can introduce their own operator.

Tarmil commented 2 months ago

If users want, they can introduce their own operator.

It won't be lazy on the right hand side though 🫤

vzarytovskii commented 2 months ago

If users want, they can introduce their own operator.

It won't be lazy on the right hand side though 🫤

I don't understand what that means. That it will be executed every time? It doesn't matter much, it will be inlined most of the times, and JIT optimised, so it won't cost almost anything. ?. won't be something new to language, it will just be a short circuit returning option.

ken-okabe commented 2 months ago

Here's my perspective:

https://github.com/ken-okabe/vanfs/blob/main/README-whatisNull.md https://github.com/ken-okabe/vanfs?tab=readme-ov-file#nullable

We need Nullable Types(nullable reference types).

Thus, given the presence of Nullable Types, it is logical to introduce a Nullable operator (?. ) within the mathematical framework. It's not a matter of the comparison to C# or JavaScript/TypeScript etc., who have simply fulfilled their obligation to maintain consistency within their algebraic structures by incorporating the Nullable operator—a natural and necessary step given the presence of Nullable Types.

ken-okabe commented 2 months ago

We will likely not special-case it, as discussed before. We already do not in all other cases.

Yes, sorry, changed the message

Yes, easier flattening is something we should consider.

Explicit flattening of the very last value would be semantically equivalent to something like x?.id (id meaning identity). But I am not sure if adding even more changes to the language would help here.

As detailed in https://github.com/ken-okabe/vanfs/blob/main/README-whatisNull.md,

Nullable types are, by definition, incapable of containing nested structures. Therefore, since the set(operand) targeted by the Nullable operation (?. ) does not contain nested structures, there is no need for discussions on whether or not to flatten it.

Simply put, if the operand paired with the Nullable operator (?. ) is not a Nullable Type, it's a type mismatch and the compiler will raise an error.

Tarmil commented 2 months ago

If users want, they can introduce their own operator.

It won't be lazy on the right hand side though 🫤

I don't understand what that means. That it will be executed every time? It doesn't matter much, it will be inlined most of the times, and JIT optimised, so it won't cost almost anything. ?. won't be something new to language, it will just be a short circuit returning option.

It means for example that ?? { x = 1 } will always allocate, and ?? failwith "oh no" will always throw. Ie situations where I would currently use Option.defaultWith instead of Option.defaultValue.

vzarytovskii commented 2 months ago

If users want, they can introduce their own operator.

It won't be lazy on the right hand side though 🫤

I don't understand what that means. That it will be executed every time? It doesn't matter much, it will be inlined most of the times, and JIT optimised, so it won't cost almost anything. ?. won't be something new to language, it will just be a short circuit returning option.

It means for example that ?? { x = 1 } will always allocate, and ?? failwith "oh no" will always throw. Ie situations where I would currently use Option.defaultWith instead of Option.defaultValue.

Yeah, that what I would suggest - use defaultWith. We're not too keen on the idea of adding a new language construct which won't be an expression, but a special "operator" (??) which will expand into piping + generating and implicit lambda.

Lanayx commented 2 months ago

Yeah, that what I would suggest - use defaultWith

Do you mean adding defaultWith function to Fshap.Core? Asking, since currently it only exists for Option and ValueOption, for Nullable and NRT this doesn't exist

T-Gro commented 2 months ago

@Lanayx : The nullness RFC impl is adding inlined defaultIfNull. defaultWithIfNull taking in a lambda it's not there yet, but can be added - if that is what you meant.

T-Gro commented 2 months ago

there is no need for discussions on whether or not to flatten it.

@ken-okabe : That is only true if the proposal covered only NRTs. However, since:

The flattening question is real and affects design choices.

Lanayx commented 2 months ago

The nullness RFC impl is adding inlined defaultIfNull. defaultWithIfNull taking in a lambda it's not there yet, but can be added - if that is what you meant.

Yes, this is what I meant, it's clearly missing to avoid allocation (and/or side effects) in this case

// current PR
let x: MyType = y?.A?.B |> defaultIfNull (MyType())
// should exist
let x: MyType = y?.A?.B |> defaultWithIfNull (fun () -> MyType())

However, there are multiple problems with "many new functions" approach 1) It becomes very verbose, and lambda shorthands are not applicable here. Compare this with

// suggesting
let x: MyType = y?.A?.B ?? MyType()

2) It's not uniform across types (doesn't look nice to me):

let x1 = None |> Option.defaultWith 1
let x2 = ValueNone |> ValueOption.defaultWith 1
let x3 = Nullable() |> defaultIfNullV 1
let x4 = y?.Value |> defaultIfNull 1

In that case it would make more sense to deprecate defaultArg and defaultValueArg functions (as well as specialized module functions) and introduce new default and defaultWith functions (probably using some overloads and/or SRTP magic), that will work for all 4 types (and maybe even future types). At the same time ?? operator could actually cover all those cases above + potentially merge two cases into one, like if it's just value, then use default, if it's some invocation (property, method, costructor) then use defaultWith. This options seems to be the most attractive to me and will be a companion to ?. operator.

P.S. And we shouldn't forget about !. operator as well, people might want to ignore null checks in certain cases

ken-okabe commented 2 months ago

@T-Gro Given that, the suggestion itself sounds inappropriate to me.

A single operator serving multiple types can lead to confusion for both the compiler and the programmer. The nullable operator is specifically designed for nullable types, and if a new operator for option type are required, a separate, dedicated optional operator should be used.

Why do we want to introduce unnecessary complexities?

vzarytovskii commented 2 months ago

Yeah, that what I would suggest - use defaultWith

Do you mean adding defaultWith function to Fshap.Core? Asking, since currently it only exists for Option and ValueOption, for Nullable and NRT this doesn't exist

let x4 = y?.Value |> defaultIfNull 1

No null propagation will always return an option (or value option, depending how we will design it), and we already have defaultwith and defaultvalue.

// suggesting let x: MyType = y?.A?.B ?? MyType()

We will for sure not be introducing a new construct (which can't be classic operator, because it needs to transform rhs to lambda) to the language.

vzarytovskii commented 2 months ago

I think, there's a slight confusion of what @T-Gro meant. What we discussed internally for the value propagation syntax (?.) is that for all the "null-like" and "option-like" types (being 'T | null, System.Nullable<'T>, 'T option and 'T voption) we will have a syntax for accessing its wrapped type safely, which will always covert to an option or a value option (depending on our design decisions), for example (in semi-pseudo example code to get the idea):

type Foo() =
    member _.ReturnsOption = if System.Random.NextInt() % 2 = 0 then Some("foo") else None
    member _.ReturnsVOption = if System.Random.NextInt() % 2 = 0 then ValueSome("foo") else ValueNone
    member _.ReturnsNullable = if System.Random.NextInt() % 2 = 0 then "foo" else null
    member _.ReturnsSystemNullalbe = if System.Random.NextInt() % 2 = 0 then Nulalble(123) else Nullable(Unchecked.defaultof<_>)

let foo = Foo()
foo?.ReturnsOption?.Length         // Option<int>
foo?.ReturnsVOption?.Length        // Option<int>
foo?.ReturnsNullable?.Length       // Option<int>
foo?.ReturnsSystemNullable?.Length // Option<int>
  1. It's uniformed (also, why option, and not Unchecked.defaultof for type? we want to avoid exposing nulls in F# as much as we can)
  2. Since it's uniformed, each of them can then be piped to defaultWith or defaultValue later.
  3. It can return a value option, it's a design and implementation detail.
  4. It doesn't introduce a new special syntax.
  5. This is also why it's crucial do decide of flattening, because it might change things a bit when APIs are not uniformly designed and some return nullable references, and some options-likes.

cc @dsyme

Tarmil commented 2 months ago

Okay this clarifies things. I'm not sure I fully agree though. Uniformity is nice, but I think option mapping to voption or vice-versa would be very surprising for the user.

vzarytovskii commented 2 months ago

Okay this clarifies things. I'm not sure I fully agree though. Uniformity is nice, but I think option mapping to voption or vice-versa would be very surprising for the user.

This is not meant to replace normal options or value options handling.

It's meant for propagation in the method chain calls (or chaining properties), which will highly likely be nullrefs in vast majority of cases. We don't want complex mapping rules when user can't expect what would be a result of long chaining and will need a decoder ring to figure out whether it will return option, voption or one of two nullables.

Like,

dto.Order?.Customer?.Address?.Street?.Name 

will always have a known type at all points when it might short circuit no matter how dto is designed (maybe some parts are options, some voption and other nullables).

Lanayx commented 2 months ago

I agree with @Tarmil, currently almost all Fsharp.Core is monadic, i.e. operators and functions returns the same type. If we map everything to Option, then it's almost a must to do flattening, because there might be case for Option<Nullable<int>> and this

let x = A?.MyNullableProp |> Option.defaultValue |> defaultIfNullV 1

looks horrible.

vzarytovskii commented 2 months ago

I agree with @Tarmil, currently almost all Fsharp.Core is monadic, i.e. operators and functions returns the same type. If we map everything to Option, then it's almost a must to do flattening, because there might be case for Option<Nullable<int>> and this


let x = A?.MyNullableProp |> Option.defaultValue |> defaultIfNullV 1

looks horrible.

It won't be. Nullable will be converted to option, it's clearly seen from the examples above. But if your property itself is int option option or Option<Nullable<int>>, it won't be flattened, only outermost container will be processed.

E.g. if your property is member _.Foo = Some(Nullable(3)), then we will only generate the check for Some, and not for underlying Nullable.

You can think of ?. as if it was pining to Option.ofObj of sorts.

Lanayx commented 2 months ago

It won't be. Nullable will be converted to option, it's clearly seen from the examples above

But you didn't show such examples above? The example will be

type Foo() =
    member _.A = Nullable(1)

let x = Foo()
let y = x?.A // Option<Nullable<1>>

From our previous conversation I understood that "we won't be ourselves flatten nested (v)options in sake of uniformity and not having special cases"

vzarytovskii commented 2 months ago

It won't be. Nullable will be converted to option, it's clearly seen from the examples above

But you didn't show such examples above? The example will be

I 100% did, just with some underlying property. Without property it will generate HasValue check and will wrap the value in option, not Nullable itself. Let me change it so it's more clear

foo?.ReturnsSystemNullable?.Length // Option


type Foo() =

  member _.A = Nullable(1)

let x = Foo()

let y = x?.A // Option<Nullable<1>>

From our previous conversation I understood that "we won't be ourselves flatten nested (v)options in sake of uniformity and not having special cases"

Yes, if you decide to return option<option<_>>, it's on you.

vzarytovskii commented 2 months ago

There's a lot confusion for it. I think next step is that we need an rfc for it listing all the corner cases and what would be produced for each.

Some things to cover in rfc

  1. What do we do with the rightmost return type for it, if it's one one the nullable or option-like ones - do we convert to option or leave it as original type.
  2. When we short circuit - what do we return, rightmost type, current type, "cast" to option.
  3. If we decide to use uniform option, which one should that be (normal or value), pros and cons (like always copying in case of valuetype).
Lanayx commented 2 months ago

I 100% did, just with some underlying property. Without property it will generate HasValue check and will wrap the value in option, not Nullable itself. Let me change it so it's more clear

Sorry, I still don't understand from this explanation, what would you like the type of y be in my case (not a rare case from consuming C# classes)

type Foo() =
    member _.A = Nullable(1)
let x: Foo | null = Foo()
let y = x?.A

should it be Option<Nullable<int>> or Option<int>? First means no flattening, second means implicit flattening. In first case we'll have to use y |> Option.defaultValue |> defaultIfNullV 1 to get default value