1

Having trouble wrapping my head around the concept of modifying these two different lists. So when using a regular for loop to iterate over each one, we can directly modify the list of integers, but cannot modify a specific item in the list of ValueTuples.

Consider this example

var test = new List<int>() { 1, 2, 3, 4 };

for (int i = 0; i < test.Count; i++)
{
    test[i] += 1;
}

var test2 = new List<(int, int)>() { (1, 2), (2, 3), (3, 4) };

for(int i = 0; i < test2.Count; i++)
{
    test2[i].Item1 += 1;
}

So in this, we can successfully add 1 to each integer value in the first list. However, with the second list we actually get a compiler error of CS1612 which states "Cannot modify the return value of 'List<(int, int)>.this[int]' because it is not a variable."

I read into the error on the official docs, and it makes sense that in the second example we are returning a copy of the ValueTuple, therefore we are not modifying the actual one in the list. But then why does the integer example work?

Feel like I might just be overcomplicating this, but wanted to ask here and see where I could be going wrong.

gunr2171
  • 16,104
  • 25
  • 61
  • 88
William
  • 429
  • 3
  • 5
  • 16
  • I'm fairly confident it's because a ValueTuple is a value type rather than a regular Tuple being a reference type. You can _overwrite_ `test2[i]`, but you can't update an individual field without pulling it into a variable. Someone who knows better can give more details. – gunr2171 May 01 '23 at 22:09

3 Answers3

2

To understand why test[i] += 1 compiles but test2[i].Item1 += 1 doesn't, let's examine how the C# compiler transforms them into simpler statements.

test[i] += 1 is transformed as follows:

var x = test.get_Item(i); // Make a copy of test[i].
var y = x + 1;
test.set_Item(i, y); // Replace test[i] with y.

// Or more concisely:
test.set_Item(i, test.get_Item(i) + 1);

The get_Item and set_Item methods refer to the get and set accessors of List<T>'s indexer. I'm using get_Item and set_Item here instead of test[i] to clarify whether test[i] refers to a get or a set.

test2[i].Item1 += 1 is transformed as follows:

var tuple = test2.get_Item(i); // Make a copy of test2[i].
var x = tuple.Item1;
var y = x + 1;
tuple.Item1 = y; // Mutate the copy. NOT ALLOWED (CS1612)

// Or more concisely:
var tuple = test2.get_Item(i);
tuple.Item1 = tuple.Item1 + 1;

Notice two important points:

  1. With integers, there's a call to set_Item. With ValueTuple, there isn't.
  2. With ValueTuple, the assignment to Item1 occurs on a temporary copy of test2[i], so it ultimately has no observable effect. This is why the compiler reports error CS1612: to prevent you from writing code that doesn't do what you think it does.

You can get your code to compile in a couple ways:

  1. Use an array instead of List. If test2 were an array, then test2[i] would be a mutable reference to the element at index i, not a copy. Array indexers are special in this regard.

  2. Replace the entire element test2[i]:

    var tuple = test2[i]; // test2.get_Item(i)
    tuple.Item1 += 1;
    test2[i] = tuple; // test2.set_Item(i, tuple)
    

    The last statement invokes set_Item because the left-hand side of the assignment is an indexer expression and nothing else. (That's just the way the C# language works.)

Michael Liu
  • 52,147
  • 13
  • 117
  • 150
  • 1
    Note: to loop over references to data in a `List` you can use `System.Runtime.InteropServices.CollectionsMarshal.AsSpan(list)` to get a `Span` the index of which does return references like an array does. – Petrusion May 01 '23 at 23:25
  • Ah I think I see now. So the difference is, with the ValueTuple, it's trying to set the value of Item1 first. Whereas with the list of integers, it can just assign the computed value directly to the element at index i? – William May 02 '23 at 00:18
  • 1
    With ValueTuple, the code makes a copy of `test2[i]` and then tries to modify Item1 of the copy instead of Item1 of the element actually in the list. (This happens because of the way that the List class's indexer was implemented.) With both ValueTuple and integers, List allows you to replace the *entire* element with a new one by writing `test[i] = ...`. – Michael Liu May 02 '23 at 00:32
  • 1
    @William: I completely rewrote my answer. Hopefully it's clearer! (If not, I can roll back to the previous version.) – Michael Liu May 02 '23 at 01:42
  • 1
    Oh no, thank you for the detailed response! The original answer was good, but the updated one definitely helps break it down a bit further so I appreciate it. – William May 02 '23 at 01:45
1

The Microsoft docs explain this behavior here:

https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/compiler-messages/cs1612

This code works:

var test2 = new List<(int, int)>() { (1, 2), (2, 3), (3, 4) };
    
for(int i = 0; i < test2.Count; i++)
{
   var x = test2[i];

   x.Item1 += 1;

   test2[i] = x;

}    
Ken
  • 526
  • 6
  • 13
1

Basically because the specification says so. From Structs: 16.4.4 Assignment section:

Assignment to a variable of a struct type creates a copy of the value being assigned. This differs from assignment to a variable of a class type, which copies the reference but not the object identified by the reference.

Similar to an assignment, when a struct is passed as a value parameter or returned as the result of a function member, a copy of the struct is created. A struct may be passed by reference to a function member using a ref or out parameter.

When a property or indexer of a struct is the target of an assignment, the instance expression associated with the property or indexer access shall be classified as a variable. If the instance expression is classified as a value, a compile-time error occurs. This is described in further detail in §12.21.2.

Basically the instance expression associated with the indexer access - test[i] (of test[i] += 1) is classified as a variable , i.e. something like:

var foo = test[i];
foo += 1;
test[i] = foo;

While the second one due to property access is classified by compiler as value due to the field access (Item1).

Other than that I could only guess why it was designed this way. My guess would be that some corner-cases and overcomplication of the compiler to handle this case could be a factor.

Guru Stron
  • 102,774
  • 10
  • 95
  • 132