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é.
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:
Foldable
, que Larsmans évoque.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:
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 RankNTypes
— lisez 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.
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.
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
.
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 ListContainer
s à 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
.
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:
Roll
pour obtenir un Cons
ou un Nil
fmap
. Nil
, fmap (fold h) Nil = Nil
, nous renvoyons donc simplement Nil
.Cons
, le fmap
continue simplement le pli sur le reste de la liste.fold
se terminant par un Nil
-- tout comme le standard foldr
.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
Où _
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.)
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 b
s et Leaf
, contenant un a
.
Ces types de plis sont donc isomorphes de la même manière que l'exemple de liste.
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.
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.
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.
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
J'appellerais cela un pli, et déclarerais Tree
a Foldable
. Voir l'exemple Foldable
dans la documentation GHC .