datavis-tech / topologica

Minimal library for reactive dataflow programming. Based on topological sort.
MIT License
50 stars 1 forks source link

API Redesign Idea #15

Closed curran closed 6 years ago

curran commented 6 years ago

The current API is starting to feel a bit cumbersome.

What if modules could define reactive functions and their inputs is a very simple way, without even importing Topologica? Then the top level program could just combine all the reactive functions into one place.

Current API example:

const dataFlow = DataFlowGraph({
  fullName: λ(
    ({firstName, lastName}) => `${firstName} ${lastName}`,
    'firstName, lastName'
  )
});

Here's how it would look if fullName were split out from the data flow graph definition.

const fullName = λ(
  ({firstName, lastName}) => `${firstName} ${lastName}`,
  'firstName, lastName'
);

const dataFlow = DataFlowGraph({ fullName });

fn.inputs Approach

Same example with new API idea:

const fullName = ({firstName, lastName}) => `${firstName} ${lastName}`;
fullName.inputs = 'firstName, lastName';

const dataFlow = DataFlowGraph({ fullName });

As a module:

export const fullName = ({firstName, lastName}) => `${firstName} ${lastName}`;
fullName.inputs = 'firstName, lastName';

With hoisting:

fullName.inputs = 'firstName, lastName';
export function fullName({firstName, lastName}){
  return `${firstName} ${lastName}`;
}

Pros:

Cons

Array Approach

What if we use an array?

const fullName = [
  ({firstName, lastName}) => `${firstName} ${lastName}`,
  'firstName, lastName'
];

const dataFlow = DataFlowGraph({ fullName });

As a module maybe it could look like this:

export const fullName = [
  ({firstName, lastName}) => `${firstName} ${lastName}`,
  'firstName, lastName'
];

What if we want to define the function separately?

const fullNameFn = ({firstName, lastName}) => `${firstName} ${lastName}`;

export const fullName = [
  fullNameFn,
  'firstName, lastName'
];

Pros:

Cons

monfera commented 6 years ago

[ended up putting here rather than the PR as it's a design consideration]

@curran thanks for the invite - if I understand, the fullName.dependencies array of strings is necessary to declare dependencies explicitly, correct? A bit like the deps in flyd, ie. analogous to the [n1, n2] array?

var n1 = flyd.stream(5);
var n2 = flyd.stream(9);
var max = flyd.combine(function(n1, n2, self, changed) {
  return n1() > n2() ? n1() : n2();
}, [n1, n2]);

If that's the case, I'd personally prefer:

const firstName = ...
const lastName = ...
const joined = separator => (...parts) => parts.join(separator)
const fullNameFun = joined(' ')

 // no explicit declaration of inputs other than as regular function args:
const fullName = _.lift(fullNameFun)(firstName, lastName)

A benefit is that this separates the meaning of values in the streams from what the transformer does - see the formal parameter name (parts), they're generic. On 1st sight it looks like the proposed API above couples the meaning and mechanism more tightly, maybe there's a reason to it. I'm influenced by the FP style of coding as seen with eg. ramda, and sometimes partially apply or curry transformers eg.

const nameMaker = _.lift((title, firstName, lastName) => `${title} ${firstName} ${lastName}`)
const withTitle = nameMaker(someTitleStream) // only 1st arg applied
const name = withTitle(firstName, lastName) // other two args applied

😄

If my premise is off, or it's simply the API style you chose, based on a set of requirements and constraints, my .2c won't be super useful 😸

(btw just linked your library to the crosslink readme)

curran commented 6 years ago

@monfera Thanks a ton for these ideas! Very interesting considerations.

I really do like the idea of "no explicit declaration of inputs other than as regular function args". I'm not really happy with how currently with this library, you need to duplicate the list of dependencies (once for explicit declaration, and once to unpack them as variables).

I took the approach of having the properties more like "observables" with https://github.com/datavis-tech/reactive-function. One interesting consequence of doing this, with the topological sorting approach, was that it ended up being necessary to construct one global dependency graph, which internally ended up as one object with a bunch of properties (with auto-generated names).

It's a very interesting design space, and I'm not sure how it will all play out. The way I'm envisioning this being used is for efficiently managing DOM updates within a single visualization component. Meaning, a scatter plot would have its own instance of Topologica, and a bar chart would have its own as well. On top of that, I'm envisioning Redux being the orchestrator of it all. This is the motivation for having these properties tied to objects.

Anyway thanks a lot for weighing in! I really appreciate your thoughts here, and in perusing your links I discovered some projects I had not heard about before.

monfera commented 6 years ago

A tangential comment, topological sorting can be done under the hood with either approach, ie. your global namespace based implementation as well as the use of regular values (flyd and crosslink both toposort too). In the case of the latter two, there's still a graph, ie. a notion of what the transform nodes are part of, similar to your more explicit approach.