Pull to refresh

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

Reading time14 min
Views18K
В предыдущей части я рассмотрел реализации кортежей в различных языках программирования (причем я рассматривал компилируемые не скриптовые языки со статической типизацией и классическим си-подобным синтаксисом, что вызвало удивление у некоторых читателей). В этой части я предлагаю выйти за рамки существующего и заняться по сути дизайном языка программирования. Исходные данные — такие же: компилируемый не скриптовый язык со статической типизацией и си-подобным синтаксисом, включающий императивную парадигму (хотя и не ограничивающийся ею разумеется).

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

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

СИНТАКСИС
Для начала определимся с синтаксисом. Можно долго рассматривать разные варианты оформления кортежей в коде — в фигурных скобках, в круглых, вообще без скобок… мне по ряду причин нравится вариант с фигурными, его и возьмем за основу (по крайней мере пока не возникнет реальная необходимость в другом синтаксисе). Итак, кортеж — это последовательность имен или выражений, перечисленных через запятую и заключенных в фигурные скобки. Как унифицированная инициализация в С++.
{1,2,3}
{i, j, k}

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

Еще хотелось бы отметить простую идею создания кортежей из диапазонов и повторений. Диапазоны — способ задания кортежей для перечислимых элементов (к которым относятся целые числа и элементы перечислений)
{1..10}

Повторения — взятый из Ассемблера способ, позволяющий заполнить кортеж одним и тем же значением
{10 dup 'A'}

В данной статье я этими способами пользоваться не буду, и упомянул их просто так — как красивую идею.

ОБЩИЕ ИДЕИ
В первой части я упомянул некую мысль, которая вызвала некоторое непонимание — о том, что кортеж «не совсем тип». Если открыть ту же Википедию, то там четко сказано
В некоторых языках программирования, например, Python или ML, кортеж — особый тип данных.
В языках программирования со статической типизацией кортеж отличается от списка тем, что элементы кортежа могут принадлежать разнымтипам и набор таких типов заранее определён типом кортежа, а значит и размер кортежа также определён. С другой стороны, коллекции (списки, массивы) имеют ограничение по типу хранимых элементов, но не имеют ограничения на длину.

Однако, что же я имел в виду, говоря что это «не совсем тип»? Иногда (и довольно часто) хочется иметь некую языковую конструкцию, которая бы позволяла локально группировать произвольные объекты на этапе компиляции. Некий групповой псевдоним, который был бы просто эквивалентом своих составных частей без введения каких-либо дополнительных сущностей. В действительности, это просто способ взглянуть на кортежи с несколько другой стороны. Например, множественный возврат из функций. Его вполне можно рассматривать как возврат одного объекта типа «кортеж»; но с другой стороны, операция присваивания значений при множественном возврате — всего лишь одна из множества операций; на примере Rust мы видели возможность другой операции над кортежами — сравнения на равенство. А что если разрешить выполнять над кортежами все операции? Сразу возникают различные вопросы — какие это могут быть операции, могут ли они выполняться над кортежами разной длины, над кортежем и одиночным значением? Также можно рассмотреть вопросы семантики передачи кортежей в функции (возможно например «раскрытие» кортежа при передаче в функцию, или выполнение функции над всеми элементами кортежа и т.д.). Безусловно, нам понадобятся удобные средства создания и декомпозиции кортежей, доступа к элементам кортежа. Возможно, что-то еще?..

МНОЖЕСТВЕННЫЙ ВОЗВРАТ ИЗ ФУНКЦИЙ И ТИП VOID
Начнем с самого простого, и уже реализованного во многих языках — с множественного возврата из функций. Функция может возвращать несколько значений, и мы называем это возвратом кортежа из нескольких значений — точно так-же, как функция может принимать несколько аргументов. Напрашивается следующее обобщение: функция, возвращающая одно значение, возвращает кортеж из одного значения. По крайней мере, можно принять как пожелание, чтобы кортежи из одного значения свободно конвертировались в эти самые значения, и наоборот.

Дальнейшая экстраполяция приводит нас к типу void. Как известно, это специальный тип, используемый в большинстве языков программирования для обозначения того, что функция не возвращает результата. Создание объектов этого типа запрещено. Совсем не трудно догадаться, что void — это не полноценный тип, а обозначение для кортежа нулевой длины. Создание объектов такого типа в привычном смысле действительно невозможно; но если наш компилятор умеет работать с кортежами более продвинутым способом, то ничто не мешает нам ввести «псеводобъект» типа void в виде пустого кортежа {} и что-то с ним делать. Вопрос — что? И в каких случаях это может быть полезно? Пока просто отметим это и перейдем к следующим экстраполяциям.

МНОЖЕСТВЕННЫЕ ОПЕРАЦИИ
Мы рассмотрели множественный возврат из функций и связанное с ним множественное присваивание. Но присваивание — это всего лишь одна из множества возможных операций. По аналогии с присваиванием (с которого все и начиналось), попытаемся построить другие операции над кортежами:
{x,y,z} = foo(); // обычный множественный возврат, считаем что foo() возвращает 3 значения
{x,y,z} += foo(); // а если присваивание, совмещенное с операцией?
{x,y,z} ++; // или унарная операция
{x,y,z} = {1,2,3} + {a,b,c}; // или полноценная бинарная операция и присваивание

Пока выглядит неплохо, хотя возможно немного настораживает мысль о том, как будут выглядеть такие выражения с большим количеством операций. Важно то, что каждый объект в выражении — кортеж из одинакового количества элементов. Отступление от этого правила порождает различные неоднозначности, которых при дизайне языка программирования лучше избегать. Тем ни менее рассмотреть их нужно обязательно (а по возможности и разрешить).

ГРУППОВЫЕ ОПЕРАЦИИ
Кроме множественных операций, еще весьма заманчивыми кажутся групповые операции над кортежем и одиночным значением. По сути, это первое отступление от правила одинакового количества элементов в каждом операнде выражения. Но хочется чтобы получилось красиво, поэтому пробуем:
{i, j, k} = 0; // обнуляем все три переменные... все просто, прозрачно и очень удобно, разве тут могут быть неопределенности?
++{i, j, k}; // тоже очень неплохо смотрится...
{i, j, k} += 100; // и это тоже
rec.{x,y,z}.At(i).flag = true; // и даже так

Общий вид такой бинарной операции — «tuple operator value», или «value operator tuple» (большинство операций коммутативны); А под форму «value operator tuple» попадает присваивание одной переменной целого кортежа, и в частности — результата множественного возврата функции.
x = {1, 2, 3}; // чему должен быть равен x после присваивания ?
x = foo(); // если foo() возвращает несколько значений, то чему равен x ?

В ситуации с присваиванием множественного возврата из функции очевидно хочется, чтобы в «x» было записано первое возвращаемое значение, а следующие проигнорированы. Вот такая вот неожиданная неоднозначность. Но решать ее как-то надо — уж очень хочется иметь такой синтаксис. Он мог бы быть полезен не только для группового присваивания, но и еще в ряде интересных случаев. Например, индексация массива и даже доступ к полю структуры по имени — тоже операции.
arr[ {1,2,3} ] = { 10, 20, 30 }; // групповой доступ к массиву
obj.{x,y,z} = {10,20,30};        // и даже к полям структуры... 
{obj1, obj2, obj3}.x = { 10, 20, 30 };    // и наоборот... 
{arr1, arr2, arr3} [ 0 ] = {10, 20, 30 }; // и с массивами аналогично

Несмотря на кажущуюся очевидность выражений, формально arr[ { 1,2,3 } ] — это форма «value operator tuple», где «value» — это arr, «tuple» — {1,2,3}, а «operator» — квадратные скобки. В отличие от присваивания кортежа единственному значению, здесь вопросов не возникает — результат такой операции должен быть кортежем { arr[1], arr[2], arr[3] }. Но для компилятора что присваивание, что индексация — всего лишь бинарные операции. Значит, выражение вида x = {1,2,3} должно разворачиваться в {x=1, x=2, x=3}, то есть переменной x последовательно присваиваются все значения кортежа, и результатом также является кортеж (кстати, если бы такой язык программирования был в реальности — это был бы интересный вопрос на засыпку для всяких собеседований: чему будут равны его элементы? {1,2,3} или {3,3,3}?)
Таким образом, вопрос о правильной организации операций над кортежем и единственным элементом пока остается открытым.

БИНАРНЫЕ ОПЕРАЦИИ НАД КОРТЕЖАМИ С РАЗНЫМИ РАЗМЕРАМИ
Рассмотрим более общий случай — размеры кортежей — операндов бинарной операции не совпадают. Что делать в этом случае? Как всегда, самое простое решение — запретить :) Но попробуем разобраться… В Go и Rust предусмотрена специальная метапеременная "_" (подчеркивание), которую используют, когда какой-то из элементов кортежа справа от оператора присваивания не нужен. В нашем синтаксисе это будет выгледеть так:
{x, _ ,z} = foo();
{x, _, z} = {1, 2, 3};

Операция со вторым компонентом кортежа просто игнорируется. Использование метапеременной "_" в Go и Rust обязательно при множественном присваивании в случае, если какие-то результаты возвращаемого кортежа не нужны. Таким образом, в этих языках требование обязательного совпадения размеров кортежей сохраняется. Но в этих языках нет других множественных операций кроме присваивания; при попытке экстраполяции метапеременной "_" на другие операции получаются настолько интересные результаты, что их следует рассмотреть в отдельной главе.

Попробуем рассмотреть общий случай: что делать, если написано такое выражение (@ — обобщенная бинарная операция)
{a, b} @ {x, y, z}

Какие есть возможности?
  • Выполнять операции по размеру кортежа с меньшим размером. То есть результат {a@x, b@y}. Противоречит групповым операциям (если считать одиночные значения эквивалентом кортежей с одним элементом) — при групповой операции одиночный операнд должен взаимодействовать с каждым элементом кортежа.
  • Выполнять операции по размеру кортежа с большим размером. При этом элементы большего кортежа, на которых не хватило элементов меньшего кортежа, остаются без изменений. {a@x, b@y, z}. Также противоречит групповым операциям по той же причине.
  • Выполнять операции по размеру кортежа с большим размером. Когда меньший кортеж закончится, циклически переходить на его начало. {a@x, b@y, a@z}. Это соответствует групповым операциям, но заумно.
  • Выполнять операции по размеру кортежа с большим размером Когда меньший кортеж закончится, выполнять операции с последним элементом меньшего кортежа. {a@x, b@y, b@z}. Тоже соответствует групповым операциям, и тоже заумно.
  • Выполнять операции попарно «каждый элемент одного кортежа с каждым элементом другого кортежа» («декартово произведение»). {a@x, a@y, a@z, b@x, b@y, b@z}. Тоже соответствует групповым операциям, и тоже заумно. И кстати, возникает вопрос как группировать пары — по первому кортежу или по второму, или еще каким-то способом.

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

ДОСТУП К ЭЛЕМЕНТАМ
А сейчас следует обратиться еще к одной важной возможности — доступу к элементам кортежа.
Традиционно для индексации используются квадратные скобки, а для доступа по имени — точка; хотя вариант Swift с индексом через точку не так уж и плох, он неоднозначен при применении именованных числовых констант вместо чисел, да и вообще непривычен; я предпочту использовать квадратные скобки для доступа по индексу (важно — по константному индексу) и точку для доступа по имени (если оно есть)
{10,20,30}[1];      // 20
{x:10,y:20,z:30}.z; // 30

Вроде все просто? На самом деле уже нет. Поскольку мы ввели множественные и групповые операции над кортежами, а к таковым относится и индексация "[ ]", и обращение к именованным членам структуры ".", то при использовании этих операций над кортежами, элементами которых являются сложные объекты (для которых определена индексация или «точка») — неясно что делать: обращаться к элементу кортежа или выполнять групповую операцию над всеми элементами кортежа?
{arr1, arr2, arr3} [1]     // "arr2" или "{arr1[1], arr2[1], arr3[1]} " ?
{x:obj1, y:obj2, z:obj3}.z // "obj3" или "{obj1.z, obj2.z, obj3.z}" ?

Еще один интересный аспект — получение (конструирование) кортежей. По соглашению, принятому в начале статьи, мы используем фигурные скобки для простого конструирования объекта-кортежа. Однако, в некоторых случаях может понадобиться построить кортеж из другого кортежа путем исключения или добавления элементов. Синтаксически это можно сделать с использованием «множественной индексации», применяя по сути те же правила, что и к массивам или структурам.
Для получения кортежа можно было бы использовать множественную индексацию или диапазоны:
{a,b,c,d,e}[1,4,0]  // {b,e,a}
{a,b,c,d,e}[1..4]   // {b,c,d,e}
{a,b,c,d,e}[1..3,0] // {b,c,d,a}

(операция индексации сама по себе содержит скобки, поэтому внутри квадратных скобок еще одни фигурные можно не писать)
При наличии у элементов составного объекта имен, можно обращаться по именам:
obj.{a, d, e} // перечисление полей
obj.{b .. f}  // диапазон - по порядку объявления полей

Следует отметить интересное следствие — поскольку кортежи индексируются константными индексами, то напрашивается идея приводить имена полей к константным индексам (и возможно наоборот)

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

ОТ ОПЕРАЦИЙ К ФУНКЦИЯМ
Любая операция эквивалентна некоторой функции. Например, унарная операция битовой инверсии ~x может быть представлена как neg(x), а бинарное сложение x+y как sum(x, y);
поэтому, рассматривая операции над кортежами как множественные операции, возникает вопрос — что делать, если в таком выражении участвует вызов функции?
Для начала унарная операция:
~{1,2,3};     // в форме операции
neg({1,2,3}); // в форме функции

по аналогии с «групповым присваиванием», мы должны развернуть кортеж следующим образом:
{neg(1), neg(2), neg(3)}

на первый взгляд это кажется вполне логично; сама функция принимает единственное значение и возвращает единственное значение; должна возвращать единственное значение, чтобы из них можно было составить кортеж.
Вероятно, аналогично могли бы раскрываться и функции с двумя аргументами. Например
{1,2,3} + {4,5,6}
sum( {1,2,3}, {4,5,6} )

в
{ sum(1,4), sum(2,5), sum(3,6) }

Хотя надо признать, что синтаксис неявного множественного вызова функции для кортежей слишком неявен, и также как с операциями, напрашивается какой-то явный способ указания такого вызова (хотя сама по себе возможость такого вызова и неплоха).

С другой стороны, вспомним синтаксис Go, D, C∀: там кортеж, переданный в функцию, разворачивается внутри списка аргументов, заменяя собой соответствующее число аргументов. И в общем это тоже весьма логично и ожидаемо — но опять несовместимо с «групповой» сементикой! Возможно ли как-то разрешить это противоречие? И это мы еще не рассматривали сложные варианты, когда размерности кортежей-аргументов не совпадают, когда смешиваются кортежи и одиночные значения, когда мы хотим получить декартово произведение из результатов операции над элементами кортежей и т.д.

Решение, кажущееся достаточно неплохим, пришло как ни странно из С++. Там есть такая возможность, как шаблоны с переменным числом параметров, и для передачи пакета параметров (кстати тоже кортежа) в другой шаблон используется синтаксис с троеточием. Троеточие визуально маркирует, что данный аргумент «раскрывается» в данном контексте (и это очень важно для восприятия кода!). Важно, что это сразу видно в коде. Единственное что не видно — так это на сколько аргументов он раскрывается. Но если хочется указать подробно — ничто не мешает воспользоваться доступом к отдельным элементам кортежа
foo(t..., 10, 20);             // кратко 
foo(t[0], t[1], t[2], 10, 20); // подробно
foo(t.x, t.y, t.z, 10, 20);    // если у элементов кортежа есть имена

Наконец, самая сложная ситуация: функция с несколькими параметрами, и в некоторые (или все) параметры передаются кортежи, причем разной длины. Аналогично ситуации с бинарными операциями, существует множество возможностей раскрытия такого вызова: множественный вызов функции по миниальному количеству элементов, по максимальному с разными типами дополнения более коротких кортежей, декартово произведение и т.д. Все эти способы достаточно сложны для восприятия, и поэтому любой из них должен декларироваться только явно — например, с помощью соответствующего ключевого слова перед вызовом функции.

МЕТАПЕРЕМЕННАЯ "_"
Возвращаемся к рассмотрению поведения метапеременной "_", которая используется в некоторых языках для игнорирования элементов кортежа-источника при присваивании кортежей. Посмотрим, можно ли экстраполировать эту метапеременную на более сложные случаи (ведь присваивание — это всего лишь бинарная операция).
{x,y,z} = {1,2,3} + {a,_,c};

По аналогии, операция сложения числа 2 с "_" будет проигнорирована, но что получится в результате? Вообще существует две возможности: или оставить число 2 в результирующем кортеже, или распространить туда "_". В первом случае "_" может рассматриваться как "нейтральный элемент". для любой операции (то есть для любой операции "@" и любого аргумента «x» справедливо x @ _ == _ @ x == x). Например, выражение x = y * ~(_ + z) может быть трансформировано в x = y * ~z.
Однако тут не все однозначно. Например, унарную операцию смены знака "-x" можно записать как бинарную операцию вычитания числа из нуля «0-x». Если вместо «x» поставить "_", то вот такое выражение будет иметь разный смысл в зависимости от способа записи.
z = y * (0 - _) // z = y*0, то есть z = 0
z = y * (- _ )  // z = y

Во втором случае при появлении "_" в какой-то позиции кортежа все дальнейшие вычисления для этой позиции от узла синтаксического дерева, содержащего "_", до корня дерева (т.е. конца выражения, как правило — точки с запятой), отбрасываются (то есть справедливо x @ _ == _ @ x == _ ). То есть наличие хотя-бы одного "_" в i-том элементе кортежа означает, что все вычисления с i-тым элементом всех кортежей во всем выражении выкидываются.
Я затрудняюсь сказать, какой способ работы с "_" лучше. Это еще один вопрос, требующий тщательного обдумывания.

ВЛОЖЕННЫЕ КОРТЕЖИ
Еще один интересный аспект, практически не рассматриваемый в документации к языкам — вложенность кортежей. Наиболее очевидное и реально существующее (даже в языке Си) применение вложенных кортежей — инициализация вложенных структур.
struct Foo { int x, y, z; };
struct Bar { Foo f; int i, j; };
Bar b = { {10, 20}, 30 };

Такой код инициализирует поля x, y, i, но оставляет неинициализированными z и j. Без вложенных кортежей это было бы не так наглядно (и кстати, это пример того как в операторе присваивания/инициализации участвуют кортежи разных размеров). Таким образом, вложенные кортежи имеют вполне конкретные варианты использования. Но это означает также необходимость продумать все особенности экстраполяции операций над вложенными кортежами.

ОПЕРАЦИИ СРАВНЕНИЯ
Операции сравнения кортежей формально должны образовывать кортеж элементов типа bool. Рассмотрим на примере операции равенства:
{x, y, z} == {1, 2, 3} // например {false, true, false}

Но операторы if, while и т.д. требуют единственного значения типа bool! Как же преобразовать кортеж логических переменных в одно значение?
Существует как минимум две возможности
  1. Все элементы попарно равны (то есть весь результирующий кортеж состоит из true)
  2. Существует хотя-бы одна пара равных элементов (то есть хотя-бы один элемент результирующего кортежа равен true).

Это очень похоже на кванторы всеобщности и существования из логики предикатов. В тех языках программирования, где сравнение кортежей возможно, обычно по умолчанию подразумевается квантор всеобщности. Это очевидно для структур и любых составных типов: действительно, два составных объекта равны, если равны соответствующие составные части этих объектов. Поэтому, если мы рассматриваем кортеж как тип — это единственная возможность.
Но всегда ли такое поведение нужно пользователю? Возможно, что и не всегда; поэтому имеет смысл не только ввести квантор существования (если условие выполняется хотя-бы для одной пары элементов) но и более сложные варианты сравнения.

При сравнении кортежа с одиночным элементом интуитивно ожидаемое поведение также подразумевает «логическое И» между результатами сравнения каждого элемента кортежа с единственным значением:
if( {x, y, z} == 0) { /*...*/ }

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

ПРЕДВАРИТЕЛЬНЫЕ ИТОГИ
Поздравляю, если вы дочитали до этого места! Как видно, введение подобного синтаксиса с одной стороны позволяет красиво записывать некоторые простые выражения; с другой, вопросов и неоднозначностей гораздо больше чем ответов. Эта длинная статья — по сути выжимка по теме «кортежи и групповые операции» из моих личных заметок, которые я делал в течение достаточно долгого времени (обычно это происходит так — сижу, работаю, вдруг приходит какая-то идея — открываю Evernote или Wiznote и записываю). Возможно, у вас тоже появлялись схожие мысли и идеи, или появились в процессе чтения статьи — прошу в комментарии!
Tags:
Hubs:
+11
Comments14

Articles

Change theme settings