web-dev-qa-db-fra.com

Qu'est-ce qui constitue un pli pour les types autres que la liste?

Considérez une liste à lien unique. Cela ressemble à quelque chose

data List x = Node x (List x) | End

Il est naturel de définir une fonction de pliage telle que

reduce :: (x -> y -> y) -> y -> List x -> y

Dans un sens, reduce f x0 remplace chaque Node par f et chaque End par x0. C'est ce que le Prélude appelle un fold.

Considérons maintenant un arbre binaire simple:

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

Il est tout aussi naturel de définir une fonction telle que

reduce :: (y -> y -> y) -> (x -> y) -> Tree x -> y

Notez que la réduction this a un caractère très différent; tandis que celle basée sur une liste est intrinsèquement séquentielle, cette nouvelle arborescence a plus une sensation de division et de conquête. Vous pourriez même imaginer y jeter quelques par combinateurs. (Où mettriez-vous une telle chose dans la version liste?)

Ma question: cette fonction est-elle toujours classée comme un "pli", ou est-ce autre chose? (Et si oui, qu'est-ce que c'est?)

Fondamentalement, chaque fois que quelqu'un parle de pliage, il parle toujours de pliage listes, qui est intrinsèquement séquentiel. Je me demande si "séquentiel" fait partie de la définition de ce qu'est un pli, ou s'il s'agit simplement d'une propriété fortuite de l'exemple de pliage le plus couramment utilisé.

64

Tikhon a les trucs techniques. Je pense que je vais essayer de simplifier ce qu'il a dit.

Le terme "pliage" est malheureusement devenu ambigu au fil des ans pour signifier l'une des deux choses suivantes:

  1. Réduire une collection séquentiellement dans un certain ordre. Dans Haskell, c'est ce que signifie "plier" dans la classe Foldable, que Larsmans évoque.
  2. La notion que vous avez demandée: "détruire" (à l'opposé de construire ), "observer" ou "éliminer" un type de données algébrique selon sa structure. Aussi appelé catamorphisme .

Il est possible de définir ces deux notions de manière générique afin qu'une fonction paramétrée soit capable de le faire pour une variété de types. Tikhon montre comment faire dans le deuxième cas.

Mais le plus souvent, faire tout le chemin avec Fix et les algèbres et autres est exagéré. Élaborons une manière plus simple d'écrire le pli pour tout type de données algébrique. Nous utiliserons Maybe, des paires, des listes et des arbres comme exemples:

data Maybe a = Nothing | Just a
data Pair a b = Pair a b
data List a = Nil | Cons a (List a)
data Tree x = Leaf x | Branch (Tree x) (Tree x)
data BTree a = Empty | Node a (BTree a) (BTree a)

Notez que Pair n'est pas récursif; la procédure que je vais montrer ne suppose pas que le type "fold" est récursif. Les gens n'appellent généralement pas ce cas un "pli", mais c'est vraiment le cas non récursif du même concept.

Première étape: le pli pour un type donné consommera le type plié et produira un type de paramètre comme résultat. J'aime appeler ce dernier r (pour "result"). Alors:

foldMaybe :: ... -> Maybe a -> r
foldPair  :: ... -> Pair a b -> r
foldList  :: ... -> List a -> r
foldTree  :: ... -> Tree a -> r
foldBTree :: ... -> BTree a -> r

Deuxième étape: en plus du dernier argument (celui de la structure), le pli prend autant d'arguments que le type a de constructeurs. Pair a un constructeur et nos autres exemples en ont deux, donc:

foldMaybe :: nothing -> just -> Maybe a -> r
foldPair  :: pair -> Pair a b -> r 
foldList  :: nil -> cons -> List a -> r
foldTree  :: leaf -> branch -> Tree a -> r
foldBTree :: empty -> node -> BTree a -> r

Troisième étape: chacun de ces arguments a la même arité que le constructeur auquel il correspond. Considérons les constructeurs comme des fonctions et écrivons leurs types (en veillant à ce que les variables de type correspondent à celles des signatures que nous écrivons):

Nothing :: Maybe a
Just    :: a -> Maybe a

Pair    :: a -> b -> Pair a b

Nil     :: List a
Cons    :: a -> List a -> List a

Leaf    :: a -> Tree a
Branch  :: Tree a -> Tree a -> Tree a

Empty   :: BTree a
Node    :: a -> BTree a -> BTree a -> BTree a

Étape 4: dans la signature de chaque constructeur, nous remplacerons toutes les occurrences du type de données qu'il construit par notre variable de type r (que nous utilisons dans nos signatures de repli):

nothing := r
just    := a -> r

pair    := a -> b -> r

nil     := r
cons    := a -> r -> r

leaf    := a -> r
branch  := r -> r -> r

empty   := r
node    := a -> r -> r -> r

Comme vous pouvez le voir, j'ai "attribué" les signatures résultantes à mes variables de type factice à partir de la deuxième étape. Maintenant étape 5: remplissez-les dans les signatures de pli d'esquisse précédentes:

foldMaybe :: r -> (a -> r) -> Maybe a -> r
foldPair  :: (a -> b -> r) -> Pair a b -> r 
foldList  :: r -> (a -> r -> r) -> List a -> r
foldTree  :: (a -> r) -> (r -> r -> r) -> Tree a -> r
foldBTree :: r -> (a -> r -> r -> r) -> BTree a -> r

Maintenant, ce sont des signatures pour les plis de ces types. Ils ont un ordre d'argument drôle, parce que je l'ai fait mécaniquement en lisant le type de repli des déclarations data et des types de constructeurs, mais pour une raison quelconque dans la programmation fonctionnelle, il est classique de mettre les cas de base en premier dans data définitions mais gestionnaires de cas récursifs en premier dans les définitions fold. Aucun problème! Remanions-les pour les rendre plus conventionnels:

foldMaybe :: (a -> r) -> r -> Maybe a -> r
foldPair  :: (a -> b -> r) -> Pair a b -> r 
foldList  :: (a -> r -> r) -> r -> List a -> r
foldTree  :: (r -> r -> r) -> (a -> r) -> Tree a -> r
foldBTree :: (a -> r -> r -> r) -> r -> BTree a -> r

Les définitions peuvent également être renseignées mécaniquement. Choisissons foldBTree et implémentons-le pas à pas. Le pli pour un type donné est la seule fonction du type que nous avons trouvé qui remplit cette condition: le pliage avec les constructeurs du type est une fonction d'identité sur ce type (vous obtenez le même résultat que la valeur avec laquelle vous avez commencé).

Nous allons commencer comme ceci:

foldBTree :: (a -> r -> r -> r) -> r -> BTree a -> r
foldBTree = ???

Nous savons qu'il faut trois arguments, nous pouvons donc ajouter des variables pour les refléter. Je vais utiliser de longs noms descriptifs:

foldBTree :: (a -> r -> r -> r) -> r -> BTree a -> r
foldBTree branch empty tree = ???

En regardant la déclaration data, nous savons que BTree a deux constructeurs possibles. Nous pouvons diviser la définition en un cas pour chacun et remplir des variables pour leurs éléments:

foldBTree :: (a -> r -> r -> r) -> r -> BTree a -> r
foldBTree branch empty Empty = ???
foldBTree branch empty (Branch a l r) = ???
    -- Let's use comments to keep track of the types:
    -- a :: a
    -- l, r :: BTree a

Maintenant, à court de quelque chose comme undefined, la seule façon de remplir la première équation est avec empty:

foldBTree :: (a -> r -> r -> r) -> r -> BTree a -> r
foldBTree branch empty Empty = empty
foldBTree branch empty (Branch a l r) = ???
    -- a :: a
    -- l, r :: BTree a

Comment remplissons-nous la deuxième équation? Encore une fois, à court de undefined, nous avons ceci:

branch :: a -> r -> r -> r
a      :: a
l, r   :: BTree a

Si nous avions subfold :: BTree a -> r, Nous pourrions faire branch a (subfold l) (subfold r) :: r. Mais bien sûr, nous pouvons facilement écrire 'subfold':

foldBTree :: (a -> r -> r -> r) -> r -> BTree a -> r
foldBTree branch empty Empty = empty
foldBTree branch empty (Branch a l r) = branch a (subfold l) (subfold r)
    where subfold = foldBTree branch empty

C'est le pli pour BTree, car foldBTree Branch Empty anyTree == anyTree. Notez que foldBTree n'est pas la seule fonction de ce type; il y a aussi ceci:

mangleBTree :: (a -> r -> r -> r) -> r -> BTree a -> r
mangleBTree branch empty Empty = empty
mangleBTree branch empty (Branch a l r) = branch a (submangle r) (submangle l)
    where submangle = mangleBTree branch empty

Mais en général, mangleBTree n'a pas la propriété requise; par exemple, si nous avons foo = Branch 1 (Branch 2 Empty Empty) Empty, il s'ensuit que mangleBTree Branch Empty foo /= foo. Donc mangleBTree, bien qu'il ait le bon type, n'est pas le pli.


Maintenant, prenons un peu de recul par rapport aux détails et concentrons-nous sur ce dernier point avec l'exemple mangleTree. Un pli (au sens structurel, # 2 en haut de ma réponse) n'est rien de plus et rien d'autre que la fonction la plus simple et non triviale pour un type algébrique de telle sorte que, lorsque vous donnez le passage aux constructeurs du type comme arguments, il devient la fonction d'identité de ce type. (Par non trivial, je veux dire que des choses comme foo f z xs = xs Ne sont pas autorisées.)

C'est très important. Deux façons j'aime y penser sont les suivantes:

  1. Le pli pour un type donné peut "voir" toutes les informations contenues par n'importe quelle valeur de ce type. (C'est pourquoi il est capable de "reconstruire" parfaitement toute valeur de ce type à partir de zéro en utilisant les constructeurs du type.)
  2. Le pli est la fonction "consommateur" la plus générale pour ce type. Toute fonction qui consomme une valeur du type en question peut être écrite de sorte que les seules opérations qu'elle utilise à partir de ce type sont le pli et les constructeurs. (Bien que les versions repliables de certaines fonctions soient difficiles à écrire et fonctionnent mal; essayez d'écrire tail :: [a] -> [a] Avec foldr, (:) Et [] Comme douloureux exercice.)

Et le deuxième point va encore plus loin, car vous n'avez même pas besoin des constructeurs. Vous pouvez implémenter n'importe quel type algébrique sans utiliser de déclarations ou de constructeurs data, en utilisant uniquement des plis:

{-# LANGUAGE RankNTypes #-}

-- | A Church-encoded list is a function that takes the two 'foldr' arguments
-- and produces a result from them.
newtype ChurchList a = 
    ChurchList { runList :: forall r. 
                            (a -> r -> r)  -- ^ first arg of 'foldr'
                         -> r              -- ^ second arg of 'foldr'
                         -> r              -- ^ 'foldr' result
               }

-- | Convenience function: make a ChurchList out of a regular list
toChurchList :: [a] -> ChurchList a
toChurchList xs = ChurchList (\kons knil -> foldr kons knil xs)

-- | 'toChurchList' isn't actually needed, however, we can make do without '[]'
-- completely.
cons :: a -> ChurchList a -> ChurchList a
cons x xs = ChurchList (\f z -> f x (runlist xs f z))

nil :: ChurchList a
nil = ChurchList (\f z -> z)

foldr' :: (a -> r -> r) -> r -> ChurchList a -> r
foldr' f z xs = runList xs f z

head :: ChurchList a -> Maybe a
head = foldr' ((Just .) . const) Nothing

append :: ChurchList a -> ChurchList a -> ChurchList a
append xs ys = foldr' cons ys xs

-- | Convert a 'ChurchList' to a regular list.
fromChurchList :: ChurchList a -> [a]
fromChurchList xs = runList xs (:) []

Comme exercice, vous pouvez essayer d'écrire d'autres types de cette manière (qui utilise l'extension RankNTypeslisez ceci pour une introduction ). Cette technique est appelée Church encoding , et est parfois utile dans la programmation réelle — par exemple, GHC utilise quelque chose appelé foldr/build fusion pour optimiser le code de liste pour supprimer les intermédiaires listes; voir cette page Haskell Wiki , et notez le type de build:

build :: (forall b. (a -> b -> b) -> b -> b) -> [a]
build g = g (:) []

Sauf pour le newtype, c'est la même chose que mon fromChurchList ci-dessus. Fondamentalement, l'une des règles que GHC utilise pour optimiser le code de traitement de liste est la suivante:

-- Don't materialize the list if all we're going to do with it is
-- fold it right away:
foldr kons knil (fromChurchList xs) ==> runChurchList xs kons knil

En implémentant les fonctions de liste de base pour utiliser les encodages Church en interne, en alignant leurs définitions de manière agressive et en appliquant cette règle au code intégré, les utilisations imbriquées de fonctions comme map peuvent être fusionnées dans une boucle étroite.

63
Luis Casillas

Un pli pour chaque occasion

Nous pouvons en fait proposer une notion générique de pliage qui peut s'appliquer à tout un tas de types différents. Autrement dit, nous pouvons définir systématiquement une fonction fold pour les listes, les arbres et plus encore.

Cette notion générique de fold correspond aux catamorphismes @pelotom mentionnés dans son commentaire.

Types récursifs

L'idée clé est que ces fonctions fold sont définies sur types récursifs . En particulier:

data List a = Cons a (List a) | Nil
data Tree a = Branch (Tree a) (Tree a) | Leaf a

Ces deux types sont clairement récursifs --List dans le cas Cons et Tree dans le cas Branch.

Points fixes

Tout comme les fonctions, nous pouvons réécrire ces types en utilisant des points fixes. Rappelez-vous la définition de fix:

fix f = f (fix f)

Nous pouvons en fait écrire quelque chose de très similaire pour les types, sauf qu'il doit avoir un wrapper de constructeur supplémentaire:

newtype Fix f = Roll (f (Fix f))

Tout comme fix définit le point fixe d'une fonction , cela définit le point fixe d'une fonction foncteur . Nous pouvons exprimer tous nos types récursifs en utilisant ce nouveau type Fix.

Cela nous permet de réécrire les types List comme suit:

data ListContainer a rest = Cons a rest | Nil
type List a = Fix (ListContainer a)

Essentiellement, Fix nous permet d'imbriquer ListContainers à des profondeurs arbitraires. Nous pourrions donc avoir:

Roll Nil
Roll (Cons 1 (Roll Nil))
Roll (Cons 1 (Roll (Cons 2 (Roll Nil))))

qui correspondent respectivement à [], [1] et [1,2].

Voir que ListContainer est un Functor est facile:

instance Functor (ListContainer a) where
  fmap f (Cons a rest) = Cons a (f rest)
  fmap f Nil           = Nil

Je pense que le mappage de ListContainer à List est assez naturel: au lieu de récursivement explicitement, nous faisons de la partie récursive une variable. Ensuite, nous utilisons simplement Fix pour remplir cette variable comme il convient.

Nous pouvons également écrire un type analogue pour Tree.

Points fixes de "déballage"

Alors, pourquoi nous en soucions-nous? Nous pouvons définir fold pour les types arbitraires écrits à l'aide de Fix. En particulier:

fold :: Functor f => (f a -> a) -> (Fix f -> a)
fold h = h . fmap (fold h) . unRoll
  where unRoll (Roll a) = a

Essentiellement, tout ce qu'un pli fait est de déballer le type "roulé" une couche à la fois, en appliquant une fonction au résultat à chaque fois. Ce "déroulement" nous permet de définir un pli pour tout type récursif et de généraliser de manière nette et naturelle le concept.

Pour l'exemple de liste, cela fonctionne comme ceci:

  1. À chaque étape, nous déroulons le Roll pour obtenir un Cons ou un Nil
  2. Nous répétons le reste de la liste en utilisant fmap.
    1. Dans le cas Nil, fmap (fold h) Nil = Nil, nous renvoyons donc simplement Nil.
    2. Dans le cas Cons, le fmap continue simplement le pli sur le reste de la liste.
  3. En fin de compte, nous obtenons un tas d'appels imbriqués vers fold se terminant par un Nil-- tout comme le standard foldr.

Comparaison des types

Voyons maintenant les types des deux fonctions de pliage. Tout d'abord, foldr:

foldr :: (a -> b -> b) -> b -> [a] -> b

Maintenant, fold spécialisé dans ListContainer:

fold :: (ListContainer a b -> b) -> (Fix (ListContainer a) -> b)

Au début, ceux-ci semblent complètement différents. Cependant, avec un peu de massage, nous pouvons montrer qu'ils sont les mêmes. Les deux premiers arguments de foldr sont a -> b -> b Et b. Nous avons une fonction et une constante. Nous pouvons penser à b comme () -> b. Nous avons maintenant deux fonctions _ -> b_ Est () Et a -> b. Pour vous simplifier la vie, utilisons la deuxième fonction en nous donnant (a, b) -> b. Maintenant, nous pouvons les écrire comme une seule fonction en utilisant Either:

Either (a, b) () -> b

Cela est vrai car étant donné f :: a -> c Et g :: b -> c, Nous pouvons toujours écrire ce qui suit:

h :: Either a b -> c
h (Left a) = f a
h (Right b) = g b

Alors maintenant, nous pouvons voir foldr comme:

foldr :: (Either (a, b) () -> b) -> ([a] -> b)

(Nous sommes toujours libres d'ajouter des parenthèses autour de -> Comme ceci tant qu'elles sont associatives à droite.)

Voyons maintenant ListContainer. Ce type a deux cas: Nil, qui ne contient aucune information et Cons, qui a à la fois un a et un b. Autrement dit, Nil est comme () Et Cons est comme (a, b), Nous pouvons donc écrire:

type ListContainer a rest = Either (a, rest) ()

Clairement, c'est la même chose que ce que j'ai utilisé dans foldr ci-dessus. Nous avons donc maintenant:

foldr :: (Either (a, b) () -> b) -> ([a] -> b)
fold  :: (Either (a, b) () -> b) -> (List a -> b)

Donc, en fait, les types sont isomorphes - juste différentes façons d'écrire la même chose! Je pense que c'est plutôt cool.

(En remarque, si vous voulez en savoir plus sur ce type de raisonnement avec les types, consultez L'algèbre des types de données algébriques , une belle série de billets de blog à ce sujet.)

Retour aux arbres

Nous avons donc vu comment définir un fold générique pour les types écrits en points fixes. Nous avons également vu comment cela correspond directement à foldr pour les listes. Voyons maintenant votre deuxième exemple, l'arbre binaire. Nous avons le type:

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

nous pouvons réécrire ceci en utilisant Fix en suivant les règles que j'ai faites ci-dessus: nous remplaçons la partie récursive par une variable de type:

data TreeContainer a rest = Branch rest rest | Leaf a
type Tree a = Fix (TreeContainer a)

Nous avons maintenant un arbre fold:

fold :: (TreeContainer a b -> b) -> (Tree a -> b)

Votre foldTree d'origine ressemble à ceci:

foldTree :: (b -> b -> b) -> (a -> b) -> Tree a -> b

foldTree accepte deux fonctions; nous allons combiner le en un en premier curry puis en utilisant Either:

foldTree :: (Either (b, b) a -> b) -> (Tree a -> b)

Nous pouvons également voir comment Either (b, b) a est isomorphe à TreeContainer a b. Le conteneur d'arborescence a deux cas: Branch, contenant deux bs et Leaf, contenant un a.

Ces types de plis sont donc isomorphes de la même manière que l'exemple de liste.

Généraliser

Un modèle clair se dessine. Étant donné un type de données récursif normal, nous pouvons systématiquement créer une version non récursive du type, ce qui nous permet d'exprimer le type comme un point fixe d'un foncteur. Cela signifie que nous pouvons mécaniquement proposer des fonctions fold pour tous ces différents types - en fait, nous pourrions probablement automatiser l'ensemble du processus en utilisant GHC Generics ou quelque chose comme ça.

Dans un sens, cela signifie que nous n'avons pas vraiment de fonctions fold différentes pour différents types. Nous avons plutôt une seule fonction fold qui est très polymorphe.

Plus

J'ai tout d'abord bien compris ces idées de un discours donné par Conal Elliott. Cela va plus en détail et parle également de unfold, qui est le double de fold.

Si vous voulez approfondir ce genre de chose encore plus profondément, lisez le fantastique "Programmation fonctionnelle avec des bananes, des lentilles, des enveloppes et des barbelés Fil "papier . Cela introduit entre autres les notions de "catamorphismes" et "anamorphismes" qui correspondent aux plis et aux dépliages.

Algèbres (et Coalgebras)

De plus, je ne peux pas résister à l'ajout d'une prise pour moi: P. Vous pouvez voir quelques similitudes intéressantes entre la façon dont nous utilisons Either ici et la façon dont je l'ai utilisé pour parler de algèbres dans un autre SO answer .

Il existe en fait une connexion profonde entre fold et les algèbres; de plus, unfold-- le dual susmentionné de fold-- est connecté à des houillères, qui sont des dual d'algèbres. L'idée importante est que les types de données algébriques correspondent aux "algèbres initiales" qui définissent également les plis comme indiqué dans le reste de ma réponse.

Vous pouvez voir cette connexion dans le type général de fold:

fold :: Functor f => (f a -> a) -> (Fix f -> a)

Le terme f a -> a Semble très familier! N'oubliez pas qu'une algèbre f a été définie comme quelque chose comme:

class Functor f => Algebra f a where
  op :: f a -> a

On peut donc penser à fold comme simplement:

fold :: Algebra f a => Fix f -> a

Essentiellement, fold nous permet simplement de "résumer" les structures définies à l'aide de l'algèbre.

66
Tikhon Jelvis

Un pli remplace chaque constructeur par une fonction.

Par exemple, foldr cons nil remplace chaque (:) avec cons et [] avec nil:

foldr cons nil ((:) 1 ((:) 2 [])) = cons 1 (cons 2 nil)

Pour un arbre, foldTree branch leaf remplace chaque Branch par branch et chaque Leaf par leaf:

foldTree branch leaf (Branch (Branch (Leaf 1) (Leaf 2)) (Leaf 3))
    = branch (branch (leaf 1) (leaf 2)) (leaf 2)

C'est pourquoi chaque repli accepte des arguments qui ont exactement le même type que les constructeurs:

foldr :: (a -> list -> list) -> list -> [a] -> list

foldTree :: (tree -> tree -> tree) -> (a -> tree) -> Tree a -> tree
37

J'appellerais cela un pli, et déclarerais Tree a Foldable . Voir l'exemple Foldable dans la documentation GHC .

7
Fred Foo