-
Notifications
You must be signed in to change notification settings - Fork 19
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Proposal: provide mutating operations on typed functions #138
Comments
OK, I made a bunch of strong claims above and I thought that "showing" might be more convincing than "saying", so I built a tiny working prototype that has no factories and is based entirely on importing, but allows selective loading of operations and generic operations that depend only on other operations and will then automatically operate on any types those other operations are extended to. You can see it at https://code.studioinfinity.org/glen/picomath -- hope it is of interest. |
Thanks Glen, I've read your above reasoning and had a look at your I have to give this a bit more thought: list the current pain-points, see which of the pain-points will be addressed with this approach (and what not), what it simplifies exactly, think through changes in perspective like moving from a function centric to a more data type centric approach. I also want to built a similar POC with this approach I had in mind, binding functions to a context and refer to dependencies via |
Just wanted to point out that operation-centric vs datatype-centric is actually orthogonal to the constant, mutating typed-function aspect. In other words, I could have organized the picomath POC in an operation centric way, in which there would be a file (or possibly directory) for each operation that adds the versions of that operation for each type it finds on the math object when it's executed, and we could arrange those operation-adding functions to be lazily re-executed when new types are added. (Let me know if you'd like to see the example re-done that way.) I just did the PoC with types primary because I was brought up to think in type-centric terms, and to show the flexibility that the mutable typed-function infrastructure allows. |
Ok I worked out a very minimal proof of concept too, for this idea I had to use In short: I do like the simple, flexibly API and absence of factory functions, but using What both these PoC's do not (yet) address is: how do I know which dependencies a function has? What functions/modules do I have to load? I.e. if I would only load the |
Well in something like picomath, which has a well-defined structure of where the source for a given (type,operation) combination would be, it's easy enough to add a utility method picomath.importDependencies() that would find all functions with no implementations and import them for all types that are currently defined in the instance (and iterate until the process stabilizes). But that will of course use dynamic import. Does that sound like something that would fill the need you see? If you would like to see this utility in action let me know and I will add it to picomath along with a test that imports and adds just the number and complex types (no operations) and the generic 'subtract' operation and then calls picomath.importDependencies() and produces a working instance where you can subtract any combination of numbers and/or complex numbers. If that doesn't seem to be on point for what you are looking for, let me know in more detail what capability you would like to have and I will think about whether there is a suitable way to add it to picomath. |
It's indeed possible to write a script to return the dependencies for you. Right now in mathjs there is such a script, generating files like Dynamic imports will indeed be a challenge: that doesn't play nice with bundling and tree shaking. I think though that it may not be necessary when having this script in place that generates "entry" files with the dependencies listed per function or something like that. Open topics that come to mind when thinking about a new architecture for mathjs, and looking at the picomath PoC:
|
As for the original topic of providing mutating operations: I think we will need them in the future (you convinced me 😉). But the future is not ironed out yet, and I don't think it will be within say "days or weeks", so I'm not sure whether it is a good idea to implement something in this regard already and ship it with I think it will boil down to a new method like |
Well, here's a specific proposal that would be quick to implement that we could put in now so that we have it in our pocket for serious experimentation in mathjs, without disrupting the current method of operation or necessitating any significant refactor in mathjs:
Barring this specific proposal or some mild variant of it that you might propose, I'd agree that it should just be postponed for the indefinite future. But it might be nice to have something like this to play around with as long as we are at it. |
I think those proposals make sense, thanks. I expect this function |
Here is the only subtle point, which is why I was keen to try it in the midst of a breaking change, rather than later: you cannot change the function body of constant function object, even if it otherwise mutates. So to implement anything like merge or any other behavior changing mutation, the original function body of any typed function has to be some sort of immediate dispatch to (an)other function(s) that it finds by examining its mutable properties. (See the pseudo typed functions of picomath for an example of this; all individual typed functions have exactly the same actual function body.) Since that's a nontrivial refactor of how an individual typed function works from where it is now, I have a niggling worry that switching to this immediate dispatch will involve some minor degree of breaking change (and also that the performance of such a scheme will definitely need to be checked). If you prefer not to wait on trying this out, I understand; or I can give it a whirl. Just let me know. And this would definitely come after #144. |
And now to reply to your "big comment":
But really the way I was envisioning this working in practice is that there would be some number of existing imports that statically import other clusters etc to give you the main selections you need. Like obviously a main module that just imports everything, like the current mathjs; and an analogue of mathjs/number that only imports the number type and just the number versions of all the operations; and maybe addon modules like mathjs/unit that you could also load if you just wanted numbers and units; and all of those would just work by static imports chasing through the source tree, so it seems like they would work well with webpack or similar tree-shaking approaches. The kinds of utilities that either do dynamic import or write scripts would be just for creating specialized instances that have a selection of functions and/or types that cuts across the ones we have provided "standard" modules for. In particular, I wouldn't see any need for something like So on your nine-point topic list:
The above is pretty much the thoughts I have on that; maybe unsophisticated, but I am not clear on what is the key example of a tricky task in this area that needs to be solved? Typically you should just be able to import the main module, and get what you need by the recursive imports encountered, and I assume webpack and friends deal well with that.
Agreed. This is a matter of taste I think to a large extent, and I am fine with any organization that supports programmatically knowing the path to import for a specific type/operation combination.
Personally I see this as orthogonal and/or a follow-on to a better organization for mathjs that doesn't require factories and has less repeated information. Priority for this item comes if there seems to be advantage to either (a) better support for TypeScript clients than simply a couple of hand-maintained declaration files, or (b) use of TypeScript in the mathjs implementation for whatever benefits that might provide. Happy to help out, but I am not a huge fan of TypeScript even though I am a fan of static typing in general, so I'm not going to be providing much drive here.
Roger that.
Right, I haven't thought at all about whether/how this approach offers any better option than the transforms (which could of course just be implemented as is, at least as first, on top of a constant-mutating style of typed function).
I think this is easy with the picomath style and one of its real wins. That's why I implemented the example of functions that lazily refresh themselves when the config object on a picomath instance changes (it's in a branch in the picomath repository). I think exactly the same principle will work for lazily creating chain functions.
Ditto on my last comment.
Ditto again.
Yup that's the main reason I was suggesting putting in at least the basic mutating method now in my |
Hm that is true. So that is a showstopper for the quick implementation of a Unless you still see an easy/neat/simple way to implement a
yes that makes sense. So instead of facilitating index files per function containing all dependencies of a single file, you would have a index files per category or module (like arithmetic, trigonometric, algebra etc)? We can indeed make these index files by hand and put a unit test on top to make sure all dependencies are satisfied. Then there would be no "smart" auto generation of index files needed. (1) Yes solved with your above comments I think. Only, dynamic imports would complicate things. If we don't use that it should be straightforward. (2) Yeah, my rough idea right now is: one module per data type, containing all "basic" functions for this data type, typically the arithmetic functions. And modules per mathematical category, that typically are generic can handle any numeric type. (6) That would be great if it's that straightforward! (9) I think in picomath you can keep the (constant) proxy function as it is, and under the hood let it use a typed-function. This typed-function can be updated by doing |
Well, the only way I see to implement merge is to have a standard main function body that all typed functions have. It could either just be:
and then the typed function can manipulate the _do property as it likes, or it could basically be the generic dispatch, but it still needs to call something for the specialized quick dispatch first. My main worry, though, is that the indirection of the execution will kill some kind of optimizations that the JavaScript implementations do, and make typed-functions too slow. picomath was too tiny to detect this sort of thing. So benchmarking with the real thing and mathjs is critical. If you feel this is too much to take on now, I understand. |
Oh, another compromise would be that the fixed function body is the quick-dispatch for whatever signatures are first added, which presumably we'd arrange to be the 'number' ones, and if none of them hits it goes to generic dispatch, which would use mutable arrays of tests and implementations. A couple of things on the other numbered issues: Your basic idea for (2) seems pretty reasonable. And on (9) I agree I could make a picomath where the pseudo-typed function is based on a current typed-function under the hood; but what we really need to test is mathjs on top of this, so it's much easier to just make a typed-function that has a merge() in it -- the only worry is if allowing for the merge by having a fixed function body slowed mathjs down even if it never uses the merge. |
Yes indeed, that is what I meant with my comment (9) #138 (comment) . So Putting this "standard main function body" around every |
Well, I guess leave this for now then, and when the dust settles on v3 one way or another (see the timing results in #144), I can just make a typed-function identical to where we end up except it has a fixed function body that fowards to the function that's generated now, and run the benchmarks on mathjs with that typed-function (which will behave identically to the non-forwarding one). Do you think the currently existing benchmarks on mathjs are sufficient to detect any problems that the forwarding might cause? Anyhow, the results of an appropriate benchmark for mathjs/typed-function with and without such a forwarding step from a fixed function body should make it clear whether or not there is any future for mutable typed functions. So I will put that high on my list for after typed-function v3 is released (if it will be in roughly its current form) and mathjs v11 is released. |
OK, so I tried a version of typed-function which changes the function body of every typed function to be:
(so that we could then change the_typed_fn property to allow for mutating the behavior of the typed function). Here's the before-and after benchmarks:
I am guessing that this represents an unacceptable performance hit, and so the idea of mutating typed functions is dead unless/until someone comes up with a different mechanism for allowing a typed function to mutate that performs better. If that's the case, feel free to close this issue (I'll reopen if I have any brainstorms about how to do this faster). |
Wow I hadn't expected such a big difference🤔. So at least it's not a good idea from a performance point of view to introduce such a wrapper function in mathjs around typed-function. So this idea for a quick workaround will not work out unfortunately. I suppose when working out the |
Just reproduced the above experiment with basically the same results, just slightly less bad looking, but still I don't think acceptable. Am pasting the results below for completeness' sake. But then I have a slightly different method I will try to see if it's any better.
|
OK, here's my second attempt:
|
OK, this is no better than the first attempt (the idea in the second attempt was rather than immediately forwarding to a function that can be changed, make all of those constants and functions like ok0 and test01 and fn0 be properties of the typed function, so that they can be changed, but leave the skeleton of the basic implementation of the typed function the same. I guess as soon as you call a potentially changeable function object, you're just in for slowness. So I'm back in the state of presuming that mutating typed functions are a dead letter unless someone comes up with a better implementation idea. |
Fine with me if you want to close this issue... |
Thanks for the latest benchmarks. Too bad that it isn't "just" working. I think it may work to try work out a fresh approach starting from picomath instead of trying to glue typed-function on it as-is. It will probably be a lot of trial and error though. Yes let's close this issue, I think we've explored this idea far enough, thanks! |
I wanted to separate a thread that came up a bit in the conversation in #126: the idea of having mutating operations on typed functions to perform all of the operations needed in mathjs: adding signatures, possibly replacing the implementation for signatures, re-constructing the full runtime when new types/conversions are added. I would really like to make a strong pitch for this being a good opportunity for adding such operations (I am not proposing that we disable any existing operations). To that end, let me gather the benefits of such an approach. Some of these points are covered in the prior conversation, but some are new -- I thought it might be helpful to have them all in one place.
It would then be possible to change to a convention in mathjs that each exported typed function in mathjs is created once, and remains constant for the lifetime of the mathjs instance. Then there is never any problem with stale references, because every reference ever created is the true reference to the object.
Therefore, there is no longer a need to worry about when dependencies for a function are added compared to when the function itself is added. As long as all of the functions are initialized before they are used, all will be well.
This in turn facilitates an organizational style in which the code file for a function, say, sum, defines a createSum, which when called on a mathjs instance, adds sum to that instance. (It could import its prerequisites and call their create functions on the instance, or it could just use a utility that creates empty versions of its prerequisites on the instance if they are not there (e.g. 'ensure(math, 'add')' and then rely on the overall import sequence to ensure that 'add' really is implemented -- I am not yet certain which will produce a better overall code layout. But with constant mutable typed functions, either is workable.)
It seems to me that this gets us to where module loading is the tree-shaking/dependency tracking mechanism -- only the modules you import, directly or indirectly, would be included in a bundle. (As per [Design question] I don't like
factory
. Why don't we just use modules? mathjs#1975)And then, there's no need at all to add all of the signatures for a function at once; they can be added in any order, and it wouldn't even matter if the signatures for 'complex' happened to be added before 'number'. This allows a shift to more type-centric code organization: your create function can just add relevant signatures to to the relevant functions, without jeopardizing any previous use.
There should then be no difference between extending a previously existing mathjs instance and building a new one from scratch. You just call all the desired create functions with that instance as an argument, in whatever order. You won't need to figure out whether you can just call math.import or if you need to write factories. (In short, factories would be gone.)
Further, since typed-functions are constant but mutable, they can all be made inherently lazy, so libraries using typed-function won't need to implement any laziness. Basically, creating a typed function initially just sets the implementation to one that generates the real implementation from the signatures and replaces itself with that real implementation. So creation is super fast, and the cost of ordering the signatures and checking for conversions etc. is only paid on the first call. Moreover, between creation time and the first call, any number of additional signatures can be added, and the ordering/etc will still only happen once, at first call.
And now typed-functions can much more easily adapt to adding conversions/types/signatures after their first call. When one of these things happens that would potentially invalidate the generated implementation, their implementation is just set back to the "generate the real implementation" one (like setting a "dirty" flag, except there is no flag -- it's just whatever the current implementation is). For conversions (as opposed to just adding a signature), this would involve a typed instance keeping a list of all of the typed functions it owns, so that when a conversion is added, it could rapidly go through and "reset" all of the typed functions that use the destination of that conversion, and then they would lazily re-generate themselves as they are called.
I really don't see how to do the bulk of these things with immutable typed-functions, especially not the lazy adaptation to new conversions. But even just accumulating functions on a mathjs instance: if math is a mathjs instance, and then math.add for example is a constant mutable typed-function object, then when the createSum function runs on that math object, you don't have to worry if the resulting math.sum function placed on that object somehow includes "hard" references to the math.add typed-function because they will never be invalidated. (In fact, in this scheme for efficiency it's perhaps best if hard references are what's compiled in, rather than lookups through the math object, but since single property accesses are presumably quite quick we probably don't really have to worry much about which it is that's getting compiled in.)
You recently write in #126 that you have a "strong preference" to "not append to existing typed-functions. Instead, I prefer to create new instances and take a pure, functional, immutable approach." But typed-functions are in the end just arrays of regular functions. So the questions of "purity" etc. should be about whether the functions in that array are pure or not, etc. Appending to that array is orthogonal to that question: it's a matter of bookkeeping as to how you keep track of the overall behavior of that operation. That is, mutability/immutability of typed-functions is to me entirely about whether references to them can go stale/be invalidated or not. From that point of view, it seems clear that there will be fewer headaches/less overall code complexity if it's impossible for a reference to a specific typed-function to ever go stale.
I will also say referring to the discussion in #126 that the question of constant, mutable typed-functions vs non-constant, immutable typed-functions is somewhat orthogonal to the question of "Option B" vs. "Option C". Either would work with B, but yes, C relies on constancy. Since we seem to be heading to B, I don't think that has any implications about the proposal here.
Again, I am not proposing that any operations be taken away from typed-function, just that mutating operations be added, so that it would be possible to build a prototype of a factory-free, module-import-based, accumulating-behaviors version of mathjs. If that experiment doesn't work, presumably the existence of the mutating functions won't do any harm to the existing implementation. Since it might involve a modest amount of breaking behavior (not certain til I go through generating a PR for it), this point at which you are contemplating a significant revamp anyway of how a typed-function implementation arranges to call other implementations seems like a very good opportunity to give it a try. I would be happy to make such a PR, I would just want to have the design for #126 more or less settled so I could base it on that, and would like to know whether you'd like to see the "built-in laziness" implemented as a part of it from the beginning.
In short, I think constant, mutating typed-function has a very strong potential to greatly simplify the organization of mathjs without losing tree-shaking or configurability of different instances of mathjs. Thanks for taking this proposal seriously, and I am looking forward to your feedback.
The text was updated successfully, but these errors were encountered: