Type-directed resolution of numeric and string literals, and extending `B` string suffix to be UTF8 strings
Literally just expanding on https://github.com/fsharp/fslang-suggestions/issues/1086
I propose we allow numeric literals like 1 1.1 to resolve to types that are not their default types (int and float).
type SomeApi() =
static member SomeEntryPoint(xs: ImmutableArray<byte>, f: float32) = ...
SomeApi.SomeEntryPoint([1; 2; 3], 3) // byte and float32
The existing way of approaching this problem in F# is
type SomeApi() =
static member SomeEntryPoint(xs: ImmutableArray<byte>, f: float32) = ...
SomeApi.SomeEntryPoint(ImmutableArray.Create(1uy, 2uy, 3uy), 3f) // Looks very unnatural.
The main thing here is that whenever a method needs a number (of more or less any kind), as input, you can always use, for example, 1 and not think about it.
This means F# APIs can reasonable choose to adopt float32 or other such numeric types for inputs without particularly changing user code.
If this is considered reasonable, the question arises about when this is allowed. For example, it's possible that it should just be at method argument position - F# has a tradition of only applying such "magic" (e.g. lambda --> delegate, ParamArray and more recently op_Implicit) only at method applications. However in theory it could also be at any position by type-directed resolution, e.g.
let x: ImmutableArray<_> = [1;2;3]
x |> id<ImmutableArray<byte>>
For some reason people don't seem to mind these transformations at method calls.
The syntax would work with
- built-in integer types (with compile-time bounds checking): int8, uint8, int16, uint16, int32, uint32, int64, uint64, nativeint, unativeint
- built-in float types (with compile-time bounds checking): float, float32, decimal
- any NumericLiteralX module in scope: BigInteger (NumericLiteralI), custom QRZING suffixes
- any other numeric type implementing
System.Numerics.INumberBase<TSelf>, callingCreateChecked: System.Half, System.Int128, System.UInt128, System.Runtime.InteropServices.NFloat, System.Numerics.Complex... - char: must be included as a result of including all numeric types implementing
INumberBase<TSelf>. - underscores, and hexadecimal, octal and binary notations: https://github.com/fsharp/fslang-design/pull/770 must be included.
-
[<Literal>]and patterns for built-in types:match 1: byte with 1 -> true | _ -> false
The underlying problem here is that we over-emphasise the "int" and "float" types in F# - that is, in an API designer wants to get nice, minimal callside syntax, they have no choice but to use int and float. However this has problems
- The types used can be relatively large in size: byte is only 1 byte, int is 4 bytes long.
- F# cannot consume APIs using other numeric types as easily as C# can: C# can just
new byte[] { 1, 2, 3 }while F# has touy-suffix every item. - It's probably not the data representation used inside the API. For example the F# quotations API uses list throughout. but internally converts to arrays.
Ditto for string types. The syntax would work with
-
strings, obviously. -
byte array, this is the current resulting type of theBsuffix("123"B). Currently, only ASCII characters are allowed. However, the .NET ecosystem is moving towards UTF8 strings. I suggest this suffix to allow UTF8 strings - We will need compiler-checking for UTF8 well-formedness. The details are the same as the UTF8 strings C# proposal. -
char array. UTF16 representation. - Any "collection" of
bytes, as defined in https://github.com/fsharp/fslang-suggestions/issues/1086. The string will be interpreted as UTF8 and checked for well-formedness. For example,ReadOnlySpan<byte>. - Any "collection" of
chars, as defined in https://github.com/fsharp/fslang-suggestions/issues/1086. The string will be interpreted as UTF16 with current string rules. For example,ImmutableArray<char>. - (Optional) Any "collection" of
System.Text.Runes. The string will be interpreted as UTF32 and checked for well-formedness. - String interpolation which will only work for concatenating bytes to bytes or chars to chars. Error if UTF8 and UTF16 are mixed. The implementation will follow collection builder methods as used in https://github.com/fsharp/fslang-suggestions/issues/1086.
Pros and Cons
The advantages of making this adjustment to F# are nice syntax for many more numeric and string types.
The disadvantages of making this adjustment to F# are
- performance can vary significantly based on type annotation
- harder to work out what actual methods are being called
- you need a type annotation or strongly known type
- there are two ways to do numbers and strings, i.e. 1uy and via known type (1: byte)
Extra information
Estimated cost (XS, S, M, L, XL, XXL): M
Related suggestions: (put links to related suggestions here) https://github.com/fsharp/fslang-suggestions/issues/1086
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
- [x] 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.
I'd think it would just be nicer if we could provide op_Implicit augmentation extensions. That already has all the benefit of providing compiler warnings when not explicit, but allowing some semblance of what you're seeking without needing to change much of anything at the moment.
I assume theres some fundamental issue to having op_Implicit as augmentations though.
@shayanhabibi Same justifications as from #1086, op_Implicit from string must incur a performance penalty with regards to string allocation, and you also lose compile-time bounds checking for int->byte for example.
The main obstacle I see are possible implementation challenges when it comes to method resolution and constraint solving, but the suggestion itself makes sense.
There are existing implicit conversions happening in the compiler and this could achieve regularity of concepts and also move the costs from runtime to compile-time for some of these conversions.
This does need a detailed RFC incl. how it plays with existing conversions and specifically highlight which concepts are brand new (e.g. the UTF8 strings) and which are improvements done for existing conversions. It should also list specific "measures by-design" which will not allow to create incorrect programs by passing overflowing values or cause weird runtime bugs due to loss of precision. Since this suggestion is about literals, all the needed checks can and should happen at compile-time.
I am not yet fully sold on the full breadth of the outlined suggestion (e.g. the addition of support of INumberBase, Rune and other BCL types which are rarely used), but will mark this suggestion as approved in principle and leave space to discuss the specific subset here or in a discussion for the RFC.
I think splitting "the B suffix on strings becoming UTF-8 instead of ASCII" into its own RFC would make sense: the change is backward-compatible and requires no changes to type conversions, and I think would likely be pretty uncontroversial? It seems to be not like the other changes in this RFC.
This is the can of worms that the [] suggestion has opened.
Almost anything that previously was concrete is now going to become an abstract and potentially ambiguous syntactic expression.
This is not some minor tinker. The benefits are minor: removing a . here and there. The cognitive costs are very large.
The idea that this is justified because of some compiler benefits doesn't make sense to me. Implicit conversions are easily detected, implicit conversions (e.g. from int to float) are easily removed (by adding a .), and they are not expensive operations. Whereas the compiler will have to do extra work since it cannot immediately type the ambiguous expression: the cognitive cost is paid for both by the compiler and by F# writers.
This is much larger than implicit conversions which are already at the fringe of F#, sparingly used. Mathematically they make sense. In 2 + 3.4, 2 can be understood via the natural embedding of integers in real numbers, i.e. as implicit conversion. It's not that 2 "is" both an integer and a real number either simultaneously or as some potential to be either.
The benefits of F# as a statically typed language with a powerful type system are based on knowing what the type is of everything at every point. This suggestion would chip away at this. If I write 2 I want to know that it's an integer.
What is the thought process here of giving this an approval with no discussion? @T-Gro It's a massive change to F#. Edit: it's also an extension of a non-approved suggestion!
@charlesroddie :
See my comment above - my chain of thought was that this suggestion is heavily dependent on details, and those are better described with context in an RFC and commented there, with references to those specifics. It does not mean that any form of this suggestion will go trough. It just means that the discussion can be (in my opinion) more focused on the details in an RFC (also the format of reactions on specific lines of the proposal and ability to have several orthogonal discussions IMO works better in an RFC PR than it does here at an issue).
Also, the bounding context given by the motivation is when literals are passed as non-generic arguments to functions/methods without introducing ambiguity to neither method resolution nor type inference. In that context, all the symbols remain statically typed, with a known type (available e.g. via tooltips or at diagnostics), without chipping away any safety.
let _ = 2 // an integer
let _ = 2 + 2 // still an integer
let _ = string 2 // still passing "2" as an integer
let x = cos 2 // Resolving the literal "2" to a floating point constant at compile-time.
This is the can of worms that the
[]suggestion has opened.Almost anything that previously was concrete is now going to become an abstract and potentially ambiguous syntactic expression.
There are no ambiguous types here. Either the type stays generic in an inline context, or the type is concrete either from type inference or type definition.
This is not some minor tinker. The benefits are minor: removing a
.here and there. The cognitive costs are very large.
You also don't have to memorize the literal suffix table anymore. Support for new numeric types also comes for free as they have op_Implicit conversions defined.
The idea that this is justified because of some compiler benefits doesn't make sense to me. Implicit conversions are easily detected, implicit conversions (e.g. from int to float) are easily removed (by adding a
.), and they are not expensive operations. Whereas the compiler will have to do extra work since it cannot immediately type the ambiguous expression: the cognitive cost is paid for both by the compiler and by F# writers.
F# already does a lot of type inference from types from other places like external library type definitions. The additional time spent on type inference is small and the cognitive cost of "tracking" type inference is normally done by the IDE. No one expects to know every type by reading F# source code on GitHub since there is so much type inference going on already. If you specify explicit types, this feature just enables more natural specification of values.
This is much larger than implicit conversions which are already at the fringe of F#, sparingly used. Mathematically they make sense. In
2 + 3.4, 2 can be understood via the natural embedding of integers in real numbers, i.e. as implicit conversion. It's not that 2 "is" both an integer and a real number either simultaneously or as some potential to be either.
int is not an integer. It is an implementation of the concept of integer with 32 bits. The decision to infer 2 as a specific integer implementation of 32 bits is arbitrarily defined. This goes similarly for other literals.
let x: int = 1 + 2 - 3 * 4 / 5 // evaluation using 32-bit integers. The result is 1
let y: float = 1 + 2 - 3 * 4 / 5 // evaluation using 64-bit floats. The result is 0.6
The benefits of F# as a statically typed language with a powerful type system are based on knowing what the type is of everything at every point. This suggestion would chip away at this. If I write
2I want to know that it's an integer.
2 is still an integer. It's just not restricted to that specific implementation of 32 bits.
What is the thought process here of giving this an approval with no discussion? @T-Gro It's a massive change to F#. Edit: it's also an extension of a non-approved suggestion!
That suggestion was once approved before but retracted because of possible drawbacks. I believe these drawbacks can be overcome with sufficient tooling like showing inferred types on hover.
See my comment above - my chain of thought was that this suggestion is heavily dependent on details, and those are better described with context in an RFC and commented there, with references to those specifics. It does not mean that any form of this suggestion will go trough. It just means that the discussion can be (in my opinion) more focused on the details in an RFC (also the format of reactions on specific lines of the proposal and ability to have several orthogonal discussions IMO works better in an RFC PR than it does here at an issue).
@T-Gro So an RFC somewhere is designed as for view only. OK. That sometimes happens, e.g. with "for view only" in the title. This RFC does that by saying it's not marked as approved. OK. Why is this issue then marked as approved? Marking this issue as approved just so a PR can be made into another repo which says it's not approved makes no sense and is obviously dangerous. E.g. someone might commit a correction to that PR saying that it is approved, with evidence from the tag here, and then it might be considered for merging. Can we resolve that before continuing with the ordinary leisurely discussion of this suggestion?
Wisened by multiple errors/mistakes and clearly a hawk for certain contributing practices; both views have merit, and I know plenty of blokes who will throw their two cents in with a bad scowl. Fine. That would at least provide opportunity for the author to provide counter points that might satisfy you. It would be nice if you gave some further reasoning as to why you do not think the idea should even be entertained any further. Choosing this hill to die on seems counter productive. Give us something @charlesroddie 💯
@charlesroddie : Please treat the approval in principle here as agreeing this idea is worth detailing out (in much larger detail that this suggestion format allows to keep parallel discussions manageable) in an RFC and discussion those details there. It is a request for comments, not a right to be merged.
I didn't feel the need to put it in the title there that an RFC PR invites for comments and a discussion - that should be the case for all of them. Would you feel better if the RFC PR title is more indicative in this specific case?
I didn't feel the need to put it in the title there that an RFC PR invites for comments and a discussion - that should be the case for all of them. Would you feel better if the RFC PR title is more indicative in this specific case?
Historically "approval in principle" has meant that, after some period of discussion - often, for complex issues, extensive and taking place over years - Don (historically) has considered an idea worth taking forwards. Within the system as it has been, this suggestion has bypassed an important gate (community discussion followed by an approval in principle representing a very considered decision).
I think we should assume that this system still exists. It's been an effective system in that I think you'd find F# devs agree that most of the implemented RFCs have been improvements, and that the net effect has been very positive. You certainly need some conservatism (and there are statements about that in this repo's readme.md) because the net effect of implementing random suggestions would be very negative. I would argue that this conservatism should increase as F# gets more polished, since the ease of finding good improvements decreases as more improvements are implemented.
This issue has potentially bypassed the "approval in principle" gate. That is very dangerous.
Re-titling the RFC would introduce some safety and would be normal for a non-mergeable PR, but the confusion about what "approval in principle" means still remains. It likely means "this is approved to write a draft non-mergeable RFC" here but that will confuse everyone - they will have to read many comments in this issue to understand that there is some exception here. Even after reading everything, they will not know who is supposed to be commenting on the RFC, what would happen after the draft is ready, if there is some other status really approved in principle that this issue would get if after the usual discussion process it were to get approved to write a mergeable RFC, whether this is a new process or a one-off.
All we need to do to fix the situation is to remove "approved in principle". The RFC draft already says it's not approved in principle.
Any changes to how the suggestions system works in future can be discussed at leisure. It may be a good time to do that!
I think I can relate to @charlesroddie concerns (this is not always the case, so let's rejoice), but I also notice that @Happypig375 has put significant effort in making comprehensive and "each part optional" RFC, he's also seasoned in engaging in many past features, that helped design, in the conservative way we all like about F#.
I'd say the "positing" parts of the RFC to be "approved for implementation" (this should be a status of the sections of a RFC, for such RFC which has taken the alternate process than the one we normally see), should occur only after we've seen significant stuff occuring in BCL & C# ecosystem, that leans on those things related to type directed magic.
The main reason for this, on my end is, approving most of the changes in the RFC, without passing the "F# code I love" and "doesn't increase compile time significantly, even on codebases that may use inline a tad more than usual" from @dsyme, is indeed, a risk, for this specific set of features.
We could have those tags as well for sensitive RFC+compiler PRs:
- "conducive to F# code Don loves"
- "feature doesn't screw compile time and results for FSharpPlus, giraffe, and paket command line"
In which case, there would be extra QA steps in the merging compiler PRs.
But I think if we overall agree, and @Happypig375 is happy to move things forward on the RFC, without attachment to outcome that it will all make it in F# vnext (because he would rush to implement it, who knows?), then, I'm still overall ok with decision of @T-Gro, knowing he didn't do it lightly, and @dsyme has already expressed that the compiler team has this overview, and he would only step in if needed or wanted.
It would be nice if you gave some further reasoning as to why you do not think the idea should even be entertained any further. Choosing this hill to die on seems counter productive. Give us something @charlesroddie 💯
I feel:
implicit conversions which are already at the fringe of F#
and knowing @charlesroddie conservatism to keep F# on the path of "passes principle of least surprise" expectation (per a certain mindset of what surprise means, @charlesroddie cares more about mathematics than F# script kiddies), it was "something".
Thank you for the insight!
I do appreciate and agree that explicit is better than implicit, and for implicit to be strictly opt-in for the solo-developers leisure rather than the norm which seems to be a unanimous viewpoint.
I was concerned the last place @charlesroddie left off did not acknowledge the response the author had placed prior, and even with disagreement, I'm sure his intention was not to appear dismissive of an earnest contribution. Naturally, he did clear this up and provided further in depth reasoning to his point. I don't even think it really differed much from the original response, but it did feel (as someone who is very fresh to this space) that it provided clarity on the history which prompted the discussion. I hope that's understandable. I recognise this is my own interpretation of the conversation which is in no way reflective of what either parties might have been thinking 🙂
@charlesroddie :
You are right, I have rushed the approved in principle as I believed comments (the C in RFC) can still continue and putting the label on does not terminate the discussion here. I now see that this was not needed, since I could have left just the Needs RFC to express that and avoid confusion.
I will leave that one to communicate what I intended to do from the start: That the suggestion is heavily dependent on details about its parts. Personally, those (many, often independent) details are better described and discussed on the RFC PR format to avoid spaghetti chat. But this place is still good for an overall opinion about the feature as a whole, and the two (RFC PR + suggestion) can co-exist.
The main reason for this, on my end is, approving most of the changes in the RFC, without passing the "F# code I love" and "doesn't increase compile time significantly, even on codebases that may use inline a tad more than usual" from @dsyme, is indeed, a risk, for this specific set of features.
I do see a big split in how I feel about the feature:
- Only use it for literals passed directly as method arguments, incl. changes to overload resolution vs.
- Use it throughout sequences of
letbindings (or|>piped chains) as well, with impact on type inference
The former is essentially a variation on " op_Implicit for constants, but compile-time". With the information available in the symbols for tooling and being statically available, I would argue that this does not increase risk and does not change the "code I love" aspect.
@smoothdeveloper @T-Gro I added this text to the RFC. Is it convincing enough?
C# has been adding type-directed features like collection expressions, which makes C# more succinct, robust and performant when calling .NET methods that expect collections, like ReadOnlySpan. It is ideal that F# achieves parity with C# at least when calling method overloads using the cleanest collection syntax ([ ... ]). However, [ ... ] currently has the fixed type _ list. Therefore, this RFC focuses on adding type-directed inference of existing literals like [ ... ]. There are a few levels of implementation to choose from -
- Only allow type-direction at literals passed directly to method arguments.
open System
// before
do
String.Join(",", [| "1"; "2"; "3" |].AsSpan()) |> printfn "%s"
String.Join(",", [| "1"; "2"; "3" |]) |> printfn "%s"
// after
do // now calls (string * ReadOnlySpan<string>) overload with stack allocation.
String.Join(",", ["1"; "2"; "3"]) |> printfn "%s"
(String.Join: string * string array -> _)(",", ["1"; "2"; "3"]) |> printfn "%s"
- In addition to level 1, also allow modification of type inference anywhere when the target type is known.
// also allow
do
let xs: ReadOnlySpan<string> = ["1"; "2"; "3"]
String.Join(",", xs) |> printfn "%s"
let ys: string array = ["1"; "2"; "3"]
String.Join(",", ys) |> printfn "%s"
- In addition to level 2, also allow modification of type inference throughout sequences of local
letbindings (or|>piped chains) as well, with impact on type inference.
// also allow
do
let xs = ["1"; "2"; "3"] // now infers ReadOnlySpan<string>
String.Join(",", xs) |> printfn "%s"
let ys = ["1"; "2"; "3"] // now infers string array
(String.Join: string * string array -> _)(",", xs) |> printfn "%s"
// also allow
do
["1"; "2"; "3"] // now infers ReadOnlySpan<string>
|> fun xs -> String.Join(",", xs) |> printfn "%s" // Note: |> support for ref structs is in scope of this RFC.
["1"; "2"; "3"] // now infers string array
|> Array.copy
|> fun ys -> String.Join(",", ys) |> printfn "%s"
- In addition to level 3, also allow
inlinefunctions to specify constraints to enable type direction for literals on a statically resolved type parameter.
// also allow
let inline g<^a when ^a: [string]>() =
["1"; "2"; "3"]
// ReadOnlySpan<string> is not applicable because ref structs cannot propagate across inline function boundaries. See https://github.com/fsharp/fslang-suggestions/issues/688#issuecomment-1201603354
// string * string array
do String.Join(",", g()) |> printfn "%s"
- In addition to level 4, also allow modification of type inference across inline function boundaries.
// also allow
let inline f() = ["1"; "2"; "3"] // f: unit -> ^a when ^a: [string]
// string * string array
do String.Join(",", f()) |> printfn "%s"
- In addition to level 5, also allow modification of type inference across non-public non-inline function boundaries.
// also allow
let private f() = ["1"; "2"; "3"] // unit -> string array
do String.Join(",", f()) |> printfn "%s"
- In addition to level 6, also allow modification of type inference across public non-inline function boundaries.
// also allow
let f() = ["1"; "2"; "3"] // return type should admit ReadOnlySpan<string> - construct an array as stack allocated data cannot be returned here
do String.Join(",", f()) |> printfn "%s"
C# only allows up to level 2 because it does not perform type inference as much as F# does.
using System;
// level 1
Console.WriteLine(String.Join(",", ["1", "2", "3"]));
// level 2
ReadOnlySpan<string> xs = ["1", "2", "3"];
Console.WriteLine(String.Join(",", xs));
// Note: The above line emits nullability warning CS8620 on `xs` because ReadOnlySpan<string> is passed into ReadOnlySpan<string?>
// level 3 - error
var xs = ["1", "2", "3"];
// error CS9176: There is no target type for the collection expression.
Console.WriteLine(String.Join(",", xs));
// level 6 - error
var f() => ["1"; "2"; "3"];
// error CS0825: The contextual keyword 'var' may only appear within a local variable declaration or in script code
Console.WriteLine(String.Join(",", f()));
Level 7 breaks binary compatibility with existing code. Not just for list literals but also for numeric literals when implicit conversions are applied to int64, nativeint and float as defined in FS-1093. Therefore, it must not be implemented.
module A
let a = 1 // This is public. There may be external dependencies.
let b = a + 1L
// before: Uses type-directed conversion of int32 -> int64.
// after: changes type of a to int64.
When are new features a good thing? notes that
features which make the language more orthogonal, simpler and easier to use are generally a very good thing.
Allowing more literals to fit different types makes the language more orthogonal. Literals can now be "implemented" with different types without the need to be explicit about conversions. The annotations that are required to denote a different numeric or collection type that is not the default can be eliminated.
// before
let simple: int list = [1; 2; 3; 4]
let moreSyntax: uint64 Set = set [1UL; 2UL; 3UL; 4UL]
let evenMoreSyntax: ImmutableArray<byte> = ImmutableArray.Create [|1uy; 2uy; 3uy; 4uy|]
// after
let simple: int list = [1; 2; 3; 4]
let moreSyntax: uint64 Set = [1; 2; 3; 4]
let evenMoreSyntax: ImmutableArray<byte> = [1; 2; 3; 4]
It's also simpler and easier to use.
Meanwhile, this should also be as orthogonal as possible with type inference - the fact that more types support direct definitions from literals should not interfere with type inference. If [ ... ] can be an ImmutableArray, then it should behave like one as much as possible under type inference.
Ideally for all the new type-directed inference, the same rules for inference of statically resolved constraints should also be followed:
let f a b = a + b
let g = f 1L 2L // Changes type of f to long -> long -> long
Ideally level 7 would result in the most orthogonality between type-directed resolution and type inference but with binary compatibility constraints, only level 6 is achievable. This RFC aims to achieve a level 6 implementation. It can also be trimmed as necessary to target a lower level implementation, as each lower level is a subset of a higher level.
Targeting implementation level 6 instead of 7 means that a series of public let bindings in a module might work differently than a series of local let bindings or a series of non-public let bindings in a module.
do
let x = ["1"; "2"; "3"] // ReadOnlySpan<string> (best type in a local context)
printfn "%s" <| System.String.Concat(",", x)
let y = ["1"; "2"; "3"] // string Set (limited by type constraint below)
printfn "%A" <| Set.union y y
module private PrivateModule =
let x = ["1"; "2"; "3"] // string array (best type outside a local context)
printfn "%s" <| System.String.Concat(",", x)
let y = ["1"; "2"; "3"] // string Set (limited by type constraint below)
printfn "%A" <| Set.union y y
module PublicModule =
let x = ["1"; "2"; "3"] // string list (defaulted for binary compatibility)
printfn "%s" <| System.String.Concat(",", x)
let y = ["1"; "2"; "3"] // string list (defaulted for binary compatibility)!!
printfn "%A" <| Set.union y y // error!!
This is especially confusing if the series of let bindings is used in anonymous implementation files. A warning should be implemented to warn against defaulting behaviour due to public visibility despite later code trying to infer it as a different type.
module PublicModule =
let x = ["1"; "2"; "3"] // warn - defaulted to string list due to public visibility despite best type being string array
printfn "%s" <| System.String.Concat(",", x)
let y = ["1"; "2"; "3"] // warn - defaulted to string list due to public visibility despite later code trying to constrain to Set
printfn "%A" <| Set.union y y // error
let n = 1 // no warning - int is chosen as the default because the type is unconstrained, not because of public visibility.
It is good practice to specify types for public declarations that others may depend on anyway. This warning should encourage clearer code.
@Happypig375 There are no ambiguous types here.
The ambiguity is that a syntactic expression has multiple interpretations. let x = 1 becomes ambiguous which is an very large degradation of the F# language.
Support for new numeric types also comes for free as they have op_Implicit conversions defined. op_Implicit from string must incur a performance penalty with regards to string allocation, and you also lose compile-time bounds checking for int->byte for example.
op_Implicit semantics have been carefully discussed and managed, with a layered system of warnings to allow careful management of relaxation of explicitness. It's a very well designed system which should be carefully adapted to when considering further relaxations.
These two benefits you give of this suggestion over existing op_Implicit can be replicated by doing some implicit conversion of literals at compile time. That gets the performance/safety advantages of this suggestion you mention without a change in semantics. It still needs serious discussion in a language suggestion because there are tradeoffs and a lot of specification needed, but existing work around both op_Implicit and constant expressions points in this direction.
No one expects to know every type by reading F# source code on GitHub
That's a deficiency of a lot of code. People have optimized for writing instead of reading. Code should be optimized for reading since it's read much more than written. AI tips the balance even further since the cost of writing code decreases and the demand for human reading of code increases.
int is not an integer...
let y: float =... 2 is still an integer.
This argument proves my point because int is a model of the integers and float is not and the boundary between the two is weakened. If 2 is interpreted as a floating point number (which it is in float since float models) it can no longer be thought of as an integer. The real number number 2 (in R) or the floating point number 2. (in float) is not an integer.
Non-mathematicians might find this subtle but the general point is that even if there is a natural injection (embedding) from x in set a to x' in set b (or an op_Implicit or cast between x of type 'a and x' of type 'b), this doesn't mean that x equals x'! For example, x may have operations on it that x' does not have.
You also don't have to memorize the literal suffix table anymore.
That is tiny cost. Typically you would need to know 0-3 of these. I personally remember "f", sometimes remember "uy", and look up "L" when I need it, the latter 2 being very rare. The most likely explanation for the rare case you would get a lot of different literals used if that there is an intentional desire to be precise and explicit about types.
@charlesroddie
The ambiguity is that a syntactic expression has multiple interpretations.
let x = 1becomes ambiguous which is an very large degradation of the F# language.
It is not ambiguous. It is as "ambiguous" as the current let x = Unchecked.defaultof<_> "let x be a default value of a type to be inferred later" or let x = None "let x be the None case of an option type with generic type parameter to be inferred later". let x = 1 would be "let x be the value 1 of a numeric type to be inferred later". or you can also put an explicit type annotation if you so please (without confusing non-programmers with the use of numeric suffixes which has been documented at https://github.com/fsharp/fslang-suggestions/issues/737): let x: decimal = 1
op_Implicit semantics have been carefully discussed and managed, with a layered system of warnings to allow careful management of relaxation of explicitness. It's a very well designed system which should be carefully adapted to when considering further relaxations.
These two benefits you give of this suggestion over existing
op_Implicitcan be replicated by doing some implicit conversion of literals at compile time. That gets the performance/safety advantages of this suggestion you mention without a change in semantics. It still needs serious discussion in a language suggestion because there are tradeoffs and a lot of specification needed, but existing work around bothop_Implicitand constant expressions points in this direction.
Implicit conversion of literals at compile time looks something like this - let x: int64 = 1 where 1 has type int32 and the conversion of int32 -> int64, which is detected at compile time, is optimized away. Fixing 1 to have the type int32 means that let x: int8 = 1 will not work because int32 -> int8 is an unsafe conversion. However, int8 can represent the numeric value 1 just fine. So why bother with fixing the type of 1 to int32 at all?
That's a deficiency of a lot of code. People have optimized for writing instead of reading. Code should be optimized for reading since it's read much more than written. AI tips the balance even further since the cost of writing code decreases and the demand for human reading of code increases.
Proposed - as written below - is considered more readable than Current for non-programmers - this is not a made-up example. It's a real example from https://github.com/fsharp/fslang-suggestions/issues/737.
// Current
let slope = -0.11m
let consta = 0.5m
let doSomething score =
let complement =
1m - (score * slope + consta)
if complement > 0.95m then 0.95m
elif complement < 0.85m then 0.85m
else complement
// Proposed
let slope = -0.11
let consta = 0.5
let doSomething score =
let complement =
1 - (score * slope + consta)
if complement > 0.95 then 0.95
elif complement < 0.85 then 0.85
else complement
let result: decimal = doSomething 3 // backwards inference of decimal from the type-annotation of result
Again, F# code that utilizes type inference is not intended to be easily type-resolvable without IDE tooltips (which can show the concrete types of numeric literals on hover). This is why we maintain and edit F# code in IDEs with full IntelliSense support and not on GitHub editors.
This argument proves my point because
intis a model of the integers andfloatis not and the boundary between the two is weakened. If 2 is interpreted as a floating point number (which it is infloatsincefloatmodels) it can no longer be thought of as an integer. The real number number 2 (in R) or the floating point number2.(infloat) is not an integer.Non-mathematicians might find this subtle but the general point is that even if there is a natural injection (embedding) from x in set a to x' in set b (or an op_Implicit or cast between x of type 'a and x' of type 'b), this doesn't mean that x equals x'! For example, x may have operations on it that x' does not have.
Ok, so your argument goes like this -
-
Mathematical Distinction:
- Integers (ℤ) and real numbers (ℝ) are distinct mathematical sets
- Floating-point numbers approximate ℝ but are fundamentally different from integers
-
2(integer) ≠2.0(float) in mathematical representation
-
Type Identity Crisis:
-
intandfloatmodel different numerical systems - Operations valid on integers (e.g., bit shifts) don't exist for floats
- Operations valid on floats (e.g., fractional exponents) don't exist for integers
-
-
Implicit ≠ Identity:
- Even if conversion exists (
int→float), the values aren't identical - The conversion is an approximation (floats can't represent all integers exactly)
- Even if conversion exists (
-
Conceptual Pollution:
- Blurring boundaries weakens mathematical reasoning
-
2interpreted as float loses its "integer-ness" properties
Here is why this isn't a concern in practice -
Haskell proves that numeric literal inference is compatible with rigorous type systems and mathematical correctness. The theoretical objection — while philosophically interesting — fails to withstand real-world evidence from functional languages. This proposal with its additional safety checks and explicit opt-in, represents a reasonable, practical compromise validated by decades of successful use in purer languages.
As a language with stricter purity guarantees than F#, Haskell demonstrates that:
- Polymorphic numeric literals (5 :: Num a => a, compare the proposal here: 5 : (^a when ^a: 5))
- Implicit conversions (2 + 3.14 :: Fractional a => a, compare the proposal here: (2 + 3.14) : (^a when ^a: 2 .. 3.14 and ^a: float and ^a: (static member (+): ^a * ^a -> ^a)))
do not compromise mathematical integrity when implemented with a robust type system.
This directly refutes claims that such features inherently "blur mathematical boundaries."
Haskell maintains mathematical rigor while allowing inference because:
- Typeclasses enforce correctness:
Operations remain domain-specific despite literal polymorphism. Compare:-- Haskell: Integer-specific operation (bit shift) 4 `shiftL` 2 -- Only works for Int/Integer, not Float -- Haskell: Float-specific operation sin (3.14 :: Float) -- Only works for Fractional types// F# 4 <<< 2 // does not work with floating-point types sin 3.14 // does not work with non-floating-point types - No false equivalence:
2 :: Intand2 :: Floatare distinct values with different behaviors.
Haskell's 30-year track record shows:
- No significant issues with "conceptual blurring"
- No type-safety failures attributed to numeric inference
- Wide adoption in academia (where mathematical purity matters)
- Real-world outcomes > theoretical concerns:
If polymorphic literals caused fundamental problems, Haskell—a language used for formal verification—would have abandoned them.
On top of that -
- Industry Standard: Every mainstream language allows this (C#, Java, Python)
- Ergonomics: Explicit conversions add noise for common cases
// Without inference
let x = float 1 / float 2
// With inference
let x: float = 1 / 2 // Cleaner - 1 and 2 are typed as float, the type annotation can be inferred from later code.
- Safety Nets:
- F# range-checks literals (
let x: byte = 256errors - Range checks ensure value preservation for mathematical purity) - Float conversion is exact for integers ≤ 2⁵³ which covers almost all use cases of numeric literals
- F# range-checks literals (
Your concerns are mitigated by:
-
Changes to type inference only applies to literals, not viral inference for non-literals (mitigates conceptual blurring):
- Only applies to literals, not variables
-
let x = 1 // Still inferred as int if without further type annotations -
let y: float = 1 // Explicit type request -
let z: float = byte 1 // Error: byte does not have an implicit conversion to float
-
Requiring any numeric literal with a decimal dot to have a floating-point type and not an integer type (preserving the expectation of 1.0 being an imprecise floating-point value):
let x: int = 1.0 // Error: int doesn't support floating-point values
but the reverse is allowed for ergonomics
let x: float = 1 // This is fine - an exact conversion
You also don't have to memorize the literal suffix table anymore.
That is tiny cost. Typically you would need to know 0-3 of these. I personally remember "f", sometimes remember "uy", and look up "L" when I need it, the latter 2 being very rare. The most likely explanation for the rare case you would get a lot of different literals used if that there is an intentional desire to be precise and explicit about types.
I agree that they can be rare, but when they appear, they detract from readability, as documented in https://github.com/fsharp/fslang-suggestions/issues/737.