Where can I find an explanation/summary of symbols used to explain functional programming, specifically Ramda.js?

前端 未结 3 581
走了就别回头了
走了就别回头了 2020-12-10 16:24

The API documentation for the JavaScript functional programming library Ramda.js contains symbolic abbreviations but does not provide a legend for understanding these. Is th

3条回答
  •  天涯浪人
    2020-12-10 17:16

    From the Ramda Wiki:

    (Part 2 / 2 -- too long for a single SO answer!)


    Type Constraints

    Sometimes we want to restrict the generic types we can use in a signature in some way or another. We might want a maximum function that can operate on Numbers, on Strings, on Dates, but not on arbitrary Objects. We want to describe ordered types, ones for which a < b will always return a meaningful result. We discuss details of the type Ord in the Types section; for our purposes, its sufficient to say that it is meant to capture those types which have some ordering operation that works with <.

    // maximum :: Ord a => [a] -> a
    const maximum = vals => reduce((curr, next) => next > curr ? next : curr,
        head(vals), tail(vals))
    maximum([3, 1, 4, 1]); //=> 4
    maximum(['foo', 'bar', 'baz', 'qux', 'quux']); //=> 'qux'
    maximum([new Date('1867-07-01'), new Date('1810-09-16'),
             new Date('1776-07-04')]); //=> new Date("1867-07-01")
    

    This description [^maximum-note] adds a constraint section at the beginning, separated from the rest by a right double arrow ("=>" in code, sometimes "" in other documentation.) Ord a ⇒ [a] → a says that maximum takes a collection of elements of some type, but that type must adhere to Ord.

    In the dynamically-typed Javascript, there is no simple way to enforce this type constraint without adding type-checking to every parameter, and even every value of each list.[^strong-types] But that's true of our type signatures in general. When we require [a] in a signature, there's no way to guarantee that the user will not pass us [1, 2, 'a', false, undefined, [42, 43], {foo: bar}, new Date, null]. So our entire type annotation is descriptive and aspirational rather than compiler-enforced, as it would be in, say, Haskell.

    The most common type-constraints on Ramda functions are those specified by the Javascript FantasyLand specification.

    When we discussed a map function earlier, we talked only about mapping a function over a list of values. But the idea of mapping is more general than that. It can be used to describe the application of a function to any data structure holding some number of values of a certain type, if it returns another structure of the same shape with new values in it. We might map over a Tree, a Dictionary, a plain Wrapper that holds only a single value, or many other types.

    The notion of something that can be mapped over is captured by an algebraic type that other languages and FantasyLand borrow from abstract mathematics, known as Functor. A Functor is simply a type that contains a map method subject to some simple laws. Ramda's map function will call the map method on our type, assuming that if we didn't pass a list (or other type known to Ramda) but did pass something with map on it, we expect it to act like a Functor.

    To describe this in a signature, we add a constraints section to the signature block:

    // map :: Functor f => (a -> b) -> f a -> f b
    

    Note that the constraint block does not have to have just one constraint on it. We can have multiple constraints, separated by commas and wrapped in parentheses. So this could be the signature for some odd function:

    // weirdFunc :: (Functor f, Monoid b, Ord b) => (a -> b) -> f a -> f b
    

    Without dwelling on what it does or how it uses Monoid or Ord, we at least can see what sorts of types need to be supplied for this function to operate correctly.

    [^maximum-note]: There is a problem with this maximum function; it will fail on an empty list. Trying to fix that problem would take us too far afield.

    [^strong-types]: There are some very good tools that address this shortcoming of Javascript, including in-language techniques such as Ramda's sister project, Sanctuary, extensions of Javascript to be more strongly typed, such as flow and TypeScript, and more strongly-typed languages that compile to Javascript such as ClojureScript, Elm, and PureScript.

    Multiple Signatures

    Sometimes rather than trying to find the most generic version of a signature, it's more straightforward to list several related signatures separately. These are included in Ramda source code as two separate JSDoc tags, and end up as two distinct lines in the documentation. This is how we might write one in our own code:

    // getIndex :: a -> [a] -> Number
    //          :: String -> String -> Number
    const getIndex = curry((needle, haystack) => haystack.indexOf(needle));
    getIndex('ba', 'foobar'); //=> 3
    getIndex(42,  [7, 14, 21, 28, 35, 42, 49]); //=> 5
    

    And obviously we could do more than two signatures if we chose. But do note that this should not be too common. The goal is to write signatures generic enough to capture our usage, without being so abstracted that they actually obscure the usage of the function. If we can do so with a single signature, we probably should. If it takes two, then so be it. But if we have a long list of signatures, then we're probably missing a common abstraction.

    Ramda Miscellany

    Variadic Functions

    There are several issues involved in porting this style signature from Haskell to Javascript. The Ramda team has solved them on an ad hoc basis, and these solutions are still subject to change.

    In Haskell, all functions have a fixed arity. But Javsacript has to deal with variadic functions. Ramda's flip function is a good example. It's a simple concept: accept any function and return a new function which swaps the order of the first two parameters.

    // flip :: (a -> b -> ... -> z) -> (b -> a -> ... -> z)
    const flip = fn => function(b, a) {
      return fn.apply(this, [a, b].concat([].slice.call(arguments, 2))); 
    }; 
    flip((x, y, z) => x + y + z)('a', 'b', 'c'); //=> 'bac'
    

    This[^flip-example] show how we deal with the possibility of variadic functions or functions of fixed-but-unknown arity: we simply use ellipses ("..." in source, "``" in output docs) to show that there are some uncounted number of parameters missing in that signature. Ramda has removed almost all variadic functions from its own code-base, but this is how it deals with external functions that it interacts with whose signatures we don't know.

    [^flip-example]: This is not Ramda's actual code, which trades a little simplicity for significant performance gains.

    Any / * Type

    We're hoping to change this soon, but Ramda's type signatures often include an asterisk (*) or the Any synthetic type. This was simply a way to report that although there was a parameter or return here, we could infer nothing about its actual type. We've come to the realization that there is only one place where this still makes sense, which is when we have a list of elements whose types could vary. At that point, we should probably report [Any]. All other uses of an arbitrary type can probably be replaced with a generic type name such as a or b. This change might happen at any time.

    Simple Objects

    There are several ways we could choose to represent plain Javascript objects. Clearly we could just say Object, but there are times when something else seems to be called for. When an object is used as a dictionary of like-typed values (as opposed to its other role as a Record), then the types of the keys and the values can become relevant. In some signatures Ramda uses "{k: v}" to represent this sort of object.

    // keys :: {k: v} -> [k]
    // values :: {k: v} -> [v]
    // ...
    keys({a: 86, b: 75, c: 309}); //=> ['a', 'b', 'c']
    values({a: 86, b: 75, c: 309}); //=> [86, 75, 309]
    

    And, as always, these can be used as the results of a function call instead:

    // makeObj :: [k,v]] -> {k: v}
    const makeObj = reduce((obj, pair) => assoc(pair[0], pair[1], obj), {});
    makeObj([['x', 10], ['y', 20]]); //=> {"x": 10, "y": 20}
    makeObj([['a', true], ['b', true], ['c', false]]);
    //=> {a: true, b: true, c: false}
    

    Records

    Although this is probably not all that relevant to Ramda itself, it's sometimes useful to be able to distinguish Javascript objects used as records, as opposed to those used as dictionaries. Dictionaries are simpler, and the {k: v} description above can be made more specific as needed, with {k: Number} or {k: Rectangle}, or even if we need it, with {String: Number} and so forth. Records we can handle similarly if we choose:

    // display :: {name: String, age: Number} -> (String -> Number -> String) -> String
    const display = curry((person, formatter) => 
                          formatter(person.name, person.age));
    const formatter = (name, age) => name + ', who is ' + age + ' years old.';
    display({name: 'Fred', age: 25, occupation: 'crane operator'}, formatter);
    //=>  "Fred, who is 25 years old."
    

    Record notation looks much like Object literals, with the values for fields replaced by their types. We only account for the field names that are somehow relevant to us. (In the example above, even though our data had an 'occupation' field, it's not in our signature, as it cannot be used directly.

    Complex Example: over

    So at this point, we should have enough information to understand the signature of the over function:

    Lens s a -> (a -> a) -> s -> s
    Lens s a = Functor f => (a -> f a) -> s -> f s
    

    We start with the type alias, Lens s a = Functor f ⇒ (a → f a) → s → f s. This tells us that the type Lens is parameterized by two generic variables, s, and a. We know that there is a constraint on the type of the f variable used in a Lens: it must be a Functor. With that in mind, we see that a Lens is a curried function of two parameters, the first being a function from a value of the generic type a to one of the parameterized type f a, and the second being a value of generic type s. The result is a value of the parameterized type f s. But what does it do? We don't know. We can't know. Our type signatures tell us a great deal about a function, but they don't answer questions about what a function actually does. We can assume that somewhere the map method of f a must be called, since that is the only function defined by the type Functor, but we don't know how or why that map is called. Still, we know that a Lens is a function as described, and we can use that to guide our understanding of over.

    The function over is described as a curried function of three parameters, a Lens a s as just analyzed, a function from the generic type a to that same type, and a value of the generic type s. The whole thing returns a value of type s.

    We could dig a bit deeper and perhaps make some further deductions about what over must do with the types it receives. There is significant research on the so-called free theorems demonstrating invariants derivable just from type signatures. But this document is already far too long. If you're interested, please see the further reading.

    But Why?

    So now we know how to read and write these signatures. Why would we want to, and why are functional programmers so enamored of them?

    There are several good reasons. First of all, once we become used to them, we can gain a lot of insight about a function from a single line of metadata, without the distraction of names. Names sound like a good idea until you realize the names chosen by someone else are not the name you would choose. Above we discussed the functions called "maximum" and "makeObj". Is it helpful or confusing to know that in Ramda, the equivalent functions are called "max" and "fromPairs"? It's significantly worse with parameter names. And of course there are often language barriers to consider as well. Even if English has become the lingua franca of the Web, there are people who will not understand our beautifully written, elegant prose about these functions. But none of this matters with the signatures; they express concisely everything important about a function except for what it actually does.

    But more important than this is the fact that these signatures make it extremely easy to think about our functions and how they combine. If we were given this function:

    foo :: Object -> Number
    

    and map, which we've already seen looks like

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

    then we can immediately derive the type of the function map(foo) by noting that if we substitute Object for a and Number for b, we satisfy the signature of the first parameter to map, and hence by currying we will be left with the remainder:

    map(foo) :: [Object] -> [Number]
    

    This makes working with functions a bit like the proverbial "Insert Tab A into Slot A" instruction. We can recognize just by the shapes of our functions exactly how they can be plugged together to build larger functions. Being able to do this is one of the key features of functional programming. The type signatures make it much easier to do so.

    Further Reading

    • Chapter 7 of Professor Frisby's Mostly Adequate Guide to Functional Programming also goes into depth on these signatures, with a somewhat different emphasis. Moreover, the entire book is well-worth a read.
    • Daniel Spiewak's article What is Hindley-Milner? (and why is it cool?) does a nice job of explaining for the lay person a bit about the sort of type system that underlies these signatures.
    • A StackOverflow answer by Norman Ramsey covers the same ground as Daniel Spiewak, but does so admirably briefly.
    • Philip Wadler's seminal paper, Theorems for Free describes how we can learn a lot more than seems obvious about a function just from its type signature.

提交回复
热议问题