Типы System.Tuple были введены в .NET 4.0 с двумя существенными недостатками:
- Типы кортежей являются классами;
- Не существует языковой поддержки для их создания/деконструкции (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.
- Синтаксис объявления Tuple похож на объявление параметра функции: (Type1 name1, Type2 name2).
- Синтаксис создания экземпляров Tuple похож на передачу аргументов: (value1, optionalName: value2).
- Два кортежа с одинаковыми типами элементов, но с разными именами, совместимы (**): (int a, int b) = (1, 2).
- Кортежи имеют семантику значений:
(1,2) .Equals ((a: 1, b: 2)) и (1,2) .GetHashCode () == (1,2) .GetHashCode () являются истинными. - Кортежи не поддерживают == и !=. В github обсуждается эта возможность: «Поддержка == и! = Для типов кортежей».
- Кортежи могут быть «деконструированы», но только в «объявление переменной», но не в «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; — не ОК. - Кортежи изменяются: (int a, int b) x (1,2); x.a++;.
- Элементы кортежа можно получить по имени (если указано при объявлении) или через общие имена, такие как 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, который теперь принимает дополнительный аргумент. И во многих случаях, состояние также является кортежем.
Эти фичи очень полезны, но я действительно хочу увидеть несколько улучшений:
- Глобальные псевдонимы: возможность «называть» кортеж и использовать их во всей сборке (****).
- Разбор кортежа в сопоставлении с образцом: в out var и в case var .
- Использование оператор == для сравнения равенства.
(****) Я знаю, что эта функция спорная, но я думаю, что это будет очень полезно. Мы можем дождаться типов Record, но я не уверен, будут ли записи значимыми типами или ссылочными типами.
Автор: Дзеранов Иосиф