Haskell: как создать наиболее общую функцию, которая применит функцию к элементам кортежа

Это личное упражнение, чтобы понять пределы системы типа Haskell немного лучше. Я хочу создать самую общую функцию, которую я могу применить к каждой записи в 2-х входных кортежах, например:

applyToTuple fn (a,b) = (fn a, fn b)

Я пытаюсь заставить эту функцию работать в каждом из следующих случаев:

(1) applyToTuple length ([1,2,3] "hello")
(2) applyToTuple show ((2 :: Double), 'c')
(3) applyToTuple (+5) (10 :: Int, 2.3 :: Float)

Итак, для length элементы в паре должны быть Foldable, для показа они должны быть экземплярами Show и т.д.

Используя RankNTypes, я могу сделать некоторые пути, например:

{-# LANGUAGE RankNTypes #-}
applyToTupleFixed :: (forall t1. f t1 -> c) -> (f a, f b) -> (c, c)
applyToTupleFixed fn (a,b) = (fn a, fn b)

Это позволяет использовать функцию, которая может работать с общим контекстом f для элементов в этом контексте. (1) работает с этим, но элементы кортежа в (2) и (3) не имеют контекста, и поэтому они не работают (и в любом случае 3 возвращают разные типы). Я мог бы, конечно, определить контекст для размещения элементов, например:

data Sh a = Show a => Sh a
instance Show (Sh a) where show (Sh a) = show a

applyToTuple show (Sh (2 :: Double), Sh 'c')

чтобы получить другие примеры. Мне просто интересно, можно ли определить такую ​​общую функцию в Haskell без необходимости обертывать элементы в кортежах или давать applyToTuple более конкретную подпись типа.

Ответы

Ответ 1

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

{-# LANGUAGE RankNTypes      #-}
{-# LANGUAGE ConstraintKinds #-}
import Data.Proxy

both :: (c a, c b)
     => Proxy c
        -> (forall x. c x => x -> r)
        -> (a, b)
        -> (r, r)
both Proxy f (x, y) = (f x, f y)

demo :: (String, String)
demo = both (Proxy :: Proxy Show) show ('a', True)

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

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

Это немного более гибко, чем я думал, что это будет:

demo2 :: (Char, Char)
demo2 = both (Proxy :: Proxy ((~) Char)) id ('a', 'b')

Я понятия не имел, что вы можете частично применить равенство типов до этого момента, ха-ха.

К сожалению, это не работает:

demo3 :: (Int, Int)
demo3 = both (Proxy :: Proxy ((~) [a])) length ([1,2,3::Int], "hello")

Для конкретного случая списков мы можем использовать IsList из GHC.Exts, чтобы заставить это работать (IsList обычно используется с расширением OverloadedLists, но здесь нам это не нужно):

demo3 :: (Int, Int)
demo3 = both (Proxy :: Proxy IsList) (length . toList) ([1,2,3], "hello")

Конечно, самым простым (и даже более общим) решением является использование функции типа (a -> a') -> (b -> b') -> (a, b) -> (a', b') (например, bimap from Data.Bifunctor или (***) от Control.Arrow) и просто дважды назначьте эту функцию:

λ> bimap length length ([1,2,3], "hello")
(3,5)

Объединяя все три примера из вопроса

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

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

Это необходимо для чего-то вроде applyToTuple (+5) (10 :: Int, 2.3 :: Float), так как оно возвращает (Int, Float).

При этом получаем:

{-# LANGUAGE RankNTypes            #-}
{-# LANGUAGE ConstraintKinds       #-}
{-# LANGUAGE FlexibleInstances     #-}
{-# LANGUAGE MultiParamTypeClasses #-}
import Data.Proxy

import GHC.Exts

both :: (c a, c b
        ,p a r1  -- p is a relation between a and r1
        ,p b r2  -- and also a relation between b and r2
        )
     => Proxy c
        -> Proxy p
        -> (forall r x. (c x, p x r) => x -> r) -- An input type x and a corresponding
                                                -- result type r are valid iff the p from
                                                -- before is a relation between x and r,
                                                -- where x is an instance of c
        -> (a, b)
        -> (r1, r2)
both Proxy Proxy f (x, y) = (f x, f y)

Proxy p представляет собой наше отношение между типами ввода и вывода. Затем мы определяем класс удобства (который, насколько я знаю, уже нигде не существует):

class r ~ a => Constant a b r
instance Constant a b a      -- We restrict the first and the third type argument to
                             -- be the same

Это позволяет нам использовать both, когда тип результата остается неизменным, частично применяя Constant к типу, который, как мы знаем, он будет (я также не знал, что вы можете частично применять классы классов до сих пор. многому научившись за этот ответ, ха-ха). Например, если мы знаем, что в обоих результатах будет Int:

example1 :: (Int, Int)
example1 =
  both (Proxy :: Proxy IsList)         -- The argument must be an IsList instance
       (Proxy :: Proxy (Constant Int)) -- The result type must be Int
       (length . toList)
       ([1,2,3], "hello")

Аналогично для вашего второго тестового примера:

example2 :: (String, String)
example2 =
  both (Proxy :: Proxy Show)              -- The argument must be a Show instance
       (Proxy :: Proxy (Constant String)) -- The result type must be String
       show
       ('a', True)

Третий, где он становится немного интереснее:

example3 :: (Int, Float)
example3 =
  both (Proxy :: Proxy Num)  -- Constrain the the argument to be a Num instance
       (Proxy :: Proxy (~))  -- <- Tell the type system that the result type of
                             --    (+5) is the same as the argument type.
       (+5)
       (10 :: Int, 2.3 :: Float)

Наше отношение между типом ввода и вывода на самом деле немного сложнее, чем два других примера: вместо игнорирования первого типа в отношении мы говорим, что типы ввода и вывода должны быть одинаковыми (что работает с (+5) :: Num a => a -> a). Другими словами, в этом конкретном случае наше отношение является отношением равенства.

Ответ 2

Что вы хотите - это функция с типом

applyToTuple :: (a -> b) -> (c, d) -> (b, b)

где компилятор будет проверять, находятся ли теги a, c и d в одном классе. Это, к сожалению, невозможно, насколько я знаю (хотя для этого может быть расширение). Когда вы передаете функцию определенного typeclass в другую функцию, она становится первой, к которой она применяется (наблюдение от GHC):

applyToTuple f (x, y) = (f x, f y)

имеет производный тип applyToTuple :: (t -> t1) -> (t, t) -> (t1, t1). Тестирование с помощью show показывает эти результаты:

λ> applyToTuple show (8, 9)
("8","9")
λ> applyToTuple show (8, [8,9])

<interactive>:5:14:
    No instance for (Show t0) arising from a use of `show'
    The type variable `t0' is ambiguous
    Possible fix: add a type signature that fixes these type variable(s)
    Note: there are several potential instances:
      instance Show Double -- Defined in `GHC.Float'
      instance Show Float -- Defined in `GHC.Float'
      instance (Integral a, Show a) => Show (GHC.Real.Ratio a)
        -- Defined in `GHC.Real'
      ...plus 28 others
    In the first argument of `applyToTuple', namely `show'
    In the expression: applyToTuple show (8, [8, 9])
    In an equation for `it': it = applyToTuple show (8, [8, 9])

<interactive>:5:20:
    No instance for (Num [t0]) arising from the literal `8'
    Possible fix: add an instance declaration for (Num [t0])
    In the expression: 8
    In the second argument of `applyToTuple', namely `(8, [8, 9])'
    In the expression: applyToTuple show (8, [8, 9])

<interactive>:5:24:
    No instance for (Num t0) arising from the literal `8'
    The type variable `t0' is ambiguous
    Possible fix: add a type signature that fixes these type variable(s)
    Note: there are several potential instances:
      instance Num Double -- Defined in `GHC.Float'
      instance Num Float -- Defined in `GHC.Float'
      instance Integral a => Num (GHC.Real.Ratio a)
        -- Defined in `GHC.Real'
      ...plus three others
    In the expression: 8
    In the expression: [8, 9]
    In the second argument of `applyToTuple', namely `(8, [8, 9])'

Вы можете, однако, сделать что-то вроде applyToTuple' f1 f2 (x, y) = (f1 x, f2 y). Я думаю, вы можете использовать Template Haskell, чтобы преобразовать это в то, что вы хотите.