Коли корисні вищі типи?


88

Я деякий час займався розробкою у F #, і мені це подобається. Однак одне модне слово, яке я знаю, не існує у F # - це вищі типи. Я читав матеріали про вищі типи, і, думаю, розумію їх визначення. Я просто не впевнений, чому вони корисні. Хтось може навести кілька прикладів того, які типи вищого роду полегшують роботу в Scala або Haskell, що вимагає обхідних шляхів у F #? Також для цих прикладів, якими були б обхідні шляхи без вищих типів (або навпаки у F #)? Можливо, я просто настільки звик обійти це питання, що не помічаю відсутності цієї функції.

(Я думаю) Я розумію, що замість myList |> List.map fабо myList |> Seq.map f |> Seq.toListвище виділені типи дозволяють вам просто писати, myList |> map fі це поверне a List. Це чудово (якщо припустити, що це правильно), але здається якийсь дріб’язковий? (І чи не можна це зробити, просто дозволивши перевантаження функції?) Я зазвичай перетворюю на Seqвсе одно, а потім можу перетворити на все, що захочу, згодом. Знову ж таки, можливо, я просто занадто звик обійти це. Але чи є приклад, коли вищі типи справді насправді рятують або натисканням клавіш, або безпекою типу?


2
Багато функцій у Control.Monad використовують вищі типи, тому вам може знадобитися переглянути деякі приклади. У F # реалізації доведеться повторити для кожного конкретного типу монади.
Lee

1
@Lee, але чи не могли ви просто створити інтерфейс, IMonad<T>а потім передати його назад, наприклад, IEnumerable<int>або IObservable<int>коли закінчите? Це все лише для того, щоб уникнути кастингу?
омаризм

4
Лиття колодязів небезпечно, тому це відповідає на ваше запитання про безпеку типу. Інше питання полягає в тому, як returnби це працювало, оскільки це дійсно належить до типу монади, а не до конкретного екземпляра, тому ви взагалі не хотіли б поміщати його в IMonadінтерфейс.
Lee

4
@Lee так, я просто думав, що вам доведеться кидати кінцевий результат після виразу, не біггі, бо ви щойно зробили вираз, щоб знати тип. Але схоже, вам теж довелося б кидати всередину кожного імпульсу bindака SelectManyтощо. Що означає, що хтось міг би використовувати API для bindan IObservableдо IEnumerableі припустити, що це спрацює, що так, якщо це так, і немає ніякого способу обійти це. Тільки не на 100% впевнений, що цього ніяк не обійти.
омаризм

5
Чудове запитання. Я ще не бачив жодного переконливого практичного прикладу, коли ця мовна функція є корисною IRL.
JD

Відповіді:


78

Отже, тип типу - це його простий тип. Наприклад, Intмає kind, *що означає, що це базовий тип, і його можна створити за допомогою значень. За деяким вільним визначенням вищого типу (і я не впевнений, де F # проводить лінію, тому давайте просто включимо його), поліморфні контейнери є чудовим прикладом вищого типу.

data List a = Cons a (List a) | Nil

Конструктор типу Listмає kind, * -> *що означає, що йому потрібно передавати конкретний тип, щоб отримати конкретний тип: List Intможе мати мешканців, як, [1,2,3]але Listсам не може.

Я збираюся припустити, що переваги поліморфних контейнерів очевидні, але * -> *існують більш корисні типи, ніж просто контейнери. Наприклад, відносини

data Rel a = Rel (a -> a -> Bool)

або парсери

data Parser a = Parser (String -> [(a, String)])

обидва також мають добро * -> *.


Однак ми можемо досягти цього в Haskell, маючи типи з типом вищого порядку. Наприклад, ми могли б шукати тип з kind (* -> *) -> *. Простим прикладом цього може бути Shapeспроба заповнити контейнер виду * -> *.

data Shape f = Shape (f ())

[(), (), ()] :: Shape List

TraversableНаприклад, це корисно для характеристики s у Haskell, оскільки їх завжди можна розділити за формою та вмістом.

split :: Traversable t => t a -> (Shape t, [a])

Як інший приклад, давайте розглянемо дерево, параметризоване за типом гілки, яке воно має. Наприклад, може бути звичайне дерево

data Tree a = Branch (Tree a) a (Tree a) | Leaf

Але ми можемо бачити, що тип гілки містить a Pairз Tree a, і тому ми можемо витягти цей фрагмент із типу параметрично

data TreeG f a = Branch a (f (TreeG f a)) | Leaf

data Pair a = Pair a a
type Tree a = TreeG Pair a

Цей TreeGконструктор типу має kind (* -> *) -> * -> *. Ми можемо використовувати його для створення цікавих інших варіантів, таких як aRoseTree

type RoseTree a = TreeG [] a

rose :: RoseTree Int
rose = Branch 3 [Branch 2 [Leaf, Leaf], Leaf, Branch 4 [Branch 4 []]]

Або патологічні, такі як MaybeTree

data Empty a = Empty
type MaybeTree a = TreeG Empty a

nothing :: MaybeTree a
nothing = Leaf

just :: a -> MaybeTree a
just a = Branch a Empty

Або a TreeTree

type TreeTree a = TreeG Tree a

treetree :: TreeTree Int
treetree = Branch 3 (Branch Leaf (Pair Leaf Leaf))

Інше місце, яке це виявляється, - це "алгебри функторів". Якщо ми скинемо кілька шарів абстрактності, це може бути краще розглядати як складку, наприклад sum :: [Int] -> Int. Алгебри параметризуються над функтором і носієм . Функтор має вигляд * -> *і носій вид *так взагалі

data Alg f a = Alg (f a -> a)

має вид (* -> *) -> * -> *. Algкорисний завдяки своєму відношенню до типів даних та схем рекурсії, побудованих поверх них.

-- | The "single-layer of an expression" functor has kind `(* -> *)`
data ExpF x = Lit Int
            | Add x x
            | Sub x x
            | Mult x x

-- | The fixed point of a functor has kind `(* -> *) -> *`
data Fix f = Fix (f (Fix f))

type Exp = Fix ExpF

exp :: Exp
exp = Fix (Add (Fix (Lit 3)) (Fix (Lit 4))) -- 3 + 4

fold :: Functor f => Alg f a -> Fix f -> a
fold (Alg phi) (Fix f) = phi (fmap (fold (Alg phi)) f)

Нарешті, хоча вони теоретично можливі, я ніколи не бачив конструктора ще вищого типу. Іноді ми бачимо функції цього типу, такі як mask :: ((forall a. IO a -> IO a) -> IO b) -> IO b, але, я думаю, вам доведеться заглибитися в пролог типу або літературу, яка вводиться залежно, щоб побачити такий рівень складності у типах.


3
Я перевірю код і відредагую код за кілька хвилин, зараз я на телефоні.
J. Abrahamson,

12
@ J.Abrahamson +1 за хорошу відповідь і терпіння набрати це на своєму телефоні O_o
Daniel Gratzer

3
@lobsterism A TreeTreeє просто патологічним, але більш практично це означає, що у вас є два різні типи дерев, переплетених між собою - просунувши цю ідею трохи далі, ви зможете отримати деякі дуже потужні поняття, безпечні для типу, такі як статично безпечний червоний / чорні дерева та акуратний статично збалансований тип FingerTree.
Дж. Абрахамсон,

3
@JonHarrop Стандартним реальним прикладом є абстрагування над монадами, наприклад, зі стеками ефектів mtl. Однак ви можете не погодитися з тим, що це справжній світ. Я думаю, що загалом зрозуміло, що мови можуть успішно існувати без HKT, тому будь-який приклад буде наводити якусь абстракцію, яка є більш складною, ніж інші мови.
J. Abrahamson

2
Ви можете мати, наприклад, підмножини дозволених ефектів у різних монадах та абстрагуватися над будь-якими монадами, що відповідають цій специфікації. Наприклад, монади, що створюють екземпляр "телетайпу", що дозволяє читати та писати на рівні символів, можуть включати як введення-виведення, так і абстракцію конвеєра. В якості іншого прикладу ви можете абстрагуватися від різних асинхронних реалізацій. Без HKT ви обмежуєте будь-який тип, що складається з цієї загальної частини.
Дж. Абрахамсон,

64

Розглянемо Functorклас типу в Haskell, де fє вища змінна типу:

class Functor f where
    fmap :: (a -> b) -> f a -> f b

Підпис цього типу говорить про те, що fmap змінює параметр типу від fвід aдо b, але залишає fяк було. Отже, якщо ви використовуєте fmapнад списком, ви отримуєте список, якщо ви використовуєте його над синтаксичним аналізатором, ви отримуєте синтаксичний аналізатор тощо. І це статичні гарантії під час компіляції.

Я не знаю F #, але давайте розглянемо, що трапиться, якщо ми спробуємо висловити Functorабстракцію на такій мові, як Java або C #, з успадкуванням та узагальненнями, але жодних вищих типів. Перша спроба:

interface Functor<A> {
    Functor<B> map(Function<A, B> f);
}

Проблема цієї першої спроби полягає в тому, що реалізації інтерфейсу дозволено повертати будь-який клас, який реалізує Functor. Хтось міг написати, FunnyList<A> implements Functor<A>чий mapметод повертає інший тип колекції, або навіть щось інше, що взагалі не є колекцією, але все ще є Functor. Крім того, коли ви використовуєте mapметод, ви не можете викликати будь-які специфічні для підтипу методи в результаті, якщо не знизити його до типу, який ви насправді очікуєте. Отже, у нас дві проблеми:

  1. Система типів не дозволяє висловити інваріант, що mapметод завжди повертає те самеFunctor підклас, що і одержувач.
  2. Отже, не існує статично безпечного способу викликати неметод Functorна результат map.

Є й інші, більш складні способи, які ви можете спробувати, але жоден з них насправді не працює. Наприклад, ви можете спробувати збільшити першу спробу, визначивши підтипи, Functorякі обмежують тип результату:

interface Collection<A> extends Functor<A> {
    Collection<B> map(Function<A, B> f);
}

interface List<A> extends Collection<A> {
    List<B> map(Function<A, B> f);
}

interface Set<A> extends Collection<A> {
    Set<B> map(Function<A, B> f);
}

interface Parser<A> extends Functor<A> {
    Parser<B> map(Function<A, B> f);
}

// …

Це допомагає заборонити реалізаторам тих вужчих інтерфейсів повертати неправильний тип Functorіз mapметоду, але оскільки обмеження кількостіFunctor реалізацій ви можете мати, немає межі того , скільки вже , інтерфейсів вам потрібно.

( EDIT: І зауважте, що це працює лише тому, що Functor<B>відображається як тип результату, і тому дочірні інтерфейси можуть це звузити. Тож AFAIK ми не можемо звузити обидва способи використання Monad<B>в наступному інтерфейсі:

interface Monad<A> {
    <B> Monad<B> flatMap(Function<? super A, ? extends Monad<? extends B>> f);
}

У Haskell із змінними типу вищого рангу це (>>=) :: Monad m => m a -> (a -> m b) -> m b .)

Ще одна спроба полягає у використанні рекурсивних дженериків, щоб спробувати інтерфейс обмежити тип результату підтипу самим підтипом. Приклад іграшки:

/**
 * A semigroup is a type with a binary associative operation.  Law:
 *
 * > x.append(y).append(z) = x.append(y.append(z))
 */
interface Semigroup<T extends Semigroup<T>> {
    T append(T arg);
}

class Foo implements Semigroup<Foo> {
    // Since this implements Semigroup<Foo>, now this method must accept 
    // a Foo argument and return a Foo result. 
    Foo append(Foo arg);
}

class Bar implements Semigroup<Bar> {
    // Any of these is a compilation error:

    Semigroup<Bar> append(Semigroup<Bar> arg);

    Semigroup<Foo> append(Bar arg);

    Semigroup append(Bar arg);

    Foo append(Bar arg);

}

Але такий метод (який є досить загадковим для вашого розробника OOP, а також для вашого функціонального розробника) ще не може виразити бажане Functorобмеження:

interface Functor<FA extends Functor<FA, A>, A> {
    <FB extends Functor<FB, B>, B> FB map(Function<A, B> f);
}

Проблема тут полягає в тому, що це не обмежує FBмати те саме, що Fі FA- тому, коли ви оголошуєте тип List<A> implements Functor<List<A>, A>, mapметод все одно може повернути a NotAList<B> implements Functor<NotAList<B>, B>.

Останній спробу на Java, використовуючи необроблені типи (непараметризовані контейнери):

interface FunctorStrategy<F> {
    F map(Function f, F arg);
} 

Тут Fбуде отримати примірник для unparametrized типів , як тільки Listабо Map. Це гарантує, що a FunctorStrategy<List>може повернути лише aList - але ви відмовились від використання змінних типу для відстеження типів елементів у списках.

Суть проблеми полягає в тому, що такі мови, як Java та C #, не дозволяють параметрам типу мати параметри. У Java, якщо Tє змінною типу, ви можете писати Tі List<T>, але ні T<String>. Типи вищого типу знімають це обмеження, щоб у вас могло бути щось подібне (не до кінця продумане):

interface Functor<F, A> {
    <B> F<B> map(Function<A, B> f);
}

class List<A> implements Functor<List, A> {

    // Since F := List, F<B> := List<B>
    <B> List<B> map(Function<A, B> f) {
        // ...
    }

}

І зокрема, звертаючись до цього біта:

(Я думаю) Я розумію, що замість myList |> List.map fабо myList |> Seq.map f |> Seq.toListвище введені типи дозволяють вам просто писати, myList |> map fі це поверне a List. Це чудово (якщо припустити, що це правильно), але здається якийсь дріб’язковий? (І чи не можна це зробити, просто дозволивши перевантаження функції?) Я зазвичай перетворюю на Seqвсе одно, а потім згодом можу перетворити на все, що хочу.

Є багато мов, які узагальнюють ідею mapфункції таким чином, моделюючи її так, ніби в основі суть відображення - це послідовності. Це ваше зауваження в цьому дусі: якщо у вас є тип, який підтримує перетворення в і з Seq, ви отримуєте операцію з картою "безкоштовно", повторно використовуючи Seq.map.

Однак у Хаскелі Functorклас є загальнішим за цей; це не пов'язано з поняттям послідовностей. Ви можете реалізувати fmapдля типів, які не мають належного відображення послідовностей, таких як IOдії, комбінатори синтаксичного аналізатора, функції тощо:

instance Functor IO where
    fmap f action =
        do x <- action
           return (f x)

 -- This declaration is just to make things easier to read for non-Haskellers 
newtype Function a b = Function (a -> b)

instance Functor (Function a) where
    fmap f (Function g) = Function (f . g)  -- `.` is function composition

Поняття "відображення" насправді не пов'язане з послідовностями. Найкраще зрозуміти закони функторів:

(1) fmap id xs == xs
(2) fmap f (fmap g xs) = fmap (f . g) xs

Дуже неофіційно:

  1. Перший закон говорить, що відображення за допомогою функції ідентичності / noop - це те саме, що нічого не робити.
  2. Другий закон говорить, що будь-який результат, який ви можете отримати шляхом картографування двічі, ви також можете отримати, виконавши картографування один раз.

Ось чому ви хочете fmapзберегти тип - адже як тільки ви отримуєте mapоперації, які дають інший тип результату, стає набагато важче зробити такі гарантії.


Так що я зацікавлений у вашому останньому бите, чому це корисно мати fmapна Function aколи він уже має .роботу? Я розумію, чому .має сенс бути визначенням fmapоперації, але я просто не потрапляю туди, де вам коли-небудь потрібно було б скористатися fmapзамість цього .. Можливо, якби ви могли навести приклад, де це було б корисно, це допомогло б мені зрозуміти.
омаризм

1
Ах, зрозумів: ти можеш зробити fn doubleз функтора, де double [1, 2, 3]дає [2, 4, 6]і double sinдає fn, що подвоює гріх. Я бачу, де, якщо ти починаєш думати в такому мисленні, коли ти запускаєш карту на масиві, ти очікуєш повернення масиву назад, а не просто наступних, бо, ну, ми працюємо над масивами тут.
омаризм

@lobsterism: Є алгоритми / методики, які покладаються на можливість абстрагувати a Functorта дозволити клієнту бібліотеки вибрати його. Відповідь Дж. Абрахамсона дає один приклад: рекурсивні складки можна узагальнити за допомогою функторів. Інший приклад - вільні монади; ви можете сприймати їх як своєрідну бібліотеку загальної реалізації інтерпретатора, де клієнт надає "набір інструкцій" як довільний Functor.
Луїс Касільяс

3
Технічно обґрунтована відповідь, але це змушує мене замислюватися, чому хтось коли-небудь хотів би цього на практиці. Я не виявив, що тягнуся Functorні до Хаскелла, ні до SemiGroup. Де справжні програми найбільше використовують цю мовну функцію?
JD

28

Я не хочу повторювати інформацію в деяких чудових відповідях вже тут, але є ключовий момент, який я хотів би додати.

Зазвичай вам не потрібні вищі типи для реалізації якоїсь конкретної монади чи функтора (або аплікативного функтора, або стрілки, або ...). Але робити це в основному не вистачає суті.

Загалом я виявив, що коли люди не бачать корисності функторів / монад / невідомих, це часто тому, що вони думають про ці речі по черзі . Операції функторів / монад / і т. Д. Насправді нічого не додають до будь-якого одного екземпляра (замість виклику bind, fmap тощо я міг би просто викликати будь-які операції, які я використовував для реалізації bind, fmap тощо). Для чого вам справді потрібні ці абстракції, щоб у вас був код, який загалом працює з будь-яким функтором / монадою / тощо.

У контексті, коли такий загальний код широко використовується, це означає, що кожного разу, коли ви пишете новий екземпляр монади, ваш тип негайно отримує доступ до великої кількості корисних операцій , які вже були написані для вас . Це сенс бачити монади (і функтори, і ...) скрізь; не так , що я можу використовувати , bindа не concatта mapреалізувати myFunkyListOperation(який не отримує нічого мені сам по собі), а так , що , коли я прийшов до необхідності myFunkyParserOperationіmyFunkyIOOperation я можу повторно використовувати код , який я спочатку бачив в термінах списків , тому що це на самому справі монади-родової .

Але щоб абстрагуватися від параметризованого типу, як монада із захистом типу , вам потрібні вищі типи (як це пояснено в інших відповідях тут).


9
Це ближче до корисної відповіді, ніж будь-яка інша відповідь, яку я читав досі, але я все одно хотів би бачити єдине практичне застосування, де корисні вищі типи.
JD

"Для чого вам справді потрібні ці абстракції, щоб у вас був код, який загалом працює з будь-яким функтором / монадою". F # отримав монади у вигляді обчислювальних виразів 13 років тому, спочатку спортивні монади seq та async. Сьогодні F # користується третьою монадою, запитом. З такою кількістю монад, у яких так мало спільного, чому ви хочете абстрагуватися від них?
JD

@JonHarrop Ви чітко знаєте, що інші люди писали код, використовуючи величезну кількість монад (і функторів, стрілок тощо; HKT - це не лише монади) мовами, які підтримують HKT, і знаходять способи їх абстрагування. І очевидно, ви не думаєте, що будь-який із цього коду має практичну користь, і вам цікаво, чому інші люди заважають його писати. Яке розуміння ви сподіваєтесь отримати, повернувшись, щоб розпочати дебати щодо 6-річного допису, який ви вже коментували 5 років тому?
Бен

"сподіваючись отримати виграш, повернувшись, щоб розпочати дебати щодо шестирічної посади". Ретроспектива. З огляду на минуле, ми тепер знаємо, що абстракції F # над монадами залишаються в основному невикористаними. Тому здатність абстрагувати понад 3 значущі різні речі є невпевненою.
JD

@JonHarrop Суть моєї відповіді полягає в тому, що окремі монади (або функтори, або інше) насправді не є більш корисними, ніж аналогічна функціональність, виражена без кочового інтерфейсу, але це об'єднання безлічі різнорідних речей. Я перейду до вашого досвіду щодо F #, але якщо ви говорите, що він має лише 3 окремі монади (а не впроваджує монадичний інтерфейс до всіх концепцій, які можуть мати одну, наприклад, збій, стан, аналіз тощо), тоді так, не дивно, що ви не отримаєте великої вигоди від об’єднання цих 3 речей.
Бен

15

Для більш конкретної точки зору .NET, я давно писав про це в блозі . Суть цього полягає в тому, що для вищих типів ви можете потенційно використовувати ті самі блоки LINQ між IEnumerablesіIObservables , але без вищих типів це неможливо.

Найближче, що ви могли отримати (я зрозумів це після розміщення блогу), це зробити свій власний IEnumerable<T>і, IObservable<T>і розширив їх обох із IMonad<T>. Це дозволить вам повторно використовувати блоки LINQ, якщо вони позначені IMonad<T>, але тоді це вже не безпечно для типів, оскільки дозволяє змішувати та поєднувати IObservablesтаIEnumerables в тому самому блоці, що, хоча це може звучати інтригуюче, щоб увімкнути це, ви б в основному просто отримати якусь невизначену поведінку.

Я писав пізніше пост про те, як Хаскелл робить це легким. (Неоперація, насправді - обмеження блоку певним типом монади вимагає коду; увімкнення повторного використання є типовим).


2
Я дам вам +1 за те, що це єдина відповідь, де згадується щось практичне, але я не думаю, що я коли-небудь використовував IObservablesу виробничому коді.
JD

5
@JonHarrop Це здається неправдою. У F # є всі події IObservable, і ви використовуєте події в главі WinForms вашої власної книги.
Дакс Фол,

1
Корпорація Майкрософт заплатила мені за написання цієї книги і вимагала, щоб я висвітлив цю функцію. Я не пам’ятаю, як використовував події у виробничому коді, але подивлюсь.
JD

Повторне використання між IQueryable та IEnumerable теж буде можливим, я гадаю
KolA

Через чотири роки, і я закінчив пошук: ми вилучили Rx з виробництва.
JD

13

Найбільш уживаним прикладом поліморфізму вищого типу в Haskell є Monadінтерфейс. Functorі Applicativeмають вищий тип таким же чином, тому я покажу Functorдля того, щоб показати щось лаконічне.

class Functor f where
    fmap :: (a -> b) -> f a -> f b

Тепер вивчимо це визначення, подивившись, як використовується змінна типу f. Ви побачите, що fце не може означати тип, що має значення. Ви можете визначити значення в підписі цього типу, оскільки вони є аргументами та результатами функції. Таким чином, змінний типу aі bтипи , які можуть мати значення. Таким чином , є виразом типу f aі f b. Але не fсама. fє прикладом змінної типу вищого типу. Враховуючи, що *це тип типів, які можуть мати значення, fповинен мати і тип * -> *. Тобто, він приймає тип, який може мати значення, оскільки з попереднього огляду ми знаємо, що aіb повинні мати значення. І ми також знаємо, що f aіf b повинен мати значення, тому він повертає тип, який повинен мати значення.

Це робить fвикористаною у визначенні Functorзмінну типу вищого типу.

Інтерфейси Applicativeі Monadдодають більше, але вони сумісні. Це означає, що вони працюють і над змінними типу з видом * -> *.

Робота над вищими типами вводить додатковий рівень абстракції - ви не обмежуєтесь лише створенням абстракцій над базовими типами. Ви також можете створювати абстракції над типами, які змінюють інші типи.


4
Ще одне чудове технічне пояснення, що таке вищі види, змушує мене замислюватися, для чого вони корисні. Де ви використали це в реальному коді?
JD
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.