adobe / ferrum

Features from the rust language in javascript: Provides Traits/Type classes & a hashing infrastructure and an advanced library for working with sequences/iterators in js
https://www.ferrumjs.org
Apache License 2.0
519 stars 25 forks source link

`map` for non-sequences #169

Open ptpaterson opened 3 years ago

ptpaterson commented 3 years ago

Overview

This looks awesome, and makes me want to bring out the whole FP toolkit to use along with ferrum. I'm looking for thoughts and/or best practices for using ferrum with non-sequence objects, too. I.e. more general functors and monads.

Details

The README and project layout make it abundantly clear that the focus is on improve working with iterators. Also, the introduction video makes jokes (which I thoroughly enjoy) when the word "monad" is brought up that no one should have to worry about that means. Still, I am curious what thoughts there are to supporting the more mathematical basis for some of these traits, or at least using this library with other sources that do assume that.

For example, if I start using ferrum, it's only a matter of time before I will want to use some version of type classes for things like Option/Maybe, Either, Result, etc. All of these things should have a map operation. map is hardcoded as applying a function to a sequence, so it cannot be overridden (overloaded? anyway...) with the implementation for non-sequence functors.

I think what I would do is start making a separate local lib with a Functor Trait and define an fmap function for it as the basis for other things.

That said, I think that the Sequence trait could be refactors to be based on a Functor trait. I honestly cannot foresee the fallout of that, but I know it would at least make this library significantly more complex. Not least of all because one can go nuts and add in BiFunctor and Applicative and.... ooh boy then we have all of Haskell now wrapped up in this bloated library... But seriously, I would just as quickly be looking for fmap_left and chain functions as anything else... 🤓 😋

Bonus points for any concrete examples of tying ferrum in with any other fp libraries.

Cheers! And thank you!

koraa commented 3 years ago

I would love fmap for js; I would love a proper algebraic data types setup for js even more!

As you have noticed, another good name for ferrum might be haskelllish-js! Besides the name being to cumbersome and the obvious PR advantages, there is another reason why it's not called that: Ferrums soundness goals. This is really one of the main features from rust ferrum tries to introduce: Every feature should be hard to abuse and should be relatively edge case free. You can see this in functions like type() or typename() which explicitly deal with null. Or in trySomething styled function which take a default parameter, making you choose which value to return instead of just returning null or undefined (enabling the None = Symbol(); v = trySomething(param, None); if (v === None) … error handling idiom.

There was a fairly decent attempt utilizing coroutines to introduce something akin to the do … notation from haskell: https://medium.com/flock-community/monads-simplified-with-generators-in-typescript-part-1-33486bf9d887 The approach works, but suffers from soundness issues which I outlined here: https://gist.github.com/koraa/78ee4620a5842ede4c62dd65708326c7

I also have reservations to just introducing fmap; it's existence has to be explained to users (why not unify with map() – this is a valid concern in haskell too where the difference between map and fmap is historic as it is here), it also wouldn't be very complete (you got .then but what about .catch? another trait for that?). Should map() try conversion to iterator first or use fmap() directly? Maybe would represent yet a fourth way of representing null-results (besides the null, undefined and the None-idiom). JS devs would as: "But what is wrong with null". The answer is a lot, but it is hard to explain and confusing to integrate with null/undef. Why introduce Either? JS is duck typed? I see the appeal, most js devs wont.

Ferrum comes with ifdef(val?, (v) => v) and isdef(val?) which are useful for dealing with null/undefined in pipes which substitute for Maybe. We could also provide default(val?, def), defaultWith(val?, () => def) – replacing null with a default value. I am considering then(Promise, fn), reject(Promise, fn), finally(Promise, fn) as curryable functions on promises, as well as resolve(val) as a free-standing promise constructor. Potentially also free standing all() and race() functions.

Note these soundness/clarity issues shouldn't stop you from implementing a functor trait. It is perfectly feasible with ferrum, just hard to communicate…

I am planning however to introduce a foundational sequence trait, probably using coroutines; there are a lot of open questions and I am not even sure it is possible, but I could imagine this might work for some monadic types too…

const Begin = Tuple("Begin", seq);
const Gather = Tuple("Gather", it);
const Provide = Tuple("Provide", val);
const EndOfSequence = Tuple("EndOfSequence");

// Implement for sequence/iterator AND async sequence; extendable for reactive/event streams
const AbstractSequence = new Trait("AbstractSequence");

// Implement methods
const map = abstractGenerator(function*(seq, fn) {
  const it = yield Begin(seq);
  while (true) {
    // How does that even work? 
    const v = yield Gather(it);
    if (v === EndOfSequence) break;
    yield Provide(fn(v));
  }
});

const filter = abstractGenerator(function*(seq, fn) {
  const it = yield Begin(seq);
  while (true) {
    // How does that even work? 
    const v = yield Gather(it);
    if (v === EndOfSequence) break;
    if (fn(v)) yield Provide(it);
  }
});

And some very limited control flow for pipe() would be nice so you could pipe(…, await, …) and pipe(…, ifdef, …).

ptpaterson commented 3 years ago

Thank you for the very detailed response!

Ferrum comes with ifdef(val?, (v) => v) and isdef(val?) which are useful for dealing with null/undefined in pipes which substitute for Maybe.

This is a very good point. I am a fan of composing curried and point free functions. This is nice and lightweight -- handles nicely without the extra baggage of mimicking pattern matching.

I am considering then(Promise, fn), reject(Promise, fn), finally(Promise, fn) as curryable functions on promises

Also a fascinating idea.

... implementing a functor trait. It is perfectly feasible with ferrum, just hard to communicate…

Yes. And the deeper I go down the rabbit hole, I can see how much deeper it is :)

I've been trying a few different things, and I've gotten a bit stuck on the convention that creating a new Trait only provides a single invoke function. Some traits require implementation of multiple functions. To invoke a trait, though, only provides a single function per implementation. This is okay for those Traits that only require one function, but is confusing for those with more. A ferrum Trait, is less like a Rust trait, and more like a more specific contract on a single behavior.

To combine the concept of Sequence's map and a functor's fmap, I don't think one should create a ferrum Functor trait. Each implementation actually needs it's own definition of map/fmap, so the ferrum Trait should maybe be more like a specific FMap trait. This is kinda trivial for just Functor, so consider Bifunctor instead, where bimap, first, and second, etc. functions could all be defined for each implementation.

So, one could take the path of creating BiMap, First, and Second ferrum Traits. This technique is kind of embodied in the stdtrait.js definitions. This does sound counter to the idea of a "Trait", but it makes using this library very straightforward.

On the other hand, I've been looking at how a trait could be implemented by providing a thunk to an object with several function definitions, so that multiple function definitions must be provided at the same time.

Also of note: I love the way implementations can be derived! If there were an fmap/map Trait, than it could be implemented automatically for Sequence (though I have not exhaustively tested it).

// FMap Trait Definition
// Could be called "Mappable" instead? for the hardcore FP uninitiated?

const FMap = new Trait('FMap')
const fmap = curry('fmap', (what, f) => FMap.invoke(what, f))

// Derive FMap for Sequence

FMap.implDerived([Sequence], function* ([iter], seq, f) {
  for (const val of iter(seq)) {
    yield f(val)
  }
})

const sequenceTest = pipe([1, 2, 3, 4], fmap(plus(10)), list)
// [11, 12, 13, 14]

easy enough to implement for a trivial Option type, for example

// Option

const Some = function (value) {
  this.value = value
}
const None = function () {}
const none = new None()

FMap.impl(Some, (what, f) => new Some(f(what.value)))
FMap.implStatic(none, () => none)

const someTest = pipe(new Some(2), fmap(plus(4))
)
// Some { value: 6 }

const noneTest = pipe(none, fmap(plus(4)))
// None { }

For a bifunctor, I tried tried out implementing multiple functions under a single trait. While it does the job, it's not very nice, and I'm sure I am breaking the ability to do other things within ferrum.

// Bifunctor

const Bifunctor = new Trait('Bifunctor')
const bimap = curry('bimap', (what, f, g) => Bifunctor.invoke(what).bimap(f, g))
const first = curry('first', (what, f) => Bifunctor.invoke(what).first(f))
const second = curry('second', (what, g) => Bifunctor.invoke(what).second(g))

// Result

const Ok = function (value) {
  this.value = value
}
const NotOk = function (value) {
  this.value = value
}

FMap.impl(Ok, (what, f) => new Ok(f(what.value)))
Bifunctor.impl(Ok, (what) => ({
  bimap: (f, g) => new Ok(f(what.value)),
  first: (f) => new Ok(f(what.value)),
  second: (g) => new Ok(what.value),
}))

FMap.impl(NotOk, (what, f) => new NotOk(what.value))
Bifunctor.impl(NotOk, (what) => ({
  bimap: (f, g) => new NotOk(g(what.value)),
  first: (f) => new NotOk(what.value),
  second: (g) => new NotOk(g(what.value)),
}))

const resultTester = (x) => pipe(x,
    bimap(plus(5), (e) => `Major ${e}`),
    first(plus(40)),
    second((e) => `${e}, Seriously`)
  )

const okTest = resultTester(new Ok(5))
// Ok { value: 50 }
const notOkTest = resultTester(new NotOk('Error'))
// NotOk { value: 'Major Error, Seriously' }
ptpaterson commented 3 years ago

I'll also say that going into this, I did not realize that Rust does not define functor/monad operations through traits. Where there is overlap, the types just implement similar functions based on convention. Rust comes out okay without a generic fmap.

I am thinking of dropping an concern for implementing traits for functors, et al.. But I still love how traits can be used for all the other use cases!

koraa commented 3 years ago

Fascinating! Thank you for sharing the code!

The idiom you are using – returning some object that implements an interface – is exactly what I would recommend for implementing more complex interfaces. It's the same idiom used by the iterator protocol.

Be careful with trait derivations; they are kind of icky, using a O(n) lookup…not a problem if you just have a small number of derivations though.

Btw, your named tuple implementation suffers from some drawbacks…e.g. will act weirdly if not invoked with new…which is why deferred to some "imaginary" future tuple constructor. If you want tuples now I recommend something like this:

function Ok(value) {
  if (isdef(this) || type(this) !== Ok)
    return new Ok(v);
  Object.assign(this, { value });
}

Ok.protoype[Symbol.iterator] = () => iter([this.value]); // Destructuring
koraa commented 3 years ago

Just interested: What are your thoughts on https://github.com/adobe/ferrum/issues/138?