Tuple exploration in C # 7

Original author: SergeyT
  • Transfer
System.Tuple types were introduced in .NET 4.0 with two significant drawbacks:

  1. Tuple types are classes;
  2. There is no language support for their creation / deconstruction.

To solve these problems, C # 7 introduces a new language feature, as well as a new type family (*).

Today, if you need to glue two values ​​in order to return them from a function or put two values ​​in a hash set, you can use the System.ValueTuple types and create them using convenient syntax:

// Constructing the tuple instance
var tpl = (1, 2);
// Using tuples with a dictionary
var d = new Dictionary<(int x, int y), (byte a, short b)>();
// Tuples with different names are compatible
d.Add(tpl, (a: 3, b: 4));
// Tuples have value semantic
if (d.TryGetValue((1, 2), out var r))
{
    // Deconstructing the tuple ignoring the first element
    var (_, b) = r;
    // Using named syntax as well as predefined name
    Console.WriteLine($"a: {r.a}, b: {r.Item2}");
}

(*) The types of System.ValueTuple are introduced in the .NET Framework 4.7. But you can use them in earlier versions of the framework, in which case you need to add a special package nuget: System.ValueTuple to the project .

  1. The syntax for a Tuple declaration is similar to declaring a function parameter: (Type1 name1, Type2 name2) .
  2. The syntax for creating Tuple instances is similar to passing arguments: (value1, optionalName: value2) .
  3. Two tuples with the same types of elements, but with different names, are compatible (**): (int a, int b) = (1, 2) .
  4. Tuples have semantics of values:
    (1,2) .Equals ((a: 1, b: 2)) and (1,2). GetHashCode () == (1,2). GetHashCode () are true .
  5. Tuples do not support == and ! = . Github discusses this feature: “Support == and! = For tuple types . "
  6. Tuples can be “deconstructed”, but only in “variable declaration”, but not in “out var” or in the case block:
    var (x, y) = (1,2) - OK, (var x, int y) = (1,2) - OK,
    dictionary.TryGetValue (key, out var (x, y)) - not OK, case var (x, y): break; - not OK.
  7. Tuples change: (int a, int b) x (1,2); x.a ++; .
  8. Tuple elements can be obtained by name (if specified during the declaration) or through common names such as Item1, Item2 , etc.

(**) We will soon see that this is not always the case.

Named Tuple Elements


The absence of user names makes the System.Tuple types not very useful. I can use System.Tuple as part of the implementation of a small method, but if I need to pass an instance of it, I prefer a named type with descriptive property names. Tuples in C # 7 quite elegantly solve this problem: you can specify names for tuple elements and, unlike anonymous classes, these names are available even in different assemblies.

The C # compiler generates a special attribute TupleElementNamesAttribute (***) for each type of tuple used in the method signature:

(***) The TupleElementNamesAttribute attribute is special and cannot be used directly in user code. The compiler throws an error if you try to use it.

public (int a, int b) Foo1((int c, int d) a) => a;
[return: TupleElementNames(new[] { "a", "b" })]
public ValueTuple Foo(
    [TupleElementNames(new[] { "c", "d" })] ValueTuple a)
{
    return a;
}

This attribute helps the IDE and the compiler to “see” the names of the elements and warn if they are used incorrectly:

// Ok: tuple literal can skip element names
(int x, int y) tpl = (1, 2);
// Warning: The tuple element 'a' is ignored because a different name
// or no name is specified by the target type '(int x, int y)'.
tpl = (a:1, b:2);
// Ok: tuple deconstruction ignore element names
var (a, b) = tpl;
// x: 2, y: 1. Tuple names are ignored
var (y, x) = tpl;

The compiler has higher requirements for inherited members:

public abstract class Base
{
    public abstract (int a, int b) Foo();
    public abstract (int, int) Bar();
}
public class Derived : Base
{
    // Error: Cannot change tuple element names when overriding method
    public override (int c, int d) Foo() => (1, 2);
    // Error: Cannot change tuple element names when overriding method
    public override (int a, int b) Bar() => (1, 2);
}

Regular method arguments can be freely changed in overridden members, but tuple element names in overridden members must exactly match names from the base type.

Display element name


C # 7.1 introduced one additional improvement: the output of the tuple element name is similar to what C # does for anonymous types.

public void NameInference(int x, int y)
{
    // (int x, int y)
    var tpl = (x, y);
    var a = new {X = x, Y = y};
    // (int X, int Y)
    var tpl2 = (a.X, a.Y);
}

Semantics of meanings and mutability.


Tuples are mutable meaningful types. We know that mutable meaningful types are considered harmful. Here is a small example of their evil nature:

var x = new { Items = new List { 1, 2, 3 }.GetEnumerator() };
while (x.Items.MoveNext())
{
    Console.WriteLine(x.Items.Current);
}

If you run this code, you will get ... an infinite loop. The List .Enumerator is a mutable type value, and the Items property. This means that x.Items returns a copy of the original iterator at each iteration of the loop, causing an infinite loop.

But mutable meaningful types are dangerous only when data is mixed with behavior: Enumerator contains state (current element) and has behavior (the ability to advance an iterator by calling MoveNext ). This combination can cause problems because it is easy to call the method on the copy, instead of the original instance, which results in the no-op (No Operation) effect. Here is a set of examples that may cause unobvious behavior due to a blind copy of the value type: gist .

Tuples have a state, but not behavior, so the above problems do not apply to them. But one problem with volatility still remains:

var tpl = (x: 1, y: 2);
var hs = new HashSet<(int x, int y)>();
hs.Add(tpl);
tpl.x++;
Console.WriteLine(hs.Contains(tpl)); // false

Tuples are very useful as keys in dictionaries and can be used as keys due to the semantics of values. But you should not change the state of a key variable between different collection operations.

Deconstruction


Although C # has a special syntax for creating tuple instances, deconstruction is a more general feature and can be used with any type.

public static class VersionDeconstrucion
{
    public static void Deconstruct(this Version v, out int major, out int minor, out int build, out int revision)
    {
        major = v.Major;
        minor = v.Minor;
        build = v.Build;
        revision = v.Revision;
    }
}
var version = Version.Parse("1.2.3.4");
var (major, minor, build, _) = version;
// Prints: 1.2.3
Console.WriteLine($"{major}.{minor}.{build}");

Parsing (deconstructing) a tuple uses the “duck typing” approach: if the compiler can find the Deconstruct method for a given type — an instance method or an extension method — the type is parsed.

Tuples aliases


Once you start using tuples, you quickly realize that you want to “reuse” the type of tuple with named elements in several places in the source code. But this is not so simple.

First, C # does not support global aliases for a given type. You can use the 'using' alias directive, but it creates an alias visible in a single file.

Secondly, you cannot even use this feature with tuples:

// You can't do this: compilation error
using Point = (int x, int y);
// But you *can* do this
using SetOfPoints = System.Collections.Generic.HashSet<(int x, int y)>;

Now on github in the topic "Types of Tuple when using directives" there is a discussion of this problem. Therefore, if you find that you are using one type of tuple in several places, you have two options: either copy to types throughout the code base or create a named type.

What naming rule for elements should I use?


Pascal case like ElementName or camel case like elementName ? On the one hand, tuple elements must follow the naming rule for public members (i.e. PascalCase ), but on the other hand, tuples are just a store for variables, and variables are named with camelСase .

You can use the following approach:

  • PascalCase if the tuple is used as an argument or return type of a method;
  • camelCase if the tuple is created locally in the function.

But I prefer to use camelCase all the time.

Conclusion


I found tuples very useful in everyday work. I need more than one return value from the function, or I need to put a couple of values ​​in a hash set, or I need to change the dictionary and save not one value, but two, or the key becomes more complex, and I need to expand it with another field.

I even use them to avoid allocating a closure using methods like ConcurrentDictionary.TryGetOrAdd , which now takes an additional argument. And in many cases, the state is also a tuple.

These features are very useful, but I really want to see some improvements:

  1. Global aliases: the ability to “name” a tuple and use them throughout the assembly (****).
  2. Parsing a tuple against a pattern: in out var and in case var .
  3. Use the == operator to compare equality.

(****) I know that this function is controversial, but I think it will be very useful. We can wait for the Record types , but I'm not sure if the records will be significant types or reference types.

Also popular now: