Элегантное сопоставление образцов по вложенным кортежам произвольной длины

Я разрабатываю функциональную библиотеку пользовательских интерфейсов в F #, и я столкнулся с ситуацией, когда мне нужно создать "коллекции" элементов гетерогенных типов. Я не хочу этого делать, прибегая к динамическому программированию и отбрасывая все на obj (технически возможно здесь, особенно потому, что я компилирую Fable). Вместо этого я хочу сохранить как можно больше безопасности типа.

Решение, с которым я столкнулся, - создать простой пользовательский оператор %%%, который строит кортежи, а затем использует его следующим образом:

let x = 4 %%% "string" %%% () %%% 2.4

Это создает значение со следующим типом:

val x: (((int * string) * unit) * float)

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

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

match x with
| (((a,b),c),d) -> ...

и компилятор выводит правильные типы для a, b, c и d. Тем не менее, я не хочу, чтобы пользователю приходилось беспокоиться обо всем, что гнездится. Мне бы хотелось сделать что-то вроде:

match x with
| a %%% b %%% c %%% d -> ...

и компилятор просто вычислит все. Есть ли способ сделать что-то подобное с F #, используя активные шаблоны (или некоторые другие функции)?

EDIT:

Я должен уточнить, что я не пытаюсь сопоставить значения кортежей неизвестной "arity" во время выполнения. Я хочу сделать это только тогда, когда число (и типы) элементов известно во время компиляции. Если бы я делал первое, мне было бы хорошо с динамичным подходом.

В настоящее время я создал активные шаблоны:

let (|Tuple2|) = function | (a,b)-> (a,b)
let (|Tuple3|) = function | ((a,b),c) -> (a,b,c)
let (|Tuple4|) = function | (((a,b),c),d) -> (a,b,c,d)
...

Что можно использовать следующим образом:

let x = 4 %%% "string" %%% () %%% 2.4
let y = match x with | Tuple4 (a,b,c,d) -> ...

Это, наверное, самое лучшее, что можно сделать, и это действительно не так уж плохо для пользователей (просто нужно считать "arity" кортежа, а затем использовать правильный шаблон TupleN). Однако он все еще меня беспокоит, потому что он просто не кажется таким изящным, каким он может быть. Вам не нужно указывать количество элементов при создании x, почему вы должны это делать, когда сопоставляете его? Мне кажется асимметричным, но я не вижу способа избежать этого.

Есть ли более глубокие причины, почему моя оригинальная идея не будет работать в F # (или статически типизированных языках вообще)? Существуют ли какие-либо функциональные языки там, где это возможно?

Ответ 1

Похоже, вы пытаетесь создать какую-то семантическую модель, хотя мне не совсем ясно, что это такое.

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

type Model =
| Integer of int
| Text of string
| Nothing
| Float of float

(Извинения за неопределенное название, но, как было сказано, мне не ясно, что именно вы пытаетесь моделировать.)

Теперь вы можете создавать значения этого типа:

let x = [Integer 4; Text "string"; Nothing; Float 2.4]

В этом случае тип x равен Model list. Теперь у вас есть тип данных, который можно тривиально сопоставить с шаблоном:

match x with
| [Integer i; Text s; Nothing; Float f] -> ...

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

Ответ 2

Марк Симан дал вам правильный ответ. Я вместо этого собираюсь сделать что-то совершенно другое, и покажу вам, почему то, что вы пытаетесь сделать с сложными кортежами, на самом деле не работает, даже если вы попробуете подход "жестко закодированные шаблоны для каждого возможного количества элементов" что вам не нравится. Вот несколько попыток реализации вашей идеи, которые не будут работать:

Попытка # 1

Сначала попробуйте написать функцию, которая будет рекурсивно выкидывать все элементы хвоста кортежа, пока не опустится до первой пары, а затем вернет эту пару. Другими словами, что-то вроде List.take 2. Если это сработает, мы можем применить подобный метод для извлечения других частей сложного кортежа. Но это не сработает, и причина очень поучительна. Здесь функция:

let rec decompose tuple =
    match tuple with
    | ((a,b),c) -> decompose (a,b)
    | (a,b) -> (a,b)

Если я напечатаю эту функцию в хорошей F # IDE (я использую VS-код с плагином Ionide), я увижу красную squiggly под a в рекурсивном вызове decompose (a,b). Это связано с тем, что в этот момент компилятор выбрасывает следующую ошибку:

Type mismatch. Expecting a
    'a * 'b
but given a
    'a
The resulting type would be infinite when unifying ''a' and ''a * 'b'

Это первый ключ к тому, почему это не сработает. Когда я наводил указатель на tuple в VS Code, Ionide показывает мне тип, который F # вывел для tuple:

val tuple : ('a * 'b) * 'b

Подождите, что? Почему a 'b для последней части составленного кортежа? Разве это не должно быть ('a * 'b) * 'c? Ну, это из-за следующей строки соответствия:

| ((a,b),c) -> decompose (a,b)

Здесь мы говорим, что аргумент tuple и его типы должны иметь форму, которая может соответствовать этой строке. Поэтому tuple должен быть 2-кортежем, так как мы передаем 2-кортеж в качестве параметра decompose в этом конкретном вызове. И поэтому вторая часть этого 2-кортежа должна соответствовать типу b, иначе было бы ошибкой типа вызывать decompose с (a,b) в качестве параметра. Следовательно, c в шаблоне (вторая часть 2-кортежа) и b в шаблоне (вторая часть "внутреннего" 2-кортежа) должны иметь один и тот же тип, и поэтому тип decompose ограничивается ('a * 'b) * 'b вместо ('a * 'b) * 'c.

Если это имеет смысл, тогда мы можем перейти к тому, почему происходит ошибка несоответствия типа. Потому что теперь нам нужно сопоставить часть a рекурсивного вызова decompose (a,b). Поскольку кортеж мы переходим к decompose must, соответствующему его сигнатуре типа, это означает, что a должен соответствовать первой части 2-кортежа, и мы уже знаем (поскольку параметр tuple должен быть способен сопоставить шаблон ((a,b),c) в инструкции match, иначе этот оператор не будет компилироваться), что первая часть 2-кортежа сама по себе является еще 2-кортежем типа 'a * 'b. Правильно?

Ну и что проблема. Мы знаем, что первая часть параметра decompose должна быть 2-кортежем типа 'a * 'b. Но шаблон соответствия также ограничивал параметр a типом 'a, потому что мы сопоставляем что-то с типом ('a * 'b) * 'b с ((a,b),c). Поэтому одна часть линии заставляет a иметь тип 'a, а другая часть заставляет его иметь тип ('a * 'b). Эти два типа не могут быть согласованы, и поэтому система типов выдает ошибку компиляции.

Попытка # 2

Но подождите! Как насчет активных шаблонов? Может быть, они могут нас спасти? Ну, позвольте взглянуть на другую вещь, которую я пробовал, что, как я думал, будет работать. И когда это не получилось, это на самом деле научило меня больше о системе типа F #, и почему то, что вы хотите, не будет возможным. Мы поговорим о том, почему в какой-то момент; но сначала здесь код:

let (|Tuple2|_|) t =
    match t with
    | (a,b) -> Some (a,b)
    | _ -> None

let (|Tuple3|_|) t =
    match t with
    | ((a,b),c) -> Some (a,b,c)
    | _ -> None

let (|Tuple4|_|) t =
    match t with
    | (((a,b),c),d) -> Some (a,b,c,d)
    | _ -> None

let (|Tuple5|_|) t =
    match t with
    | ((((a,b),c),d),e) -> Some (a,b,c,d,e)
    | _ -> None

Введите это в свою IDE, и вы увидите обнадеживающий знак. Он компилируется! И если вы наведите указатель на параметр t в каждом из этих активных шаблонов, вы увидите, что F # определил правильную "форму" для t в каждом из них. Итак, теперь мы должны сделать что-то вроде этого, верно?

let (%%%) a b = (a,b)

let complicated = 5 %%% "foo" %%% true %%% [1;2;3]

let result =
    match complicated with
    | Tuple5 (a,b,c,d,e) -> sprintf "5-tuple of (%A,%A,%A,%A,%A)" a b c d e
    | Tuple4 (a,b,c,d) -> sprintf "4-tuple of (%A,%A,%A,%A)" a b c d
    | Tuple3 (a,b,c) -> sprintf "3-tuple of (%A,%A,%A)" a b c
    | Tuple2 (a,b) -> sprintf "2-tuple of (%A,%A)" a b
    | _ -> "Not matched"

(Обратите внимание на порядок: поскольку ВСЕ ваши сложные кортежи являются 2-мя кортежами, с сложным кортежем в качестве первой части 2-кортежа, шаблон Tuple2 будет соответствовать любому такому кортежу, если он был первым.)

Это кажется многообещающим, но это также не сработает. Введите (или вставьте) это в свою среду IDE, и вы увидите красное squiggly под шаблоном Tuple5 (a,b,c,d,e) (первый шаблон оператора match). Я скажу вам, что ошибка за минуту, но сначала наведите курсор на определение complicated и убедитесь, что оно исправлено:

val complicated : ((int * string) * bool) * int list

Да, это выглядит правильно. Поэтому, поскольку это не может соответствовать активному шаблону Tuple5, почему этот активный шаблон просто не возвращает None и позволяет перейти к шаблону Tuple4 (который будет работать)? Ну, посмотрим на ошибку:

Type mismatch. Expecting a
    ((int * string) * bool) * int list -> 'a option
but given a
    ((('b * 'c) * 'd) * 'e) * 'f -> ('b * 'c * 'd * 'e * 'f) option
The type 'int' does not match the type ''a * 'b'

Там нет 'a в любом из двух несоответствующих типов. Откуда появился 'a? Ну, если вы специально наводите курсор на слово Tuple5 в этой строке, вы увидите подпись типа Tuple5:

active recognizer Tuple5: ((('a * 'b) * 'c) * 'd) * 'e -> ('a * 'b * 'c * 'd * 'e) option

То, откуда пришел 'a. Но что более важно, сообщение об ошибке сообщает вам, что первая часть complicated, int не может соответствовать 2-кортежу. Зачем это пытаться? Опять же, поскольку match выражения должны соответствовать типу вещи, которую они соответствуют, и поэтому они ограничивают этот тип. Так же, как мы видели с помощью функции decompose, это происходит и здесь. Вы можете увидеть это лучше, изменив переменную let result в функцию, например:

let showArity t =
    match t with
    | Tuple5 (a,b,c,d,e) -> sprintf "5-tuple of (%A,%A,%A,%A,%A)" a b c d e
    | Tuple4 (a,b,c,d) -> sprintf "4-tuple of (%A,%A,%A,%A)" a b c d
    | Tuple3 (a,b,c) -> sprintf "3-tuple of (%A,%A,%A)" a b c
    | Tuple2 (a,b) -> sprintf "2-tuple of (%A,%A)" a b
    | _ -> "Not matched"

showArity complicated

Теперь функция showArity компилируется без ошибок; у вас может возникнуть соблазн радоваться, но вы увидите, что его нельзя вызвать с помощью значения complicated, которое мы определили ранее, и что вы получаете ошибку несоответствия типа (где, в конечном счете, int не может совпадать 'a * 'b). Но почему showArity компилируется без ошибок? Ну, наведите указатель на тип своего аргумента t:

val t : ((('a * 'b) * 'c) * 'd) * 'e

Итак, t был ограничен тем, что я назову "сложным 5-кортежем" (который на самом деле по-прежнему остается только 2-кортежем, помните) с помощью этого первого шаблона Tuple5. И другие шаблоны Tuple4, Tuple3 и Tuple2 будут соответствовать, потому что на самом деле они фактически соответствуют 2-кортежам в реальности. Чтобы показать это, удалите строку Tuple5 из функции showArity и посмотрите на ее результат при запуске showArity complicated в F # Interactive (вам также нужно будет заново запустить определение showArity). Вы получите:

"4-tuple of (5,"foo",true,[1; 2; 3])"

Выглядит хорошо, но подождите: теперь удалите строку Tuple4 и запустите определение showArity, а также строку showArity complicated. На этот раз он производит:

"3-tuple of ((5, "foo"),true,[1; 2; 3])"

Посмотрите, как это согласовано, но не разложил "самый внутренний" кортеж (int * string)? Вот почему вам нужен порядок. Запустите его еще раз, оставив только оставшуюся строку Tuple2, и вы получите:

"2-tuple of (((5, "foo"), true),[1; 2; 3])"

Таким образом, этот подход не будет работать: вы не можете определить "фальшивую сущность" сложного кортежа. ( "Поддельная арность" в цитатах с испугом, потому что арность всех этих кортежей действительно 2, но мы пытаемся рассматривать их так, как если бы они были 3- или 4- или 5-кортежи). Поскольку любой шаблон, чья "фальшивая арность" меньше, чем у сложного кортежа, который вы передаете, будет по-прежнему соответствовать, но он не будет разлагать какую-то часть сложного кортежа. Хотя любой шаблон, чья "фальшивая арность" больше, чем у сложного кортежа, который вы передаете ему, просто не будет компилировать, так как он создает несоответствие типа между самой внутренней частью кортежа, re сопоставлено с.

Я надеюсь, что прочтение всего этого дало вам лучшее понимание тонкостей системы типа F #; Я знаю, что писать его, конечно, многому научил.