简体   繁体   中英

These concepts about Functors (Maps) and Monads (Either, Maybe, Bind, Then) are right?

I'm relatively new studying functional programming and things were going well until I had to treat errors and promises. Trying to do it in the “right” way I get a lot of references for Monads as to be the better solution for it, but while studying it I ended up with what I honestly would call a "reference hell", where there was a lot of references and sub-references either mathematical or in programming for the same thing where this things would have different name for the same concepts, which was really confusing. So after persisting in the subject, I'm now I trying to summarize and clarify it, and this is what I get so far:

For the sake of the understanding I'll oversimplify it.

Monoids: are anything that concatenate/sum two things returning a thing of the same group so in JS any math addition or just concatenation from a string are Monoids for definition as well the composition of functions.

Maps: Maps are just methods that apply a function to each element of a group, without changing the category of the group itself or its length.

Functors: Functors are just objects that have a Map method which return the Functor itself.

Monads: Monads are Functors that use FlatMaps.

FlatMaps: FlatMaps are maps that have the capability of treat promises/fetch or summarize the received value.

Either, Maybe, Bind, Then: are all FlatMaps but with different names depending of context you use it.

(I think they all are FlatMaps for definition but that there is difference in the way they are used, since there is library like Monets.js that have both a Maybe and a Either function, but I don't get the use case difference).

So my question is: are these concepts right?

if anyone can reassure me what so far I got right and correct what I got wrong, or even expand on what I have missed, I would be very grateful.

Thanks to anyone who take the time.

//=============================================================//

EDIT: I should've emphasized more in this post, but this affirmations and simplified definitions are only from the "practical perspective in JavaScript" (I'm aware of the impossibility of made such a small simplification of a huge and complexe theme like these especially if you add another field like mathematics).

//=============================================================//

Monoids: are anything that concatenate/sum two things returning a thing of the same group...

First thing that I don't like here is the word “group”. I know, you're just trying to use simple language and all, but the problem is that group has a very specific mathematical meaning, and we can't just ignore this because groups and monoids are very closely related. (A group is basically a monoid with inverse elements.) So definitely don't use this word in any definition of monoids, however informal. You could say “underlying set” there, but I'd just say type . That may not match the semantics in all programming languages, but certainly in Haskell.

So,

Monoids: are anything that concatenate/sum two things returning a thing of the same type so in JS any math addition or just concatenation from a string are Monoids for definition as well the composition of functions.

Ok. Specifically, concatenation of endofunctions is a monoid. In JavaScript, all functions are in a sense endofunctions, so you can get away with this.

But that's actually describing only a semigroup , not a monoid. (See, there's out groups ... confusingly, monoids are in between semigroups and groups.) A monoid is a semigroup that has also a unit element , which can be concatenated to any other element without making a difference.

  • For the addition-monoid, this is the number zero.
  • For the string-monoid, it is the empty string.
  • For the function monoid, it is the identity function.

Even with unit elements, your characterization is missing the crucial feature of a semigroup/monoid: the concatenation operation must be associative . Associativity is a strangely un-intuitive property, perhaps because in most examples it seems stupidly obvious. But it's actually crucial for much of the maths that is built on those definitions.

To make the importance of associativity clear, it helps to look at some things that are not semigroups because they aren't associative. The type of integers with subtraction is such an example. The result is that you need to watch out where to put your parentheses in maths expressions, to avoid incurring sign errors. Whereas strings can just be concatenated in either order – ("Hello"+", ")+"World" is the same as "Hello"+(", "+"World") .

Maps: Maps are just methods that apply a function to each element of a group, without changing the category of the group itself or its length.

Here we have the next badly chosen word: categories are again a specific maths thing that's very closely related to all we're talking about here, so please don't use the term with any other meaning.

IMO your definitions of “maps” and functors are unnecessary. Just define functors, using the already known concept of functions.

But before we can do that –

Functors: Functors are just objects...

here we go again, with the conflict between mathematical terminology and natural language. Mathematically, objects are the things that live in a category. Functors are not objects per se (although you can construct a specific category in which they are, by construction, objects). And also, itself already conflicting: in programming, “object” usually means “value with associated methods”, most often realized via a class.

Your usage of the terms seems to match neither of these established meanings, so I suggest you avoid it.

Mathematically, a functor is a mapping between two categories. That's hardly intuitive, but if you consider the category as a collection of types then a functor simply maps types to types. For example, the list functor maps some type (say, the type of integers) to the type of lists containing values of that type (the type of lists of integers).

Here of course we're running a bit into trouble when considering it all with respect to JS. In dynamic languages, you can easily have lists containing elements of multiple different types. But it's actually ok if we just treat the language as having only one big type that all values are members of. The list functor in Python maps the universal type to itself.

Blabla theory, what's the point of this all? The actual feature of a functor is not the type-mapping, but instead that it lifts a function on the contained values (ie on the values of the type you started with, in my example integers) to a function on the container-values (on lists of integers). More generally, the functor F lifts a function a -> b to a function F(a) -> F(b) , for any types a and b . What you called “category of the group itself” means that you really are mapping lists to lists. Even in a dynamically typed language, the list functor's map method won't take a list and produce a dictionary as the result.

I suggest a different understandable-definition:

Functors: Functors wrap types as container-types, which have a mapping method that applies functions on contained values to functions on the whole container.

What you said about length is true of the list functor in particular, but it doesn't really make sense for functors in general. In Haskell we often talk about the fact that functor mapping preserves the “shape” of the container, but that too isn't actually part of the mathematical definition.

What is part of the definition is that a functor should be compatible with composition of the functions. This boils down to being able to map as often as you like. You can always map the identity function without changing the structure, and if you map two functions separately it has the same effect as mapping their composition in one go. It's kind of intuitive that this amounts to the mapping being “shape-preserving”.

Monads: Monads are Functors that use FlatMaps.

Fair enough, but of course this is just shifting everything to: what's a FlatMap?

Mathematically it's actually easier to not consider the FlatMap / >>= operation at first, but just consider the flatte ning operation, as well as the singleton injector. Going by example: the list monad is the list functor, equipped with

  • The operation that creates a list of just a plain contained value. (This is analogous to the unit value of a monoid.)
  • The operation that takes a nested list and flattens it out to a plain list, by gathering all the values in each of the inner lists. (This is analogous to the sum operation in a monoid.)

Again, it is important that these operations obey laws . These are also analogous to the monoid laws, but unfortunately even less intuitive because their simultaneously hard to think about and yet again so almost-trivial that they can seem a bit useless. But specifically the associativity law for lists can be phrased quite nicely:

Flattening the inner lists in a doubly nested list and then flattening the outer ones has the same effect as first flattening the outer ones and then the inner ones.

[[[1,2,3],[4,5]],[[6],[7,8,9]]] ⟼ [[1,2,3,4,5],[6,7,8,9]] ⟼ [1,2,3,4,5,6,7,8,9]
[[[1,2,3],[4,5]],[[6],[7,8,9]]]⟼[[1,2,3],[4,5],[6],[7,8,9]]⟼[1,2,3,4,5,6,7,8,9]

A Monoid is a set and an operator, such that:

  1. The operator is associative for that set
  2. The operator has an identity within that set

So, addition is associative for the set of real numbers, and in the set of real numbers has the identity zero.

a+(b+c) = (a+b)+c  -- associative
a+0 = a            -- identity

A Map is a transformation between two sets. For every element with the first set, there is a matching element in the second set. As an example, the transformation could be 'take a number and double it'.

The transformation is called a Functor. If the set is mapped back to itself, it is called an Endofunctor.

If an operator and a set is a Monoid, and also can be considered an Endofunctor, then we call that a Monad.

Monoids, Functors, Endofunctor, and Monads are not a thing but rather the property of a thing, that the operator and set has these properties. Can we declare this in Haskell by creating instances in the appropriate Monoid, Functor and Monad type-classes.

A FlatMap is a Map combined with a flattening operator. I can declare a map to be from a list to a list of lists. For a Monad we want to go from a list to a list, and so we flatten the list at the end to make it so.

To be blunt, I think all of your definitions are pretty terrible, except maybe the "monoid" one.

Here's another way of thinking about these concepts. It's not "practical" in the sense that it will tell you exactly why a flatmap over the list monad should flatten nested lists, but I think it's "practical" in the sense that it should tell you why we care about programming with monads in the first place, and what monads in general are supposed to accomplish from a practical perspective within functional programs, whether they are written in JavaScript or Haskell or whatever.

In functional programming, we write functions that take certain types as input and produce certain types as output, and we build programs by composing functions whose input and output types match. This is an elegant approach that results in beautiful programs, and it's one of the main reasons functional programmers like functional programming.

Functors provide a way to systematically transform types in a way that adds useful functionality to the original types. For example, we can use a functor to add functionality to a "normal type" that allows it to be missing or absent or "null" ( Maybe ) or represent either a successfully computed result or an error condition ( Either ) or that allows it to represent multiple possible values instead of only one (list) or that allows it to be computed at some time in the future (promise) or that requires a context for evaluation ( Reader ), or that allows a combination of these things.

A map allows us to reuse the functions we've defined for normal types on these new types that have been transformed by a functor, in some natural way. If we already have a function that doubles an integer, we can re-use that function on various functor transformations of integers, like doubling an integer that might be missing (mapping over a Maybe ) or doubling an integer that hasn't been computed yet (mapping over a promise) or doubling every element of a list (mapping over a list).

A monad involves applying the functor concept to the output types of functions to produce "operations" that have additional functionality. With monad-less functional programming, we write functions that take "normal types" of inputs and produce "normal types" of outputs, but monads allow us to take "normal types" of inputs and produce transformed types of outputs, like the ones above. Such a monadic operation can represent a function that takes an input and Maybe produces an output, or one that takes an input and promises to produce an output later, or that takes an input and produces a list of outputs.

A flatmap generalizes the composition of functions on normal types (ie, the way we build monad-less functional programs) to composition of monadic operations, appropriately "chaining" or combining the extra functionality provided by the transformed output types of the monadic operations. So, flatmaps over the maybe monad will compose functions as long as they keep producing outputs and give up when one of those functions has a missing output; flatmaps over the promise monad will turn a chain of operations that each take an input and promise an output into a single composed operation that takes an input and promises a final output; flatmaps over the list monad will turn a chain of operations that each take a single input and produce multiple outputs into a single composed operation that takes an input and produces multiple outputs.

Note that these concepts are useful because of their convenience and the systematic approach they take, not because they add magic functionality to functional programs that we wouldn't otherwise have. Of course we don't need a functor to create a list data type, and we don't need a monad to write a function that takes a single input and produces a list of outputs. It just ends up being useful thinking in terms of "operations that take an input and promise to produce either an error message or a list of outputs", compose 50 of those operations together, and end up with a single composed operation that takes an input and promises either an error message or a list of outputs (without requiring deeply nested lists of nested promises to be manually resolved -- hence the value of "flattening").

(In practical programming terms, monoids don't have that much to do with the rest of these, except to make hilarious in-jokes about the category of endofunctors. Monoids are just a systematic way of combining or "reducing" a bunch of values of a particular type into a single value of that type, in a manner that doesn't depend on which values are combined first or last.)

In a nutshell, functors and their maps allow us to add functionality to our types, while monads and their flatmaps provide a mechanism to use functors while retaining some semblance of the elegance of simple functional composition that makes functional programming so enjoyable in the first place.

An example might help. Consider the problem of performing a depth-first traversal of a file tree. In some sense, this is a simple recursive composition of functions. To generate a filetree() rooted at pathname , we need to call a function on the pathname to fetch its children() , and then we need to recursively call filetree() on those children() . In pseudo-JavaScript:

// to generate a filetree rooted at a pathname...
function filetree(pathname) {
    // we need to get the children and generate filetrees rooted at their pathnames
    filetree(children(pathname))
}

Obviously, though, this won't work as real code. For one thing, the types don't match. The filetree function should be called on a single pathname, but children(pathname) will return multiple pathnames. There are also some additional problems -- it's unclear how the recursion is supposed to stop, and there's also the issue that the original pathname appears to get lost in the shuffle as we jump right to its children and their filetrees. Plus, if we're trying to integrate this into an existing Node application with a promise-based architecture, it's unclear how this version of filetree could support the promise-based filesystem API.

But, what if there was a way to add functionality to the types involved while maintaining the elegance of this simple composition? For example, what if we had a functor that allowed us to promise to return multiple values (eg, multiple child pathnames) while logging strings (eg, parent pathnames) as a side effect of the processing?

Such a functor would, as I've said above, be a transformation of types. That means that it would transform a "normal" type, like a "integer", into a "promise for a list of integers together with a log of strings". Suppose we implement this as an object containing a single promise:

function M(promise) {
    this.promise = promise
}

which when resolved will yield an object of form:

{
  "data": [1,2,3,4]  // a list of integers
  "log": ["strings","that","have","been","logged"]
}

As a functor, M would have the following map function:

M.prototype = {
    map: function(f) {
        return this.promise.then((obj) => ({
            data: obj.data.map(f),
            log: obj.log
        }))
    }
}

which would apply a plain function to the promised data (without affecting the log).

More importantly, as a monad, M would have the following flatMap function:

M.prototype = {
    ...
    flatMap: function(f) {
        // when the promised data is ready
        return new M(this.promise.then(function(obj) {
            // map the function f across the data, generating promises
            var promises = obj.data.map((d) => f(d).promise)
            // wait on all promises
            return Promise.all(promises).then((results) => ({
                // flatten all the outputs
                data: results.flatMap((result) => result.data),
                // add to the existing log
                log: obj.log.concat(results.flatMap((result) => result.log))
            }))
        }))
    }
}

I won't explain in detail, but the idea is that if I have two monadic operations in the M monad, that take a "plain" input and produce an M -transformed output, representing a promise to provide a list of values together with a log, I can use the flatMap method on the output of the first operation to compose it with the second operation, yielding a composite operation that takes a single "plain" input and produces an M -transformed output.

By defining children as a monadic operation in the M monad that promises to take a parent pathname, write it to the log, and produce a list of the children of this pathname as its output data:

function children(parent) {
    return new M(fsPromises.lstat(parent)
                 .then((stat) => stat.isDirectory() ? fsPromises.readdir(parent) : [])
                 .then((names) => ({
                     data: names.map((x) => path.join(parent, x)),
                     log: [parent]
                 })))
}

I can write the recursive filetree function almost as elegantly as the original above, as a flatMap -assisted composition of the children and recursively invoked filetree functions:

function filetree(pathname) {
    return children(pathname).flatMap(filetree)
}

In order to use filetree , I need to "run" it to extract the log and, say, print it to the console.

// recursively list files starting at current directory
filetree(".").promise.then((x) => console.log(x.log))

The full code is below. Admittedly, there's a fair bit of it, and some of it is pretty complicated, so the elegance of the filetree function appears to have come at a fairly big cost, as we've apparently just moved all the complexity (and them some) into the M monad. However, the M monad is a general tool, not specific to performing depth-first traversals of file trees. Also, in an ideal world, a sophisticated JavaScript monad library would allow you to build the M monad from monadic pieces (promise, list, and log) with a couple lines of code.

var path = require('path')
var fsPromises = require('fs').promises

function M(promise) {
    this.promise = promise
}
M.prototype = {
    map: function(f) {
        return this.promise.then((obj) => ({
            data: obj.data.map(f),
            log: obj.log
        }))
    },
    flatMap: function(f) {
        // when the promised data is ready
        return new M(this.promise.then(function(obj) {
            // map the function f across the data, generating promises
            var promises = obj.data.map((d) => f(d).promise)
            // wait on all promises
            return Promise.all(promises).then((results) => ({
                // flatten all the outputs
                data: results.flatMap((result) => result.data),
                // add to the existing log
                log: obj.log.concat(results.flatMap((result) => result.log))
            }))
        }))
    }
}
// not used in this example, but this embeds a single value of a "normal" type into the M monad
M.of = (x) => new M(Promise.resolve({ data: [x], log: [] }))

function filetree(pathname) {
    return children(pathname).flatMap(filetree)
}
function children(parent) {
    return new M(fsPromises.lstat(parent)
                 .then((stat) => stat.isDirectory() ? fsPromises.readdir(parent) : [])
                 .then((names) => ({
                     data: names.map((x) => path.join(parent, x)),
                     log: [parent]
                 })))
}

// recursively list files starting at current directory
filetree(".").promise.then((x) => console.log(x.log))

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