Типы System.Tuple были введены в .NET 4.0 с двумя существенными недостатками:

  1. Типы кортежей являются классами;
  2. Не существует языковой поддержки для их создания/деконструкции (deconstruction).

Чтобы решить эти проблемы, в C# 7 представлена новая возможность языка, а также новое семейство типов (*).

Сегодня, если вам нужно склеить два значения, чтобы вернуть их из функции или поместить два значения в хэш-набор, вы можете использовать типы System.ValueTuple и создать их с помощью удобного синтаксиса:

// 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}");
}

(*) Типы System.ValueTuple представлены в .NET Framework 4.7. Но вы можете использовать их в более ранних версиях фреймворка, в этом случае вам нужно добавить в проект специальный пакету nuget: System.ValueTuple.

  1. Синтаксис объявления Tuple похож на объявление параметра функции: (Type1 name1, Type2 name2).
  2. Синтаксис создания экземпляров Tuple похож на передачу аргументов: (value1, optionalName: value2).
  3. Два кортежа с одинаковыми типами элементов, но с разными именами, совместимы (**): (int a, int b) = (1, 2).
  4. Кортежи имеют семантику значений:
    (1,2) .Equals ((a: 1, b: 2)) и (1,2) .GetHashCode () == (1,2) .GetHashCode () являются истинными.
  5. Кортежи не поддерживают == и !=. В github обсуждается эта возможность: «Поддержка == и! = Для типов кортежей».
  6. Кортежи могут быть «деконструированы», но только в «объявление переменной», но не в «out var» или в блок case:
    var (x, y) = (1,2) — OK, (var x, int y) = ( 1,2) — OK,
    dictionary.TryGetValue (key, out var (x, y)) — не OK, case var (x, y): break; — не ОК.
  7. Кортежи изменяются: (int a, int b) x (1,2); x.a++;.
  8. Элементы кортежа можно получить по имени (если указано при объявлении) или через общие имена, такие как Item1, Item2 и т. Д.

(**) Мы скоро увидим, что это не всегда так.

Именованные элементы кортежа


Отсутствие пользовательских имен делает типы System.Tuple не очень полезными. Я могу использовать System.Tuple как часть реализации небольшого метода, но если мне нужно передать его экземпляр, я предпочитаю именованный тип с описательными именами свойств. Кортежи в C# 7 довольно элегантно решают эту проблему: вы можете указать имена для элементов кортежа и, в отличие от анонимных классов, эти имена доступны даже в разных сборок.

Компилятор C# генерирует специальный атрибут TupleElementNamesAttribute (***) для каждого типа кортежа, используемого в сигнатуре метода:

(***) Атрибут TupleElementNamesAttribute является специальным и не может использоваться непосредственно в коде пользователя. Компилятор выдает ошибку, если вы попытаетесь его использовать.

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

Данный атрибут помогает IDE и компилятору «видеть» имена элементов и предупреждать, если они используются неправильно:

// 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;

У компилятора более высокие требования к унаследованным членам:

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);
}

Обычные аргументы метода могут быть свободно изменены в переопределенных членах, но имена элементов кортежей в переопределенных членах должны точно совпадать с именами из базового типа.

Вывод имени элемента


C # 7.1 появилось одно дополнительное усовершенствование: вывод имени элемента кортежа аналогичен тому, что C# делает для анонимных типов.

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);
}

Семантика значений и изменяемость.


Кортежи являются изменяемыми значимыми типами. Мы знаем, что изменяемые значимые типы считаются вредными. Вот небольшой пример их злой природы:

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

Если вы запустите этот код, вы получите… бесконечный цикл. Список List .Enumerator — это изменяемый значимый типа, а Items свойство. Это означает, что x.Items возвращает копию исходного итератора на каждой итерации цикла, вызывая бесконечный цикл.

Но изменяемые значимые типы опасны только тогда, когда данные смешиваются с поведением: Enumerator содержит состояние (текущий элемент) и имеет поведение (возможность продвижения итератора путем вызова метода MoveNext). Эта комбинация может вызывать проблемы, потому что легко вызвать метод на копии, вместо исходного экземпляра, что приводит к эффекту no-op (No Operation). Вот набор примеров, которые могут вызвать неочевидное поведение из-за скрытой копии типа значения: gist.

Кортежи обладают состоянием, но не поведением, поэтому приведенные выше проблемы к ним не применимы. Но одна проблема с изменчивостью все же остается:

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

Кортежи являются очень полезными в качестве ключей в словарях и могут использоваться в качестве ключей благодаря семантики значений. Но не следует изменять состояние переменной ключа между различными операциями с коллекцией.

Деконструкция


Несмотря на то, что язык C# обладает специальным синтаксисом для создания экземпляров кортежей, деконструкция является более общей возможностью и может использоваться с любым типом.

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}");

Разбор (деконструкция) кортежа использует подход «утиной типизации»: если компилятор может найти метод Deconstruct для данного типа – экземплярный метод или метод расширения — тип является разбираемым.

Алиасы кортежей


После того, как вы начнете использовать кортежи, вы быстро поймете, что хотите «повторно использовать» тип кортежа с именованными элементами в нескольких местах исходного кода. Но с этим не все так просто.

Во-первых, C # не поддерживает глобальные псевдонимы для заданного типа. Вы можете использовать 'using' alias директиву, но она создает псевдоним, видимый в одном файле.

Во-вторых, вы даже не можете использовать эту возможность совместно с кортежами:

// 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)>;

Сейчас на github в теме «Типы Tuple при использовании директив» идет обсуждение этой проблемы. Поэтому, если вы обнаружите, что используете один тип кортежа в нескольких местах, у вас есть два варианта: либо копировать во типы по всей кодовой базе либо создать именованный тип.

Какое правило именования для элементов я должен использовать?


Pascal case, например ElementName, или camel case, например elementName? С одной стороны, элементы кортежей должны следовать правилу именования для публичных членов (т.е. PascalCase), но, с другой стороны, кортежи — это просто хранилище для переменных, а переменные именуются с camelСase.

Вы можете использовать следующий подход:

  • PascalCase, если кортеж используется в качестве аргумента или возвращаемого типа метода;
  • camelCase, если кортеж создается локально в функции.

Но я предпочитаю использовать camelCase все время.

Вывод


Я нашел кортежи очень полезными в повседневной работе. Мне нужно больше одного возвращаемого значения из функции, или мне нужно поместить пару значений в хэш-набор, или мне нужно изменить словарь и сохранить не одно значение, а два, или ключ становится более сложным, и мне нужно расширить его другим полем.

Я даже использую их, чтобы избежать аллокации замыкания с помощью таких методов, как ConcurrentDictionary.TryGetOrAdd, который теперь принимает дополнительный аргумент. И во многих случаях, состояние также является кортежем.

Эти фичи очень полезны, но я действительно хочу увидеть несколько улучшений:

  1. Глобальные псевдонимы: возможность «называть» кортеж и использовать их во всей сборке (****).
  2. Разбор кортежа в сопоставлении с образцом: в out var и в case var .
  3. Использование оператор == для сравнения равенства.

(****) Я знаю, что эта функция спорная, но я думаю, что это будет очень полезно. Мы можем дождаться типов Record, но я не уверен, будут ли записи значимыми типами или ссылочными типами.

Комментарии (13)


  1. AgentFire
    23.12.2017 23:43

    Глобальные псевдонимы: возможность «называть» кортеж и использовать их во всей сборке (****).

    А как насчет этой новой фичи C# под названием класс?


    1. aikixd
      24.12.2017 12:52

      Классы и туплы совершенно разные вещи. Тупл это неизменяемый* value type, со структурным сравнением. Класс по умолчанию бесполезен. Он может быть null, ему нужно определить десяток методов, что-бы нормально сравнивать, и что не маловажно, постоянно следить за релевантностью этих методов. Его легко сломать, и уронить программу через тысячу строк после бага. Класс это инструмент для конкретных задач. Просто исторически его используют для всех гвоздей.


      1. AgentFire
        24.12.2017 18:40

        Не нужен null? Тогда struct.


        1. aikixd
          24.12.2017 19:15

          У struct'а нет оператора сравнения, он изменяемый, обладает обязательным публичным пустым конструктором и без дополнительных действий большой struct медленный.


          1. aikixd
            24.12.2017 19:20

            Сейчас посмотрел, туплы реализованы через struct. Если для них нет специальной обработки рантаймом, они тоже могут быть медленными.


  1. DistortNeo
    23.12.2017 23:56

    Глобальные псевдонимы: возможность «называть» кортеж и использовать их во всей сборке (****).

    Здесь будет большой минус в том, что потеряется строгая типизация.
    То есть если объявить два типа: CartesianCoordinates = (float x, float y) и PolarCoordinates = (float r, float arg), то они будут неявно преобразуемы друг к другу, и это повысит риск ошибки.


    Лучше уж действительно подождать record-ы, надеясь, что сделают и class, и struct варианты.


    Разбор кортежа в сопоставлении с образцом: в out var и в case var .

    Да, очень не хватает этого.


    Использование оператор == для сравнения равенства.

    А вот с этим, боюсь, будет сложнее. Во-первых, это будет обновление, ломающее существующий код.


    А во-вторых, для generic типов оператор == не может быть применён. То есть сравнение элементов ValueTuple с помощью == реализовать будет нельзя, только через Equals. А вот для record — вполне можно.


    1. mayorovp
      24.12.2017 09:34

      А вот с этим, боюсь, будет сложнее. Во-первых, это будет обновление, ломающее существующий код.

      Каким образом?


    1. aikixd
      24.12.2017 12:55

      То есть сравнение элементов ValueTuple с помощью == реализовать будет нельзя, только через Equals.

      Зачем сравнивать элементы через оператор? Внутри == пусть все через Equals будет.


      1. DistortNeo
        24.12.2017 13:46

        Так нелогично же. Зачем тогда вообще нужен этот оператор, если есть Equals?


        1. aikixd
          24.12.2017 18:29

          Не хотите, пишите Equals. Главное что-бы оба вели себя одинаково.


  1. mayorovp
    24.12.2017 09:33

    (комментарий был удален)


  1. nporaMep
    24.12.2017 15:32

    Я один такой кому очень сложно читать это в полном переводе на русский и когда термины пишут в виде нескольких слов с маленькой буквы все слова?

    По делу в моих тестах ValueTuple это аналог struct с имплементированными IEquatable/IComparable. Юз кейсы у них соответственно такие же, просто не надо писать эти 10-15 строк кода с объявлением структуры, но за это теряете возможность именовать этот кортеж.
    Поэтому я сошелся на том, что для private implementation методов ок, для public будьте добры структуру. Во многом по причине текущего тулинга — прайват методы в одном файле обычно и можно глазами найти что как где используется, find all references же на кортеже не работает, поэтому найти другие зависимые классы будет сложнее. Ну и в целом когда я вижу чужой интерфейс который мне возвращает x,y,z или даже что-нить более бизнесовое типа cost,weight первый возникающий вопрос — что конкретно это сочетание значений значит, где имя структуры/класса?

    Собственно это видно и в коде тут github.com/dotnet/corefx/blob/master/src/System.ValueTuple/src/System/ValueTuple/ValueTuple.cs


  1. IL_Agent
    24.12.2017 22:20

    Два вида таплов — не очень красиво. лучше б сразу рекорды запилили.