简体   繁体   中英

What is practical use of monoids?

I'm reading Learn You a Haskell and I've already covered applicative and now I'm on monoids. I have no problem understanding the both, although I found applicative useful in practice and monoid isn't quite so. So I think I don't understand something about Haskell.

First, speaking of Applicative , it creates something like uniform syntax to perform various actions on 'containers'. So we can use normal functions to perform actions on Maybe , lists, IO (should I have said monads? I don't know monads yet), functions:

λ> :m + Control.Applicative
λ> (+) <$> (Just 10) <*> (Just 13)
Just 23
λ> (+) <$> [1..5] <*> [1..5]
[2,3,4,5,6,3,4,5,6,7,4,5,6,7,8,5,6,7,8,9,6,7,8,9,10]
λ> (++) <$> getLine <*> getLine
one line
 and another one
"one line and another one"
λ> (+) <$> (* 7) <*> (+ 7) $ 10
87

So applicative is an abstraction. I think we can live without it but it helps express some ideas mode clearly and that's fine.

Now, let's take a look at Monoid . It is also abstraction and pretty simple one. But does it help us? For every example from the book it seems to be obvious that there is more clear way to do things:

λ> :m + Data.Monoid
λ> mempty :: [a]
[]
λ> [1..3] `mappend` [4..6]
[1,2,3,4,5,6]
λ> [1..3] ++ [4..6]
[1,2,3,4,5,6]
λ> mconcat [[1,2],[3,6],[9]]
[1,2,3,6,9]
λ> concat [[1,2],[3,6],[9]]
[1,2,3,6,9]
λ> getProduct $ Product 3 `mappend` Product 9
27
λ> 3 * 9
27
λ> getProduct $ Product 3 `mappend` Product 4 `mappend` Product 2
24
λ> product [3,4,2]
24
λ> getSum . mconcat . map Sum $ [1,2,3]
6
λ> sum [1..3]
6
λ> getAny . mconcat . map Any $ [False, False, False, True]
True
λ> or [False, False, False, True]
True
λ> getAll . mconcat . map All $ [True, True, True]
True
λ> and [True, True, True]
True

So we have noticed some patterns and created new type class... Fine, I like math. But from practical point of view, what the point of Monoid ? How does it help us better express ideas?

Gabriel Gonzalez wrote in his blog great information about why you should care, and you truly should care. You can read it here (and also see this ).

It's about scalability, architecture & design of API. The idea is that there's the "Conventional architecture" that says:

Combine a several components together of type A to generate a "network" or "topology" of type B

The issue with this kind of design is that as your program scales, so does your hell when you refactor.

So you want to change module A to improve your design or domain, so you do. Oh, but now module B & C that depend on A broke. You fix B, great. Now you fix C. Now B broke again, as B also used some of C's functionality. And I can go on with this forever, and if you ever used OOP - so can you.

Then there's what Gabriel calls the "Haskell architecture":

Combine several components together of type A to generate a new component of the same type A, indistinguishable in character from its substituent parts

This solves the issue, elegantly too. Basically: do not layer your modules or extend to make specialized ones.
Instead, combine.

So now, what's encouraged is that instead of saying things like "I have multiple X, so let's make a type to represent their union", you say "I have multiple X, so let's combine them into an X". Or in simple English: "Let's make composable types in the very first place." (do you sense the monoids' lurking yet?).

Imagine you want to make a form for your webpage or application, and you have the module "Personal Information Form" that you created because you needed personal information. Later you found that you also need "Change Picture Form" so quickly wrote that. And now you say I want to combine them, so let's make a "Personal Information & Picture Form" module. And in real life scalable applications this can and does get out of hand. Probably not with forms but to demonstrate, you need to compose and compose so you will end up with "Personal Information & Change Picture & Change Password & Change Status & Manage Friends & Manage Wishlist & Change View Settings & Please don't extend me anymore & please & please stop! & STOP!!!!" module. This is not pretty, and you will have to manage this complexity in the API. Oh, and if you want change anything - it probably has dependencies. So.. yeah.. Welcome to hell.

Now let's look at the other option, but first let's look at the benefit because it will guide us to it:

These abstractions scale limitlessly because they always preserve combinability, therefore we never need to layer further abstractions on top. This is one reason why you should learn Haskell: you learn how to build flat architectures.

Sounds good, so, instead of making "Personal Information Form" / "Change Picture Form" module, stop and think if we can make anything here composable. Well, we can just make a "Form", right? would be more abstract too.
Then it can make sense to construct one for everything you want, combine them together and get one form just like any other.

And so, you don't get a messy complex tree anymore, because of the key that you take two forms and get one form. So Form -> Form -> Form . And as you can already see clearly, this signature is an instance of mappend .

The alternative, and the conventional architecture would probably look like a -> b -> c and then c -> d -> e and then...

Now, with forms it's not so challenging; the challenge is to work with this in real world applications. And to do that simply ask yourself as much as you can (because it pays off, as you can see): How can I make this concept composable? and since monoids are such a simple way to achieve that (we want simple) ask yourself first: How is this concept a monoid?

Sidenote: Thankfully Haskell will very much discourage you to extend types as it is a functional language (no inheritance). But it's still possible to make a type for something, another type for something, and in the third type to have both types as fields. If this is for composition - see if you can avoid it.

Fine, I like math. But from practical point of view, what the point of Monoid? How does it help us better express ideas?

It's an API. A simple one. For types that support:

  • having a zero element
  • having an append operation

Lots of types support these operations. So having a name for the operations and an API helps us capture the fact more clearly.

APIs are good because they let us reuse code, and reuse concepts. Meaning better, more maintainable code.

The point to it is that when you tag an Int as Product , you express your intent for the integers to be multiplied. And by tagging them as Sum , to be added together.

Then you can use the same mconcat on both. This is used eg in Foldable where one foldMap expresses the idea of folding over a containing structure, while combining the elements in a specific monoid's kind of way.

A very simple example is foldMap . Just by plugging in different monoids into this single function, you can compute:

  • the first and the last element,
  • the sum or the product of elements (from this also their average etc.),
  • check if all elements or any has a given property,
  • compute the maximal or minimal element,
  • map the elements to a collection (like lists, sets , strings, Text , ByteString or ByteString Builder ) and concatenate them together - they're all monoids.

Moreover, monoids are composable: if a and b are monoids, so is (a, b) . So you can easily compute several different monoidal values in one pass (like the sum and the product when computing the average of elements etc).

And although you can do all this without monoids, using foldr or foldl , it's much more cumbersome and also often less effective: for example, if you have a balanced binary tree and you want to find its minimum and maximum element, you can't do both effectively with foldr (or both with foldl ), one will be always O(n) for one of the cases, while when using foldMap with appropriate monoids, it'll be O(log n) in both cases.

And this was all just a single function foldMap ! There are many other interesting applications. To give one, exponentiation by squaring is an efficient way for computing powers. But it's not actually tied to computing powers. You can implement it for any monoid, and if its <> is O(1) , you have an efficient way of computing n -times x <> ... <> x . And suddenly you can do efficient matrix exponentiation and compute n -th Fibonacci number with just O(log n) multipications. See times1p in semigroup .

See also Monoids and Finger Trees .

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM