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

Allow mutating structs through read-only property setters in non-mutable contexts #1410

Open
6 tasks done
IS4Code opened this issue Feb 18, 2025 · 7 comments
Open
6 tasks done

Comments

@IS4Code
Copy link

IS4Code commented Feb 18, 2025

I propose we allow the mutation (via <-) of structs through properties (and indexers) that are [<IsReadOnly>] even in situations when the location is not mutable (or the struct is the result of an expression). This parallels dotnet/csharplang#8364 in the reasoning and usage.

Currently, this code is prohibited:

[<IsReadOnly; Struct>]
type ListMember<'T>(list: IList<'T>, index: int) =
  member this.Value
    with get() = list[index]
    and set v = list[index] <- v

let getMember() = ListMember<_>([| 1 |], 0)

do
  getMember().Value <- 20 // FS0257 Invalid mutation of a constant expression. Consider copying the expression to a mutable local, e.g. 'let mutable x = ...'.

The reason F# even prohibits that operation in the first place has to do with avoiding errors in code that might look like it is mutating a value, but it is working on a copy of it and thus the mutation is not sensible.

However, in this situation, the struct is IsReadOnly so its contents cannot be mutated. This means that the setter, if sensibly defined, must operate on a value that is detached from the struct's contents, and thus any arbitrary copy of the struct is as good to be "mutated" through this property as the original.

Therefore, if the struct is mutated through an IsReadOnly property, or the struct itself is IsReadOnly, both of these operations should be allowed:

do
  getMember().Value <- 20 // calls set_Value on the result

  let m = getMember()
  m.Value <- 20 // calls m.set_Value without a copy and without requiring `let mutable`

In other words, for such properties, the fact that the type is a struct should be irrelevant.

Also compare equivalent C# code that is valid:

readonly struct ListMember<T>(IList<T> list, int index)
{
    public T Value {
        get => list[index];
        set => list[index] = Value;
    }
}

static ListMember<int> GetMember() => default;

GetMember().Value = 20; // ok but removing `readonly` makes it an error

The existing way of approaching this problem in F# is to define a method instead of a property, which hides the mutation from the language.

Pros and Cons

The advantages of making this adjustment to F# are better syntax for utilizing "proxy structs" (slim values that point into other locations), like ArraySegment, or the ListMember above, all of which are conceptually IsReadOnly, as well as parity with properties/indexers that return byref values (such as the one on Span).

The disadvantages of making this adjustment to F# are adding an exception to the mutability rules based on custom attributes.

Extra information

Estimated cost (XS, S, M, L, XL, XXL): S
Related suggestions: C# ‒ dotnet/csharplang#2068 dotnet/roslyn#45284

Affidavit (please submit!)

  • This is not a question (e.g. like one you might ask on StackOverflow) and I have searched StackOverflow for discussions of this issue

  • 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

  • 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

  • I have searched both open and closed suggestions on this site and believe this is not a duplicate

  • This is not a breaking change to the F# language design

  • I or my company would be willing to help implement and/or test this

For Readers

If you would like to see this issue implemented, please click the 👍 emoji on this issue. These counts are used to generally order the suggestions by engagement.

@T-Gro
Copy link

T-Gro commented Feb 18, 2025

Is there a real-life API which is widely used and requires this pattern?
(i.e. an API which really exposes a ReadOnly struct with set property and it is a meaningful way to operate on it)

Design like that does not fit F# programming, so I would be after interop reasons that need it.

@IS4Code
Copy link
Author

IS4Code commented Feb 22, 2025

@T-Gro It is true that mutation does not fit F# programming, but this is about correctness. In situations where you have a type like ArraySegment, or anything else that represents a view over a mutable collection, you have either incorrect or less performant program.

This situation applies when you have an existing mutable container, for example:

open System

type ListSpan<'T>(list: Collections.Generic.IList<'T>, start: int, length: int) =
  member this.Item
    with get (index) = list[if index >= length then -1 else start + index]
    and set index value = list[if index >= length then -1 else start + index] <- value

let list = Collections.Generic.List<int>()
list.Add(1)
list.Add(2)
list.Add(3)
list.Add(4)

let view = ListSpan<_>(list, 1, 2)
view[0] <- 2

printf "%d" list[1] // 2

You would use a type like ListSpan to protect against unwanted mutation, by restricting access only to certain indexes and exposing only what is necessary. I believe that is very much in-line with F#'s principles.

Now, why does F# allow mutation here in the first place, even without mutable? Because it is a class; you are not changing the data stored in the variable, but the object behind the reference.

But what if you want to use a value type for performance? You add [<Struct>] but now you get an error:

// error FS0256: A value must be mutable in order to mutate the contents or take the address of a value type, e.g. 'let mutable x = ...'
view[0] <- 2

But you are not mutating the contents of view! Adding mutable here conveys incorrect meaning because view never really changes.

Of course F# cannot assess whether an arbitrary property mutates or not; it has to assume the worst and it is only logical to assume that a property mutates the contents of the value. However, it can do that if the type (or property) has [<IsReadOnly>], then it is impossible for it to mutate the contents and the error message is simply wrong because such mutation cannot occur.

@IS4Code
Copy link
Author

IS4Code commented Feb 22, 2025

And there are existing APIs where structs are not used even though they could. One is even in the docs:

open System.Collections.Generic

/// Basic implementation of a sparse matrix based on a dictionary
type SparseMatrix() =
    let table = new Dictionary<(int * int), float>()
    member _.Item
        // Because the key is comprised of two values, 'get' has two index values
        with get(key1, key2) = table[(key1, key2)]

        // 'set' has two index values and a new value to place in the key's position
        and set (key1, key2) value = table[(key1, key2)] <- value

let sm = new SparseMatrix()
for i in 1..1000 do
    sm[i, i] <- float i * float i

This API seems reasonable to me, but it cannot be used conveniently as a struct if you wished to get rid of the additional indirection. Why not make everything work just with [<Struct; IsReadOnly>] (and of course with proper construction)?

It should also be noted that C# already allows this syntax:

public readonly struct ListMember<T>(IList<T> list, int index)
{
    public T Value {
        get => list[index];
        set => list[index] = Value;
    }
}

public static ListMember<int> GetMember() => default;

GetMember().Value = 20; // ok but removing readonly makes it an error

F# is simply over-restrictive here without any apparent reason (except for historical).

@IS4Code
Copy link
Author

IS4Code commented Feb 24, 2025

Also linking to the original C# discussion that made the above code work, and the follow-up: dotnet/csharplang#2068 dotnet/roslyn#45284
I think similar patterns apply in F# as well, and would work well in interop with C# code that utilizes this feature.

@voronoipotato
Copy link

Could you provide the example for the listspan where you actually complete the example? it's not immediately obvious to me how your ListSpan breaks with a struct without an example.

@IS4Code
Copy link
Author

IS4Code commented Feb 25, 2025

@voronoipotato Sorry ‒ the change is just adding [<Struct; IsReadOnly>], i.e:

open System

[<Struct; IsReadOnly>]
type ListSpan<'T>(list: Collections.Generic.IList<'T>, start: int, length: int) =
  member this.Item
    with get (index) = list[if index >= length then -1 else start + index]
    and set index value = list[if index >= length then -1 else start + index] <- value

let list = Collections.Generic.List<int>()
list.Add(1)
list.Add(2)
list.Add(3)
list.Add(4)

let view = ListSpan<_>(list, 1, 2)
view[0] <- 2 // error

My point there is that view need not (and should not) be mutable, because its contents (the reference to the list, start and length) are not mutated and cannot ever be. You can "fix" this by not making ListSpan a struct in the first place, but that has performance drawbacks, especially for these "flyweight" types whose sole purpose is to access parts of existing objects.

Compare this with spans:

do
  let span = [| 1 |].AsSpan()
  span[0] <- 2 // completely fine

@voronoipotato
Copy link

Interesting, thanks

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

3 participants