4

given that map is:

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

why R.map(R.toUpper, 'hello') returns ['H', 'E', 'L', 'L', 'O'] and not "HELLO"?

in haskell, for instance, a string is a list of chars, so map toUpper "hello" behaves as expected (HELLO).

Shouldn't Ramda's map be doing the same?


It might be a design choice but I think Ramda's map might be violating the functor law: If we map the id function over a functor, we don't get back the original functor

console.log(
  R.map(R.identity, 'Hello World'),
);
<script src="https://cdnjs.cloudflare.com/ajax/libs/ramda/0.26.1/ramda.js" integrity="sha256-xB25ljGZ7K2VXnq087unEnoVhvTosWWtqXB4tAtZmHU=" crossorigin="anonymous"></script>

Why wouldn't I be expecting map to behave more like:

const map = (fn, string) => string.replace(/./g, fn);


console.log(
  map(R.toUpper, 'hello world'),
);
<script src="https://cdnjs.cloudflare.com/ajax/libs/ramda/0.26.1/ramda.js" integrity="sha256-xB25ljGZ7K2VXnq087unEnoVhvTosWWtqXB4tAtZmHU=" crossorigin="anonymous"></script>
Hitmands
  • 13,491
  • 4
  • 34
  • 69
  • 3
    In JavaScript, a string is not a list of characters. A string is an iterable of characters, but not a list. In Haskell `String` is a type synonym of `[Char]`. – Willem Van Onsem Jul 26 '19 at 14:34
  • well, why is it returning a list of strings then? I would expect the functor law to be respected... `map identity "hello"` should return "hello" – Hitmands Jul 26 '19 at 14:36
  • because afaik, JavaScript sees characters as 1-char strings, just like Python. – Willem Van Onsem Jul 26 '19 at 14:39
  • 3
    I suppose Ramda's `map` is not designed to work on strings at all, and just doesn't throw an error because strings are array-like enough. – Bergi Jul 26 '19 at 14:45
  • it works with iterables such as arrays, objects, strings... and behaves consistently for array and objects, but not for strings... – Hitmands Jul 26 '19 at 14:47
  • 2
    strings aren't functors since they have the wrong kind. If you view strings as an iterable of strings then it appears to be consistent. – Lee Jul 26 '19 at 14:57
  • There is definitely room out there for a library that works on arbitrary iterables, doing many of the same things that Ramda does (see raganwald's work for many examples) but I don't know that Ramda will ever turn into that. – Scott Sauyet Jul 26 '19 at 17:25

1 Answers1

6

As the comments say, Strings are not functors. The functor laws require

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

That is, for a functor holding an item or items of type a and a function from type a to type b, it will return a functor of the same type holding an item or items of type b. A String cannot do that, as it holds only characters. For instance, what would you expect map(_ => 1.234, "hello") to return?

Ramda's behavior on Strings is not intentional. It simply falls out of the implementation, as Bergi suggested. Strings look enough like arrays (with integer length properties and integer-indexed sub-elements) that the code treats them as though they were arrays.

Ramda has always intended to be a low-level library, and the founders weren't particularly interested in writing hand-holding code. It should work as advertised if you supply the types required, but there are few guarantees if you don't. However, if you feel strongly about this, feel free to raise an issue with the Ramda team about it, or even better, raise a pull request with the behavior you'd prefer. It might not get accepted, but it will receive a fair hearing.

Scott Sauyet
  • 49,207
  • 4
  • 49
  • 103
  • This makes sense, we can't think of a string as a functor. That's fair enough... and unintentional map's behaviour on string is why it differs from haskell. – Hitmands Jul 26 '19 at 17:30
  • @hitmands: It would not be unreasonable to do the type-checking, and if the input is a String, and the results of the transformation function are all strings, to `join('')` the results back into one, if you're interested in pursuing this. Again, no guarantees that it would be accepted. – Scott Sauyet Jul 26 '19 at 17:35
  • @ScottSauyet that behavior is not at all reasonable to me. God forbid you optimize `map(f, map(g, s))` to `map(compose(f,g),s)` and you get a phantom concatenation because `f` returns a string (but `g` doesn't). Worse if this is in a polymorphic context where `f` returns strings sometimes but not others. – luqui Jul 27 '19 at 05:09
  • @luqui: Note that this would only happen if the second parameter to `map` was a string. Right now that's formally undefined behavior, and in practice acts as `map(f, str.split(''))`. I'm suggesting that we might add the behavior `map :: (char -> char) -> String -> String`, where `char` is a single-character string. This would match Haskell's behavior, and would be fairly simple to implement (and we could avoid the expensive part of that calculation unless the user supplied a string.) As to your "polymorphic" context, any function like that would already have issues working with Ramda. – Scott Sauyet Jul 27 '19 at 22:01
  • @ScottSauyet, yes, true, the wacky behavior would only occur a tiny fraction of the time. This does not improve the situation... – luqui Jul 28 '19 at 06:37
  • @luqui: I suppose I simply don't see what those cases would be. I've opened up [an issue](https://github.com/ramda/ramda/issues/2870) on the Ramda tracker to discuss this, if anyone is interested – Scott Sauyet Jul 28 '19 at 20:56
  • @ScottSauyet I told you: if you fuse two maps in a row, `map(f,map(g,s))`, and the intermediate type is not char/string, say `g : char -> int`; `f : int -> char`, (imagine, say, mapping to ints and back for rot13) the implicit concatenation disappears and suddenly you are getting a string instead of a list back. Imagine trying to track that down as the cause of a bug! These "cute" type-sensitive conveniences are more nefarious than they appear. – luqui Jul 29 '19 at 08:35
  • @luqui: This is already undefined behavior that happens to fall out of the implementation. It has no guarantee. If you call `map(f, 'somestring')` Ramda might, for all you know, divide by zero, launch the missiles, and steal your girlfriend. If we were to define this behavior, what would we want? We obviously can't handle `char -> a` in any useful way, but we could reasonably handle `char -> char`. The question is whether it would make sense to cover a non-functor in `map`. In Haskell, it's simply a separate function; here it's a harder call. My instinct is still no, but it's interesting. – Scott Sauyet Jul 29 '19 at 11:04
  • @ScottSauyet, Ah, I see what you mean about undefined behavior. So you're saying if the function doesn't happen to always return chars, then it's just an error. That feels more reasonable. I still am uncomfortable with ad hoc overloading (esp in dynamic languages); I would rather it be a method of string or a separate function with a different name. – luqui Jul 29 '19 at 22:18
  • @luqui: Ramda often delegates calls to the data object when we don't supply them. For this reason, Ramda's `map`, which we only implement internally for `Array`, `Object`, and `Function`, will also work with any Fantasy-Land functor, or with your custom type that has a `map` method. That can't happen here, since `map` is not on `String.prototype`, but we could decide to handle it ourselves. I'm inclined not to do so, but I don't like the current behavior, and I'm loathe to throw exceptions for reasons of composibility. So I'm not really happy with any options I can see. – Scott Sauyet Jul 30 '19 at 11:38