Open Avaq opened 8 years ago
Very interesting!
Here are some comments and questions:
concat
rather than contact
in the first code block?createWithPlugins
. Are 'string'
and 'number'
special values?S.negate
polymorphic, we could dispatch to a negate
method, if present. The downside of this approach is that a user can only apply a Sanctuary function to her custom data type if we have chosen to have that function dispatch.We could choose to make it an error to concatenate a strict environment and a loose one
That's clearly the nicer way! No surprises, and it takes very little effort to adjust it explicitly:
$.env.loosy().concat(myEnv.loosy())
Do you mean
concat
rather thancontact
in the first code block?
Whoops. Fixed :D
Are 'string' and 'number' special values?
Nah, they're just the names of the sanctuary packages to load, without the sanctuary-
prefix. I just wanted to show the boilerplate can be reduced, specifically how is not that important. I guess createWithPlugins([require('sanctuary-string'), require('sanctuary-number')])
would've been clearer.
I'm not sure about bilby-style polymorphism.
Me neither. But it's an idea I thought worth sharing. You bring up some good points against its employment. The nice thing is that the API for the end-user wouldn't have to change if we do decide to add bilby polymorphism at some point.
I guess
createWithPlugins([require('sanctuary-string'), require('sanctuary-number')])
would've been clearer.
I'd prefer this approach. If we're to support "plug-ins" I'd like to provide a level playing field rather than privilege sanctuary-*
packages.
I'm not sure about bilby-style polymorphism.
Me neither. But it's an idea I thought worth sharing.
Absolutely! Thank you for doing so. I'll give myself some time to mull it over.
I really love this thread! I thought I'd chime in with a couple of thoughts as well.
Sorry about the big header above. I just feel bad if I break spec and don't start with an h1.
I think there are 2 sensible approaches to think about this. "Doing one thing well" and how people will use the API.
Sanctuary is the culmination of 4 things:
sanctuary-def
)sanctuary-def
is the relevant part for API tweaking (as it appears others have twigged before me, considering the placement of this issue).
On use cases, I think there are 3 that need to be considered.
a) A user wants Sanctuary to reason with more types in their application b) A user want to add runtime type checking in their module c) A user wants to use Sanctuary functions in their module
I think we've already thought about some of the issues with (a), but I at least haven't thought as much about the (b) & (c) cases. Also, modules built with (b) & (c) will have a-c for themselves. Similarly, the ability to disable typechecking will need to be dealt with by modules consuming sanctuary-def
and sanctuary
.
My favourite API to date has been functions waiting for def
from $.create
. That means that there's no dependency on sanctuary-def
. It also means its trivial to allow the consumer to control the type checking (you can just pass in a def
that throws away the first 3 arguments and passes the implementation through transparently). It's a bit of a shame that you have to think in terms of defining functions rather than type environments; I think the latter is slightly more intuitive.
This API gives a natural decomposition of a project using sanctuary-def
into 3 parts: a module of needed types, a module of functions waiting for a def
. And then a 'batteries included' module. I think there's a problem or two with this.
The first is that there's a bit of a weird dependence on which types make its way into the module of needed types. The tail is wagging the dog. I think if there're not too many barriers to making many small type modules and combining them, that's not a big issue.
The second is that this is a relatively large amount of overhead for a small typed module. I don't think there's anything wrong with having to extract out the types (though they could just be exported on the library). Perhaps a bigger concern is that it may lead to people exporting their modules with 'types sealed in' (like in Sanctuary currently). I think we should make it as easy as possible to let people do 'the right thing'.
@Avaq's suggestions have a lot to recommend them (and I'm fond of waiting for def
), but I can think of one or two other APIs that might be worth mentioning. Specifically, they're based around the idea that the ability to extend the type environment can be given to the function itself, rather than to the module and wrapping which creates it. The suggestions where each function still needs an env / def are arguably cases of this, but here are some ideas that allow a minimum environment to already be available (I worry in the other cases that there may be fake decoupling which just puts a lot of the burden on how to fit everything together into the memory of the programmer).
sanctuary-def
could have a concatEnv
methodThis is kind of gross and OOP-y, but I don't think it would be too tough to implement. The method would return a new function. This has the disadvantage of disabling .apply
optimisation throughout the codebase in V8. I'm not sure we should reject this function because of that though. What's fast in V8 can change fairly quickly
sanctuary-def
that returns the original function and environmentThis is conceptually pretty clean, but tougher to implement. Adding a property to mark out the functions has the V8 problem and is a bit of a hack (trying to stick some type safety in without a tool like Sanctuary). Another would be to have a special argument or a final parameter for every function that got defined; when supplied it would have the necessary results. I think this is ghastly; function signatures are easy to contort in JS so they spring to mind as solutions when its rather inappropriate.
def
returns a special type that needs a trivial 'trigger pull' to be finalisedOne example would be to return functions that when provided an environment return another function waiting for an environment. If they're called without an argument then they are now ready to be used. This allows people to provide a default environment and have a low cost finalisation process. Of course, rather than overloading the function, the best thing to do would be to return a custom type with 2 methods. Say the type is called Q
, it would have :: Q ~> Env -> Q
and :: Q ~> () -> Function
.
I still think accepting def
is best. I think it's the best conceptual separation and I like the way it handles type checking. After that, I think I like the custom type, but that could be provided as a helper module for working with the def
-accepting API. The downside is I think that we would need to make an effort to support using and creating Sanctuary ecosphere modules with good APIs. I have a feeling there may be a superior option that someone's going to come up with that doesn't have this flaw, but has the other advantages of waiting for def
.
Great comment. I've read it twice but feel I need to sketch the different options in order to get a better sense of how they would affect users.
It also means its trivial to allow the consumer to control the type checking (you can just pass in a
def
that throws away the first 3 arguments and passes the implementation through transparently).
I loved this when I first read it, but I spotted a problem upon second reading: def
is responsible for currying as well as type checking. Complecting these two concerns feels wrong, but it's necessary if we wish to perform type checking as arguments are provided rather than when the last argument is provided (so that S.add('XXX')
will raise an exception rather than return a function which raises an exception when applied).
Great catch! I guess there are 2 responses. The first is a user providing a function that throws away the first 3 arguments and curries the implementation is still fairly low overhead and lets type checking be turned off. The other is that we could go Purescript-FFI and define the functions as manually curried. I'm not sure what would go in to having the type checking percolate recursively to the resulting functions though
a user [could provide] a function that throws away the first 3 arguments and curries the implementation
Currently one could provide R.curry
, but ramda/ramda#1843 will remove placeholder support so it will not not remain equivalent to def
with type checking disabled for much longer. Furthermore, we intend to make placeholders cyclical (#73) so the behaviour will diverge irrespective of the Ramda pull request.
we could go Purescript-FFI and define the functions as manually curried
Wouldn't this mean that if the user-provided function were to return the implementation untouched, f(x)(y)
would work but not f(x, y)
?
It seems that what would be ideal is a function equivalent to sanctuary-def's internal curry
function in every way but one: it would not perform type checking. We almost have such a function, as it turns out, since all the type-checking code is guarded by if (checkTypes) { … }
.
Currently checkTypes
is defined in the closure created by $.create(opts)
, where the type of opts
is { checkTypes :: Boolean, env :: Array Type }
. All functions defined via the resulting def
function share opts
. This is inflexible. Rather than take the options as early as possible, we could take them as late as possible: def
could return a function which takes opts
and returns the appropriately wrapped implementation. $.create
would no longer be necessary since we could provide $.def
:
type Options = { checkTypes :: Boolean, env :: Array Type }
$.def :: String -> StrMap (Array TypeClass) -> Array Type -> AnyFunction -> Options -> AnyFunction
Before:
const def = $.create({checkTypes: …, env: …});
// add :: Number -> Number -> Number
const add =
def('add',
{},
[$.Number, $.Number, $.Number],
(x, y) => x + y);
After:
// add_ :: Options -> Number -> Number -> Number
const add_ =
$.def('add',
{},
[$.Number, $.Number, $.Number],
(x, y) => x + y);
// add :: Number -> Number -> Number
const add = add_({checkTypes: …, env: …});
How would this affect Sanctuary? The API needn't change at all. We could continue to provide S.create
. The internals would change slightly: we'd provide the options given to us each time we call $.def
.
We could do away with S.create
and simply provide functions awaiting options in addition to their normal arguments, but writing S.add(opts, 2, 2)
wouldn't feel good. Perhaps, though, we could provide a function which takes options and provides a Sanctuary module with the options bound to each function:
const S = require('sanctuary');
// env :: Array Type
const env = …;
// opts :: { checkTypes :: Boolean, env :: Array Type }
const opts = {checkTypes: …, env: …}
S.add_(opts, 2, 2); // => 4
S.withOptions({checkTypes: true, env: env}, S => {
S.add(2, 2); // => 4
S.add('foo', 'bar'); // ! TypeError
// ...
});
S.withOptions({checkTypes: false, env: env}, S => {
S.add(2, 2); // => 4
S.add('foo', 'bar'); // => 'foobar'
// ...
});
// We could even support this by having default options (as we currently do).
S.add(2, 2); // => 4
I'm not sure this is any better than our current solution (S.create
).
I don't know whether I've proposed something useful or whether I've walked in a complete circle. What exactly is the problem we'd like to solve?
I think for me the answer is still to take def
, but expect people for now to use $.create
to create a def
function with typechecking off
I don't have much time now, but I'll try and justify that this weekend
Sorry for the delay!
My reasons for accepting def
are above and in the link in that comment. To summarise, I feel it cleanly separates the responsibilities of the 2 modules.
I agree that it's a good idea not to change Sanctuary's API. I do think it's a good idea to extend it, because I don't feel very comfortable using Sanctuary.create
, especially in a context of extending Sanctuary. Having a version of the library that's still "open" to the def
functions provides an API that I think is cleaner.
I'd very much appreciate concrete examples to discuss. I'd like to see how advanced Sanctuary usage would change (I believe we're all in favour of require('sanctuary')
continuing to provide ready-to-use versions of the various functions). It seems that rather than use S.create
one would use $.create
to define def
, then map over the "unbound" Sanctuary module, applying each unbound function to def
to produce a curried, possibly type-checked function.
Basic usage, before:
const S = require('sanctuary');
Basic usage, after:
const S = require('sanctuary');
Advanced usage, before:
const envvar = require('envvar');
const sanctuary = require('sanctuary');
const Bar = require('./types/Bar');
const Foo = require('./types/Foo');
const SANCTUARY_CHECK_TYPES = envvar.boolean('SANCTUARY_CHECK_TYPES');
const S = sanctuary.create({
checkTypes: SANCTUARY_CHECK_TYPES,
env: sanctuary.env.concat([Bar, Foo]),
});
Advanced usage, after:
const envvar = require('envvar');
const R = require('ramda');
const sanctuary = require('sanctuary');
const $ = require('sanctuary-def');
const Bar = require('./types/Bar');
const Foo = require('./types/Foo');
const SANCTUARY_CHECK_TYPES = envvar.boolean('SANCTUARY_CHECK_TYPES');
const def = $.create({
checkTypes: SANCTUARY_CHECK_TYPES,
env: sanctuary.env.concat([Bar, Foo]),
});
const S = R.map(f => f(def), sanctuary.unbound);
Diff:
const envvar = require('envvar');
+const R = require('ramda');
const sanctuary = require('sanctuary');
+const $ = require('sanctuary-def');
const Bar = require('./types/Bar');
const Foo = require('./types/Foo');
const SANCTUARY_CHECK_TYPES = envvar.boolean('SANCTUARY_CHECK_TYPES');
-const S = sanctuary.create({
+const def = $.create({
checkTypes: SANCTUARY_CHECK_TYPES,
env: sanctuary.env.concat([Bar, Foo]),
});
+
+const S = R.map(f => f(def), sanctuary.unbound);
Is this what you have in mind, @rjmk?
I'd very much appreciate concrete examples to discuss
Yeah, that's a great idea.
You're right about my imagined usage. I'd also like to like at some other use cases.
After:
1.
const envvar = require('envvar')
const objmap = require('object.map')
const sanctuary = require('sanctuary')
const $ = require('some-other-type-checker')
const def = $(sanctuary.env)
const S = objmap(f => f(def), sanctuary.unbound)
2.
const envvar = require('envvar')
const objmap = require('object.map')
const sanctuary = require('sanctuary')
const curry = require('sanctuary-compatible-curry')
const def = (_, __, f, ___) => curry(f)
const S = objmap(f => f(def), sanctuary.unbound)
3.
const envvar = require('envvar')
const objmap = require('object.map')
const $ = require('sanctuary-def')
const Foo = require('./foo')
const Bar = require('./bar')
const fs = require('./fs')
const env = [Foo, Bar]
const def = $(env)
const gs =
{ x: def => def('x', ..., ..., f.x)
, ...
}
const U = objmap(f => f(def), fs)
U.env = env
U.unbound = fs
(Apologies for mutating const
s throughout)
How possible are these with the old way?
I think (1) would be achieved with using S.create
for an unchecked Sanctuary and then recreate the wrapping functions and give them to the type checker [It should be noted that in the 'after' version, the alternative type checker needs to have the same signature as sanctuary-def
, so I don't want to make it sound like that it completely uncouples the modules].
(2) is just the same as turning off checking. It just means that another module could implement currying that behaves the same as Sanctuary's
(3) shows using the type checking for another module. This is compared to exposing a create
function that acts similarly to Sanctuary's. Looking at (3), though I prefer it to asking others to recreate create
, I can't say it seems a huge improvement. It seems it would be better to have a helper function (:: String -> StrMap Constraint -> Array Type -> (String -> StrMap Constraint -> Array Type -> a) -> a
aka Cont (String, StrMap Constraint, Array Type)
). It's easy to make that helper with this API, though. Not so easy with Create
.
There might be some other cases worth looking at, but I thought I would help the ball continue rolling
Could you explain the helper function in (3), Rafe?
Sure. I missed an argument in the type signature. The idea is that def :: String -> StrMap Constraint -> Array Type -> Function -> Function
. We could define a function that allows a def
to be provided, but holds on to all the other necessary arguments for us. We might call that def
and the old def
, checkWrap
.
const def = (name, constraints, expTypes, impl) => checkWrap =>
checkWrap(name, constraints, expTypes, impl)
It's not much of a helper, but it's a nice way of reifying a proposed API
Thanks for the clarification, Rafe.
I'm going to wait until Sanctuary v0.12.0 is out the door before investigating your proposal further. Feel free to take the lead on this if you feel so inclined. :)
Some ideas I've been meaning to put on
paperscreen.Definition of environment
Creation of functions
Functions are defined through the
def
function. It takes an environment, function name, constraints, type definition and finally, a function (body). It will return a function that is curried, and uses the environment for its type-checking.In most cases, we could partially apply
$.def
with our environment.This approach allows a user to build an environment first, allowing all
sanctuary-def
-based libraries to type-check the entire range of types a user uses:Sanctuary could have a utility that does this for you:
Adding polymorhpism
Since
sanctuary-env
andfantasy-environment
/bilby
have very similar solutions to different problems, I thought it might be nice to combine them.This would mean
sanctuary-env
created functions would not only be curried and type-checked, they would also be polymorphic.The downside is that it will become quite difficult to keep the API as described above. Every time a function is created that shares a name with one created earlier, a function that represents both should be created.
This means
def
would have to have access to earlier functions somehow. Fantasy Land solves it by returning a new environment that contains the function, rather than returning the actual function. It pays to move theenv
parameter to the end ofdef
if we take this approach:In this case, the returned object is the environment. It remains useful to allow users to create the environment in two steps; first types, then functions. The API for the user hasn't changed, but the way we define our functions internally has changed completely.
Users are now able to do the following:
Note One possible degradation is the
loosy
feature. How do we do the polymorphism without checking the input types? Perhaps this kind of feature is best kept for when the performance is no longer a concern.