[Design question] I don't like factory
. Why don't we just use modules?
#2741
Replies: 39 comments 2 replies
-
Good question. Thanks for asking. One side-note about the screenshot you shared for To explain the context: What I wanted to achieve with mathjs is an environment where you can do calculations with mixed data types, like multiplying a regular The solution that we have in mathjs now is a combination of two things:
The dependency injection indeed complicates the code hugely. If you or anyone can come up with a simpler approach I would love hear! Maybe we can do something smart during a compile/pre-build step instead or so. |
Beta Was this translation helpful? Give feedback.
-
Thanks for your reply!
You mean I should bundle
I think I understand
Hmm... I was convinced that if was possible to add call signatures to a typed function, but I couldn't find that anywhere in the examples. Okay, I take back my last statement, maybe there are things to improve in const fn4 = typed({
'number': function (a) {
return 'a is a number';
}
});
fn4.addSignature({
'number, number': function (a, b) {
return 'a is a number, b is a number';
}
})
fn4(1,2) // a is a number, b is a number Then the import math from 'mathjs'
math.typed.addType({
name: 'BigInt',
test: (x) => typeof x === 'bigint'
})
math.bigint = (x) => BigInt(x)
math.add.addSignature({
'BigInt, BigInt': (a, b) => a + b
})
math.pow.addSignature({
'BigInt, BigInt': (a, b) => a ** b
})
export default math This is arguably much simpler than the original, and no compile-time magic was needed – just vanilla modules.
This one sounds a lot more difficult to achieve with modules. Generally speaking, it's impossible to make a module "unlearn" some dependency – at least without some compile-time magic. But at the same time, it seems quite impractical to manually remove dependencies on something like BigNumbers... Since more than half of the code directly mentions them, one would have to rewrite almost all functions to remove the dependency – I struggle to understand why would anyone do that. Are there any more practical examples of where such "unlearning" might be used? |
Beta Was this translation helpful? Give feedback.
-
Hm, good point. We should probably not expose So far, typed-function is created in an immutable way. So you can merge typed functions like The world would be simple if we have a single mathjs instance and can extend functions there like you describe, and also change the config there. However, suppose you're using two libraries in you application, library A and library B, and both use mathjs. If those two libraries both need different config and extend functions in some ways, they would get in conflict with each other. Therefore, I think it's important that you can create your own instance of mathjs with your own config and extensions, and the global instance should be immutable.
I suppose you mean loading light-weight functions with just number support instead of the full package? Reasons are performance (typed-function does introduce overhead) and bundle size for the browser. Currently, functions like |
Beta Was this translation helpful? Give feedback.
-
Huh, I didn't realize mathjs is immutable now and making it mutable could break some real world code... This makes things infinitely more complicated. After some time of listening to music and thinking hard, I've come up with this design idea. It's probably still full of holes, so if you notice something that wouldn't work, doubt that something is a good idea, or simply don't understand my explanation, please do comment on that. It's definitely more complicated than “just exporting and importing” as I initially hoped, so please be patient with my explanation 😅️ Proposal v0In any mathjs bundle, according to this proposal, there would always be two abstract types:
The subtypes of these can be modified (you can remove some, or add your own), but these two abstract types shall always remain. In any mathjs bundle, the
If a user wants to add their own Real type or Scalar type, they should provide these methods – for the best results they should provide all of them. Ordinary methods (like Pseudo-code to showcase this convention: // file: multiply_DenseMatrix.js
import { typed } from "../core/typed.js"
import { DenseMatrix, pointwise } from "../type/matrix/DenseMatrix.js"
export const multiply_DenseMatrix = typed('multiply', {
'Scalar, DenseMatrix': (a, B) => pointwise(B, e => this.multiplyScalar(a, e)),
'DenseMatrix, DenseMatrix': (A, B) => DenseMatrix([ /* actual multiplication code */ ])
}) There are various pre-made bundles like This is a pseudo-code implementation of export create(methods, config = {})
{
const math = { config }
for (fname of keysOf(methods))
{
if (!fname.contains('_'))
{
math[fname] = bind(methods[fname], math)
}
else
{
const name = fname.split('_')[0]
const method = mergeAllOverloads(name, methods)
math[name] = bind(methods, math)
}
}
return math
} The result of How a user uses this new API
Pros & Cons
|
Beta Was this translation helpful? Give feedback.
-
Mathjs currently has a complicated hybrid: the lowest building blocks, functions, are immutable. On higher level, you can create a mathjs instance and change configuration. That will result in all functions being re-created with the new configuration. Interesting idea to separate required core functions (like So if I understand your idea correctly, when creating a mathjs instance, the functions are simply bound to new function add (a, b) {
return a + b
}
function sum (values) {
let total = 0
values.forEach(value => {
total = this.add(total, value) // <-- here we use 'this'
})
return total
}
const instance = {}
instance.add = add.bind(instance)
instance.sum = sum.bind(instance)
console.log('sum', instance.sum([1, 2, 3])) Relying on a It would be nice if it would be possible to export already bound functions which can be used directly, in such a way that tree-shaking would work and you should be able to re-bind the function to a different context later. Something like this works (but having to wrap it is still ugly): // because we import all dependencies here, tree-shaking and cherry picking a function just works
import { add } from './add.js'
const sum = (function () {
// default binding of all dependencies
this.add = add
return function sum (values) {
let total = 0
values.forEach(value => {
total = this.add(total, value)
})
return total
}
})() Which can be used like: import { sum } from './sum.js'
console.log('sum', sum([1, 2, 3])) And you can bind it to a different context like: const instance = {}
instance.add = // ... some different implementation
instance.sum = sum.bind(instance)
// now, instance.sum uses the new add function I think though that it is not possible to bind Really interesting to think this through 😎 |
Beta Was this translation helpful? Give feedback.
-
Bump! I'm interested in continuing this discussion and possibly making a few prototypes to test the possibilities! Regarding your last comment: I think I have an idea how to solve this. I'll assume we'll be using TypeScript, but the idea would work without it too.
// file: matrix/essential.ts
export { matrix } from './matrix'
export { add } from './add'
export { multiply } from './multiply'
export { transpose } from './transpose'
... // file: matrix/solve.ts
import * as core from '../core'
import * as essential from './essential'
import { createExtras } from '../utils/createExtras'
// non-essential functions; for the sake of example, only one of them is made extensible
import { lup } from './lup' // extensible
import { usolve } from './usolve' // not extensible
import { lsolve } from './lsolve' // not extensible
export function solve(this: core & essential, M: Matrix, b: Vector) {
const extras = createExtras(this, { lup }) // only creates the object once, then caches it
const { L, U, P } = extras.lup(M)
const c = usolve(U, b)
const d = lsolve(L, c)
return d
} Then if you cherry-pick import { create } from 'mathjs/custom'
import * as core from 'mathjs/core'
import * as matrix from 'mathjs/matrix/essential'
import { solve } from 'mathjs/matrix'
const fn = { ...core, ...matrix, solve, lup: ()=>{ my code }, usolve: ()=>{ my code } }
const math = create(fn)
math.lup === fn.lup // true
math.usolve === fn.usolve //true
math.solve([[1]], [1])
// uses your custom lup
// but doesn't use your custom usolve What do you think about the proposal? Should I make a prototype repo to test this in practise? |
Beta Was this translation helpful? Give feedback.
-
An argument for designing a new architecture: In the current system, creating one function means modifying five different files in different parts of the codebase. I think this number should go down :^) |
Beta Was this translation helpful? Give feedback.
-
Your proposal can be interesting @m93a . I guess a main difference with the proposal I made before (see #1975 (comment)) is that you're relying on a I'll reply on the other issues in mathjs that you commented on soon but I'm not managing to keep up with all the issues at this moment. |
Beta Was this translation helpful? Give feedback.
-
For a working tiny prototype using many of the ideas in the discussion of this issue, but which does not use any manipulation of "this" and takes a more simpleminded approach to bundle-gathering than the "areas" and "createExtras" later in the conversation, but nevertheless seems as though it may well be scalable to the size of mathjs, see: https://code.studioinfinity.org/glen/picomath |
Beta Was this translation helpful? Give feedback.
-
Oohh, I'm really curious to have a look at your PoC 😎 ! Will be next week I expect though. |
Beta Was this translation helpful? Give feedback.
-
Great. I also went ahead and (in an additional |
Beta Was this translation helpful? Give feedback.
-
Unfortunately the picomath proof-of-concept relies on a possible version of typed-function in which typed-functions are mutable (e.g. they can have additional signatures added to them after initial creation, without changing object identity). That is possible, but the experiments in josdejong/typed-function#138 indicate that it comes at too heavy a performance cost. So I think that approach is a dead end for now |
Beta Was this translation helpful? Give feedback.
-
Thanks for adding the pointer here to the experiments and benchmarking that you did in this regard. That was indeed quite a bummer. We have too keep experimenting and trying out new ideas :) |
Beta Was this translation helpful? Give feedback.
-
Yes, I have a new concept: in typed-function v3, we allow implementations to flag that they refer to another signature of themselves (or their entire self). I am imagining a variant of typed-function in which individual implementations can flag that they refer to a signature of any other typed-function (or the entire other typed-function) as well. Then we just load modules gathering up all implementations of all typed-functions (creating a huge directed graph among impementations, that is hopefully acyclic). But no actual typed-functions are instantiated yet, because we don't know if any will get more implementations as we load more modules. Then just before we actually start to compute (maybe triggered by the first attempt to call a typed function, rather than define one), the whole web of definitions is swept through in topological sort order, finally instantiating all of the typed-functions, so that all of their references have been instantiated as well, and they can all be compiled down to code that doesn't need to call functions through possibly changing variables, which is the slow operation. This would have the likely effect of slowing down initialization (but perhaps it can be done incrementally so that this burden is spread out) but hopefully keeping computational performance once everything is initialized as high or higher than it currently is. This description may be a bit vague; as time permits I will do a "pocomath" proof of concept and post here. |
Beta Was this translation helpful? Give feedback.
-
OK, the new proof-of-concept Pocomath is working. It does everything the original picomath did, and more. (I have not implemented the lazy-reloading of functions when a configuration option changes. I think it's quite clear from what's there that Pocomath can easily handle this capability, but I would be happy to do a specific reference implementation of it if anyone would like.) Specifically, it uses the current, non-mutable, typed functions of typed-function v3, but allows gathering of implementations solely via module imports, it has no factory functions, and it should easily allow tree-shaking. To show that this latest architecture makes it very easy to implement new types and more generic types, I have implemented a bigint type in Pocomath and in combination with the Complex type there it gives you Gaussian integers (ie. Complex numbers whose real and imaginary parts are bigints) automatically. I am very encouraged by this proof-of-concept, and would be delighted for everyone interested to look it over. I humbly submit that adopting this basic architecture would make organizing the mathjs code as desired and adding new types and functions much easier. (In particular on @m93a's criterion of the number of files you have to touch to add a new operation.) Looking forward to your thoughts, @josdejong. |
Beta Was this translation helpful? Give feedback.
-
Sure, I am perfectly happy with a "wait and see" approach on an "adapter for a plain-function implementation".
Yes, it does seem powerful to import external libraries. I will add an issue for installing ordinary JavaScript functions to the list I am implementing in Pocomath.
This is an excellent point. It is not yet clear to me if the reasons that addScalar is needed in the current architecture will arise with a Pocomath-based architecture. One question that has been kicking around in my head is why is there a Matrix type in mathjs in addition to Arrays? I assume the primary answer is so there can be both DenseMatrix and SparseMatrix. But it has also been kicking around in my head that another item that makes Matrix powerful is that it is (or at least can be) type-homeogeneous, i.e., all entries are the same type. And if this is part of the sauce, then it seems to me that it would be pleasant if It is indeed possible to build a concept of template types on top of typed-function as it exists (or eventually it could perhaps be incorporated internally therein). Then we might have something like (in pseudo-code):
(Hence, an item to implement templates is next up on my list of things to try in Pocomath.) I think an approach like this might make it unlikely that the need for a dependency on On the other hand, if we find ourselves needing a way to refer to "add for scalar types", maybe to deal with potentially inhomogeneous collections like Arrays, I think it would be possible to introduce esentially "typedefs" into Pocomath in which we install a type 'scalar' into an instance with a designation that it simply means (After all, the current version of Pocomath always filters implementations to include only those that mention a defined type, in order to allow operation-centric files like mathjs has where a single operation is defined for numerous types, but only the implementations for types that have actually been installed in the instance are employed. Pocomath is trying very deliberately to be completely agnostic as to how operations and their implementations are organized into source files, even though the demo so far uses one operation for one type per file. I plan to add an example of many operations for one type in a single file, and I will also add an example of one operation for many types in a single file just to verify it works.) However, this feels like it may end up being a little complicated so I would definitely put this concept on the same list as plain-function-implementation-adapters, i.e., things to be implemented if the actual need arises. |
Beta Was this translation helpful? Give feedback.
-
I'm not sure either, but I think the same issues would pop up, it helps for performance and also better error messaging to have functions that only work for scalars. There was one (quite) specific circular dependency: The reasons for a
When rethinking matrices, here are some thoughts:
That is a very interesting idea 🤔. So basically, that would allow not only to inject a function, or a single function signature, or self reference, but it would allow you to filter a set of signatures out of all of the signatures of a function and create a new, optimized function. It sounds very cool. I agree with you though, that something like this could open a lot of tricky complexities, so maybe best to keep it as an idea for now. |
Beta Was this translation helpful? Give feedback.
-
On scalar functions:
I am thinking/hoping that the template implementations will be as good or better on both of these counts.
The Pocomath-style of infrastructure has no problem with co-recursion (as long as it bottoms out at some point). For example, the exact circular dependency you mention currently exists in Pocomath as it stands right now: the generic implementation of |
Beta Was this translation helpful? Give feedback.
-
On Matrix and friends: |
Beta Was this translation helpful? Give feedback.
-
OK, template implementations (but not yet template types) are working in Pocomath now. I've switched as many of the generic operations to use them now as I could figure out how to. Also, there's a nifty built-in implementation of |
Beta Was this translation helpful? Give feedback.
-
And now I have added an example of defining the 'floor' function in an operation-centric way. Note I am not proposing that in a putative reorganization of mathjs to use a Pocomath-like core that both operation-centric and type-centric code layouts would be mixed, I just wanted to demonstrate that the Pocomath core is completely agnostic as to the organization of the code. (Except for |
Beta Was this translation helpful? Give feedback.
-
Pocomath now has a working Chain type (with the methods only being chainified when called through a chain and updating when they are modified on the underlying Pocomath instance). However, the chain methods are not quite as completely "lazily" updated in that at every extraction of a method, this implementation of Chain checks whether the underlying operation has changed on the Pocomath instance. If you think that's enough of a problem for performance (as opposed to the Pocomath instance invalidating the chainified version when it updates the underlying, so that the chainified version can simply be called directly whenever it's valid, without checking for an update) to be worth it, I can modify it to work that way as well. The only cost is somewhat tighter coupling between the instance and the Chain. (Right now, the instance provides a place for Chains to store their chainified functions, since of course the underlying items that are being chainified could vary from instance to instance. So that repository has to be associated with the specific instance. But the contents of that place are completely managed by Chain. To make things even lazier, Chain and PocomathInstance would have to have shared knowledge of the format used to store chainified functions in the repository so that PocomathInstance could do the invalidation directly when it was changing one of its operations. Let me know if it seems OK for the proof of concept or if you'd like the invalidations to be "pushed" from the instance. |
Beta Was this translation helpful? Give feedback.
-
Import of plain functions is working now. |
Beta Was this translation helpful? Give feedback.
-
Template operations and template types are both working in the prototype (Pocomath) now. There is a type-homogeneous vector type |
Beta Was this translation helpful? Give feedback.
-
And now there is an adapter that just sucks in fraction.js (well actually bigfraction.js, because I think it's clear mathjs and fraction.js should move to the bigint version) with no additional code added elsewhere in Pocomath. With that, my feeling is that Pocomath is "feature-complete" as a proof-of-concept: it demonstrates all of the aspects that I would be proposing to bring to mathjs with a reorganization along these lines. There are a couple of very minor concerns mentioned on the issues page, but I think they would easily come out in the wash in an integration with typed-function/mathjs so I don't feel they are worth running down now. So I think the only remaining "proof" the concept needs for evaluation is some benchmarks, to ensure that the additions to typed-function do not bog the system down. I believe they won't, because in fact Pocomath tries very hard to bypass typed-function dispatch as much as possible and bundle in direct references to individual implementations rather than to typed-function entry points. So please just point me to some benchmarks for mathjs that you think might be reasonable for evaluating Pocomath in this regard, and I will duplicate them in Pocomath and post the results. Thanks! |
Beta Was this translation helpful? Give feedback.
-
just 😎
Yes you're right. I should better separate the different discussions (and at the same time, the discussions related to TypeScript and the pocomath architecture are a bit scattered right now).
The genererics are impressive, it makes sense. The
👍
😎 again. I how to implement the lazy or less lazy updating of functions is an implementation detail. I think we can just start with the current approach, and do some benchmarks to see if there is a performance issue in the first place, and if so try out alternative solutions.
🤯 ...Shortcut in my head... 😂
yeeees, that's where we want to go, easily import a new data type that has a set of built-in functions/methods. Fraction.js, Complex.js, BigNumber.js, UnitMath, ... Optional dependencies Benchmarks
Thanks a lot for working this concept out Glen. I really appreciate this. I think this concept addresses all the pain points of the current architecture, I really believe this is the way to go. There is one big open challenge: TypeScript support discussed in #2076. I want to find a satisfying solution for that first, before implementing this architecture for real in mathjs. |
Beta Was this translation helpful? Give feedback.
-
Return type annotations are now supported, and in fact supplied for all operations, in the Pocomath proof of concept. This move the POC closer, I suppose, to the sort of organization need for a conceivable TypeScript switch/unification. The change took a while both because I physically relocated, but also because it was a very good bench for strengthening the underlying infrastructure. Type checking and template instantiations are now significantly more robust. As soon as I can I will get to the final piece of the proof of concept, namely some reasonable benchmark. I am virtually certain that building an instance with all of its dependencies satisfied will be slower in Pocomath; there's just a great deal to do as far as instantiating templates and resolving every individual implementation, which is clearly less efficient than resolving the dependencies for an entire source file all at once, which may have many implementations in it. What we're getting is a great deal more flexibility in organizing code. So in a benchmarking of bootstrapping time, Pocomath is bound to lose by a significant margin. But that's just a one-time initialization cost. The aspect I feel needs benchmarking is the performance of the resulting implementations of the operations, and there I think I can reasonably hope that Pocomath will noticeably outperform current mathjs because with the templating (or generics if you prefer that terminology), a nested Pocomath operation should perform many fewer typed-function dispatches. I will report back here when I have results. |
Beta Was this translation helpful? Give feedback.
-
Hmm, I looked into this but currently the only benchmarks that do a significant amount of numerical computation are the matrix benchmarks (but reimplementing matrices in Pocomath doesn't make sense) and isPrime (but that test just calls a single typed-function that has no dependencies, so there really isn't any difference to test). So I will need to add a new benchmark on the mathjs side. I need something fairly realistic that calls a variety of basic arithmetic functions. Please does anyone following this issue have a suggestion for an algorithm or calculation I might use as a benchmark for this purpose? I could solve a quadratic and a cubic using the respective general formulas... That's reasonably attractive because the cubic goes back and forth into complex numbers. But it's fairly simplistic. Any better suggestions? |
Beta Was this translation helpful? Give feedback.
-
I see the Returns is used dynamically, inside the function body. I'm not sure, but I have the feeling that that will make it hard or impossible to statistically analyse with TypeScript. What do you think? About benchmarks:
I thought I had reported about this but I probably forgot or wasn't yet happy with it yet, sorry. This benchmark that I parked in this separate branch is quite limited, it would be better indeed to have a real expression with multiple operations. |
Beta Was this translation helpful? Give feedback.
-
On benchmarking: OK, I will add functions for roots of quadratics and cubics and use computation of several roots with both real and complex cases to both mathjs and Pocomath and report on performance results. |
Beta Was this translation helpful? Give feedback.
-
I'm working on this PR and the current dependency system of math.js seems really frustrating to me.
^^ the first lines of a more complicated script look like this with
factory
To be more precise, I don't understand why don't we use ES6 modules more, instead of the
factory
function which to me seems worse in many ways (the code isn't DRY at all, worse IntelliSense, problems with circular dependency).I have a vague understanding that math.js has custom bundling support and some package-wide settings and that's the reason why
factory
was invented, but I think these should be doable with modules too.So what's the reason why we use
factory
again? 😁️Beta Was this translation helpful? Give feedback.
All reactions