Pull to refresh

Исследование кортежей в C# 7

Reading time 6 min
Views 42K
Original author: SergeyT
Типы 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, но я не уверен, будут ли записи значимыми типами или ссылочными типами.
Tags:
Hubs:
+18
Comments 14
Comments Comments 14

Articles