funkia / turbine

Purely functional frontend framework for building web applications
MIT License
686 stars 27 forks source link

Usage of OOP Classes #43

Closed dmitriz closed 7 years ago

dmitriz commented 7 years ago

The OOP subject came up in this discussion and I have been wondering myself about the reasoning to use the OO classes over FP factories, where several class-style declarations and annotation would not be necessary, as far as I understand.

I am sure you guys thought well through it, just curious what were the ideas behind it.

paldepind commented 7 years ago

Performance is the main reason for using classes.

When using classes and methods one can imitate Haskell type classes (interfaces are a bit like type classes).

When using abstract classes we can get something that is somewhat like sum types. The sum type Maybe a = Nothing | Just a can be an abstract class, Maybe, with two implementations Just and Nothing. Then the two classes can overload methods, which is somewhat like pattern matching on the specific values.

I definitely wouldn't want to write my own applications in that style. But for a library, I think it will give the best performance.

dmitriz commented 7 years ago

I would have never guessed :)

Aren't the classes are just sugar on top of the constructors, with some additional pipings? Plus all the extra work due to more lines and the verbosity?

When using abstract classes we can get something that is somewhat like sum types. The sum type Maybe a = Nothing | Just a can be an abstract class, Maybe, with two implementations Just and Nothing. Then the two classes can overload methods, which is somewhat like pattern matching on the specific values.

I still find it surprising how classes can be more performant than exactly the same de-sugared equivalent. Is there any simple example?

I know they do some AST hoisting to improve the performance at FB, and imagine suitable babel transformers would compile it to something more performant, where the source you can write having more fun, am I wrong?

dmitriz commented 7 years ago

In addition, as you previously explained with DOM, you don't need unnecessarily call your factories, so the difference might be even less noticeable... just guessing ...

paldepind commented 7 years ago

I still find it surprising how classes can be more performant than exactly the same de-sugared equivalent.

What do you mean with "de-sugared equivalent"? I don't think I fully understand what alternatives to classes you're thinking of?

dmitriz commented 7 years ago

What do you mean with "de-sugared equivalent"? I don't think I fully understand what alternatives to classes you're thinking of?

I mean that there are no native classes in JS. There are only native functions, objects and prototypal inheritance. Everything else is a "sugar syntax" on top of it. To make it look "sweet" to the coder, whence the sugar :)

You can see it in the TS repl: https://www.typescriptlang.org/play/ The very short

class Component {}

is really de-sugared as

var Component = (function () {
    function Component() {}
    return Component;
}());

I call the latter a "de-sugared equivalent" of the former.

Now this one line already is desugared into 4, but it gets convoluted very fast:

class CreateDomNow<A> extends Now<A> {
    constructor(
        private parent: Node,
        private tagName: string,
        private props?: Properties<A> & { output?: OutputNames<A> },
        private children?: Child
    ) { super(); }
}

becomes

var __extends = (this && this.__extends) || (function () {
    var extendStatics = Object.setPrototypeOf ||
        ({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) ||
        function (d, b) { for (var p in b) if (b.hasOwnProperty(p)) d[p] = b[p]; };
    return function (d, b) {
        extendStatics(d, b);
        function __() { this.constructor = d; }
        d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __());
    };
})();
var CreateDomNow = (function (_super) {
    __extends(CreateDomNow, _super);
    function CreateDomNow(parent, tagName, props, children) {
        var _this = _super.call(this) || this;
        _this.parent = parent;
        _this.tagName = tagName;
        _this.props = props;
        _this.children = children;
        return _this;
    }
    return CreateDomNow;
}(Now));

There is a lot of plumbing going on behind the "sugar", such as calling functions, setting properties, and even accessing the __proto__ property, which is generally considered slow and discouraged.

Now comparing with the functional style

const CreateDomNow = ( parent, tagName, props, children ) => ({
    run: () => {
        let output
            ...
             handleObject(props.attrs, elm, propertySetter);
           ...
        return output
    }
})

Here we have a plain function, with all parameters explicit at the top, and, in contrast to the class syntax, available in the scope. So you can simply write props instead of the longer and more fragile this.props.

And since we just have a plain function returning plain objects, everything is native and there is no need to desugar. I would have assumed this way closer to the native would be more performant, unless I miss something here.

And you can call it directly as native function call

CreateDomNow( parent, tagName, props, children )

without the new operator, that would again be non-native and needed to be desugared into creating an object and setting its prototype.

I didn't touch the inheritance part here, which leads to even more desugaring plumbing work behind the scene as you can see from the REPL. Is that really known as more performant?

Instead, exploiting the native prototypal inheritance via the native JS methods such as Object.create without any desugaring and no overheads would feel simpler, and more performant I would assume?

This all reflects my own understanding and I'd be happy to learn if it is flawed :)

paldepind commented 7 years ago

Consider this function from the transpiled code you posted:

    function CreateDomNow(parent, tagName, props, children) {
        var _this = _super.call(this) || this;
        _this.parent = parent;
        _this.tagName = tagName;
        _this.props = props;
        _this.children = children;
        return _this;
    }

The only thing it will do at runtime is create an object with 4 properties. All the methods on it will be setup through the prototype chain. That is pretty cheap.

Now consider this function:

const CreateDomNow = ( parent, tagName, props, children ) => ({
    run: () => {
        let output
            ...
             handleObject(props.attrs, elm, propertySetter);
           ...
        return output
    }
})

This will create a property and a closure for each method that we want on Now. That is about 10 closures that will be created every time you call CreateDomNow. I think that is going to be very expensive. The advantage of using prototypes is that functions can be shared across all instances of the class. They don't have to be created on the fly.

I think creating benchmarks between the style that we use and the style that you have in mind would be very interesting.

paldepind commented 7 years ago

Also, let me just add that from what I've benchmarked, using classes and transpiling with TypeScript adds no overhead compared to setting up the prototype chain the old-school fashion.

In my finger tree implementation I benchmarked the cost of constructing objects between TypeScript, Babel and the manual method. I found that TypeScript and the manual method was equal while Babel was slightly slower. The table here seems to say the same.

I have not benchmarked the cost using inheritance with classes. If the __extends method really is as slow as you claim I'd love to see a benchmark.

Using Object.create instead of new is almost certainly going to be slower. From what I've read and seen browsers optimize new much more than Object.create. Here is a JSPerf that confirms that.

dmitriz commented 7 years ago

Not sure I understand.

The only thing it will do at runtime is create an object with 4 properties. All the methods on it will be setup through the prototype chain.

Aren't they set on each object directly at the time of creation?

Also what about the d.__proto__ = b; writing to the __proto__?

This will create a property and a closure for each method that we want on Now. That is about 10 closures that will be created every time you call CreateDomNow. I think that is going to be very expensive. The advantage of using prototypes is that functions can be shared across all instances of the class. They don't have to be created on the fly.

So this is related to how you use the Now methods, not the 4 above properties, if I see it correctly. The 4 properties would be replaced by the function arguments living inside the single closure created by the factory vs setting each prop on the object directly. I would feel setting each prop would be (slightly) more expensive but I might be wrong.

The idea to write like that is really stolen from Brian Loisdorf that I put in https://github.com/dmitriz/functional-examples. I found it very elegant with minimum possible amount of code I can think of :)

About the Now methods and inheritance in general, this is how Eric Elliott is suggesting to do it:

let animal = {
  animalType: 'animal',

  describe: () => 
    `An ${this.animalType} with ${this.furColor} fur, 
      ${this.legs} legs, and a ${this.tail} tail.`
};

let mouseFactory =  () => {
  let secret = 'secret agent';

  return Object.assign(Object.create(animal), {
    animalType: 'mouse',
    furColor: 'brown',
    legs: 4,
    tail: 'long, skinny',
    profession () {
      return secret;
    }
  });
};

let james = mouseFactory();

I like it in that how light it is with minimal code and giving exact control. Everything you can reuse, you put on your prototype object, and simply inherit from it via prototypal chain. You can totally fine grain the whole prototypal chain that way :)

I have never considered performance as something I could reliably test away from my actual use cases, but Eric writes about it in his article and gives some links that might help.

dmitriz commented 7 years ago

Also, let me just add that from what I've benchmarked, using classes and transpiling with TypeScript adds no overhead compared to setting up the prototype chain the old-school fashion.

I can easily believe it as in the most cases the difference will be tiny. It the performance advantage of the classes that surprises me.

Using Object.create instead of new is almost certainly going to be slower. From what I've read and seen browsers optimize new much more than Object.create. Here is a JSPerf that confirms that.

I've thought the two were essentially the same but the new has been longer around, so it might be more optimised in older browsers, but I imagine the new methods will catch up. But then, it is probably going to be tiny fraction of milliseconds magnitude below the cost of the DOM element creation that you are already optimising as you explained in another post :)

Yes, your benchmarks show that it is slower, but it may not be the way it would run with Turbine, with its fine grained reactive update control.

I am sure you know what you are doing, it just surprises me if that would make any notable difference performancewise.

dmitriz commented 7 years ago

BTW you don't seem to use any classes or constructors in snabbdom and I understand that you use pure functions instead of inheritance. Is it correct?

paldepind commented 7 years ago

Aren't they set on each object directly at the time of creation?

No. You just need to set the four properties and the prototype.

__proto__ is only used inside __extends which is only being run once when the class inheritance is being set up.

I like it in that how light it is with minimal code and giving exact control.

I don't see how it's more minimal than using classes. And I think it's going to be slower. For example, you're giving a new object to Object.create every time the mouseFactory is being run. So you're not getting any sharing of functions. Performance wise I think it's going to be pretty bad.

BTW you don't seem to use any classes or constructors in snabbdom and I understand that you use pure functions instead of inheritance. Is it correct?

I Snabbdom there is no need for neither methods nor inheritance. So I saw no need for using classes.

I don't particularly like classes nor OOP. But I definitely prefer it to using Object.create and closures to create objects.

dmitriz commented 7 years ago

I Snabbdom there is no need for neither methods nor inheritance. So I saw no need for using classes.

I'd be curious to understand the difference, where the need comes from in Turbine vs no need in Snabbdom. That might perhaps answer the other questions for me :)

paldepind commented 7 years ago

@dmitriz What I meant by that was that there was no need for methods in the API. The API in Snabbdom is pretty much nothing but the h and the patch function. There are no methods in the API. You cannot call a method on a vnode for instance.

But, in Turbine there are a lot of methods in the API. On Component there is map and chain. And on Stream there is, as you know, map, filter, etc.

I personally think that to implement an API with methods using classes is both cleanest and most performant.

dmitriz commented 7 years ago

@paldepind

I do admire your work, and love the simplicity of the snabbdom, not last because of the tiny number of functions and no methods :) It might well be one of the reasons for its wide usage and popularity.

The best things I've seen are those where the concepts have been crystallised to the very few key ones and the api cut down to the absolute minimum. You only need the map and scan methods to implement Redux :)

Looking on the opposite end, the RxJS never seemed to make lowering the complexity and the number of methods a priority :)
Which bit them hard by very poor adoption as far as I can tell.

I know Turbine is more complex and has ambitious goals, and I am sure you have put a lot of thoughts in its design. And it would probably be hard to simplify it much right now. But it might be worth trying as the usability and adoption are often inverse proportional to the amount of api and the number of methods :)

paldepind commented 7 years ago

@dmitriz

I think there is a huge difference between a virtual DOM library and an FRP library. I also love the simplicity of Snabbdom. But, Snabbdom doesn't do anything besides letting you create a vdom and patching it. So it's easier to get away with a small API.

The best things I've seen are those where the concepts have been crystallised to the very few key ones and the api cut down to the absolute minimum. You only need the map and scan methods to implement Redux :)

I don't think havings few methods is nececarrily a goal in itself. If a function is common enough it's better to implement it in a library rather than every user doing so for himself.

I know Turbine is more complex and has ambitious goals, and I am sure you have put a lot of thoughts in its design. And it would probably be hard to simplify it much right now. But it might be worth trying as the usability and adoption are often inverse proportional to the amount of api and the number of methods :)

I actually think Turbine is pretty simple. It's not as simple as some other frameworks and it's not as easy to learn as yet another variation of the Elm architecture. It definitely wont be easy for everybody due to the use of monads. Hopefully the docs can ease people into that.

I think Turbine is simple in that there is no unnececarry complexity. And the API isn't that large. Component for example is just a monad 95% of it's API stems from that. Now unfortunately monads aren't well established in JS, but if they where everybody would be instantly familiar with Component's API and how it works.

I actually think that many of the functional architectures are too simple. They want to build everything with functions. But functios simply aren't powerful enough. If you use a tool that is too simple it's going to result in the code you write with that tool being more complex.

I think a great example is callbacks vs promises for asynchronus code. Callbacks are much simpler than promises. But, would anyone who's learned promises want to go back to callbacks? Definitely no. Because the complexity and extra power (that they get for being monads) of promises makes them worth it.

Turbine's goal is not to be as easy to learn as possible. It's to be as easy to use as possible. You only learn a framework once but you use it all the time.

dmitriz commented 7 years ago

@paldepind

I actually think Turbine is pretty simple. It's not as simple as some other frameworks and it's not as easy to learn as yet another variation of the Elm architecture. It definitely wont be easy for everybody due to the use of monads. Hopefully the docs can ease people into that.

It surely has a few more advanced concepts, which for me, however, is rather an attraction :)

I think Turbine is simple in that there is no unnececarry complexity. And the API isn't that large. Component for example is just a monad 95% of it's API stems from that. Now unfortunately monads aren't well established in JS, but if they where everybody would be instantly familiar with Component's API and how it works.

It is an interesting point. I suppose what makes it harder for me, is the number of the new concepts and methods coming from Hareactive. I presume you do not include them into the Turbine's API. But they still have to be completely understood for any of the examples. That leaves me puzzled about how hard a dependency is Hareactive. For instance, can I use Turbine with flyd instead?

This is not to say that one of them is "better" than the other. Hareactive is clearly more advanced and powerful and has many clever ideas. But it is always simpler to focus on one new advanced thing at a time :)

About the use of monads, that actually might be a good selling point. Monads are "hot" and everyone these days likes to think to understand them. 😄 I find it a clever idea to see the components as monads that can simply pass their output to each other instead of going through the multiple whoops as common. And if I see it right, a component is equivalent to the pair functor of the dom node and an arbitrary value, which includes the map method. Then you define the chain as you describe by using the identity monad from the value part and the concatenation monoid for the dom.

I actually think that many of the functional architectures are too simple. They want to build everything with functions. But functios simply aren't powerful enough. If you use a tool that is too simple it's going to result in the code you write with that tool being more complex.

That is quite interesting, I wonder where do you see the functions limitations?

paldepind commented 7 years ago

I suppose what makes it harder for me, is the number of the new concepts and methods coming from Hareactive. I presume you do not include them into the Turbine's API. But they still have to be completely understood for any of the examples.

I completely understand that. Part of the problem is of course the lack of documentation.

I suppose what makes it harder for me, is the number of the new concepts and methods coming from Hareactive. I presume you do not include them into the Turbine's API. But they still have to be completely understood for any of the examples. That leaves me puzzled about how hard a dependency is Hareactive. For instance, can I use Turbine with flyd instead?

Currently Hareactive is a hard dependency. Hareactive is the only library that has the features that Turbine needs. For instance a completely pure API. Turbine is more Hareactive than it is Turbine. Turbine is just Component while Hareactive is both Behavior, Stream and Now. Turbine is mostly just a nice way to build DOM together with Hareactive.

About the use of monads, that actually might be a good selling point. Monads are "hot" and everyone these days likes to think to understand them. 😄

I hope so 😄

I find it a clever idea to see the components as monads that can simply pass their output to each other instead of going through the multiple whoops as common. And if I see it right, a component is equivalent to the pair functor of the dom node and an arbitrary value, which includes the map method. Then you define the chain as you describe by using the identity monad from the value part and the concatenation monoid for the dom.

That is a very good way of seeing it! In most cases it is completely accurate. The catch is that components can both remove existing components and add new ones dynamically. But beside the dynamic cases your way of seeing it is correct.