-
Notifications
You must be signed in to change notification settings - Fork 18
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
Should ap be parallel? #28
Comments
Practical consequence is that refactoring or reusing code must be done very carefully to make sure you don't accidentally make things sequential or parallel. |
Yeah, good point. We won't be able to rely on algebra laws during refactoring. |
Just an idea: // TaskAp Is Applicative but not Monad so `ap` can be parallel
// Task is Monad so `ap` can't be parallel
import {Task, TaskAp} from 'fun-task'
// get :: a -> Task r a
// lift2 :: (Applicative f) => (a -> b -> c) -> f a -> f b -> f c
// toTaskAp :: Task r a ~> TaskAp r a
// toTask :: TaskAp r a ~> Task r a
const main = lift2(
(a) => (b) => [a,b],
get('/a').toTaskAp(),
get('/b').toTaskAp()
).toTask().chain(
([a,b]) => .....
) something like Concurrently |
I was thinking about having two static-land dictionaries. So with static-land it could look like this: import {Task, TaskAp} from 'fun-task'
function lift2(Type, fn, a, b) {
return Type.ap(Type.map(x => y => fn(x, y), a), b)
}
const main = Task.chain(
([a, b]) => ...,
lift2(TaskAp, (a, b) => [a, b], get('/a'), get('/b'))
) |
So a Task could be used as Task and as TaskAp at the same time without conversion? that looks unfamiliar |
This is just how static-land types work. They don't require wrapping or conversion. We could do the same with arrays for example: const List = {
map(f, a) {
return a.map(f)
},
ap(f, a) {
return f.map(f => a.map(f)).reduce((r, i) => r.concat(i), [])
},
}
const ZipList = {
map(f, a) {
return a.map(f)
},
ap(f, a) {
return f.map((f, index) => f(a[index]))
},
}
function lift2(Type, fn, a, b) {
return Type.ap(Type.map(x => y => fn(x, y), a), b)
}
lift2(List, (x, y) => x * y, [1, 2], [3, 4]) // [3, 4, 6, 8]
lift2(ZipList, (x, y) => x * y, [1, 2], [3, 4]) // [3, 8] |
👍 I have taken look at static-land and now it makes sense! |
^ this, is what Parallel spec will look like but |
@rpominov IMO without a formal definition of "equivalent" is hard to settle this kind of problems. Given the current informal definition: Terminology
both parallel Note that by the same informal definition the following function is not ("equivalent"-)pure const f = (url: string): Promise<string> => axios.get(url) because when called twice with the same argument, it may return promises that yield different values. The following type IO<A> = () => A;
function random(): IO<number> {
return () => Math.random()
} because when called twice, it returns functions that yield different values with the same input ( |
Basically it would mean to define a suitable equivalence relation. In static-land there is For example, the
the
the
The last equivalence relation on EDIT: in the |
Yea, it really depends on how we define equivalence. And I think the definition should be based on referential transparency. E.g. if we replace value A with value B in a program and it still be the same program then A and B are equivalent. When we trying to decide if two programs are the same, we have to take into account not only our pure code that uses our pure abstraction, but also all side effects that happen when we "run" our "program" (data structure) that we have build using pure abstraction. Getting back to Task, when we create our tasks and compose them using all available methods like So we construct our complex pure structure with tasks as building blocks. This structure is just data, although it contains some functions that will perform side effects, but until we run them we can consider them as data. This data structure may look for example like this: {
type: 'race',
elements: [
{type: 'of', value: 1},
{type: 'map', function: x => x + 1, parent: {type: 'of', value: 2}},
{type: 'create', impureComputation: f},
]
} Then we run this data structure using Now the important idea is that when we consider if two tasks are equivalent, we must take into account not only values that they produce but also side effects that they perform, including order in which side effects happen. So in my opinion, parallel / sequential execution matters when it comes to equivalence of tasks. Otherwise we get a leaky abstraction.
Promises are really a bad example because they are impure to start with. I guess this example included there only because of familiarity with promises. So people could get the idea quicker.
It really hard to implement Setoid for any reasonable definition of equivalence of tasks I think. |
After thinking a bit more: Promises represent only result of a computation while tasks represent entire computation. So with promises it's correct to define equivalence based only on the value that they produce since they represent only the value. But not so with tasks. |
Actually you get the same program, based on the types. A type If you want to make them different, you have to encode the difference in the types, otherwise the different behaviour simply does not exist from the point of view of the type system and it's only an implementation detail. |
Yeah, from types perspective sure. But from that standpoint |
IMO it always depends on the underlying equivalence relation. The informal "same" or "equal" should always be a formal "equivalent". For functions, as
then const numberSetoid: Setoid<number> = {
equals: (x, y) => true
} (This is weird but no more weird than seeing But another (more sensible) choice is const numberSetoid: Setoid<number> = {
equals: (x, y) => x === y
} then For
so effects don't matter and a function like We could try to model our system differently and choose another equivalence relation for
|
This sounds good.
Although I'm not sure I understand this correctly. If A and B here are types, and say we substitute them with Although if A and B are values, than it makes sense (e.g. Anyway, as I was saying in #28 (comment) , considering only produced values is not enough.
I guess we could say something like: Update: At least it's not practical in general, although I can agree that there can be cases when side effects don't mater so much. For instance we have a task that fetches data from server, and we don't really care if it makes two request or one, as long as it fetches the same data. |
Yes, they are types. I come up with that definition, which at first seems weird, in order to solve the purity problem. Let's say we have the following function
If we state that type IO<A> = () => A;
const g = (): IO<number> => () => Math.random()
Exactly, if we don't encode the different behavior in the types or in a suitable equivalence relation, when we write My point is that systems can be modeled in different ways, depending on the properties we care about, using types and formal definitions (like the equivalence relations). If we are not able to encode or specify formally a property we care about, well... I think we have a problem: either we can find a formal definition or maybe we should abandon that idea. Writing an informal definition is brittle and can lead to theoretical errors. |
@gcanti |
This discussion is has some weird theoretical ideas. There is a very simple and practical reason why No-cost code reuse and refactoring are the reasons for the Fantasy Land specification. I want to never think about code reuse or refactoring. If code reuse or refactoring changes parallelism of my program, it now requires my thought (because parallelism is not free) and I have lost all benefits of the specification. |
@gcanti I think I starting to understand what you mean. The problem is that we have a single type |
@puffnfresh If
I don't understand the underlying model here, how can a property like purity change? In my mind purity is a static property. In set theory, my reference model, (pure) functions are just subsets of the cartesian product (*) such that every element of |
@gcanti we have encoded IO using functions but we have to pretend there is no domain. |
@puffnfresh in my mental model the domain is
In order to be pure |
@puffnfresh Ah yes sorry, I wrote "initial" instead of "terminal", I don't mean the empty type (for example in Flow would be the type |
@gcanti But, if it were the model: a = g(undefined)
b = g(undefined) Should mean |
@rpominov are you planning on releasing the applicative (or alternative) version of task? |
I don't plan to do anything soon. I still don't have a strong opinion on how this should be solved from the library API perspective. Also don't have much time to work on this currently. But I may get back to this in the future. |
Meanwhile, you could use |
Excellent discussion! My 2 cents:
When you use the Only when you call In this light, the value resulting from calling So However, equivalence can be defined for the execution graphs encoded by a If this seems strange because a parallel
In summary, I think you can define an equivalence relation for Final note - all of the above is very hand wavy. I hope it's helpful, but I haven't proven anything, and I mostly wrote this relying on my fallible human memory. I'm also not an expert mathematician, logician, category or type theorist, etc - just a relatively inexperienced enthusiast. Please challenge everything I've said, and feel free to point out any mistakes in my informal lines of reasoning. |
I like the idea of thinking about Tasks in terms of their "execution graph" to define equivalence. The execution graph for a task using parallel
The way I see it, this law defines a relation between a "child" algebra and its "parent". Eg, Monad is a "child" of Applicative (ref), in that all values with Monad instances must also have Applicative instances. But through the law of derivation, Applicative is also a "child" of Monad, in that its behaviour is defined in terms of Monad. I've recently been using an example to defend sequential discard :: a -> b -> b
createDatabase :: String -> Task Error a
insertRecord :: String -> Object -> Task Error Object createDatabase ('users') .chain (_ => insertRecord ('users') ({name: 'bob'})) The code above uses a monadic Task type to encode a sequential program. Knowing that it's monadic, and knowing the relationship between Monad and Applicative, the following program must be equivalent to the prior: createDatabase ('users') .map (discard) .ap (insertRecord ('users') ({name: 'bob'})) However, if The benefit we get out of using these strictly defined algebras, is that we can create reliable general abstractions. We should be able, for example, to create a general abstraction that uses the Applicative instance, but restricts the input to have Monad instance so that we can rely on "sequential" behaviour. |
Strictly speaking this would violate Fantasy Land and Static Land requirements.
Here is an explanation I wrote in Fantasy Land gitter room recently:
But I can't think of actual practical consequence of this violation. And on the other hand it would be nice if
traverse
of types other than array would execute tasks in parallel (we have built-in kindatraverse
for arrays —Task.parallel()
).Also other libraries like Fluture or data.task have parallel
ap
and seem to not have any trouble with it. @Avaq @robotlolita Maybe you can share some thoughts?The text was updated successfully, but these errors were encountered: