Кортежи в языках программирования. Часть 1

в 10:54, , рубрики: C, c++, D, Go, Rust, swift, tuples, кортежи, Программирование, языки программирования, метки: ,

Сейчас во многих языках программирования существует такая конструкция, как кортежи (tuples). Где-то кортежи в той или иной мере встроены в язык, иногда — опять же в той или иной мере — реализуются средствами библиотек. C++, C#, D, Python, Ruby, Go, Rust, Swift (а также Erlang, F#, Groovy, Haskell, Lisp, OCaml и многие другие)…
Что же такое кортеж? В Википедии дается достаточно точное определение: кортеж — упорядоченный набор фиксированной длины. Определение хоть и точное, но для нас пока бесполезное, и вот почему: задумывается ли большинство программистов, зачем понадобилась эта сущность? В программировании существует множество структур данных, как фиксированной, так и переменной длины; они позволяют хранить различные значения — как однитипные, так и разных типов. Всевозможные массивы, ассоциативные массивы, списки, структуры… зачем еще и кортежи? А в языках со слабой типизацией — и тем более, разница между кортежами и списками/векторами совсем размытая… ну нельзя добавлять в кортеж элементы, ну и что с того? Это может ввести в некоторое заблуждение. Поэтому стоит копнуть глубже и разобраться, зачем же на самом деле нужны кортежи, чем они отличаются от других языковых конструкций, и как сформировать идеальный синтаксис и семантику кортежей в идеальном (или близком к идеальному) языке программирования.

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

Первая важная вещь, о которой на Википедии не упомянули: кортеж — это структура времени компиляции. Иными словами, это некая сущность, объединяющая некоторые объекты на этапе компиляции. И это очень важно. Кортежи неявно используются во всех языках программирования, даже в Си и Ассемблере. Давайте поищем их в том же Си, С++, в любом компилируемом языке.
Так, список аргументов функции — это кортеж;
Список инициализации структуры или массива — это тоже кортеж;
Список аргументов шаблона или макроса — тоже кортеж
Описание структуры, и даже обычный блок кода — это тоже кортеж; только элементами его являются не объекты, а синтаксические конструкции.

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

Начнем с самого очевидного — возврата нескольких значений из функции. Еще со школьных времен меня удивляла такая несправедливость: почему функция может принимать сколько угодно значений, а возвращать только одно? В самом деле, почему y=x*x — нормальная парабола, а y = sqrt(x) — какая-то обрезанная наполовину фигня? Разве это не нарушение математической гармонии? В программировании конечно можно возвратить структурный объект, но суть остается та же: возвращается один объект, а не несколько.

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

func foo() (r1 int, r2 int) {
     return 7, 4
}
x, y := foo()
x, y = 1, 2
x, y = y, x

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

func bar(x int, y int) {
}
bar(foo())

Такая пакетная передача сама по себе крайне интересна. С одной стороны, она кажется весьма элегантной; но с другой — она слишком «неявная», неуниверсальная. Например, если попытаться добавить в bar третий аргумент, и попытаться совместить «пакетную» передачу и обычную

bar(foo(), 100)

то ничего не получится — ошибка компиляции.

Еще один интересный аспект — неиспользование возвращаемых значений. Вспомним С/С++. В них (а также в подавляющем большинстве других языков — Java, C#, ObjC, D...) можно было спокойно игнорировать возвращаемые значения при вызове функции. В Go такое тоже возможно, причем можно игнорировать как единственное возвращаемое значение, так и группу. Однако, попытка использовать первое возвращаемое значение и неявно проигнорировать второе приводит к ошибке компиляции. Игнорировать возможно, но явно — с использованием специального символа "_":

x, _ := foo()

Т.е. работает принцип «все или ничего»: можно или игнорировать все возвращаемые значения, или использовать — но также все.

В Rust имеются схожие возможности. Точно также функции могут возвращать несколько значений; также можно инициализировать ими новые значения. При этом множественное присваивание как таковое отсутствует, возможна только инициализация. Аналогично можно использовать символ "_" для неиспользуемых значений. Аналогично можно игнорировать возвращаемые значения полностью, или получать их также все полностью. Также кортежи можно сравнивать:

let x = (1i, 2i, 3i);
let y = (2i, 3i, 4i);
if x == y {
    println!("yes");
} else {
    println!("no");
}

Отметим этот факт: мы встретили первую операцию над кортежами, отличную от присваивания. Также здесь наблюдается еще одна интересная возможность — создание именованных кортежей и их последующее использование «как единого целого».

В языке Swift возможности в целом аналогичны. Из интересного — обращение к элементам кортежа по константному индексу через точку; возможность назначать элементам кортежа имена и обращаться к элементам через них.

let httpStatus = (statusCode: 200, description: "OK")
print("The status code is (httpStatus.0)")
print("The status code is (httpStatus.statusCode)") 

Такие кортежи уже близки к структурам, но все-же структурами не являются. И здесь я бы хотел отойти от примеров и перейти к своим собственным мыслям. Разница между кортежами и структурами в том, что кортеж — это не тип данных, это нечто более низкоуровневое; можно сказать — что кортеж — это просто (возможно именованная) группа (возможно именованных) объектов времени компиляции. В этом месте вспомним языки C/С++. Простейшие конструкции инициализации массива и структуры выглядят так:

int arr[] = {1, 2, 3};
Point3D pt = {1, 2, 3};

Обратите внимание, списки инициализации в данном случае вообще идентичны. И тем ни менее, они инициализируют совершенно разные объекты данных. Такое поведение в общем нетипично для типа данных. Но зато это близко к другой интересной фиче, которая иногда (но редко) встречается в языках программирования — структурной типизации. Конструкция в фигурных скобках — типичный кортеж. Кстати, в Си есть именованная инициализация полей структуры (идея кстати весьма похожа на Swift), которую пока так и не протащили в C++17:

Point3D pt = {.x=1, .y=2, .z=3};

В С++ пошли немного в другом направлении: ввели понятие " унифицированный синтаксис инициализации и списки инициализации". Синтаксически это те же кортежи, которые можно использовать для инициализации объектов; в дополнение к старым возможностям, унифицированный синтаксис инициализации позволяет передавать объекты в функции и возвращать из функций в виде кортежей.

    Point3D  pt{10,20,30}; // новый синтаксис инициализации
Point3D foo(Point3D a)
{
    return {1, 2, 3}; // возвращаем "кортеж"
}
foo( {3,2,1} ); // передаем "кортеж" 

Другая интересная возможность — списки инициализации.Они используются для начальной инициализации динамических структур данных — таких как вектора и списки. Списки инициализации в С++ должны быть однородными, то есть все элементы списка должны быть одного типа. Технически такие списки формируют константные массивы в памяти, для доступа к которым применяются итераторы std::initializer_list. Можно сказать, что шаблонный тип std::initializer_list — специальный определенный на уровне компилятора интерфейс к однородным кортежам (а фактически к константным массивам). Разумеется, списки инициализации можно использовать не только в конструкторах, но и как аргументы любых функций и методов. Думаю, если бы в С++ изначально был бы некий шаблонный тип данных, соответствующий литеральному массиву и содержащий информацию о длине этого массива, он бы вполне подошел на роль std::initializer_list.

Также в стандартной библиотеке С++ (и в Boost) существуют кортежи, реализованные с помощью шаблонов. Поскольку такая реализация не является частью языка, синтаксис получился слегка громоздким и неуниверсальным. Так, тип кортежа приходится объявлять явно с указанием типов всех полей; для конструирования объектов применяется функция std::make_tuple; для создания кортежа «на лету» (из существующих переменных) применяется другой шаблон — tie, а получение доступа к элементам осуществляется с помощью специального шаблонного метода, который требует константного индекса.

std::tuple<int,char> t1(10,'x');
auto t2 = std::make_tuple ("test", 3.1, 14, 'y');
int myint; char mychar;
std::tie (myint, mychar) = t1;                            // unpack elements
std::tie (std::ignore, std::ignore, myint, mychar) = t2;  // unpack (with ignore)
std::get<2>(t2) = 100;   
char mychr = std::get<3>(t2);

В примере применяется распаковка со специальным значением std::ignore. Это в точности соответствует символу подчеркивания "_", применяемому для тех же целей при групповых возвратах из функций в Go и Rust.

Похожим способом (хотя и упрощенно по сравнению с С++) реализованы кортежи в C#. Для создания используются методы Tuple.Create(), набора шаблонных классов Tuple<>, для доступа к элементам — поля с фиксированными именами Item1… item8 (чем достигается константность индекса).

В языке D есть достаточно богатая поддержка кортежей. С помощью конструкции tuple можно сформировать кортеж, и — в том числе — осуществить множественный возврат из функции. Для доступа к элементам кортежа используется индексация константными индексами. Также кортеж можно сконструировать с помощью шаблона Tuple, что позволяет создать кортеж с именованными полями.

auto t = Tuple!(int, "number", string, "message")(123, "hello");
writeln("by index 0 : ", t[0]);
writeln("by .number : ", t.number);
writeln("by index 1 : ", t[1]);
writeln("by .message: ", t.message);

Кортежи можно передавать в функции. Для этого применяется индексация с диапазоном. Синтаксически это выглядит как будто передается один аргумент, а на самом деле кортеж раскрывается сразу в несколько аргументов. При этом в D, в отличие от Go, нет требования точного равенства количества аргументов функции и элементов кортежа, то есть можно смешивать передачу одиночных аргументов и кортежей.

void bar(int i, double d, char c) { }
auto t = tuple(1, "2", 3.3, '4');
bar(t[0], t[$-2..$]);

В D существует еще множество возможностей, связанных с кортежами — Compile-time foreach для обхода кортежей на этапе компиляции, шаблон AliasSeq, оператор tupleof… в общем все это требует отдельной большой статьи.

А напоследок рассмотрим реализацию кортежей в малоизвестном расширении языка Си — CForAll или C∀ (забавно, но на момент написания статьи я не смог нагуглить сайт языка — весьма вероятно что он давно закрылся и упоминаний просто не осталось; вот поэтому я регулярно сканирую сеть в поисках новых языков программирования и скачиваю все до чего смогу дотянуться).

Кортежи в C∀можно объявить на уровне языка, заключив список объектов в квадратные скобки. Тип кортежа создается аналогично — в квадратные скобки заключается список типов. Объекты и типы кортежей можно объявлять явно. Кортежи можно передавать в функции, где они разворачиваются в списки аргументов (в отличие от Go, где такое возможно только при точном совпадении кортежа и списка аргументов функции).

[ int, int ] w1; // объект-кортеж из двух элементов
[ int, int, int ] w2; // объект-кортеж из трех элементов
void f (int, int, int); // функция, принимающая три аргумента

f( 1, 2, 3 ); // просто числа
f( [ 1, 2, 3 ] ); // кортеж-объект объявленный прямо на месте
f( w1, 3 ) // кортеж и число
f( w2 ) // кортеж-объект

Еще одна интересная тема — вложенные кортежи и правила их раскрытия. В С/С++ также применяется вложенность — при инициализации массивов структур, элементы которых также являются массивами и структурами. В C∀ существуют правила, называемые «tuple coercions», в частности — раскрытие кортежей с внутренней структурой (flattering) и наоборот, структурирование (structuring), когда «плоский» кортеж подстраивается под сложную внутреннюю структуру (хотя эта возможность весьма спорная, обсуждение будет в следующей части). И все это относится лишь к присваиванию, никаких упоминаний использования этих возможностей с другими операциями нет.

[ a, b, c, d ] = [ 1, [ 2, 3 ], 4 ];

В C∀ предусмотрено как групповое, так и множественное присваивание.

[ x, y, z ] = 1.5;
[ x, y, z ] = [ 1, 2, 3 ];

и даже использование кортежей для доступа к полям структур

obj.[ f3, f1, f2 ] = [ x, 11, 17 ];

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

Автор: NeoCode

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js