Подробное знакомство с кортежами в C#

Кортежи  —  это круто.

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

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

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

Появление кортежей в C#4

В 2010 году вышла новая версия .NET и C#, в которой и появились первые кортежи.

Задача этого нового принципа (нового для C#, конечно) состояла в том, чтобы упростить работу с небольшим числом значений. До этого для сохранения этих значений приходилось либо создавать собственный класс/структуру, либо использовать набор параметров ref  —  что в обоих случаях было не идеальным решением.

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

private Tuple<bool, int> GetData()
{
return new Tuple<bool, int>(true, 47);
}

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

Затем значения из кортежа можно извлекать:

Tuple<bool, int> tuple = new Tuple<bool, int>(true, 47);

bool boolValue = tuple.Item1; // равно 'true'
int intValue = tuple.Item2; // равно '47'

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

Так что хоть начальная проблема и была решена, разработчикам еще было над чем потрудиться.

Улучшенные кортежи в C# 7

Переместимся на семь лет вперед в 2017 год, когда вышел C#7. В этой версии кортежам было уделено особое внимание.

Теперь нам уже не нужно было использовать сам класс Tuple, а для выражения намерений в нашем распоряжении появились специальные конструкции.

Определение нового кортежа

В первую очередь мы получили ускоренный способ создания кортежа. Вместо того, чтобы инстанцировать класс Tuple, теперь можно было просто заключать элементы в скобки:

private (bool, int) GetData()
{
return (true, 47);
}

Неплохо, но относительно извлечения данных ничего не изменилось —  это по-прежнему происходило при помощи Item1 и Item2.

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

Однако так все работало, пока вы не присваивали этим элементам имена:

private (bool isSuccessful, int totalItems) GetData()
{
return (true, 47);
}

В сигнатуре метода можно вносить в определение кортежа имена. В плане создания кортежа для возвращения в таком виде ничего не меняется, но взгляните, что показывает IntelliSense при попытке извлечь из этой структуры данные:

Автоподстановка для Tuple показывает именованные элементы в качестве свойств

Да, в Visual Studio 2022 (и прежних версиях) эти имена элементов мы видим в качестве свойств и можем использовать так, будто они являются свойствами класса. Вот соответствующий код:

var returnedData = GetData();

bool boolValue = returnedData.isSuccessful;
int intValue = returnedData.totalItems;

Теперь мы отчетливо видим, что означает каждый элемент кортежа. И когда у вас будет кортеж со множеством элементов int, вы поблагодарите меня за то, что не нужно вспоминать о том, какую же запись использовать: Item5 или Item6.

Элементы также можно именовать при создании кортежа, что особенно полезно, когда вы создаете его встроенным, а не возвращаете из метода. Вот подобный код:

var tupleData = (isSuccessful: true, totalItems: 47);

Использование Var с кортежами

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

Посмотрим, что Visual Studio предлагает в качестве рефакторинга этой переменной:

Варианты рефакторинга кортежа ‘var’

Здесь у нас два варианта:

  • использовать вместо var явный тип;
  • деконструировать объявление переменной.

Разберем их по очереди.

Определение кортежа как явного типа, а не var

Если мы хотим указать вместо var явный тип, то используем ту же конструкцию, что и в сигнатуре метода:

(bool isSuccessful, int totalItems) returnedData = GetData();

bool boolValue = returnedData.isSuccessful;
int intValue = returnedData.totalItems;

Становится кристально ясно, что именно находится в кортеже.

Однако можно пойти еще дальше и перенести кортежи на новый уровень. 

Деконструкция объявления переменной кортежа

Когда мы возвращаем кортеж, например, из метода, и нас интересует только его содержимое, то можно разобрать объявление этого кортежа на элементы данных и использовать их непосредственно в качестве переменных. Вот код:

(bool isSuccessful, int totalItems) = GetData();

bool boolValue = isSuccessful;
int intValue = totalItems;

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

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

А как быть, если некоторые возвращаемые кортежем элементы вас не интересуют? Обязательно ли придется сталкиваться с этими лишними переменными, которые вы все равно игнорируете? Нет!

Для любого элемента можно использовать отмену (discard), и соответствующий фрагмент кода весьма прост:

(bool isSuccessful, _) = GetData();

Вместо определения int totaltime мы просто используем нижнее подчеркивание. Таким образом мы сообщаем компилятору, что не будем использовать этот элемент, и он не будет загромождать им оставшуюся часть кода.

Что можно хранить в кортеже?

Хранить в кортеже можно практически все. Если сильно захочется, то можно даже создавать кортежи функций. Вот один из способов:

private (Func<bool> function1, Func<bool> function2) GetFunctionTuples()
{
return (() => true, () => false);
}

Сколько элементов вмещает кортеж?

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

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

Интересно отметить, что метод .ToTuple(), доступный для созданных таким образом кортежей, преобразует современный их вариант в класс Tuple. При этом в случае присутствия в исходном кортеже выходящих за его пределы элементов, он использует описанное выше вложение. Вот код:

Метод ToTuple() создает кортеж, используя класс Tuple, и вкладывает лишние элементы так, чтобы они вписывались в предел его емкости

Сравнение кортежей

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

Если у вас два кортежа с одинаковыми значениями, тогда проверка равенства покажет, что они равны. Вот пример:

var tuple1 = (1, 2, 3);
var tuple2 = (1, 2, 3);

bool isEqual = tuple1.Equals(tuple2); // Дает 'true'

Если вы используете C# 7.3 и выше, то также можете выполнять эту проверку равенства с помощью ==:

var tuple1 = (1, 2, 3);
var tuple2 = (1, 2, 3);

bool isEqual = tuple1 == tuple2; // Дает 'true'

Первым делом здесь нужно пояснить, что сравнению подвергаются именно значения, и имена элементов в нем не учитываются. Так что, если содержимому двух кортежей были даны разные имена, но при этом значения в них одинаковые, то они будут признаны равными. Вот код:

var tuple1 = (element1: 1, element2: 2, element3: 3);
var tuple2 = (other1: 1, other2: 2, other3: 3);

bool isEqual = tuple1 == tuple2; // Дает 'true'

И это важно помнить, поскольку, даже если именованные элементы совпадают, при сравнении учитывается их порядок:

var tuple1 = (element1: 1, element2: 2, element3: 3);
var tuple2 = (element3: 3, element2: 2, element1: 1);

bool isEqual = tuple1 == tuple2; // Дает 'false'

Нагляднее всего это покажет модульный тест:

[TestMethod]
public void CompareTuple()
{
var tuple1 = (element1: 1, element2: 2, element3: 3);
var tuple2 = (element3: 3, element2: 2, element1: 1);

Assert.AreEqual(tuple1, tuple2);
}

При выполнении тест проваливается, и мы получает такой ответ:

Assert.AreEqual failed. Expected:<(1, 2, 3)>. Actual:<(3, 2, 1)>.

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

При сравнении кортежей нужно учитывать и многие другие моменты, о которых лучше всего прочесть в документации Microsoft.

Выводы

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

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

Кортежи также можно сравнивать, но тут важно помнить, что в сравнении учитывается порядок их элементов, а не имена.

Читайте также:

Читайте нас в TelegramVK и Дзен


Перевод статьи Jamie Burns: A Deep Dive Into Tuples in C#

Предыдущая статьяКак использовать ESLint, чтобы повысить качество кода JavaScript и TypeScript
Следующая статьяЛокальная ретушь фотографий при помощи ИИ