oschulz / FunctionChains.jl

Function chains in Julia
Other
4 stars 0 forks source link

Add fcoll to turn collection of functions into a function that returns collections #7

Open oschulz opened 10 months ago

oschulz commented 10 months ago

This would add something like

fcoll(fs) = x -> map(f -> f(x), fs)

So that

f = fcoll((a = f1, b = f2, c = f3))
f(x) == (a = f1(x), b = f2(x), c = f3(x))

Would be good to support fcoll(a = f1, b = f2, c = f3), fcoll(f1, f2, cf3) and fcoll(f1) as will, similar to how fchain handles tuples of functions.

oschulz commented 10 months ago

This would also enable constructions like

f1(x) = (a = x^2, b = 2*x)
f2(x) = (c = x^3, b = 3*x)
fchain(fcoll((f1, f2)), splat(merge))(x)

A specializable fcombine(op, fs) with default implementation

fcombine(op, fs) = fchain(fcoll((f1, f2)), splat(op))

could also be very useful in this context.

aplavin commented 8 months ago

Neat convergence of design approaches from different directions :)

julia> using AccessorsExtra

# like your fcoll(a=first, b=last):
julia> f = @optic₊ (a=first(_), b=last(_))

# non-macro interface also available:
julia> f === AccessorsExtra.ContainerOptic((a=first, b=last))
true

julia> x = [1, 2, 3, 4]

# can call as a function:
julia> f(x)
(a = 1, b = 4)

# and it's more than just an anonymous function:
julia> @set f(x) = (a=10, b=20)
4-element Vector{Int64}:
 10
  2
  3
 20

# opposite keys order + non-macro
julia> set(x, f, (b=10, a=20))
4-element Vector{Int64}:
 20
  2
  3
 10
oschulz commented 8 months ago

Oh, very nice @aplavin ! Hm, maybe we don't even need this in FunctionChains.

Does AccessorsExtra.ContainerOptic always represent a callable object?

aplavin commented 8 months ago

Yeah, the full implementation of ContainerOptic as a callable is effectively two lines: https://github.com/JuliaAPlavin/AccessorsExtra.jl/blob/00000000cef99a28debfb7cc31321eef769b8c87/src/concatoptic.jl#L100-L105. It could use map as you suggested instead of modify(values) if not for Dicts. I'm not really sure if supporting dicts is really useful there, only use Tuple/NamedTuple/SVector myself...

This simplicity indicates that such an object can easily live in a much lighter package, if useful in isolation. If such an object gets added somewhere (eg here), I think I can probably reuse it in AccessorsExtra and just define set()... Probably the interplay with PropertyFunction and similar objects can be resolved in a reasonably clean way.

oschulz commented 8 months ago

What does ContainerOptic do for non-callables, actually?

aplavin commented 8 months ago

Don't think it makes much sense for non-callables. I mean, you can create the ContainerOptic object just fine no matter what's inside, there's just no actual functionality in that case. What do you have in mind?

oschulz commented 8 months ago

What do you have in mind?

I was just wondering if it might make sense to make it a subtype of Function, if it has no non-callable use case.

aplavin commented 8 months ago

Accessors callables don't subtype functions: eg, https://github.com/JuliaObjects/Accessors.jl/blob/dc42b02b21bc229d5bcd91071701dbec0b084095/src/optics.jl#L359. That's why I also don't do this in AccessorsExtra.

For some discussion, see https://github.com/JuliaObjects/Accessors.jl/issues/37.

And what concrete benefit would that bring? Dispatching on Function in Julia isn't recommended anyway.

oschulz commented 8 months ago

Dispatching on Function in Julia isn't recommended anyway.

No, it would be more to express semantics. Base.ComposedFunction also subtypes Function, for example. But it's certainly not necessary.

oschulz commented 8 months ago

Thought about this a bit more, fcoll may not be an ideal name here, because it doesn't clarify if we're building a function that operates on "collections" or a function the generates "collections".

There are (at least) two kind of generalized products for functions - in respect to the input and in respect to the output:

For measures (which are functions on sets, semantically), probability densities and the like, we want something like

h = input_fprod((f, g), NamedTuple{(:a, :b)})
h(x) = f(x.a) * g(x.b)

In other cases, we may want

h = output_fprod((a = f, b = g))
h(x) = (a = f(x), b = g(x))

and in yet other cases

h = output_fprod(f, g, merge)
h(x) = merge(f(x), g(x))

I wonder if it's possible to find a "super-generalized" product that can cover all of these? This whole thing looks like it's going beyond the scope of FunctionChains though, it may deserve it's own package.

oschulz commented 8 months ago

I wonder if it's possible to find a "super-generalized" product that can cover all of these?

This might work as a "super-generalized function product":

h = gfp(fs, fc_input, fc_output)

so that for h(x_h)

h(x_h) == fc_output(map(f -> f(x), fs, x_fs))

with

x_h == fc_input(x_fs)

Constructing x_fs involves "inverting" the combinator fc_input, so only ∘ unique would turn into a fill operation, splat(merge) into a splitting operation and so on. This would need to be specialized for most input combinators. It may seem backwards, but it's the only approach that provides what measures and the like need (it's what we're currently building for MeasureBase). This "input decomposition" would look like

x_fs = decompose_input(fs, fc_input, x_h)

With that, one may be able to do it all:

f(x.a) * f(x.b) == gfp((f, g), NamedTuple{(:a, :b)}, prod)((; a, b))

f((a = x.a, b = x.b)) * f((c = x.c, d = x.d)) == gfp((f, g), splat(merge), prod)((; a, b, c, d))

(a = f(x), b = g(x)) == gfp((a = f, b = h), only ∘ unique, identity)(x)

merge(f(x), g(x)) == gfp((f, g), only ∘ unique, splat(merge))(x)

(a = f(x.a), b = f(x.b)) == gfp((a = f, b = g), identity}, identity)((; a, b))

Whethers it's pracical and useful is a different question, of course. Input combinators like splat(merge) and splat(vcat) would only work if the element functions in fs have a known input space, like measures and probability density functions (usually) do.